diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index e801a7c..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(wc -l /home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src/features/settings/SettingsAdminPanel.vue /home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src/features/settings/components/Admin*.vue)", - "Read(//home/jv/src/personal/playground/scholar_scraper/scholar-scraper/**)", - "Bash(while read line rest)", - "Bash(do echo \"$line $rest\")", - "Bash(done)", - "Bash(grep -c 'def test_' tests/unit/*.py tests/unit/**/*.py tests/integration/*.py)", - "Bash(sort -t: -k2 -rn)", - "Bash(wc -l frontend/src/pages/*.vue)", - "Bash(uv run ruff format app/api/routers/runs.py app/services/arxiv/rate_limit.py tests/integration/test_run_lifecycle_consistency.py)", - "Bash(ruff format app/api/routers/runs.py app/services/arxiv/rate_limit.py tests/integration/test_run_lifecycle_consistency.py)", - "Bash(npm install --package-lock-only)", - "Bash(python -m ruff format app/api/routers/runs.py app/services/arxiv/rate_limit.py tests/integration/test_run_lifecycle_consistency.py)", - "Bash(pip show ruff)", - "Bash(pip install ruff)", - "Bash(uvx ruff format app/api/routers/runs.py app/services/arxiv/rate_limit.py tests/integration/test_run_lifecycle_consistency.py)", - "Bash(git add app/api/routers/runs.py app/services/arxiv/rate_limit.py tests/integration/test_run_lifecycle_consistency.py docs/website/package-lock.json)", - "Bash(git push)", - "Bash(uvx ruff format --check app/api/routers/runs.py app/services/arxiv/rate_limit.py tests/integration/test_run_lifecycle_consistency.py)", - "Bash(test -f docs/website/package-lock.json)", - "Bash(gh run list --branch openalex-size-reduction-scholar-hydration --limit 3 --json databaseId,status,conclusion,name,event)", - "Bash(gh run list --limit 10 --json databaseId,status,conclusion,name,event,headBranch)", - "Bash(gh run view 22554585841 --log-failed)", - "Bash(gh run rerun 22554585841 --failed)", - "Bash(bash scripts/premerge.sh)" - ], - "additionalDirectories": [ - "/home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src" - ] - } -} diff --git a/.env.example b/.env.example index 9e4a0a9..ec10203 100644 --- a/.env.example +++ b/.env.example @@ -1,53 +1,26 @@ -# ------------------------------ -# Compose + Database -# ------------------------------ POSTGRES_DB=scholar POSTGRES_USER=scholar POSTGRES_PASSWORD=change-me + DATABASE_URL=postgresql+asyncpg://scholar:scholar@db:5432/scholar # Optional override. If empty, tests derive "_test". TEST_DATABASE_URL= SCHOLARR_IMAGE=justinzeus/scholarr:latest -# ------------------------------ -# App Runtime + Networking -# ------------------------------ APP_NAME=scholarr APP_HOST=0.0.0.0 APP_PORT=8000 APP_HOST_PORT=8000 APP_RELOAD=0 -MIGRATE_ON_START=1 FRONTEND_ENABLED=1 FRONTEND_DIST_DIR=/app/frontend/dist - -# ------------------------------ -# Database Pool -# ------------------------------ -DATABASE_POOL_MODE=auto -DATABASE_POOL_SIZE=5 -DATABASE_POOL_MAX_OVERFLOW=10 -DATABASE_POOL_TIMEOUT_SECONDS=30 -DATABASE_RESERVED_API_CONNECTIONS=3 - -# ------------------------------ -# Frontend Dev Overrides -# ------------------------------ FRONTEND_HOST_PORT=5173 CHOKIDAR_USEPOLLING=1 VITE_DEV_API_PROXY_TARGET=http://app:8000 +MIGRATE_ON_START=1 -# ------------------------------ -# Auth + Session -# ------------------------------ SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters SESSION_COOKIE_SECURE=1 -LOGIN_RATE_LIMIT_ATTEMPTS=5 -LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 - -# ------------------------------ -# HTTP Security Headers + CSP -# ------------------------------ SECURITY_HEADERS_ENABLED=1 SECURITY_X_CONTENT_TYPE_OPTIONS=nosniff SECURITY_X_FRAME_OPTIONS=DENY @@ -63,32 +36,22 @@ SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=0 SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE=31536000 SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS=1 SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD=0 - -# ------------------------------ -# Logging -# ------------------------------ +LOGIN_RATE_LIMIT_ATTEMPTS=5 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 LOG_LEVEL=INFO LOG_FORMAT=console LOG_REQUESTS=1 LOG_UVICORN_ACCESS=0 LOG_REQUEST_SKIP_PATHS=/healthz LOG_REDACT_FIELDS= - -# ------------------------------ -# Scheduler + Ingestion Safety -# ------------------------------ SCHEDULER_ENABLED=1 SCHEDULER_TICK_SECONDS=60 -SCHEDULER_QUEUE_BATCH_SIZE=10 -SCHEDULER_PDF_QUEUE_BATCH_SIZE=15 INGESTION_AUTOMATION_ALLOWED=1 INGESTION_MANUAL_RUN_ALLOWED=1 INGESTION_MIN_RUN_INTERVAL_MINUTES=15 INGESTION_MIN_REQUEST_DELAY_SECONDS=2 INGESTION_NETWORK_ERROR_RETRIES=1 INGESTION_RETRY_BACKOFF_SECONDS=1.0 -INGESTION_RATE_LIMIT_RETRIES=3 -INGESTION_RATE_LIMIT_BACKOFF_SECONDS=30.0 INGESTION_MAX_PAGES_PER_SCHOLAR=30 INGESTION_PAGE_SIZE=100 INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD=1 @@ -100,10 +63,7 @@ INGESTION_CONTINUATION_QUEUE_ENABLED=1 INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_ATTEMPTS=6 - -# ------------------------------ -# Scholar Images + Name Search Safety -# ------------------------------ +SCHEDULER_QUEUE_BATCH_SIZE=10 SCHOLAR_IMAGE_UPLOAD_DIR=/var/lib/scholarr/uploads SCHOLAR_IMAGE_UPLOAD_MAX_BYTES=2000000 SCHOLAR_NAME_SEARCH_ENABLED=1 @@ -116,49 +76,11 @@ SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1 SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800 SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD=2 SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD=3 -SCHOLAR_HTTP_USER_AGENT= -SCHOLAR_HTTP_ROTATE_USER_AGENT=0 -SCHOLAR_HTTP_ACCEPT_LANGUAGE=en-US,en;q=0.9 -SCHOLAR_HTTP_COOKIE= -# ------------------------------ -# OA Enrichment + PDF Resolution -# ------------------------------ -UNPAYWALL_ENABLED=1 -UNPAYWALL_EMAIL= -UNPAYWALL_TIMEOUT_SECONDS=4.0 -UNPAYWALL_MIN_INTERVAL_SECONDS=0.6 -UNPAYWALL_MAX_ITEMS_PER_REQUEST=20 -UNPAYWALL_RETRY_COOLDOWN_SECONDS=1800 -UNPAYWALL_PDF_DISCOVERY_ENABLED=1 -UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES=5 -UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES=500000 -ARXIV_ENABLED=1 -ARXIV_TIMEOUT_SECONDS=3.0 -ARXIV_MIN_INTERVAL_SECONDS=4.0 -ARXIV_RATE_LIMIT_COOLDOWN_SECONDS=60.0 -ARXIV_DEFAULT_MAX_RESULTS=3 -ARXIV_CACHE_TTL_SECONDS=900 -ARXIV_CACHE_MAX_ENTRIES=512 -ARXIV_MAILTO= -PDF_AUTO_RETRY_INTERVAL_SECONDS=86400 -PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS=3600 -PDF_AUTO_RETRY_MAX_ATTEMPTS=3 -CROSSREF_ENABLED=1 -CROSSREF_MAX_ROWS=10 -CROSSREF_TIMEOUT_SECONDS=8.0 -CROSSREF_MIN_INTERVAL_SECONDS=0.6 -CROSSREF_MAX_LOOKUPS_PER_REQUEST=8 -OPENALEX_API_KEY= -CROSSREF_API_TOKEN= -CROSSREF_API_MAILTO= - -# ------------------------------ -# Startup Bootstrap + DB Wait -# ------------------------------ BOOTSTRAP_ADMIN_ON_START=0 BOOTSTRAP_ADMIN_EMAIL= BOOTSTRAP_ADMIN_PASSWORD= BOOTSTRAP_ADMIN_FORCE_PASSWORD=0 + DB_WAIT_TIMEOUT_SECONDS=60 DB_WAIT_INTERVAL_SECONDS=2 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index f5f3785..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -buy_me_a_coffee: justinzeus diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 3dbdcd9..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: 2 -updates: - - package-ecosystem: pip - directory: / - schedule: - interval: weekly - - package-ecosystem: npm - directory: /frontend - schedule: - interval: weekly - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly diff --git a/.github/logo-dark.png b/.github/logo-dark.png deleted file mode 100644 index a3e5424..0000000 Binary files a/.github/logo-dark.png and /dev/null differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c86ea..4ff6965 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,31 +17,10 @@ jobs: - name: Enforce no generated artifacts run: ./scripts/check_no_generated_artifacts.sh - - name: Enforce env contract parity - run: python3 scripts/check_env_contract.py - - lint: - runs-on: ubuntu-latest - needs: [repo-hygiene] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - - run: uv sync --extra dev - - name: Ruff check - run: uv run ruff check . - - name: Ruff format check - run: uv run ruff format --check . - - name: Mypy - run: uv run mypy app/ --ignore-missing-imports - test: runs-on: ubuntu-latest needs: - repo-hygiene - - lint services: postgres: image: postgres:15 @@ -109,7 +88,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: npm ci + run: npm install - name: Frontend theme token policy working-directory: frontend @@ -127,33 +106,12 @@ jobs: working-directory: frontend run: npm run build - docs-quality: - runs-on: ubuntu-latest - needs: - - repo-hygiene - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install docs dependencies - run: npm ci --prefix docs/website - - - name: Docs build - run: npm --prefix docs/website run build - docker-publish: runs-on: ubuntu-latest needs: - repo-hygiene - test - frontend-quality - - docs-quality if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 64a22d1..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CodeQL - -on: - push: - branches: [main] - pull_request: - branches: [main] - schedule: - - cron: "30 5 * * 1" - -jobs: - analyze: - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - strategy: - matrix: - language: [python, javascript] - steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@v3 - - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml deleted file mode 100644 index 24ebe7a..0000000 --- a/.github/workflows/docs-pages.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Docs Pages - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - docs/** - - .github/workflows/docs-pages.yml - -permissions: - contents: read - -concurrency: - group: docs-pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install docs dependencies - run: npm ci --prefix docs/website - - - name: Build docs site - run: npm --prefix docs/website run build - - - name: Upload pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docs/website/build - - deploy: - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f43fb27..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Release - -on: - push: - branches: [main] - -jobs: - release: - runs-on: ubuntu-latest - if: github.repository == 'JustinZeus/scholarr' - permissions: - contents: write - id-token: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - - run: uv sync --extra dev - - name: Lint - run: uv run ruff check - - name: Test - run: uv run pytest - - name: Semantic Release - id: release - run: uv run semantic-release publish - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7c06050..f539821 100644 --- a/.gitignore +++ b/.gitignore @@ -12,11 +12,7 @@ htmlcov/ *.log .DS_Store planning/ -build/ frontend/node_modules/ frontend/dist/ frontend/.vite/ -docs/website/node_modules/ -docs/website/build/ -docs/website/.docusaurus/ -.claude/ +AGENTS.MD diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..3c72362 --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,28 @@ +# AI Agent Instructions: Scholarr + +Adhere strictly to these constraints. + +## 1. Coding Standards (Strict Enforcement) +* **Function Length:** Maximum 50 lines of code per function. Break down complex logic into small, testable, single-responsibility functions. +* **DRY (Don't Repeat Yourself):** Abstract repetitive logic immediately. No duplicate boilerplate for database queries, API responses, or error handling. +* **Negative Space Programming:** Utilize explicit assertions and constraints to define invalid states. Fail fast and early. Do not allow silent failures or cascading malformed data, especially in DOM parsing. +* **Cyclomatic Complexity:** Flatten logic. Use early returns and guard clauses instead of deep nesting. + +## 2. Domain Architecture & Data Model +* **Data Isolation:** Scholar tracking is **user-scoped**. Validate mapping/join tables; never assume global links between users and Scholar IDs. +* **Data Deduplication:** Publications are **global records**. Deduplicate via Scholar cluster ID and normalized fingerprinting prior to database insertion. +* **State Management:** Visibility and "read/unread" states exist exclusively on the scholar-publication link table, not the global publication table. +* **API Contract:** Exact envelope format required: + * Success: `{"data": ..., "meta": {"request_id": "..."}}` + * Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` + +## 3. Scrape Safety & Rate Limiting (Immutable) +These limits prevent IP bans and are not to be optimized away. +* **Minimum Delay:** Enforce `INGESTION_MIN_REQUEST_DELAY_SECONDS` (default 2s) between all external requests. +* **Anti-Detection:** Default to direct ID or profile URL ingestion. Name searches trigger CAPTCHAs. +* **Cooldowns:** Respect `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` (1800s) and `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` (900s) upon threshold breaches. + +## 4. Current Environment & Stack +* **Backend:** Python 3.12+, FastAPI, SQLAlchemy (Async/asyncpg), Alembic. +* **Frontend:** TypeScript, React, Vite. +* **Infrastructure:** Multi-stage Docker. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab44797 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +## Scope +This project favors small, reviewable pull requests that keep runtime behavior clear and operationally safe. + +## Essential-File Policy +Commit only source-of-truth files required to build, run, test, or document the app. + +Do not commit generated or local-only artifacts, including: +- `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/` +- frontend build/install outputs like `frontend/dist/`, `frontend/node_modules/`, `frontend/.vite/` +- coverage outputs (`.coverage`, `htmlcov/`) +- packaging/build leftovers (`*.egg-info/`, `build/`, `dist/`) +- local probe/scratch material (`planning/`) + +CI enforces this with `scripts/check_no_generated_artifacts.sh`. + +## Merge Checklist +- [ ] Changes are minimal, purposeful, and remove obsolete/dead code in touched areas. +- [ ] Backend tests pass (`uv run pytest tests/unit` and integration scope as needed). +- [ ] Frontend checks pass (`npm run typecheck`, `npm run test:run`, `npm run build`). +- [ ] API/behavior docs are updated when env vars, endpoints, or payloads change. +- [ ] `README.md`, `.env.example`, and deployment notes stay aligned. +- [ ] `scripts/check_no_generated_artifacts.sh` passes locally. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 6d0fc52..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 JustinZeus - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index e2bf4f6..553bf5c 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,278 @@ +# scholarr +
- - - - Scholarr - +Self-hosted scholar tracking with a single app image (API + frontend). -# Scholarr - -**Self-hosted academic publication tracker.** - -Track Google Scholar profiles, discover new papers automatically, -resolve open-access PDFs, and stay on top of the literature you care about. - -[![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/JustinZeus/scholarr/actions/workflows/ci.yml) -[![CodeQL](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/codeql.yml?style=for-the-badge&label=CodeQL)](https://github.com/JustinZeus/scholarr/actions/workflows/codeql.yml) -[![Release](https://img.shields.io/github/v/release/justinzeus/scholarr?style=for-the-badge)](https://github.com/JustinZeus/scholarr/releases) +[![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/justinzeus/scholarr/actions/workflows/ci.yml) [![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/scholarr) -[![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/) -[![License](https://img.shields.io/github/license/justinzeus/scholarr?style=for-the-badge)](LICENSE) -[![Docs](https://img.shields.io/badge/docs-scholarr-2e8555?style=for-the-badge)](https://justinzeus.github.io/scholarr/) +[![Docker Image](https://img.shields.io/badge/docker-justinzeus%2Fscholarr-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/justinzeus/scholarr)
---- - - - -

- Publications dashboard -

- - - -## Why Scholarr? - -Most researchers track new papers by manually checking Google Scholar, setting up email alerts, or juggling RSS feeds. Scholarr replaces all of that with a single self-hosted service: - -- **Add scholars once** -- by profile URL, Scholar ID, or name search -- **Publications appear automatically** -- a background scheduler scrapes profiles on a configurable interval -- **Open-access PDFs are resolved for you** -- Unpaywall and arXiv are queried automatically when a DOI is found -- **Everything is deduplicated** -- publications are global records; no duplicates across scholars -- **Your data stays yours** -- fully self-hosted, export/import your entire library at any time - -## Features - -| | | -|---|---| -| **Automated Ingestion** | Background scheduler with configurable intervals, continuation queue, and multi-page pagination | -| **Identifier Resolution** | Cross-references arXiv, Crossref, and OpenAlex to gather DOIs, arXiv IDs, PMIDs | -| **PDF Discovery** | Resolves open-access PDFs via Unpaywall API and arXiv, with automatic retry queue | -| **Scrape Safety** | Rate limiting, cooldowns, and backoff strategies that prevent IP bans -- these are safety floors, not optional | -| **Multi-User** | Session-based auth, admin user management, user-scoped scholar tracking | -| **Theming** | 7 color presets with light/dark mode, tokenized component system | -| **Import / Export** | Portable scholar data with full publication and read-state preservation | -| **Single Container** | FastAPI backend + Vue 3 frontend ship as one Docker image | - ## Quick Start +1. Copy env template: + ```bash -# 1. Clone and configure -git clone https://github.com/JustinZeus/scholarr.git -cd scholarr cp .env.example .env +``` -# 2. Set required secrets in .env -# POSTGRES_PASSWORD= -# SESSION_SECRET_KEY= +2. Set required values in `.env`: +- `POSTGRES_PASSWORD` +- `SESSION_SECRET_KEY` -# 3. Start +3. Choose a deploy method below. + +## Deploy Method A: Prebuilt Image (Recommended) + +Use this for normal self-hosted deployment. + +```bash +docker compose pull docker compose up -d - -# 4. Open http://localhost:8000 ``` -To bootstrap an admin account on first run, add to `.env`: +Open: +- App/API: `http://localhost:8000` +- Health: `http://localhost:8000/healthz` + +Upgrade: ```bash -BOOTSTRAP_ADMIN_ON_START=1 -BOOTSTRAP_ADMIN_EMAIL=admin@example.com -BOOTSTRAP_ADMIN_PASSWORD= +docker compose pull +docker compose up -d ``` -## How It Works +## Deploy Method B: Local Source Build + Dev Frontend -```mermaid -graph LR - UI[Vue 3 Dashboard] <-->|REST + SSE| API[FastAPI] - API --> Scheduler[Scheduler] - Scheduler -->|Scrape HTML| Scholar[Google Scholar] - Scholar -->|Parse & Deduplicate| DB[(PostgreSQL)] - Scholar -.->|Identify| Ext[arXiv / Crossref / OpenAlex] - Ext --> DB - DB -->|DOIs| PDF[PDF Resolution] - PDF -->|Unpaywall / arXiv| DB - API <--> DB -``` - -## Tech Stack - -| Layer | Technology | -|-------|------------| -| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic | -| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS | -| Database | PostgreSQL 15 | -| Infrastructure | Multi-stage Docker, Docker Compose | - -## Documentation - -Full documentation: **[justinzeus.github.io/scholarr](https://justinzeus.github.io/scholarr/)** - -| Section | Covers | -|---------|--------| -| [User Guide](docs/user/overview.md) | Installation, configuration, all environment variables | -| [Developer Guide](docs/developer/overview.md) | Architecture, local dev, contributing, testing | -| [Operations](docs/operations/overview.md) | Deployment, database runbook, scrape safety | -| [API Reference](docs/reference/api.md) | Envelope spec, all endpoints, DTO contracts | - -## Contributing - -Scholarr uses [conventional commits](https://www.conventionalcommits.org/) and [semantic versioning](https://semver.org/). See the [contributing guide](docs/developer/contributing.md) for PR process and code standards. +Use this for development on this repository. ```bash -# Dev environment -docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build - -# Run tests (always in containers) -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ``` -## License +Open: +- API (FastAPI): `http://localhost:8000` +- Frontend dev server (Vite): `http://localhost:5173` -See [LICENSE](LICENSE) for details. +Stop: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml down +``` + +## Essential Files + +- `docker-compose.yml`: deployment compose (prebuilt image). +- `docker-compose.dev.yml`: dev override (source mounts + Vite dev server). +- `.env.example`: full env variable template. +- `Dockerfile`: multi-stage build (frontend + backend runtime). +- `README.md`: deployment and operations reference. +- `CONTRIBUTING.md`: contribution policy and merge checklist. +- `docs/ops/scrape_safety_runbook.md`: scrape cooldown and blocked-IP operations guide. +- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI. + +## Data Model Notes + +- Scholar tracking is user-scoped: each account can track the same Scholar ID independently. +- Publications are shared/global records deduplicated by Scholar cluster ID and normalized fingerprint. +- Per-account visibility and read state is stored on scholar-publication links, not on the global publication row. + +## Name Search Status + +- Scholar name search is intentionally WIP in the UI. +- Current Google Scholar behavior often redirects name-search traffic to login/challenge flows, so production onboarding should use direct Scholar ID/profile URL adds. + +## Environment Variables (Complete Reference) + +Notes: +- Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`. +- Values shown are deployment defaults from `.env.example` and `docker-compose.yml`. +- Some internal app fallbacks may differ for local test/dev safety when env vars are omitted. +- `deploy` means used in regular deployment. +- `dev` means used in local dev workflow. + +### Core Compose and Database + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `POSTGRES_DB` | `scholar` | any valid DB name | deploy, dev | PostgreSQL database name. | +| `POSTGRES_USER` | `scholar` | any valid DB user | deploy, dev | PostgreSQL user. | +| `POSTGRES_PASSWORD` | `change-me` | strong password | deploy, dev | PostgreSQL password. Required. | +| `DATABASE_URL` | `postgresql+asyncpg://scholar:scholar@db:5432/scholar` | valid SQLAlchemy asyncpg URL | deploy, dev, test | App database connection URL. | +| `TEST_DATABASE_URL` | empty | valid SQLAlchemy asyncpg URL | test | Optional explicit integration test DB URL. | +| `SCHOLARR_IMAGE` | `justinzeus/scholarr:latest` | any image ref | deploy | App image tag used by deployment compose. | + +### App Runtime and Networking + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `APP_NAME` | `scholarr` | any string | deploy, dev | FastAPI app name/title. | +| `APP_HOST` | `0.0.0.0` | bind host | deploy, dev | Uvicorn bind host inside container. | +| `APP_PORT` | `8000` | integer | deploy, dev | Uvicorn bind port inside container. | +| `APP_HOST_PORT` | `8000` | integer | deploy, dev | Host port mapped to app container port 8000. | +| `APP_RELOAD` | `0` | boolean | deploy, dev | Enable uvicorn reload mode. Recommended `0` in deploy, `1` in dev. | +| `MIGRATE_ON_START` | `1` | boolean | deploy, dev | Run Alembic migrations during app startup. | +| `FRONTEND_ENABLED` | `1` | boolean | deploy, dev | Serve bundled frontend assets from FastAPI. | +| `FRONTEND_DIST_DIR` | `/app/frontend/dist` | absolute path | deploy, dev | Path to built frontend assets inside app container. | + +### Dev Frontend Overrides + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `FRONTEND_HOST_PORT` | `5173` | integer | dev | Host port mapped to Vite dev server. | +| `CHOKIDAR_USEPOLLING` | `1` | boolean | dev | File watching mode for containerized Vite. | +| `VITE_DEV_API_PROXY_TARGET` | `http://app:8000` | URL | dev | Vite proxy target for `/api` and `/healthz`. | + +### Auth and Session Security + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `SESSION_SECRET_KEY` | `replace-with-a-long-random-secret-at-least-32-characters` | long random string | deploy, dev | Session signing secret. Required in deploy. | +| `SESSION_COOKIE_SECURE` | `1` | boolean | deploy, dev | Mark session cookie as HTTPS-only. Set `1` behind HTTPS. | +| `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. | +| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. | + +### HTTP Security Headers and CSP + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `SECURITY_HEADERS_ENABLED` | `1` | boolean | deploy, dev | Global switch for response security headers middleware. | +| `SECURITY_X_CONTENT_TYPE_OPTIONS` | `nosniff` | header value | deploy, dev | `X-Content-Type-Options` response header value. | +| `SECURITY_X_FRAME_OPTIONS` | `DENY` | header value | deploy, dev | `X-Frame-Options` response header value. | +| `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | header value | deploy, dev | `Referrer-Policy` response header value. | +| `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()` | permissions policy string | deploy, dev | `Permissions-Policy` response header value. | +| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Opener-Policy` response header value. | +| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Resource-Policy` response header value. | +| `SECURITY_CSP_ENABLED` | `1` | boolean | deploy, dev | Enable Content Security Policy headers. | +| `SECURITY_CSP_POLICY` | strict SPA/API default | CSP policy string | deploy, dev | CSP applied to app/API routes (excluding docs paths). | +| `SECURITY_CSP_DOCS_POLICY` | relaxed docs default | CSP policy string | deploy, dev | CSP override for `/docs` and `/redoc` to keep Swagger/ReDoc usable. | +| `SECURITY_CSP_REPORT_ONLY` | `0` | boolean | deploy, dev | Emit CSP in report-only mode instead of enforcement mode. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | `0` | boolean | deploy, dev | Enable `Strict-Transport-Security` header. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | `31536000` | integer >= 0 | deploy, dev | `max-age` for HSTS header. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | `1` | boolean | deploy, dev | Add `includeSubDomains` directive to HSTS header. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | `0` | boolean | deploy, dev | Add `preload` directive to HSTS header. | + +### Logging + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | deploy, dev | Application log level. | +| `LOG_FORMAT` | `console` | `console`, `json` | deploy, dev | Log output format. | +| `LOG_REQUESTS` | `1` | boolean | deploy, dev | Enable request start/completion logs. | +| `LOG_UVICORN_ACCESS` | `0` | boolean | deploy, dev | Enable uvicorn access logs. | +| `LOG_REQUEST_SKIP_PATHS` | `/healthz` | comma-separated path prefixes | deploy, dev | Request log skip list. | +| `LOG_REDACT_FIELDS` | empty | comma-separated keys | deploy, dev | Extra fields to redact in structured logs. | + +### Scheduler and Ingestion + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `SCHEDULER_ENABLED` | `1` | boolean | deploy, dev | Enable background scheduler loop. | +| `SCHEDULER_TICK_SECONDS` | `60` | integer >= 1 | deploy, dev | Scheduler interval in seconds. | +| `INGESTION_AUTOMATION_ALLOWED` | `1` | boolean | deploy, dev | Global safety switch for scheduled/automatic checks. When disabled, auto-run settings are forced off. | +| `INGESTION_MANUAL_RUN_ALLOWED` | `1` | boolean | deploy, dev | Global safety switch for manual checks from API/UI. | +| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | `15` | integer >= 15 | deploy, dev | Server-enforced minimum for user-configured automatic check interval. | +| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | `2` | integer >= 2 | deploy, dev | Server-enforced minimum delay between scholar requests. | +| `SCHEDULER_QUEUE_BATCH_SIZE` | `10` | integer >= 1 | deploy, dev | Queue items processed per scheduler tick. | +| `INGESTION_NETWORK_ERROR_RETRIES` | `1` | integer >= 0 | deploy, dev | Retries for transient ingestion network errors. | +| `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. | +| `INGESTION_MAX_PAGES_PER_SCHOLAR` | `30` | integer >= 1 | deploy, dev | Upper bound of pages fetched per scholar run. | +| `INGESTION_PAGE_SIZE` | `100` | integer >= 1 | deploy, dev | Requested Scholar page size. | +| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Trigger blocked/captcha scrape alert flag when this many blocked failures occur in a run. | +| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Trigger network scrape alert flag when this many network failures occur in a run. | +| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Trigger retry alert flag when scheduled retry count reaches this threshold in a run. | +| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | `1800` | integer >= 60 | deploy, dev | Cooldown duration applied when blocked-failure threshold is exceeded. | +| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | `900` | integer >= 60 | deploy, dev | Cooldown duration applied when network-failure threshold is exceeded. | +| `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. | +| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. | +| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. | +| `INGESTION_CONTINUATION_MAX_ATTEMPTS` | `6` | integer >= 1 | deploy, dev | Max failed continuation attempts before dropping item. | + +### Scholar Images and Name Search Safety + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images in compose deployments. App fallback without env is `/tmp/scholarr_uploads/scholar_images`. | +| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. | +| `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. | +| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. | +| `SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS` | `300` | integer >= 1 | deploy, dev | Cache TTL for blocked/captcha responses. | +| `SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES` | `512` | integer >= 1 | deploy, dev | Max cached search entries. | +| `SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS` | `8.0` | float >= 0 | deploy, dev | Minimum interval between live name-search requests. | +| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | `2.0` | float >= 0 | deploy, dev | Added jitter to reduce request burst patterns. | +| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Consecutive blocked responses before cooldown starts. | +| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Cooldown duration after repeated blocked responses. | +| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Emit retry-threshold observability warning when name-search retry count reaches this value. | +| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Emit cooldown-threshold observability alert after this many requests are rejected during active cooldown. | + +### Scrape Safety Operations + +- Structured safety events are emitted for: + - policy/manual run blocks, + - cooldown entered/cleared transitions, + - blocked/network/retry threshold trips, + - scheduler cooldown skips/deferments. +- Use `LOG_FORMAT=json` and ship logs to your preferred collector for alerting. +- Runbook: `docs/ops/scrape_safety_runbook.md`. + +### Startup Bootstrap and DB Wait + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `BOOTSTRAP_ADMIN_ON_START` | `0` | boolean | deploy, dev | Auto-create admin at startup. | +| `BOOTSTRAP_ADMIN_EMAIL` | empty | valid email | deploy, dev | Bootstrap admin email. | +| `BOOTSTRAP_ADMIN_PASSWORD` | empty | strong password | deploy, dev | Bootstrap admin password. | +| `BOOTSTRAP_ADMIN_FORCE_PASSWORD` | `0` | boolean | deploy, dev | Force-reset bootstrap admin password if user already exists. | +| `DB_WAIT_TIMEOUT_SECONDS` | `60` | integer >= 1 | deploy, dev | Max seconds to wait for DB readiness at startup. | +| `DB_WAIT_INTERVAL_SECONDS` | `2` | integer >= 1 | deploy, dev | Interval between DB readiness checks. | + +## Quality and Test Commands + +Backend: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest tests/unit +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest -m integration +``` + +Frontend: + +```bash +cd frontend +npm install +npm run typecheck +npm run test:run +npm run build +``` + +Contract drift: + +```bash +python3 scripts/check_frontend_api_contract.py +``` + +Repository hygiene: + +```bash +./scripts/check_no_generated_artifacts.sh +``` + +Scheduled fixture probes run in GitHub Actions via `.github/workflows/scheduled-probes.yml`. + +## API Contract + +- Base path: `/api/v1` +- Success envelope: `{"data": ..., "meta": {"request_id": "..."}}` +- Error envelope: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` + +### Publications semantics + +- `GET /api/v1/publications` supports `mode=all|unread|latest` (plus temporary alias `mode=new`). +- `unread` = actionable read-state (`is_read=false`). +- `latest` = discovery-state (`first seen in the latest completed check`). +- Response counters: + - `unread_count`: unread publications in current scope. + - `latest_count`: publications discovered in latest completed check. + - `new_count`: compatibility alias for `latest_count` (temporary). diff --git a/agents.md b/agents.md deleted file mode 100644 index 1767078..0000000 --- a/agents.md +++ /dev/null @@ -1,123 +0,0 @@ -# AI Agent Instructions: Scholarr - -Adhere strictly to these constraints when working on this codebase. - -## 1. Code Quality - -- **Function length:** 50 lines max. Extract helpers ruthlessly. -- **File length:** 400 lines target, 600 lines hard ceiling. Files above this must be decomposed before adding more code. -- **DRY:** Abstract repeated logic immediately. No duplicate boilerplate for queries, responses, or error handling. -- **Negative space programming:** Fail fast with explicit assertions and guard clauses. No silent failures, especially in DOM parsing. -- **Cyclomatic complexity:** Flatten with early returns. No deep nesting. No magic numbers. -- **No dead code:** Do not leave commented-out code, unused imports, or backward-compatibility shims. Delete cleanly. -- **Variable naming:** Any variable outside of a lambda functions or iterators should be descriptive. Code should be readable for maintainers. - -## 2. Architecture - -### Data Model - -- Scholar tracking is **user-scoped**. Never assume global links between users and Scholar IDs. -- Publications are **global, deduplicated records**. Deduplicate via cluster ID and normalized fingerprinting. -- Read/unread, favorites, and visibility state live on the **scholar-publication link**, not the publication. - -### Service Boundaries - -All business logic lives in `app/services//`. No flat files in `app/services/` root. Each domain owns its application service, types, and helpers. - -### API Envelope - -All `/api/v1` responses use this exact envelope: - -``` -Success: {"data": ..., "meta": {"request_id": "..."}} -Error: {"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}} -``` - -Use the Pydantic envelope schemas in `app/api/schemas.py`. Do not construct raw dicts. - -## 3. Scrape Safety (Immutable) - -These constraints prevent IP bans. They are not tunable to zero and must not be optimized away. - -- Enforce `INGESTION_MIN_REQUEST_DELAY_SECONDS` (default 2s) between all external requests. -- Default to direct ID or profile URL ingestion. Name searches trigger CAPTCHAs. -- Respect `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` (1800s) and `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` (900s) upon threshold breaches. - -## 4. Logging - -Use `structured_log()` from `app/logging_utils.py` for all domain logging. Do not use raw `logger.info()` / `logger.warning()` calls. - -```python -from app.logging_utils import structured_log - -structured_log(logger, "info", "ingestion.run_started", user_id=user_id, scholar_count=count) -``` - -Every event name should be dot-namespaced to its domain (e.g., `arxiv.cache_hit`, `ingestion.safety_cooldown_entered`). - -## 5. Stack & Tooling - -- **Backend:** Python 3.12+, FastAPI, SQLAlchemy 2.0 (async/asyncpg), Alembic -- **Frontend:** TypeScript, Vue 3, Vite, Tailwind CSS -- **Infrastructure:** Multi-stage Docker, Docker Compose -- **Package manager:** `uv` (used in Dockerfile and CI; `uv run` prefix for all commands) -- **Linting:** `ruff check .` and `ruff format --check .` (config in `pyproject.toml`) -- **Type checking:** `mypy app/` -- **Versioning:** python-semantic-release with conventional commits - -## 6. Commits - -Follow [Conventional Commits](https://www.conventionalcommits.org/): - -``` -(): -``` - -Types: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`, `perf`. - -## 7. Testing & Quality Gates - -All **local development** commands run inside containers. Do not run bare `npm`, `pytest`, or `ruff` on the host. CI workflows (`.github/workflows/`) use bare runners with `uv` and `npm` directly — this is intentional since CI has no Docker-in-Docker dependency. - -### Backend - -```bash -# Unit tests (default, excludes integration) -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app python -m pytest - -# Integration tests -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app python -m pytest -m integration - -# Linting -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app ruff check . -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app ruff format --check . - -# Type checking -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app mypy app/ -``` - -Pytest markers: `integration`, `db`, `migrations`, `schema`, `smoke`. - -### Frontend - -```bash -# Type checking -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run typecheck - -# Unit tests -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run test:run - -# Lint -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run lint - -# Production build -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run build -``` - -All four gates must pass before a PR is considered ready. - -## 8. Frontend - -- Use the tokenized theme system (`frontend/src/theme/presets/`). Do not hardcode colors. -- Integrate Tailwind with preset theme tokens. Reference `frontend/scripts/check_theme_tokens.mjs` for enforcement. -- Every UI element must have a clear purpose. Clarity through styling and language. diff --git a/alembic/env.py b/alembic/env.py index 127078d..17575e4 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,13 +1,13 @@ -import os from logging.config import fileConfig +import os +from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context -from app.db import models # noqa: F401 from app.db.base import metadata +from app.db import models # noqa: F401 config = context.config diff --git a/alembic/versions/20260216_0001_multi_user_schema.py b/alembic/versions/20260216_0001_multi_user_schema.py index 6c9375e..221bbf7 100644 --- a/alembic/versions/20260216_0001_multi_user_schema.py +++ b/alembic/versions/20260216_0001_multi_user_schema.py @@ -8,11 +8,10 @@ Create Date: 2026-02-16 17:10:00.000000 from collections.abc import Sequence +from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -from alembic import op - # revision identifiers, used by Alembic. revision: str = "20260216_0001" down_revision: str | Sequence[str] | None = None diff --git a/alembic/versions/20260216_0002_add_user_admin_flag.py b/alembic/versions/20260216_0002_add_user_admin_flag.py index 5ea932d..e60de0f 100644 --- a/alembic/versions/20260216_0002_add_user_admin_flag.py +++ b/alembic/versions/20260216_0002_add_user_admin_flag.py @@ -7,9 +7,8 @@ Create Date: 2026-02-16 17:40:00.000000 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op +import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "20260216_0002" @@ -32,3 +31,4 @@ def upgrade() -> None: def downgrade() -> None: op.drop_column("users", "is_admin") + diff --git a/alembic/versions/20260216_0003_add_ingestion_queue.py b/alembic/versions/20260216_0003_add_ingestion_queue.py index 4c81fa0..cc03867 100644 --- a/alembic/versions/20260216_0003_add_ingestion_queue.py +++ b/alembic/versions/20260216_0003_add_ingestion_queue.py @@ -7,9 +7,8 @@ Create Date: 2026-02-16 23:45:00.000000 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op +import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "20260216_0003" diff --git a/alembic/versions/20260217_0004_queue_status_fields.py b/alembic/versions/20260217_0004_queue_status_fields.py index f6c29e6..68da9dd 100644 --- a/alembic/versions/20260217_0004_queue_status_fields.py +++ b/alembic/versions/20260217_0004_queue_status_fields.py @@ -7,9 +7,8 @@ Create Date: 2026-02-17 10:15:00.000000 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op +import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "20260217_0004" @@ -23,7 +22,9 @@ def upgrade() -> None: inspector = sa.inspect(bind) columns = {column["name"] for column in inspector.get_columns("ingestion_queue_items")} indexes = {index["name"] for index in inspector.get_indexes("ingestion_queue_items")} - checks = {constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items")} + checks = { + constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items") + } if "status" not in columns: op.add_column( @@ -79,7 +80,9 @@ def downgrade() -> None: inspector = sa.inspect(bind) columns = {column["name"] for column in inspector.get_columns("ingestion_queue_items")} indexes = {index["name"] for index in inspector.get_indexes("ingestion_queue_items")} - checks = {constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items")} + checks = { + constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items") + } if "ix_ingestion_queue_status_next_attempt" in indexes: op.drop_index("ix_ingestion_queue_status_next_attempt", table_name="ingestion_queue_items") diff --git a/alembic/versions/20260217_0005_manual_run_idempotency.py b/alembic/versions/20260217_0005_manual_run_idempotency.py index ad0269b..0375569 100644 --- a/alembic/versions/20260217_0005_manual_run_idempotency.py +++ b/alembic/versions/20260217_0005_manual_run_idempotency.py @@ -7,9 +7,8 @@ Create Date: 2026-02-17 16:20:00.000000 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op +import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "20260217_0005" @@ -71,7 +70,9 @@ def upgrade() -> None: "crawl_runs", ["user_id", "idempotency_key"], unique=True, - postgresql_where=sa.text("idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type"), + postgresql_where=sa.text( + "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" + ), ) diff --git a/alembic/versions/20260217_0006_scholar_profile_images.py b/alembic/versions/20260217_0006_scholar_profile_images.py index f736737..08035c8 100644 --- a/alembic/versions/20260217_0006_scholar_profile_images.py +++ b/alembic/versions/20260217_0006_scholar_profile_images.py @@ -7,9 +7,8 @@ Create Date: 2026-02-17 18:30:00.000000 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op +import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "20260217_0006" diff --git a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py index 6022c4c..dcfd0f7 100644 --- a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py +++ b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py @@ -7,9 +7,8 @@ Create Date: 2026-02-17 18:55:00.000000 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op +import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "20260217_0007" diff --git a/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py b/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py index ef38677..df481fd 100644 --- a/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py +++ b/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py @@ -7,10 +7,10 @@ Create Date: 2026-02-19 14:58:00.000000 from collections.abc import Sequence +from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -from alembic import op # revision identifiers, used by Alembic. revision: str = "20260219_0008" @@ -21,7 +21,7 @@ depends_on: str | Sequence[str] | None = None TABLE_NAME = "user_settings" DEFAULT_NAV_VISIBLE_PAGES_SQL = ( - '\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb' + "'[\"dashboard\",\"scholars\",\"publications\",\"settings\",\"style-guide\",\"runs\",\"users\"]'::jsonb" ) diff --git a/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py b/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py index 175b6b7..9ea658b 100644 --- a/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py +++ b/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py @@ -7,10 +7,10 @@ Create Date: 2026-02-19 21:20:00.000000 from collections.abc import Sequence +from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -from alembic import op # revision identifiers, used by Alembic. revision: str = "20260219_0009" diff --git a/alembic/versions/20260219_0010_author_search_shared_state.py b/alembic/versions/20260219_0010_author_search_shared_state.py index 0a07a42..bc2b97d 100644 --- a/alembic/versions/20260219_0010_author_search_shared_state.py +++ b/alembic/versions/20260219_0010_author_search_shared_state.py @@ -7,10 +7,10 @@ Create Date: 2026-02-19 22:20:00.000000 from collections.abc import Sequence +from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -from alembic import op # revision identifiers, used by Alembic. revision: str = "20260219_0010" diff --git a/alembic/versions/20260220_0011_add_publication_doi.py b/alembic/versions/20260220_0011_add_publication_doi.py deleted file mode 100644 index c286a00..0000000 --- a/alembic/versions/20260220_0011_add_publication_doi.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Add DOI column to publications. - -Revision ID: 20260220_0011 -Revises: 20260219_0010 -Create Date: 2026-02-20 13:35:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "20260220_0011" -down_revision: str | Sequence[str] | None = "20260219_0010" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: - for column in inspector.get_columns(table_name): - if str(column.get("name")) == column_name: - return True - return False - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - table_names = set(inspector.get_table_names()) - if "publications" not in table_names: - return - if not _has_column(inspector, "publications", "doi"): - op.add_column("publications", sa.Column("doi", sa.String(length=255), nullable=True)) - index_names = {index["name"] for index in inspector.get_indexes("publications")} - if "ix_publications_doi" not in index_names: - op.create_index("ix_publications_doi", "publications", ["doi"], unique=False) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - table_names = set(inspector.get_table_names()) - if "publications" not in table_names: - return - index_names = {index["name"] for index in inspector.get_indexes("publications")} - if "ix_publications_doi" in index_names: - op.drop_index("ix_publications_doi", table_name="publications") - if _has_column(inspector, "publications", "doi"): - op.drop_column("publications", "doi") diff --git a/alembic/versions/20260220_0012_add_data_repair_jobs.py b/alembic/versions/20260220_0012_add_data_repair_jobs.py deleted file mode 100644 index 6e7b53f..0000000 --- a/alembic/versions/20260220_0012_add_data_repair_jobs.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Add data repair job audit table. - -Revision ID: 20260220_0012 -Revises: 20260220_0011 -Create Date: 2026-02-20 23:35:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -from alembic import op - -revision: str = "20260220_0012" -down_revision: str | Sequence[str] | None = "20260220_0011" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _table_names() -> set[str]: - bind = op.get_bind() - inspector = sa.inspect(bind) - return set(inspector.get_table_names()) - - -def _create_data_repair_jobs_table() -> None: - op.create_table( - "data_repair_jobs", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("job_name", sa.String(length=64), nullable=False), - sa.Column("requested_by", sa.String(length=255), nullable=True), - sa.Column( - "scope", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - server_default=sa.text("'{}'::jsonb"), - ), - sa.Column("dry_run", sa.Boolean(), nullable=False, server_default=sa.text("true")), - sa.Column( - "status", - sa.String(length=16), - nullable=False, - server_default=sa.text("'planned'"), - ), - sa.Column( - "summary", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - server_default=sa.text("'{}'::jsonb"), - ), - sa.Column("error_text", sa.Text(), nullable=True), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.CheckConstraint( - "status IN ('planned', 'running', 'completed', 'failed')", - name="data_repair_jobs_status_valid", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_data_repair_jobs")), - ) - - -def _create_data_repair_jobs_indexes() -> None: - op.create_index( - "ix_data_repair_jobs_created_at", - "data_repair_jobs", - ["created_at"], - unique=False, - ) - op.create_index( - "ix_data_repair_jobs_job_name_created_at", - "data_repair_jobs", - ["job_name", "created_at"], - unique=False, - ) - - -def _drop_data_repair_jobs_indexes() -> None: - op.drop_index("ix_data_repair_jobs_job_name_created_at", table_name="data_repair_jobs") - op.drop_index("ix_data_repair_jobs_created_at", table_name="data_repair_jobs") - - -def upgrade() -> None: - if "data_repair_jobs" in _table_names(): - return - _create_data_repair_jobs_table() - _create_data_repair_jobs_indexes() - - -def downgrade() -> None: - if "data_repair_jobs" not in _table_names(): - return - _drop_data_repair_jobs_indexes() - op.drop_table("data_repair_jobs") diff --git a/alembic/versions/20260221_0013_add_publication_pdf_jobs.py b/alembic/versions/20260221_0013_add_publication_pdf_jobs.py deleted file mode 100644 index d7046d9..0000000 --- a/alembic/versions/20260221_0013_add_publication_pdf_jobs.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Add persistent publication PDF job tracking tables. - -Revision ID: 20260221_0013 -Revises: 20260220_0012 -Create Date: 2026-02-21 12:25:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "20260221_0013" -down_revision: str | Sequence[str] | None = "20260220_0012" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _table_names() -> set[str]: - bind = op.get_bind() - inspector = sa.inspect(bind) - return set(inspector.get_table_names()) - - -def _create_publication_pdf_jobs_table() -> None: - op.create_table( - "publication_pdf_jobs", - sa.Column("publication_id", sa.Integer(), nullable=False), - sa.Column( - "status", - sa.String(length=16), - nullable=False, - server_default=sa.text("'queued'"), - ), - sa.Column("attempt_count", sa.Integer(), nullable=False, server_default=sa.text("0")), - sa.Column("queued_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("last_failure_reason", sa.String(length=64), nullable=True), - sa.Column("last_failure_detail", sa.Text(), nullable=True), - sa.Column("last_source", sa.String(length=32), nullable=True), - sa.Column("last_requested_by_user_id", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.CheckConstraint( - "status IN ('queued', 'running', 'resolved', 'failed')", - name="publication_pdf_jobs_status_valid", - ), - sa.ForeignKeyConstraint( - ["last_requested_by_user_id"], - ["users.id"], - ondelete="SET NULL", - ), - sa.ForeignKeyConstraint( - ["publication_id"], - ["publications.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("publication_id", name=op.f("pk_publication_pdf_jobs")), - ) - - -def _create_publication_pdf_jobs_indexes() -> None: - op.create_index( - "ix_publication_pdf_jobs_status", - "publication_pdf_jobs", - ["status"], - unique=False, - ) - op.create_index( - "ix_publication_pdf_jobs_updated_at", - "publication_pdf_jobs", - ["updated_at"], - unique=False, - ) - op.create_index( - "ix_publication_pdf_jobs_queued_at", - "publication_pdf_jobs", - ["queued_at"], - unique=False, - ) - - -def _create_publication_pdf_job_events_table() -> None: - op.create_table( - "publication_pdf_job_events", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("publication_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=True), - sa.Column("event_type", sa.String(length=32), nullable=False), - sa.Column("status", sa.String(length=16), nullable=True), - sa.Column("source", sa.String(length=32), nullable=True), - sa.Column("failure_reason", sa.String(length=64), nullable=True), - sa.Column("message", sa.Text(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint( - ["publication_id"], - ["publications.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ondelete="SET NULL", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_publication_pdf_job_events")), - ) - - -def _create_publication_pdf_job_events_indexes() -> None: - op.create_index( - "ix_publication_pdf_job_events_publication_created", - "publication_pdf_job_events", - ["publication_id", "created_at"], - unique=False, - ) - op.create_index( - "ix_publication_pdf_job_events_created_at", - "publication_pdf_job_events", - ["created_at"], - unique=False, - ) - op.create_index( - "ix_publication_pdf_job_events_event_type", - "publication_pdf_job_events", - ["event_type"], - unique=False, - ) - - -def _drop_publication_pdf_jobs_indexes() -> None: - op.drop_index("ix_publication_pdf_jobs_queued_at", table_name="publication_pdf_jobs") - op.drop_index("ix_publication_pdf_jobs_updated_at", table_name="publication_pdf_jobs") - op.drop_index("ix_publication_pdf_jobs_status", table_name="publication_pdf_jobs") - - -def _drop_publication_pdf_job_events_indexes() -> None: - op.drop_index("ix_publication_pdf_job_events_event_type", table_name="publication_pdf_job_events") - op.drop_index("ix_publication_pdf_job_events_created_at", table_name="publication_pdf_job_events") - op.drop_index( - "ix_publication_pdf_job_events_publication_created", - table_name="publication_pdf_job_events", - ) - - -def upgrade() -> None: - tables = _table_names() - if "publication_pdf_jobs" not in tables: - _create_publication_pdf_jobs_table() - _create_publication_pdf_jobs_indexes() - if "publication_pdf_job_events" not in tables: - _create_publication_pdf_job_events_table() - _create_publication_pdf_job_events_indexes() - - -def downgrade() -> None: - tables = _table_names() - if "publication_pdf_job_events" in tables: - _drop_publication_pdf_job_events_indexes() - op.drop_table("publication_pdf_job_events") - if "publication_pdf_jobs" in tables: - _drop_publication_pdf_jobs_indexes() - op.drop_table("publication_pdf_jobs") diff --git a/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py b/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py deleted file mode 100644 index 7c06d52..0000000 --- a/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py +++ /dev/null @@ -1,44 +0,0 @@ -"""add scholar publication favorite state - -Revision ID: 20260221_0014 -Revises: 20260221_0013 -Create Date: 2026-02-21 14:35:00 -""" - -from __future__ import annotations - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision = "20260221_0014" -down_revision = "20260221_0013" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column( - "scholar_publications", - sa.Column( - "is_favorite", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - ) - op.create_index( - "ix_scholar_publications_is_favorite", - "scholar_publications", - ["is_favorite"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - "ix_scholar_publications_is_favorite", - table_name="scholar_publications", - ) - op.drop_column("scholar_publications", "is_favorite") diff --git a/alembic/versions/20260221_0015_enforce_request_delay_floor.py b/alembic/versions/20260221_0015_enforce_request_delay_floor.py deleted file mode 100644 index 122f055..0000000 --- a/alembic/versions/20260221_0015_enforce_request_delay_floor.py +++ /dev/null @@ -1,71 +0,0 @@ -"""enforce request delay floor at two seconds - -Revision ID: 20260221_0015 -Revises: 20260221_0014 -Create Date: 2026-02-21 18:10:00 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260221_0015" -down_revision: str | Sequence[str] | None = "20260221_0014" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -TABLE_NAME = "user_settings" -CHECK_NAME_MIN_1 = "request_delay_seconds_min_1" -CHECK_NAME_MIN_2 = "request_delay_seconds_min_2" -LEGACY_CHECK_NAME_MIN_1 = "ck_user_settings_request_delay_seconds_min_1" -LEGACY_CHECK_NAME_MIN_2 = "ck_user_settings_request_delay_seconds_min_2" - - -def _check_constraints() -> set[str]: - bind = op.get_bind() - inspector = sa.inspect(bind) - return {str(item.get("name")) for item in inspector.get_check_constraints(TABLE_NAME) if item.get("name")} - - -def _drop_check_if_exists(name: str) -> None: - if name not in _check_constraints(): - return - op.drop_constraint( - name, - TABLE_NAME, - type_="check", - ) - - -def upgrade() -> None: - op.execute(sa.text("UPDATE user_settings SET request_delay_seconds = 2 WHERE request_delay_seconds < 2")) - - _drop_check_if_exists(CHECK_NAME_MIN_1) - _drop_check_if_exists(LEGACY_CHECK_NAME_MIN_1) - - existing = _check_constraints() - if CHECK_NAME_MIN_2 not in existing and LEGACY_CHECK_NAME_MIN_2 not in existing: - op.create_check_constraint( - CHECK_NAME_MIN_2, - TABLE_NAME, - "request_delay_seconds >= 2", - ) - - -def downgrade() -> None: - _drop_check_if_exists(CHECK_NAME_MIN_2) - _drop_check_if_exists(LEGACY_CHECK_NAME_MIN_2) - - existing = _check_constraints() - if CHECK_NAME_MIN_1 not in existing and LEGACY_CHECK_NAME_MIN_1 not in existing: - op.create_check_constraint( - CHECK_NAME_MIN_1, - TABLE_NAME, - "request_delay_seconds >= 1", - ) diff --git a/alembic/versions/20260222_0016_add_publication_identifiers.py b/alembic/versions/20260222_0016_add_publication_identifiers.py deleted file mode 100644 index 9730a2d..0000000 --- a/alembic/versions/20260222_0016_add_publication_identifiers.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Add publication identifiers table. - -Revision ID: 20260222_0016 -Revises: 20260221_0015 -Create Date: 2026-02-22 12:45:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "20260222_0016" -down_revision: str | Sequence[str] | None = "20260221_0015" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _table_names() -> set[str]: - bind = op.get_bind() - inspector = sa.inspect(bind) - return set(inspector.get_table_names()) - - -def _create_publication_identifiers_table() -> None: - op.create_table( - "publication_identifiers", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("publication_id", sa.Integer(), nullable=False), - sa.Column("kind", sa.String(length=32), nullable=False), - sa.Column("value_raw", sa.Text(), nullable=False), - sa.Column("value_normalized", sa.String(length=255), nullable=False), - sa.Column("source", sa.String(length=64), nullable=False), - sa.Column( - "confidence_score", - sa.Float(), - nullable=False, - server_default=sa.text("0"), - ), - sa.Column("evidence_url", sa.Text(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.CheckConstraint( - "confidence_score >= 0 AND confidence_score <= 1", - name="publication_identifiers_confidence_score_range", - ), - sa.ForeignKeyConstraint( - ["publication_id"], - ["publications.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_publication_identifiers")), - sa.UniqueConstraint( - "publication_id", - "kind", - "value_normalized", - name="uq_publication_identifiers_publication_kind_value", - ), - ) - - -def _create_publication_identifiers_indexes() -> None: - op.create_index( - "ix_publication_identifiers_kind_value", - "publication_identifiers", - ["kind", "value_normalized"], - unique=False, - ) - op.create_index( - "ix_publication_identifiers_publication_id", - "publication_identifiers", - ["publication_id"], - unique=False, - ) - - -def _drop_publication_identifiers_indexes() -> None: - op.drop_index("ix_publication_identifiers_publication_id", table_name="publication_identifiers") - op.drop_index("ix_publication_identifiers_kind_value", table_name="publication_identifiers") - - -def upgrade() -> None: - if "publication_identifiers" in _table_names(): - return - _create_publication_identifiers_table() - _create_publication_identifiers_indexes() - - -def downgrade() -> None: - if "publication_identifiers" not in _table_names(): - return - _drop_publication_identifiers_indexes() - op.drop_table("publication_identifiers") diff --git a/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py b/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py deleted file mode 100644 index ee669fe..0000000 --- a/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Add openalex_enriched to publications - -Revision ID: 20260224_0018 -Revises: fa0c5e3262d5 -Create Date: 2026-02-24 19:10:00.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260224_0018" -down_revision: str | Sequence[str] | None = "fa0c5e3262d5" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column( - "publications", sa.Column("openalex_enriched", sa.Boolean(), server_default=sa.text("false"), nullable=False) - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("publications", "openalex_enriched") - # ### end Alembic commands ### diff --git a/alembic/versions/20260224_0019_add_resolving_to_runstatus.py b/alembic/versions/20260224_0019_add_resolving_to_runstatus.py deleted file mode 100644 index 163747c..0000000 --- a/alembic/versions/20260224_0019_add_resolving_to_runstatus.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Add resolving to RunStatus enum - -Revision ID: 20260224_0019 -Revises: 20260224_0018 -Create Date: 2026-02-24 22:20:00.000000 - -""" - -from collections.abc import Sequence - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260224_0019" -down_revision: str | Sequence[str] | None = "20260224_0018" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # Use COMMIT to break out of the transaction block that Alembic creates in env.py. - # Postgres restricts ALTER TYPE ... ADD VALUE inside transactions when the type is in use. - # This is the robust, non-patchwork way to ensure the schema evolves correctly. - op.execute("COMMIT") - op.execute("ALTER TYPE run_status ADD VALUE IF NOT EXISTS 'resolving' AFTER 'running'") - - -def downgrade() -> None: - # Enum values cannot be easily removed in Postgres without recreating the type, - # which is risky and usually avoided in simple migrations. - pass diff --git a/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py b/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py deleted file mode 100644 index 1b239f6..0000000 --- a/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Add openalex_last_attempt_at to publications - -Revision ID: 20260224_0020 -Revises: 20260224_0019 -Create Date: 2026-02-24 22:50:00.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260224_0020" -down_revision: str | Sequence[str] | None = "20260224_0019" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.add_column("publications", sa.Column("openalex_last_attempt_at", sa.DateTime(timezone=True), nullable=True)) - - -def downgrade() -> None: - op.drop_column("publications", "openalex_last_attempt_at") diff --git a/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py b/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py deleted file mode 100644 index f5b20f2..0000000 --- a/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Enforce one active crawl run per user. - -Revision ID: 20260225_0021 -Revises: 20260224_0020 -Create Date: 2026-02-25 09:10:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260225_0021" -down_revision: str | Sequence[str] | None = "20260224_0020" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -INDEX_NAME = "uq_crawl_runs_user_active" -ACTIVE_STATUSES = ("running", "resolving") - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} - - op.execute("ALTER TYPE run_status ADD VALUE IF NOT EXISTS 'canceled'") - - op.execute( - """ - WITH ranked AS ( - SELECT - id, - ROW_NUMBER() OVER ( - PARTITION BY user_id - ORDER BY start_dt DESC, id DESC - ) AS rn - FROM crawl_runs - WHERE status IN ('running', 'resolving') - ) - UPDATE crawl_runs AS runs - SET - status = 'failed', - end_dt = COALESCE(runs.end_dt, NOW()) - FROM ranked - WHERE runs.id = ranked.id - AND ranked.rn > 1 - """ - ) - - if INDEX_NAME not in indexes: - op.create_index( - INDEX_NAME, - "crawl_runs", - ["user_id"], - unique=True, - postgresql_where=sa.text("status IN ('running'::run_status, 'resolving'::run_status)"), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} - if INDEX_NAME in indexes: - op.drop_index(INDEX_NAME, table_name="crawl_runs") diff --git a/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py b/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py deleted file mode 100644 index 97ba39d..0000000 --- a/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Add canonical_title_hash to publications for cross-scholar dedup. - -Revision ID: 20260225_0022 -Revises: 20260225_0021 -Create Date: 2026-02-25 10:00:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260225_0022" -down_revision: str | Sequence[str] | None = "20260225_0021" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.add_column( - "publications", - sa.Column("canonical_title_hash", sa.String(64), nullable=True), - ) - op.create_index( - "ix_publications_canonical_title_hash", - "publications", - ["canonical_title_hash"], - ) - - -def downgrade() -> None: - op.drop_index("ix_publications_canonical_title_hash", table_name="publications") - op.drop_column("publications", "canonical_title_hash") diff --git a/alembic/versions/20260226_0023_add_arxiv_runtime_state.py b/alembic/versions/20260226_0023_add_arxiv_runtime_state.py deleted file mode 100644 index b9efd0d..0000000 --- a/alembic/versions/20260226_0023_add_arxiv_runtime_state.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Add shared arXiv runtime state table. - -Revision ID: 20260226_0023 -Revises: 20260225_0022 -Create Date: 2026-02-26 12:40:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260226_0023" -down_revision: str | Sequence[str] | None = "20260225_0022" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - table_names = set(inspector.get_table_names()) - - if "arxiv_runtime_state" in table_names: - return - - op.create_table( - "arxiv_runtime_state", - sa.Column("state_key", sa.String(length=64), nullable=False), - sa.Column("next_allowed_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("cooldown_until", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.PrimaryKeyConstraint("state_key", name=op.f("pk_arxiv_runtime_state")), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - table_names = set(inspector.get_table_names()) - if "arxiv_runtime_state" in table_names: - op.drop_table("arxiv_runtime_state") diff --git a/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py b/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py deleted file mode 100644 index 8fddda4..0000000 --- a/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Add arXiv query cache table. - -Revision ID: 20260226_0024 -Revises: 20260226_0023 -Create Date: 2026-02-26 14:20:00.000000 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "20260226_0024" -down_revision: str | Sequence[str] | None = "20260226_0023" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - table_names = set(inspector.get_table_names()) - if "arxiv_query_cache_entries" in table_names: - return - - op.create_table( - "arxiv_query_cache_entries", - sa.Column("query_fingerprint", sa.String(length=64), nullable=False), - sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), - sa.Column( - "cached_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.PrimaryKeyConstraint( - "query_fingerprint", - name=op.f("pk_arxiv_query_cache_entries"), - ), - ) - op.create_index( - "ix_arxiv_query_cache_expires_at", - "arxiv_query_cache_entries", - ["expires_at"], - unique=False, - ) - op.create_index( - "ix_arxiv_query_cache_cached_at", - "arxiv_query_cache_entries", - ["cached_at"], - unique=False, - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - table_names = set(inspector.get_table_names()) - if "arxiv_query_cache_entries" in table_names: - op.drop_table("arxiv_query_cache_entries") diff --git a/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py b/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py deleted file mode 100644 index f47adbd..0000000 --- a/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py +++ /dev/null @@ -1,27 +0,0 @@ -"""remove_doi_from_publications_and_migrate_identifiers - -Revision ID: 44f7e10ef777 -Revises: 20260222_0016 -Create Date: 2026-02-22 17:03:56.261936 - -""" -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '44f7e10ef777' -down_revision: str | Sequence[str] | None = '20260222_0016' -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass - diff --git a/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py b/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py deleted file mode 100644 index 627a18a..0000000 --- a/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Add API integration keys to UserSetting - -Revision ID: fa0c5e3262d5 -Revises: 44f7e10ef777 -Create Date: 2026-02-23 14:25:45.021512 - -""" -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'fa0c5e3262d5' -down_revision: str | Sequence[str] | None = '44f7e10ef777' -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_publications_doi'), table_name='publications') - op.drop_column('publications', 'doi') - op.add_column('user_settings', sa.Column('openalex_api_key', sa.String(length=255), nullable=True)) - op.add_column('user_settings', sa.Column('crossref_api_token', sa.String(length=255), nullable=True)) - op.add_column('user_settings', sa.Column('crossref_api_mailto', sa.String(length=255), nullable=True)) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('user_settings', 'crossref_api_mailto') - op.drop_column('user_settings', 'crossref_api_token') - op.drop_column('user_settings', 'openalex_api_key') - op.add_column('publications', sa.Column('doi', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) - op.create_index(op.f('ix_publications_doi'), 'publications', ['doi'], unique=False) - # ### end Alembic commands ### - diff --git a/app/api/__init__.py b/app/api/__init__.py index 9d48db4..3ce55a6 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1 +1,2 @@ from __future__ import annotations + diff --git a/app/api/errors.py b/app/api/errors.py index d926690..976ccb8 100644 --- a/app/api/errors.py +++ b/app/api/errors.py @@ -82,3 +82,4 @@ def register_api_exception_handlers(app: FastAPI) -> None: message="Request validation failed.", details=exc.errors(), ) + diff --git a/app/api/media.py b/app/api/media.py deleted file mode 100644 index 3bed5f8..0000000 --- a/app/api/media.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import mimetypes - -from fastapi import APIRouter, Depends -from fastapi.responses import FileResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.deps import get_api_current_user -from app.api.errors import ApiException -from app.db.models import User -from app.db.session import get_db_session -from app.services.scholars import application as scholar_service -from app.services.scholars import uploads as scholar_uploads -from app.settings import settings - -router = APIRouter(tags=["media"]) - - -@router.get("/scholar-images/{scholar_profile_id}/upload") -async def get_uploaded_scholar_image( - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - ) - if profile is None: - raise ApiException( - status_code=404, - code="scholar_not_found", - message="Scholar not found.", - ) - if not profile.profile_image_upload_path: - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) - - try: - image_path = scholar_uploads.resolve_upload_file_path( - upload_dir=settings.scholar_image_upload_dir, - relative_path=profile.profile_image_upload_path, - ) - except scholar_service.ScholarServiceError as exc: - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) from exc - - if not image_path.exists() or not image_path.is_file(): - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) - - media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" - return FileResponse(path=image_path, media_type=media_type) diff --git a/app/api/router.py b/app/api/router.py index 1b97d07..09d49b2 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,13 +2,11 @@ from __future__ import annotations from fastapi import APIRouter -from app.api.routers import admin, admin_dbops, admin_settings, auth, publications, runs, scholars, settings +from app.api.routers import admin, auth, publications, runs, scholars, settings router = APIRouter(prefix="/api/v1") router.include_router(auth.router) router.include_router(admin.router) -router.include_router(admin_settings.router) -router.include_router(admin_dbops.router) router.include_router(scholars.router) router.include_router(settings.router) router.include_router(runs.router) diff --git a/app/api/routers/__init__.py b/app/api/routers/__init__.py index 9d48db4..3ce55a6 100644 --- a/app/api/routers/__init__.py +++ b/app/api/routers/__init__.py @@ -1 +1,2 @@ from __future__ import annotations + diff --git a/app/api/routers/admin.py b/app/api/routers/admin.py index 35c61b6..dbf00bf 100644 --- a/app/api/routers/admin.py +++ b/app/api/routers/admin.py @@ -20,8 +20,7 @@ from app.auth.deps import get_auth_service from app.auth.service import AuthService from app.db.models import User from app.db.session import get_db_session -from app.logging_utils import structured_log -from app.services.users import application as user_service +from app.services import users as user_service logger = logging.getLogger(__name__) @@ -49,7 +48,14 @@ async def list_users( admin_user: User = Depends(get_api_admin_user), ): users = await user_service.list_users(db_session) - structured_log(logger, "info", "api.admin.users_listed", admin_user_id=int(admin_user.id), user_count=len(users)) + logger.info( + "api.admin.users_listed", + extra={ + "event": "api.admin.users_listed", + "admin_user_id": int(admin_user.id), + "user_count": len(users), + }, + ) return success_payload( request, data={ @@ -86,13 +92,14 @@ async def create_user( message=str(exc), ) from exc - structured_log( - logger, - "info", + logger.info( "api.admin.user_created", - admin_user_id=int(admin_user.id), - target_user_id=int(created_user.id), - target_is_admin=bool(created_user.is_admin), + extra={ + "event": "api.admin.user_created", + "admin_user_id": int(admin_user.id), + "target_user_id": int(created_user.id), + "target_is_admin": bool(created_user.is_admin), + }, ) return success_payload( request, @@ -118,7 +125,11 @@ async def set_user_active( code="user_not_found", message="User not found.", ) - if int(target_user.id) == int(admin_user.id) and bool(target_user.is_active) and not bool(payload.is_active): + if ( + int(target_user.id) == int(admin_user.id) + and bool(target_user.is_active) + and not bool(payload.is_active) + ): raise ApiException( status_code=400, code="cannot_deactivate_self", @@ -129,13 +140,14 @@ async def set_user_active( user=target_user, is_active=bool(payload.is_active), ) - structured_log( - logger, - "info", + logger.info( "api.admin.user_active_updated", - admin_user_id=int(admin_user.id), - target_user_id=int(updated_user.id), - is_active=bool(updated_user.is_active), + extra={ + "event": "api.admin.user_active_updated", + "admin_user_id": int(admin_user.id), + "target_user_id": int(updated_user.id), + "is_active": bool(updated_user.is_active), + }, ) return success_payload( request, @@ -176,12 +188,13 @@ async def reset_user_password( user=target_user, password_hash=auth_service.hash_password(validated_password), ) - structured_log( - logger, - "info", + logger.info( "api.admin.user_password_reset", - admin_user_id=int(admin_user.id), - target_user_id=int(target_user.id), + extra={ + "event": "api.admin.user_password_reset", + "admin_user_id": int(admin_user.id), + "target_user_id": int(target_user.id), + }, ) return success_payload( request, diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py deleted file mode 100644 index 048be70..0000000 --- a/app/api/routers/admin_dbops.py +++ /dev/null @@ -1,445 +0,0 @@ -from __future__ import annotations - -import logging - -from fastapi import APIRouter, Depends, Path, Query, Request -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.deps import get_api_admin_user, get_api_current_user -from app.api.errors import ApiException -from app.api.responses import success_payload -from app.api.schemas import ( - AdminDbIntegrityEnvelope, - AdminDbRepairJobsEnvelope, - AdminPdfQueueBulkEnqueueEnvelope, - AdminPdfQueueEnvelope, - AdminPdfQueueRequeueEnvelope, - AdminRepairPublicationLinksEnvelope, - AdminRepairPublicationLinksRequest, - AdminRepairPublicationNearDuplicatesEnvelope, - AdminRepairPublicationNearDuplicatesRequest, -) -from app.db.models import DataRepairJob, User -from app.db.session import get_db_session -from app.logging_utils import structured_log -from app.services.dbops import ( - collect_integrity_report, - list_repair_jobs, - run_publication_link_repair, - run_publication_near_duplicate_repair, -) -from app.services.publications import application as publication_service - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/admin/db", tags=["api-admin-dbops"]) - - -def _serialize_repair_job(job: DataRepairJob) -> dict[str, object]: - return { - "id": int(job.id), - "job_name": job.job_name, - "requested_by": job.requested_by, - "dry_run": bool(job.dry_run), - "status": job.status, - "scope": dict(job.scope or {}), - "summary": dict(job.summary or {}), - "error_text": job.error_text, - "started_at": job.started_at, - "finished_at": job.finished_at, - "created_at": job.created_at, - "updated_at": job.updated_at, - } - - -def _requested_by_value(*, payload, admin_user: User) -> str: - from_payload = (payload.requested_by or "").strip() - return from_payload or admin_user.email - - -def _serialize_pdf_queue_item(item, *, is_admin: bool = False) -> dict[str, object]: - return { - "publication_id": item.publication_id, - "title": item.title, - "display_identifier": _serialize_display_identifier(item.display_identifier), - "pdf_url": item.pdf_url, - "status": item.status, - "attempt_count": item.attempt_count, - "last_failure_reason": item.last_failure_reason, - "last_failure_detail": item.last_failure_detail, - "last_source": item.last_source, - "requested_by_user_id": item.requested_by_user_id, - "requested_by_email": item.requested_by_email if is_admin else None, - "queued_at": item.queued_at, - "last_attempt_at": item.last_attempt_at, - "resolved_at": item.resolved_at, - "updated_at": item.updated_at, - } - - -def _serialize_display_identifier(value) -> dict[str, object] | None: - if value is None: - return None - return { - "kind": value.kind, - "value": value.value, - "label": value.label, - "url": value.url, - "confidence_score": float(value.confidence_score), - } - - -def _requeue_response_state(*, queued: bool) -> tuple[str, str]: - if queued: - return "queued", "PDF lookup queued." - return "blocked", "PDF lookup is already queued or currently running." - - -def _bulk_enqueue_message(*, queued_count: int, requested_count: int) -> str: - if requested_count == 0: - return "No missing-PDF publications were found." - if queued_count == 0: - return "No publications were queued; all candidates were already in-flight." - return f"Queued {queued_count} publication(s) for PDF lookup." - - -def _resolve_pdf_queue_paging( - *, - page: int, - page_size: int, - limit: int | None, - offset: int | None, -) -> tuple[int, int, int]: - resolved_limit = int(limit) if limit is not None else int(page_size) - resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) - resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) - return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) - - -def _pdf_queue_page_data(*, total_count: int, page: int, page_size: int, offset: int) -> dict[str, object]: - return { - "total_count": int(total_count), - "page": int(page), - "page_size": int(page_size), - "has_prev": int(offset) > 0, - "has_next": int(offset) + int(page_size) < int(total_count), - } - - -@router.get( - "/integrity", - response_model=AdminDbIntegrityEnvelope, -) -async def get_integrity_report( - request: Request, - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - report = await collect_integrity_report(db_session) - structured_log( - logger, - "info", - "api.admin.db.integrity_checked", - admin_user_id=int(admin_user.id), - status=report.get("status"), - failure_count=len(report.get("failures", [])), - warning_count=len(report.get("warnings", [])), - ) - return success_payload(request, data=report) - - -@router.get( - "/repair-jobs", - response_model=AdminDbRepairJobsEnvelope, -) -async def get_repair_jobs( - request: Request, - limit: int = Query(default=50, ge=1, le=200), - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - jobs = await list_repair_jobs(db_session, limit=limit) - structured_log( - logger, - "info", - "api.admin.db.repair_jobs_listed", - admin_user_id=int(admin_user.id), - limit=int(limit), - job_count=len(jobs), - ) - return success_payload( - request, - data={"jobs": [_serialize_repair_job(job) for job in jobs]}, - ) - - -@router.get( - "/pdf-queue", - response_model=AdminPdfQueueEnvelope, -) -async def get_pdf_queue( - request: Request, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=100, ge=1, le=500), - limit: int | None = Query(default=None, ge=1, le=500), - offset: int | None = Query(default=None, ge=0), - status: str | None = Query(default=None), - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - normalized_status = (status or "").strip().lower() or None - resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( - page=page, - page_size=page_size, - limit=limit, - offset=offset, - ) - queue_page = await publication_service.list_pdf_queue_page( - db_session, - limit=resolved_limit, - offset=resolved_offset, - status=normalized_status, - ) - structured_log( - logger, - "info", - "api.admin.db.pdf_queue_listed", - user_id=int(current_user.id), - page=int(resolved_page), - page_size=int(resolved_limit), - offset=int(resolved_offset), - status=normalized_status, - item_count=len(queue_page.items), - total_count=int(queue_page.total_count), - ) - return success_payload( - request, - data={ - "items": [_serialize_pdf_queue_item(item, is_admin=current_user.is_admin) for item in queue_page.items], - **_pdf_queue_page_data( - total_count=queue_page.total_count, - page=resolved_page, - page_size=resolved_limit, - offset=resolved_offset, - ), - }, - ) - - -@router.post( - "/pdf-queue/{publication_id}/requeue", - response_model=AdminPdfQueueRequeueEnvelope, -) -async def requeue_pdf_lookup( - request: Request, - publication_id: int = Path(ge=1), - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - result = await publication_service.enqueue_retry_pdf_job_for_publication_id( - db_session, - user_id=int(admin_user.id), - request_email=admin_user.email, - publication_id=publication_id, - ) - if not result.publication_exists: - raise ApiException( - status_code=404, - code="publication_not_found", - message="Publication not found.", - ) - status, message = _requeue_response_state(queued=result.queued) - structured_log( - logger, - "info", - "api.admin.db.pdf_requeued", - admin_user_id=int(admin_user.id), - publication_id=int(publication_id), - queued=bool(result.queued), - ) - return success_payload( - request, - data={ - "publication_id": int(publication_id), - "queued": bool(result.queued), - "status": status, - "message": message, - }, - ) - - -@router.post( - "/pdf-queue/requeue-all", - response_model=AdminPdfQueueBulkEnqueueEnvelope, -) -async def requeue_all_missing_pdfs( - request: Request, - limit: int = Query(default=1000, ge=1, le=5000), - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - result = await publication_service.enqueue_all_missing_pdf_jobs( - db_session, - user_id=int(admin_user.id), - request_email=admin_user.email, - limit=limit, - ) - structured_log( - logger, - "info", - "api.admin.db.pdf_queue_requeue_all", - admin_user_id=int(admin_user.id), - limit=int(limit), - requested_count=int(result.requested_count), - queued_count=int(result.queued_count), - ) - return success_payload( - request, - data={ - "requested_count": int(result.requested_count), - "queued_count": int(result.queued_count), - "message": _bulk_enqueue_message( - queued_count=int(result.queued_count), - requested_count=int(result.requested_count), - ), - }, - ) - - -@router.post( - "/repairs/publication-links", - response_model=AdminRepairPublicationLinksEnvelope, -) -async def trigger_publication_link_repair( - payload: AdminRepairPublicationLinksRequest, - request: Request, - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - try: - result = await run_publication_link_repair( - db_session, - scope_mode=payload.scope_mode, - user_id=payload.user_id, - scholar_profile_ids=payload.scholar_profile_ids, - dry_run=bool(payload.dry_run), - gc_orphan_publications=bool(payload.gc_orphan_publications), - requested_by=_requested_by_value(payload=payload, admin_user=admin_user), - ) - except ValueError as exc: - raise ApiException( - status_code=400, - code="invalid_repair_scope", - message=str(exc), - ) from exc - structured_log( - logger, - "info", - "api.admin.db.publication_link_repair_triggered", - admin_user_id=int(admin_user.id), - scope_mode=payload.scope_mode, - target_user_id=int(payload.user_id) if payload.user_id is not None else None, - dry_run=bool(payload.dry_run), - gc_orphan_publications=bool(payload.gc_orphan_publications), - job_id=int(result["job_id"]), - status=result["status"], - ) - return success_payload(request, data=result) - - -@router.post( - "/repairs/publication-near-duplicates", - response_model=AdminRepairPublicationNearDuplicatesEnvelope, -) -async def trigger_publication_near_duplicate_repair( - payload: AdminRepairPublicationNearDuplicatesRequest, - request: Request, - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - try: - result = await run_publication_near_duplicate_repair( - db_session, - dry_run=bool(payload.dry_run), - similarity_threshold=float(payload.similarity_threshold), - min_shared_tokens=int(payload.min_shared_tokens), - max_year_delta=int(payload.max_year_delta), - max_clusters=int(payload.max_clusters), - selected_cluster_keys=list(payload.selected_cluster_keys), - requested_by=_requested_by_value(payload=payload, admin_user=admin_user), - ) - except ValueError as exc: - raise ApiException( - status_code=400, - code="invalid_near_duplicate_repair_request", - message=str(exc), - ) from exc - structured_log( - logger, - "info", - "api.admin.db.dedup_repair_triggered", - admin_user_id=int(admin_user.id), - dry_run=bool(payload.dry_run), - selected_cluster_count=len(payload.selected_cluster_keys), - job_id=int(result["job_id"]), - status=result["status"], - ) - return success_payload(request, data=result) - - -DROP_PUBLICATIONS_CONFIRMATION = "DROP ALL PUBLICATIONS" - - -@router.post("/drop-all-publications") -async def drop_all_publications( - request: Request, - db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), -): - body = await request.json() - confirmation_text = (body.get("confirmation_text") or "").strip() - if confirmation_text != DROP_PUBLICATIONS_CONFIRMATION: - raise ApiException( - status_code=400, - code="confirmation_required", - message=f"Type '{DROP_PUBLICATIONS_CONFIRMATION}' to confirm this destructive action.", - ) - - from sqlalchemy import delete, func, select, update - - from app.db.models import ( - Publication, - PublicationIdentifier, - PublicationPdfJob, - PublicationPdfJobEvent, - ScholarProfile, - ScholarPublication, - ) - - count_result = await db_session.execute(select(func.count()).select_from(Publication)) - total_publications = count_result.scalar_one() - - await db_session.execute(delete(ScholarPublication)) - await db_session.execute(delete(PublicationIdentifier)) - await db_session.execute(delete(PublicationPdfJobEvent)) - await db_session.execute(delete(PublicationPdfJob)) - await db_session.execute(delete(Publication)) - await db_session.execute(update(ScholarProfile).values(baseline_completed=False)) - await db_session.commit() - - structured_log( - logger, - "warning", - "api.admin.db.all_publications_dropped", - admin_user_id=int(admin_user.id), - admin_email=admin_user.email, - deleted_count=int(total_publications), - ) - return success_payload( - request, - data={ - "deleted_count": int(total_publications), - "message": f"Dropped {total_publications} publication(s) and all related data. " - "Scholar baselines have been reset; the next run will re-discover all publications.", - }, - ) diff --git a/app/api/routers/admin_settings.py b/app/api/routers/admin_settings.py deleted file mode 100644 index bbdaad3..0000000 --- a/app/api/routers/admin_settings.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import logging - -from fastapi import APIRouter, Depends, Request - -from app.api.deps import get_api_admin_user -from app.api.errors import ApiException -from app.api.responses import success_payload -from app.api.schemas import ( - AdminScholarHttpSettingsEnvelope, - AdminScholarHttpSettingsUpdateRequest, -) -from app.db.models import User -from app.logging_utils import structured_log -from app.settings import settings - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/admin/settings", tags=["api-admin-settings"]) -_MAX_USER_AGENT_LENGTH = 512 -_MAX_ACCEPT_LANGUAGE_LENGTH = 128 -_MAX_COOKIE_LENGTH = 8192 - - -def _normalize_header_value(value: str, *, field_name: str, max_length: int) -> str: - normalized = value.strip() - if len(normalized) > max_length: - raise ApiException( - status_code=400, - code="invalid_admin_setting", - message=f"{field_name} must be {max_length} characters or fewer.", - ) - return normalized - - -def _serialize_scholar_http_settings() -> dict[str, object]: - return { - "user_agent": settings.scholar_http_user_agent, - "rotate_user_agent": bool(settings.scholar_http_rotate_user_agent), - "accept_language": settings.scholar_http_accept_language, - "cookie": settings.scholar_http_cookie, - } - - -def _apply_scholar_http_settings(payload: AdminScholarHttpSettingsUpdateRequest) -> None: - user_agent = _normalize_header_value( - payload.user_agent, - field_name="user_agent", - max_length=_MAX_USER_AGENT_LENGTH, - ) - accept_language = _normalize_header_value( - payload.accept_language, - field_name="accept_language", - max_length=_MAX_ACCEPT_LANGUAGE_LENGTH, - ) - cookie = _normalize_header_value( - payload.cookie or "", - field_name="cookie", - max_length=_MAX_COOKIE_LENGTH, - ) - object.__setattr__(settings, "scholar_http_user_agent", user_agent) - object.__setattr__(settings, "scholar_http_rotate_user_agent", bool(payload.rotate_user_agent)) - object.__setattr__(settings, "scholar_http_accept_language", accept_language) - object.__setattr__(settings, "scholar_http_cookie", cookie) - - -@router.get( - "/scholar-http", - response_model=AdminScholarHttpSettingsEnvelope, -) -async def get_scholar_http_settings( - request: Request, - admin_user: User = Depends(get_api_admin_user), -): - structured_log( - logger, - "info", - "api.admin.settings.scholar_http_read", - admin_user_id=int(admin_user.id), - ) - return success_payload(request, data=_serialize_scholar_http_settings()) - - -@router.put( - "/scholar-http", - response_model=AdminScholarHttpSettingsEnvelope, -) -async def update_scholar_http_settings( - payload: AdminScholarHttpSettingsUpdateRequest, - request: Request, - admin_user: User = Depends(get_api_admin_user), -): - _apply_scholar_http_settings(payload) - structured_log( - logger, - "info", - "api.admin.settings.scholar_http_updated", - admin_user_id=int(admin_user.id), - rotate_user_agent=bool(settings.scholar_http_rotate_user_agent), - user_agent_set=bool(settings.scholar_http_user_agent.strip()), - cookie_set=bool(settings.scholar_http_cookie.strip()), - ) - return success_payload(request, data=_serialize_scholar_http_settings()) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index a46d71b..1a76ced 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -1,13 +1,12 @@ from __future__ import annotations import logging -from typing import NoReturn from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_api_current_user from app.api.errors import ApiException +from app.api.deps import get_api_current_user from app.api.responses import success_payload from app.api.schemas import ( AuthMeEnvelope, @@ -17,60 +16,21 @@ from app.api.schemas import ( LoginRequest, MessageEnvelope, ) -from app.auth import runtime as auth_runtime from app.auth.deps import get_auth_service, get_login_rate_limiter from app.auth.rate_limit import SlidingWindowRateLimiter +from app.auth import runtime as auth_runtime from app.auth.service import AuthService from app.auth.session import set_session_user from app.db.models import User from app.db.session import get_db_session -from app.logging_utils import structured_log from app.security.csrf import ensure_csrf_token -from app.services.users import application as user_service +from app.services import users as user_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["api-auth"]) -def _login_limiter_key_and_email(request: Request, payload: LoginRequest) -> tuple[str, str]: - return auth_runtime.login_rate_limit_key(request, payload.email), payload.email.strip().lower() - - -def _raise_rate_limited(normalized_email: str, retry_after_seconds: int) -> None: - structured_log( - logger, - "warning", - "api.auth.login_rate_limited", - email=normalized_email, - retry_after_seconds=retry_after_seconds, - ) - raise ApiException( - status_code=429, - code="rate_limited", - message="Too many login attempts. Please try again later.", - details={"retry_after_seconds": retry_after_seconds}, - ) - - -def _serialize_user_payload(user: User) -> dict[str, object]: - return { - "id": int(user.id), - "email": user.email, - "is_admin": bool(user.is_admin), - "is_active": bool(user.is_active), - } - - -def _raise_invalid_credentials(*, normalized_email: str) -> NoReturn: - structured_log(logger, "info", "api.auth.login_failed", email=normalized_email) - raise ApiException( - status_code=401, - code="invalid_credentials", - message="Invalid email or password.", - ) - - @router.post( "/login", response_model=LoginEnvelope, @@ -82,10 +42,24 @@ async def login( auth_service: AuthService = Depends(get_auth_service), rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), ): - limiter_key, normalized_email = _login_limiter_key_and_email(request, payload) + limiter_key = auth_runtime.login_rate_limit_key(request, payload.email) decision = rate_limiter.check(limiter_key) + normalized_email = payload.email.strip().lower() if not decision.allowed: - _raise_rate_limited(normalized_email, int(decision.retry_after_seconds)) + logger.warning( + "api.auth.login_rate_limited", + extra={ + "event": "api.auth.login_rate_limited", + "email": normalized_email, + "retry_after_seconds": decision.retry_after_seconds, + }, + ) + raise ApiException( + status_code=429, + code="rate_limited", + message="Too many login attempts. Please try again later.", + details={"retry_after_seconds": decision.retry_after_seconds}, + ) user = await auth_service.authenticate_user( db_session, @@ -94,7 +68,18 @@ async def login( ) if user is None: rate_limiter.record_failure(limiter_key) - _raise_invalid_credentials(normalized_email=normalized_email) + logger.info( + "api.auth.login_failed", + extra={ + "event": "api.auth.login_failed", + "email": normalized_email, + }, + ) + raise ApiException( + status_code=401, + code="invalid_credentials", + message="Invalid email or password.", + ) rate_limiter.reset(limiter_key) set_session_user( @@ -103,13 +88,25 @@ async def login( email=user.email, is_admin=user.is_admin, ) - structured_log(logger, "info", "api.auth.login_succeeded", user_id=user.id, is_admin=user.is_admin) + logger.info( + "api.auth.login_succeeded", + extra={ + "event": "api.auth.login_succeeded", + "user_id": user.id, + "is_admin": user.is_admin, + }, + ) return success_payload( request, data={ "authenticated": True, "csrf_token": ensure_csrf_token(request), - "user": _serialize_user_payload(user), + "user": { + "id": int(user.id), + "email": user.email, + "is_admin": bool(user.is_admin), + "is_active": bool(user.is_active), + }, }, ) @@ -127,7 +124,12 @@ async def get_current_session( data={ "authenticated": True, "csrf_token": ensure_csrf_token(request), - "user": _serialize_user_payload(current_user), + "user": { + "id": int(current_user.id), + "email": current_user.email, + "is_admin": bool(current_user.is_admin), + "is_active": bool(current_user.is_active), + }, }, ) @@ -190,7 +192,13 @@ async def change_password( user=current_user, password_hash=auth_service.hash_password(validated_password), ) - structured_log(logger, "info", "api.auth.password_changed", user_id=int(current_user.id)) + logger.info( + "api.auth.password_changed", + extra={ + "event": "api.auth.password_changed", + "user_id": int(current_user.id), + }, + ) return success_payload( request, data={"message": "Password updated successfully."}, @@ -207,8 +215,12 @@ async def logout( ): current_user = await auth_runtime.get_authenticated_user(request, db_session) auth_runtime.invalidate_session(request) - structured_log( - logger, "info", "api.auth.logout", user_id=int(current_user.id) if current_user is not None else None + logger.info( + "api.auth.logout", + extra={ + "event": "api.auth.logout", + "user_id": int(current_user.id) if current_user is not None else None, + }, ) return success_payload( request, diff --git a/app/api/routers/publications.py b/app/api/routers/publications.py index 1d8ec53..6e04685 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -1,10 +1,9 @@ from __future__ import annotations import logging -from datetime import UTC, datetime from typing import Literal -from fastapi import APIRouter, Depends, Path, Query, Request +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user @@ -15,332 +14,17 @@ from app.api.schemas import ( MarkSelectedReadEnvelope, MarkSelectedReadRequest, PublicationsListEnvelope, - RetryPublicationPdfEnvelope, - RetryPublicationPdfRequest, - TogglePublicationFavoriteEnvelope, - TogglePublicationFavoriteRequest, ) from app.db.models import User from app.db.session import get_db_session -from app.logging_utils import structured_log -from app.services.publication_identifiers import application as identifier_service -from app.services.publications import application as publication_service -from app.services.scholars import application as scholar_service -from app.settings import settings +from app.services import publications as publication_service +from app.services import scholars as scholar_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/publications", tags=["api-publications"]) -async def _require_selected_profile( - db_session: AsyncSession, - *, - user_id: int, - selected_scholar_id: int | None, -) -> None: - if selected_scholar_id is None: - return - selected_profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=user_id, - scholar_profile_id=selected_scholar_id, - ) - if selected_profile is None: - raise ApiException( - status_code=404, - code="scholar_not_found", - message="Scholar filter not found.", - ) - - -def _serialize_publication_item(item) -> dict[str, object]: - return { - "publication_id": item.publication_id, - "scholar_profile_id": item.scholar_profile_id, - "scholar_label": item.scholar_label, - "title": item.title, - "year": item.year, - "citation_count": item.citation_count, - "venue_text": item.venue_text, - "pub_url": item.pub_url, - "display_identifier": _serialize_display_identifier(item.display_identifier), - "pdf_url": item.pdf_url, - "pdf_status": item.pdf_status, - "pdf_attempt_count": item.pdf_attempt_count, - "pdf_failure_reason": item.pdf_failure_reason, - "pdf_failure_detail": item.pdf_failure_detail, - "is_read": item.is_read, - "is_favorite": item.is_favorite, - "first_seen_at": item.first_seen_at, - "is_new_in_latest_run": item.is_new_in_latest_run, - } - - -def _serialize_display_identifier(value) -> dict[str, object] | None: - if value is None: - return None - return { - "kind": value.kind, - "value": value.value, - "label": value.label, - "url": value.url, - "confidence_score": float(value.confidence_score), - } - - -async def _publication_counts( - db_session: AsyncSession, - *, - user_id: int, - selected_scholar_id: int | None, - favorite_only: bool, - search: str | None, - snapshot_before: datetime | None, -) -> tuple[int, int, int, int]: - unread_count = await publication_service.count_unread_for_user( - db_session, - user_id=user_id, - scholar_profile_id=selected_scholar_id, - favorite_only=favorite_only, - snapshot_before=snapshot_before, - ) - favorites_count = await publication_service.count_favorite_for_user( - db_session, - user_id=user_id, - scholar_profile_id=selected_scholar_id, - snapshot_before=snapshot_before, - ) - latest_count = await publication_service.count_latest_for_user( - db_session, - user_id=user_id, - scholar_profile_id=selected_scholar_id, - favorite_only=favorite_only, - snapshot_before=snapshot_before, - ) - total_count = await publication_service.count_for_user( - db_session, - user_id=user_id, - mode=publication_service.MODE_ALL, - scholar_profile_id=selected_scholar_id, - favorite_only=favorite_only, - search=search, - snapshot_before=snapshot_before, - ) - return unread_count, favorites_count, latest_count, total_count - - -async def _list_publications_for_request( - db_session: AsyncSession, - *, - current_user: User, - mode: Literal["all", "unread", "latest", "new"] | None, - favorite_only: bool, - scholar_profile_id: int | None, - search: str | None, - sort_by: str, - sort_dir: str, - limit: int, - offset: int, - snapshot_before: datetime | None, -) -> tuple[str, int | None, list]: - resolved_mode = publication_service.resolve_publication_view_mode(mode) - selected_scholar_id = scholar_profile_id - await _require_selected_profile( - db_session, - user_id=current_user.id, - selected_scholar_id=selected_scholar_id, - ) - publications = await publication_service.list_for_user( - db_session, - user_id=current_user.id, - mode=resolved_mode, - scholar_profile_id=selected_scholar_id, - favorite_only=favorite_only, - search=search, - sort_by=sort_by, - sort_dir=sort_dir, - limit=limit, - offset=offset, - snapshot_before=snapshot_before, - ) - await publication_service.schedule_missing_pdf_enrichment_for_user( - db_session, - user_id=current_user.id, - request_email=current_user.email, - items=publications, - max_items=settings.unpaywall_max_items_per_request, - ) - hydrated = await publication_service.hydrate_pdf_enrichment_state( - db_session, - items=publications, - ) - return resolved_mode, selected_scholar_id, hydrated - - -def _resolve_publications_snapshot( - *, - snapshot: str | None, -) -> tuple[datetime, str]: - if snapshot is None: - now_utc = datetime.now(UTC) - return now_utc, now_utc.isoformat() - try: - parsed = datetime.fromisoformat(snapshot) - except ValueError as exc: - raise ApiException( - status_code=400, - code="invalid_snapshot", - message="Invalid publications snapshot cursor.", - ) from exc - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - normalized = parsed.astimezone(UTC) - return normalized, normalized.isoformat() - - -def _resolve_publications_paging( - *, - page: int, - page_size: int, - limit: int | None, - offset: int | None, -) -> tuple[int, int, int]: - resolved_limit = int(limit) if limit is not None else int(page_size) - resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) - resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) - return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) - - -def _publications_list_data( - *, - mode: str, - favorite_only: bool, - selected_scholar_id: int | None, - unread_count: int, - favorites_count: int, - latest_count: int, - total_count: int, - publications: list, - page: int, - page_size: int, - offset: int, - snapshot: str, -) -> dict[str, object]: - return { - "mode": mode, - "favorite_only": favorite_only, - "selected_scholar_profile_id": selected_scholar_id, - "unread_count": unread_count, - "favorites_count": favorites_count, - "latest_count": latest_count, - "new_count": latest_count, - "total_count": total_count, - "page": int(page), - "page_size": int(page_size), - "snapshot": snapshot, - "has_prev": int(offset) > 0, - "has_next": int(offset) + int(page_size) < int(total_count), - "publications": [_serialize_publication_item(item) for item in publications], - } - - -def _retry_pdf_message(*, queued: bool, resolved_pdf: bool, pdf_status: str) -> str: - if resolved_pdf: - return "Open-access PDF link already resolved." - if queued: - return "PDF lookup queued." - if pdf_status in {"queued", "running"}: - return "PDF lookup is already queued." - return "No open-access PDF link found." - - -def _favorite_message(*, is_favorite: bool) -> str: - if is_favorite: - return "Publication marked as favorite." - return "Publication removed from favorites." - - -async def _retry_publication_state( - db_session: AsyncSession, - *, - current_user: User, - scholar_profile_id: int, - publication_id: int, -): - publication = await publication_service.retry_pdf_for_user( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - publication_id=publication_id, - ) - if publication is None: - raise ApiException( - status_code=404, - code="publication_not_found", - message="Publication not found.", - ) - queued = False - if not publication.pdf_url: - queued = await publication_service.schedule_retry_pdf_enrichment_for_row( - db_session, - user_id=current_user.id, - request_email=current_user.email, - item=publication, - ) - hydrated = await publication_service.hydrate_pdf_enrichment_state( - db_session, - items=[publication], - ) - current = hydrated[0] if hydrated else publication - return current, queued - - -async def _favorite_publication_state( - db_session: AsyncSession, - *, - current_user: User, - scholar_profile_id: int, - publication_id: int, - is_favorite: bool, -): - updated_count = await publication_service.set_publication_favorite_for_user( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - publication_id=publication_id, - is_favorite=is_favorite, - ) - if updated_count <= 0: - raise ApiException( - status_code=404, - code="publication_not_found", - message="Publication not found.", - ) - publication = await publication_service.get_publication_item_for_user( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - publication_id=publication_id, - ) - if publication is None: - raise ApiException( - status_code=404, - code="publication_not_found", - message="Publication not found.", - ) - hydrated = await publication_service.hydrate_pdf_enrichment_state( - db_session, - items=[publication], - ) - current = hydrated[0] if hydrated else publication - identifiers = await identifier_service.overlay_publication_items_with_display_identifiers( - db_session, - items=[current], - ) - return identifiers[0] if identifiers else current - - @router.get( "", response_model=PublicationsListEnvelope, @@ -348,63 +32,76 @@ async def _favorite_publication_state( async def list_publications( request: Request, mode: Literal["all", "unread", "latest", "new"] | None = Query(default=None), - favorite_only: bool = Query(default=False), scholar_profile_id: int | None = Query(default=None, ge=1), - search: str | None = Query(default=None, min_length=1, max_length=200), - sort_by: Literal["first_seen", "title", "year", "citations", "scholar", "pdf_status"] = Query(default="first_seen"), - sort_dir: Literal["asc", "desc"] = Query(default="desc"), - page: int = Query(default=1, ge=1), - page_size: int = Query(default=100, ge=1, le=500), - limit: int | None = Query(default=None, ge=1, le=1000), - offset: int | None = Query(default=None, ge=0), - snapshot: str | None = Query(default=None, min_length=1, max_length=64), + limit: int = Query(default=300, ge=1, le=1000), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - resolved_page, resolved_limit, resolved_offset = _resolve_publications_paging( - page=page, - page_size=page_size, - limit=limit, - offset=offset, - ) - snapshot_before, snapshot_cursor = _resolve_publications_snapshot(snapshot=snapshot) - normalized_search = (search or "").strip() or None - resolved_mode, selected_scholar_id, publications = await _list_publications_for_request( - db_session, - current_user=current_user, - mode=mode, - favorite_only=favorite_only, - scholar_profile_id=scholar_profile_id, - search=normalized_search, - sort_by=sort_by, - sort_dir=sort_dir, - limit=resolved_limit, - offset=resolved_offset, - snapshot_before=snapshot_before, - ) - unread_count, favorites_count, latest_count, total_count = await _publication_counts( + resolved_mode = publication_service.resolve_publication_view_mode(mode) + selected_scholar_id = scholar_profile_id + if selected_scholar_id is not None: + selected_profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=selected_scholar_id, + ) + if selected_profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar filter not found.", + ) + + publications = await publication_service.list_for_user( db_session, user_id=current_user.id, - selected_scholar_id=selected_scholar_id, - favorite_only=favorite_only, - search=normalized_search, - snapshot_before=snapshot_before, - ) - data = _publications_list_data( mode=resolved_mode, - favorite_only=favorite_only, - selected_scholar_id=selected_scholar_id, - unread_count=unread_count, - favorites_count=favorites_count, - latest_count=latest_count, - total_count=total_count, - publications=publications, - page=resolved_page, - page_size=resolved_limit, - offset=resolved_offset, - snapshot=snapshot_cursor, + scholar_profile_id=selected_scholar_id, + limit=limit, + ) + unread_count = await publication_service.count_unread_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=selected_scholar_id, + ) + latest_count = await publication_service.count_latest_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=selected_scholar_id, + ) + total_count = await publication_service.count_for_user( + db_session, + user_id=current_user.id, + mode=publication_service.MODE_ALL, + scholar_profile_id=selected_scholar_id, + ) + return success_payload( + request, + data={ + "mode": resolved_mode, + "selected_scholar_profile_id": selected_scholar_id, + "unread_count": unread_count, + "latest_count": latest_count, + "new_count": latest_count, + "total_count": total_count, + "publications": [ + { + "publication_id": item.publication_id, + "scholar_profile_id": item.scholar_profile_id, + "scholar_label": item.scholar_label, + "title": item.title, + "year": item.year, + "citation_count": item.citation_count, + "venue_text": item.venue_text, + "pub_url": item.pub_url, + "is_read": item.is_read, + "first_seen_at": item.first_seen_at, + "is_new_in_latest_run": item.is_new_in_latest_run, + } + for item in publications + ], + }, ) - return success_payload(request, data=data) @router.post( @@ -420,8 +117,13 @@ async def mark_all_publications_read( db_session, user_id=current_user.id, ) - structured_log( - logger, "info", "api.publications.mark_all_read", user_id=current_user.id, updated_count=updated_count + logger.info( + "api.publications.mark_all_read", + extra={ + "event": "api.publications.mark_all_read", + "user_id": current_user.id, + "updated_count": updated_count, + }, ) return success_payload( request, @@ -442,19 +144,25 @@ async def mark_selected_publications_read( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - selection_pairs = sorted({(int(item.scholar_profile_id), int(item.publication_id)) for item in payload.selections}) + selection_pairs = sorted( + { + (int(item.scholar_profile_id), int(item.publication_id)) + for item in payload.selections + } + ) updated_count = await publication_service.mark_selected_as_read_for_user( db_session, user_id=current_user.id, selections=selection_pairs, ) - structured_log( - logger, - "info", + logger.info( "api.publications.mark_selected_read", - user_id=current_user.id, - requested_count=len(selection_pairs), - updated_count=updated_count, + extra={ + "event": "api.publications.mark_selected_read", + "user_id": current_user.id, + "requested_count": len(selection_pairs), + "updated_count": updated_count, + }, ) return success_payload( request, @@ -464,84 +172,3 @@ async def mark_selected_publications_read( "updated_count": updated_count, }, ) - - -@router.post( - "/{publication_id}/retry-pdf", - response_model=RetryPublicationPdfEnvelope, -) -async def retry_publication_pdf( - payload: RetryPublicationPdfRequest, - request: Request, - publication_id: int = Path(ge=1), - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - current, queued = await _retry_publication_state( - db_session, - current_user=current_user, - scholar_profile_id=payload.scholar_profile_id, - publication_id=publication_id, - ) - resolved_pdf = bool(current.pdf_url) - message = _retry_pdf_message( - queued=queued, - resolved_pdf=resolved_pdf, - pdf_status=current.pdf_status, - ) - structured_log( - logger, - "info", - "api.publications.retry_pdf", - user_id=current_user.id, - scholar_profile_id=payload.scholar_profile_id, - publication_id=publication_id, - queued=queued, - resolved_pdf=resolved_pdf, - pdf_status=current.pdf_status, - ) - return success_payload( - request, - data={ - "message": message, - "queued": queued, - "resolved_pdf": resolved_pdf, - "publication": _serialize_publication_item(current), - }, - ) - - -@router.post( - "/{publication_id}/favorite", - response_model=TogglePublicationFavoriteEnvelope, -) -async def toggle_publication_favorite( - payload: TogglePublicationFavoriteRequest, - request: Request, - publication_id: int = Path(ge=1), - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - current = await _favorite_publication_state( - db_session, - current_user=current_user, - scholar_profile_id=payload.scholar_profile_id, - publication_id=publication_id, - is_favorite=payload.is_favorite, - ) - structured_log( - logger, - "info", - "api.publications.favorite", - user_id=current_user.id, - scholar_profile_id=payload.scholar_profile_id, - publication_id=publication_id, - is_favorite=bool(payload.is_favorite), - ) - return success_payload( - request, - data={ - "message": _favorite_message(is_favorite=bool(payload.is_favorite)), - "publication": _serialize_publication_item(current), - }, - ) diff --git a/app/api/routers/run_manual.py b/app/api/routers/run_manual.py deleted file mode 100644 index cdb5e99..0000000 --- a/app/api/routers/run_manual.py +++ /dev/null @@ -1,243 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any, NoReturn - -from fastapi import Request -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.errors import ApiException -from app.api.responses import success_payload -from app.api.routers.run_serializers import manual_run_payload_from_run -from app.db.models import RunStatus, RunTriggerType -from app.logging_utils import structured_log -from app.services.ingestion import application as ingestion_service -from app.services.ingestion import safety as run_safety_service -from app.services.runs import application as run_service -from app.services.settings import application as user_settings_service -from app.settings import settings - -logger = logging.getLogger(__name__) - - -async def load_safety_state( - db_session: AsyncSession, - *, - user_id: int, -) -> dict[str, Any]: - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=user_id, - ) - previous_safety_state = run_safety_service.get_safety_event_context(user_settings) - if run_safety_service.clear_expired_cooldown(user_settings): - await db_session.commit() - await db_session.refresh(user_settings) - structured_log( - logger, - "info", - "api.runs.safety_cooldown_cleared", - user_id=user_id, - reason=previous_safety_state.get("cooldown_reason"), - cooldown_until=previous_safety_state.get("cooldown_until"), - ) - return run_safety_service.get_safety_state_payload(user_settings) - - -def raise_manual_runs_disabled(*, user_id: int, safety_state: dict[str, Any]) -> None: - structured_log( - logger, - "warning", - "api.runs.manual_blocked_policy", - user_id=user_id, - policy={"manual_run_allowed": False}, - safety_state=safety_state, - ) - raise ApiException( - status_code=403, - code="manual_runs_disabled", - message="Manual checks are disabled by server policy.", - details={ - "policy": {"manual_run_allowed": False}, - "safety_state": safety_state, - }, - ) - - -async def reused_manual_run_payload( - db_session: AsyncSession, - *, - request: Request, - user_id: int, - idempotency_key: str | None, - safety_state: dict[str, Any], -) -> dict[str, Any] | None: - if idempotency_key is None: - return None - previous_run = await run_service.get_manual_run_by_idempotency_key( - db_session, - user_id=user_id, - idempotency_key=idempotency_key, - ) - if previous_run is None: - return None - if previous_run.status in (RunStatus.RUNNING, RunStatus.RESOLVING): - raise ApiException( - status_code=409, - code="run_in_progress", - message="A run with this idempotency key is still in progress.", - details={"run_id": int(previous_run.id), "idempotency_key": idempotency_key}, - ) - return success_payload( - request, - data=manual_run_payload_from_run( - previous_run, - idempotency_key=idempotency_key, - reused_existing_run=True, - safety_state=safety_state, - ), - ) - - -async def run_ingestion_for_manual( - db_session: AsyncSession, - *, - ingest_service: ingestion_service.ScholarIngestionService, - user_id: int, - idempotency_key: str | None, -): - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=user_id, - ) - return await ingest_service.run_for_user( - db_session, - user_id=user_id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=user_settings.request_delay_seconds, - network_error_retries=settings.ingestion_network_error_retries, - retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, - max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, - page_size=settings.ingestion_page_size, - auto_queue_continuations=settings.ingestion_continuation_queue_enabled, - queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, - idempotency_key=idempotency_key, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - - -async def recover_integrity_error( - db_session: AsyncSession, - *, - request: Request, - user_id: int, - idempotency_key: str | None, - original_exc: IntegrityError, -) -> dict[str, Any]: - if idempotency_key is None: - structured_log( - logger, - "exception", - "api.runs.manual_integrity_error", - user_id=user_id, - ) - raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc - existing_run = await run_service.get_manual_run_by_idempotency_key( - db_session, - user_id=user_id, - idempotency_key=idempotency_key, - ) - if existing_run is None: - structured_log( - logger, - "exception", - "api.runs.manual_integrity_error", - user_id=user_id, - ) - raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc - if existing_run.status in (RunStatus.RUNNING, RunStatus.RESOLVING): - raise ApiException( - status_code=409, - code="run_in_progress", - message="A run with this idempotency key is still in progress.", - details={"run_id": int(existing_run.id), "idempotency_key": idempotency_key}, - ) from original_exc - return success_payload( - request, - data=manual_run_payload_from_run( - existing_run, - idempotency_key=idempotency_key, - reused_existing_run=True, - safety_state=await load_safety_state(db_session, user_id=user_id), - ), - ) - - -async def execute_manual_run( - db_session: AsyncSession, - *, - request: Request, - ingest_service: ingestion_service.ScholarIngestionService, - user_id: int, - idempotency_key: str | None, -): - try: - return await run_ingestion_for_manual( - db_session, - ingest_service=ingest_service, - user_id=user_id, - idempotency_key=idempotency_key, - ) - except ingestion_service.RunAlreadyInProgressError as exc: - await db_session.rollback() - raise ApiException( - status_code=409, - code="run_in_progress", - message="A run is already in progress for this account.", - ) from exc - except ingestion_service.RunBlockedBySafetyPolicyError as exc: - await db_session.rollback() - raise_manual_blocked_safety(exc=exc, user_id=user_id) - except IntegrityError as exc: - await db_session.rollback() - return await recover_integrity_error( - db_session, - request=request, - user_id=user_id, - idempotency_key=idempotency_key, - original_exc=exc, - ) - except Exception as exc: - await db_session.rollback() - raise_manual_failed(exc=exc, user_id=user_id) - - -def raise_manual_blocked_safety(*, exc, user_id: int) -> NoReturn: - structured_log( - logger, - "info", - "api.runs.manual_blocked_safety", - user_id=user_id, - reason=exc.safety_state.get("cooldown_reason"), - cooldown_until=exc.safety_state.get("cooldown_until"), - cooldown_remaining_seconds=exc.safety_state.get("cooldown_remaining_seconds"), - ) - raise ApiException( - status_code=429, - code=exc.code, - message=exc.message, - details={"safety_state": exc.safety_state}, - ) from exc - - -def raise_manual_failed(*, exc: Exception, user_id: int) -> NoReturn: - structured_log( - logger, - "exception", - "api.runs.manual_failed", - user_id=user_id, - ) - raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from exc diff --git a/app/api/routers/run_serializers.py b/app/api/routers/run_serializers.py deleted file mode 100644 index 53db3b5..0000000 --- a/app/api/routers/run_serializers.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import re -from typing import Any - -from app.api.errors import ApiException -from app.services.runs import application as run_service - -IDEMPOTENCY_HEADER = "Idempotency-Key" -IDEMPOTENCY_MAX_LENGTH = 128 -IDEMPOTENCY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") - - -def _int_value(value: Any, default: int = 0) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _str_value(value: Any) -> str | None: - if value is None: - return None - text = str(value).strip() - return text if text else None - - -def _bool_value(value: Any, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in {"1", "true", "yes", "on"}: - return True - if lowered in {"0", "false", "no", "off"}: - return False - return default - - -def normalize_idempotency_key(raw_value: str | None) -> str | None: - if raw_value is None: - return None - candidate = raw_value.strip() - if not candidate: - return None - if len(candidate) > IDEMPOTENCY_MAX_LENGTH or not IDEMPOTENCY_PATTERN.match(candidate): - raise ApiException( - status_code=400, - code="invalid_idempotency_key", - message=("Invalid Idempotency-Key. Use 8-128 characters from: A-Z a-z 0-9 . _ : -"), - ) - return candidate - - -def serialize_run(run) -> dict[str, Any]: - summary = run_service.extract_run_summary(run.error_log) - return { - "id": int(run.id), - "trigger_type": run.trigger_type.value, - "status": run.status.value, - "start_dt": run.start_dt, - "end_dt": run.end_dt, - "scholar_count": int(run.scholar_count or 0), - "new_publication_count": int(run.new_pub_count or 0), - "failed_count": int(summary["failed_count"]), - "partial_count": int(summary["partial_count"]), - } - - -def serialize_queue_item(item) -> dict[str, Any]: - return { - "id": int(item.id), - "scholar_profile_id": int(item.scholar_profile_id), - "scholar_label": item.scholar_label, - "status": item.status, - "reason": item.reason, - "dropped_reason": item.dropped_reason, - "attempt_count": int(item.attempt_count), - "resume_cstart": int(item.resume_cstart), - "next_attempt_dt": item.next_attempt_dt, - "updated_at": item.updated_at, - "last_error": item.last_error, - "last_run_id": item.last_run_id, - } - - -def _normalize_attempt_log(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in value: - if not isinstance(item, dict): - continue - normalized.append( - { - "attempt": _int_value(item.get("attempt"), 0), - "cstart": _int_value(item.get("cstart"), 0), - "state": _str_value(item.get("state")), - "state_reason": _str_value(item.get("state_reason")), - "status_code": (_int_value(item.get("status_code")) if item.get("status_code") is not None else None), - "fetch_error": _str_value(item.get("fetch_error")), - } - ) - return normalized - - -def _normalize_page_logs(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in value: - if not isinstance(item, dict): - continue - warning_codes = item.get("warning_codes") - normalized.append( - { - "page": _int_value(item.get("page"), 0), - "cstart": _int_value(item.get("cstart"), 0), - "state": _str_value(item.get("state")) or "unknown", - "state_reason": _str_value(item.get("state_reason")), - "status_code": (_int_value(item.get("status_code")) if item.get("status_code") is not None else None), - "publication_count": _int_value(item.get("publication_count"), 0), - "attempt_count": _int_value(item.get("attempt_count"), 0), - "has_show_more_button": _bool_value(item.get("has_show_more_button"), False), - "articles_range": _str_value(item.get("articles_range")), - "warning_codes": [str(code) for code in (warning_codes if isinstance(warning_codes, list) else [])], - } - ) - return normalized - - -def _normalize_debug(value: Any) -> dict[str, Any] | None: - if not isinstance(value, dict): - return None - marker_counts = value.get("marker_counts_nonzero") - warning_codes = value.get("warning_codes") - return { - "status_code": (_int_value(value.get("status_code")) if value.get("status_code") is not None else None), - "final_url": _str_value(value.get("final_url")), - "fetch_error": _str_value(value.get("fetch_error")), - "body_sha256": _str_value(value.get("body_sha256")), - "body_length": (_int_value(value.get("body_length")) if value.get("body_length") is not None else None), - "has_show_more_button": ( - _bool_value(value.get("has_show_more_button"), False) - if value.get("has_show_more_button") is not None - else None - ), - "articles_range": _str_value(value.get("articles_range")), - "state_reason": _str_value(value.get("state_reason")), - "warning_codes": [str(code) for code in (warning_codes if isinstance(warning_codes, list) else [])], - "marker_counts_nonzero": { - str(key): _int_value(count, 0) - for key, count in (marker_counts.items() if isinstance(marker_counts, dict) else []) - }, - "page_logs": _normalize_page_logs(value.get("page_logs")), - "attempt_log": _normalize_attempt_log(value.get("attempt_log")), - } - - -def normalize_scholar_result(value: Any) -> dict[str, Any]: - if not isinstance(value, dict): - return { - "scholar_profile_id": 0, - "scholar_id": "unknown", - "state": "unknown", - "state_reason": None, - "outcome": "failed", - "attempt_count": 0, - "publication_count": 0, - "start_cstart": 0, - "continuation_cstart": None, - "continuation_enqueued": False, - "continuation_cleared": False, - "warnings": [], - "error": None, - "debug": None, - } - warnings = value.get("warnings") - return { - "scholar_profile_id": _int_value(value.get("scholar_profile_id"), 0), - "scholar_id": _str_value(value.get("scholar_id")) or "unknown", - "state": _str_value(value.get("state")) or "unknown", - "state_reason": _str_value(value.get("state_reason")), - "outcome": _str_value(value.get("outcome")) or "failed", - "attempt_count": _int_value(value.get("attempt_count"), 0), - "publication_count": _int_value(value.get("publication_count"), 0), - "start_cstart": _int_value(value.get("start_cstart"), 0), - "continuation_cstart": ( - _int_value(value.get("continuation_cstart")) if value.get("continuation_cstart") is not None else None - ), - "continuation_enqueued": _bool_value(value.get("continuation_enqueued"), False), - "continuation_cleared": _bool_value(value.get("continuation_cleared"), False), - "warnings": [str(item) for item in (warnings if isinstance(warnings, list) else [])], - "error": _str_value(value.get("error")), - "debug": _normalize_debug(value.get("debug")), - } - - -def manual_run_payload_from_run( - run, - *, - idempotency_key: str | None, - reused_existing_run: bool, - safety_state: dict[str, Any], -) -> dict[str, Any]: - summary = run_service.extract_run_summary(run.error_log) - return { - "run_id": int(run.id), - "status": run.status.value, - "scholar_count": int(run.scholar_count or 0), - "succeeded_count": int(summary["succeeded_count"]), - "failed_count": int(summary["failed_count"]), - "partial_count": int(summary["partial_count"]), - "new_publication_count": int(run.new_pub_count or 0), - "reused_existing_run": reused_existing_run, - "idempotency_key": idempotency_key, - "safety_state": safety_state, - } - - -def manual_run_success_payload( - *, - run_summary, - idempotency_key: str | None, - safety_state: dict[str, Any], -) -> dict[str, Any]: - return { - "run_id": run_summary.crawl_run_id, - "status": run_summary.status.value, - "scholar_count": run_summary.scholar_count, - "succeeded_count": run_summary.succeeded_count, - "failed_count": run_summary.failed_count, - "partial_count": run_summary.partial_count, - "new_publication_count": run_summary.new_publication_count, - "reused_existing_run": False, - "idempotency_key": idempotency_key, - "safety_state": safety_state, - } diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index 87dfa0a..ae9d803 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -1,33 +1,16 @@ from __future__ import annotations -import asyncio import logging +import re from typing import Any from fastapi import APIRouter, Depends, Query, Request -from fastapi.responses import StreamingResponse from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload -from app.api.routers.run_manual import ( - load_safety_state, - raise_manual_blocked_safety, - raise_manual_failed, - raise_manual_runs_disabled, - recover_integrity_error, - reused_manual_run_payload, -) -from app.api.routers.run_serializers import ( - IDEMPOTENCY_HEADER, - normalize_idempotency_key, - normalize_scholar_result, - serialize_queue_item, - serialize_run, -) -from app.api.runtime_deps import get_ingestion_service from app.api.schemas import ( ManualRunEnvelope, QueueClearEnvelope, @@ -37,28 +20,282 @@ from app.api.schemas import ( RunsListEnvelope, ) from app.db.models import RunStatus, RunTriggerType, User -from app.db.session import get_db_session, get_session_factory -from app.logging_utils import structured_log -from app.services.ingestion import application as ingestion_service -from app.services.runs import application as run_service -from app.services.runs.events import event_generator -from app.services.settings import application as user_settings_service +from app.db.session import get_db_session +from app.services import ingestion as ingestion_service +from app.services import run_safety as run_safety_service +from app.services import runs as run_service +from app.services import user_settings as user_settings_service from app.settings import settings +from app.api.runtime_deps import get_ingestion_service logger = logging.getLogger(__name__) -_background_tasks: set[asyncio.Task[Any]] = set() - - -def _drop_finished_task(task: asyncio.Task[Any]) -> None: - _background_tasks.discard(task) - try: - task.result() - except Exception: - structured_log(logger, "exception", "runs.background_task_failed") - router = APIRouter(prefix="/runs", tags=["api-runs"]) -ACTIVE_RUN_STATUSES = {RunStatus.RUNNING, RunStatus.RESOLVING} + +IDEMPOTENCY_HEADER = "Idempotency-Key" +IDEMPOTENCY_MAX_LENGTH = 128 +IDEMPOTENCY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") + + +def _int_value(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _str_value(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text if text else None + + +def _bool_value(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def _normalize_idempotency_key(raw_value: str | None) -> str | None: + if raw_value is None: + return None + candidate = raw_value.strip() + if not candidate: + return None + if len(candidate) > IDEMPOTENCY_MAX_LENGTH or not IDEMPOTENCY_PATTERN.match(candidate): + raise ApiException( + status_code=400, + code="invalid_idempotency_key", + message=( + "Invalid Idempotency-Key. Use 8-128 characters from: " + "A-Z a-z 0-9 . _ : -" + ), + ) + return candidate + + +def _serialize_run(run) -> dict[str, Any]: + summary = run_service.extract_run_summary(run.error_log) + return { + "id": int(run.id), + "trigger_type": run.trigger_type.value, + "status": run.status.value, + "start_dt": run.start_dt, + "end_dt": run.end_dt, + "scholar_count": int(run.scholar_count or 0), + "new_publication_count": int(run.new_pub_count or 0), + "failed_count": int(summary["failed_count"]), + "partial_count": int(summary["partial_count"]), + } + + +def _serialize_queue_item(item) -> dict[str, Any]: + return { + "id": int(item.id), + "scholar_profile_id": int(item.scholar_profile_id), + "scholar_label": item.scholar_label, + "status": item.status, + "reason": item.reason, + "dropped_reason": item.dropped_reason, + "attempt_count": int(item.attempt_count), + "resume_cstart": int(item.resume_cstart), + "next_attempt_dt": item.next_attempt_dt, + "updated_at": item.updated_at, + "last_error": item.last_error, + "last_run_id": item.last_run_id, + } + + +def _normalize_attempt_log(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + normalized.append( + { + "attempt": _int_value(item.get("attempt"), 0), + "cstart": _int_value(item.get("cstart"), 0), + "state": _str_value(item.get("state")), + "state_reason": _str_value(item.get("state_reason")), + "status_code": ( + _int_value(item.get("status_code")) + if item.get("status_code") is not None + else None + ), + "fetch_error": _str_value(item.get("fetch_error")), + } + ) + return normalized + + +def _normalize_page_logs(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + warning_codes = item.get("warning_codes") + normalized.append( + { + "page": _int_value(item.get("page"), 0), + "cstart": _int_value(item.get("cstart"), 0), + "state": _str_value(item.get("state")) or "unknown", + "state_reason": _str_value(item.get("state_reason")), + "status_code": ( + _int_value(item.get("status_code")) + if item.get("status_code") is not None + else None + ), + "publication_count": _int_value(item.get("publication_count"), 0), + "attempt_count": _int_value(item.get("attempt_count"), 0), + "has_show_more_button": _bool_value(item.get("has_show_more_button"), False), + "articles_range": _str_value(item.get("articles_range")), + "warning_codes": [ + str(code) + for code in (warning_codes if isinstance(warning_codes, list) else []) + ], + } + ) + return normalized + + +def _normalize_debug(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + marker_counts = value.get("marker_counts_nonzero") + warning_codes = value.get("warning_codes") + return { + "status_code": ( + _int_value(value.get("status_code")) + if value.get("status_code") is not None + else None + ), + "final_url": _str_value(value.get("final_url")), + "fetch_error": _str_value(value.get("fetch_error")), + "body_sha256": _str_value(value.get("body_sha256")), + "body_length": ( + _int_value(value.get("body_length")) + if value.get("body_length") is not None + else None + ), + "has_show_more_button": ( + _bool_value(value.get("has_show_more_button"), False) + if value.get("has_show_more_button") is not None + else None + ), + "articles_range": _str_value(value.get("articles_range")), + "state_reason": _str_value(value.get("state_reason")), + "warning_codes": [ + str(code) + for code in (warning_codes if isinstance(warning_codes, list) else []) + ], + "marker_counts_nonzero": { + str(key): _int_value(count, 0) + for key, count in (marker_counts.items() if isinstance(marker_counts, dict) else []) + }, + "page_logs": _normalize_page_logs(value.get("page_logs")), + "attempt_log": _normalize_attempt_log(value.get("attempt_log")), + } + + +def _normalize_scholar_result(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return { + "scholar_profile_id": 0, + "scholar_id": "unknown", + "state": "unknown", + "state_reason": None, + "outcome": "failed", + "attempt_count": 0, + "publication_count": 0, + "start_cstart": 0, + "continuation_cstart": None, + "continuation_enqueued": False, + "continuation_cleared": False, + "warnings": [], + "error": None, + "debug": None, + } + warnings = value.get("warnings") + return { + "scholar_profile_id": _int_value(value.get("scholar_profile_id"), 0), + "scholar_id": _str_value(value.get("scholar_id")) or "unknown", + "state": _str_value(value.get("state")) or "unknown", + "state_reason": _str_value(value.get("state_reason")), + "outcome": _str_value(value.get("outcome")) or "failed", + "attempt_count": _int_value(value.get("attempt_count"), 0), + "publication_count": _int_value(value.get("publication_count"), 0), + "start_cstart": _int_value(value.get("start_cstart"), 0), + "continuation_cstart": ( + _int_value(value.get("continuation_cstart")) + if value.get("continuation_cstart") is not None + else None + ), + "continuation_enqueued": _bool_value(value.get("continuation_enqueued"), False), + "continuation_cleared": _bool_value(value.get("continuation_cleared"), False), + "warnings": [str(item) for item in (warnings if isinstance(warnings, list) else [])], + "error": _str_value(value.get("error")), + "debug": _normalize_debug(value.get("debug")), + } + + +def _manual_run_payload_from_run( + run, + *, + idempotency_key: str | None, + reused_existing_run: bool, + safety_state: dict[str, Any], +) -> dict[str, Any]: + summary = run_service.extract_run_summary(run.error_log) + return { + "run_id": int(run.id), + "status": run.status.value, + "scholar_count": int(run.scholar_count or 0), + "succeeded_count": int(summary["succeeded_count"]), + "failed_count": int(summary["failed_count"]), + "partial_count": int(summary["partial_count"]), + "new_publication_count": int(run.new_pub_count or 0), + "reused_existing_run": reused_existing_run, + "idempotency_key": idempotency_key, + "safety_state": safety_state, + } + + +async def _load_safety_state( + db_session: AsyncSession, + *, + user_id: int, +) -> dict[str, Any]: + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=user_id, + ) + previous_safety_state = run_safety_service.get_safety_event_context(user_settings) + if run_safety_service.clear_expired_cooldown(user_settings): + await db_session.commit() + await db_session.refresh(user_settings) + logger.info( + "api.runs.safety_cooldown_cleared", + extra={ + "event": "api.runs.safety_cooldown_cleared", + "user_id": user_id, + "reason": previous_safety_state.get("cooldown_reason"), + "cooldown_until": previous_safety_state.get("cooldown_until"), + "metric_name": "api_runs_safety_cooldown_cleared_total", + "metric_value": 1, + }, + ) + return run_safety_service.get_safety_state_payload(user_settings) @router.get( @@ -78,14 +315,14 @@ async def list_runs( limit=limit, failed_only=failed_only, ) - safety_state = await load_safety_state( + safety_state = await _load_safety_state( db_session, user_id=current_user.id, ) return success_payload( request, data={ - "runs": [serialize_run(run) for run in runs], + "runs": [_serialize_run(run) for run in runs], "safety_state": safety_state, }, ) @@ -116,137 +353,21 @@ async def get_run( scholar_results = error_log.get("scholar_results") if not isinstance(scholar_results, list): scholar_results = [] - safety_state = await load_safety_state( + safety_state = await _load_safety_state( db_session, user_id=current_user.id, ) return success_payload( request, data={ - "run": serialize_run(run), + "run": _serialize_run(run), "summary": run_service.extract_run_summary(error_log), - "scholar_results": [normalize_scholar_result(item) for item in scholar_results], + "scholar_results": [_normalize_scholar_result(item) for item in scholar_results], "safety_state": safety_state, }, ) -@router.post( - "/{run_id}/cancel", - response_model=RunDetailEnvelope, -) -async def cancel_run( - run_id: int, - request: Request, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - run = await run_service.get_run_for_user( - db_session, - user_id=current_user.id, - run_id=run_id, - ) - if run is None: - raise ApiException( - status_code=404, - code="run_not_found", - message="Run not found.", - ) - - if run.status in ACTIVE_RUN_STATUSES: - run.status = RunStatus.CANCELED - await db_session.commit() - await db_session.refresh(run) - else: - raise ApiException( - status_code=409, - code="run_not_cancelable", - message="Run is already terminal and cannot be canceled.", - details={"run_id": int(run.id), "status": run.status.value}, - ) - - error_log = run.error_log if isinstance(run.error_log, dict) else {} - scholar_results = error_log.get("scholar_results") - if not isinstance(scholar_results, list): - scholar_results = [] - - safety_state = await load_safety_state( - db_session, - user_id=current_user.id, - ) - - return success_payload( - request, - data={ - "run": serialize_run(run), - "summary": run_service.extract_run_summary(error_log), - "scholar_results": [normalize_scholar_result(item) for item in scholar_results], - "safety_state": safety_state, - }, - ) - - -async def _start_manual_run( - db_session: AsyncSession, - *, - current_user: User, - ingest_service: ingestion_service.ScholarIngestionService, - idempotency_key: str | None, -) -> tuple[Any, Any, list, dict]: - user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=current_user.id) - run, scholars, start_cstart_map = await ingest_service.initialize_run( - db_session, - user_id=current_user.id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=user_settings.request_delay_seconds, - network_error_retries=settings.ingestion_network_error_retries, - retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, - max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, - page_size=settings.ingestion_page_size, - idempotency_key=idempotency_key, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - return user_settings, run, scholars, start_cstart_map - - -def _spawn_background_execution( - ingest_service: ingestion_service.ScholarIngestionService, - *, - run: Any, - current_user: User, - scholars: list, - start_cstart_map: dict, - user_settings: Any, - idempotency_key: str | None, -) -> None: - from app.db.background_session import background_session - - task = asyncio.create_task( - ingest_service.execute_run( - session_factory=background_session, - run_id=run.id, - user_id=current_user.id, - scholars=scholars, - start_cstart_map=start_cstart_map, - request_delay_seconds=user_settings.request_delay_seconds, - network_error_retries=settings.ingestion_network_error_retries, - retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, - max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, - page_size=settings.ingestion_page_size, - auto_queue_continuations=settings.ingestion_continuation_queue_enabled, - queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - idempotency_key=idempotency_key, - ) - ) - _background_tasks.add(task) - task.add_done_callback(_drop_finished_task) - - @router.post( "/manual", response_model=ManualRunEnvelope, @@ -257,57 +378,83 @@ async def run_manual( current_user: User = Depends(get_api_current_user), ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service), ): - user_id = int(current_user.id) - safety_state = await load_safety_state(db_session, user_id=user_id) - if not settings.ingestion_manual_run_allowed: - raise_manual_runs_disabled(user_id=user_id, safety_state=safety_state) - - idempotency_key = normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER)) - reused_payload = await reused_manual_run_payload( + safety_state = await _load_safety_state( db_session, - request=request, - user_id=user_id, - idempotency_key=idempotency_key, - safety_state=safety_state, + user_id=current_user.id, ) - if reused_payload is not None: - return reused_payload - - try: - user_settings, run, scholars, start_cstart_map = await _start_manual_run( - db_session, - current_user=current_user, - ingest_service=ingest_service, - idempotency_key=idempotency_key, - ) - await db_session.commit() - _spawn_background_execution( - ingest_service, - run=run, - current_user=current_user, - scholars=scholars, - start_cstart_map=start_cstart_map, - user_settings=user_settings, - idempotency_key=idempotency_key, - ) - return success_payload( - request, - data={ - "run_id": int(run.id), - "status": run.status.value, - "scholar_count": int(run.scholar_count or 0), - "succeeded_count": 0, - "failed_count": 0, - "partial_count": 0, - "new_publication_count": 0, - "reused_existing_run": False, - "idempotency_key": idempotency_key, - "safety_state": await load_safety_state(db_session, user_id=user_id), + if not settings.ingestion_manual_run_allowed: + logger.warning( + "api.runs.manual_blocked_policy", + extra={ + "event": "api.runs.manual_blocked_policy", + "user_id": current_user.id, + "policy": {"manual_run_allowed": False}, + "safety_state": safety_state, + "metric_name": "api_runs_manual_blocked_policy_total", + "metric_value": 1, }, ) - except ingestion_service.RunBlockedBySafetyPolicyError as exc: - await db_session.rollback() - raise_manual_blocked_safety(exc=exc, user_id=user_id) + raise ApiException( + status_code=403, + code="manual_runs_disabled", + message="Manual checks are disabled by server policy.", + details={ + "policy": { + "manual_run_allowed": False, + }, + "safety_state": safety_state, + }, + ) + + idempotency_key = _normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER)) + if idempotency_key is not None: + previous_run = await run_service.get_manual_run_by_idempotency_key( + db_session, + user_id=current_user.id, + idempotency_key=idempotency_key, + ) + if previous_run is not None: + if previous_run.status == RunStatus.RUNNING: + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run with this idempotency key is still in progress.", + details={ + "run_id": int(previous_run.id), + "idempotency_key": idempotency_key, + }, + ) + return success_payload( + request, + data=_manual_run_payload_from_run( + previous_run, + idempotency_key=idempotency_key, + reused_existing_run=True, + safety_state=safety_state, + ), + ) + + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=current_user.id, + ) + try: + run_summary = await ingest_service.run_for_user( + db_session, + user_id=current_user.id, + trigger_type=RunTriggerType.MANUAL, + request_delay_seconds=user_settings.request_delay_seconds, + network_error_retries=settings.ingestion_network_error_retries, + retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, + max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, + page_size=settings.ingestion_page_size, + auto_queue_continuations=settings.ingestion_continuation_queue_enabled, + queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, + idempotency_key=idempotency_key, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) except ingestion_service.RunAlreadyInProgressError as exc: await db_session.rollback() raise ApiException( @@ -315,18 +462,105 @@ async def run_manual( code="run_in_progress", message="A run is already in progress for this account.", ) from exc + except ingestion_service.RunBlockedBySafetyPolicyError as exc: + await db_session.rollback() + logger.info( + "api.runs.manual_blocked_safety", + extra={ + "event": "api.runs.manual_blocked_safety", + "user_id": current_user.id, + "reason": exc.safety_state.get("cooldown_reason"), + "cooldown_until": exc.safety_state.get("cooldown_until"), + "cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"), + "metric_name": "api_runs_manual_blocked_safety_total", + "metric_value": 1, + }, + ) + raise ApiException( + status_code=429, + code=exc.code, + message=exc.message, + details={ + "safety_state": exc.safety_state, + }, + ) from exc except IntegrityError as exc: await db_session.rollback() - return await recover_integrity_error( - db_session, - request=request, - user_id=user_id, - idempotency_key=idempotency_key, - original_exc=exc, + if idempotency_key is not None: + existing_run = await run_service.get_manual_run_by_idempotency_key( + db_session, + user_id=current_user.id, + idempotency_key=idempotency_key, + ) + if existing_run is not None: + if existing_run.status == RunStatus.RUNNING: + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run with this idempotency key is still in progress.", + details={ + "run_id": int(existing_run.id), + "idempotency_key": idempotency_key, + }, + ) from exc + return success_payload( + request, + data=_manual_run_payload_from_run( + existing_run, + idempotency_key=idempotency_key, + reused_existing_run=True, + safety_state=await _load_safety_state( + db_session, + user_id=current_user.id, + ), + ), + ) + logger.exception( + "api.runs.manual_integrity_error", + extra={ + "event": "api.runs.manual_integrity_error", + "user_id": current_user.id, + }, ) + raise ApiException( + status_code=500, + code="manual_run_failed", + message="Manual run failed.", + ) from exc except Exception as exc: await db_session.rollback() - raise_manual_failed(exc=exc, user_id=user_id) + logger.exception( + "api.runs.manual_failed", + extra={ + "event": "api.runs.manual_failed", + "user_id": current_user.id, + }, + ) + raise ApiException( + status_code=500, + code="manual_run_failed", + message="Manual run failed.", + ) from exc + + current_safety_state = await _load_safety_state( + db_session, + user_id=current_user.id, + ) + return success_payload( + request, + data={ + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + "scholar_count": run_summary.scholar_count, + "succeeded_count": run_summary.succeeded_count, + "failed_count": run_summary.failed_count, + "partial_count": run_summary.partial_count, + "new_publication_count": run_summary.new_publication_count, + "reused_existing_run": False, + "idempotency_key": idempotency_key, + "safety_state": current_safety_state, + }, + ) @router.get( @@ -347,7 +581,7 @@ async def list_queue_items( return success_payload( request, data={ - "queue_items": [serialize_queue_item(item) for item in items], + "queue_items": [_serialize_queue_item(item) for item in items], }, ) @@ -383,7 +617,7 @@ async def retry_queue_item( ) return success_payload( request, - data=serialize_queue_item(queue_item), + data=_serialize_queue_item(queue_item), ) @@ -418,7 +652,7 @@ async def drop_queue_item( ) return success_payload( request, - data=serialize_queue_item(dropped), + data=_serialize_queue_item(dropped), ) @@ -460,24 +694,3 @@ async def clear_queue_item( "message": "Queue item cleared.", }, ) - - -@router.get("/{run_id}/stream") -async def stream_run_events( - run_id: int, - current_user: User = Depends(get_api_current_user), -): - session_factory = get_session_factory() - async with session_factory() as db_session: - run = await run_service.get_run_for_user( - db_session, - user_id=current_user.id, - run_id=run_id, - ) - if run is None: - raise ApiException( - status_code=404, - code="run_not_found", - message="Run not found.", - ) - return StreamingResponse(event_generator(run_id), media_type="text/event-stream") diff --git a/app/api/routers/scholar_helpers.py b/app/api/routers/scholar_helpers.py deleted file mode 100644 index 4023a3a..0000000 --- a/app/api/routers/scholar_helpers.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -from fastapi import UploadFile -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.errors import ApiException -from app.logging_utils import structured_log -from app.services.ingestion import queue as ingestion_queue_service -from app.services.scholar import rate_limit as scholar_rate_limit -from app.services.scholar.source import ScholarSource -from app.services.scholars import application as scholar_service -from app.services.scholars import search_hints as scholar_search_hints -from app.settings import settings - -logger = logging.getLogger(__name__) - -CREATE_METADATA_HYDRATION_TIMEOUT_SECONDS = 5.0 -INITIAL_SCHOLAR_SCRAPE_QUEUE_DELAY_SECONDS = 0 -INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON = "scholar_added_initial_scrape" - - -def needs_metadata_hydration(profile) -> bool: - if not profile.profile_image_url: - return True - return not (profile.display_name or "").strip() - - -def is_create_hydration_rate_limited() -> tuple[bool, float]: - remaining_seconds = scholar_rate_limit.remaining_scholar_slot_seconds( - min_interval_seconds=float(settings.ingestion_min_request_delay_seconds), - ) - return remaining_seconds > 0, remaining_seconds - - -def auto_enqueue_new_scholar_enabled() -> bool: - if not settings.scheduler_enabled: - return False - if not settings.ingestion_automation_allowed: - return False - return bool(settings.ingestion_continuation_queue_enabled) - - -async def enqueue_initial_scrape_job_for_scholar( - db_session: AsyncSession, - *, - profile, - user_id: int, -) -> bool: - if not auto_enqueue_new_scholar_enabled(): - return False - try: - await ingestion_queue_service.upsert_job( - db_session, - user_id=user_id, - scholar_profile_id=int(profile.id), - resume_cstart=0, - reason=INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON, - run_id=None, - delay_seconds=INITIAL_SCHOLAR_SCRAPE_QUEUE_DELAY_SECONDS, - ) - await db_session.commit() - except Exception: - await db_session.rollback() - structured_log( - logger, - "warning", - "api.scholars.initial_scrape_enqueue_failed", - user_id=user_id, - scholar_profile_id=profile.id, - ) - return False - - structured_log( - logger, - "info", - "api.scholars.initial_scrape_enqueued", - user_id=user_id, - scholar_profile_id=profile.id, - reason=INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON, - ) - return True - - -def uploaded_image_media_path(scholar_profile_id: int) -> str: - return f"/scholar-images/{scholar_profile_id}/upload" - - -def serialize_scholar(profile) -> dict[str, object]: - uploaded_image_url = None - if profile.profile_image_upload_path: - uploaded_image_url = uploaded_image_media_path(int(profile.id)) - - profile_image_url, profile_image_source = scholar_search_hints.resolve_profile_image( - profile, - uploaded_image_url=uploaded_image_url, - ) - - return { - "id": int(profile.id), - "scholar_id": profile.scholar_id, - "display_name": profile.display_name, - "profile_image_url": profile_image_url, - "profile_image_source": profile_image_source, - "is_enabled": bool(profile.is_enabled), - "baseline_completed": bool(profile.baseline_completed), - "last_run_dt": profile.last_run_dt, - "last_run_status": (profile.last_run_status.value if profile.last_run_status is not None else None), - } - - -async def hydrate_scholar_metadata_if_needed( - db_session: AsyncSession, - *, - profile, - source: ScholarSource, - user_id: int, -): - if not needs_metadata_hydration(profile): - return profile - - should_skip, remaining_seconds = is_create_hydration_rate_limited() - if should_skip: - structured_log( - logger, - "info", - "api.scholars.create_metadata_hydration_skipped", - reason="scholar_request_throttle_active", - user_id=user_id, - scholar_profile_id=profile.id, - retry_after_seconds=round(remaining_seconds, 3), - ) - return profile - - try: - return await asyncio.wait_for( - scholar_service.hydrate_profile_metadata( - db_session, - profile=profile, - source=source, - ), - timeout=CREATE_METADATA_HYDRATION_TIMEOUT_SECONDS, - ) - except TimeoutError: - structured_log( - logger, - "info", - "api.scholars.create_metadata_hydration_skipped", - reason="create_timeout", - user_id=user_id, - scholar_profile_id=profile.id, - ) - except Exception: - structured_log( - logger, - "warning", - "api.scholars.create_metadata_hydration_failed", - user_id=user_id, - scholar_profile_id=profile.id, - ) - return profile - - -def search_kwargs() -> dict[str, Any]: - return { - "network_error_retries": settings.ingestion_network_error_retries, - "retry_backoff_seconds": settings.ingestion_retry_backoff_seconds, - "search_enabled": settings.scholar_name_search_enabled, - "cache_ttl_seconds": settings.scholar_name_search_cache_ttl_seconds, - "blocked_cache_ttl_seconds": settings.scholar_name_search_blocked_cache_ttl_seconds, - "cache_max_entries": settings.scholar_name_search_cache_max_entries, - "min_interval_seconds": settings.scholar_name_search_min_interval_seconds, - "interval_jitter_seconds": settings.scholar_name_search_interval_jitter_seconds, - "cooldown_block_threshold": settings.scholar_name_search_cooldown_block_threshold, - "cooldown_seconds": settings.scholar_name_search_cooldown_seconds, - "retry_alert_threshold": settings.scholar_name_search_alert_retry_count_threshold, - "cooldown_rejection_alert_threshold": (settings.scholar_name_search_alert_cooldown_rejections_threshold), - } - - -def search_response_data(query: str, parsed) -> dict[str, object]: - return { - "query": query.strip(), - "state": parsed.state.value, - "state_reason": parsed.state_reason, - "action_hint": scholar_search_hints.scrape_state_hint( - state=parsed.state, - state_reason=parsed.state_reason, - ), - "candidates": [ - { - "scholar_id": item.scholar_id, - "display_name": item.display_name, - "affiliation": item.affiliation, - "email_domain": item.email_domain, - "cited_by_count": item.cited_by_count, - "interests": item.interests, - "profile_url": item.profile_url, - "profile_image_url": item.profile_image_url, - } - for item in parsed.candidates - ], - "warnings": parsed.warnings, - } - - -async def read_uploaded_image(image: UploadFile) -> bytes: - try: - return await image.read() - finally: - await image.close() - - -async def require_user_profile( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, -): - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=user_id, - scholar_profile_id=scholar_profile_id, - ) - if profile is None: - raise ApiException( - status_code=404, - code="scholar_not_found", - message="Scholar not found.", - ) - return profile diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 2a0c276..7a4171e 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -1,31 +1,19 @@ from __future__ import annotations +import asyncio import logging +import mimetypes from fastapi import APIRouter, Depends, File, Query, Request, UploadFile +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload -from app.api.routers.scholar_helpers import ( - enqueue_initial_scrape_job_for_scholar, - hydrate_scholar_metadata_if_needed, - read_uploaded_image, - require_user_profile, - search_kwargs, - search_response_data, - serialize_scholar, -) from app.api.runtime_deps import get_scholar_source from app.api.schemas import ( - DataExportEnvelope, - DataImportEnvelope, - DataImportRequest, MessageEnvelope, - ScholarBulkCountEnvelope, - ScholarBulkIdsRequest, - ScholarBulkToggleRequest, ScholarCreateRequest, ScholarEnvelope, ScholarImageUrlUpdateRequest, @@ -34,10 +22,8 @@ from app.api.schemas import ( ) from app.db.models import User from app.db.session import get_db_session -from app.logging_utils import structured_log -from app.services.portability import application as import_export_service -from app.services.scholar.source import ScholarSource -from app.services.scholars import application as scholar_service +from app.services import scholars as scholar_service +from app.services.scholar_source import ScholarSource from app.settings import settings logger = logging.getLogger(__name__) @@ -45,20 +31,33 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/scholars", tags=["api-scholars"]) -def _parse_ids_param(ids: str | None) -> list[int] | None: - if not ids: - return None - parts = [p.strip() for p in ids.split(",") if p.strip()] - if not parts: - return None - try: - return [int(p) for p in parts] - except ValueError as exc: - raise ApiException( - status_code=400, - code="invalid_ids_param", - message="The 'ids' parameter must be a comma-separated list of integers.", - ) from exc +def _uploaded_image_api_path(scholar_profile_id: int) -> str: + return f"/api/v1/scholars/{scholar_profile_id}/image/upload" + + +def _serialize_scholar(profile) -> dict[str, object]: + uploaded_image_url = None + if profile.profile_image_upload_path: + uploaded_image_url = _uploaded_image_api_path(int(profile.id)) + + profile_image_url, profile_image_source = scholar_service.resolve_profile_image( + profile, + uploaded_image_url=uploaded_image_url, + ) + + return { + "id": int(profile.id), + "scholar_id": profile.scholar_id, + "display_name": profile.display_name, + "profile_image_url": profile_image_url, + "profile_image_source": profile_image_source, + "is_enabled": bool(profile.is_enabled), + "baseline_completed": bool(profile.baseline_completed), + "last_run_dt": profile.last_run_dt, + "last_run_status": ( + profile.last_run_status.value if profile.last_run_status is not None else None + ), + } @router.get( @@ -77,130 +76,11 @@ async def list_scholars( return success_payload( request, data={ - "scholars": [serialize_scholar(profile) for profile in scholars], + "scholars": [_serialize_scholar(profile) for profile in scholars], }, ) -@router.get( - "/export", - response_model=DataExportEnvelope, -) -async def export_scholars_and_publications( - request: Request, - ids: str | None = Query(None, description="Comma-separated scholar profile IDs to export"), - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - scholar_profile_ids = _parse_ids_param(ids) - data = await import_export_service.export_user_data( - db_session, - user_id=current_user.id, - scholar_profile_ids=scholar_profile_ids, - ) - return success_payload(request, data=data) - - -@router.post( - "/bulk-delete", - response_model=ScholarBulkCountEnvelope, -) -async def bulk_delete_scholars( - payload: ScholarBulkIdsRequest, - request: Request, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - try: - deleted_count = await scholar_service.bulk_delete_scholars( - db_session, - user_id=current_user.id, - scholar_profile_ids=payload.scholar_profile_ids, - upload_dir=settings.scholar_image_upload_dir, - ) - except scholar_service.ScholarServiceError as exc: - raise ApiException( - status_code=409, - code="scholar_bulk_delete_failed", - message=str(exc), - ) from exc - structured_log( - logger, - "info", - "scholars.bulk_delete", - user_id=current_user.id, - requested_ids=payload.scholar_profile_ids, - deleted_count=deleted_count, - ) - return success_payload(request, data={"deleted_count": deleted_count, "updated_count": 0}) - - -@router.post( - "/bulk-toggle", - response_model=ScholarBulkCountEnvelope, -) -async def bulk_toggle_scholars( - payload: ScholarBulkToggleRequest, - request: Request, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - updated_count = await scholar_service.bulk_toggle_scholars( - db_session, - user_id=current_user.id, - scholar_profile_ids=payload.scholar_profile_ids, - is_enabled=payload.is_enabled, - ) - structured_log( - logger, - "info", - "scholars.bulk_toggle", - user_id=current_user.id, - requested_ids=payload.scholar_profile_ids, - is_enabled=payload.is_enabled, - updated_count=updated_count, - ) - return success_payload(request, data={"deleted_count": 0, "updated_count": updated_count}) - - -@router.post( - "/import", - response_model=DataImportEnvelope, -) -async def import_scholars_and_publications( - payload: DataImportRequest, - request: Request, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - if ( - payload.schema_version is not None - and int(payload.schema_version) != import_export_service.EXPORT_SCHEMA_VERSION - ): - raise ApiException( - status_code=400, - code="invalid_import_schema_version", - message=( - f"Import schema version is not supported. Expected {import_export_service.EXPORT_SCHEMA_VERSION}." - ), - ) - try: - result = await import_export_service.import_user_data( - db_session, - user_id=current_user.id, - scholars=[item.model_dump() for item in payload.scholars], - publications=[item.model_dump() for item in payload.publications], - ) - except import_export_service.ImportExportError as exc: - raise ApiException( - status_code=400, - code="invalid_import_payload", - message=str(exc), - ) from exc - structured_log(logger, "info", "api.scholars.imported", user_id=current_user.id, **result) - return success_payload(request, data=result) - - @router.post( "", response_model=ScholarEnvelope, @@ -227,22 +107,37 @@ async def create_scholar( code="invalid_scholar", message=str(exc), ) from exc - structured_log(logger, "info", "api.scholars.created", user_id=current_user.id, scholar_profile_id=created.id) - await enqueue_initial_scrape_job_for_scholar( - db_session, - profile=created, - user_id=current_user.id, - ) - created = await hydrate_scholar_metadata_if_needed( - db_session, - profile=created, - source=source, - user_id=current_user.id, + logger.info( + "api.scholars.created", + extra={ + "event": "api.scholars.created", + "user_id": current_user.id, + "scholar_profile_id": created.id, + }, ) + try: + if not created.profile_image_url or not (created.display_name or "").strip(): + created = await asyncio.wait_for( + scholar_service.hydrate_profile_metadata( + db_session, + profile=created, + source=source, + ), + timeout=5.0, + ) + except Exception: + logger.warning( + "api.scholars.create_metadata_hydration_failed", + extra={ + "event": "api.scholars.create_metadata_hydration_failed", + "user_id": current_user.id, + "scholar_profile_id": created.id, + }, + ) return success_payload( request, - data=serialize_scholar(created), + data=_serialize_scholar(created), ) @@ -264,7 +159,20 @@ async def search_scholars( db_session=db_session, query=query, limit=limit, - **search_kwargs(), + network_error_retries=settings.ingestion_network_error_retries, + retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, + search_enabled=settings.scholar_name_search_enabled, + cache_ttl_seconds=settings.scholar_name_search_cache_ttl_seconds, + blocked_cache_ttl_seconds=settings.scholar_name_search_blocked_cache_ttl_seconds, + cache_max_entries=settings.scholar_name_search_cache_max_entries, + min_interval_seconds=settings.scholar_name_search_min_interval_seconds, + interval_jitter_seconds=settings.scholar_name_search_interval_jitter_seconds, + cooldown_block_threshold=settings.scholar_name_search_cooldown_block_threshold, + cooldown_seconds=settings.scholar_name_search_cooldown_seconds, + retry_alert_threshold=settings.scholar_name_search_alert_retry_count_threshold, + cooldown_rejection_alert_threshold=( + settings.scholar_name_search_alert_cooldown_rejections_threshold + ), ) except scholar_service.ScholarServiceError as exc: raise ApiException( @@ -273,18 +181,41 @@ async def search_scholars( message=str(exc), ) from exc - structured_log( - logger, - "info", + logger.info( "api.scholars.search.completed", - user_id=current_user.id, - query=query.strip(), - candidate_count=len(parsed.candidates), - state=parsed.state.value, + extra={ + "event": "api.scholars.search.completed", + "user_id": current_user.id, + "query": query.strip(), + "candidate_count": len(parsed.candidates), + "state": parsed.state.value, + }, ) return success_payload( request, - data=search_response_data(query, parsed), + data={ + "query": query.strip(), + "state": parsed.state.value, + "state_reason": parsed.state_reason, + "action_hint": scholar_service.scrape_state_hint( + state=parsed.state, + state_reason=parsed.state_reason, + ), + "candidates": [ + { + "scholar_id": item.scholar_id, + "display_name": item.display_name, + "affiliation": item.affiliation, + "email_domain": item.email_domain, + "cited_by_count": item.cited_by_count, + "interests": item.interests, + "profile_url": item.profile_url, + "profile_image_url": item.profile_image_url, + } + for item in parsed.candidates + ], + "warnings": parsed.warnings, + }, ) @@ -310,17 +241,18 @@ async def toggle_scholar( message="Scholar not found.", ) updated = await scholar_service.toggle_scholar_enabled(db_session, profile=profile) - structured_log( - logger, - "info", + logger.info( "api.scholars.toggled", - user_id=current_user.id, - scholar_profile_id=updated.id, - is_enabled=updated.is_enabled, + extra={ + "event": "api.scholars.toggled", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + "is_enabled": updated.is_enabled, + }, ) return success_payload( request, - data=serialize_scholar(updated), + data=_serialize_scholar(updated), ) @@ -345,20 +277,18 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - try: - await scholar_service.delete_scholar( - db_session, - profile=profile, - upload_dir=settings.scholar_image_upload_dir, - ) - except scholar_service.ScholarServiceError as exc: - raise ApiException( - status_code=409, - code="scholar_delete_failed", - message=str(exc), - ) from exc - structured_log( - logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id + await scholar_service.delete_scholar( + db_session, + profile=profile, + upload_dir=settings.scholar_image_upload_dir, + ) + logger.info( + "api.scholars.deleted", + extra={ + "event": "api.scholars.deleted", + "user_id": current_user.id, + "scholar_profile_id": scholar_profile_id, + }, ) return success_payload( request, @@ -403,12 +333,17 @@ async def update_scholar_image_url( message=str(exc), ) from exc - structured_log( - logger, "info", "api.scholars.image_url_updated", user_id=current_user.id, scholar_profile_id=updated.id + logger.info( + "api.scholars.image_url_updated", + extra={ + "event": "api.scholars.image_url_updated", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + }, ) return success_payload( request, - data=serialize_scholar(updated), + data=_serialize_scholar(updated), ) @@ -423,14 +358,20 @@ async def upload_scholar_image( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - profile = await require_user_profile( + profile = await scholar_service.get_user_scholar_by_id( db_session, user_id=current_user.id, scholar_profile_id=scholar_profile_id, ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) - image_bytes = await read_uploaded_image(image) try: + image_bytes = await image.read() updated = await scholar_service.set_profile_image_upload( db_session, profile=profile, @@ -445,20 +386,22 @@ async def upload_scholar_image( code="invalid_scholar_image", message=str(exc), ) from exc + finally: + await image.close() - image_size = len(image_bytes) - structured_log( - logger, - "info", + logger.info( "api.scholars.image_uploaded", - user_id=current_user.id, - scholar_profile_id=updated.id, - content_type=image.content_type, - size_bytes=image_size, + extra={ + "event": "api.scholars.image_uploaded", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + "content_type": image.content_type, + "size_bytes": len(image_bytes), + }, ) return success_payload( request, - data=serialize_scholar(updated), + data=_serialize_scholar(updated), ) @@ -489,8 +432,64 @@ async def clear_scholar_image_customization( profile=profile, upload_dir=settings.scholar_image_upload_dir, ) - structured_log(logger, "info", "api.scholars.image_cleared", user_id=current_user.id, scholar_profile_id=updated.id) + logger.info( + "api.scholars.image_customization_cleared", + extra={ + "event": "api.scholars.image_customization_cleared", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + }, + ) return success_payload( request, - data=serialize_scholar(updated), + data=_serialize_scholar(updated), ) + + +@router.get( + "/{scholar_profile_id}/image/upload", +) +async def get_uploaded_scholar_image( + scholar_profile_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + if not profile.profile_image_upload_path: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + try: + image_path = scholar_service.resolve_upload_file_path( + upload_dir=settings.scholar_image_upload_dir, + relative_path=profile.profile_image_upload_path, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) from exc + + if not image_path.exists() or not image_path.is_file(): + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" + return FileResponse(path=image_path, media_type=media_type) diff --git a/app/api/routers/settings.py b/app/api/routers/settings.py index a3cc88f..f4b36a5 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -11,9 +11,8 @@ from app.api.responses import success_payload from app.api.schemas import SettingsEnvelope, SettingsUpdateRequest from app.db.models import User from app.db.session import get_db_session -from app.logging_utils import structured_log -from app.services.ingestion import safety as run_safety_service -from app.services.settings import application as user_settings_service +from app.services import run_safety as run_safety_service +from app.services import user_settings as user_settings_service from app.settings import settings as settings_module logger = logging.getLogger(__name__) @@ -22,7 +21,12 @@ router = APIRouter(prefix="/settings", tags=["api-settings"]) def _serialize_settings(user_settings) -> dict[str, object]: - min_run_interval_minutes, min_request_delay_seconds = _minimum_policy() + min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum( + settings_module.ingestion_min_run_interval_minutes + ) + min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum( + settings_module.ingestion_min_request_delay_seconds + ) return { "auto_run_enabled": bool(user_settings.auto_run_enabled) and settings_module.ingestion_automation_allowed, "run_interval_minutes": int(user_settings.run_interval_minutes), @@ -39,65 +43,9 @@ def _serialize_settings(user_settings) -> dict[str, object]: "cooldown_network_seconds": max(60, int(settings_module.ingestion_safety_cooldown_network_seconds)), }, "safety_state": run_safety_service.get_safety_state_payload(user_settings), - "openalex_api_key": user_settings.openalex_api_key, - "crossref_api_token": user_settings.crossref_api_token, - "crossref_api_mailto": user_settings.crossref_api_mailto, } -def _minimum_policy() -> tuple[int, int]: - min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum( - settings_module.ingestion_min_run_interval_minutes - ) - min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum( - settings_module.ingestion_min_request_delay_seconds - ) - return min_run_interval_minutes, min_request_delay_seconds - - -def _parse_settings_payload(payload: SettingsUpdateRequest, user_settings) -> tuple[int, int, list[str]]: - min_run_interval_minutes, min_request_delay_seconds = _minimum_policy() - parsed_interval = user_settings_service.parse_run_interval_minutes( - str(payload.run_interval_minutes), - minimum=min_run_interval_minutes, - ) - parsed_delay = user_settings_service.parse_request_delay_seconds( - str(payload.request_delay_seconds), - minimum=min_request_delay_seconds, - ) - parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages( - payload.nav_visible_pages - if payload.nav_visible_pages is not None - else list(user_settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES) - ) - return parsed_interval, parsed_delay, parsed_nav_visible_pages - - -async def _clear_expired_cooldown_with_log( - db_session: AsyncSession, - *, - user_id: int, - user_settings, -) -> None: - previous_safety_state = run_safety_service.get_safety_event_context(user_settings) - if not run_safety_service.clear_expired_cooldown(user_settings): - return - await db_session.commit() - await db_session.refresh(user_settings) - structured_log( - logger, - "info", - "api.settings.safety_cooldown_cleared", - user_id=user_id, - reason=previous_safety_state.get("cooldown_reason"), - cooldown_until=previous_safety_state.get("cooldown_until"), - ) - - -def _effective_auto_run_enabled(payload: SettingsUpdateRequest) -> bool: - return bool(payload.auto_run_enabled) and settings_module.ingestion_automation_allowed - - @router.get( "", response_model=SettingsEnvelope, @@ -111,11 +59,21 @@ async def get_settings( db_session, user_id=current_user.id, ) - await _clear_expired_cooldown_with_log( - db_session, - user_id=current_user.id, - user_settings=user_settings, - ) + previous_safety_state = run_safety_service.get_safety_event_context(user_settings) + if run_safety_service.clear_expired_cooldown(user_settings): + await db_session.commit() + await db_session.refresh(user_settings) + logger.info( + "api.settings.safety_cooldown_cleared", + extra={ + "event": "api.settings.safety_cooldown_cleared", + "user_id": current_user.id, + "reason": previous_safety_state.get("cooldown_reason"), + "cooldown_until": previous_safety_state.get("cooldown_until"), + "metric_name": "api_settings_safety_cooldown_cleared_total", + "metric_value": 1, + }, + ) return success_payload( request, data=_serialize_settings(user_settings), @@ -137,10 +95,26 @@ async def update_settings( user_id=current_user.id, ) + min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum( + settings_module.ingestion_min_run_interval_minutes + ) + min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum( + settings_module.ingestion_min_request_delay_seconds + ) + try: - parsed_interval, parsed_delay, parsed_nav_visible_pages = _parse_settings_payload( - payload, - user_settings, + parsed_interval = user_settings_service.parse_run_interval_minutes( + str(payload.run_interval_minutes), + minimum=min_run_interval_minutes, + ) + parsed_delay = user_settings_service.parse_request_delay_seconds( + str(payload.request_delay_seconds), + minimum=min_request_delay_seconds, + ) + parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages( + payload.nav_visible_pages + if payload.nav_visible_pages is not None + else list(user_settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES) ) except user_settings_service.UserSettingsServiceError as exc: raise ApiException( @@ -149,32 +123,43 @@ async def update_settings( message=str(exc), ) from exc + effective_auto_run_enabled = ( + bool(payload.auto_run_enabled) and settings_module.ingestion_automation_allowed + ) + updated = await user_settings_service.update_settings( db_session, settings=user_settings, - auto_run_enabled=_effective_auto_run_enabled(payload), + auto_run_enabled=effective_auto_run_enabled, run_interval_minutes=parsed_interval, request_delay_seconds=parsed_delay, nav_visible_pages=parsed_nav_visible_pages, - openalex_api_key=payload.openalex_api_key, - crossref_api_token=payload.crossref_api_token, - crossref_api_mailto=payload.crossref_api_mailto, ) - await _clear_expired_cooldown_with_log( - db_session, - user_id=current_user.id, - user_settings=updated, - ) - structured_log( - logger, - "info", + previous_safety_state = run_safety_service.get_safety_event_context(updated) + if run_safety_service.clear_expired_cooldown(updated): + await db_session.commit() + await db_session.refresh(updated) + logger.info( + "api.settings.safety_cooldown_cleared", + extra={ + "event": "api.settings.safety_cooldown_cleared", + "user_id": current_user.id, + "reason": previous_safety_state.get("cooldown_reason"), + "cooldown_until": previous_safety_state.get("cooldown_until"), + "metric_name": "api_settings_safety_cooldown_cleared_total", + "metric_value": 1, + }, + ) + logger.info( "api.settings.updated", - user_id=current_user.id, - auto_run_enabled=updated.auto_run_enabled, - run_interval_minutes=updated.run_interval_minutes, - request_delay_seconds=updated.request_delay_seconds, - nav_visible_pages=updated.nav_visible_pages, - openalex_api_key="SET" if updated.openalex_api_key else "UNSET", + extra={ + "event": "api.settings.updated", + "user_id": current_user.id, + "auto_run_enabled": updated.auto_run_enabled, + "run_interval_minutes": updated.run_interval_minutes, + "request_delay_seconds": updated.request_delay_seconds, + "nav_visible_pages": updated.nav_visible_pages, + }, ) return success_payload( request, diff --git a/app/api/runtime_deps.py b/app/api/runtime_deps.py index 5a6e9de..167e2ef 100644 --- a/app/api/runtime_deps.py +++ b/app/api/runtime_deps.py @@ -2,8 +2,8 @@ from __future__ import annotations from fastapi import Depends -from app.services.ingestion import application as ingestion_service -from app.services.scholar import source as scholar_source_service +from app.services import ingestion as ingestion_service +from app.services import scholar_source as scholar_source_service def get_scholar_source() -> scholar_source_service.ScholarSource: diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 0000000..f2f2b0a --- /dev/null +++ b/app/api/schemas.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ApiMeta(BaseModel): + request_id: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ApiErrorData(BaseModel): + code: str + message: str + details: Any | None = None + + model_config = ConfigDict(extra="forbid") + + +class ApiErrorEnvelope(BaseModel): + error: ApiErrorData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class MessageData(BaseModel): + message: str + + model_config = ConfigDict(extra="forbid") + + +class MessageEnvelope(BaseModel): + data: MessageData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class SessionUserData(BaseModel): + id: int + email: str + is_admin: bool + is_active: bool + + model_config = ConfigDict(extra="forbid") + + +class AuthMeData(BaseModel): + authenticated: bool + csrf_token: str + user: SessionUserData + + model_config = ConfigDict(extra="forbid") + + +class AuthMeEnvelope(BaseModel): + data: AuthMeData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class CsrfBootstrapData(BaseModel): + csrf_token: str + authenticated: bool + + model_config = ConfigDict(extra="forbid") + + +class CsrfBootstrapEnvelope(BaseModel): + data: CsrfBootstrapData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class LoginRequest(BaseModel): + email: str + password: str + + model_config = ConfigDict(extra="forbid") + + +class LoginData(BaseModel): + authenticated: bool + csrf_token: str + user: SessionUserData + + model_config = ConfigDict(extra="forbid") + + +class LoginEnvelope(BaseModel): + data: LoginData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + confirm_password: str + + model_config = ConfigDict(extra="forbid") + + +class ScholarItemData(BaseModel): + id: int + scholar_id: str + display_name: str | None + profile_image_url: str | None + profile_image_source: str + is_enabled: bool + baseline_completed: bool + last_run_dt: datetime | None + last_run_status: str | None + + model_config = ConfigDict(extra="forbid") + + +class ScholarsListData(BaseModel): + scholars: list[ScholarItemData] + + model_config = ConfigDict(extra="forbid") + + +class ScholarsListEnvelope(BaseModel): + data: ScholarsListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarCreateRequest(BaseModel): + scholar_id: str + profile_image_url: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ScholarEnvelope(BaseModel): + data: ScholarItemData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchCandidateData(BaseModel): + scholar_id: str + display_name: str + affiliation: str | None + email_domain: str | None + cited_by_count: int | None + interests: list[str] = Field(default_factory=list) + profile_url: str + profile_image_url: str | None + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchData(BaseModel): + query: str + state: str + state_reason: str + action_hint: str | None = None + candidates: list[ScholarSearchCandidateData] + warnings: list[str] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchEnvelope(BaseModel): + data: ScholarSearchData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarImageUrlUpdateRequest(BaseModel): + image_url: str + + model_config = ConfigDict(extra="forbid") + + +class RunListItemData(BaseModel): + id: int + trigger_type: str + status: str + start_dt: datetime + end_dt: datetime | None + scholar_count: int + new_publication_count: int + failed_count: int + partial_count: int + + model_config = ConfigDict(extra="forbid") + + +class RunsListData(BaseModel): + runs: list[RunListItemData] + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class RunsListEnvelope(BaseModel): + data: RunsListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class RunSummaryData(BaseModel): + succeeded_count: int + failed_count: int + partial_count: int + failed_state_counts: dict[str, int] = Field(default_factory=dict) + failed_reason_counts: dict[str, int] = Field(default_factory=dict) + scrape_failure_counts: dict[str, int] = Field(default_factory=dict) + retry_counts: dict[str, int] = Field(default_factory=dict) + alert_thresholds: dict[str, int] = Field(default_factory=dict) + alert_flags: dict[str, bool] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +class ScrapeSafetyCountersData(BaseModel): + consecutive_blocked_runs: int = 0 + consecutive_network_runs: int = 0 + cooldown_entry_count: int = 0 + blocked_start_count: int = 0 + last_blocked_failure_count: int = 0 + last_network_failure_count: int = 0 + last_evaluated_run_id: int | None = None + + model_config = ConfigDict(extra="forbid") + + +class ScrapeSafetyStateData(BaseModel): + cooldown_active: bool + cooldown_reason: str | None = None + cooldown_reason_label: str | None = None + cooldown_until: datetime | None = None + cooldown_remaining_seconds: int = 0 + recommended_action: str | None = None + counters: ScrapeSafetyCountersData = Field(default_factory=ScrapeSafetyCountersData) + + model_config = ConfigDict(extra="forbid") + + +class RunAttemptLogData(BaseModel): + attempt: int + cstart: int + state: str | None = None + state_reason: str | None = None + status_code: int | None = None + fetch_error: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class RunPageLogData(BaseModel): + page: int + cstart: int + state: str + state_reason: str | None = None + status_code: int | None = None + publication_count: int = 0 + attempt_count: int = 0 + has_show_more_button: bool = False + articles_range: str | None = None + warning_codes: list[str] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class RunDebugData(BaseModel): + status_code: int | None = None + final_url: str | None = None + fetch_error: str | None = None + body_sha256: str | None = None + body_length: int | None = None + has_show_more_button: bool | None = None + articles_range: str | None = None + state_reason: str | None = None + warning_codes: list[str] = Field(default_factory=list) + marker_counts_nonzero: dict[str, int] = Field(default_factory=dict) + page_logs: list[RunPageLogData] = Field(default_factory=list) + attempt_log: list[RunAttemptLogData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class RunScholarResultData(BaseModel): + scholar_profile_id: int + scholar_id: str + state: str + state_reason: str | None = None + outcome: str + attempt_count: int = 0 + publication_count: int = 0 + start_cstart: int = 0 + continuation_cstart: int | None = None + continuation_enqueued: bool = False + continuation_cleared: bool = False + warnings: list[str] = Field(default_factory=list) + error: str | None = None + debug: RunDebugData | None = None + + model_config = ConfigDict(extra="forbid") + + +class RunDetailData(BaseModel): + run: RunListItemData + summary: RunSummaryData + scholar_results: list[RunScholarResultData] + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class RunDetailEnvelope(BaseModel): + data: RunDetailData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ManualRunData(BaseModel): + run_id: int + status: str + scholar_count: int + succeeded_count: int + failed_count: int + partial_count: int + new_publication_count: int + reused_existing_run: bool + idempotency_key: str | None = None + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class ManualRunEnvelope(BaseModel): + data: ManualRunData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class QueueItemData(BaseModel): + id: int + scholar_profile_id: int + scholar_label: str + status: str + reason: str + dropped_reason: str | None + attempt_count: int + resume_cstart: int + next_attempt_dt: datetime | None + updated_at: datetime + last_error: str | None + last_run_id: int | None + + model_config = ConfigDict(extra="forbid") + + +class QueueListData(BaseModel): + queue_items: list[QueueItemData] + + model_config = ConfigDict(extra="forbid") + + +class QueueListEnvelope(BaseModel): + data: QueueListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class QueueItemEnvelope(BaseModel): + data: QueueItemData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class QueueClearData(BaseModel): + queue_item_id: int + previous_status: str + status: str + message: str + + model_config = ConfigDict(extra="forbid") + + +class QueueClearEnvelope(BaseModel): + data: QueueClearData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminUserData(BaseModel): + id: int + email: str + is_active: bool + is_admin: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(extra="forbid") + + +class AdminUsersListData(BaseModel): + users: list[AdminUserData] + + model_config = ConfigDict(extra="forbid") + + +class AdminUsersListEnvelope(BaseModel): + data: AdminUsersListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminUserCreateRequest(BaseModel): + email: str + password: str + is_admin: bool = False + + model_config = ConfigDict(extra="forbid") + + +class AdminUserActiveUpdateRequest(BaseModel): + is_active: bool + + model_config = ConfigDict(extra="forbid") + + +class AdminUserEnvelope(BaseModel): + data: AdminUserData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminResetPasswordRequest(BaseModel): + new_password: str + + model_config = ConfigDict(extra="forbid") + + +class SettingsPolicyData(BaseModel): + min_run_interval_minutes: int + min_request_delay_seconds: int + automation_allowed: bool + manual_run_allowed: bool + blocked_failure_threshold: int + network_failure_threshold: int + cooldown_blocked_seconds: int + cooldown_network_seconds: int + + model_config = ConfigDict(extra="forbid") + + +class SettingsData(BaseModel): + auto_run_enabled: bool + run_interval_minutes: int + request_delay_seconds: int + nav_visible_pages: list[str] + policy: SettingsPolicyData + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class SettingsEnvelope(BaseModel): + data: SettingsData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class SettingsUpdateRequest(BaseModel): + auto_run_enabled: bool + run_interval_minutes: int + request_delay_seconds: int + nav_visible_pages: list[str] | None = None + + model_config = ConfigDict(extra="forbid") + + +class PublicationItemData(BaseModel): + publication_id: int + scholar_profile_id: int + scholar_label: str + title: str + year: int | None + citation_count: int + venue_text: str | None + pub_url: str | None + is_read: bool + first_seen_at: datetime + is_new_in_latest_run: bool + + model_config = ConfigDict(extra="forbid") + + +class PublicationsListData(BaseModel): + mode: str + selected_scholar_profile_id: int | None + unread_count: int + latest_count: int + new_count: int + total_count: int + publications: list[PublicationItemData] + + model_config = ConfigDict(extra="forbid") + + +class PublicationsListEnvelope(BaseModel): + data: PublicationsListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class MarkAllReadData(BaseModel): + message: str + updated_count: int + + model_config = ConfigDict(extra="forbid") + + +class MarkAllReadEnvelope(BaseModel): + data: MarkAllReadData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class PublicationSelectionItem(BaseModel): + scholar_profile_id: int + publication_id: int + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadRequest(BaseModel): + selections: list[PublicationSelectionItem] = Field(min_length=1, max_length=500) + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadData(BaseModel): + message: str + requested_count: int + updated_count: int + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadEnvelope(BaseModel): + data: MarkSelectedReadData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py deleted file mode 100644 index 019d86a..0000000 --- a/app/api/schemas/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from app.api.schemas.admin import * # noqa: F403 -from app.api.schemas.auth import * # noqa: F403 -from app.api.schemas.common import * # noqa: F403 -from app.api.schemas.publications import * # noqa: F403 -from app.api.schemas.runs import * # noqa: F403 -from app.api.schemas.scholars import * # noqa: F403 -from app.api.schemas.settings import * # noqa: F403 diff --git a/app/api/schemas/admin.py b/app/api/schemas/admin.py deleted file mode 100644 index 932f93c..0000000 --- a/app/api/schemas/admin.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from app.api.schemas.common import ApiMeta -from app.api.schemas.publications import DisplayIdentifierData - - -class AdminUserData(BaseModel): - id: int - email: str - is_active: bool - is_admin: bool - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(extra="forbid") - - -class AdminUsersListData(BaseModel): - users: list[AdminUserData] - - model_config = ConfigDict(extra="forbid") - - -class AdminUsersListEnvelope(BaseModel): - data: AdminUsersListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminUserCreateRequest(BaseModel): - email: str = Field(max_length=254) - password: str = Field(min_length=8, max_length=128) - is_admin: bool = False - - model_config = ConfigDict(extra="forbid") - - -class AdminUserActiveUpdateRequest(BaseModel): - is_active: bool - - model_config = ConfigDict(extra="forbid") - - -class AdminUserEnvelope(BaseModel): - data: AdminUserData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminResetPasswordRequest(BaseModel): - new_password: str = Field(min_length=8, max_length=128) - - model_config = ConfigDict(extra="forbid") - - -class AdminScholarHttpSettingsData(BaseModel): - user_agent: str - rotate_user_agent: bool - accept_language: str - cookie: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class AdminScholarHttpSettingsEnvelope(BaseModel): - data: AdminScholarHttpSettingsData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminScholarHttpSettingsUpdateRequest(BaseModel): - user_agent: str - rotate_user_agent: bool - accept_language: str - cookie: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class AdminDbIntegrityCheckData(BaseModel): - name: str - count: int - severity: str - message: str - - model_config = ConfigDict(extra="forbid") - - -class AdminDbIntegrityData(BaseModel): - status: str - checked_at: datetime - failures: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - checks: list[AdminDbIntegrityCheckData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class AdminDbIntegrityEnvelope(BaseModel): - data: AdminDbIntegrityData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminDbRepairJobData(BaseModel): - id: int - job_name: str - requested_by: str | None - dry_run: bool - status: str - scope: dict[str, Any] = Field(default_factory=dict) - summary: dict[str, Any] = Field(default_factory=dict) - error_text: str | None - started_at: datetime | None - finished_at: datetime | None - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(extra="forbid") - - -class AdminDbRepairJobsData(BaseModel): - jobs: list[AdminDbRepairJobData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class AdminDbRepairJobsEnvelope(BaseModel): - data: AdminDbRepairJobsData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueItemData(BaseModel): - publication_id: int - title: str - display_identifier: DisplayIdentifierData | None = None - pdf_url: str | None - status: str - attempt_count: int - last_failure_reason: str | None - last_failure_detail: str | None - last_source: str | None - requested_by_user_id: int | None - requested_by_email: str | None - queued_at: datetime | None - last_attempt_at: datetime | None - resolved_at: datetime | None - updated_at: datetime - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueData(BaseModel): - items: list[AdminPdfQueueItemData] = Field(default_factory=list) - total_count: int = 0 - page: int = 1 - page_size: int = 100 - has_next: bool = False - has_prev: bool = False - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueEnvelope(BaseModel): - data: AdminPdfQueueData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueRequeueData(BaseModel): - publication_id: int - queued: bool - status: str - message: str - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueRequeueEnvelope(BaseModel): - data: AdminPdfQueueRequeueData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueBulkEnqueueData(BaseModel): - requested_count: int - queued_count: int - message: str - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueBulkEnqueueEnvelope(BaseModel): - data: AdminPdfQueueBulkEnqueueData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminRepairPublicationLinksRequest(BaseModel): - scope_mode: Literal["single_user", "all_users"] = "single_user" - user_id: int | None = Field(default=None, ge=1) - scholar_profile_ids: list[int] = Field(default_factory=list, max_length=200) - dry_run: bool = True - gc_orphan_publications: bool = False - requested_by: str | None = None - confirmation_text: str | None = None - - model_config = ConfigDict(extra="forbid") - - @model_validator(mode="after") - def validate_scope(self) -> AdminRepairPublicationLinksRequest: - if self.scope_mode == "single_user" and self.user_id is None: - raise ValueError("user_id is required when scope_mode=single_user.") - if self.scope_mode == "all_users" and self.user_id is not None: - raise ValueError("user_id must be omitted when scope_mode=all_users.") - if not self.dry_run and self.scope_mode == "all_users": - expected = "REPAIR ALL USERS" - provided = (self.confirmation_text or "").strip() - if provided != expected: - raise ValueError("confirmation_text must equal 'REPAIR ALL USERS' when applying a repair to all users.") - return self - - -class AdminRepairPublicationLinksResultData(BaseModel): - job_id: int - status: str - scope: dict[str, Any] = Field(default_factory=dict) - summary: dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(extra="forbid") - - -class AdminRepairPublicationLinksEnvelope(BaseModel): - data: AdminRepairPublicationLinksResultData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminNearDuplicateClusterMemberData(BaseModel): - publication_id: int - title: str - year: int | None - citation_count: int - - model_config = ConfigDict(extra="forbid") - - -class AdminNearDuplicateClusterData(BaseModel): - cluster_key: str - winner_publication_id: int - member_count: int - similarity_score: float = Field(ge=0.0, le=1.0) - members: list[AdminNearDuplicateClusterMemberData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class AdminRepairPublicationNearDuplicatesRequest(BaseModel): - dry_run: bool = True - similarity_threshold: float = Field(default=0.78, ge=0.5, le=1.0) - min_shared_tokens: int = Field(default=3, ge=1, le=8) - max_year_delta: int = Field(default=1, ge=0, le=5) - max_clusters: int = Field(default=25, ge=1, le=200) - selected_cluster_keys: list[str] = Field(default_factory=list, max_length=200) - requested_by: str | None = None - confirmation_text: str | None = None - - model_config = ConfigDict(extra="forbid") - - @model_validator(mode="after") - def validate_apply_mode(self) -> AdminRepairPublicationNearDuplicatesRequest: - if self.dry_run: - return self - if not self.selected_cluster_keys: - raise ValueError("selected_cluster_keys is required when dry_run=false.") - expected = "MERGE SELECTED DUPLICATES" - provided = (self.confirmation_text or "").strip() - if provided != expected: - raise ValueError( - "confirmation_text must equal 'MERGE SELECTED DUPLICATES' when applying near-duplicate merges." - ) - return self - - -class AdminRepairPublicationNearDuplicatesResultData(BaseModel): - job_id: int - status: str - scope: dict[str, Any] = Field(default_factory=dict) - summary: dict[str, Any] = Field(default_factory=dict) - clusters: list[AdminNearDuplicateClusterData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class AdminRepairPublicationNearDuplicatesEnvelope(BaseModel): - data: AdminRepairPublicationNearDuplicatesResultData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py deleted file mode 100644 index 14efa57..0000000 --- a/app/api/schemas/auth.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, ConfigDict, Field - -from app.api.schemas.common import ApiMeta - - -class SessionUserData(BaseModel): - id: int - email: str - is_admin: bool - is_active: bool - - model_config = ConfigDict(extra="forbid") - - -class AuthMeData(BaseModel): - authenticated: bool - csrf_token: str - user: SessionUserData - - model_config = ConfigDict(extra="forbid") - - -class AuthMeEnvelope(BaseModel): - data: AuthMeData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class CsrfBootstrapData(BaseModel): - csrf_token: str - authenticated: bool - - model_config = ConfigDict(extra="forbid") - - -class CsrfBootstrapEnvelope(BaseModel): - data: CsrfBootstrapData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class LoginRequest(BaseModel): - email: str = Field(max_length=254) - password: str = Field(min_length=8, max_length=128) - - model_config = ConfigDict(extra="forbid") - - -class LoginData(BaseModel): - authenticated: bool - csrf_token: str - user: SessionUserData - - model_config = ConfigDict(extra="forbid") - - -class LoginEnvelope(BaseModel): - data: LoginData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ChangePasswordRequest(BaseModel): - current_password: str = Field(min_length=8, max_length=128) - new_password: str = Field(min_length=8, max_length=128) - confirm_password: str = Field(min_length=8, max_length=128) - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/common.py b/app/api/schemas/common.py deleted file mode 100644 index cc0805f..0000000 --- a/app/api/schemas/common.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict - - -class ApiMeta(BaseModel): - request_id: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class ApiErrorData(BaseModel): - code: str - message: str - details: Any | None = None - - model_config = ConfigDict(extra="forbid") - - -class ApiErrorEnvelope(BaseModel): - error: ApiErrorData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class MessageData(BaseModel): - message: str - - model_config = ConfigDict(extra="forbid") - - -class MessageEnvelope(BaseModel): - data: MessageData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/publications.py b/app/api/schemas/publications.py deleted file mode 100644 index 6ae7c2e..0000000 --- a/app/api/schemas/publications.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field - -from app.api.schemas.common import ApiMeta - - -class DisplayIdentifierData(BaseModel): - kind: str - value: str - label: str - url: str | None - confidence_score: float = Field(ge=0.0, le=1.0) - - model_config = ConfigDict(extra="forbid") - - -class PublicationItemData(BaseModel): - publication_id: int - scholar_profile_id: int - scholar_label: str - title: str - year: int | None - citation_count: int - venue_text: str | None - pub_url: str | None - display_identifier: DisplayIdentifierData | None = None - pdf_url: str | None - pdf_status: str = "untracked" - pdf_attempt_count: int = 0 - pdf_failure_reason: str | None = None - pdf_failure_detail: str | None = None - is_read: bool - is_favorite: bool = False - first_seen_at: datetime - is_new_in_latest_run: bool - - model_config = ConfigDict(extra="forbid") - - -class PublicationsListData(BaseModel): - mode: str - favorite_only: bool = False - selected_scholar_profile_id: int | None - unread_count: int - favorites_count: int - latest_count: int - new_count: int - total_count: int - page: int - page_size: int - snapshot: str - has_next: bool = False - has_prev: bool = False - publications: list[PublicationItemData] - - model_config = ConfigDict(extra="forbid") - - -class PublicationsListEnvelope(BaseModel): - data: PublicationsListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class MarkAllReadData(BaseModel): - message: str - updated_count: int - - model_config = ConfigDict(extra="forbid") - - -class MarkAllReadEnvelope(BaseModel): - data: MarkAllReadData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class PublicationSelectionItem(BaseModel): - scholar_profile_id: int - publication_id: int - - model_config = ConfigDict(extra="forbid") - - -class MarkSelectedReadRequest(BaseModel): - selections: list[PublicationSelectionItem] = Field(min_length=1, max_length=500) - - model_config = ConfigDict(extra="forbid") - - -class MarkSelectedReadData(BaseModel): - message: str - requested_count: int - updated_count: int - - model_config = ConfigDict(extra="forbid") - - -class MarkSelectedReadEnvelope(BaseModel): - data: MarkSelectedReadData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class RetryPublicationPdfRequest(BaseModel): - scholar_profile_id: int = Field(ge=1) - - model_config = ConfigDict(extra="forbid") - - -class RetryPublicationPdfData(BaseModel): - message: str - queued: bool - resolved_pdf: bool - publication: PublicationItemData - - model_config = ConfigDict(extra="forbid") - - -class RetryPublicationPdfEnvelope(BaseModel): - data: RetryPublicationPdfData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class TogglePublicationFavoriteRequest(BaseModel): - scholar_profile_id: int = Field(ge=1) - is_favorite: bool - - model_config = ConfigDict(extra="forbid") - - -class TogglePublicationFavoriteData(BaseModel): - message: str - publication: PublicationItemData - - model_config = ConfigDict(extra="forbid") - - -class TogglePublicationFavoriteEnvelope(BaseModel): - data: TogglePublicationFavoriteData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/runs.py b/app/api/schemas/runs.py deleted file mode 100644 index e9e7791..0000000 --- a/app/api/schemas/runs.py +++ /dev/null @@ -1,226 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field - -from app.api.schemas.common import ApiMeta - - -class RunListItemData(BaseModel): - id: int - trigger_type: str - status: str - start_dt: datetime - end_dt: datetime | None - scholar_count: int - new_publication_count: int - failed_count: int - partial_count: int - - model_config = ConfigDict(extra="forbid") - - -class ScrapeSafetyCountersData(BaseModel): - consecutive_blocked_runs: int = 0 - consecutive_network_runs: int = 0 - cooldown_entry_count: int = 0 - blocked_start_count: int = 0 - last_blocked_failure_count: int = 0 - last_network_failure_count: int = 0 - last_evaluated_run_id: int | None = None - - model_config = ConfigDict(extra="forbid") - - -class ScrapeSafetyStateData(BaseModel): - cooldown_active: bool - cooldown_reason: str | None = None - cooldown_reason_label: str | None = None - cooldown_until: datetime | None = None - cooldown_remaining_seconds: int = 0 - recommended_action: str | None = None - counters: ScrapeSafetyCountersData = Field(default_factory=ScrapeSafetyCountersData) - - model_config = ConfigDict(extra="forbid") - - -class RunsListData(BaseModel): - runs: list[RunListItemData] - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class RunsListEnvelope(BaseModel): - data: RunsListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class RunSummaryData(BaseModel): - succeeded_count: int - failed_count: int - partial_count: int - failed_state_counts: dict[str, int] = Field(default_factory=dict) - failed_reason_counts: dict[str, int] = Field(default_factory=dict) - scrape_failure_counts: dict[str, int] = Field(default_factory=dict) - retry_counts: dict[str, int] = Field(default_factory=dict) - alert_thresholds: dict[str, int] = Field(default_factory=dict) - alert_flags: dict[str, bool] = Field(default_factory=dict) - - model_config = ConfigDict(extra="forbid") - - -class RunAttemptLogData(BaseModel): - attempt: int - cstart: int - state: str | None = None - state_reason: str | None = None - status_code: int | None = None - fetch_error: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class RunPageLogData(BaseModel): - page: int - cstart: int - state: str - state_reason: str | None = None - status_code: int | None = None - publication_count: int = 0 - attempt_count: int = 0 - has_show_more_button: bool = False - articles_range: str | None = None - warning_codes: list[str] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class RunDebugData(BaseModel): - status_code: int | None = None - final_url: str | None = None - fetch_error: str | None = None - body_sha256: str | None = None - body_length: int | None = None - has_show_more_button: bool | None = None - articles_range: str | None = None - state_reason: str | None = None - warning_codes: list[str] = Field(default_factory=list) - marker_counts_nonzero: dict[str, int] = Field(default_factory=dict) - page_logs: list[RunPageLogData] = Field(default_factory=list) - attempt_log: list[RunAttemptLogData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class RunScholarResultData(BaseModel): - scholar_profile_id: int - scholar_id: str - state: str - state_reason: str | None = None - outcome: str - attempt_count: int = 0 - publication_count: int = 0 - start_cstart: int = 0 - continuation_cstart: int | None = None - continuation_enqueued: bool = False - continuation_cleared: bool = False - warnings: list[str] = Field(default_factory=list) - error: str | None = None - debug: RunDebugData | None = None - - model_config = ConfigDict(extra="forbid") - - -class RunDetailData(BaseModel): - run: RunListItemData - summary: RunSummaryData - scholar_results: list[RunScholarResultData] - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class RunDetailEnvelope(BaseModel): - data: RunDetailData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ManualRunData(BaseModel): - run_id: int - status: str - scholar_count: int - succeeded_count: int - failed_count: int - partial_count: int - new_publication_count: int - reused_existing_run: bool - idempotency_key: str | None = None - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class ManualRunEnvelope(BaseModel): - data: ManualRunData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class QueueItemData(BaseModel): - id: int - scholar_profile_id: int - scholar_label: str - status: str - reason: str - dropped_reason: str | None - attempt_count: int - resume_cstart: int - next_attempt_dt: datetime | None - updated_at: datetime - last_error: str | None - last_run_id: int | None - - model_config = ConfigDict(extra="forbid") - - -class QueueListData(BaseModel): - queue_items: list[QueueItemData] - - model_config = ConfigDict(extra="forbid") - - -class QueueListEnvelope(BaseModel): - data: QueueListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class QueueItemEnvelope(BaseModel): - data: QueueItemData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class QueueClearData(BaseModel): - queue_item_id: int - previous_status: str - status: str - message: str - - model_config = ConfigDict(extra="forbid") - - -class QueueClearEnvelope(BaseModel): - data: QueueClearData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/scholars.py b/app/api/schemas/scholars.py deleted file mode 100644 index bc1035d..0000000 --- a/app/api/schemas/scholars.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from app.api.schemas.common import ApiMeta - - -class ScholarItemData(BaseModel): - id: int - scholar_id: str - display_name: str | None - profile_image_url: str | None - profile_image_source: str - is_enabled: bool - baseline_completed: bool - last_run_dt: datetime | None - last_run_status: str | None - - model_config = ConfigDict(extra="forbid") - - -class ScholarsListData(BaseModel): - scholars: list[ScholarItemData] - - model_config = ConfigDict(extra="forbid") - - -class ScholarsListEnvelope(BaseModel): - data: ScholarsListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarCreateRequest(BaseModel): - scholar_id: str - profile_image_url: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class ScholarEnvelope(BaseModel): - data: ScholarItemData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarSearchCandidateData(BaseModel): - scholar_id: str - display_name: str - affiliation: str | None - email_domain: str | None - cited_by_count: int | None - interests: list[str] = Field(default_factory=list) - profile_url: str - profile_image_url: str | None - - model_config = ConfigDict(extra="forbid") - - -class ScholarSearchData(BaseModel): - query: str - state: str - state_reason: str - action_hint: str | None = None - candidates: list[ScholarSearchCandidateData] - warnings: list[str] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class ScholarSearchEnvelope(BaseModel): - data: ScholarSearchData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarImageUrlUpdateRequest(BaseModel): - image_url: str - - model_config = ConfigDict(extra="forbid") - - -class ScholarExportItemData(BaseModel): - scholar_id: str - display_name: str | None = None - is_enabled: bool = True - profile_image_override_url: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class PublicationExportItemData(BaseModel): - scholar_id: str - cluster_id: str | None = None - fingerprint_sha256: str | None = None - title: str - year: int | None = None - citation_count: int = 0 - author_text: str | None = None - venue_text: str | None = None - pub_url: str | None = None - pdf_url: str | None = None - is_read: bool = False - - model_config = ConfigDict(extra="forbid") - - -class DataExportData(BaseModel): - schema_version: int - exported_at: str - scholars: list[ScholarExportItemData] - publications: list[PublicationExportItemData] - - model_config = ConfigDict(extra="forbid") - - -class DataExportEnvelope(BaseModel): - data: DataExportData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarBulkIdsRequest(BaseModel): - scholar_profile_ids: list[int] = Field(..., min_length=1, max_length=500) - - model_config = ConfigDict(extra="forbid") - - -class ScholarBulkToggleRequest(BaseModel): - scholar_profile_ids: list[int] = Field(..., min_length=1, max_length=500) - is_enabled: bool - - model_config = ConfigDict(extra="forbid") - - -class ScholarBulkCountData(BaseModel): - deleted_count: int = 0 - updated_count: int = 0 - - model_config = ConfigDict(extra="forbid") - - -class ScholarBulkCountEnvelope(BaseModel): - data: ScholarBulkCountData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class DataImportRequest(BaseModel): - schema_version: int | None = None - exported_at: str | None = None - scholars: list[ScholarExportItemData] = Field(default_factory=list) - publications: list[PublicationExportItemData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - @model_validator(mode="before") - @classmethod - def unwrap_export_envelope(cls, values: dict) -> dict: - """Accept the full export envelope format (with data/meta wrapper).""" - if isinstance(values, dict) and "data" in values and isinstance(values["data"], dict): - return values["data"] - return values - - -class DataImportResultData(BaseModel): - scholars_created: int - scholars_updated: int - publications_created: int - publications_updated: int - links_created: int - links_updated: int - skipped_records: int - - model_config = ConfigDict(extra="forbid") - - -class DataImportEnvelope(BaseModel): - data: DataImportResultData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/settings.py b/app/api/schemas/settings.py deleted file mode 100644 index cc06ee8..0000000 --- a/app/api/schemas/settings.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, ConfigDict - -from app.api.schemas.common import ApiMeta -from app.api.schemas.runs import ScrapeSafetyStateData - - -class SettingsPolicyData(BaseModel): - min_run_interval_minutes: int - min_request_delay_seconds: int - automation_allowed: bool - manual_run_allowed: bool - blocked_failure_threshold: int - network_failure_threshold: int - cooldown_blocked_seconds: int - cooldown_network_seconds: int - - model_config = ConfigDict(extra="forbid") - - -class SettingsData(BaseModel): - auto_run_enabled: bool - run_interval_minutes: int - request_delay_seconds: int - nav_visible_pages: list[str] - policy: SettingsPolicyData - safety_state: ScrapeSafetyStateData - - openalex_api_key: str | None = None - crossref_api_token: str | None = None - crossref_api_mailto: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class SettingsEnvelope(BaseModel): - data: SettingsData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class SettingsUpdateRequest(BaseModel): - auto_run_enabled: bool - run_interval_minutes: int - request_delay_seconds: int - nav_visible_pages: list[str] | None = None - - openalex_api_key: str | None = None - crossref_api_token: str | None = None - crossref_api_mailto: str | None = None - - model_config = ConfigDict(extra="forbid") diff --git a/app/auth/__init__.py b/app/auth/__init__.py index b2c2619..79644a4 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -1 +1,2 @@ """Authentication package for scholarr.""" + diff --git a/app/auth/deps.py b/app/auth/deps.py index ec7613c..50424ae 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -24,3 +24,4 @@ def get_login_rate_limiter() -> SlidingWindowRateLimiter: max_attempts=settings.login_rate_limit_attempts, window_seconds=settings.login_rate_limit_window_seconds, ) + diff --git a/app/auth/runtime.py b/app/auth/runtime.py index 267325a..5bb1a74 100644 --- a/app/auth/runtime.py +++ b/app/auth/runtime.py @@ -7,9 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.session import clear_session_user, get_session_user, set_session_user from app.db.models import User -from app.logging_utils import structured_log from app.security.csrf import CSRF_SESSION_KEY -from app.services.users import application as user_service +from app.services import users as user_service logger = logging.getLogger(__name__) @@ -29,7 +28,13 @@ async def get_authenticated_user( user = await user_service.get_user_by_id(db_session, session_user.id) if user is None or not user.is_active: - structured_log(logger, "info", "auth.session_invalidated", session_user_id=session_user.id) + logger.info( + "auth.session_invalidated", + extra={ + "event": "auth.session_invalidated", + "session_user_id": session_user.id, + }, + ) invalidate_session(request) return None diff --git a/app/auth/security.py b/app/auth/security.py index befcd7c..f89b122 100644 --- a/app/auth/security.py +++ b/app/auth/security.py @@ -16,3 +16,4 @@ class PasswordService: return bool(self._hasher.verify(password_hash, password)) except (InvalidHashError, VerificationError): return False + diff --git a/app/auth/service.py b/app/auth/service.py index e13e3fe..9772358 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -21,7 +21,9 @@ class AuthService: normalized_email = email.strip().lower() if not normalized_email or not password: return None - result = await db_session.execute(select(User).where(User.email == normalized_email)) + result = await db_session.execute( + select(User).where(User.email == normalized_email) + ) user = result.scalar_one_or_none() if user is None or not user.is_active: return None diff --git a/app/auth/session.py b/app/auth/session.py index cf52836..8c83197 100644 --- a/app/auth/session.py +++ b/app/auth/session.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from starlette.requests import Request + SESSION_USER_ID_KEY = "auth_user_id" SESSION_USER_EMAIL_KEY = "auth_user_email" SESSION_USER_IS_ADMIN_KEY = "auth_user_is_admin" diff --git a/app/db/background_session.py b/app/db/background_session.py deleted file mode 100644 index 745f155..0000000 --- a/app/db/background_session.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Semaphore-gated DB sessions for background tasks. - -Background tasks (ingestion, PDF resolution, scheduler) acquire a semaphore -permit before checking out a database connection. The semaphore cap is -``pool_size + max_overflow - reserved_for_api``, which guarantees that at -least *reserved_for_api* connections remain available for API request -handlers at all times. -""" - -from __future__ import annotations - -import asyncio -import logging -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_session_factory -from app.logging_utils import structured_log -from app.settings import settings - -logger = logging.getLogger(__name__) - -_semaphore: asyncio.Semaphore | None = None - - -def _build_semaphore() -> asyncio.Semaphore: - pool_capacity = max(1, settings.database_pool_size) + max(0, settings.database_pool_max_overflow) - reserved = max(0, settings.database_reserved_api_connections) - limit = max(1, pool_capacity - reserved) - structured_log( - logger, - "info", - "db.background_semaphore_initialized", - pool_capacity=pool_capacity, - reserved_for_api=reserved, - background_limit=limit, - ) - return asyncio.Semaphore(limit) - - -def get_background_semaphore() -> asyncio.Semaphore: - global _semaphore - if _semaphore is None: - _semaphore = _build_semaphore() - return _semaphore - - -@asynccontextmanager -async def background_session() -> AsyncIterator[AsyncSession]: - """Yield a DB session after acquiring the background semaphore.""" - semaphore = get_background_semaphore() - async with semaphore: - session_factory = get_session_factory() - async with session_factory() as session: - yield session diff --git a/app/db/base.py b/app/db/base.py index 78cb78d..6fa2049 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,8 +1,9 @@ -from datetime import UTC, datetime +from datetime import datetime, timezone from sqlalchemy import MetaData, event from sqlalchemy.orm import DeclarativeBase + NAMING_CONVENTION = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", @@ -20,7 +21,7 @@ class Base(DeclarativeBase): def _set_updated_at_before_update(_mapper, _connection, target) -> None: # Keep audit timestamps current for ORM-managed updates. if hasattr(target, "updated_at"): - target.updated_at = datetime.now(UTC) + target.updated_at = datetime.now(timezone.utc) metadata = Base.metadata diff --git a/app/db/models.py b/app/db/models.py index 66c6ff8..2d7de3c 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -8,7 +8,6 @@ from sqlalchemy import ( CheckConstraint, DateTime, Enum, - Float, ForeignKey, Index, Integer, @@ -31,11 +30,9 @@ class RunTriggerType(StrEnum): class RunStatus(StrEnum): RUNNING = "running" - RESOLVING = "resolving" SUCCESS = "success" PARTIAL_FAILURE = "partial_failure" FAILED = "failed" - CANCELED = "canceled" class QueueItemStatus(StrEnum): @@ -62,10 +59,18 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) - is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("true") + ) + is_admin: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) class UserSetting(Base): @@ -76,15 +81,23 @@ class UserSetting(Base): name="run_interval_minutes_min_15", ), CheckConstraint( - "request_delay_seconds >= 2", - name="request_delay_seconds_min_2", + "request_delay_seconds >= 1", + name="request_delay_seconds_min_1", ), ) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) - auto_run_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) - run_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1440")) - request_delay_seconds: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("10")) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + auto_run_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + run_interval_minutes: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("1440") + ) + request_delay_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("10") + ) nav_visible_pages: Mapped[list[str]] = mapped_column( JSONB, nullable=False, @@ -99,13 +112,12 @@ class UserSetting(Base): ) scrape_cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scrape_cooldown_reason: Mapped[str | None] = mapped_column(String(64)) - - openalex_api_key: Mapped[str | None] = mapped_column(String(255)) - crossref_api_token: Mapped[str | None] = mapped_column(String(255)) - crossref_api_mailto: Mapped[str | None] = mapped_column(String(255)) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) class ScholarProfile(Base): @@ -116,7 +128,9 @@ class ScholarProfile(Base): ) id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) scholar_id: Mapped[str] = mapped_column(String(64), nullable=False) display_name: Mapped[str | None] = mapped_column(String(255)) profile_image_url: Mapped[str | None] = mapped_column(Text) @@ -124,47 +138,69 @@ class ScholarProfile(Base): profile_image_upload_path: Mapped[str | None] = mapped_column(Text) last_initial_page_fingerprint_sha256: Mapped[str | None] = mapped_column(String(64)) last_initial_page_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) - baseline_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("true") + ) + baseline_completed: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) last_run_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_run_status: Mapped[RunStatus | None] = mapped_column( RUN_STATUS_DB_ENUM, ) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) class CrawlRun(Base): __tablename__ = "crawl_runs" __table_args__ = ( Index("ix_crawl_runs_user_start", "user_id", "start_dt"), - Index( - "uq_crawl_runs_user_active", - "user_id", - unique=True, - postgresql_where=text("status IN ('running'::run_status, 'resolving'::run_status)"), - ), Index( "uq_crawl_runs_user_manual_idempotency_key", "user_id", "idempotency_key", unique=True, - postgresql_where=text("idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type"), + postgresql_where=text( + "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" + ), ), ) id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - trigger_type: Mapped[RunTriggerType] = mapped_column(RUN_TRIGGER_TYPE_DB_ENUM, nullable=False) - status: Mapped[RunStatus] = mapped_column(RUN_STATUS_DB_ENUM, nullable=False) - start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + trigger_type: Mapped[RunTriggerType] = mapped_column( + RUN_TRIGGER_TYPE_DB_ENUM, nullable=False + ) + status: Mapped[RunStatus] = mapped_column( + RUN_STATUS_DB_ENUM, nullable=False + ) + start_dt: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - scholar_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) - new_pub_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + scholar_count: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("0") + ) + new_pub_count: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("0") + ) idempotency_key: Mapped[str | None] = mapped_column(String(128)) - error_log: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + error_log: Mapped[dict] = mapped_column( + JSONB, nullable=False, server_default=text("'{}'::jsonb") + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) class Publication(Base): @@ -185,129 +221,25 @@ class Publication(Base): title_raw: Mapped[str] = mapped_column(Text, nullable=False) title_normalized: Mapped[str] = mapped_column(Text, nullable=False) year: Mapped[int | None] = mapped_column(Integer) - citation_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + citation_count: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("0") + ) author_text: Mapped[str | None] = mapped_column(Text) venue_text: Mapped[str | None] = mapped_column(Text) pub_url: Mapped[str | None] = mapped_column(Text) pdf_url: Mapped[str | None] = mapped_column(Text) - canonical_title_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - openalex_enriched: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) - openalex_last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class PublicationIdentifier(Base): - __tablename__ = "publication_identifiers" - __table_args__ = ( - UniqueConstraint( - "publication_id", - "kind", - "value_normalized", - name="uq_publication_identifiers_publication_kind_value", - ), - CheckConstraint( - "confidence_score >= 0 AND confidence_score <= 1", - name="publication_identifiers_confidence_score_range", - ), - Index( - "ix_publication_identifiers_kind_value", - "kind", - "value_normalized", - ), - Index( - "ix_publication_identifiers_publication_id", - "publication_id", - ), + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() ) - - id: Mapped[int] = mapped_column(primary_key=True) - publication_id: Mapped[int] = mapped_column( - ForeignKey("publications.id", ondelete="CASCADE"), - nullable=False, + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() ) - kind: Mapped[str] = mapped_column(String(32), nullable=False) - value_raw: Mapped[str] = mapped_column(Text, nullable=False) - value_normalized: Mapped[str] = mapped_column(String(255), nullable=False) - source: Mapped[str] = mapped_column(String(64), nullable=False) - confidence_score: Mapped[float] = mapped_column( - Float, - nullable=False, - server_default=text("0"), - ) - evidence_url: Mapped[str | None] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class PublicationPdfJob(Base): - __tablename__ = "publication_pdf_jobs" - __table_args__ = ( - CheckConstraint( - "status IN ('queued', 'running', 'resolved', 'failed')", - name="publication_pdf_jobs_status_valid", - ), - Index("ix_publication_pdf_jobs_status", "status"), - Index("ix_publication_pdf_jobs_updated_at", "updated_at"), - Index("ix_publication_pdf_jobs_queued_at", "queued_at"), - ) - - publication_id: Mapped[int] = mapped_column( - ForeignKey("publications.id", ondelete="CASCADE"), - primary_key=True, - ) - status: Mapped[str] = mapped_column( - String(16), - nullable=False, - server_default=text("'queued'"), - ) - attempt_count: Mapped[int] = mapped_column( - Integer, - nullable=False, - server_default=text("0"), - ) - queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - last_failure_reason: Mapped[str | None] = mapped_column(String(64)) - last_failure_detail: Mapped[str | None] = mapped_column(Text) - last_source: Mapped[str | None] = mapped_column(String(32)) - last_requested_by_user_id: Mapped[int | None] = mapped_column( - ForeignKey("users.id", ondelete="SET NULL"), - ) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class PublicationPdfJobEvent(Base): - __tablename__ = "publication_pdf_job_events" - __table_args__ = ( - Index("ix_publication_pdf_job_events_publication_created", "publication_id", "created_at"), - Index("ix_publication_pdf_job_events_created_at", "created_at"), - Index("ix_publication_pdf_job_events_event_type", "event_type"), - ) - - id: Mapped[int] = mapped_column(primary_key=True) - publication_id: Mapped[int] = mapped_column( - ForeignKey("publications.id", ondelete="CASCADE"), - nullable=False, - ) - user_id: Mapped[int | None] = mapped_column( - ForeignKey("users.id", ondelete="SET NULL"), - ) - event_type: Mapped[str] = mapped_column(String(32), nullable=False) - status: Mapped[str | None] = mapped_column(String(16)) - source: Mapped[str | None] = mapped_column(String(32)) - failure_reason: Mapped[str | None] = mapped_column(String(64)) - message: Mapped[str | None] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class ScholarPublication(Base): __tablename__ = "scholar_publications" __table_args__ = ( Index("ix_scholar_publications_is_read", "is_read"), - Index("ix_scholar_publications_is_favorite", "is_favorite"), ) scholar_profile_id: Mapped[int] = mapped_column( @@ -318,10 +250,15 @@ class ScholarPublication(Base): ForeignKey("publications.id", ondelete="CASCADE"), primary_key=True, ) - is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) - is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) - first_seen_run_id: Mapped[int | None] = mapped_column(ForeignKey("crawl_runs.id", ondelete="SET NULL")) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + is_read: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + first_seen_run_id: Mapped[int | None] = mapped_column( + ForeignKey("crawl_runs.id", ondelete="SET NULL") + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) class IngestionQueueItem(Base): @@ -376,49 +313,12 @@ class IngestionQueueItem(Base): last_error: Mapped[str | None] = mapped_column(Text) dropped_reason: Mapped[str | None] = mapped_column(String(128)) dropped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class DataRepairJob(Base): - __tablename__ = "data_repair_jobs" - __table_args__ = ( - CheckConstraint( - "status IN ('planned', 'running', 'completed', 'failed')", - name="data_repair_jobs_status_valid", - ), - Index("ix_data_repair_jobs_created_at", "created_at"), - Index("ix_data_repair_jobs_job_name_created_at", "job_name", "created_at"), + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() ) - - id: Mapped[int] = mapped_column(primary_key=True) - job_name: Mapped[str] = mapped_column(String(64), nullable=False) - requested_by: Mapped[str | None] = mapped_column(String(255)) - scope: Mapped[dict] = mapped_column( - JSONB, - nullable=False, - server_default=text("'{}'::jsonb"), + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() ) - dry_run: Mapped[bool] = mapped_column( - Boolean, - nullable=False, - server_default=text("true"), - ) - status: Mapped[str] = mapped_column( - String(16), - nullable=False, - server_default=text("'planned'"), - ) - summary: Mapped[dict] = mapped_column( - JSONB, - nullable=False, - server_default=text("'{}'::jsonb"), - ) - error_text: Mapped[str | None] = mapped_column(Text) - started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class AuthorSearchRuntimeState(Base): @@ -442,38 +342,12 @@ class AuthorSearchRuntimeState(Base): nullable=False, server_default=text("false"), ) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class ArxivRuntimeState(Base): - __tablename__ = "arxiv_runtime_state" - - state_key: Mapped[str] = mapped_column(String(64), primary_key=True) - next_allowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class ArxivQueryCacheEntry(Base): - __tablename__ = "arxiv_query_cache_entries" - __table_args__ = ( - Index("ix_arxiv_query_cache_expires_at", "expires_at"), - Index("ix_arxiv_query_cache_cached_at", "cached_at"), + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() ) - - query_fingerprint: Mapped[str] = mapped_column(String(64), primary_key=True) - payload: Mapped[dict] = mapped_column( - JSONB, - nullable=False, + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() ) - expires_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - ) - cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class AuthorSearchCacheEntry(Base): @@ -492,5 +366,9 @@ class AuthorSearchCacheEntry(Base): DateTime(timezone=True), nullable=False, ) - cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + cached_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) diff --git a/app/db/session.py b/app/db/session.py index 59a1745..c05266e 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,6 +1,6 @@ +from collections.abc import AsyncIterator import logging import os -from collections.abc import AsyncIterator from sqlalchemy import text from sqlalchemy.ext.asyncio import ( @@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.pool import NullPool -from app.logging_utils import structured_log from app.settings import settings logger = logging.getLogger(__name__) @@ -31,12 +30,13 @@ def _normalized_pool_mode(raw_mode: str) -> str: return "queue" if mode in {"null", "queue"}: return mode - structured_log( - logger, - "warning", + logger.warning( "db.invalid_pool_mode_fallback", - database_pool_mode=raw_mode, - fallback_mode="queue", + extra={ + "event": "db.invalid_pool_mode_fallback", + "database_pool_mode": raw_mode, + "fallback_mode": "queue", + }, ) return "queue" @@ -56,11 +56,12 @@ def get_engine() -> AsyncEngine: engine_kwargs["pool_timeout"] = max(1, int(settings.database_pool_timeout_seconds)) _engine = create_async_engine(settings.database_url, **engine_kwargs) - structured_log( - logger, - "info", + logger.info( "db.engine_initialized", - pool_mode=pool_mode, + extra={ + "event": "db.engine_initialized", + "pool_mode": pool_mode, + }, ) return _engine @@ -85,7 +86,7 @@ async def check_database() -> bool: result = await conn.execute(text("SELECT 1")) return result.scalar_one() == 1 except Exception: - structured_log(logger, "exception", "db.healthcheck_failed") + logger.exception("db.healthcheck_failed", extra={"event": "db.healthcheck_failed"}) return False @@ -93,6 +94,6 @@ async def close_engine() -> None: global _engine, _session_factory if _engine is not None: await _engine.dispose() - structured_log(logger, "info", "db.engine_disposed") + logger.info("db.engine_disposed", extra={"event": "db.engine_disposed"}) _engine = None _session_factory = None diff --git a/app/http/middleware.py b/app/http/middleware.py index 65607ed..49a40a4 100644 --- a/app/http/middleware.py +++ b/app/http/middleware.py @@ -1,15 +1,14 @@ from __future__ import annotations +from secrets import token_urlsafe import logging import time -from secrets import token_urlsafe from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response from app.logging_context import set_request_id -from app.logging_utils import structured_log REQUEST_ID_HEADER = "X-Request-ID" DEFAULT_PERMISSIONS_POLICY = ( @@ -62,39 +61,42 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): start = time.perf_counter() should_log = self._log_requests and not self._is_skipped_path(request.url.path) if should_log: - structured_log( - logger, - "debug", + logger.info( "request.started", - method=request.method, - path=request.url.path, + extra={ + "event": "request.started", + "method": request.method, + "path": request.url.path, + }, ) try: response = await call_next(request) except Exception: duration_ms = int((time.perf_counter() - start) * 1000) - structured_log( - logger, - "exception", + logger.exception( "request.failed", - method=request.method, - path=request.url.path, - duration_ms=duration_ms, + extra={ + "event": "request.failed", + "method": request.method, + "path": request.url.path, + "duration_ms": duration_ms, + }, ) raise else: duration_ms = int((time.perf_counter() - start) * 1000) response.headers[REQUEST_ID_HEADER] = request_id if should_log: - structured_log( - logger, - "debug", + logger.info( "request.completed", - method=request.method, - path=request.url.path, - status_code=response.status_code, - duration_ms=duration_ms, + extra={ + "event": "request.completed", + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "duration_ms": duration_ms, + }, ) return response finally: @@ -168,7 +170,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): csp_policy = self._csp_policy_for_path(request.url.path) if self._csp_enabled and csp_policy: - csp_header = "Content-Security-Policy-Report-Only" if self._csp_report_only else "Content-Security-Policy" + csp_header = ( + "Content-Security-Policy-Report-Only" + if self._csp_report_only + else "Content-Security-Policy" + ) response.headers.setdefault(csp_header, csp_policy) hsts = self._strict_transport_security_value() diff --git a/app/logging_config.py b/app/logging_config.py index 805fcbf..22907be 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -1,9 +1,9 @@ from __future__ import annotations +from datetime import datetime, timezone import json import logging import sys -from datetime import UTC, datetime from typing import Any from app.logging_context import get_request_id @@ -102,22 +102,12 @@ class JsonLogFormatter(logging.Formatter): if key.lower() in self._redact_fields: return "[REDACTED]" if isinstance(value, dict): - return { - nested_key: self._redact_value(nested_key, nested_value) for nested_key, nested_value in value.items() - } + return {nested_key: self._redact_value(nested_key, nested_value) for nested_key, nested_value in value.items()} if isinstance(value, (list, tuple)): return [self._redact_value(key, item) for item in value] return value -_CONSOLE_SHORT_KEYS = { - "user_id": "user", - "scholar_id": "scholar", - "crawl_run_id": "run", - "run_id": "run", -} - - class ConsoleLogFormatter(logging.Formatter): def __init__(self, *, redact_fields: set[str]) -> None: super().__init__() @@ -150,8 +140,7 @@ class ConsoleLogFormatter(logging.Formatter): for key in sorted(payload.keys()): if key in {"timestamp", "level", "logger", "event", "exception"}: continue - display_key = _CONSOLE_SHORT_KEYS.get(key, key) - parts.append(f"{display_key}={payload[key]}") + parts.append(f"{key}={payload[key]}") if "exception" in payload: parts.append(f"exception={payload['exception']}") @@ -179,7 +168,7 @@ def _normalize_level(level: str) -> int: def _format_timestamp(created_ts: float) -> str: - dt = datetime.fromtimestamp(created_ts, tz=UTC) + dt = datetime.fromtimestamp(created_ts, tz=timezone.utc) return dt.strftime("%Y-%m-%d %H:%M:%SZ") diff --git a/app/logging_context.py b/app/logging_context.py index a45136b..ee578fb 100644 --- a/app/logging_context.py +++ b/app/logging_context.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextvars import ContextVar + _request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None) @@ -11,3 +12,4 @@ def get_request_id() -> str | None: def set_request_id(value: str | None) -> None: _request_id_ctx.set(value) + diff --git a/app/logging_utils.py b/app/logging_utils.py deleted file mode 100644 index e81628e..0000000 --- a/app/logging_utils.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Structured logging utility — eliminates boilerplate across domain services.""" - -from __future__ import annotations - -import logging -from typing import Any - - -def structured_log( - logger: logging.Logger, - level: str, - event: str, - /, - **fields: Any, -) -> None: - """Emit a structured log entry. - - The event name is passed as the log message. The JsonLogFormatter in - logging_config.py extracts it via record.getMessage() when no explicit - 'event' key exists in extra — so we do NOT duplicate it. - - Usage: - structured_log(logger, "info", "ingestion.run_started", user_id=1, scholar_count=5) - """ - fields.pop("metric_name", None) - fields.pop("metric_value", None) - - log_method = getattr(logger, level.lower()) - log_method(event, extra=fields) diff --git a/app/main.py b/app/main.py index f8610e1..7fbef93 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from contextlib import asynccontextmanager from pathlib import Path @@ -10,23 +9,20 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.api.errors import register_api_exception_handlers -from app.api.media import router as media_router from app.api.router import router as api_router -from app.db.session import check_database, close_engine +from app.api.runtime_deps import get_ingestion_service, get_scholar_source +from app.db.session import check_database +from app.db.session import close_engine from app.http.middleware import ( RequestLoggingMiddleware, SecurityHeadersMiddleware, parse_skip_paths, ) from app.logging_config import configure_logging, parse_redact_fields -from app.logging_utils import structured_log from app.security.csrf import CSRFMiddleware -from app.services.ingestion.scheduler import SchedulerService +from app.services.scheduler import SchedulerService from app.settings import settings -logger = logging.getLogger(__name__) -BUILD_MARKER = "2026-02-19.phase2.direct-pdf-import-export-dashboard-sync" - configure_logging( level=settings.log_level, log_format=settings.log_format, @@ -51,56 +47,6 @@ scheduler_service = SchedulerService( @asynccontextmanager async def lifespan(_: FastAPI): - structured_log( - logger, - "info", - "app.startup_build_marker", - build_marker=BUILD_MARKER, - frontend_enabled=settings.frontend_enabled, - scheduler_enabled=settings.scheduler_enabled, - log_format=settings.log_format, - ) - - from sqlalchemy import text - - from app.db.session import get_session_factory - - try: - session_factory = get_session_factory() - async with session_factory() as session: - await session.execute( - text("UPDATE crawl_runs SET status = 'failed' WHERE status::text IN ('running', 'resolving')") - ) - await session.commit() - structured_log(logger, "info", "app.startup_orphaned_runs_cleaned") - except Exception as exc: - structured_log( - logger, - "error", - "app.startup_orphaned_runs_cleanup_failed", - error=str(exc), - ) - - try: - session_factory = get_session_factory() - async with session_factory() as session: - await session.execute( - text( - "UPDATE publication_pdf_jobs SET status = 'queued'" - " WHERE status = 'running'" - " AND (last_attempt_at IS NULL OR last_attempt_at < NOW() - INTERVAL '10 minutes')" - ) - ) - await session.commit() - structured_log(logger, "info", "app.startup_stuck_pdf_jobs_recovered") - except Exception as exc: - structured_log( - logger, - "error", - "app.startup_stuck_pdf_jobs_recovery_failed", - error=str(exc), - ) - await scheduler_service.start() yield await scheduler_service.stop() @@ -136,11 +82,12 @@ app.add_middleware( content_security_policy_report_only=settings.security_csp_report_only, strict_transport_security_enabled=settings.security_strict_transport_security_enabled, strict_transport_security_max_age=settings.security_strict_transport_security_max_age, - strict_transport_security_include_subdomains=(settings.security_strict_transport_security_include_subdomains), + strict_transport_security_include_subdomains=( + settings.security_strict_transport_security_include_subdomains + ), strict_transport_security_preload=settings.security_strict_transport_security_preload, ) app.include_router(api_router) -app.include_router(media_router) @app.get("/healthz") diff --git a/app/security/__init__.py b/app/security/__init__.py index 3cf1e91..4033692 100644 --- a/app/security/__init__.py +++ b/app/security/__init__.py @@ -1 +1,2 @@ """Security middleware and helpers for scholarr.""" + diff --git a/app/security/csrf.py b/app/security/csrf.py index 822be3b..70eecac 100644 --- a/app/security/csrf.py +++ b/app/security/csrf.py @@ -6,11 +6,9 @@ from urllib.parse import parse_qs from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from starlette.responses import PlainTextResponse, Response +from starlette.responses import JSONResponse, PlainTextResponse, Response from starlette.types import Message -from app.api.responses import error_response -from app.logging_utils import structured_log CSRF_SESSION_KEY = "csrf_token" CSRF_FORM_FIELD = "csrf_token" @@ -38,12 +36,13 @@ class CSRFMiddleware(BaseHTTPMiddleware): session_token = request.session.get(CSRF_SESSION_KEY) if not session_token: - structured_log( - logger, - "warning", + logger.warning( "csrf.missing_session_token", - method=request.method, - path=request.url.path, + extra={ + "event": "csrf.missing_session_token", + "method": request.method, + "path": request.url.path, + }, ) return self._csrf_error_response( request, @@ -55,15 +54,16 @@ class CSRFMiddleware(BaseHTTPMiddleware): if request_token is None and self._is_form_payload(request): body = await request.body() request_token = self._token_from_form_body(request, body) - request._receive = self._build_receive(body) + request._receive = self._build_receive(body) # type: ignore[attr-defined] if not request_token or not compare_digest(str(session_token), str(request_token)): - structured_log( - logger, - "warning", + logger.warning( "csrf.invalid_token", - method=request.method, - path=request.url.path, + extra={ + "event": "csrf.invalid_token", + "method": request.method, + "path": request.url.path, + }, ) return self._csrf_error_response( request, @@ -89,10 +89,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): content_type = request.headers.get("content-type", "") if not content_type.startswith("application/x-www-form-urlencoded"): return None - try: - parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) - except (UnicodeDecodeError, ValueError): - return None + parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) values = parsed.get(CSRF_FORM_FIELD) if not values: return None @@ -118,11 +115,19 @@ class CSRFMiddleware(BaseHTTPMiddleware): message: str, ) -> Response: if request.url.path.startswith("/api/"): - return error_response( - request, + request_state = getattr(request, "state", None) + request_id = getattr(request_state, "request_id", None) if request_state else None + return JSONResponse( + { + "error": { + "code": code, + "message": message, + "details": None, + }, + "meta": { + "request_id": request_id, + }, + }, status_code=403, - code=code, - message=message, - details=None, ) return PlainTextResponse(message, status_code=403) diff --git a/app/services/__init__.py b/app/services/__init__.py index 6093d3d..167f869 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1 +1,2 @@ """Service layer for scholarr application workflows.""" + diff --git a/app/services/arxiv/application.py b/app/services/arxiv/application.py deleted file mode 100644 index ff53632..0000000 --- a/app/services/arxiv/application.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from app.services.arxiv.gateway import ( - build_arxiv_query, - get_arxiv_gateway, -) - -if TYPE_CHECKING: - from app.services.publications.types import PublicationListItem, UnreadPublicationItem - - -def _build_arxiv_query(title: str, author_surname: str | None) -> str | None: - return build_arxiv_query(title, author_surname) - - -async def discover_arxiv_id_for_publication( - *, - item: PublicationListItem | UnreadPublicationItem, - request_email: str | None = None, - timeout_seconds: float | None = None, -) -> str | None: - gateway = get_arxiv_gateway() - return await gateway.discover_arxiv_id_for_publication( - item=item, - request_email=request_email, - timeout_seconds=timeout_seconds, - ) diff --git a/app/services/arxiv/cache.py b/app/services/arxiv/cache.py deleted file mode 100644 index 1e9dac9..0000000 --- a/app/services/arxiv/cache.py +++ /dev/null @@ -1,318 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import json -from collections.abc import Awaitable, Callable, Mapping -from dataclasses import asdict -from datetime import UTC, datetime, timedelta -from typing import Any - -from sqlalchemy import delete, func, select - -from app.db.models import ArxivQueryCacheEntry -from app.db.session import get_session_factory -from app.services.arxiv.constants import ARXIV_CACHE_FINGERPRINT_VERSION -from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta - -_INFLIGHT_LOCK = asyncio.Lock() -_INFLIGHT_FEEDS: dict[str, asyncio.Future[ArxivFeed]] = {} - - -def build_query_fingerprint(*, params: Mapping[str, object]) -> str: - canonical = _canonical_cache_payload(params=params) - encoded = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True) - payload = f"{ARXIV_CACHE_FINGERPRINT_VERSION}:{encoded}" - return hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -async def get_cached_feed( - *, - query_fingerprint: str, - now_utc: datetime | None = None, -) -> ArxivFeed | None: - timestamp = _as_utc(now_utc) - session_factory = get_session_factory() - async with session_factory() as db_session, db_session.begin(): - result = await db_session.execute( - select(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint == query_fingerprint) - ) - entry = result.scalar_one_or_none() - return await _validate_cached_entry(db_session, entry=entry, now_utc=timestamp) - - -async def set_cached_feed( - *, - query_fingerprint: str, - feed: ArxivFeed, - ttl_seconds: float, - max_entries: int, - now_utc: datetime | None = None, -) -> None: - timestamp = _as_utc(now_utc) - session_factory = get_session_factory() - async with session_factory() as db_session, db_session.begin(): - await _write_cached_entry( - db_session, - query_fingerprint=query_fingerprint, - feed=feed, - ttl_seconds=ttl_seconds, - max_entries=max_entries, - now_utc=timestamp, - ) - - -async def run_with_inflight_dedupe( - *, - query_fingerprint: str, - fetch_feed: Callable[[], Awaitable[ArxivFeed]], -) -> ArxivFeed: - future, is_owner = await _reserve_inflight_future(query_fingerprint=query_fingerprint) - if not is_owner: - return await asyncio.shield(future) - try: - result = await fetch_feed() - except Exception as exc: - _complete_future(future, error=exc) - raise - finally: - await _release_inflight_future(query_fingerprint=query_fingerprint, future=future) - _complete_future(future, result=result) - return result - - -def _canonical_cache_payload(*, params: Mapping[str, object]) -> dict[str, object]: - payload: dict[str, object] = {} - for key in sorted(params.keys()): - payload[str(key)] = _normalize_param_value(str(key), params[key]) - return payload - - -def _normalize_param_value(key: str, value: object) -> object: - if key == "search_query": - return _normalize_search_query(str(value or "")) - if key == "id_list": - return _normalize_id_list(str(value or "")) - if isinstance(value, str): - return " ".join(value.strip().split()) - if isinstance(value, (int, float, bool)) or value is None: - return value - return str(value).strip() - - -def _normalize_search_query(value: str) -> str: - return " ".join(value.strip().lower().split()) - - -def _normalize_id_list(value: str) -> str: - normalized = [item.strip().lower() for item in value.split(",") if item.strip()] - return ",".join(sorted(normalized)) - - -async def _validate_cached_entry( - db_session, - *, - entry: ArxivQueryCacheEntry | None, - now_utc: datetime, -) -> ArxivFeed | None: - if entry is None: - return None - if _as_utc(entry.expires_at) <= now_utc: - await db_session.delete(entry) - return None - parsed = _deserialize_feed(entry.payload) - if parsed is None: - await db_session.delete(entry) - return None - return parsed - - -async def _write_cached_entry( - db_session, - *, - query_fingerprint: str, - feed: ArxivFeed, - ttl_seconds: float, - max_entries: int, - now_utc: datetime, -) -> None: - ttl = max(float(ttl_seconds), 0.0) - result = await db_session.execute( - select(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint == query_fingerprint) - ) - existing = result.scalar_one_or_none() - if ttl <= 0.0: - if existing is not None: - await db_session.delete(existing) - return - expires_at = now_utc + timedelta(seconds=ttl) - payload = _serialize_feed(feed) - if existing is None: - db_session.add( - ArxivQueryCacheEntry( - query_fingerprint=query_fingerprint, - payload=payload, - expires_at=expires_at, - cached_at=now_utc, - updated_at=now_utc, - ) - ) - else: - existing.payload = payload - existing.expires_at = expires_at - existing.cached_at = now_utc - existing.updated_at = now_utc - await _prune_cache_entries(db_session, now_utc=now_utc, max_entries=max_entries) - - -async def _prune_cache_entries( - db_session, - *, - now_utc: datetime, - max_entries: int, -) -> None: - await db_session.execute(delete(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.expires_at <= now_utc)) - bounded_max_entries = int(max_entries) - if bounded_max_entries <= 0: - return - count_result = await db_session.execute(select(func.count()).select_from(ArxivQueryCacheEntry)) - entry_count = int(count_result.scalar_one() or 0) - overflow = max(0, entry_count - bounded_max_entries) - if overflow <= 0: - return - stale_result = await db_session.execute( - select(ArxivQueryCacheEntry.query_fingerprint).order_by(ArxivQueryCacheEntry.cached_at.asc()).limit(overflow) - ) - stale_keys = [str(row[0]) for row in stale_result.all()] - if stale_keys: - await db_session.execute( - delete(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint.in_(stale_keys)) - ) - - -def _serialize_feed(feed: ArxivFeed) -> dict[str, Any]: - return asdict(feed) - - -def _deserialize_feed(payload: object) -> ArxivFeed | None: - if not isinstance(payload, dict): - return None - entries_payload = payload.get("entries") - opensearch_payload = payload.get("opensearch") - if not isinstance(entries_payload, list): - return None - entries: list[ArxivEntry] = [] - for value in entries_payload: - entry = _deserialize_entry(value) - if entry is None: - return None - entries.append(entry) - opensearch = _deserialize_opensearch(opensearch_payload) - if opensearch is None: - return None - return ArxivFeed(entries=entries, opensearch=opensearch) - - -def _deserialize_entry(value: object) -> ArxivEntry | None: - if not isinstance(value, dict): - return None - try: - return ArxivEntry( - entry_id_url=str(value["entry_id_url"]), - arxiv_id=_as_optional_string(value.get("arxiv_id")), - title=str(value["title"]), - summary=str(value["summary"]), - published=_as_optional_string(value.get("published")), - updated=_as_optional_string(value.get("updated")), - authors=_as_string_list(value.get("authors")), - links=_as_string_list(value.get("links")), - categories=_as_string_list(value.get("categories")), - primary_category=_as_optional_string(value.get("primary_category")), - ) - except KeyError: - return None - - -def _deserialize_opensearch(value: object) -> ArxivOpenSearchMeta | None: - if not isinstance(value, dict): - return None - try: - return ArxivOpenSearchMeta( - total_results=int(value.get("total_results", 0)), - start_index=int(value.get("start_index", 0)), - items_per_page=int(value.get("items_per_page", 0)), - ) - except (TypeError, ValueError): - return None - - -def _as_optional_string(value: object) -> str | None: - if value is None: - return None - normalized = str(value).strip() - return normalized or None - - -def _as_string_list(value: object) -> list[str]: - if not isinstance(value, list): - return [] - return [str(item) for item in value] - - -def _as_utc(value: datetime | None) -> datetime: - if value is None: - return datetime.now(UTC) - if value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value - - -async def _reserve_inflight_future( - *, - query_fingerprint: str, -) -> tuple[asyncio.Future[ArxivFeed], bool]: - async with _INFLIGHT_LOCK: - existing = _INFLIGHT_FEEDS.get(query_fingerprint) - if existing is not None: - return existing, False - loop = asyncio.get_running_loop() - created = loop.create_future() - created.add_done_callback(_consume_unretrieved_future_exception) - _INFLIGHT_FEEDS[query_fingerprint] = created - return created, True - - -async def _release_inflight_future( - *, - query_fingerprint: str, - future: asyncio.Future[ArxivFeed], -) -> None: - async with _INFLIGHT_LOCK: - current = _INFLIGHT_FEEDS.get(query_fingerprint) - if current is future: - _INFLIGHT_FEEDS.pop(query_fingerprint, None) - - -def _complete_future( - future: asyncio.Future[ArxivFeed], - *, - result: ArxivFeed | None = None, - error: Exception | None = None, -) -> None: - if future.done(): - return - if error is not None: - future.set_exception(error) - return - if result is None: - raise RuntimeError("in-flight future completion requires result or error") - future.set_result(result) - - -def _consume_unretrieved_future_exception(future: asyncio.Future[ArxivFeed]) -> None: - if future.cancelled(): - return - try: - _ = future.exception() - except Exception: - return diff --git a/app/services/arxiv/client.py b/app/services/arxiv/client.py deleted file mode 100644 index 41de63d..0000000 --- a/app/services/arxiv/client.py +++ /dev/null @@ -1,278 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable - -import httpx - -from app.logging_utils import structured_log -from app.services.arxiv.cache import ( - build_query_fingerprint, - get_cached_feed, - run_with_inflight_dedupe, - set_cached_feed, -) -from app.services.arxiv.constants import ( - ARXIV_SOURCE_PATH_LOOKUP_IDS, - ARXIV_SOURCE_PATH_SEARCH, - ARXIV_SOURCE_PATH_UNKNOWN, -) -from app.services.arxiv.errors import ArxivClientValidationError, ArxivRateLimitError -from app.services.arxiv.parser import parse_arxiv_feed -from app.services.arxiv.rate_limit import get_arxiv_cooldown_status, run_with_global_arxiv_limit -from app.services.arxiv.types import ArxivFeed -from app.settings import settings - -_ARXIV_API_URL = "https://export.arxiv.org/api/query" -_ARXIV_QUERY_START = 0 -_ARXIV_MAX_RESULTS_LIMIT = 30_000 -_ARXIV_SORT_BY_ALLOWED = {"relevance", "lastUpdatedDate", "submittedDate"} -_ARXIV_SORT_ORDER_ALLOWED = {"ascending", "descending"} -_FALLBACK_CONTACT_EMAIL = "unknown@example.com" - -ArxivRequestFn = Callable[..., Awaitable[httpx.Response]] -logger = logging.getLogger(__name__) - - -class ArxivClient: - def __init__( - self, - *, - request_fn: ArxivRequestFn | None = None, - cache_enabled: bool | None = None, - ) -> None: - self._request_fn = request_fn or _request_arxiv_feed - self._cache_enabled = _resolve_cache_enabled( - cache_enabled=cache_enabled, - request_fn=request_fn, - ) - self._cache_ttl_seconds = _cache_ttl_seconds() - self._cache_max_entries = _cache_max_entries() - - async def search( - self, - *, - query: str, - start: int = _ARXIV_QUERY_START, - max_results: int | None = None, - sort_by: str | None = None, - sort_order: str | None = None, - request_email: str | None = None, - timeout_seconds: float | None = None, - ) -> ArxivFeed: - params = _search_params( - query=query, - start=start, - max_results=max_results, - sort_by=sort_by, - sort_order=sort_order, - ) - return await self._fetch_feed( - params=params, - request_email=request_email, - timeout_seconds=timeout_seconds, - source_path=ARXIV_SOURCE_PATH_SEARCH, - ) - - async def lookup_ids( - self, - *, - id_list: list[str], - start: int = _ARXIV_QUERY_START, - max_results: int | None = None, - request_email: str | None = None, - timeout_seconds: float | None = None, - ) -> ArxivFeed: - params = _lookup_params(id_list=id_list, start=start, max_results=max_results) - return await self._fetch_feed( - params=params, - request_email=request_email, - timeout_seconds=timeout_seconds, - source_path=ARXIV_SOURCE_PATH_LOOKUP_IDS, - ) - - async def _fetch_feed( - self, - *, - params: dict[str, object], - request_email: str | None, - timeout_seconds: float | None, - source_path: str, - ) -> ArxivFeed: - query_fingerprint = build_query_fingerprint(params=params) - if self._cache_enabled: - cached = await get_cached_feed(query_fingerprint=query_fingerprint) - if cached is not None: - structured_log( - logger, "info", "arxiv.cache_hit", query_fingerprint=query_fingerprint, source_path=source_path - ) - return cached - structured_log( - logger, "info", "arxiv.cache_miss", query_fingerprint=query_fingerprint, source_path=source_path - ) - return await run_with_inflight_dedupe( - query_fingerprint=query_fingerprint, - fetch_feed=lambda: self._fetch_live_feed( - params=params, - request_email=request_email, - timeout_seconds=timeout_seconds, - query_fingerprint=query_fingerprint, - ), - ) - - async def _fetch_live_feed( - self, - *, - params: dict[str, object], - request_email: str | None, - timeout_seconds: float | None, - query_fingerprint: str, - ) -> ArxivFeed: - response = await self._request_fn( - params=params, - request_email=request_email, - timeout_seconds=timeout_seconds, - ) - response.raise_for_status() - feed = parse_arxiv_feed(response.text) - if self._cache_enabled: - await set_cached_feed( - query_fingerprint=query_fingerprint, - feed=feed, - ttl_seconds=self._cache_ttl_seconds, - max_entries=self._cache_max_entries, - ) - return feed - - -def _search_params( - *, - query: str, - start: int, - max_results: int | None, - sort_by: str | None, - sort_order: str | None, -) -> dict[str, object]: - clean_query = query.strip() - if not clean_query: - raise ArxivClientValidationError("search query must not be empty") - params: dict[str, object] = { - "search_query": clean_query, - "start": _validate_start(start), - "max_results": _validate_max_results(max_results), - } - if sort_by is not None: - params["sortBy"] = _validate_sort_by(sort_by) - if sort_order is not None: - params["sortOrder"] = _validate_sort_order(sort_order) - return params - - -def _lookup_params(*, id_list: list[str], start: int, max_results: int | None) -> dict[str, object]: - normalized_ids = [value.strip() for value in id_list if value and value.strip()] - if not normalized_ids: - raise ArxivClientValidationError("id_list must include at least one id") - return { - "id_list": ",".join(normalized_ids), - "start": _validate_start(start), - "max_results": _validate_max_results(max_results), - } - - -def _validate_start(value: int) -> int: - start = int(value) - if start < 0: - raise ArxivClientValidationError("start must be >= 0") - return start - - -def _validate_max_results(value: int | None) -> int: - if value is None: - default_value = int(settings.arxiv_default_max_results) - return max(default_value, 1) - parsed = int(value) - if parsed < 1: - raise ArxivClientValidationError("max_results must be >= 1") - if parsed > _ARXIV_MAX_RESULTS_LIMIT: - raise ArxivClientValidationError(f"max_results must be <= {_ARXIV_MAX_RESULTS_LIMIT}") - return parsed - - -def _validate_sort_by(value: str) -> str: - if value not in _ARXIV_SORT_BY_ALLOWED: - raise ArxivClientValidationError(f"sort_by must be one of: {sorted(_ARXIV_SORT_BY_ALLOWED)!r}") - return value - - -def _validate_sort_order(value: str) -> str: - if value not in _ARXIV_SORT_ORDER_ALLOWED: - raise ArxivClientValidationError(f"sort_order must be one of: {sorted(_ARXIV_SORT_ORDER_ALLOWED)!r}") - return value - - -async def _request_arxiv_feed( - *, - params: dict[str, object], - request_email: str | None, - timeout_seconds: float | None, -) -> httpx.Response: - source_path = _source_path_from_params(params) - cooldown_status = await get_arxiv_cooldown_status() - if cooldown_status.is_active: - structured_log( - logger, - "warning", - "arxiv.request_skipped_cooldown", - source_path=source_path, - cooldown_remaining_seconds=float(cooldown_status.remaining_seconds), - ) - raise ArxivRateLimitError(f"arXiv global cooldown active ({cooldown_status.remaining_seconds:.0f}s remaining)") - - async def _fetch() -> httpx.Response: - timeout_value = _timeout_seconds(timeout_seconds) - headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{_contact_email(request_email)})"} - async with httpx.AsyncClient(timeout=timeout_value, follow_redirects=True, headers=headers) as client: - return await client.get(_ARXIV_API_URL, params=params) # type: ignore[arg-type] - - return await run_with_global_arxiv_limit( - fetch=_fetch, - source_path=source_path, - ) - - -def _timeout_seconds(timeout_seconds: float | None) -> float: - if timeout_seconds is not None: - return max(float(timeout_seconds), 0.5) - return max(float(settings.arxiv_timeout_seconds), 0.5) - - -def _contact_email(request_email: str | None) -> str: - return request_email or settings.arxiv_mailto or settings.crossref_api_mailto or _FALLBACK_CONTACT_EMAIL - - -def _resolve_cache_enabled( - *, - cache_enabled: bool | None, - request_fn: ArxivRequestFn | None, -) -> bool: - if cache_enabled is not None: - return bool(cache_enabled) - if request_fn is not None: - return False - return _cache_ttl_seconds() > 0.0 - - -def _cache_ttl_seconds() -> float: - return max(float(settings.arxiv_cache_ttl_seconds), 0.0) - - -def _cache_max_entries() -> int: - return max(int(settings.arxiv_cache_max_entries), 0) - - -def _source_path_from_params(params: dict[str, object]) -> str: - if "search_query" in params: - return ARXIV_SOURCE_PATH_SEARCH - if "id_list" in params: - return ARXIV_SOURCE_PATH_LOOKUP_IDS - return ARXIV_SOURCE_PATH_UNKNOWN diff --git a/app/services/arxiv/constants.py b/app/services/arxiv/constants.py deleted file mode 100644 index eab9293..0000000 --- a/app/services/arxiv/constants.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -ARXIV_RUNTIME_STATE_KEY = "global" -ARXIV_RATE_LIMIT_LOCK_NAMESPACE = 91_100 -ARXIV_RATE_LIMIT_LOCK_KEY = 1 -ARXIV_SOURCE_PATH_SEARCH = "search" -ARXIV_SOURCE_PATH_LOOKUP_IDS = "lookup_ids" -ARXIV_SOURCE_PATH_UNKNOWN = "unknown" -ARXIV_CACHE_FINGERPRINT_VERSION = "v1" -ARXIV_TITLE_TOKEN_MIN_LENGTH = 3 -ARXIV_TITLE_MIN_TOKENS = 3 -ARXIV_TITLE_MIN_ALPHA_TOKENS = 2 -ARXIV_STRONG_IDENTIFIER_CONFIDENCE = 0.9 diff --git a/app/services/arxiv/errors.py b/app/services/arxiv/errors.py deleted file mode 100644 index 3561f61..0000000 --- a/app/services/arxiv/errors.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - - -class ArxivRateLimitError(Exception): - """arXiv returned 429 or cooldown is active.""" - - -class ArxivClientValidationError(ValueError): - """arXiv client inputs are invalid.""" - - -class ArxivParseError(ValueError): - """arXiv API payload could not be parsed.""" diff --git a/app/services/arxiv/gateway.py b/app/services/arxiv/gateway.py deleted file mode 100644 index b352894..0000000 --- a/app/services/arxiv/gateway.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -import logging -import re -import unicodedata -from typing import TYPE_CHECKING, Protocol - -from app.logging_utils import structured_log -from app.services.arxiv.client import ArxivClient -from app.services.arxiv.errors import ArxivRateLimitError -from app.services.arxiv.types import ArxivFeed -from app.settings import settings - -if TYPE_CHECKING: - from app.services.publications.types import PublicationListItem, UnreadPublicationItem - -logger = logging.getLogger(__name__) - -_default_gateway: ArxivGateway | None = None -_MOJIBAKE_HINT_RE = re.compile(r"[ÃÂâ]") -_NON_ALNUM_RE = re.compile(r"[^\w\s]+", re.UNICODE) -_WHITESPACE_RE = re.compile(r"\s+") - - -class ArxivGateway(Protocol): - async def discover_arxiv_id_for_publication( - self, - *, - item: PublicationListItem | UnreadPublicationItem, - request_email: str | None = None, - timeout_seconds: float | None = None, - max_results: int | None = None, - ) -> str | None: ... - - -def build_arxiv_query(title: str, author_surname: str | None) -> str | None: - parts: list[str] = [] - if title: - clean_title = _normalize_query_title(title) - if clean_title: - parts.append(f'ti:"{clean_title}"') - if author_surname: - clean_author = _normalize_query_title(author_surname) - if clean_author: - parts.append(f'au:"{clean_author}"') - if not parts: - return None - return " AND ".join(parts) - - -def _normalize_query_title(value: str) -> str: - repaired = _repair_mojibake(value.strip()) - normalized = unicodedata.normalize("NFKC", repaired) - stripped = _NON_ALNUM_RE.sub(" ", _MOJIBAKE_HINT_RE.sub(" ", normalized)) - return _WHITESPACE_RE.sub(" ", stripped).strip() - - -def _repair_mojibake(value: str) -> str: - if not value or not _MOJIBAKE_HINT_RE.search(value): - return value - try: - repaired = value.encode("latin1").decode("utf-8") - except UnicodeError: - return value - return repaired if _mojibake_score(repaired) < _mojibake_score(value) else value - - -def _mojibake_score(value: str) -> int: - return len(_MOJIBAKE_HINT_RE.findall(value)) - - -def get_arxiv_gateway() -> ArxivGateway: - global _default_gateway - if _default_gateway is None: - _default_gateway = HttpArxivGateway() - return _default_gateway - - -def set_arxiv_gateway(gateway: ArxivGateway | None) -> ArxivGateway | None: - global _default_gateway - previous = _default_gateway - _default_gateway = gateway - return previous - - -class HttpArxivGateway: - def __init__(self, *, client: ArxivClient | None = None) -> None: - self._client = client or ArxivClient() - - async def discover_arxiv_id_for_publication( - self, - *, - item: PublicationListItem | UnreadPublicationItem, - request_email: str | None = None, - timeout_seconds: float | None = None, - max_results: int | None = None, - ) -> str | None: - if not settings.arxiv_enabled: - return None - query = _query_for_item(item) - if query is None: - return None - - try: - result = await self._client.search( - query=query, - start=0, - request_email=request_email, - timeout_seconds=timeout_seconds, - max_results=max_results, - ) - return _first_discovered_id(result) - except ArxivRateLimitError: - raise - except Exception as exc: - structured_log(logger, "debug", "arxiv.query_failed", error=str(exc)) - return None - - -def _query_for_item(item: PublicationListItem | UnreadPublicationItem) -> str | None: - title = (item.title or "").strip() - if not title: - return None - author_surname = _author_surname(item.scholar_label) - return build_arxiv_query(title, author_surname) - - -def _author_surname(scholar_label: str | None) -> str | None: - if not scholar_label: - return None - tokens = [token for token in scholar_label.strip().split() if token] - if not tokens: - return None - return tokens[-1].lower() - - -def _first_discovered_id(result: ArxivFeed) -> str | None: - for entry in result.entries: - if entry.arxiv_id: - structured_log(logger, "debug", "arxiv.id_discovered", arxiv_id=entry.arxiv_id) - return entry.arxiv_id - return None diff --git a/app/services/arxiv/guards.py b/app/services/arxiv/guards.py deleted file mode 100644 index db88b3f..0000000 --- a/app/services/arxiv/guards.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import re -from typing import TYPE_CHECKING - -from app.services.arxiv.constants import ( - ARXIV_STRONG_IDENTIFIER_CONFIDENCE, - ARXIV_TITLE_MIN_ALPHA_TOKENS, - ARXIV_TITLE_MIN_TOKENS, - ARXIV_TITLE_TOKEN_MIN_LENGTH, -) -from app.services.doi.normalize import normalize_doi -from app.services.publication_identifiers.normalize import normalize_arxiv_id - -if TYPE_CHECKING: - from app.services.publications.types import PublicationListItem, UnreadPublicationItem - -_TITLE_TOKEN_RE = re.compile(r"[a-z0-9]+") - - -def arxiv_skip_reason_for_item( - *, - item: PublicationListItem | UnreadPublicationItem, - has_strong_doi: bool = False, - has_existing_arxiv: bool = False, -) -> str | None: - if has_existing_arxiv or _has_arxiv_identifier_evidence(item): - return "arxiv_identifier_present" - if has_strong_doi or _has_strong_doi_evidence(item): - return "strong_doi_present" - if not _title_passes_quality_guard(item.title): - return "title_quality_below_threshold" - return None - - -def _has_arxiv_identifier_evidence(item: PublicationListItem | UnreadPublicationItem) -> bool: - if _display_identifier_matches(item, expected_kind="arxiv"): - return True - return _has_normalized_identifier(item, normalizer=normalize_arxiv_id) - - -def _has_strong_doi_evidence(item: PublicationListItem | UnreadPublicationItem) -> bool: - if _display_identifier_matches(item, expected_kind="doi"): - return True - return _has_normalized_identifier(item, normalizer=normalize_doi) - - -def _display_identifier_matches( - item: PublicationListItem | UnreadPublicationItem, - *, - expected_kind: str, -) -> bool: - display = getattr(item, "display_identifier", None) - if display is None: - return False - if str(display.kind).lower() != expected_kind: - return False - return float(display.confidence_score) >= ARXIV_STRONG_IDENTIFIER_CONFIDENCE - - -def _has_normalized_identifier( - item: PublicationListItem | UnreadPublicationItem, - *, - normalizer, -) -> bool: - if normalizer(item.pub_url): - return True - return normalizer(item.pdf_url) is not None - - -def _title_passes_quality_guard(title: str | None) -> bool: - tokens = _normalized_tokens(title or "") - if len(tokens) < ARXIV_TITLE_MIN_TOKENS: - return False - alpha_tokens = [token for token in tokens if _is_alpha_token(token)] - return len(alpha_tokens) >= ARXIV_TITLE_MIN_ALPHA_TOKENS - - -def _normalized_tokens(value: str) -> list[str]: - return [token for token in _TITLE_TOKEN_RE.findall(value.lower()) if token] - - -def _is_alpha_token(token: str) -> bool: - if len(token) < ARXIV_TITLE_TOKEN_MIN_LENGTH: - return False - return any(char.isalpha() for char in token) diff --git a/app/services/arxiv/parser.py b/app/services/arxiv/parser.py deleted file mode 100644 index f563493..0000000 --- a/app/services/arxiv/parser.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import xml.etree.ElementTree as ET - -from app.services.arxiv.errors import ArxivParseError -from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta -from app.services.publication_identifiers.normalize import normalize_arxiv_id - -_NAMESPACES = { - "atom": "http://www.w3.org/2005/Atom", - "opensearch": "http://a9.com/-/spec/opensearch/1.1/", - "arxiv": "http://arxiv.org/schemas/atom", -} - - -def parse_arxiv_feed(payload: str) -> ArxivFeed: - root = _parse_xml_root(payload) - opensearch = ArxivOpenSearchMeta( - total_results=_opensearch_int(root, "opensearch:totalResults"), - start_index=_opensearch_int(root, "opensearch:startIndex"), - items_per_page=_opensearch_int(root, "opensearch:itemsPerPage"), - ) - entries = [_parse_entry(entry_elem) for entry_elem in root.findall("atom:entry", _NAMESPACES)] - return ArxivFeed(entries=entries, opensearch=opensearch) - - -def _parse_xml_root(payload: str) -> ET.Element: - try: - return ET.fromstring(payload) - except ET.ParseError as exc: - raise ArxivParseError(f"Invalid arXiv XML payload: {exc}") from exc - - -def _opensearch_int(root: ET.Element, path: str) -> int: - text = _optional_text(root, path) - if text is None: - return 0 - try: - return int(text.strip()) - except ValueError as exc: - raise ArxivParseError(f"Invalid integer value at {path}: {text!r}") from exc - - -def _parse_entry(entry_elem: ET.Element) -> ArxivEntry: - entry_id_url = _required_text(entry_elem, "atom:id").strip() - arxiv_id = normalize_arxiv_id(entry_id_url) - title = _required_text(entry_elem, "atom:title").strip() - summary = (_optional_text(entry_elem, "atom:summary") or "").strip() - published = _optional_text(entry_elem, "atom:published") - updated = _optional_text(entry_elem, "atom:updated") - return ArxivEntry( - entry_id_url=entry_id_url, - arxiv_id=arxiv_id, - title=title, - summary=summary, - published=published, - updated=updated, - authors=_authors(entry_elem), - links=_links(entry_elem), - categories=_categories(entry_elem), - primary_category=_primary_category(entry_elem), - ) - - -def _required_text(elem: ET.Element, path: str) -> str: - text = _optional_text(elem, path) - if text is None or not text.strip(): - raise ArxivParseError(f"Missing required field: {path}") - return text - - -def _optional_text(elem: ET.Element, path: str) -> str | None: - node = elem.find(path, _NAMESPACES) - if node is None or node.text is None: - return None - return str(node.text) - - -def _authors(entry_elem: ET.Element) -> list[str]: - authors: list[str] = [] - for author in entry_elem.findall("atom:author", _NAMESPACES): - name = _optional_text(author, "atom:name") - if name: - authors.append(name.strip()) - return authors - - -def _links(entry_elem: ET.Element) -> list[str]: - values: list[str] = [] - for link in entry_elem.findall("atom:link", _NAMESPACES): - href = str(link.attrib.get("href") or "").strip() - if href: - values.append(href) - return values - - -def _categories(entry_elem: ET.Element) -> list[str]: - values: list[str] = [] - for cat in entry_elem.findall("atom:category", _NAMESPACES): - term = str(cat.attrib.get("term") or "").strip() - if term: - values.append(term) - return values - - -def _primary_category(entry_elem: ET.Element) -> str | None: - node = entry_elem.find("arxiv:primary_category", _NAMESPACES) - if node is None: - return None - value = str(node.attrib.get("term") or "").strip() - return value or None diff --git a/app/services/arxiv/rate_limit.py b/app/services/arxiv/rate_limit.py deleted file mode 100644 index 9d262d4..0000000 --- a/app/services/arxiv/rate_limit.py +++ /dev/null @@ -1,221 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta - -import httpx -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ArxivRuntimeState -from app.db.session import get_session_factory -from app.logging_utils import structured_log -from app.services.arxiv.constants import ( - ARXIV_RATE_LIMIT_LOCK_KEY, - ARXIV_RATE_LIMIT_LOCK_NAMESPACE, - ARXIV_RUNTIME_STATE_KEY, - ARXIV_SOURCE_PATH_UNKNOWN, -) -from app.services.arxiv.errors import ArxivRateLimitError -from app.settings import settings - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class ArxivCooldownStatus: - is_active: bool - remaining_seconds: float - cooldown_until: datetime | None - - -async def run_with_global_arxiv_limit( - *, - fetch: Callable[[], Awaitable[httpx.Response]], - source_path: str = ARXIV_SOURCE_PATH_UNKNOWN, -) -> httpx.Response: - response, hit_rate_limit = await _run_serialized_fetch( - fetch=fetch, - source_path=source_path, - ) - if hit_rate_limit: - raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch") - return response - - -async def get_arxiv_cooldown_status(*, now_utc: datetime | None = None) -> ArxivCooldownStatus: - timestamp = _normalize_datetime(now_utc) or datetime.now(UTC) - session_factory = get_session_factory() - async with session_factory() as db_session: - result = await db_session.execute( - select(ArxivRuntimeState.cooldown_until).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY) - ) - cooldown_until = _normalize_datetime(result.scalar_one_or_none()) - remaining_seconds = _cooldown_remaining_seconds(cooldown_until, now_utc=timestamp) - return ArxivCooldownStatus( - is_active=remaining_seconds > 0.0, - remaining_seconds=float(remaining_seconds), - cooldown_until=cooldown_until, - ) - - -async def _run_serialized_fetch( - *, - fetch: Callable[[], Awaitable[httpx.Response]], - source_path: str, -) -> tuple[httpx.Response, bool]: - session_factory = get_session_factory() - async with session_factory() as lock_session: - await _acquire_arxiv_lock(lock_session) - try: - async with session_factory() as db_session, db_session.begin(): - runtime_state = await _load_runtime_state_for_update(db_session) - wait_seconds = await _wait_for_allowed_slot_or_raise( - runtime_state, - source_path=source_path, - ) - runtime_state.next_allowed_at = datetime.now(UTC) + timedelta(seconds=_min_interval_seconds()) - - if wait_seconds > 0: - await asyncio.sleep(wait_seconds) - response = await fetch() - - async with session_factory() as db_session, db_session.begin(): - runtime_state = await _load_runtime_state_for_update(db_session) - hit_rate_limit = _record_post_response_state( - runtime_state, - response_status=int(response.status_code), - source_path=source_path, - ) - cooldown_until_value = runtime_state.cooldown_until - finally: - await _release_arxiv_lock(lock_session) - structured_log( - logger, - "info", - "arxiv.request_completed", - status_code=int(response.status_code), - wait_seconds=wait_seconds, - cooldown_remaining_seconds=_cooldown_remaining_seconds(cooldown_until_value, now_utc=datetime.now(UTC)), - source_path=source_path, - ) - return response, hit_rate_limit - - -async def _acquire_arxiv_lock(db_session: AsyncSession) -> None: - await db_session.execute( - text("SELECT pg_advisory_lock(:namespace, :lock_key)"), - { - "namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE, - "lock_key": ARXIV_RATE_LIMIT_LOCK_KEY, - }, - ) - - -async def _release_arxiv_lock(db_session: AsyncSession) -> None: - await db_session.execute( - text("SELECT pg_advisory_unlock(:namespace, :lock_key)"), - { - "namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE, - "lock_key": ARXIV_RATE_LIMIT_LOCK_KEY, - }, - ) - - -async def _load_runtime_state_for_update(db_session: AsyncSession) -> ArxivRuntimeState: - result = await db_session.execute( - select(ArxivRuntimeState).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY).with_for_update() - ) - state = result.scalar_one_or_none() - if state is not None: - return state - state = ArxivRuntimeState(state_key=ARXIV_RUNTIME_STATE_KEY) - db_session.add(state) - await db_session.flush() - return state - - -async def _wait_for_allowed_slot_or_raise( - runtime_state: ArxivRuntimeState, - *, - source_path: str, -) -> float: - now_utc = datetime.now(UTC) - cooldown_seconds = _cooldown_remaining_seconds(runtime_state.cooldown_until, now_utc=now_utc) - if cooldown_seconds > 0: - structured_log( - logger, - "info", - "arxiv.request_scheduled", - wait_seconds=0.0, - source_path=source_path, - cooldown_remaining_seconds=cooldown_seconds, - ) - raise ArxivRateLimitError(f"arXiv global cooldown active ({cooldown_seconds:.0f}s remaining)") - wait_seconds = _next_allowed_wait_seconds(runtime_state.next_allowed_at, now_utc=now_utc) - structured_log( - logger, - "info", - "arxiv.request_scheduled", - wait_seconds=wait_seconds, - source_path=source_path, - cooldown_remaining_seconds=0.0, - ) - return wait_seconds - - -def _record_post_response_state( - runtime_state: ArxivRuntimeState, - *, - response_status: int, - source_path: str, -) -> bool: - now_utc = datetime.now(UTC) - runtime_state.next_allowed_at = now_utc + timedelta(seconds=_min_interval_seconds()) - if response_status == 429: - cooldown_seconds = _cooldown_seconds() - runtime_state.cooldown_until = now_utc + timedelta(seconds=cooldown_seconds) - structured_log( - logger, - "warning", - "arxiv.cooldown_activated", - cooldown_remaining_seconds=cooldown_seconds, - source_path=source_path, - ) - return True - if _cooldown_remaining_seconds(runtime_state.cooldown_until, now_utc=now_utc) <= 0: - runtime_state.cooldown_until = None - return False - - -def _cooldown_remaining_seconds(cooldown_until: datetime | None, *, now_utc: datetime) -> float: - bounded = _normalize_datetime(cooldown_until) - if bounded is None: - return 0.0 - return max((bounded - now_utc).total_seconds(), 0.0) - - -def _next_allowed_wait_seconds(next_allowed_at: datetime | None, *, now_utc: datetime) -> float: - bounded = _normalize_datetime(next_allowed_at) - if bounded is None: - return 0.0 - return max((bounded - now_utc).total_seconds(), 0.0) - - -def _normalize_datetime(value: datetime | None) -> datetime | None: - if value is None: - return None - if value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value - - -def _min_interval_seconds() -> float: - return max(float(settings.arxiv_min_interval_seconds), 0.0) - - -def _cooldown_seconds() -> float: - return max(float(settings.arxiv_rate_limit_cooldown_seconds), 0.0) diff --git a/app/services/arxiv/types.py b/app/services/arxiv/types.py deleted file mode 100644 index 574ed93..0000000 --- a/app/services/arxiv/types.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Literal - -ArxivSortBy = Literal["relevance", "lastUpdatedDate", "submittedDate"] -ArxivSortOrder = Literal["ascending", "descending"] - - -@dataclass(frozen=True) -class ArxivOpenSearchMeta: - total_results: int = 0 - start_index: int = 0 - items_per_page: int = 0 - - -@dataclass(frozen=True) -class ArxivEntry: - entry_id_url: str - arxiv_id: str | None - title: str - summary: str - published: str | None - updated: str | None - authors: list[str] = field(default_factory=list) - links: list[str] = field(default_factory=list) - categories: list[str] = field(default_factory=list) - primary_category: str | None = None - - -@dataclass(frozen=True) -class ArxivFeed: - entries: list[ArxivEntry] = field(default_factory=list) - opensearch: ArxivOpenSearchMeta = field(default_factory=ArxivOpenSearchMeta) diff --git a/app/services/ingestion/queue.py b/app/services/continuation_queue.py similarity index 72% rename from app/services/ingestion/queue.py rename to app/services/continuation_queue.py index 8bdddbc..7d7d93e 100644 --- a/app/services/ingestion/queue.py +++ b/app/services/continuation_queue.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -41,57 +41,45 @@ def compute_backoff_seconds(*, base_seconds: int, attempt_count: int, max_second return min(seconds, maximum) -async def _get_item_for_user_scholar( +async def upsert_job( db_session: AsyncSession, *, user_id: int, scholar_profile_id: int, -) -> IngestionQueueItem | None: + resume_cstart: int, + reason: str, + run_id: int | None, + delay_seconds: int, +) -> IngestionQueueItem: + now = datetime.now(timezone.utc) + next_attempt_dt = now + timedelta(seconds=max(0, int(delay_seconds))) result = await db_session.execute( select(IngestionQueueItem).where( IngestionQueueItem.user_id == user_id, IngestionQueueItem.scholar_profile_id == scholar_profile_id, ) ) - return result.scalar_one_or_none() + item = result.scalar_one_or_none() + normalized_cstart = normalize_cstart(resume_cstart) + if item is None: + item = IngestionQueueItem( + user_id=user_id, + scholar_profile_id=scholar_profile_id, + resume_cstart=normalized_cstart, + reason=reason, + status=QueueItemStatus.QUEUED.value, + attempt_count=0, + next_attempt_dt=next_attempt_dt, + last_run_id=run_id, + last_error=None, + dropped_reason=None, + dropped_at=None, + created_at=now, + updated_at=now, + ) + db_session.add(item) + return item - -def _build_queue_item( - *, - now: datetime, - user_id: int, - scholar_profile_id: int, - normalized_cstart: int, - reason: str, - run_id: int | None, - next_attempt_dt: datetime, -) -> IngestionQueueItem: - return IngestionQueueItem( - user_id=user_id, - scholar_profile_id=scholar_profile_id, - resume_cstart=normalized_cstart, - reason=reason, - status=QueueItemStatus.QUEUED.value, - attempt_count=0, - next_attempt_dt=next_attempt_dt, - last_run_id=run_id, - last_error=None, - dropped_reason=None, - dropped_at=None, - created_at=now, - updated_at=now, - ) - - -def _update_existing_queue_item( - *, - item: IngestionQueueItem, - now: datetime, - normalized_cstart: int, - reason: str, - run_id: int | None, - next_attempt_dt: datetime, -) -> None: item.resume_cstart = normalized_cstart item.reason = reason if item.status == QueueItemStatus.DROPPED.value: @@ -103,47 +91,6 @@ def _update_existing_queue_item( item.dropped_reason = None item.dropped_at = None item.updated_at = now - - -async def upsert_job( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, - resume_cstart: int, - reason: str, - run_id: int | None, - delay_seconds: int, -) -> IngestionQueueItem: - now = datetime.now(UTC) - next_attempt_dt = now + timedelta(seconds=max(0, int(delay_seconds))) - item = await _get_item_for_user_scholar( - db_session, - user_id=user_id, - scholar_profile_id=scholar_profile_id, - ) - normalized_cstart = normalize_cstart(resume_cstart) - if item is None: - item = _build_queue_item( - now=now, - user_id=user_id, - scholar_profile_id=scholar_profile_id, - normalized_cstart=normalized_cstart, - reason=reason, - run_id=run_id, - next_attempt_dt=next_attempt_dt, - ) - db_session.add(item) - return item - - _update_existing_queue_item( - item=item, - now=now, - normalized_cstart=normalized_cstart, - reason=reason, - run_id=run_id, - next_attempt_dt=next_attempt_dt, - ) return item @@ -171,7 +118,9 @@ async def delete_job_by_id( *, job_id: int, ) -> bool: - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return False @@ -196,7 +145,6 @@ async def list_due_jobs( IngestionQueueItem.id.asc(), ) .limit(limit) - .with_for_update(skip_locked=True) ) rows = list(result.scalars().all()) jobs: list[ContinuationQueueJob] = [] @@ -221,8 +169,10 @@ async def increment_attempt_count( *, job_id: int, ) -> IngestionQueueItem | None: - now = datetime.now(UTC) - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return None @@ -236,8 +186,10 @@ async def reset_attempt_count( *, job_id: int, ) -> IngestionQueueItem | None: - now = datetime.now(UTC) - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return None @@ -254,8 +206,10 @@ async def reschedule_job( reason: str, error: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(UTC) - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return None @@ -275,8 +229,10 @@ async def mark_retrying( job_id: int, reason: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(UTC) - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return None @@ -296,8 +252,10 @@ async def mark_dropped( reason: str, error: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(UTC) - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return None @@ -318,8 +276,10 @@ async def mark_queued_now( reason: str, reset_attempt_count: bool = False, ) -> IngestionQueueItem | None: - now = datetime.now(UTC) - result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) item = result.scalar_one_or_none() if item is None: return None diff --git a/app/services/crossref/__init__.py b/app/services/crossref/__init__.py deleted file mode 100644 index 98882f0..0000000 --- a/app/services/crossref/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from app.services.crossref.application import discover_doi_for_publication - -__all__ = ["discover_doi_for_publication"] diff --git a/app/services/crossref/application.py b/app/services/crossref/application.py deleted file mode 100644 index a546a9f..0000000 --- a/app/services/crossref/application.py +++ /dev/null @@ -1,388 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import re -import threading -import time -from importlib.metadata import version as pkg_version -from typing import TYPE_CHECKING - -from crossref.restful import Etiquette, Works - -from app.logging_utils import structured_log -from app.services.doi.normalize import normalize_doi -from app.settings import settings - -_APP_VERSION = pkg_version("scholarr") - -if TYPE_CHECKING: - from app.services.publications.types import PublicationListItem, UnreadPublicationItem - -TOKEN_RE = re.compile(r"[a-z0-9]+") -NON_ALNUM_RE = re.compile(r"[^a-z0-9\s]+") -STOP_WORDS = {"the", "and", "for", "with", "from", "method", "study", "analysis"} -_RATE_LOCK = threading.Lock() -_LAST_REQUEST_AT = 0.0 -logger = logging.getLogger(__name__) -STRICT_TITLE_MATCH_THRESHOLD = 0.75 -RELAXED_TITLE_MATCH_THRESHOLD = 0.85 - - -def _rate_limit_wait(min_interval_seconds: float) -> None: - global _LAST_REQUEST_AT - interval = max(float(min_interval_seconds), 0.0) - with _RATE_LOCK: - elapsed = time.monotonic() - _LAST_REQUEST_AT - remaining = interval - elapsed - if remaining > 0: - time.sleep(remaining) - with _RATE_LOCK: - _LAST_REQUEST_AT = time.monotonic() - - -def _normalized_tokens(value: str) -> list[str]: - lowered = value.lower().replace("’", "'").replace("“", '"').replace("”", '"') - lowered = NON_ALNUM_RE.sub(" ", lowered) - return [token for token in TOKEN_RE.findall(lowered) if len(token) >= 3] - - -def _normalized_query(value: str) -> str: - tokens = [token for token in _normalized_tokens(value) if token not in STOP_WORDS] - if len(tokens) < 3: - tokens = _normalized_tokens(value) - if len(tokens) < 3: - return "" - return " ".join(tokens[:12]).strip() - - -def _query_author(value: str) -> str | None: - tokens = [token for token in value.strip().split() if token] - if len(tokens) < 2: - return None - return " ".join(tokens[:2])[:64] - - -def _author_surname(value: str) -> str | None: - tokens = [token for token in value.strip().split() if token] - if not tokens: - return None - return NON_ALNUM_RE.sub("", tokens[-1].lower()) or None - - -def _query_filters(year: int | None) -> list[tuple[str, str] | None]: - if year is None: - return [None] - return [ - (f"{year - 1}-01-01", f"{year + 1}-12-31"), - (f"{year}-01-01", f"{year}-12-31"), - None, - ] - - -def _candidate_title(item: dict) -> str: - titles = item.get("title") - if isinstance(titles, list) and titles: - return str(titles[0] or "") - return str(item.get("title") or "") - - -def _title_match_score(source: str, candidate: str) -> float: - source_tokens = {token for token in _normalized_tokens(source) if len(token) >= 3} - candidate_tokens = {token for token in _normalized_tokens(candidate) if len(token) >= 3} - if not source_tokens or not candidate_tokens: - return 0.0 - return len(source_tokens & candidate_tokens) / float(len(source_tokens)) - - -def _candidate_year(item: dict) -> int | None: - issued = item.get("issued") - if not isinstance(issued, dict): - return None - date_parts = issued.get("date-parts") - if not isinstance(date_parts, list) or not date_parts: - return None - first = date_parts[0] - if not isinstance(first, list) or not first: - return None - try: - return int(first[0]) - except (TypeError, ValueError): - return None - - -def _candidate_author_match(item: dict, surname: str | None) -> bool: - if not surname: - return True - authors = item.get("author") - if not isinstance(authors, list): - return False - for author in authors: - if not isinstance(author, dict): - continue - family = NON_ALNUM_RE.sub("", str(author.get("family") or "").lower()) - if family and family == surname: - return True - return False - - -def _candidate_rank(*, title: str, year: int | None, item: dict) -> tuple[float, str | None]: - doi = normalize_doi(str(item.get("DOI") or "")) - if doi is None: - return 0.0, None - score = _title_match_score(title, _candidate_title(item)) - candidate_year = _candidate_year(item) - if year is not None and candidate_year is not None: - if abs(year - candidate_year) > 1: - return 0.0, None - score += 0.1 - return score, doi - - -def _year_delta(source_year: int | None, candidate_year: int | None) -> int | None: - if source_year is None or candidate_year is None: - return None - return abs(int(source_year) - int(candidate_year)) - - -def _candidate_rank_relaxed( - *, - title: str, - year: int | None, - item: dict, - author_surname: str | None, -) -> tuple[float, str | None]: - doi = normalize_doi(str(item.get("DOI") or "")) - if doi is None: - return 0.0, None - score = _title_match_score(title, _candidate_title(item)) - if score <= 0: - return 0.0, None - candidate_year = _candidate_year(item) - delta = _year_delta(year, candidate_year) - if delta is not None: - if delta <= 1: - score += 0.05 - elif delta <= 3: - score += 0.0 - elif delta <= 5: - score -= 0.03 - else: - score -= 0.08 - if _candidate_author_match(item, author_surname): - score += 0.03 - return score, doi - - -def _best_candidate_doi_strict( - *, - title: str, - year: int | None, - items: list[dict], - author_surname: str | None, -) -> str | None: - best_score = 0.0 - best_doi: str | None = None - best_year: int | None = None - for item in items: - if not isinstance(item, dict): - continue - if not _candidate_author_match(item, author_surname): - continue - score, doi = _candidate_rank(title=title, year=year, item=item) - candidate_year = _candidate_year(item) - if doi is None or score < STRICT_TITLE_MATCH_THRESHOLD: - continue - if score > best_score: - best_score = score - best_doi = doi - best_year = candidate_year - continue - if abs(score - best_score) > 0.02: - continue - if best_year is None or candidate_year is None: - continue - if candidate_year < best_year: - best_doi = doi - best_year = candidate_year - return best_doi - - -def _best_candidate_doi_relaxed( - *, - title: str, - year: int | None, - items: list[dict], - author_surname: str | None, -) -> str | None: - best_score = 0.0 - best_doi: str | None = None - best_author_match = False - best_delta: int | None = None - best_year: int | None = None - for item in items: - if not isinstance(item, dict): - continue - score, doi = _candidate_rank_relaxed( - title=title, - year=year, - item=item, - author_surname=author_surname, - ) - if doi is None or score < RELAXED_TITLE_MATCH_THRESHOLD: - continue - candidate_year = _candidate_year(item) - candidate_author_match = _candidate_author_match(item, author_surname) - candidate_delta = _year_delta(year, candidate_year) - if score > best_score: - best_score = score - best_doi = doi - best_author_match = candidate_author_match - best_delta = candidate_delta - best_year = candidate_year - continue - if abs(score - best_score) > 0.02: - continue - if candidate_author_match and not best_author_match: - best_doi = doi - best_author_match = True - best_delta = candidate_delta - best_year = candidate_year - continue - if best_delta is None and candidate_delta is not None: - best_doi = doi - best_author_match = candidate_author_match - best_delta = candidate_delta - best_year = candidate_year - continue - if best_delta is not None and candidate_delta is not None and candidate_delta < best_delta: - best_doi = doi - best_author_match = candidate_author_match - best_delta = candidate_delta - best_year = candidate_year - continue - if best_year is None or candidate_year is None: - continue - if candidate_year < best_year: - best_doi = doi - best_author_match = candidate_author_match - best_delta = candidate_delta - best_year = candidate_year - return best_doi - - -def _best_candidate_doi( - *, - title: str, - year: int | None, - items: list[dict], - author_surname: str | None, -) -> str | None: - strict_match = _best_candidate_doi_strict( - title=title, - year=year, - items=items, - author_surname=author_surname, - ) - if strict_match: - return strict_match - return _best_candidate_doi_relaxed( - title=title, - year=year, - items=items, - author_surname=author_surname, - ) - - -def _works_client(email: str | None) -> Works: - if email: - etiquette = Etiquette(settings.app_name, _APP_VERSION, "https://scholarr.local", email) - return Works(etiquette=etiquette) - return Works() - - -def _fetch_items_sync( - *, - query: str, - author: str | None, - date_range: tuple[str, str] | None, - max_rows: int, - email: str | None, - min_interval_seconds: float, -) -> list[dict]: - _rate_limit_wait(min_interval_seconds) - works = _works_client(email) - params = {"bibliographic": query} - if author: - params["author"] = author - request = works.query(**params) - if date_range is not None: - from_date, until_date = date_range - request = request.filter(from_pub_date=from_date, until_pub_date=until_date) - request = request.select(["DOI", "title", "issued", "score", "author"]) - items: list[dict] = [] - for entry in request: - if isinstance(entry, dict): - items.append(entry) - if len(items) >= max(max_rows, 1): - break - return items - - -async def _fetch_items( - *, - query: str, - author: str | None, - date_range: tuple[str, str] | None, - max_rows: int, - email: str | None, -) -> list[dict]: - timeout = max(float(settings.crossref_timeout_seconds), 0.5) - try: - return await asyncio.wait_for( - asyncio.to_thread( - _fetch_items_sync, - query=query, - author=author, - date_range=date_range, - max_rows=max_rows, - email=email, - min_interval_seconds=settings.crossref_min_interval_seconds, - ), - timeout=timeout, - ) - except Exception as exc: - structured_log(logger, "warning", "crossref.fetch_failed", error=str(exc)) - return [] - - -async def discover_doi_for_publication( - *, - item: PublicationListItem | UnreadPublicationItem, - max_rows: int = 10, - email: str | None = None, -) -> str | None: - title = (item.title or "").strip() - query = _normalized_query(title) - if not query: - return None - author = _query_author(item.scholar_label) - author_surname = _author_surname(item.scholar_label) - for date_range in _query_filters(item.year): - items = await _fetch_items( - query=query, - author=author, - date_range=date_range, - max_rows=max_rows, - email=email, - ) - doi = _best_candidate_doi( - title=title, - year=item.year, - items=items, - author_surname=author_surname, - ) - if doi: - structured_log(logger, "debug", "crossref.doi_discovered") - return doi - return None diff --git a/app/services/dbops/__init__.py b/app/services/dbops/__init__.py deleted file mode 100644 index 3209dec..0000000 --- a/app/services/dbops/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from app.services.dbops.application import run_publication_link_repair -from app.services.dbops.integrity import collect_integrity_report -from app.services.dbops.near_duplicate_repair import ( - run_publication_near_duplicate_repair, -) -from app.services.dbops.query import list_repair_jobs - -__all__ = [ - "collect_integrity_report", - "list_repair_jobs", - "run_publication_link_repair", - "run_publication_near_duplicate_repair", -] diff --git a/app/services/dbops/application.py b/app/services/dbops/application.py deleted file mode 100644 index a5fa077..0000000 --- a/app/services/dbops/application.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import CursorResult, delete, exists, func, select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import DataRepairJob, IngestionQueueItem, Publication, ScholarProfile, ScholarPublication - -REPAIR_STATUS_PLANNED = "planned" -REPAIR_STATUS_RUNNING = "running" -REPAIR_STATUS_COMPLETED = "completed" -REPAIR_STATUS_FAILED = "failed" -SCOPE_MODE_SINGLE_USER = "single_user" -SCOPE_MODE_ALL_USERS = "all_users" - - -def _utcnow() -> datetime: - return datetime.now(UTC) - - -def _normalize_scope_mode(scope_mode: str) -> str: - normalized = scope_mode.strip().lower() - if normalized in {SCOPE_MODE_SINGLE_USER, SCOPE_MODE_ALL_USERS}: - return normalized - raise ValueError("Unknown scope mode.") - - -def _scope_user_id(*, scope_mode: str, user_id: int | None) -> int | None: - if scope_mode == SCOPE_MODE_SINGLE_USER: - if user_id is None: - raise ValueError("user_id is required when scope_mode=single_user.") - return int(user_id) - if user_id is not None: - raise ValueError("user_id must be omitted when scope_mode=all_users.") - return None - - -def _scope_payload( - *, - scope_mode: str, - user_id: int | None, - target_scholar_profile_ids: list[int], - orphan_gc: bool, -) -> dict[str, Any]: - payload: dict[str, Any] = { - "scope_mode": scope_mode, - "scholar_profile_ids": [int(value) for value in target_scholar_profile_ids], - "gc_orphan_publications": bool(orphan_gc), - } - if user_id is not None: - payload["user_id"] = int(user_id) - return payload - - -async def _target_scholar_profile_ids( - db_session: AsyncSession, - *, - scope_mode: str, - user_id: int | None, - scholar_profile_ids: list[int] | None, -) -> list[int]: - stmt = select(ScholarProfile.id) - if scope_mode == SCOPE_MODE_SINGLE_USER: - stmt = stmt.where(ScholarProfile.user_id == user_id) - if scholar_profile_ids: - normalized_ids = [int(value) for value in scholar_profile_ids] - stmt = stmt.where(ScholarProfile.id.in_(normalized_ids)) - result = await db_session.execute(stmt.order_by(ScholarProfile.id.asc())) - ids = [int(row[0]) for row in result.all()] - if not ids: - raise ValueError("No target scholar profiles found for the requested scope.") - return ids - - -async def _count_scope( - db_session: AsyncSession, - *, - user_id: int | None, - target_scholar_profile_ids: list[int], -) -> dict[str, int]: - links_result = await db_session.execute( - select(func.count()) - .select_from(ScholarPublication) - .where(ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids)) - ) - queue_stmt = ( - select(func.count()) - .select_from(IngestionQueueItem) - .where(IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids)) - ) - if user_id is not None: - queue_stmt = queue_stmt.where(IngestionQueueItem.user_id == user_id) - queue_result = await db_session.execute(queue_stmt) - return { - "target_scholar_count": len(target_scholar_profile_ids), - "links_in_scope": int(links_result.scalar_one() or 0), - "queue_items_in_scope": int(queue_result.scalar_one() or 0), - } - - -async def _count_orphan_publications(db_session: AsyncSession) -> int: - stmt = ( - select(func.count()) - .select_from(Publication) - .where(~exists(select(1).where(ScholarPublication.publication_id == Publication.id))) - ) - result = await db_session.execute(stmt) - return int(result.scalar_one() or 0) - - -async def _create_job( - db_session: AsyncSession, - *, - requested_by: str | None, - scope: dict[str, Any], - dry_run: bool, -) -> DataRepairJob: - job = DataRepairJob( - job_name="repair_publication_links", - requested_by=(requested_by or "").strip() or None, - scope=scope, - dry_run=dry_run, - status=REPAIR_STATUS_PLANNED, - summary={}, - ) - db_session.add(job) - await db_session.flush() - return job - - -def _job_summary( - *, - counts: dict[str, int], - dry_run: bool, - links_deleted: int, - queue_items_deleted: int, - scholars_reset: int, - orphan_publications_before: int, - orphan_publications_deleted: int, -) -> dict[str, Any]: - return { - **counts, - "dry_run": bool(dry_run), - "links_deleted": int(links_deleted), - "queue_items_deleted": int(queue_items_deleted), - "scholars_reset": int(scholars_reset), - "orphan_publications_before": int(orphan_publications_before), - "orphan_publications_deleted": int(orphan_publications_deleted), - } - - -async def _delete_links_for_targets(db_session: AsyncSession, *, target_scholar_profile_ids: list[int]) -> int: - result: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] - delete(ScholarPublication).where(ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids)) - ) - return int(result.rowcount or 0) - - -async def _delete_queue_for_targets( - db_session: AsyncSession, - *, - user_id: int | None, - target_scholar_profile_ids: list[int], -) -> int: - stmt = delete(IngestionQueueItem).where(IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids)) - if user_id is not None: - stmt = stmt.where(IngestionQueueItem.user_id == user_id) - result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] - return int(result.rowcount or 0) - - -async def _reset_scholar_tracking_state( - db_session: AsyncSession, - *, - user_id: int | None, - target_scholar_profile_ids: list[int], -) -> int: - stmt = update(ScholarProfile).where(ScholarProfile.id.in_(target_scholar_profile_ids)) - if user_id is not None: - stmt = stmt.where(ScholarProfile.user_id == user_id) - result: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] - stmt.values( - baseline_completed=False, - last_initial_page_fingerprint_sha256=None, - last_initial_page_checked_at=None, - last_run_dt=None, - last_run_status=None, - ) - ) - return int(result.rowcount or 0) - - -async def _delete_orphan_publications(db_session: AsyncSession) -> int: - result: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] - delete(Publication).where(~exists(select(1).where(ScholarPublication.publication_id == Publication.id))) - ) - return int(result.rowcount or 0) - - -async def _mutation_counts( - db_session: AsyncSession, - *, - user_id: int | None, - target_ids: list[int], - dry_run: bool, - gc_orphan_publications: bool, -) -> tuple[int, int, int, int]: - if dry_run: - return 0, 0, 0, 0 - links_deleted = await _delete_links_for_targets( - db_session, - target_scholar_profile_ids=target_ids, - ) - queue_deleted = await _delete_queue_for_targets( - db_session, - user_id=user_id, - target_scholar_profile_ids=target_ids, - ) - scholars_reset = await _reset_scholar_tracking_state( - db_session, - user_id=user_id, - target_scholar_profile_ids=target_ids, - ) - orphan_deleted = 0 - if gc_orphan_publications: - orphan_deleted = await _delete_orphan_publications(db_session) - return links_deleted, queue_deleted, scholars_reset, orphan_deleted - - -def _result_payload(*, job: DataRepairJob, scope: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: - return { - "job_id": int(job.id), - "status": job.status, - "scope": scope, - "summary": summary, - } - - -async def _complete_job( - db_session: AsyncSession, - *, - job: DataRepairJob, - summary: dict[str, Any], - scope: dict[str, Any], -) -> dict[str, Any]: - job.summary = summary - job.status = REPAIR_STATUS_COMPLETED - job.finished_at = _utcnow() - await db_session.commit() - return _result_payload(job=job, scope=scope, summary=summary) - - -async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None: - await db_session.rollback() - job.status = REPAIR_STATUS_FAILED - job.error_text = str(error) - job.finished_at = _utcnow() - db_session.add(job) - await db_session.commit() - - -async def _prepare_repair_job( - db_session: AsyncSession, - *, - scope_mode: str, - user_id: int | None, - scholar_profile_ids: list[int] | None, - dry_run: bool, - gc_orphan_publications: bool, - requested_by: str | None, -) -> tuple[int | None, list[int], dict[str, Any], DataRepairJob]: - normalized_scope = _normalize_scope_mode(scope_mode) - scope_user_id = _scope_user_id(scope_mode=normalized_scope, user_id=user_id) - target_ids = await _target_scholar_profile_ids( - db_session, - scope_mode=normalized_scope, - user_id=scope_user_id, - scholar_profile_ids=scholar_profile_ids, - ) - scope = _scope_payload( - scope_mode=normalized_scope, - user_id=scope_user_id, - target_scholar_profile_ids=target_ids, - orphan_gc=gc_orphan_publications, - ) - job = await _create_job(db_session, requested_by=requested_by, scope=scope, dry_run=dry_run) - job.status = REPAIR_STATUS_RUNNING - job.started_at = _utcnow() - return scope_user_id, target_ids, scope, job - - -async def _build_repair_summary( - db_session: AsyncSession, - *, - scope_user_id: int | None, - target_ids: list[int], - dry_run: bool, - gc_orphan_publications: bool, -) -> dict[str, Any]: - counts = await _count_scope(db_session, user_id=scope_user_id, target_scholar_profile_ids=target_ids) - orphan_before = await _count_orphan_publications(db_session) - links_deleted, queue_deleted, scholars_reset, orphan_deleted = await _mutation_counts( - db_session, - user_id=scope_user_id, - target_ids=target_ids, - dry_run=dry_run, - gc_orphan_publications=gc_orphan_publications, - ) - return _job_summary( - counts=counts, - dry_run=dry_run, - links_deleted=links_deleted, - queue_items_deleted=queue_deleted, - scholars_reset=scholars_reset, - orphan_publications_before=orphan_before, - orphan_publications_deleted=orphan_deleted, - ) - - -async def run_publication_link_repair( - db_session: AsyncSession, - *, - scope_mode: str = SCOPE_MODE_SINGLE_USER, - user_id: int | None = None, - scholar_profile_ids: list[int] | None = None, - dry_run: bool = True, - gc_orphan_publications: bool = False, - requested_by: str | None = None, -) -> dict[str, Any]: - scope_user_id, target_ids, scope, job = await _prepare_repair_job( - db_session, - scope_mode=scope_mode, - user_id=user_id, - scholar_profile_ids=scholar_profile_ids, - dry_run=dry_run, - gc_orphan_publications=gc_orphan_publications, - requested_by=requested_by, - ) - - try: - summary = await _build_repair_summary( - db_session, - scope_user_id=scope_user_id, - target_ids=target_ids, - dry_run=dry_run, - gc_orphan_publications=gc_orphan_publications, - ) - return await _complete_job(db_session, job=job, summary=summary, scope=scope) - except Exception as exc: - await _fail_job(db_session, job=job, error=exc) - raise diff --git a/app/services/dbops/integrity.py b/app/services/dbops/integrity.py deleted file mode 100644 index f05d9a3..0000000 --- a/app/services/dbops/integrity.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import func, select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import Publication - -INTEGRITY_CHECK_DEFS = ( - ( - "legacy_cluster_id_format", - "warning", - "Publications with non-namespaced cluster IDs.", - ), - ( - "negative_citation_count", - "failure", - "Publications with negative citation counts.", - ), - ( - "orphan_publications", - "warning", - "Publications with no scholar links.", - ), - ( - "orphan_scholar_publication_links", - "failure", - "Link rows missing parent scholar/publication.", - ), - ( - "duplicate_fingerprint_keys", - "failure", - "Duplicate publication fingerprint keys.", - ), - ( - "duplicate_cluster_ids", - "failure", - "Duplicate non-null publication cluster IDs.", - ), - ( - "missing_pdf_url", - "metric", - "Publications without a resolved PDF URL.", - ), -) - - -async def _legacy_cluster_id_count(db_session: AsyncSession) -> int: - stmt = ( - select(func.count()) - .select_from(Publication) - .where(Publication.cluster_id.is_not(None)) - .where(~Publication.cluster_id.like("cfv:%")) - .where(~Publication.cluster_id.like("cluster:%")) - ) - result = await db_session.execute(stmt) - return int(result.scalar_one() or 0) - - -async def _negative_citation_count(db_session: AsyncSession) -> int: - result = await db_session.execute( - select(func.count()).select_from(Publication).where(Publication.citation_count < 0) - ) - return int(result.scalar_one() or 0) - - -async def _missing_pdf_url_count(db_session: AsyncSession) -> int: - result = await db_session.execute( - select(func.count()).select_from(Publication).where(Publication.pdf_url.is_(None)) - ) - return int(result.scalar_one() or 0) - - -async def _count_from_sql(db_session: AsyncSession, *, sql: str) -> int: - result = await db_session.execute(text(sql)) - return int(result.scalar_one() or 0) - - -def _issues_for_severity(*, checks: list[dict[str, Any]], severity: str) -> list[str]: - return [row["name"] for row in checks if row["severity"] == severity and row["count"] > 0] - - -def _check_row(*, name: str, count: int, severity: str, message: str) -> dict[str, Any]: - return { - "name": name, - "count": int(count), - "severity": severity, - "message": message, - } - - -async def _collect_counts(db_session: AsyncSession) -> dict[str, int]: - orphan_publications = await _count_from_sql( - db_session, - sql=( - "SELECT count(*) FROM publications p " - "LEFT JOIN scholar_publications sp ON sp.publication_id = p.id " - "WHERE sp.publication_id IS NULL" - ), - ) - orphan_links = await _count_from_sql( - db_session, - sql=( - "SELECT count(*) FROM scholar_publications sp " - "LEFT JOIN publications p ON p.id = sp.publication_id " - "LEFT JOIN scholar_profiles s ON s.id = sp.scholar_profile_id " - "WHERE p.id IS NULL OR s.id IS NULL" - ), - ) - duplicate_fingerprints = await _count_from_sql( - db_session, - sql=( - "SELECT count(*) FROM (" - "SELECT fingerprint_sha256 FROM publications " - "GROUP BY fingerprint_sha256 HAVING count(*) > 1" - ") dup" - ), - ) - duplicate_cluster_ids = await _count_from_sql( - db_session, - sql=( - "SELECT count(*) FROM (" - "SELECT cluster_id FROM publications " - "WHERE cluster_id IS NOT NULL " - "GROUP BY cluster_id HAVING count(*) > 1" - ") dup" - ), - ) - return { - "legacy_cluster_id_format": await _legacy_cluster_id_count(db_session), - "negative_citation_count": await _negative_citation_count(db_session), - "orphan_publications": orphan_publications, - "orphan_scholar_publication_links": orphan_links, - "duplicate_fingerprint_keys": duplicate_fingerprints, - "duplicate_cluster_ids": duplicate_cluster_ids, - "missing_pdf_url": await _missing_pdf_url_count(db_session), - } - - -def _build_checks(*, counts: dict[str, int]) -> list[dict[str, Any]]: - return [ - _check_row( - name=name, - count=counts[name], - severity=severity, - message=message, - ) - for name, severity, message in INTEGRITY_CHECK_DEFS - ] - - -def _status_from_issues(*, failures: list[str], warnings: list[str]) -> str: - status = "ok" - if failures: - status = "failed" - elif warnings: - status = "warning" - return status - - -async def collect_integrity_report(db_session: AsyncSession) -> dict[str, Any]: - counts = await _collect_counts(db_session) - checks = _build_checks(counts=counts) - failures = _issues_for_severity(checks=checks, severity="failure") - warnings = _issues_for_severity(checks=checks, severity="warning") - - return { - "status": _status_from_issues(failures=failures, warnings=warnings), - "checked_at": datetime.now(UTC).isoformat(), - "failures": failures, - "warnings": warnings, - "checks": checks, - } diff --git a/app/services/dbops/near_duplicate_repair.py b/app/services/dbops/near_duplicate_repair.py deleted file mode 100644 index 70c902c..0000000 --- a/app/services/dbops/near_duplicate_repair.py +++ /dev/null @@ -1,228 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import DataRepairJob -from app.services.dbops.application import ( - REPAIR_STATUS_COMPLETED, - REPAIR_STATUS_FAILED, - REPAIR_STATUS_PLANNED, - REPAIR_STATUS_RUNNING, -) -from app.services.publications import dedup as dedup_service - -NEAR_DUP_JOB_NAME = "repair_publication_near_duplicates" -NEAR_DUP_DEFAULT_MAX_CLUSTERS = 25 - - -def _utcnow() -> datetime: - return datetime.now(UTC) - - -def _normalized_cluster_keys(values: list[str] | None) -> list[str]: - if not values: - return [] - seen: set[str] = set() - normalized: list[str] = [] - for value in values: - key = str(value or "").strip().lower() - if not key or key in seen: - continue - seen.add(key) - normalized.append(key) - return normalized - - -def _normalized_max_clusters(value: int) -> int: - return max(1, min(int(value), 200)) - - -def _scope_payload( - *, - similarity_threshold: float, - min_shared_tokens: int, - max_year_delta: int, - max_clusters: int, - selected_cluster_keys: list[str], -) -> dict[str, Any]: - return { - "similarity_threshold": float(similarity_threshold), - "min_shared_tokens": int(min_shared_tokens), - "max_year_delta": int(max_year_delta), - "max_clusters": int(max_clusters), - "selected_cluster_keys": selected_cluster_keys, - } - - -async def _create_job( - db_session: AsyncSession, - *, - requested_by: str | None, - scope: dict[str, Any], - dry_run: bool, -) -> DataRepairJob: - job = DataRepairJob( - job_name=NEAR_DUP_JOB_NAME, - requested_by=(requested_by or "").strip() or None, - scope=scope, - dry_run=bool(dry_run), - status=REPAIR_STATUS_PLANNED, - summary={}, - ) - db_session.add(job) - await db_session.flush() - job.status = REPAIR_STATUS_RUNNING - job.started_at = _utcnow() - return job - - -def _selected_clusters( - *, - clusters: list[dedup_service.NearDuplicateCluster], - selected_cluster_keys: list[str], -) -> tuple[list[dedup_service.NearDuplicateCluster], list[str]]: - if not selected_cluster_keys: - return [], [] - by_key = {cluster.cluster_key.lower(): cluster for cluster in clusters} - selected: list[dedup_service.NearDuplicateCluster] = [] - missing: list[str] = [] - for key in selected_cluster_keys: - cluster = by_key.get(key) - if cluster is None: - missing.append(key) - continue - selected.append(cluster) - return selected, missing - - -async def _merge_selected_clusters( - db_session: AsyncSession, - *, - selected_clusters: list[dedup_service.NearDuplicateCluster], -) -> int: - merged_publications = 0 - for cluster in selected_clusters: - merged_publications += await dedup_service.merge_near_duplicate_cluster( - db_session, - cluster=cluster, - ) - return merged_publications - - -def _clusters_payload( - *, - clusters: list[dedup_service.NearDuplicateCluster], - max_clusters: int, -) -> list[dict[str, object]]: - preview = clusters[:max_clusters] - return [dedup_service.near_duplicate_cluster_payload(cluster) for cluster in preview] - - -def _summary_payload( - *, - dry_run: bool, - cluster_count: int, - selected_count: int, - missing_count: int, - merged_publications: int, - max_clusters: int, -) -> dict[str, Any]: - return { - "dry_run": bool(dry_run), - "candidate_cluster_count": int(cluster_count), - "selected_cluster_count": int(selected_count), - "missing_selected_cluster_count": int(missing_count), - "merged_publications": int(merged_publications), - "preview_cluster_count": int(min(cluster_count, max_clusters)), - } - - -async def _complete_job( - db_session: AsyncSession, - *, - job: DataRepairJob, - scope: dict[str, Any], - summary: dict[str, Any], - clusters: list[dict[str, object]], -) -> dict[str, Any]: - job.status = REPAIR_STATUS_COMPLETED - job.finished_at = _utcnow() - job.summary = summary - await db_session.commit() - return { - "job_id": int(job.id), - "status": job.status, - "scope": scope, - "summary": summary, - "clusters": clusters, - } - - -async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None: - from sqlalchemy.orm import make_transient - - await db_session.rollback() - make_transient(job) - job.status = REPAIR_STATUS_FAILED - job.error_text = str(error) - job.finished_at = _utcnow() - db_session.add(job) - await db_session.commit() - - -async def run_publication_near_duplicate_repair( - db_session: AsyncSession, - *, - dry_run: bool = True, - similarity_threshold: float = dedup_service.NEAR_DUP_DEFAULT_SIMILARITY_THRESHOLD, - min_shared_tokens: int = dedup_service.NEAR_DUP_DEFAULT_MIN_SHARED_TOKENS, - max_year_delta: int = dedup_service.NEAR_DUP_DEFAULT_MAX_YEAR_DELTA, - max_clusters: int = NEAR_DUP_DEFAULT_MAX_CLUSTERS, - selected_cluster_keys: list[str] | None = None, - requested_by: str | None = None, -) -> dict[str, Any]: - normalized_keys = _normalized_cluster_keys(selected_cluster_keys) - bounded_clusters = _normalized_max_clusters(max_clusters) - scope = _scope_payload( - similarity_threshold=similarity_threshold, - min_shared_tokens=min_shared_tokens, - max_year_delta=max_year_delta, - max_clusters=bounded_clusters, - selected_cluster_keys=normalized_keys, - ) - job = await _create_job(db_session, requested_by=requested_by, scope=scope, dry_run=dry_run) - try: - clusters = await dedup_service.find_near_duplicate_clusters( - db_session, - similarity_threshold=similarity_threshold, - min_shared_tokens=min_shared_tokens, - max_year_delta=max_year_delta, - ) - selected, missing = _selected_clusters(clusters=clusters, selected_cluster_keys=normalized_keys) - merged_publications = 0 - if not dry_run: - if not selected: - raise ValueError("No selected near-duplicate clusters matched current data.") - merged_publications = await _merge_selected_clusters(db_session, selected_clusters=selected) - preview = _clusters_payload(clusters=clusters, max_clusters=bounded_clusters) - summary = _summary_payload( - dry_run=dry_run, - cluster_count=len(clusters), - selected_count=len(selected), - missing_count=len(missing), - merged_publications=merged_publications, - max_clusters=bounded_clusters, - ) - return await _complete_job( - db_session, - job=job, - scope=scope, - summary=summary, - clusters=preview, - ) - except Exception as exc: - await _fail_job(db_session, job=job, error=exc) - raise diff --git a/app/services/dbops/query.py b/app/services/dbops/query.py deleted file mode 100644 index f65c582..0000000 --- a/app/services/dbops/query.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import DataRepairJob - - -def _bounded_limit(limit: int) -> int: - return max(1, min(int(limit), 200)) - - -async def list_repair_jobs( - db_session: AsyncSession, - *, - limit: int = 50, -) -> list[DataRepairJob]: - bounded = _bounded_limit(limit) - result = await db_session.execute(select(DataRepairJob).order_by(DataRepairJob.created_at.desc()).limit(bounded)) - return list(result.scalars()) diff --git a/app/services/doi/normalize.py b/app/services/doi/normalize.py deleted file mode 100644 index 4719cf3..0000000 --- a/app/services/doi/normalize.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -import re -from urllib.parse import unquote - -DOI_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I) - - -def normalize_doi(value: str | None) -> str | None: - if not value: - return None - match = DOI_RE.search(unquote(value)) - if not match: - return None - return match.group(0).rstrip(" .;,)").lower() - - -def first_doi_from_texts(*values: str | None) -> str | None: - for value in values: - doi = normalize_doi(value) - if doi: - return doi - return None diff --git a/app/services/ingestion.py b/app/services/ingestion.py new file mode 100644 index 0000000..f8d8b60 --- /dev/null +++ b/app/services/ingestion.py @@ -0,0 +1,1356 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +import logging +import re +from typing import Any +from urllib.parse import urljoin + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + Publication, + RunStatus, + RunTriggerType, + ScholarProfile, + ScholarPublication, +) +from app.services import continuation_queue as queue_service +from app.services import run_safety as run_safety_service +from app.services import user_settings as user_settings_service +from app.services.scholar_parser import ( + ParseState, + ParsedProfilePage, + PublicationCandidate, + parse_profile_page, +) +from app.services.scholar_source import FetchResult, ScholarSource +from app.settings import settings + +TITLE_ALNUM_RE = re.compile(r"[^a-z0-9]+") +WORD_RE = re.compile(r"[a-z0-9]+") +HTML_TAG_RE = re.compile(r"<[^>]+>", re.S) +SPACE_RE = re.compile(r"\s+") +FAILED_STATES = { + ParseState.BLOCKED_OR_CAPTCHA.value, + ParseState.LAYOUT_CHANGED.value, + ParseState.NETWORK_ERROR.value, + "ingestion_error", +} +FAILURE_BUCKET_BLOCKED = "blocked_or_captcha" +FAILURE_BUCKET_NETWORK = "network_error" +FAILURE_BUCKET_LAYOUT = "layout_changed" +FAILURE_BUCKET_INGESTION = "ingestion_error" +FAILURE_BUCKET_OTHER = "other_failure" +RUN_LOCK_NAMESPACE = 8217 +RESUMABLE_PARTIAL_REASONS = { + "max_pages_reached", + "pagination_cursor_stalled", +} +RESUMABLE_PARTIAL_REASON_PREFIXES = ("page_state_network_error",) +INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS = 30 +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class RunExecutionSummary: + crawl_run_id: int + status: RunStatus + scholar_count: int + succeeded_count: int + failed_count: int + partial_count: int + new_publication_count: int + + +@dataclass(frozen=True) +class PagedParseResult: + fetch_result: FetchResult + parsed_page: ParsedProfilePage + first_page_fetch_result: FetchResult + first_page_parsed_page: ParsedProfilePage + first_page_fingerprint_sha256: str | None + publications: list[PublicationCandidate] + attempt_log: list[dict[str, Any]] + page_logs: list[dict[str, Any]] + pages_fetched: int + pages_attempted: int + has_more_remaining: bool + pagination_truncated_reason: str | None + continuation_cstart: int | None + skipped_no_change: bool + + +class RunAlreadyInProgressError(RuntimeError): + """Raised when a run lock for a user is already held by another process.""" + + +class RunBlockedBySafetyPolicyError(RuntimeError): + def __init__( + self, + *, + code: str, + message: str, + safety_state: dict[str, Any], + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.safety_state = safety_state + + +def _int_or_default(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _classify_failure_bucket(*, state: str, state_reason: str) -> str: + reason = state_reason.strip().lower() + normalized_state = state.strip().lower() + + if normalized_state == ParseState.BLOCKED_OR_CAPTCHA.value or reason.startswith("blocked_"): + return FAILURE_BUCKET_BLOCKED + if normalized_state == ParseState.NETWORK_ERROR.value or reason.startswith("network_"): + return FAILURE_BUCKET_NETWORK + if normalized_state == ParseState.LAYOUT_CHANGED.value: + return FAILURE_BUCKET_LAYOUT + if normalized_state == "ingestion_error": + return FAILURE_BUCKET_INGESTION + return FAILURE_BUCKET_OTHER + + +class ScholarIngestionService: + def __init__(self, *, source: ScholarSource) -> None: + self._source = source + + async def run_for_user( + self, + db_session: AsyncSession, + *, + user_id: int, + trigger_type: RunTriggerType, + request_delay_seconds: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + max_pages_per_scholar: int = 30, + page_size: int = 100, + scholar_profile_ids: set[int] | None = None, + start_cstart_by_scholar_id: dict[int, int] | None = None, + auto_queue_continuations: bool = True, + queue_delay_seconds: int = 60, + idempotency_key: str | None = None, + alert_blocked_failure_threshold: int = 1, + alert_network_failure_threshold: int = 2, + alert_retry_scheduled_threshold: int = 3, + ) -> RunExecutionSummary: + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=user_id, + ) + now_utc = datetime.now(timezone.utc) + previous_safety_state = run_safety_service.get_safety_event_context( + user_settings, + now_utc=now_utc, + ) + if run_safety_service.clear_expired_cooldown( + user_settings, + now_utc=now_utc, + ): + await db_session.commit() + await db_session.refresh(user_settings) + logger.info( + "ingestion.safety_cooldown_cleared", + extra={ + "event": "ingestion.safety_cooldown_cleared", + "user_id": user_id, + "reason": previous_safety_state.get("cooldown_reason"), + "cooldown_until": previous_safety_state.get("cooldown_until"), + "metric_name": "ingestion_safety_cooldown_cleared_total", + "metric_value": 1, + }, + ) + now_utc = datetime.now(timezone.utc) + if run_safety_service.is_cooldown_active(user_settings, now_utc=now_utc): + safety_state = run_safety_service.register_cooldown_blocked_start( + user_settings, + now_utc=now_utc, + ) + await db_session.commit() + logger.warning( + "ingestion.safety_policy_blocked_run_start", + extra={ + "event": "ingestion.safety_policy_blocked_run_start", + "user_id": user_id, + "trigger_type": trigger_type.value, + "reason": safety_state.get("cooldown_reason"), + "cooldown_until": safety_state.get("cooldown_until"), + "cooldown_remaining_seconds": safety_state.get("cooldown_remaining_seconds"), + "blocked_start_count": ( + (safety_state.get("counters") or {}).get("blocked_start_count") + ), + "metric_name": "ingestion_safety_run_start_blocked_total", + "metric_value": 1, + }, + ) + raise RunBlockedBySafetyPolicyError( + code="scrape_cooldown_active", + message="Scrape safety cooldown is active; run start is temporarily blocked.", + safety_state=safety_state, + ) + + lock_acquired = await self._try_acquire_user_lock( + db_session, + user_id=user_id, + ) + if not lock_acquired: + raise RunAlreadyInProgressError(f"Run already in progress for user_id={user_id}.") + + filtered_scholar_ids = ( + {int(value) for value in scholar_profile_ids} + if scholar_profile_ids is not None + else None + ) + start_cstart_map = { + int(key): max(0, int(value)) + for key, value in (start_cstart_by_scholar_id or {}).items() + } + + scholars_stmt = ( + select(ScholarProfile) + .where( + ScholarProfile.user_id == user_id, + ScholarProfile.is_enabled.is_(True), + ) + .order_by(ScholarProfile.created_at.asc(), ScholarProfile.id.asc()) + ) + if filtered_scholar_ids is not None: + scholars_stmt = scholars_stmt.where( + ScholarProfile.id.in_(filtered_scholar_ids) + ) + + scholars_result = await db_session.execute( + scholars_stmt + ) + scholars = list(scholars_result.scalars().all()) + if filtered_scholar_ids is not None: + found_ids = {int(scholar.id) for scholar in scholars} + missing_ids = filtered_scholar_ids - found_ids + for scholar_profile_id in missing_ids: + await queue_service.clear_job_for_scholar( + db_session, + user_id=user_id, + scholar_profile_id=scholar_profile_id, + ) + logger.info( + "ingestion.run_started", + extra={ + "event": "ingestion.run_started", + "user_id": user_id, + "trigger_type": trigger_type.value, + "scholar_count": len(scholars), + "is_filtered_run": filtered_scholar_ids is not None, + "request_delay_seconds": request_delay_seconds, + "network_error_retries": network_error_retries, + "retry_backoff_seconds": retry_backoff_seconds, + "max_pages_per_scholar": max_pages_per_scholar, + "page_size": page_size, + "idempotency_key": idempotency_key, + "alert_blocked_failure_threshold": alert_blocked_failure_threshold, + "alert_network_failure_threshold": alert_network_failure_threshold, + "alert_retry_scheduled_threshold": alert_retry_scheduled_threshold, + }, + ) + + run = CrawlRun( + user_id=user_id, + trigger_type=trigger_type, + status=RunStatus.RUNNING, + scholar_count=len(scholars), + new_pub_count=0, + idempotency_key=idempotency_key, + error_log={}, + ) + db_session.add(run) + await db_session.flush() + + succeeded_count = 0 + failed_count = 0 + partial_count = 0 + scholar_results: list[dict[str, Any]] = [] + + for index, scholar in enumerate(scholars): + if index > 0 and request_delay_seconds > 0: + await asyncio.sleep(float(request_delay_seconds)) + + run_dt = datetime.now(timezone.utc) + start_cstart = int(start_cstart_map.get(int(scholar.id), 0)) + + paged_parse_result = await self._fetch_and_parse_all_pages_with_retry( + scholar_id=scholar.scholar_id, + start_cstart=start_cstart, + request_delay_seconds=request_delay_seconds, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + max_pages=max_pages_per_scholar, + page_size=page_size, + previous_initial_page_fingerprint_sha256=scholar.last_initial_page_fingerprint_sha256, + ) + fetch_result = paged_parse_result.fetch_result + parsed_page = paged_parse_result.parsed_page + publications = paged_parse_result.publications + attempt_log = paged_parse_result.attempt_log + page_logs = paged_parse_result.page_logs + + first_page = paged_parse_result.first_page_parsed_page + if first_page.profile_name and not (scholar.display_name or "").strip(): + scholar.display_name = first_page.profile_name + if first_page.profile_image_url: + scholar.profile_image_url = first_page.profile_image_url + if paged_parse_result.first_page_fingerprint_sha256: + scholar.last_initial_page_fingerprint_sha256 = ( + paged_parse_result.first_page_fingerprint_sha256 + ) + scholar.last_initial_page_checked_at = run_dt + + logger.info( + "ingestion.scholar_parsed", + extra={ + "event": "ingestion.scholar_parsed", + "user_id": user_id, + "crawl_run_id": run.id, + "scholar_profile_id": scholar.id, + "scholar_id": scholar.scholar_id, + "state": parsed_page.state.value, + "publication_count": len(publications), + "has_show_more_button": parsed_page.has_show_more_button, + "pages_fetched": paged_parse_result.pages_fetched, + "pages_attempted": paged_parse_result.pages_attempted, + "has_more_remaining": paged_parse_result.has_more_remaining, + "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, + "warning_count": len(parsed_page.warnings), + "skipped_no_change": paged_parse_result.skipped_no_change, + }, + ) + + result_entry = { + "scholar_profile_id": scholar.id, + "scholar_id": scholar.scholar_id, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "outcome": "failed", + "attempt_count": len(attempt_log), + "publication_count": len(publications), + "start_cstart": start_cstart, + "articles_range": parsed_page.articles_range, + "warnings": parsed_page.warnings, + "has_show_more_button": parsed_page.has_show_more_button, + "pages_fetched": paged_parse_result.pages_fetched, + "pages_attempted": paged_parse_result.pages_attempted, + "has_more_remaining": paged_parse_result.has_more_remaining, + "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, + "continuation_cstart": paged_parse_result.continuation_cstart, + "skipped_no_change": paged_parse_result.skipped_no_change, + "initial_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, + } + if paged_parse_result.skipped_no_change: + scholar.last_run_status = RunStatus.SUCCESS + scholar.last_run_dt = run_dt + succeeded_count += 1 + result_entry["state"] = first_page.state.value + result_entry["state_reason"] = "no_change_initial_page_signature" + result_entry["outcome"] = "success" + result_entry["publication_count"] = 0 + result_entry["warnings"] = first_page.warnings + result_entry["debug"] = { + "state_reason": "no_change_initial_page_signature", + "first_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, + "attempt_log": attempt_log, + "page_logs": page_logs, + } + scholar_results.append(result_entry) + queue_reason, queue_cstart = self._resolve_continuation_queue_target( + outcome=str(result_entry.get("outcome", "")), + state=str(result_entry.get("state", "")), + pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, + continuation_cstart=paged_parse_result.continuation_cstart, + fallback_cstart=start_cstart, + ) + if auto_queue_continuations and queue_reason is not None: + await queue_service.upsert_job( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + resume_cstart=queue_cstart, + reason=queue_reason, + run_id=run.id, + delay_seconds=queue_delay_seconds, + ) + result_entry["continuation_enqueued"] = True + result_entry["continuation_reason"] = queue_reason + result_entry["continuation_cstart"] = queue_cstart + else: + cleared = await queue_service.clear_job_for_scholar( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + ) + if cleared: + result_entry["continuation_cleared"] = True + continue + + had_page_failure = parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS} + has_partial_publication_set = len(publications) > 0 and had_page_failure + is_partial_due_to_pagination = ( + paged_parse_result.has_more_remaining + or paged_parse_result.pagination_truncated_reason is not None + ) + + if (not had_page_failure) or has_partial_publication_set: + try: + discovered_publication_count = await self._upsert_profile_publications( + db_session, + run=run, + scholar=scholar, + publications=publications, + ) + run.new_pub_count = int(run.new_pub_count or 0) + discovered_publication_count + + is_partial_scholar = is_partial_due_to_pagination or has_partial_publication_set + scholar.last_run_status = ( + RunStatus.PARTIAL_FAILURE if is_partial_scholar else RunStatus.SUCCESS + ) + scholar.last_run_dt = run_dt + succeeded_count += 1 + result_entry["outcome"] = "partial" if is_partial_scholar else "success" + + if is_partial_scholar: + partial_count += 1 + result_entry["debug"] = self._build_failure_debug_context( + fetch_result=fetch_result, + parsed_page=parsed_page, + attempt_log=attempt_log, + page_logs=page_logs, + ) + except Exception as exc: + failed_count += 1 + scholar.last_run_status = RunStatus.FAILED + scholar.last_run_dt = run_dt + result_entry["state"] = "ingestion_error" + result_entry["state_reason"] = "publication_upsert_exception" + result_entry["outcome"] = "failed" + result_entry["error"] = str(exc) + result_entry["debug"] = self._build_failure_debug_context( + fetch_result=fetch_result, + parsed_page=parsed_page, + attempt_log=attempt_log, + page_logs=page_logs, + exception=exc, + ) + logger.exception( + "ingestion.scholar_failed", + extra={ + "event": "ingestion.scholar_failed", + "user_id": user_id, + "crawl_run_id": run.id, + "scholar_profile_id": scholar.id, + "scholar_id": scholar.scholar_id, + }, + ) + else: + failed_count += 1 + scholar.last_run_status = RunStatus.FAILED + scholar.last_run_dt = run_dt + result_entry["debug"] = self._build_failure_debug_context( + fetch_result=fetch_result, + parsed_page=parsed_page, + attempt_log=attempt_log, + page_logs=page_logs, + ) + logger.warning( + "ingestion.scholar_parse_failed", + extra={ + "event": "ingestion.scholar_parse_failed", + "user_id": user_id, + "crawl_run_id": run.id, + "scholar_profile_id": scholar.id, + "scholar_id": scholar.scholar_id, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "status_code": fetch_result.status_code, + }, + ) + + queue_reason, queue_cstart = self._resolve_continuation_queue_target( + outcome=str(result_entry.get("outcome", "")), + state=str(result_entry.get("state", "")), + pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, + continuation_cstart=paged_parse_result.continuation_cstart, + fallback_cstart=start_cstart, + ) + if auto_queue_continuations and queue_reason is not None: + await queue_service.upsert_job( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + resume_cstart=queue_cstart, + reason=queue_reason, + run_id=run.id, + delay_seconds=queue_delay_seconds, + ) + result_entry["continuation_enqueued"] = True + result_entry["continuation_reason"] = queue_reason + result_entry["continuation_cstart"] = queue_cstart + else: + cleared = await queue_service.clear_job_for_scholar( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + ) + if cleared: + result_entry["continuation_cleared"] = True + + scholar_results.append(result_entry) + + failed_state_counts: dict[str, int] = {} + failed_reason_counts: dict[str, int] = {} + scrape_failure_counts: dict[str, int] = {} + retries_scheduled_count = 0 + scholars_with_retries_count = 0 + retry_exhausted_count = 0 + for entry in scholar_results: + attempt_count = max(0, _int_or_default(entry.get("attempt_count"), 0)) + retries_for_entry = max(0, attempt_count - 1) + if retries_for_entry > 0: + retries_scheduled_count += retries_for_entry + scholars_with_retries_count += 1 + + outcome = str(entry.get("outcome", "")) + if outcome != "failed": + continue + + state = str(entry.get("state", "")).strip() + if state not in FAILED_STATES: + continue + failed_state_counts[state] = failed_state_counts.get(state, 0) + 1 + + reason = str(entry.get("state_reason", "")).strip() + if reason: + failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1 + + failure_bucket = _classify_failure_bucket(state=state, state_reason=reason) + scrape_failure_counts[failure_bucket] = ( + scrape_failure_counts.get(failure_bucket, 0) + 1 + ) + + if ( + state == ParseState.NETWORK_ERROR.value + and retries_for_entry > 0 + ): + retry_exhausted_count += 1 + + blocked_failure_count = int(scrape_failure_counts.get(FAILURE_BUCKET_BLOCKED, 0)) + network_failure_count = int(scrape_failure_counts.get(FAILURE_BUCKET_NETWORK, 0)) + + bounded_blocked_threshold = max(1, int(alert_blocked_failure_threshold)) + bounded_network_threshold = max(1, int(alert_network_failure_threshold)) + bounded_retry_threshold = max(1, int(alert_retry_scheduled_threshold)) + alert_flags = { + "blocked_failure_threshold_exceeded": blocked_failure_count >= bounded_blocked_threshold, + "network_failure_threshold_exceeded": network_failure_count >= bounded_network_threshold, + "retry_scheduled_threshold_exceeded": retries_scheduled_count >= bounded_retry_threshold, + } + + if alert_flags["blocked_failure_threshold_exceeded"]: + logger.warning( + "ingestion.alert_blocked_failure_threshold_exceeded", + extra={ + "event": "ingestion.alert_blocked_failure_threshold_exceeded", + "user_id": user_id, + "crawl_run_id": run.id, + "blocked_failure_count": blocked_failure_count, + "threshold": bounded_blocked_threshold, + "metric_name": "ingestion_blocked_failure_threshold_exceeded_total", + "metric_value": 1, + }, + ) + if alert_flags["network_failure_threshold_exceeded"]: + logger.warning( + "ingestion.alert_network_failure_threshold_exceeded", + extra={ + "event": "ingestion.alert_network_failure_threshold_exceeded", + "user_id": user_id, + "crawl_run_id": run.id, + "network_failure_count": network_failure_count, + "threshold": bounded_network_threshold, + "metric_name": "ingestion_network_failure_threshold_exceeded_total", + "metric_value": 1, + }, + ) + if alert_flags["retry_scheduled_threshold_exceeded"]: + logger.warning( + "ingestion.alert_retry_scheduled_threshold_exceeded", + extra={ + "event": "ingestion.alert_retry_scheduled_threshold_exceeded", + "user_id": user_id, + "crawl_run_id": run.id, + "retries_scheduled_count": retries_scheduled_count, + "threshold": bounded_retry_threshold, + "metric_name": "ingestion_retry_scheduled_threshold_exceeded_total", + "metric_value": 1, + }, + ) + + pre_apply_safety_state = run_safety_service.get_safety_event_context( + user_settings, + now_utc=datetime.now(timezone.utc), + ) + safety_state, cooldown_trigger_reason = run_safety_service.apply_run_safety_outcome( + user_settings, + run_id=int(run.id), + blocked_failure_count=blocked_failure_count, + network_failure_count=network_failure_count, + blocked_failure_threshold=bounded_blocked_threshold, + network_failure_threshold=bounded_network_threshold, + blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds, + network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds, + now_utc=datetime.now(timezone.utc), + ) + if cooldown_trigger_reason is not None: + logger.warning( + "ingestion.safety_cooldown_entered", + extra={ + "event": "ingestion.safety_cooldown_entered", + "user_id": user_id, + "crawl_run_id": run.id, + "reason": cooldown_trigger_reason, + "blocked_failure_count": blocked_failure_count, + "network_failure_count": network_failure_count, + "blocked_failure_threshold": bounded_blocked_threshold, + "network_failure_threshold": bounded_network_threshold, + "cooldown_until": safety_state.get("cooldown_until"), + "cooldown_remaining_seconds": safety_state.get("cooldown_remaining_seconds"), + "safety_counters": safety_state.get("counters", {}), + "metric_name": "ingestion_safety_cooldown_entered_total", + "metric_value": 1, + }, + ) + elif pre_apply_safety_state.get("cooldown_active") and not safety_state.get("cooldown_active"): + logger.info( + "ingestion.safety_cooldown_cleared", + extra={ + "event": "ingestion.safety_cooldown_cleared", + "user_id": user_id, + "crawl_run_id": run.id, + "reason": pre_apply_safety_state.get("cooldown_reason"), + "cooldown_until": pre_apply_safety_state.get("cooldown_until"), + "metric_name": "ingestion_safety_cooldown_cleared_total", + "metric_value": 1, + }, + ) + + run.end_dt = datetime.now(timezone.utc) + run.status = self._resolve_run_status( + scholar_count=len(scholars), + succeeded_count=succeeded_count, + failed_count=failed_count, + partial_count=partial_count, + ) + run.error_log = { + "scholar_results": scholar_results, + "summary": { + "succeeded_count": succeeded_count, + "failed_count": failed_count, + "partial_count": partial_count, + "failed_state_counts": failed_state_counts, + "failed_reason_counts": failed_reason_counts, + "scrape_failure_counts": scrape_failure_counts, + "retry_counts": { + "retries_scheduled_count": retries_scheduled_count, + "scholars_with_retries_count": scholars_with_retries_count, + "retry_exhausted_count": retry_exhausted_count, + }, + "alert_thresholds": { + "blocked_failure_threshold": bounded_blocked_threshold, + "network_failure_threshold": bounded_network_threshold, + "retry_scheduled_threshold": bounded_retry_threshold, + }, + "alert_flags": alert_flags, + }, + "meta": { + "idempotency_key": idempotency_key, + } + if idempotency_key + else {}, + } + + await db_session.commit() + logger.info( + "ingestion.run_completed", + extra={ + "event": "ingestion.run_completed", + "user_id": user_id, + "crawl_run_id": run.id, + "status": run.status.value, + "scholar_count": len(scholars), + "succeeded_count": succeeded_count, + "failed_count": failed_count, + "partial_count": partial_count, + "new_publication_count": run.new_pub_count, + "blocked_failure_count": blocked_failure_count, + "network_failure_count": network_failure_count, + "retries_scheduled_count": retries_scheduled_count, + "alert_flags": alert_flags, + }, + ) + + return RunExecutionSummary( + crawl_run_id=run.id, + status=run.status, + scholar_count=len(scholars), + succeeded_count=succeeded_count, + failed_count=failed_count, + partial_count=partial_count, + new_publication_count=run.new_pub_count, + ) + + async def _fetch_profile_page( + self, + *, + scholar_id: str, + cstart: int, + page_size: int, + ) -> FetchResult: + try: + page_fetcher = getattr(self._source, "fetch_profile_page_html", None) + if callable(page_fetcher): + return await page_fetcher( + scholar_id, + cstart=cstart, + pagesize=page_size, + ) + if cstart <= 0: + return await self._source.fetch_profile_html(scholar_id) + return FetchResult( + requested_url=( + "https://scholar.google.com/citations" + f"?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" + ), + status_code=None, + final_url=None, + body="", + error="source_does_not_support_pagination", + ) + except Exception as exc: + logger.exception( + "ingestion.fetch_unexpected_error", + extra={ + "event": "ingestion.fetch_unexpected_error", + "scholar_id": scholar_id, + "cstart": cstart, + "page_size": page_size, + }, + ) + return FetchResult( + requested_url=( + "https://scholar.google.com/citations" + f"?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" + ), + status_code=None, + final_url=None, + body="", + error=str(exc), + ) + + async def _fetch_and_parse_page_with_retry( + self, + *, + scholar_id: str, + cstart: int, + page_size: int, + network_error_retries: int, + retry_backoff_seconds: float, + ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: + max_attempts = max(1, int(network_error_retries) + 1) + backoff = max(float(retry_backoff_seconds), 0.0) + attempt_log: list[dict[str, Any]] = [] + fetch_result: FetchResult | None = None + parsed_page: ParsedProfilePage | None = None + + for attempt_index in range(max_attempts): + fetch_result = await self._fetch_profile_page( + scholar_id=scholar_id, + cstart=cstart, + page_size=page_size, + ) + parsed_page = parse_profile_page(fetch_result) + attempt_log.append( + { + "attempt": attempt_index + 1, + "cstart": cstart, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "status_code": fetch_result.status_code, + "fetch_error": fetch_result.error, + } + ) + + should_retry = ( + parsed_page.state == ParseState.NETWORK_ERROR + and attempt_index < max_attempts - 1 + ) + if not should_retry: + break + + sleep_seconds = backoff * (2**attempt_index) + logger.warning( + "ingestion.scholar_retry_scheduled", + extra={ + "event": "ingestion.scholar_retry_scheduled", + "scholar_id": scholar_id, + "cstart": cstart, + "attempt": attempt_index + 1, + "next_attempt": attempt_index + 2, + "sleep_seconds": sleep_seconds, + "state_reason": parsed_page.state_reason, + }, + ) + if sleep_seconds > 0: + await asyncio.sleep(sleep_seconds) + + if fetch_result is None or parsed_page is None: + raise RuntimeError("Fetch-and-parse retry loop produced no result.") + return fetch_result, parsed_page, attempt_log + + async def _fetch_and_parse_all_pages_with_retry( + self, + *, + scholar_id: str, + start_cstart: int, + request_delay_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + max_pages: int, + page_size: int, + previous_initial_page_fingerprint_sha256: str | None = None, + ) -> PagedParseResult: + bounded_max_pages = max(1, int(max_pages)) + bounded_page_size = max(1, int(page_size)) + + ( + fetch_result, + parsed_page, + first_attempt_log, + ) = await self._fetch_and_parse_page_with_retry( + scholar_id=scholar_id, + cstart=start_cstart, + page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + ) + first_page_fetch_result = fetch_result + first_page_parsed_page = parsed_page + first_page_fingerprint_sha256 = build_initial_page_fingerprint(parsed_page) + + attempt_log: list[dict[str, Any]] = list(first_attempt_log) + page_logs: list[dict[str, Any]] = [] + page_logs.append( + { + "page": 1, + "cstart": start_cstart, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "status_code": fetch_result.status_code, + "publication_count": len(parsed_page.publications), + "articles_range": parsed_page.articles_range, + "has_show_more_button": parsed_page.has_show_more_button, + "warning_codes": parsed_page.warnings, + "attempt_count": len(first_attempt_log), + } + ) + pages_attempted = 1 + + should_skip_no_change = ( + start_cstart <= 0 + and first_page_fingerprint_sha256 is not None + and previous_initial_page_fingerprint_sha256 is not None + and first_page_fingerprint_sha256 == previous_initial_page_fingerprint_sha256 + and parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} + ) + if should_skip_no_change: + return PagedParseResult( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=[], + attempt_log=attempt_log, + page_logs=page_logs, + pages_fetched=1, + pages_attempted=pages_attempted, + has_more_remaining=False, + pagination_truncated_reason=None, + continuation_cstart=None, + skipped_no_change=True, + ) + + # Immediate hard failure: nothing to salvage. + if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + return PagedParseResult( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=[], + attempt_log=attempt_log, + page_logs=page_logs, + pages_fetched=0, + pages_attempted=pages_attempted, + has_more_remaining=False, + pagination_truncated_reason=None, + continuation_cstart=( + start_cstart if parsed_page.state == ParseState.NETWORK_ERROR else None + ), + skipped_no_change=False, + ) + + publications = list(parsed_page.publications) + pages_fetched = 1 + has_more_remaining = False + pagination_truncated_reason: str | None = None + continuation_cstart: int | None = None + current_cstart = start_cstart + next_cstart = _next_cstart_value( + articles_range=parsed_page.articles_range, + fallback=current_cstart + len(parsed_page.publications), + ) + + while parsed_page.has_show_more_button: + if pages_fetched >= bounded_max_pages: + has_more_remaining = True + pagination_truncated_reason = "max_pages_reached" + continuation_cstart = next_cstart if next_cstart > current_cstart else current_cstart + break + if next_cstart <= current_cstart: + has_more_remaining = True + pagination_truncated_reason = "pagination_cursor_stalled" + continuation_cstart = current_cstart + break + if request_delay_seconds > 0: + await asyncio.sleep(float(request_delay_seconds)) + + current_cstart = next_cstart + ( + page_fetch_result, + page_parsed, + page_attempt_log, + ) = await self._fetch_and_parse_page_with_retry( + scholar_id=scholar_id, + cstart=current_cstart, + page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + ) + + pages_attempted += 1 + attempt_log.extend(page_attempt_log) + page_logs.append( + { + "page": pages_attempted, + "cstart": current_cstart, + "state": page_parsed.state.value, + "state_reason": page_parsed.state_reason, + "status_code": page_fetch_result.status_code, + "publication_count": len(page_parsed.publications), + "articles_range": page_parsed.articles_range, + "has_show_more_button": page_parsed.has_show_more_button, + "warning_codes": page_parsed.warnings, + "attempt_count": len(page_attempt_log), + } + ) + + fetch_result = page_fetch_result + parsed_page = page_parsed + if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + has_more_remaining = True + pagination_truncated_reason = f"page_state_{parsed_page.state.value}" + continuation_cstart = current_cstart + break + + # Google may keep a stale/disabled "show more" marker while returning an empty tail page. + # Treat this as a terminal page to avoid false cursor-stalled partial runs. + if parsed_page.state == ParseState.NO_RESULTS and len(parsed_page.publications) == 0: + pages_fetched += 1 + break + + pages_fetched += 1 + publications.extend(parsed_page.publications) + next_cstart = _next_cstart_value( + articles_range=parsed_page.articles_range, + fallback=current_cstart + len(parsed_page.publications), + ) + + return PagedParseResult( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=_dedupe_publication_candidates(publications), + attempt_log=attempt_log, + page_logs=page_logs, + pages_fetched=pages_fetched, + pages_attempted=pages_attempted, + has_more_remaining=has_more_remaining, + pagination_truncated_reason=pagination_truncated_reason, + continuation_cstart=continuation_cstart, + skipped_no_change=False, + ) + + async def _upsert_profile_publications( + self, + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + publications: list[PublicationCandidate], + ) -> int: + seen_publication_ids: set[int] = set() + discovered_count = 0 + + for candidate in publications: + publication = await self._resolve_publication(db_session, candidate) + if publication.id in seen_publication_ids: + continue + seen_publication_ids.add(publication.id) + + link_result = await db_session.execute( + select(ScholarPublication).where( + ScholarPublication.scholar_profile_id == scholar.id, + ScholarPublication.publication_id == publication.id, + ) + ) + link = link_result.scalar_one_or_none() + if link is not None: + continue + + link = ScholarPublication( + scholar_profile_id=scholar.id, + publication_id=publication.id, + is_read=False, + first_seen_run_id=run.id, + ) + db_session.add(link) + discovered_count += 1 + + logger.debug( + "ingestion.publication_discovered", + extra={ + "event": "ingestion.publication_discovered", + "scholar_profile_id": scholar.id, + "publication_id": publication.id, + "crawl_run_id": run.id, + }, + ) + + if not scholar.baseline_completed: + scholar.baseline_completed = True + + return discovered_count + + async def _resolve_publication( + self, + db_session: AsyncSession, + candidate: PublicationCandidate, + ) -> Publication: + fingerprint = build_publication_fingerprint(candidate) + + publication: Publication | None = None + cluster_publication: Publication | None = None + + if candidate.cluster_id: + cluster_result = await db_session.execute( + select(Publication).where(Publication.cluster_id == candidate.cluster_id) + ) + cluster_publication = cluster_result.scalar_one_or_none() + publication = cluster_publication + + if publication is None: + fingerprint_result = await db_session.execute( + select(Publication).where(Publication.fingerprint_sha256 == fingerprint) + ) + publication = fingerprint_result.scalar_one_or_none() + + if publication is not None and cluster_publication is not None and publication.id != cluster_publication.id: + publication = cluster_publication + + if publication is None: + publication = Publication( + cluster_id=candidate.cluster_id, + fingerprint_sha256=fingerprint, + title_raw=candidate.title, + title_normalized=normalize_title(candidate.title), + year=candidate.year, + citation_count=int(candidate.citation_count or 0), + author_text=candidate.authors_text, + venue_text=candidate.venue_text, + pub_url=build_publication_url(candidate.title_url), + pdf_url=None, + ) + db_session.add(publication) + await db_session.flush() + logger.debug( + "ingestion.publication_created", + extra={ + "event": "ingestion.publication_created", + "publication_id": publication.id, + "cluster_id": publication.cluster_id, + }, + ) + return publication + + if candidate.cluster_id and publication.cluster_id is None: + publication.cluster_id = candidate.cluster_id + publication.title_raw = candidate.title + publication.title_normalized = normalize_title(candidate.title) + if candidate.year is not None: + publication.year = candidate.year + if candidate.citation_count is not None: + publication.citation_count = int(candidate.citation_count) + if candidate.authors_text: + publication.author_text = candidate.authors_text + if candidate.venue_text: + publication.venue_text = candidate.venue_text + if candidate.title_url: + publication.pub_url = build_publication_url(candidate.title_url) + + return publication + + def _resolve_run_status( + self, + *, + scholar_count: int, + succeeded_count: int, + failed_count: int, + partial_count: int, + ) -> RunStatus: + if scholar_count == 0: + return RunStatus.SUCCESS + if failed_count == scholar_count: + return RunStatus.FAILED + if failed_count > 0 or partial_count > 0: + return RunStatus.PARTIAL_FAILURE + if succeeded_count > 0: + return RunStatus.SUCCESS + return RunStatus.FAILED + + def _resolve_continuation_queue_target( + self, + *, + outcome: str, + state: str, + pagination_truncated_reason: str | None, + continuation_cstart: int | None, + fallback_cstart: int, + ) -> tuple[str | None, int]: + if outcome == "partial": + reason = (pagination_truncated_reason or "").strip() + if reason in RESUMABLE_PARTIAL_REASONS or reason.startswith( + RESUMABLE_PARTIAL_REASON_PREFIXES + ): + return reason, queue_service.normalize_cstart( + continuation_cstart if continuation_cstart is not None else fallback_cstart + ) + return None, queue_service.normalize_cstart(fallback_cstart) + + if outcome == "failed" and state == ParseState.NETWORK_ERROR.value: + return "network_error_retry", queue_service.normalize_cstart( + continuation_cstart if continuation_cstart is not None else fallback_cstart + ) + + return None, queue_service.normalize_cstart(fallback_cstart) + + def _build_failure_debug_context( + self, + *, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + attempt_log: list[dict[str, Any]], + page_logs: list[dict[str, Any]] | None = None, + exception: Exception | None = None, + ) -> dict[str, Any]: + context: dict[str, Any] = { + "requested_url": fetch_result.requested_url, + "final_url": fetch_result.final_url, + "status_code": fetch_result.status_code, + "fetch_error": fetch_result.error, + "state_reason": parsed_page.state_reason, + "profile_name": parsed_page.profile_name, + "profile_image_url": parsed_page.profile_image_url, + "articles_range": parsed_page.articles_range, + "has_show_more_button": parsed_page.has_show_more_button, + "has_operation_error_banner": parsed_page.has_operation_error_banner, + "warning_codes": parsed_page.warnings, + "marker_counts_nonzero": { + key: value for key, value in parsed_page.marker_counts.items() if value > 0 + }, + "body_length": len(fetch_result.body), + "body_sha256": hashlib.sha256(fetch_result.body.encode("utf-8")).hexdigest() + if fetch_result.body + else None, + "body_excerpt": _build_body_excerpt(fetch_result.body), + "attempt_log": attempt_log, + } + if page_logs: + context["page_logs"] = page_logs + if exception is not None: + context["exception_type"] = type(exception).__name__ + context["exception_message"] = str(exception) + return context + + async def _try_acquire_user_lock( + self, + db_session: AsyncSession, + *, + user_id: int, + ) -> bool: + result = await db_session.execute( + text( + "SELECT pg_try_advisory_xact_lock(:namespace, :user_key)" + ), + { + "namespace": RUN_LOCK_NAMESPACE, + "user_key": int(user_id), + }, + ) + return bool(result.scalar_one()) + + +def normalize_title(value: str) -> str: + lowered = value.lower() + cleaned = TITLE_ALNUM_RE.sub("", lowered) + return cleaned + + +def _first_author_last_name(authors_text: str | None) -> str: + if not authors_text: + return "" + first_author = authors_text.split(",", maxsplit=1)[0].strip().lower() + words = WORD_RE.findall(first_author) + if not words: + return "" + return words[-1] + + +def _first_venue_word(venue_text: str | None) -> str: + if not venue_text: + return "" + words = WORD_RE.findall(venue_text.lower()) + if not words: + return "" + return words[0] + + +def build_publication_fingerprint(candidate: PublicationCandidate) -> str: + canonical = "|".join( + [ + normalize_title(candidate.title), + str(candidate.year) if candidate.year is not None else "", + _first_author_last_name(candidate.authors_text), + _first_venue_word(candidate.venue_text), + ] + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_initial_page_fingerprint(parsed_page: ParsedProfilePage) -> str | None: + if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + return None + + normalized_rows: list[dict[str, Any]] = [] + for publication in parsed_page.publications[:INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS]: + normalized_rows.append( + { + "cluster_id": publication.cluster_id or "", + "title_normalized": normalize_title(publication.title), + "year": publication.year, + "citation_count": publication.citation_count, + } + ) + + payload = { + "state": parsed_page.state.value, + "articles_range": parsed_page.articles_range or "", + "has_show_more_button": parsed_page.has_show_more_button, + "profile_name": parsed_page.profile_name or "", + "publications": normalized_rows, + } + canonical = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_publication_url(path_or_url: str | None) -> str | None: + if not path_or_url: + return None + return urljoin("https://scholar.google.com", path_or_url) + + +def _next_cstart_value(*, articles_range: str | None, fallback: int) -> int: + if articles_range: + numbers = re.findall(r"\d+", articles_range) + if len(numbers) >= 2: + try: + return int(numbers[1]) + except ValueError: + pass + return int(fallback) + + +def _dedupe_publication_candidates( + publications: list[PublicationCandidate], +) -> list[PublicationCandidate]: + deduped: list[PublicationCandidate] = [] + seen: set[str] = set() + for publication in publications: + if publication.cluster_id: + identity = f"cluster:{publication.cluster_id}" + else: + identity = "|".join( + [ + "fallback", + normalize_title(publication.title), + str(publication.year) if publication.year is not None else "", + publication.authors_text or "", + publication.venue_text or "", + ] + ) + if identity in seen: + continue + seen.add(identity) + deduped.append(publication) + return deduped + + +def _build_body_excerpt(body: str, *, max_chars: int = 220) -> str | None: + if not body: + return None + flattened = SPACE_RE.sub(" ", HTML_TAG_RE.sub(" ", body)).strip() + if not flattened: + return None + if len(flattened) <= max_chars: + return flattened + return f"{flattened[:max_chars - 1]}..." diff --git a/app/services/ingestion/__init__.py b/app/services/ingestion/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/app/services/ingestion/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/app/services/ingestion/application.py b/app/services/ingestion/application.py deleted file mode 100644 index 1a6293e..0000000 --- a/app/services/ingestion/application.py +++ /dev/null @@ -1,550 +0,0 @@ -from __future__ import annotations - -import logging -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import select, text -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - RunStatus, - RunTriggerType, - ScholarProfile, -) -from app.logging_utils import structured_log -from app.services.ingestion import queue as queue_service -from app.services.ingestion.constants import RUN_LOCK_NAMESPACE -from app.services.ingestion.enrichment import EnrichmentRunner -from app.services.ingestion.pagination import PaginationEngine -from app.services.ingestion.run_completion import ( - complete_run_for_user, - int_or_default, - log_run_completed, - run_execution_summary, -) -from app.services.ingestion.run_enrichment import ( - inline_enrich_and_finalize, - spawn_background_enrichment_task, -) -from app.services.ingestion.run_guards import ( - load_user_settings_for_run, - run_preflight_guard, -) -from app.services.ingestion.scholar_processing import run_scholar_iteration -from app.services.ingestion.types import ( - RunAlertSummary, - RunAlreadyInProgressError, - RunBlockedBySafetyPolicyError, # noqa: F401 (re-exported) - RunFailureSummary, - RunProgress, -) -from app.services.scholar.source import ScholarSource -from app.services.settings import application as user_settings_service -from app.settings import settings - -logger = logging.getLogger(__name__) -ACTIVE_RUN_INDEX_NAME = "uq_crawl_runs_user_active" - - -def _is_active_run_integrity_error(exc: IntegrityError) -> bool: - original_error = getattr(exc, "orig", None) - if ACTIVE_RUN_INDEX_NAME in str(exc): - return True - if original_error is None: - return False - if ACTIVE_RUN_INDEX_NAME in str(original_error): - return True - diagnostics = getattr(original_error, "diag", None) - if diagnostics is None: - return False - return getattr(diagnostics, "constraint_name", None) == ACTIVE_RUN_INDEX_NAME - - -def _resolve_paging_kwargs( - *, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int | None, - rate_limit_backoff_seconds: float | None, - max_pages_per_scholar: int, - page_size: int, -) -> dict[str, Any]: - return { - "request_delay_seconds": request_delay_seconds, - "network_error_retries": network_error_retries, - "retry_backoff_seconds": retry_backoff_seconds, - "rate_limit_retries": rate_limit_retries - if rate_limit_retries is not None - else settings.ingestion_rate_limit_retries, - "rate_limit_backoff_seconds": rate_limit_backoff_seconds - if rate_limit_backoff_seconds is not None - else settings.ingestion_rate_limit_backoff_seconds, - "max_pages_per_scholar": max_pages_per_scholar, - "page_size": page_size, - } - - -def _threshold_kwargs( - *, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, -) -> dict[str, Any]: - return { - "alert_blocked_failure_threshold": alert_blocked_failure_threshold, - "alert_network_failure_threshold": alert_network_failure_threshold, - "alert_retry_scheduled_threshold": alert_retry_scheduled_threshold, - } - - -class ScholarIngestionService: - def __init__(self, *, source: ScholarSource) -> None: - self._source = source - self._pagination = PaginationEngine(source=source) - self._enrichment = EnrichmentRunner() - - @staticmethod - def _effective_request_delay_seconds(value: int) -> int: - policy_minimum = user_settings_service.resolve_request_delay_minimum( - settings.ingestion_min_request_delay_seconds - ) - return max(policy_minimum, int_or_default(value, policy_minimum)) - - async def _load_target_scholars( - self, - db_session: AsyncSession, - *, - user_id: int, - filtered_scholar_ids: set[int] | None, - ) -> list[ScholarProfile]: - scholars_stmt = ( - select(ScholarProfile) - .where(ScholarProfile.user_id == user_id, ScholarProfile.is_enabled.is_(True)) - .order_by(ScholarProfile.created_at.asc(), ScholarProfile.id.asc()) - ) - if filtered_scholar_ids is not None: - scholars_stmt = scholars_stmt.where(ScholarProfile.id.in_(filtered_scholar_ids)) - scholars_result = await db_session.execute(scholars_stmt) - scholars = list(scholars_result.scalars().all()) - if filtered_scholar_ids is not None: - found_ids = {int(s.id) for s in scholars} - for sid in filtered_scholar_ids - found_ids: - await queue_service.clear_job_for_scholar(db_session, user_id=user_id, scholar_profile_id=sid) - return scholars - - async def _initialize_run_for_user( - self, - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, - scholar_profile_ids: set[int] | None, - start_cstart_by_scholar_id: dict[int, int] | None, - paging_kwargs: dict[str, Any], - threshold_kwargs: dict[str, Any], - idempotency_key: str | None, - ) -> tuple[Any, CrawlRun, list[ScholarProfile], dict[int, int]]: - user_settings = await load_user_settings_for_run( - db_session, - user_id=user_id, - trigger_type=trigger_type, - ) - if not await self._try_acquire_user_lock(db_session, user_id=user_id): - raise RunAlreadyInProgressError(f"Run already in progress for user_id={user_id}.") - filtered_scholar_ids = {int(v) for v in scholar_profile_ids} if scholar_profile_ids is not None else None - start_cstart_map = {int(k): max(0, int(v)) for k, v in (start_cstart_by_scholar_id or {}).items()} - scholars = await self._load_target_scholars( - db_session, - user_id=user_id, - filtered_scholar_ids=filtered_scholar_ids, - ) - await run_preflight_guard( - db_session, - self._source, - user_settings=user_settings, - user_id=user_id, - scholars=scholars, - ) - run = await self._start_run_record( - db_session, - user_id=user_id, - trigger_type=trigger_type, - scholars=scholars, - filtered=filtered_scholar_ids is not None, - idempotency_key=idempotency_key, - paging_kwargs=paging_kwargs, - threshold_kwargs=threshold_kwargs, - ) - return user_settings, run, scholars, start_cstart_map - - async def _start_run_record( - self, - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, - scholars: list[ScholarProfile], - filtered: bool, - idempotency_key: str | None, - paging_kwargs: dict[str, Any], - threshold_kwargs: dict[str, Any], - ) -> CrawlRun: - structured_log( - logger, - "info", - "ingestion.run_started", - user_id=user_id, - trigger_type=trigger_type.value, - scholar_count=len(scholars), - is_filtered_run=filtered, - idempotency_key=idempotency_key, - **paging_kwargs, - **threshold_kwargs, - ) - run = CrawlRun( - user_id=user_id, - trigger_type=trigger_type, - status=RunStatus.RUNNING, - scholar_count=len(scholars), - new_pub_count=0, - idempotency_key=idempotency_key, - error_log={}, - ) - db_session.add(run) - try: - await db_session.flush() - except IntegrityError as exc: - if _is_active_run_integrity_error(exc): - await db_session.rollback() - raise RunAlreadyInProgressError(f"Run already in progress for user_id={user_id}.") from exc - raise - return run - - async def initialize_run( - self, - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, - request_delay_seconds: int, - network_error_retries: int = 1, - retry_backoff_seconds: float = 1.0, - rate_limit_retries: int | None = None, - rate_limit_backoff_seconds: float | None = None, - max_pages_per_scholar: int = 30, - page_size: int = 100, - scholar_profile_ids: set[int] | None = None, - start_cstart_by_scholar_id: dict[int, int] | None = None, - idempotency_key: str | None = None, - alert_blocked_failure_threshold: int = 1, - alert_network_failure_threshold: int = 2, - alert_retry_scheduled_threshold: int = 3, - ) -> tuple[CrawlRun, list[ScholarProfile], dict[int, int]]: - effective_delay = self._effective_request_delay_seconds(request_delay_seconds) - if effective_delay != int_or_default(request_delay_seconds, effective_delay): - structured_log( - logger, - "warning", - "ingestion.delay_coerced", - user_id=user_id, - requested_request_delay_seconds=int_or_default(request_delay_seconds, 0), - effective_request_delay_seconds=effective_delay, - policy_minimum_request_delay_seconds=user_settings_service.resolve_request_delay_minimum( - settings.ingestion_min_request_delay_seconds - ), - ) - paging = _resolve_paging_kwargs( - request_delay_seconds=effective_delay, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - ) - thresholds = _threshold_kwargs( - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - _, run, scholars, start_cstart_map = await self._initialize_run_for_user( - db_session, - user_id=user_id, - trigger_type=trigger_type, - scholar_profile_ids=scholar_profile_ids, - start_cstart_by_scholar_id=start_cstart_by_scholar_id, - idempotency_key=idempotency_key, - paging_kwargs=paging, - threshold_kwargs=thresholds, - ) - return run, scholars, start_cstart_map - - async def execute_run( - self, - session_factory: Any, - *, - run_id: int, - user_id: int, - scholars: list[ScholarProfile], - start_cstart_map: dict[int, int], - request_delay_seconds: int, - network_error_retries: int = 1, - retry_backoff_seconds: float = 1.0, - rate_limit_retries: int | None = None, - rate_limit_backoff_seconds: float | None = None, - max_pages_per_scholar: int = 30, - page_size: int = 100, - auto_queue_continuations: bool = True, - queue_delay_seconds: int = 60, - alert_blocked_failure_threshold: int = 1, - alert_network_failure_threshold: int = 2, - alert_retry_scheduled_threshold: int = 3, - idempotency_key: str | None = None, - ) -> None: - paging = _resolve_paging_kwargs( - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - ) - thresholds = _threshold_kwargs( - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - async with session_factory() as db_session: - try: - run, user_settings, attached_scholars = await self._prepare_execute_run( - db_session, run_id=run_id, user_id=user_id, scholars=scholars - ) - progress = await run_scholar_iteration( - db_session, - pagination=self._pagination, - run=run, - scholars=attached_scholars, - user_id=user_id, - start_cstart_map=start_cstart_map, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - **paging, - ) - failure_summary, alert_summary = complete_run_for_user( - user_settings=user_settings, - run=run, - scholars=attached_scholars, - user_id=user_id, - progress=progress, - idempotency_key=idempotency_key, - **thresholds, - ) - intended_final_status = run.status - if intended_final_status not in (RunStatus.CANCELED,): - run.status = RunStatus.RESOLVING - await db_session.commit() - log_run_completed( - run=run, - user_id=user_id, - scholars=attached_scholars, - progress=progress, - failure_summary=failure_summary, - alert_summary=alert_summary, - ) - if intended_final_status not in (RunStatus.CANCELED,): - spawn_background_enrichment_task( - session_factory, - self._enrichment, - run_id=run.id, - intended_final_status=intended_final_status, - openalex_api_key=getattr(user_settings, "openalex_api_key", None), - ) - except Exception as exc: - await db_session.rollback() - structured_log( - logger, - "exception", - "ingestion.background_run_failed", - run_id=run_id, - user_id=user_id, - ) - await self._fail_run_in_background(session_factory, run_id, exc) - - async def _prepare_execute_run( - self, - db_session: AsyncSession, - *, - run_id: int, - user_id: int, - scholars: list[ScholarProfile], - ) -> tuple[CrawlRun, Any, list[ScholarProfile]]: - run_result = await db_session.execute(select(CrawlRun).where(CrawlRun.id == run_id)) - run = run_result.scalar_one() - user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) - scholar_ids = [s.id for s in scholars] - scholars_result = await db_session.execute( - select(ScholarProfile) - .where(ScholarProfile.id.in_(scholar_ids)) - .order_by(ScholarProfile.created_at.asc(), ScholarProfile.id.asc()) - ) - return run, user_settings, list(scholars_result.scalars().all()) - - async def _fail_run_in_background(self, session_factory: Any, run_id: int, exc: Exception) -> None: - try: - async with session_factory() as cleanup_session: - run_to_fail = await cleanup_session.get(CrawlRun, run_id) - if run_to_fail: - run_to_fail.status = RunStatus.FAILED - run_to_fail.end_dt = datetime.now(UTC) - run_to_fail.error_log = run_to_fail.error_log or {} - run_to_fail.error_log["terminal_exception"] = str(exc) - await cleanup_session.commit() - except Exception: - structured_log( - logger, - "exception", - "ingestion.fail_run_cleanup_failed", - run_id=run_id, - ) - - async def run_for_user( - self, - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, - request_delay_seconds: int, - network_error_retries: int = 1, - retry_backoff_seconds: float = 1.0, - rate_limit_retries: int | None = None, - rate_limit_backoff_seconds: float | None = None, - max_pages_per_scholar: int = 30, - page_size: int = 100, - scholar_profile_ids: set[int] | None = None, - start_cstart_by_scholar_id: dict[int, int] | None = None, - auto_queue_continuations: bool = True, - queue_delay_seconds: int = 60, - idempotency_key: str | None = None, - alert_blocked_failure_threshold: int = 1, - alert_network_failure_threshold: int = 2, - alert_retry_scheduled_threshold: int = 3, - ): - run, scholars, start_cstart_map = await self.initialize_run( - db_session, - user_id=user_id, - trigger_type=trigger_type, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - scholar_profile_ids=scholar_profile_ids, - start_cstart_by_scholar_id=start_cstart_by_scholar_id, - idempotency_key=idempotency_key, - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - paging = _resolve_paging_kwargs( - request_delay_seconds=self._effective_request_delay_seconds(request_delay_seconds), - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - ) - thresholds = _threshold_kwargs( - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - progress, failure_summary, alert_summary, intended_final_status = await self._run_iteration_and_complete( - db_session, - run=run, - scholars=scholars, - user_id=user_id, - start_cstart_map=start_cstart_map, - paging=paging, - thresholds=thresholds, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - idempotency_key=idempotency_key, - ) - user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) - await inline_enrich_and_finalize( - db_session, - self._enrichment, - run=run, - user_settings=user_settings, - intended_final_status=intended_final_status, - ) - log_run_completed( - run=run, - user_id=user_id, - scholars=scholars, - progress=progress, - failure_summary=failure_summary, - alert_summary=alert_summary, - ) - return run_execution_summary(run=run, scholars=scholars, progress=progress) - - async def _run_iteration_and_complete( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholars: list[ScholarProfile], - user_id: int, - start_cstart_map: dict[int, int], - paging: dict[str, Any], - thresholds: dict[str, Any], - auto_queue_continuations: bool, - queue_delay_seconds: int, - idempotency_key: str | None, - ) -> tuple[RunProgress, RunFailureSummary, RunAlertSummary, RunStatus]: - progress = await run_scholar_iteration( - db_session, - pagination=self._pagination, - run=run, - scholars=scholars, - user_id=user_id, - start_cstart_map=start_cstart_map, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - **paging, - ) - user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) - failure_summary, alert_summary = complete_run_for_user( - user_settings=user_settings, - run=run, - scholars=scholars, - user_id=user_id, - progress=progress, - idempotency_key=idempotency_key, - **thresholds, - ) - intended_final_status = run.status - if intended_final_status not in (RunStatus.CANCELED,): - run.status = RunStatus.RESOLVING - await db_session.commit() - return progress, failure_summary, alert_summary, intended_final_status - - async def _try_acquire_user_lock( - self, - db_session: AsyncSession, - *, - user_id: int, - ) -> bool: - result = await db_session.execute( - text("SELECT pg_try_advisory_xact_lock(:namespace, :user_key)"), - {"namespace": RUN_LOCK_NAMESPACE, "user_key": int(user_id)}, - ) - return bool(result.scalar_one()) diff --git a/app/services/ingestion/constants.py b/app/services/ingestion/constants.py deleted file mode 100644 index cf371c6..0000000 --- a/app/services/ingestion/constants.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import re - -from app.services.scholar.parser import ParseState - -TITLE_ALNUM_RE = re.compile(r"[^a-z0-9]+") -WORD_RE = re.compile(r"[a-z0-9]+") -HTML_TAG_RE = re.compile(r"<[^>]+>", re.S) -SPACE_RE = re.compile(r"\s+") - -FAILED_STATES = { - ParseState.BLOCKED_OR_CAPTCHA.value, - ParseState.LAYOUT_CHANGED.value, - ParseState.NETWORK_ERROR.value, - "ingestion_error", -} - -FAILURE_BUCKET_BLOCKED = "blocked_or_captcha" -FAILURE_BUCKET_NETWORK = "network_error" -FAILURE_BUCKET_LAYOUT = "layout_changed" -FAILURE_BUCKET_INGESTION = "ingestion_error" -FAILURE_BUCKET_OTHER = "other_failure" - -RUN_LOCK_NAMESPACE = 8217 -RESUMABLE_PARTIAL_REASONS = { - "max_pages_reached", - "pagination_cursor_stalled", -} -RESUMABLE_PARTIAL_REASON_PREFIXES = ("page_state_network_error",) -INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS = 30 diff --git a/app/services/ingestion/enrichment.py b/app/services/ingestion/enrichment.py deleted file mode 100644 index 181ff41..0000000 --- a/app/services/ingestion/enrichment.py +++ /dev/null @@ -1,409 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import re -from datetime import UTC, datetime, timedelta - -from sqlalchemy import or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - Publication, - RunStatus, - ScholarProfile, - ScholarPublication, -) -from app.logging_utils import structured_log -from app.services.arxiv.errors import ArxivRateLimitError -from app.services.publication_identifiers import application as identifier_service -from app.services.runs.events import run_events -from app.services.scholar.parser import PublicationCandidate -from app.settings import settings - -logger = logging.getLogger(__name__) - - -def _sanitize_titles(publications: list) -> list[str]: - titles = [] - for p in publications: - raw = getattr(p, "title_raw", None) or getattr(p, "title", None) - if not raw or not raw.strip(): - continue - safe = " ".join(re.sub(r"[^\w\s]", " ", raw).split()) - if safe: - titles.append(safe) - return titles - - -# OpenAlex (and most HTTP servers) struggle with very long query strings. -# Non-ASCII characters expand significantly when percent-encoded in URLs, -# so a fixed batch count is unreliable. Instead we cap the combined -# byte-length of the pipe-joined title filter value to stay well within -# safe URL limits (~4 KiB of filter text ≈ ~6 KiB when percent-encoded). -_MAX_TITLE_FILTER_BYTES = 4_000 - - -def _chunk_titles_by_url_length( - titles: list[str], - max_bytes: int = _MAX_TITLE_FILTER_BYTES, -) -> list[list[str]]: - """Split *titles* into chunks whose pipe-joined UTF-8 size stays under *max_bytes*.""" - chunks: list[list[str]] = [] - current_chunk: list[str] = [] - current_bytes = 0 - - for title in titles: - title_bytes = len(title.encode("utf-8")) - # Account for the pipe separator between titles - separator = 3 if current_chunk else 0 # len("|".encode) but url-encoded as %7C = 3 - if current_chunk and current_bytes + separator + title_bytes > max_bytes: - chunks.append(current_chunk) - current_chunk = [title] - current_bytes = title_bytes - else: - current_chunk.append(title) - current_bytes += separator + title_bytes - - if current_chunk: - chunks.append(current_chunk) - return chunks - - -class EnrichmentRunner: - """Post-run OpenAlex enrichment logic. - - Receives service dependencies at construction so it can be tested - independently of ``ScholarIngestionService``. - """ - - async def run_is_canceled( - self, - db_session: AsyncSession, - *, - run_id: int, - ) -> bool: - check_result = await db_session.execute(select(CrawlRun.status).where(CrawlRun.id == run_id)) - status = check_result.scalar_one_or_none() - if status is None: - raise RuntimeError(f"Missing crawl_run for run_id={run_id}.") - return status == RunStatus.CANCELED - - async def _load_unenriched_publications( - self, - db_session: AsyncSession, - *, - run_id: int, - ) -> tuple[int, list[Publication]]: - run_result = await db_session.execute(select(CrawlRun.user_id).where(CrawlRun.id == run_id)) - user_id = run_result.scalar_one() - cooldown_threshold = datetime.now(UTC) - timedelta(days=7) - stmt = ( - select(Publication) - .join(ScholarPublication) - .join(ScholarProfile, ScholarPublication.scholar_profile_id == ScholarProfile.id) - .where( - ScholarProfile.user_id == user_id, - Publication.openalex_enriched.is_(False), - or_( - Publication.openalex_last_attempt_at.is_(None), - Publication.openalex_last_attempt_at < cooldown_threshold, - ), - ) - .distinct() - ) - result = await db_session.execute(stmt) - return user_id, list(result.scalars().all()) - - async def _enrich_batch( - self, - db_session: AsyncSession, - *, - batch: list[Publication], - run_id: int, - openalex_works: list, - now: datetime, - arxiv_lookup_allowed: bool, - ) -> tuple[bool, bool]: - from app.services.openalex.matching import find_best_match - - for p in batch: - if await self.run_is_canceled(db_session, run_id=run_id): - structured_log(logger, "info", "ingestion.enrichment_aborted", run_id=run_id) - return False, arxiv_lookup_allowed - p.openalex_last_attempt_at = now - arxiv_lookup_allowed = await self._discover_identifiers_for_enrichment( - db_session, - publication=p, - run_id=run_id, - allow_arxiv_lookup=arxiv_lookup_allowed, - ) - match = find_best_match( - target_title=p.title_raw, - target_year=p.year, - target_authors=p.author_text or "", - candidates=openalex_works, - ) - if match: - p.year = match.publication_year if match.publication_year is not None else p.year - p.citation_count = match.cited_by_count if match.cited_by_count is not None else p.citation_count - p.pdf_url = match.oa_url if match.oa_url is not None else p.pdf_url - p.openalex_enriched = True - return True, arxiv_lookup_allowed - - async def _flush_and_sweep_duplicates( - self, - db_session: AsyncSession, - *, - run_id: int, - ) -> None: - await db_session.flush() - from app.services.publications.dedup import sweep_identifier_duplicates - - merge_count = await sweep_identifier_duplicates(db_session) - if merge_count: - structured_log( - logger, - "info", - "ingestion.identifier_dedup_sweep", - merged_count=merge_count, - run_id=run_id, - ) - - async def enrich_pending_publications( - self, - db_session: AsyncSession, - *, - run_id: int, - openalex_api_key: str | None = None, - ) -> None: - from app.services.openalex.client import ( - OpenAlexBudgetExhaustedError, - OpenAlexClient, - OpenAlexRateLimitError, - ) - - _, publications = await self._load_unenriched_publications(db_session, run_id=run_id) - if not publications: - return - - resolved_key = openalex_api_key or settings.openalex_api_key - client = OpenAlexClient(api_key=resolved_key, mailto=settings.crossref_api_mailto) - now = datetime.now(UTC) - arxiv_lookup_allowed = True - - all_titles = _sanitize_titles(publications) - title_chunks = _chunk_titles_by_url_length(all_titles) - - # Build a mapping from sanitized title → publications for batch association - title_to_pubs: dict[str, list[Publication]] = {} - for p in publications: - raw = getattr(p, "title_raw", None) or getattr(p, "title", None) - if not raw or not raw.strip(): - continue - safe = " ".join(re.sub(r"[^\w\s]", " ", raw).split()) - if safe: - title_to_pubs.setdefault(safe, []).append(p) - - for title_chunk in title_chunks: - if await self.run_is_canceled(db_session, run_id=run_id): - structured_log(logger, "info", "ingestion.enrichment_aborted", run_id=run_id) - return - batch = [] - for t in title_chunk: - batch.extend(title_to_pubs.get(t, [])) - if not batch: - continue - try: - openalex_works = await client.get_works_by_filter( - {"title.search": "|".join(title_chunk)}, limit=len(title_chunk) * 3 - ) - except OpenAlexBudgetExhaustedError: - structured_log(logger, "warning", "ingestion.openalex_budget_exhausted", run_id=run_id) - break - except OpenAlexRateLimitError: - structured_log(logger, "warning", "ingestion.openalex_rate_limited", run_id=run_id) - await asyncio.sleep(60) - continue - except Exception as e: - structured_log(logger, "warning", "ingestion.openalex_enrichment_failed", error=str(e), run_id=run_id) - for p in batch: - p.openalex_last_attempt_at = now - continue - should_continue, arxiv_lookup_allowed = await self._enrich_batch( - db_session, - batch=batch, - run_id=run_id, - openalex_works=openalex_works, - now=now, - arxiv_lookup_allowed=arxiv_lookup_allowed, - ) - if not should_continue: - return - - await self._flush_and_sweep_duplicates(db_session, run_id=run_id) - - async def _discover_identifiers_for_enrichment( - self, - db_session: AsyncSession, - *, - publication: Publication, - run_id: int, - allow_arxiv_lookup: bool, - ) -> bool: - if not allow_arxiv_lookup: - await identifier_service.sync_identifiers_for_publication_fields( - db_session, - publication=publication, - ) - await self._publish_identifier_update_event( - db_session, - run_id=run_id, - publication_id=int(publication.id), - ) - return False - try: - await identifier_service.discover_and_sync_identifiers_for_publication( - db_session, - publication=publication, - scholar_label=publication.author_text or "", - ) - await self._publish_identifier_update_event( - db_session, - run_id=run_id, - publication_id=int(publication.id), - ) - return True - except ArxivRateLimitError: - structured_log( - logger, - "warning", - "ingestion.arxiv_rate_limited", - run_id=run_id, - publication_id=int(publication.id), - detail="arXiv temporarily disabled for remaining enrichment pass", - ) - await identifier_service.sync_identifiers_for_publication_fields( - db_session, - publication=publication, - ) - await self._publish_identifier_update_event( - db_session, - run_id=run_id, - publication_id=int(publication.id), - ) - return False - - async def _publish_identifier_update_event( - self, - db_session: AsyncSession, - *, - run_id: int, - publication_id: int, - ) -> None: - display = await identifier_service.display_identifier_for_publication_id( - db_session, - publication_id=publication_id, - ) - if display is None: - return - await run_events.publish( - run_id=run_id, - event_type="identifier_updated", - data={ - "publication_id": int(publication_id), - "display_identifier": { - "kind": display.kind, - "value": display.value, - "label": display.label, - "url": display.url, - "confidence_score": float(display.confidence_score), - }, - }, - ) - - async def enrich_publications_with_openalex( - self, - scholar: ScholarProfile, - publications: list[PublicationCandidate], - ) -> list[PublicationCandidate]: - if not publications: - return publications - - from app.services.openalex.client import OpenAlexClient - from app.services.openalex.matching import find_best_match - - client = OpenAlexClient(api_key=settings.openalex_api_key, mailto=settings.crossref_api_mailto) - - all_titles = _sanitize_titles(publications) - title_chunks = _chunk_titles_by_url_length(all_titles) - - # Build title → publication mapping for batch association - title_to_pubs: dict[str, list[PublicationCandidate]] = {} - for p in publications: - raw = getattr(p, "title", None) - if not raw or not raw.strip(): - continue - safe = " ".join(re.sub(r"[^\w\s]", " ", raw).split()) - if safe: - title_to_pubs.setdefault(safe, []).append(p) - - # Collect publications that had no sanitizable title — pass through as-is - pubs_with_titles = set() - for titles in title_to_pubs.values(): - for p in titles: - pubs_with_titles.add(id(p)) - - enriched: list[PublicationCandidate] = [] - - for title_chunk in title_chunks: - batch = [] - for t in title_chunk: - batch.extend(title_to_pubs.get(t, [])) - if not batch: - continue - - try: - openalex_works = await client.get_works_by_filter( - {"title.search": "|".join(title_chunk)}, limit=len(title_chunk) * 3 - ) - except Exception as e: - structured_log( - logger, - "warning", - "ingestion.openalex_enrichment_failed", - error=str(e), - batch_size=len(batch), - scholar_id=scholar.id, - ) - openalex_works = [] - - for p in batch: - match = find_best_match( - target_title=p.title, - target_year=p.year, - target_authors=p.authors_text or (scholar.display_name or scholar.scholar_id), - candidates=openalex_works, - ) - if match: - new_p = PublicationCandidate( - title=p.title, - title_url=p.title_url, - cluster_id=p.cluster_id, - year=match.publication_year if match.publication_year is not None else p.year, - citation_count=match.cited_by_count if match.cited_by_count is not None else p.citation_count, - authors_text=p.authors_text, - venue_text=p.venue_text, - pdf_url=match.oa_url if match.oa_url is not None else p.pdf_url, - ) - enriched.append(new_p) - else: - enriched.append(p) - - # Append publications that had no sanitizable title (pass-through) - for p in publications: - if id(p) not in pubs_with_titles: - enriched.append(p) - - return enriched diff --git a/app/services/ingestion/fingerprints.py b/app/services/ingestion/fingerprints.py deleted file mode 100644 index 0d04f01..0000000 --- a/app/services/ingestion/fingerprints.py +++ /dev/null @@ -1,381 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import re -import unicodedata -from typing import Any -from urllib.parse import urljoin - -from app.services.ingestion.constants import ( - HTML_TAG_RE, - INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS, - SPACE_RE, - TITLE_ALNUM_RE, - WORD_RE, -) -from app.services.scholar.parser import ParsedProfilePage, ParseState, PublicationCandidate - -# Scholar-specific noise patterns stripped before canonical comparison. -# Applied in order; each targets a different Scholar metadata injection style. -_NOISE_DOI_RE = re.compile(r"[,.\s]+doi\s*:\s*\S+.*$", re.IGNORECASE) -_NOISE_ARXIV_RE = re.compile(r"[,.\s]+arxiv\b.*$", re.IGNORECASE) -_NOISE_PREPRINT_RE = re.compile( - r"[,\s]+(?:preprint|extended\s+version|technical\s+report|working\s+paper)\b.*$", - re.IGNORECASE, -) -_NOISE_TRAILING_YEAR_RE = re.compile(r"\s*[,(]\s*\d{4}\s*[),]?\s*$") -_NOISE_TRAILING_MONTH_YEAR_RE = re.compile( - r"\s*[,(]\s*(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{4}\s*[),]?\s*$", - re.IGNORECASE, -) -_NOISE_TRAILING_PUBLICATION_TYPE_RE = re.compile( - r"[,.\s]+(?:conference\s+paper|journal\s+article)\s*$", - re.IGNORECASE, -) -_NOISE_IN_PROCEEDINGS_SUFFIX_RE = re.compile(r"\s+in:\s+proceedings\b.*$", re.IGNORECASE) -# Strips ". Capitalised sentence" appended as venue: ". Comput. Sci…", ". Journal of…" -_NOISE_VENUE_SENTENCE_RE = re.compile(r"(?<=\w{3})\.\s+[A-Z][a-z].*$") -_MOJIBAKE_HINT_RE = re.compile(r"[ÃÂâ]") -_MOJIBAKE_CHAR_RE = re.compile(r"[Ó”€™]") -_METADATA_ORDINAL_RE = re.compile(r"^\d+(st|nd|rd|th)$") -_NOISE_LEADING_DATE_PREFIX_RE = re.compile( - r"^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{1,2}(?:\s*[-–]\s*\d{1,2})?\)?[,\.\s:;-]+", - re.IGNORECASE, -) -_NOISE_LEADING_AUTHOR_FRAGMENT_RE = re.compile(r"^(?:and|&)\s+[a-z.\s]{1,40}:\s*", re.IGNORECASE) -_METADATA_SEPARATORS = (" - ", " — ", ",", ";", ". ") -_VENUE_HINT_TOKENS = { - "aaai", - "conference", - "conf", - "cvpr", - "eccv", - "iclr", - "icml", - "journal", - "nips", - "neurips", - "proceedings", - "proc", - "symposium", - "workshop", -} -_PUBLICATION_TYPE_TOKENS = {"conference", "paper", "journal", "article"} -_MIN_METADATA_HINT_TOKENS = 2 -_MIN_METADATA_CONTEXT_TOKENS = 4 - -_CANONICAL_DEDUP_THRESHOLD = 0.82 - - -def normalize_title(value: str) -> str: - lowered = _normalized_text(value).lower() - return TITLE_ALNUM_RE.sub("", lowered) - - -def canonical_title_for_dedup(title: str) -> str: - """Strip Scholar-specific noise suffixes then normalize for dedup comparison.""" - return normalize_title(_canonical_title_text(title)) - - -def canonical_title_text_for_dedup(title: str) -> str: - """Noise-stripped lowercase title with spaces preserved for token-level matching.""" - return _stripped_title_for_canonical(title) - - -def canonical_title_tokens_for_dedup(title: str) -> set[str]: - """Word tokens of the noise-stripped title.""" - return _canonical_title_tokens(title) - - -def _stripped_title_for_canonical(title: str) -> str: - """Apply noise-stripping and lowercase but PRESERVE spaces (for later tokenization).""" - t = _canonical_title_text(title) - return t.lower().strip() - - -def _canonical_title_text(title: str) -> str: - t = _normalized_text(title) - t = _strip_noise_suffixes(t) - t = _strip_venue_metadata_suffixes(t) - return _NOISE_VENUE_SENTENCE_RE.sub("", t).strip() - - -def _strip_noise_suffixes(value: str) -> str: - t = _strip_leading_noise_prefixes(value.strip()) - t = _NOISE_DOI_RE.sub("", t) - t = _NOISE_ARXIV_RE.sub("", t) - t = _NOISE_PREPRINT_RE.sub("", t) - t = _NOISE_TRAILING_YEAR_RE.sub("", t) - t = _NOISE_TRAILING_MONTH_YEAR_RE.sub("", t) - t = _NOISE_TRAILING_PUBLICATION_TYPE_RE.sub("", t) - t = _NOISE_IN_PROCEEDINGS_SUFFIX_RE.sub("", t) - return t.strip() - - -def _strip_venue_metadata_suffixes(value: str) -> str: - stripped = value.strip() - while True: - cut_index = _metadata_cut_index(stripped) - if cut_index is None: - return stripped - stripped = stripped[:cut_index].strip() - - -def _metadata_cut_index(value: str) -> int | None: - candidates: list[int] = [] - for candidate in _METADATA_SEPARATORS: - start = 0 - while True: - index = value.find(candidate, start) - if index <= 0: - break - suffix = value[index + len(candidate) :].strip() - if suffix and _looks_like_venue_metadata(suffix): - candidates.append(index) - start = index + len(candidate) - if not candidates: - return None - return min(candidates) - - -def _looks_like_venue_metadata(value: str) -> bool: - tokens = WORD_RE.findall(value.lower()) - if len(tokens) < _MIN_METADATA_HINT_TOKENS: - return False - has_hint = any(_is_venue_hint_token(token) for token in tokens) - if not has_hint: - return False - has_year = any(_is_year_token(token) for token in tokens) - has_ordinal = any(_METADATA_ORDINAL_RE.match(token) for token in tokens) - publication_type_only = all(token in _PUBLICATION_TYPE_TOKENS for token in tokens) - return has_year or has_ordinal or publication_type_only or len(tokens) >= _MIN_METADATA_CONTEXT_TOKENS - - -def _strip_leading_noise_prefixes(value: str) -> str: - stripped = value - while True: - next_value = _NOISE_LEADING_DATE_PREFIX_RE.sub("", stripped).strip() - next_value = _NOISE_LEADING_AUTHOR_FRAGMENT_RE.sub("", next_value).strip() - if next_value == stripped: - return stripped - stripped = next_value - - -def _is_venue_hint_token(token: str) -> bool: - if token in _VENUE_HINT_TOKENS: - return True - return token.startswith("conf") or token.startswith("proceed") - - -def _is_year_token(token: str) -> bool: - if len(token) != 4 or not token.isdigit(): - return False - year = int(token) - return 1900 <= year <= 2100 - - -def _normalized_text(value: str) -> str: - repaired = _repair_mojibake(value.strip()) - normalized = unicodedata.normalize("NFKC", repaired) - cleaned = _MOJIBAKE_CHAR_RE.sub(" ", normalized) - return SPACE_RE.sub(" ", cleaned).strip() - - -def _repair_mojibake(value: str) -> str: - if not value or not _MOJIBAKE_HINT_RE.search(value): - return value - try: - repaired = value.encode("latin1").decode("utf-8") - except UnicodeError: - return value - if _mojibake_score(repaired) < _mojibake_score(value): - return repaired - return value - - -def _mojibake_score(value: str) -> int: - return len(_MOJIBAKE_HINT_RE.findall(value)) - - -def _canonical_title_tokens(title: str) -> set[str]: - """Word tokens of the noise-stripped title (preserves token boundaries).""" - return set(WORD_RE.findall(_stripped_title_for_canonical(title))) - - -def _jaccard(a: set[str], b: set[str]) -> float: - if not a or not b: - return 0.0 - return len(a & b) / len(a | b) - - -def _first_author_last_name(authors_text: str | None) -> str: - if not authors_text: - return "" - first_author = authors_text.split(",", maxsplit=1)[0].strip().lower() - words = WORD_RE.findall(first_author) - if not words: - return "" - return words[-1] - - -def _first_venue_word(venue_text: str | None) -> str: - if not venue_text: - return "" - words = WORD_RE.findall(venue_text.lower()) - if not words: - return "" - return words[0] - - -def build_publication_fingerprint(candidate: PublicationCandidate) -> str: - canonical = "|".join( - [ - normalize_title(candidate.title), - str(candidate.year) if candidate.year is not None else "", - _first_author_last_name(candidate.authors_text), - _first_venue_word(candidate.venue_text), - ] - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def build_initial_page_fingerprint(parsed_page: ParsedProfilePage) -> str | None: - if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: - return None - - normalized_rows: list[dict[str, Any]] = [] - for publication in parsed_page.publications[:INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS]: - normalized_rows.append( - { - "cluster_id": publication.cluster_id or "", - "title_normalized": normalize_title(publication.title), - "year": publication.year, - "citation_count": publication.citation_count, - } - ) - - payload = { - "state": parsed_page.state.value, - "articles_range": parsed_page.articles_range or "", - "has_show_more_button": parsed_page.has_show_more_button, - "profile_name": parsed_page.profile_name or "", - "publications": normalized_rows, - } - canonical = json.dumps( - payload, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def build_publication_url(path_or_url: str | None) -> str | None: - if not path_or_url: - return None - return urljoin("https://scholar.google.com", path_or_url) - - -def _next_cstart_value(*, articles_range: str | None, fallback: int) -> int: - if articles_range: - numbers = re.findall(r"\d+", articles_range) - if len(numbers) >= 2: - try: - return int(numbers[1]) - except ValueError: - pass - return int(fallback) - - -def _title_tokens(value: str) -> set[str]: - """Extract normalized word tokens for fuzzy title comparison.""" - return set(WORD_RE.findall(value.lower())) - - -def fuzzy_titles_match( - title_a: str, - title_b: str, - *, - threshold: float = 0.85, -) -> bool: - """Return True if two titles are near-duplicates by token-level Jaccard similarity. - - A threshold of 0.85 catches common academic duplicate patterns: - differences in punctuation, minor word variations, subtitle changes. - """ - tokens_a = _title_tokens(title_a) - tokens_b = _title_tokens(title_b) - return _jaccard(tokens_a, tokens_b) >= threshold - - -def _dedupe_publication_candidates( - publications: list[PublicationCandidate], - *, - seen_canonical: set[str] | None = None, -) -> list[PublicationCandidate]: - """Deduplicate candidates using canonical title matching. - - Args: - publications: candidates to filter - seen_canonical: optional mutable set shared across pages. Stores the - noise-stripped *lowercased* (but space-preserved) canonical string - so it can be tokenized on the next page for cross-page fuzzy dedup. - Accepted canonicals are added; existing entries are consulted. - """ - deduped: list[PublicationCandidate] = [] - seen_exact: set[str] = set() - # Token sets for fuzzy comparison; seeded from cross-page state. - seen_tokens: list[set[str]] = [] - - if seen_canonical: - for stripped in seen_canonical: - seen_tokens.append(set(WORD_RE.findall(stripped))) - - for pub in publications: - identity = _publication_identity(pub) - if identity in seen_exact: - continue - - # Use space-preserving stripped form for token-level fuzzy match. - tokens = _canonical_title_tokens(pub.title) - if _is_fuzzy_dup(tokens, seen_tokens): - continue - - seen_exact.add(identity) - seen_tokens.append(tokens) - if seen_canonical is not None: - # Store the noise-stripped lowercased (space-preserved) form. - seen_canonical.add(_stripped_title_for_canonical(pub.title)) - deduped.append(pub) - - return deduped - - -def _publication_identity(pub: PublicationCandidate) -> str: - if pub.cluster_id: - return f"cluster:{pub.cluster_id}" - canonical = canonical_title_for_dedup(pub.title) - return "|".join( - [ - "fallback", - canonical, - str(pub.year) if pub.year is not None else "", - _first_author_last_name(pub.authors_text), - ] - ) - - -def _is_fuzzy_dup(tokens: set[str], seen: list[set[str]]) -> bool: - return any(_jaccard(tokens, existing) >= _CANONICAL_DEDUP_THRESHOLD for existing in seen) - - -def _build_body_excerpt(body: str, *, max_chars: int = 220) -> str | None: - if not body: - return None - flattened = SPACE_RE.sub(" ", HTML_TAG_RE.sub(" ", body)).strip() - if not flattened: - return None - if len(flattened) <= max_chars: - return flattened - return f"{flattened[: max_chars - 1]}..." diff --git a/app/services/ingestion/page_fetch.py b/app/services/ingestion/page_fetch.py deleted file mode 100644 index 3a57206..0000000 --- a/app/services/ingestion/page_fetch.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -from app.logging_utils import structured_log -from app.services.scholar.parser import ( - ParsedProfilePage, - ParseState, - ScholarParserError, - parse_profile_page, -) -from app.services.scholar.source import FetchResult, ScholarSource - -logger = logging.getLogger(__name__) - - -class PageFetcher: - """Fetches and parses a single Google Scholar profile page with retry logic.""" - - def __init__(self, *, source: ScholarSource) -> None: - self._source = source - - async def fetch_profile_page( - self, - *, - scholar_id: str, - cstart: int, - page_size: int, - ) -> FetchResult: - try: - page_fetcher = getattr(self._source, "fetch_profile_page_html", None) - if callable(page_fetcher): - return await page_fetcher( - scholar_id, - cstart=cstart, - pagesize=page_size, - ) - if cstart <= 0: - return await self._source.fetch_profile_html(scholar_id) - return FetchResult( - requested_url=( - f"https://scholar.google.com/citations?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" - ), - status_code=None, - final_url=None, - body="", - error="source_does_not_support_pagination", - ) - except Exception as exc: - structured_log( - logger, - "exception", - "ingestion.fetch_unexpected_error", - scholar_id=scholar_id, - cstart=cstart, - page_size=page_size, - ) - return FetchResult( - requested_url=( - f"https://scholar.google.com/citations?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" - ), - status_code=None, - final_url=None, - body="", - error=str(exc), - ) - - # ── Parse helpers ──────────────────────────────────────────────── - - def parse_page_or_layout_error( - self, - *, - fetch_result: FetchResult, - ) -> ParsedProfilePage: - try: - return parse_profile_page(fetch_result) - except ScholarParserError as exc: - return self._parsed_page_from_parser_error(code=exc.code) - - @staticmethod - def _parsed_page_from_parser_error(*, code: str) -> ParsedProfilePage: - return ParsedProfilePage( - state=ParseState.LAYOUT_CHANGED, - state_reason=code, - profile_name=None, - profile_image_url=None, - publications=[], - marker_counts={}, - warnings=[code], - has_show_more_button=False, - has_operation_error_banner=False, - articles_range=None, - ) - - # ── Retry logic ────────────────────────────────────────────────── - - @staticmethod - def _should_retry( - *, - parsed_page: ParsedProfilePage, - network_attempt_count: int, - rate_limit_attempt_count: int, - network_error_retries: int, - rate_limit_retries: int, - ) -> bool: - if parsed_page.state == ParseState.NETWORK_ERROR: - return network_attempt_count <= network_error_retries - if ( - parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA - and parsed_page.state_reason == "blocked_http_429_rate_limited" - ): - return rate_limit_attempt_count <= rate_limit_retries - return False - - @staticmethod - async def _sleep_backoff( - *, - scholar_id: str, - cstart: int, - network_attempt_count: int, - rate_limit_attempt_count: int, - retry_backoff_seconds: float, - rate_limit_backoff_seconds: float, - state_reason: str, - ) -> None: - if state_reason == "blocked_http_429_rate_limited": - sleep_seconds = rate_limit_backoff_seconds * rate_limit_attempt_count - attempt_label = rate_limit_attempt_count - else: - sleep_seconds = retry_backoff_seconds * (2 ** (network_attempt_count - 1)) - attempt_label = network_attempt_count - - structured_log( - logger, - "warning", - "ingestion.scholar_retry_scheduled", - scholar_id=scholar_id, - cstart=cstart, - attempt_count=attempt_label, - sleep_seconds=sleep_seconds, - state_reason=state_reason, - ) - if sleep_seconds > 0: - await asyncio.sleep(sleep_seconds) - - @staticmethod - def _classify_attempt( - parsed_page: ParsedProfilePage, - *, - network_attempts: int, - rate_limit_attempts: int, - ) -> tuple[int, int, int]: - if parsed_page.state == ParseState.NETWORK_ERROR: - network_attempts += 1 - return network_attempts, rate_limit_attempts, network_attempts - if ( - parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA - and parsed_page.state_reason == "blocked_http_429_rate_limited" - ): - rate_limit_attempts += 1 - return network_attempts, rate_limit_attempts, rate_limit_attempts - return network_attempts, rate_limit_attempts, network_attempts + rate_limit_attempts + 1 - - async def fetch_and_parse_with_retry( - self, - *, - scholar_id: str, - cstart: int, - page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: - network_attempts = 0 - rate_limit_attempts = 0 - attempt_log: list[dict[str, Any]] = [] - fetch_result: FetchResult | None = None - parsed_page: ParsedProfilePage | None = None - - while True: - fetch_result = await self.fetch_profile_page( - scholar_id=scholar_id, - cstart=cstart, - page_size=page_size, - ) - parsed_page = self.parse_page_or_layout_error(fetch_result=fetch_result) - network_attempts, rate_limit_attempts, total_attempts = self._classify_attempt( - parsed_page, network_attempts=network_attempts, rate_limit_attempts=rate_limit_attempts - ) - attempt_log.append( - { - "attempt": total_attempts, - "cstart": cstart, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "status_code": fetch_result.status_code, - "fetch_error": fetch_result.error, - } - ) - if not self._should_retry( - parsed_page=parsed_page, - network_attempt_count=network_attempts, - rate_limit_attempt_count=rate_limit_attempts, - network_error_retries=network_error_retries, - rate_limit_retries=rate_limit_retries, - ): - break - await self._sleep_backoff( - scholar_id=scholar_id, - cstart=cstart, - network_attempt_count=network_attempts, - rate_limit_attempt_count=rate_limit_attempts, - retry_backoff_seconds=max(float(retry_backoff_seconds), 0.0), - rate_limit_backoff_seconds=max(float(rate_limit_backoff_seconds), 0.0), - state_reason=parsed_page.state_reason, - ) - - if fetch_result is None or parsed_page is None: - raise RuntimeError("Fetch-and-parse retry loop produced no result.") - return fetch_result, parsed_page, attempt_log diff --git a/app/services/ingestion/pagination.py b/app/services/ingestion/pagination.py deleted file mode 100644 index 91bc984..0000000 --- a/app/services/ingestion/pagination.py +++ /dev/null @@ -1,504 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -from app.db.models import CrawlRun, RunStatus, ScholarProfile -from app.logging_utils import structured_log -from app.services.ingestion.fingerprints import ( - _dedupe_publication_candidates, - _next_cstart_value, - build_initial_page_fingerprint, -) -from app.services.ingestion.page_fetch import PageFetcher -from app.services.ingestion.types import PagedLoopState, PagedParseResult -from app.services.scholar.parser import ParsedProfilePage, ParseState -from app.services.scholar.source import FetchResult, ScholarSource - -logger = logging.getLogger(__name__) - - -class PaginationEngine: - """Fetches and paginates Google Scholar profile pages. - - Pure HTTP + parsing — no DB writes except via the provided upsert callback. - """ - - def __init__(self, *, source: ScholarSource) -> None: - self._source = source - self._fetcher = PageFetcher(source=source) - - # ── No-change / initial failure short-circuits ─────────────────── - - @staticmethod - def _should_skip_no_change( - *, - start_cstart: int, - first_page_fingerprint_sha256: str | None, - previous_initial_page_fingerprint_sha256: str | None, - parsed_page: ParsedProfilePage, - ) -> bool: - return ( - start_cstart <= 0 - and first_page_fingerprint_sha256 is not None - and previous_initial_page_fingerprint_sha256 is not None - and first_page_fingerprint_sha256 == previous_initial_page_fingerprint_sha256 - and parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} - ) - - @staticmethod - def _skip_no_change_result( - *, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedParseResult: - return PagedParseResult( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fetch_result=fetch_result, - first_page_parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - publications=[], - attempt_log=attempt_log, - page_logs=page_logs, - pages_fetched=1, - pages_attempted=1, - has_more_remaining=False, - pagination_truncated_reason=None, - continuation_cstart=None, - skipped_no_change=True, - discovered_publication_count=0, - ) - - @staticmethod - def _initial_failure_result( - *, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - start_cstart: int, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedParseResult: - continuation_cstart = start_cstart if parsed_page.state == ParseState.NETWORK_ERROR else None - return PagedParseResult( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fetch_result=fetch_result, - first_page_parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - publications=[], - attempt_log=attempt_log, - page_logs=page_logs, - pages_fetched=0, - pages_attempted=1, - has_more_remaining=False, - pagination_truncated_reason=None, - continuation_cstart=continuation_cstart, - skipped_no_change=False, - discovered_publication_count=0, - ) - - # ── Loop state management ──────────────────────────────────────── - - @staticmethod - def _build_loop_state( - *, - start_cstart: int, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedLoopState: - next_cstart = _next_cstart_value( - articles_range=parsed_page.articles_range, - fallback=start_cstart + len(parsed_page.publications), - ) - return PagedLoopState( - fetch_result=fetch_result, - parsed_page=parsed_page, - attempt_log=attempt_log, - page_logs=page_logs, - publications=list(parsed_page.publications), - pages_fetched=1, - pages_attempted=1, - current_cstart=start_cstart, - next_cstart=next_cstart, - ) - - @staticmethod - def _set_truncated_state( - *, - state: PagedLoopState, - reason: str, - continuation_cstart: int, - ) -> None: - state.has_more_remaining = True - state.pagination_truncated_reason = reason - state.continuation_cstart = continuation_cstart - - def _should_stop_pagination(self, *, state: PagedLoopState, bounded_max_pages: int) -> bool: - if state.pages_fetched >= bounded_max_pages: - self._set_truncated_state( - state=state, - reason="max_pages_reached", - continuation_cstart=( - state.next_cstart if state.next_cstart > state.current_cstart else state.current_cstart - ), - ) - return True - if state.next_cstart <= state.current_cstart: - self._set_truncated_state( - state=state, - reason="pagination_cursor_stalled", - continuation_cstart=state.current_cstart, - ) - return True - return False - - # ── Multi-page fetch helpers ───────────────────────────────────── - - async def _fetch_next_page( - self, - *, - scholar_id: str, - state: PagedLoopState, - request_delay_seconds: int, - bounded_page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: - if request_delay_seconds > 0: - await asyncio.sleep(float(request_delay_seconds)) - state.current_cstart = state.next_cstart - return await self._fetcher.fetch_and_parse_with_retry( - scholar_id=scholar_id, - cstart=state.current_cstart, - page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - ) - - @staticmethod - def _record_next_page( - *, - state: PagedLoopState, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - page_attempt_log: list[dict[str, Any]], - ) -> None: - state.pages_attempted += 1 - state.attempt_log.extend(page_attempt_log) - state.page_logs.append( - { - "page": state.pages_attempted, - "cstart": state.current_cstart, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "status_code": fetch_result.status_code, - "publication_count": len(parsed_page.publications), - "articles_range": parsed_page.articles_range, - "has_show_more_button": parsed_page.has_show_more_button, - "warning_codes": parsed_page.warnings, - "attempt_count": len(page_attempt_log), - } - ) - state.fetch_result = fetch_result - state.parsed_page = parsed_page - - def _handle_page_state_transition(self, *, state: PagedLoopState) -> bool: - if state.parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: - self._set_truncated_state( - state=state, - reason=f"page_state_{state.parsed_page.state.value}", - continuation_cstart=state.current_cstart, - ) - return True - if state.parsed_page.state == ParseState.NO_RESULTS and len(state.parsed_page.publications) == 0: - state.pages_fetched += 1 - return True - state.pages_fetched += 1 - state.publications.extend(state.parsed_page.publications) - state.next_cstart = _next_cstart_value( - articles_range=state.parsed_page.articles_range, - fallback=state.current_cstart + len(state.parsed_page.publications), - ) - return False - - async def _fetch_initial_page_context( - self, - *, - scholar_id: str, - start_cstart: int, - bounded_page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - ) -> tuple[FetchResult, ParsedProfilePage, str | None, list[dict[str, Any]], list[dict[str, Any]]]: - fetch_result, parsed_page, first_attempt_log = await self._fetcher.fetch_and_parse_with_retry( - scholar_id=scholar_id, - cstart=start_cstart, - page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - ) - first_page_fingerprint_sha256 = build_initial_page_fingerprint(parsed_page) - attempt_log = list(first_attempt_log) - page_logs = [ - { - "page": 1, - "cstart": start_cstart, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "status_code": fetch_result.status_code, - "publication_count": len(parsed_page.publications), - "articles_range": parsed_page.articles_range, - "has_show_more_button": parsed_page.has_show_more_button, - "warning_codes": parsed_page.warnings, - "attempt_count": len(first_attempt_log), - } - ] - return fetch_result, parsed_page, first_page_fingerprint_sha256, attempt_log, page_logs - - # ── Pagination loop ────────────────────────────────────────────── - - @staticmethod - async def _upsert_page_publications( - db_session: Any, - *, - run: CrawlRun, - scholar: ScholarProfile, - publications: list, - seen_canonical: set[str], - state: PagedLoopState, - upsert_publications_fn: Any, - ) -> None: - deduped = _dedupe_publication_candidates(list(publications), seen_canonical=seen_canonical) - if deduped: - discovered_count = await upsert_publications_fn(db_session, run=run, scholar=scholar, publications=deduped) - state.discovered_publication_count += discovered_count - - async def _paginate_loop( - self, - *, - scholar: ScholarProfile, - run: CrawlRun, - db_session: Any, - state: PagedLoopState, - bounded_max_pages: int, - request_delay_seconds: int, - bounded_page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - upsert_publications_fn: Any, - ) -> None: - seen_canonical: set[str] = set() - - if state.parsed_page.publications: - await self._upsert_page_publications( - db_session, - run=run, - scholar=scholar, - publications=state.parsed_page.publications, - seen_canonical=seen_canonical, - state=state, - upsert_publications_fn=upsert_publications_fn, - ) - - while state.parsed_page.has_show_more_button: - await db_session.refresh(run) - if run.status == RunStatus.CANCELED: - structured_log( - logger, - "info", - "ingestion.pagination_canceled", - run_id=run.id, - ) - self._set_truncated_state( - state=state, - reason="run_canceled", - continuation_cstart=state.current_cstart, - ) - return - - if self._should_stop_pagination(state=state, bounded_max_pages=bounded_max_pages): - return - next_fetch_result, next_parsed_page, next_attempt_log = await self._fetch_next_page( - scholar_id=scholar.scholar_id, - state=state, - request_delay_seconds=request_delay_seconds, - bounded_page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - ) - self._record_next_page( - state=state, - fetch_result=next_fetch_result, - parsed_page=next_parsed_page, - page_attempt_log=next_attempt_log, - ) - - if self._handle_page_state_transition(state=state): - return - - if next_parsed_page.publications: - await self._upsert_page_publications( - db_session, - run=run, - scholar=scholar, - publications=next_parsed_page.publications, - seen_canonical=seen_canonical, - state=state, - upsert_publications_fn=upsert_publications_fn, - ) - - @staticmethod - def _result_from_pagination_state( - *, - state: PagedLoopState, - first_page_fetch_result: FetchResult, - first_page_parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - ) -> PagedParseResult: - return PagedParseResult( - fetch_result=state.fetch_result, - parsed_page=state.parsed_page, - first_page_fetch_result=first_page_fetch_result, - first_page_parsed_page=first_page_parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - publications=_dedupe_publication_candidates(state.publications), - attempt_log=state.attempt_log, - page_logs=state.page_logs, - pages_fetched=state.pages_fetched, - pages_attempted=state.pages_attempted, - has_more_remaining=state.has_more_remaining, - pagination_truncated_reason=state.pagination_truncated_reason, - continuation_cstart=state.continuation_cstart, - skipped_no_change=False, - discovered_publication_count=state.discovered_publication_count, - ) - - def _short_circuit_initial_page( - self, - *, - start_cstart: int, - previous_initial_page_fingerprint_sha256: str | None, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedParseResult | None: - if self._should_skip_no_change( - start_cstart=start_cstart, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - previous_initial_page_fingerprint_sha256=previous_initial_page_fingerprint_sha256, - parsed_page=parsed_page, - ): - return self._skip_no_change_result( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - attempt_log=attempt_log, - page_logs=page_logs, - ) - if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: - return self._initial_failure_result( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - start_cstart=start_cstart, - attempt_log=attempt_log, - page_logs=page_logs, - ) - return None - - # ── Public entry point ─────────────────────────────────────────── - - async def fetch_and_parse_all_pages( - self, - *, - scholar: ScholarProfile, - run: CrawlRun, - db_session: Any, - start_cstart: int, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - max_pages: int, - page_size: int, - previous_initial_page_fingerprint_sha256: str | None = None, - upsert_publications_fn: Any = None, - ) -> PagedParseResult: - bounded_max_pages = max(1, int(max_pages)) - bounded_page_size = max(1, int(page_size)) - ( - fetch_result, - parsed_page, - first_page_fingerprint_sha256, - attempt_log, - page_logs, - ) = await self._fetch_initial_page_context( - scholar_id=scholar.scholar_id, - start_cstart=start_cstart, - bounded_page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - ) - shortcut_result = self._short_circuit_initial_page( - start_cstart=start_cstart, - previous_initial_page_fingerprint_sha256=previous_initial_page_fingerprint_sha256, - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - attempt_log=attempt_log, - page_logs=page_logs, - ) - if shortcut_result is not None: - return shortcut_result - state = self._build_loop_state( - start_cstart=start_cstart, - fetch_result=fetch_result, - parsed_page=parsed_page, - attempt_log=attempt_log, - page_logs=page_logs, - ) - await self._paginate_loop( - scholar=scholar, - run=run, - db_session=db_session, - state=state, - bounded_max_pages=bounded_max_pages, - request_delay_seconds=request_delay_seconds, - bounded_page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - upsert_publications_fn=upsert_publications_fn, - ) - return self._result_from_pagination_state( - state=state, - first_page_fetch_result=fetch_result, - first_page_parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - ) diff --git a/app/services/ingestion/preflight.py b/app/services/ingestion/preflight.py deleted file mode 100644 index 44441e3..0000000 --- a/app/services/ingestion/preflight.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass - -from app.logging_utils import structured_log -from app.services.scholar.source import FetchResult, ScholarSource -from app.services.scholar.state_detection import ( - classify_block_or_captcha_reason, - is_hard_challenge_reason, -) - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PreflightResult: - passed: bool - block_reason: str | None - status_code: int | None - - -def _evaluate_fetch_result(fetch_result: FetchResult) -> PreflightResult: - if fetch_result.status_code is None: - return PreflightResult(passed=True, block_reason=None, status_code=None) - block_reason = classify_block_or_captcha_reason( - status_code=fetch_result.status_code, - final_url=(fetch_result.final_url or "").lower(), - body_lowered=fetch_result.body.lower(), - ) - if block_reason is not None and is_hard_challenge_reason(block_reason): - return PreflightResult( - passed=False, - block_reason=block_reason, - status_code=fetch_result.status_code, - ) - return PreflightResult( - passed=True, - block_reason=None, - status_code=fetch_result.status_code, - ) - - -async def check_scholar_reachable( - source: ScholarSource, - *, - scholar_id: str, -) -> PreflightResult: - """Single-request probe to detect active Scholar blocks before a full run.""" - structured_log(logger, "info", "ingestion.preflight_started", scholar_id=scholar_id) - fetch_result = await source.fetch_profile_html(scholar_id) - result = _evaluate_fetch_result(fetch_result) - if result.passed: - structured_log( - logger, - "info", - "ingestion.preflight_passed", - scholar_id=scholar_id, - status_code=result.status_code, - ) - else: - structured_log( - logger, - "warning", - "ingestion.preflight_failed", - scholar_id=scholar_id, - block_reason=result.block_reason, - status_code=result.status_code, - ) - return result diff --git a/app/services/ingestion/publication_upsert.py b/app/services/ingestion/publication_upsert.py deleted file mode 100644 index ae7b1b8..0000000 --- a/app/services/ingestion/publication_upsert.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -import hashlib -import logging -from datetime import UTC, datetime - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import CrawlRun, Publication, ScholarProfile, ScholarPublication -from app.services.ingestion.fingerprints import ( - build_publication_fingerprint, - build_publication_url, - canonical_title_for_dedup, - normalize_title, -) -from app.services.publication_identifiers import application as identifier_service -from app.services.runs.events import run_events -from app.services.scholar.parser import PublicationCandidate - -logger = logging.getLogger(__name__) - - -def validate_publication_candidate(candidate: PublicationCandidate) -> None: - if not candidate.title.strip(): - raise RuntimeError("Publication candidate is missing title.") - if candidate.citation_count is not None and int(candidate.citation_count) < 0: - raise RuntimeError("Publication candidate has negative citation_count.") - - -async def find_publication_by_cluster( - db_session: AsyncSession, - *, - cluster_id: str | None, -) -> Publication | None: - if not cluster_id: - return None - result = await db_session.execute(select(Publication).where(Publication.cluster_id == cluster_id)) - return result.scalar_one_or_none() - - -async def find_publication_by_fingerprint( - db_session: AsyncSession, - *, - fingerprint: str, -) -> Publication | None: - result = await db_session.execute(select(Publication).where(Publication.fingerprint_sha256 == fingerprint)) - return result.scalar_one_or_none() - - -def select_existing_publication( - *, - cluster_publication: Publication | None, - fingerprint_publication: Publication | None, -) -> Publication | None: - if cluster_publication is not None: - return cluster_publication - return fingerprint_publication - - -def compute_canonical_title_hash(title: str) -> str: - canonical = canonical_title_for_dedup(title) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -async def find_publication_by_canonical_title_hash( - db_session: AsyncSession, - *, - canonical_title_hash: str, -) -> Publication | None: - result = await db_session.execute( - select(Publication).where(Publication.canonical_title_hash == canonical_title_hash) - ) - return result.scalar_one_or_none() - - -async def create_publication( - db_session: AsyncSession, - *, - candidate: PublicationCandidate, - fingerprint: str, -) -> Publication: - publication = Publication( - cluster_id=candidate.cluster_id, - fingerprint_sha256=fingerprint, - title_raw=candidate.title, - title_normalized=normalize_title(candidate.title), - canonical_title_hash=compute_canonical_title_hash(candidate.title), - year=candidate.year, - citation_count=int(candidate.citation_count or 0), - author_text=candidate.authors_text, - venue_text=candidate.venue_text, - pub_url=build_publication_url(candidate.title_url), - pdf_url=None, - ) - db_session.add(publication) - await db_session.flush() - return publication - - -def update_existing_publication( - *, - publication: Publication, - candidate: PublicationCandidate, -) -> None: - if candidate.cluster_id and publication.cluster_id is None: - publication.cluster_id = candidate.cluster_id - if not publication.title_raw: - publication.title_raw = candidate.title - publication.title_normalized = normalize_title(candidate.title) - if candidate.year is not None: - publication.year = candidate.year - if candidate.citation_count is not None: - publication.citation_count = int(candidate.citation_count) - if candidate.authors_text: - publication.author_text = candidate.authors_text - if candidate.venue_text: - publication.venue_text = candidate.venue_text - if candidate.title_url: - publication.pub_url = build_publication_url(candidate.title_url) - - -async def resolve_publication( - db_session: AsyncSession, - candidate: PublicationCandidate, -) -> Publication: - validate_publication_candidate(candidate) - fingerprint = build_publication_fingerprint(candidate) - cluster_publication = await find_publication_by_cluster( - db_session, - cluster_id=candidate.cluster_id, - ) - fingerprint_publication = await find_publication_by_fingerprint( - db_session, - fingerprint=fingerprint, - ) - publication = select_existing_publication( - cluster_publication=cluster_publication, - fingerprint_publication=fingerprint_publication, - ) - if publication is None: - canonical_hash = compute_canonical_title_hash(candidate.title) - publication = await find_publication_by_canonical_title_hash( - db_session, - canonical_title_hash=canonical_hash, - ) - if publication is None: - created = await create_publication( - db_session, - candidate=candidate, - fingerprint=fingerprint, - ) - await identifier_service.sync_identifiers_for_publication_fields( - db_session, - publication=created, - ) - return created - update_existing_publication( - publication=publication, - candidate=candidate, - ) - await identifier_service.sync_identifiers_for_publication_fields( - db_session, - publication=publication, - ) - return publication - - -async def upsert_profile_publications( - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - publications: list[PublicationCandidate], -) -> int: - seen_publication_ids: set[int] = set() - discovered_count = 0 - - try: - for candidate in publications: - publication = await resolve_publication(db_session, candidate) - if publication.id in seen_publication_ids: - continue - seen_publication_ids.add(publication.id) - - link_result = await db_session.execute( - select(ScholarPublication).where( - ScholarPublication.scholar_profile_id == scholar.id, - ScholarPublication.publication_id == publication.id, - ) - ) - link = link_result.scalar_one_or_none() - if link is not None: - continue - - link = ScholarPublication( - scholar_profile_id=scholar.id, - publication_id=publication.id, - is_read=False, - first_seen_run_id=run.id, - ) - db_session.add(link) - discovered_count += 1 - - await flush_discovered_publication( - db_session, - run=run, - scholar=scholar, - publication=publication, - ) - - if not scholar.baseline_completed: - scholar.baseline_completed = True - - await db_session.commit() - except Exception: - await db_session.rollback() - raise - - return discovered_count - - -async def flush_discovered_publication( - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - publication: Publication, -) -> None: - run.new_pub_count = int(run.new_pub_count or 0) + 1 - await db_session.flush() - await run_events.publish( - run_id=run.id, - event_type="publication_discovered", - data={ - "publication_id": publication.id, - "title": publication.title_raw, - "pub_url": publication.pub_url, - "scholar_profile_id": scholar.id, - "scholar_label": scholar.display_name or scholar.scholar_id, - "first_seen_at": datetime.now(UTC).isoformat(), - "new_publication_count": int(run.new_pub_count or 0), - }, - ) diff --git a/app/services/ingestion/queue_runner.py b/app/services/ingestion/queue_runner.py deleted file mode 100644 index 9f7bd1b..0000000 --- a/app/services/ingestion/queue_runner.py +++ /dev/null @@ -1,381 +0,0 @@ -from __future__ import annotations - -import logging -from datetime import UTC, datetime - -from sqlalchemy import select - -from app.db.background_session import background_session -from app.db.models import QueueItemStatus, RunTriggerType, ScholarProfile, UserSetting -from app.logging_utils import structured_log -from app.services.ingestion import queue as queue_service -from app.services.ingestion.application import ( - RunAlreadyInProgressError, - RunBlockedBySafetyPolicyError, - ScholarIngestionService, -) -from app.services.scholar.source import LiveScholarSource -from app.settings import settings - -logger = logging.getLogger(__name__) - - -def effective_request_delay_seconds(value: int | None, *, floor: int) -> int: - try: - parsed = int(value) if value is not None else floor - except (TypeError, ValueError): - parsed = floor - return max(floor, parsed) - - -class QueueJobRunner: - def __init__( - self, - *, - tick_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - continuation_queue_enabled: bool, - continuation_base_delay_seconds: int, - continuation_max_delay_seconds: int, - continuation_max_attempts: int, - queue_batch_size: int, - ) -> None: - self._tick_seconds = tick_seconds - self._network_error_retries = network_error_retries - self._retry_backoff_seconds = retry_backoff_seconds - self._max_pages_per_scholar = max_pages_per_scholar - self._page_size = page_size - self._continuation_queue_enabled = continuation_queue_enabled - self._continuation_base_delay_seconds = continuation_base_delay_seconds - self._continuation_max_delay_seconds = continuation_max_delay_seconds - self._continuation_max_attempts = continuation_max_attempts - self._queue_batch_size = queue_batch_size - self._source = LiveScholarSource() - - async def drain_continuation_queue(self) -> None: - now = datetime.now(UTC) - async with background_session() as session: - jobs = await queue_service.list_due_jobs( - session, - now=now, - limit=self._queue_batch_size, - ) - for job in jobs: - await self._run_queue_job(job) - - async def _drop_queue_job_if_max_attempts( - self, - job: queue_service.ContinuationQueueJob, - ) -> bool: - if job.attempt_count < self._continuation_max_attempts: - return False - async with background_session() as session: - dropped = await queue_service.mark_dropped( - session, - job_id=job.id, - reason="max_attempts_reached", - ) - await session.commit() - if dropped is not None: - structured_log( - logger, - "warning", - "scheduler.queue_item_dropped_max_attempts", - queue_item_id=job.id, - user_id=job.user_id, - scholar_profile_id=job.scholar_profile_id, - attempt_count=job.attempt_count, - max_attempts=self._continuation_max_attempts, - ) - return True - - async def _mark_queue_job_retrying( - self, - job: queue_service.ContinuationQueueJob, - ) -> bool: - async with background_session() as session: - queue_item = await queue_service.mark_retrying(session, job_id=job.id) - await session.commit() - if queue_item is None: - return False - return queue_item.status != QueueItemStatus.DROPPED.value - - async def _queue_job_has_available_scholar( - self, - job: queue_service.ContinuationQueueJob, - ) -> bool: - async with background_session() as session: - scholar_result = await session.execute( - select(ScholarProfile.id).where( - ScholarProfile.user_id == job.user_id, - ScholarProfile.id == job.scholar_profile_id, - ScholarProfile.is_enabled.is_(True), - ) - ) - scholar_id = scholar_result.scalar_one_or_none() - if scholar_id is not None: - return True - dropped = await queue_service.mark_dropped( - session, - job_id=job.id, - reason="scholar_unavailable", - ) - await session.commit() - if dropped is not None: - structured_log( - logger, - "info", - "scheduler.queue_item_dropped_scholar_unavailable", - queue_item_id=job.id, - user_id=job.user_id, - scholar_profile_id=job.scholar_profile_id, - ) - return False - - async def _reschedule_queue_job_lock_active(self, job: queue_service.ContinuationQueueJob) -> None: - async with background_session() as recovery_session: - await queue_service.reschedule_job( - recovery_session, - job_id=job.id, - delay_seconds=max(self._tick_seconds, 15), - reason="user_run_lock_active", - error="run_already_in_progress", - ) - await recovery_session.commit() - structured_log( - logger, - "info", - "scheduler.queue_item_deferred_lock", - queue_item_id=job.id, - user_id=job.user_id, - ) - - async def _reschedule_queue_job_safety_cooldown( - self, - job: queue_service.ContinuationQueueJob, - exc: RunBlockedBySafetyPolicyError, - ) -> None: - cooldown_remaining_seconds = max( - self._tick_seconds, - int(exc.safety_state.get("cooldown_remaining_seconds") or 0), - ) - async with background_session() as recovery_session: - await queue_service.reschedule_job( - recovery_session, - job_id=job.id, - delay_seconds=max(self._tick_seconds, cooldown_remaining_seconds), - reason="scrape_safety_cooldown", - error=str(exc.message), - ) - await recovery_session.commit() - structured_log( - logger, - "info", - "scheduler.queue_item_deferred_safety_cooldown", - queue_item_id=job.id, - user_id=job.user_id, - reason=exc.safety_state.get("cooldown_reason"), - cooldown_remaining_seconds=cooldown_remaining_seconds, - ) - - async def _reschedule_queue_job_after_exception( - self, - job: queue_service.ContinuationQueueJob, - *, - exc: Exception, - ) -> None: - async with background_session() as recovery_session: - queue_item = await queue_service.increment_attempt_count(recovery_session, job_id=job.id) - if queue_item is None: - await recovery_session.commit() - return - if int(queue_item.attempt_count) >= self._continuation_max_attempts: - await queue_service.mark_dropped( - recovery_session, - job_id=job.id, - reason="scheduler_exception_max_attempts", - error=str(exc), - ) - await recovery_session.commit() - structured_log( - logger, - "warning", - "scheduler.queue_item_dropped_after_exception", - queue_item_id=job.id, - user_id=job.user_id, - attempt_count=queue_item.attempt_count, - ) - return - delay_seconds = queue_service.compute_backoff_seconds( - base_seconds=self._continuation_base_delay_seconds, - attempt_count=int(queue_item.attempt_count), - max_seconds=self._continuation_max_delay_seconds, - ) - await queue_service.reschedule_job( - recovery_session, - job_id=job.id, - delay_seconds=delay_seconds, - reason="scheduler_exception", - error=str(exc), - ) - await recovery_session.commit() - structured_log( - logger, - "exception", - "scheduler.queue_item_run_failed", - queue_item_id=job.id, - user_id=job.user_id, - ) - - async def _load_request_delay_for_user(self, user_id: int, *, floor: int) -> int: - async with background_session() as session: - result = await session.execute( - select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id) - ) - delay = result.scalar_one_or_none() - return effective_request_delay_seconds(delay, floor=floor) - - async def _run_ingestion_for_queue_job( - self, - job: queue_service.ContinuationQueueJob, - *, - request_delay_floor: int, - ): - request_delay_seconds = await self._load_request_delay_for_user(job.user_id, floor=request_delay_floor) - async with background_session() as session: - ingestion = ScholarIngestionService(source=self._source) - try: - return await ingestion.run_for_user( - session, - user_id=job.user_id, - trigger_type=RunTriggerType.SCHEDULED, - request_delay_seconds=request_delay_seconds, - network_error_retries=self._network_error_retries, - retry_backoff_seconds=self._retry_backoff_seconds, - rate_limit_retries=settings.ingestion_rate_limit_retries, - rate_limit_backoff_seconds=settings.ingestion_rate_limit_backoff_seconds, - max_pages_per_scholar=self._max_pages_per_scholar, - page_size=self._page_size, - scholar_profile_ids={job.scholar_profile_id}, - start_cstart_by_scholar_id={job.scholar_profile_id: job.resume_cstart}, - auto_queue_continuations=self._continuation_queue_enabled, - queue_delay_seconds=self._continuation_base_delay_seconds, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - except RunAlreadyInProgressError: - await session.rollback() - await self._reschedule_queue_job_lock_active(job) - except RunBlockedBySafetyPolicyError as exc: - await session.rollback() - await self._reschedule_queue_job_safety_cooldown(job, exc) - except Exception as exc: - await session.rollback() - await self._reschedule_queue_job_after_exception(job, exc=exc) - return None - - async def _finalize_successful_queue_job(self, session, job, run_summary) -> None: - queue_item = await queue_service.reset_attempt_count(session, job_id=job.id) - await session.commit() - if queue_item is None: - structured_log( - logger, - "info", - "scheduler.queue_item_resolved", - queue_item_id=job.id, - user_id=job.user_id, - run_id=run_summary.crawl_run_id, - status=run_summary.status.value, - ) - return - structured_log( - logger, - "info", - "scheduler.queue_item_progressed", - queue_item_id=job.id, - user_id=job.user_id, - run_id=run_summary.crawl_run_id, - status=run_summary.status.value, - attempt_count=int(queue_item.attempt_count), - ) - - async def _finalize_failed_queue_job(self, session, job, run_summary) -> None: - queue_item = await queue_service.increment_attempt_count(session, job_id=job.id) - if queue_item is None: - await session.commit() - structured_log( - logger, - "info", - "scheduler.queue_item_resolved", - queue_item_id=job.id, - user_id=job.user_id, - run_id=run_summary.crawl_run_id, - status=run_summary.status.value, - ) - return - if int(queue_item.attempt_count) >= self._continuation_max_attempts: - await queue_service.mark_dropped(session, job_id=job.id, reason="max_attempts_after_run") - await session.commit() - structured_log( - logger, - "warning", - "scheduler.queue_item_dropped_max_attempts_after_run", - queue_item_id=job.id, - user_id=job.user_id, - attempt_count=queue_item.attempt_count, - run_id=run_summary.crawl_run_id, - status=run_summary.status.value, - ) - return - delay_seconds = queue_service.compute_backoff_seconds( - base_seconds=self._continuation_base_delay_seconds, - attempt_count=int(queue_item.attempt_count), - max_seconds=self._continuation_max_delay_seconds, - ) - await queue_service.reschedule_job( - session, - job_id=job.id, - delay_seconds=delay_seconds, - reason=queue_item.reason, - error=queue_item.last_error, - ) - await session.commit() - structured_log( - logger, - "info", - "scheduler.queue_item_rescheduled_failed", - queue_item_id=job.id, - user_id=job.user_id, - run_id=run_summary.crawl_run_id, - status=run_summary.status.value, - attempt_count=int(queue_item.attempt_count), - delay_seconds=delay_seconds, - ) - - async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None: - async with background_session() as session: - if int(run_summary.failed_count) <= 0: - await self._finalize_successful_queue_job(session, job, run_summary) - else: - await self._finalize_failed_queue_job(session, job, run_summary) - - async def _run_queue_job(self, job: queue_service.ContinuationQueueJob) -> None: - if await self._drop_queue_job_if_max_attempts(job): - return - if not await self._mark_queue_job_retrying(job): - return - if not await self._queue_job_has_available_scholar(job): - return - from app.services.settings import application as user_settings_service - - request_delay_floor = user_settings_service.resolve_request_delay_minimum( - settings.ingestion_min_request_delay_seconds, - ) - run_summary = await self._run_ingestion_for_queue_job(job, request_delay_floor=request_delay_floor) - if run_summary is None: - return - await self._finalize_queue_job_after_run(job, run_summary) diff --git a/app/services/ingestion/run_completion.py b/app/services/ingestion/run_completion.py deleted file mode 100644 index d22b8fc..0000000 --- a/app/services/ingestion/run_completion.py +++ /dev/null @@ -1,457 +0,0 @@ -from __future__ import annotations - -import hashlib -import logging -from datetime import UTC, datetime -from typing import Any - -from app.db.models import ( - CrawlRun, - RunStatus, - ScholarProfile, -) -from app.logging_utils import structured_log -from app.services.ingestion import safety as run_safety_service -from app.services.ingestion.constants import ( - FAILED_STATES, - FAILURE_BUCKET_BLOCKED, - FAILURE_BUCKET_INGESTION, - FAILURE_BUCKET_LAYOUT, - FAILURE_BUCKET_NETWORK, - FAILURE_BUCKET_OTHER, -) -from app.services.ingestion.fingerprints import _build_body_excerpt -from app.services.ingestion.types import ( - RunAlertSummary, - RunExecutionSummary, - RunFailureSummary, - RunProgress, - ScholarProcessingOutcome, -) -from app.services.scholar.parser import ParsedProfilePage, ParseState -from app.services.scholar.source import FetchResult -from app.settings import settings - -logger = logging.getLogger(__name__) - - -def int_or_default(value: Any, default: int = 0) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def classify_failure_bucket(*, state: str, state_reason: str) -> str: - reason = state_reason.strip().lower() - normalized_state = state.strip().lower() - - if normalized_state == ParseState.BLOCKED_OR_CAPTCHA.value or reason.startswith("blocked_"): - return FAILURE_BUCKET_BLOCKED - if normalized_state == ParseState.NETWORK_ERROR.value or reason.startswith("network_"): - return FAILURE_BUCKET_NETWORK - if normalized_state == ParseState.LAYOUT_CHANGED.value: - return FAILURE_BUCKET_LAYOUT - if normalized_state == "ingestion_error": - return FAILURE_BUCKET_INGESTION - return FAILURE_BUCKET_OTHER - - -def summarize_failures( - *, - scholar_results: list[dict[str, Any]], -) -> RunFailureSummary: - failed_state_counts: dict[str, int] = {} - failed_reason_counts: dict[str, int] = {} - scrape_failure_counts: dict[str, int] = {} - retries_scheduled_count = 0 - scholars_with_retries_count = 0 - retry_exhausted_count = 0 - for entry in scholar_results: - retries_for_entry = max(0, int_or_default(entry.get("attempt_count"), 0) - 1) - if retries_for_entry > 0: - retries_scheduled_count += retries_for_entry - scholars_with_retries_count += 1 - if str(entry.get("outcome", "")) != "failed": - continue - state = str(entry.get("state", "")).strip() - if state not in FAILED_STATES: - continue - failed_state_counts[state] = failed_state_counts.get(state, 0) + 1 - reason = str(entry.get("state_reason", "")).strip() - if reason: - failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1 - bucket = classify_failure_bucket(state=state, state_reason=reason) - scrape_failure_counts[bucket] = scrape_failure_counts.get(bucket, 0) + 1 - if state == ParseState.NETWORK_ERROR.value and retries_for_entry > 0: - retry_exhausted_count += 1 - return RunFailureSummary( - failed_state_counts=failed_state_counts, - failed_reason_counts=failed_reason_counts, - scrape_failure_counts=scrape_failure_counts, - retries_scheduled_count=retries_scheduled_count, - scholars_with_retries_count=scholars_with_retries_count, - retry_exhausted_count=retry_exhausted_count, - ) - - -def build_alert_summary( - *, - failure_summary: RunFailureSummary, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, -) -> RunAlertSummary: - blocked_failure_count = int(failure_summary.scrape_failure_counts.get(FAILURE_BUCKET_BLOCKED, 0)) - network_failure_count = int(failure_summary.scrape_failure_counts.get(FAILURE_BUCKET_NETWORK, 0)) - blocked_threshold = max(1, int(alert_blocked_failure_threshold)) - network_threshold = max(1, int(alert_network_failure_threshold)) - retry_threshold = max(1, int(alert_retry_scheduled_threshold)) - alert_flags = { - "blocked_failure_threshold_exceeded": blocked_failure_count >= blocked_threshold, - "network_failure_threshold_exceeded": network_failure_count >= network_threshold, - "retry_scheduled_threshold_exceeded": failure_summary.retries_scheduled_count >= retry_threshold, - } - return RunAlertSummary( - blocked_failure_count=blocked_failure_count, - network_failure_count=network_failure_count, - blocked_failure_threshold=blocked_threshold, - network_failure_threshold=network_threshold, - retry_scheduled_threshold=retry_threshold, - alert_flags=alert_flags, - ) - - -def apply_safety_outcome( - *, - user_settings: Any, - run: CrawlRun, - user_id: int, - alert_summary: RunAlertSummary, -) -> None: - pre_apply_state = run_safety_service.get_safety_event_context( - user_settings, - now_utc=datetime.now(UTC), - ) - safety_state, cooldown_trigger_reason = run_safety_service.apply_run_safety_outcome( - user_settings, - run_id=int(run.id), - blocked_failure_count=alert_summary.blocked_failure_count, - network_failure_count=alert_summary.network_failure_count, - blocked_failure_threshold=alert_summary.blocked_failure_threshold, - network_failure_threshold=alert_summary.network_failure_threshold, - blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds, - network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds, - now_utc=datetime.now(UTC), - ) - if cooldown_trigger_reason is not None: - structured_log( - logger, - "warning", - "ingestion.safety_cooldown_entered", - user_id=user_id, - crawl_run_id=int(run.id), - reason=cooldown_trigger_reason, - blocked_failure_count=alert_summary.blocked_failure_count, - network_failure_count=alert_summary.network_failure_count, - blocked_failure_threshold=alert_summary.blocked_failure_threshold, - network_failure_threshold=alert_summary.network_failure_threshold, - cooldown_until=safety_state.get("cooldown_until"), - cooldown_remaining_seconds=safety_state.get("cooldown_remaining_seconds"), - safety_counters=safety_state.get("counters", {}), - ) - elif pre_apply_state.get("cooldown_active") and not safety_state.get("cooldown_active"): - structured_log( - logger, - "info", - "ingestion.cooldown_cleared", - user_id=user_id, - crawl_run_id=int(run.id), - reason=pre_apply_state.get("cooldown_reason"), - cooldown_until=pre_apply_state.get("cooldown_until"), - ) - - -def finalize_run_record( - *, - run: CrawlRun, - scholars: list[ScholarProfile], - progress: RunProgress, - failure_summary: RunFailureSummary, - alert_summary: RunAlertSummary, - idempotency_key: str | None, - run_status: RunStatus, -) -> None: - run.end_dt = datetime.now(UTC) - if run.status != RunStatus.CANCELED: - run.status = run_status - run.error_log = { - "scholar_results": progress.scholar_results, - "summary": { - "succeeded_count": progress.succeeded_count, - "failed_count": progress.failed_count, - "partial_count": progress.partial_count, - "failed_state_counts": failure_summary.failed_state_counts, - "failed_reason_counts": failure_summary.failed_reason_counts, - "scrape_failure_counts": failure_summary.scrape_failure_counts, - "retry_counts": { - "retries_scheduled_count": failure_summary.retries_scheduled_count, - "scholars_with_retries_count": failure_summary.scholars_with_retries_count, - "retry_exhausted_count": failure_summary.retry_exhausted_count, - }, - "alert_thresholds": { - "blocked_failure_threshold": alert_summary.blocked_failure_threshold, - "network_failure_threshold": alert_summary.network_failure_threshold, - "retry_scheduled_threshold": alert_summary.retry_scheduled_threshold, - }, - "alert_flags": alert_summary.alert_flags, - }, - "meta": {"idempotency_key": idempotency_key} if idempotency_key else {}, - } - - -def resolve_run_status( - *, - scholar_count: int, - succeeded_count: int, - failed_count: int, - partial_count: int, -) -> RunStatus: - if scholar_count == 0: - return RunStatus.SUCCESS - if failed_count == scholar_count: - return RunStatus.FAILED - if failed_count > 0 or partial_count > 0: - return RunStatus.PARTIAL_FAILURE - if succeeded_count > 0: - return RunStatus.SUCCESS - return RunStatus.FAILED - - -def build_failure_debug_context( - *, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]] | None = None, - exception: Exception | None = None, -) -> dict[str, Any]: - context: dict[str, Any] = { - "requested_url": fetch_result.requested_url, - "final_url": fetch_result.final_url, - "status_code": fetch_result.status_code, - "fetch_error": fetch_result.error, - "state_reason": parsed_page.state_reason, - "profile_name": parsed_page.profile_name, - "profile_image_url": parsed_page.profile_image_url, - "articles_range": parsed_page.articles_range, - "has_show_more_button": parsed_page.has_show_more_button, - "has_operation_error_banner": parsed_page.has_operation_error_banner, - "warning_codes": parsed_page.warnings, - "marker_counts_nonzero": {key: value for key, value in parsed_page.marker_counts.items() if value > 0}, - "body_length": len(fetch_result.body), - "body_sha256": hashlib.sha256(fetch_result.body.encode("utf-8")).hexdigest() if fetch_result.body else None, - "body_excerpt": _build_body_excerpt(fetch_result.body), - "attempt_log": attempt_log, - } - if page_logs: - context["page_logs"] = page_logs - if exception is not None: - context["exception_type"] = type(exception).__name__ - context["exception_message"] = str(exception) - return context - - -def _log_alert_threshold_warnings( - *, - user_id: int, - run: CrawlRun, - failure_summary: RunFailureSummary, - alert_summary: RunAlertSummary, -) -> None: - if alert_summary.alert_flags["blocked_failure_threshold_exceeded"]: - structured_log( - logger, - "warning", - "ingestion.alert_blocked_failure_threshold_exceeded", - user_id=user_id, - crawl_run_id=int(run.id), - blocked_failure_count=alert_summary.blocked_failure_count, - threshold=alert_summary.blocked_failure_threshold, - ) - if alert_summary.alert_flags["network_failure_threshold_exceeded"]: - structured_log( - logger, - "warning", - "ingestion.alert_network_failure_threshold_exceeded", - user_id=user_id, - crawl_run_id=int(run.id), - network_failure_count=alert_summary.network_failure_count, - threshold=alert_summary.network_failure_threshold, - ) - if alert_summary.alert_flags["retry_scheduled_threshold_exceeded"]: - structured_log( - logger, - "warning", - "ingestion.alert_retry_scheduled_threshold_exceeded", - user_id=user_id, - crawl_run_id=int(run.id), - retries_scheduled_count=failure_summary.retries_scheduled_count, - threshold=alert_summary.retry_scheduled_threshold, - ) - - -def complete_run_for_user( - *, - user_settings: Any, - run: CrawlRun, - scholars: list[ScholarProfile], - user_id: int, - progress: RunProgress, - idempotency_key: str | None, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, -) -> tuple[RunFailureSummary, RunAlertSummary]: - failure_summary = summarize_failures(scholar_results=progress.scholar_results) - alert_summary = build_alert_summary( - failure_summary=failure_summary, - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - _log_alert_threshold_warnings( - user_id=user_id, run=run, failure_summary=failure_summary, alert_summary=alert_summary - ) - apply_safety_outcome(user_settings=user_settings, run=run, user_id=user_id, alert_summary=alert_summary) - run_status = resolve_run_status( - scholar_count=len(scholars), - succeeded_count=progress.succeeded_count, - failed_count=progress.failed_count, - partial_count=progress.partial_count, - ) - finalize_run_record( - run=run, - scholars=scholars, - progress=progress, - failure_summary=failure_summary, - alert_summary=alert_summary, - idempotency_key=idempotency_key, - run_status=run_status, - ) - return failure_summary, alert_summary - - -def result_counters(result_entry: dict[str, Any]) -> tuple[int, int, int]: - outcome = str(result_entry.get("outcome", "")).strip().lower() - if outcome == "success": - return 1, 0, 0 - if outcome == "partial": - return 1, 0, 1 - if outcome == "failed": - return 0, 1, 0 - raise RuntimeError(f"Unexpected scholar outcome label: {outcome!r}") - - -def find_scholar_result_index( - *, - scholar_results: list[dict[str, Any]], - scholar_profile_id: int, -) -> int | None: - for index, result_entry in enumerate(scholar_results): - current_scholar_id = int_or_default(result_entry.get("scholar_profile_id"), 0) - if current_scholar_id == scholar_profile_id: - return index - return None - - -def adjust_progress_counts( - *, - progress: RunProgress, - succeeded_delta: int, - failed_delta: int, - partial_delta: int, -) -> None: - progress.succeeded_count += succeeded_delta - progress.failed_count += failed_delta - progress.partial_count += partial_delta - if progress.succeeded_count < 0 or progress.failed_count < 0 or progress.partial_count < 0: - raise RuntimeError("RunProgress counters entered invalid negative state.") - - -def apply_outcome_to_progress( - *, - progress: RunProgress, - outcome: ScholarProcessingOutcome, -) -> None: - scholar_profile_id = int_or_default(outcome.result_entry.get("scholar_profile_id"), 0) - if scholar_profile_id <= 0: - raise RuntimeError("Scholar outcome missing valid scholar_profile_id.") - prior_index = find_scholar_result_index( - scholar_results=progress.scholar_results, - scholar_profile_id=scholar_profile_id, - ) - next_succeeded, next_failed, next_partial = result_counters(outcome.result_entry) - if prior_index is None: - progress.scholar_results.append(outcome.result_entry) - adjust_progress_counts( - progress=progress, - succeeded_delta=next_succeeded, - failed_delta=next_failed, - partial_delta=next_partial, - ) - return - previous_entry = progress.scholar_results[prior_index] - prev_succeeded, prev_failed, prev_partial = result_counters(previous_entry) - progress.scholar_results[prior_index] = outcome.result_entry - adjust_progress_counts( - progress=progress, - succeeded_delta=next_succeeded - prev_succeeded, - failed_delta=next_failed - prev_failed, - partial_delta=next_partial - prev_partial, - ) - - -def run_execution_summary( - *, - run: CrawlRun, - scholars: list[ScholarProfile], - progress: RunProgress, -) -> RunExecutionSummary: - return RunExecutionSummary( - crawl_run_id=run.id, - status=run.status, - scholar_count=len(scholars), - succeeded_count=progress.succeeded_count, - failed_count=progress.failed_count, - partial_count=progress.partial_count, - new_publication_count=run.new_pub_count, - ) - - -def log_run_completed( - *, - run: CrawlRun, - user_id: int, - scholars: list[ScholarProfile], - progress: RunProgress, - failure_summary: RunFailureSummary, - alert_summary: RunAlertSummary, -) -> None: - structured_log( - logger, - "info", - "ingestion.run_completed", - user_id=user_id, - crawl_run_id=run.id, - status=run.status.value, - scholar_count=len(scholars), - succeeded_count=progress.succeeded_count, - failed_count=progress.failed_count, - partial_count=progress.partial_count, - new_publication_count=run.new_pub_count, - blocked_failure_count=alert_summary.blocked_failure_count, - network_failure_count=alert_summary.network_failure_count, - retries_scheduled_count=failure_summary.retries_scheduled_count, - alert_flags=alert_summary.alert_flags, - ) diff --git a/app/services/ingestion/run_enrichment.py b/app/services/ingestion/run_enrichment.py deleted file mode 100644 index 94ed0f4..0000000 --- a/app/services/ingestion/run_enrichment.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import CrawlRun, RunStatus -from app.logging_utils import structured_log -from app.services.ingestion.enrichment import EnrichmentRunner - -logger = logging.getLogger(__name__) - -_background_tasks: set[asyncio.Task[Any]] = set() - - -async def background_enrich( - session_factory: Any, - enrichment_runner: EnrichmentRunner, - *, - run_id: int, - intended_final_status: RunStatus, - openalex_api_key: str | None = None, -) -> None: - try: - async with session_factory() as session: - await enrichment_runner.enrich_pending_publications( - session, - run_id=run_id, - openalex_api_key=openalex_api_key, - ) - run = await session.get(CrawlRun, run_id) - if run is not None and run.status == RunStatus.RESOLVING: - run.status = intended_final_status - await session.commit() - structured_log( - logger, - "info", - "ingestion.background_enrichment_complete", - run_id=run_id, - final_status=str(intended_final_status), - ) - except Exception: - structured_log( - logger, - "exception", - "ingestion.background_enrichment_failed", - run_id=run_id, - ) - try: - async with session_factory() as fallback_session: - run = await fallback_session.get(CrawlRun, run_id) - if run is not None and run.status == RunStatus.RESOLVING: - run.status = intended_final_status - await fallback_session.commit() - except Exception: - structured_log( - logger, - "exception", - "ingestion.background_enrichment_fallback_failed", - run_id=run_id, - ) - - -async def inline_enrich_and_finalize( - db_session: AsyncSession, - enrichment_runner: EnrichmentRunner, - *, - run: CrawlRun, - user_settings: Any, - intended_final_status: RunStatus, -) -> None: - try: - await enrichment_runner.enrich_pending_publications( - db_session, - run_id=run.id, - openalex_api_key=getattr(user_settings, "openalex_api_key", None), - ) - except Exception: - structured_log( - logger, - "exception", - "ingestion.enrichment_failed", - run_id=run.id, - ) - if run.status == RunStatus.RESOLVING: - run.status = intended_final_status - await db_session.commit() - - -def spawn_background_enrichment_task( - session_factory: Any, - enrichment_runner: EnrichmentRunner, - *, - run_id: int, - intended_final_status: RunStatus, - openalex_api_key: str | None, -) -> None: - task = asyncio.create_task( - background_enrich( - session_factory, - enrichment_runner, - run_id=run_id, - intended_final_status=intended_final_status, - openalex_api_key=openalex_api_key, - ) - ) - _background_tasks.add(task) - task.add_done_callback(_background_tasks.discard) diff --git a/app/services/ingestion/run_guards.py b/app/services/ingestion/run_guards.py deleted file mode 100644 index fafc405..0000000 --- a/app/services/ingestion/run_guards.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import logging -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - RunTriggerType, - ScholarProfile, -) -from app.logging_utils import structured_log -from app.services.ingestion import safety as run_safety_service -from app.services.ingestion.preflight import check_scholar_reachable -from app.services.ingestion.types import RunBlockedBySafetyPolicyError -from app.services.scholar.source import ScholarSource -from app.services.settings import application as user_settings_service -from app.settings import settings - -logger = logging.getLogger(__name__) - - -async def load_user_settings_for_run( - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, -): - user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) - await enforce_safety_gate(db_session, user_settings=user_settings, user_id=user_id, trigger_type=trigger_type) - return user_settings - - -async def enforce_safety_gate( - db_session: AsyncSession, - *, - user_settings, - user_id: int, - trigger_type: RunTriggerType, -) -> None: - now_utc = datetime.now(UTC) - previous = run_safety_service.get_safety_event_context(user_settings, now_utc=now_utc) - if run_safety_service.clear_expired_cooldown(user_settings, now_utc=now_utc): - await db_session.commit() - await db_session.refresh(user_settings) - structured_log( - logger, - "info", - "ingestion.cooldown_cleared", - user_id=user_id, - reason=previous.get("cooldown_reason"), - cooldown_until=previous.get("cooldown_until"), - ) - now_utc = datetime.now(UTC) - if run_safety_service.is_cooldown_active(user_settings, now_utc=now_utc): - await raise_safety_blocked_start( - db_session, - user_settings=user_settings, - user_id=user_id, - trigger_type=trigger_type, - now_utc=now_utc, - ) - - -async def raise_safety_blocked_start( - db_session: AsyncSession, - *, - user_settings, - user_id: int, - trigger_type: RunTriggerType, - now_utc: datetime, -) -> None: - safety_state = run_safety_service.register_cooldown_blocked_start(user_settings, now_utc=now_utc) - await db_session.commit() - structured_log( - logger, - "warning", - "ingestion.safety_policy_blocked_run_start", - user_id=user_id, - trigger_type=trigger_type.value, - reason=safety_state.get("cooldown_reason"), - cooldown_until=safety_state.get("cooldown_until"), - cooldown_remaining_seconds=safety_state.get("cooldown_remaining_seconds"), - blocked_start_count=((safety_state.get("counters") or {}).get("blocked_start_count")), - ) - raise RunBlockedBySafetyPolicyError( - code="scrape_cooldown_active", - message="Scrape safety cooldown is active; run start is temporarily blocked.", - safety_state=safety_state, - ) - - -async def run_preflight_guard( - db_session: AsyncSession, - source: ScholarSource, - *, - user_settings: Any, - user_id: int, - scholars: list[ScholarProfile], -) -> None: - if not scholars: - return - result = await check_scholar_reachable( - source, - scholar_id=scholars[0].scholar_id, - ) - if result.passed: - return - now_utc = datetime.now(UTC) - safety_state, _ = run_safety_service.apply_run_safety_outcome( - user_settings, - run_id=0, - blocked_failure_count=1, - network_failure_count=0, - blocked_failure_threshold=1, - network_failure_threshold=1, - blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds, - network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds, - now_utc=now_utc, - ) - await db_session.commit() - structured_log( - logger, - "warning", - "ingestion.cooldown_entered_preflight", - user_id=user_id, - block_reason=result.block_reason, - cooldown_until=safety_state.get("cooldown_until"), - ) - raise RunBlockedBySafetyPolicyError( - code="scrape_cooldown_active", - message=f"Preflight detected Scholar block ({result.block_reason}). Cooldown activated.", - safety_state=safety_state, - ) diff --git a/app/services/ingestion/scheduler.py b/app/services/ingestion/scheduler.py deleted file mode 100644 index e44cb45..0000000 --- a/app/services/ingestion/scheduler.py +++ /dev/null @@ -1,313 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import Any - -from sqlalchemy import select - -from app.db.background_session import background_session -from app.db.models import ( - CrawlRun, - RunTriggerType, - User, - UserSetting, -) -from app.logging_utils import structured_log -from app.services.ingestion.application import ( - RunAlreadyInProgressError, - RunBlockedBySafetyPolicyError, - ScholarIngestionService, -) -from app.services.ingestion.queue_runner import QueueJobRunner, effective_request_delay_seconds -from app.services.scholar.source import LiveScholarSource -from app.services.settings import application as user_settings_service -from app.settings import settings - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class _AutoRunCandidate: - user_id: int - run_interval_minutes: int - request_delay_seconds: int - cooldown_until: datetime | None - cooldown_reason: str | None - - -class SchedulerService: - def __init__( - self, - *, - enabled: bool, - tick_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - continuation_queue_enabled: bool, - continuation_base_delay_seconds: int, - continuation_max_delay_seconds: int, - continuation_max_attempts: int, - queue_batch_size: int, - ) -> None: - self._enabled = enabled - self._tick_seconds = max(5, int(tick_seconds)) - self._network_error_retries = max(0, int(network_error_retries)) - self._retry_backoff_seconds = max(0.0, float(retry_backoff_seconds)) - self._max_pages_per_scholar = max(1, int(max_pages_per_scholar)) - self._page_size = max(1, int(page_size)) - self._continuation_queue_enabled = bool(continuation_queue_enabled) - self._continuation_base_delay_seconds = max(1, int(continuation_base_delay_seconds)) - self._continuation_max_delay_seconds = max( - self._continuation_base_delay_seconds, - int(continuation_max_delay_seconds), - ) - self._continuation_max_attempts = max(1, int(continuation_max_attempts)) - self._queue_batch_size = max(1, int(queue_batch_size)) - self._task: asyncio.Task[None] | None = None - self._source = LiveScholarSource() - self._queue_runner = QueueJobRunner( - tick_seconds=self._tick_seconds, - network_error_retries=self._network_error_retries, - retry_backoff_seconds=self._retry_backoff_seconds, - max_pages_per_scholar=self._max_pages_per_scholar, - page_size=self._page_size, - continuation_queue_enabled=self._continuation_queue_enabled, - continuation_base_delay_seconds=self._continuation_base_delay_seconds, - continuation_max_delay_seconds=self._continuation_max_delay_seconds, - continuation_max_attempts=self._continuation_max_attempts, - queue_batch_size=self._queue_batch_size, - ) - - async def start(self) -> None: - if not self._enabled: - structured_log(logger, "info", "scheduler.disabled") - return - if self._task is not None: - return - self._task = asyncio.create_task(self._run_loop(), name="scholarr-scheduler") - structured_log( - logger, - "info", - "scheduler.started", - tick_seconds=self._tick_seconds, - network_error_retries=self._network_error_retries, - retry_backoff_seconds=self._retry_backoff_seconds, - max_pages_per_scholar=self._max_pages_per_scholar, - page_size=self._page_size, - continuation_queue_enabled=self._continuation_queue_enabled, - continuation_base_delay_seconds=self._continuation_base_delay_seconds, - continuation_max_delay_seconds=self._continuation_max_delay_seconds, - continuation_max_attempts=self._continuation_max_attempts, - queue_batch_size=self._queue_batch_size, - ) - - async def stop(self) -> None: - if self._task is None: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - finally: - self._task = None - structured_log(logger, "info", "scheduler.stopped") - - async def _run_loop(self) -> None: - while True: - try: - await self._tick_once() - except asyncio.CancelledError: - raise - except Exception: - structured_log( - logger, - "exception", - "scheduler.tick_failed", - ) - await asyncio.sleep(float(self._tick_seconds)) - - async def _tick_once(self) -> None: - if self._continuation_queue_enabled: - await self._queue_runner.drain_continuation_queue() - - await self._drain_pdf_queue() - - candidates = await self._load_candidates() - if not candidates: - return - now = datetime.now(UTC) - for candidate in candidates: - if not await self._is_due(candidate, now=now): - continue - await self._run_candidate(candidate) - - async def _load_candidate_rows(self) -> list[Any]: - async with background_session() as session: - result = await session.execute( - select( - UserSetting.user_id, - UserSetting.run_interval_minutes, - UserSetting.request_delay_seconds, - UserSetting.scrape_cooldown_until, - UserSetting.scrape_cooldown_reason, - ) - .join(User, User.id == UserSetting.user_id) - .where(User.is_active.is_(True), UserSetting.auto_run_enabled.is_(True)) - .order_by(UserSetting.user_id.asc()) - ) - return list(result.all()) - - @staticmethod - def _candidate_from_row(row: Any, *, now_utc: datetime) -> _AutoRunCandidate | None: - user_id, run_interval_minutes, request_delay_seconds, cooldown_until, cooldown_reason = row - if cooldown_until is not None and cooldown_until.tzinfo is None: - cooldown_until = cooldown_until.replace(tzinfo=UTC) - if cooldown_until is not None and cooldown_until > now_utc: - structured_log( - logger, - "info", - "scheduler.run_skipped_safety_cooldown_precheck", - user_id=int(user_id), - reason=cooldown_reason, - cooldown_until=cooldown_until, - cooldown_remaining_seconds=int((cooldown_until - now_utc).total_seconds()), - ) - return None - return _AutoRunCandidate( - user_id=int(user_id), - run_interval_minutes=int(run_interval_minutes), - request_delay_seconds=effective_request_delay_seconds( - request_delay_seconds, - floor=user_settings_service.resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds), - ), - cooldown_until=cooldown_until, - cooldown_reason=(str(cooldown_reason).strip() if cooldown_reason else None), - ) - - async def _load_candidates(self) -> list[_AutoRunCandidate]: - if not settings.ingestion_automation_allowed: - return [] - rows = await self._load_candidate_rows() - now_utc = datetime.now(UTC) - candidates: list[_AutoRunCandidate] = [] - for row in rows: - candidate = self._candidate_from_row(row, now_utc=now_utc) - if candidate is not None: - candidates.append(candidate) - return candidates - - async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool: - async with background_session() as session: - result = await session.execute( - select(CrawlRun.start_dt) - .where( - CrawlRun.user_id == candidate.user_id, - ) - .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) - .limit(1) - ) - last_run = result.scalar_one_or_none() - - if last_run is None: - return True - - next_due_dt = last_run + timedelta(minutes=candidate.run_interval_minutes) - return now >= next_due_dt - - async def _run_candidate_ingestion( - self, - *, - candidate: _AutoRunCandidate, - ): - async with background_session() as session: - ingestion = ScholarIngestionService(source=self._source) - try: - return await ingestion.run_for_user( - session, - user_id=candidate.user_id, - trigger_type=RunTriggerType.SCHEDULED, - request_delay_seconds=candidate.request_delay_seconds, - network_error_retries=self._network_error_retries, - retry_backoff_seconds=self._retry_backoff_seconds, - max_pages_per_scholar=self._max_pages_per_scholar, - page_size=self._page_size, - auto_queue_continuations=self._continuation_queue_enabled, - queue_delay_seconds=self._continuation_base_delay_seconds, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - except RunAlreadyInProgressError: - await session.rollback() - structured_log(logger, "info", "scheduler.run_skipped_locked", user_id=candidate.user_id) - return None - except RunBlockedBySafetyPolicyError as exc: - await session.rollback() - structured_log( - logger, - "info", - "scheduler.run_skipped_safety_cooldown", - user_id=candidate.user_id, - reason=exc.safety_state.get("cooldown_reason"), - cooldown_until=exc.safety_state.get("cooldown_until"), - cooldown_remaining_seconds=exc.safety_state.get("cooldown_remaining_seconds"), - ) - return None - except Exception: - await session.rollback() - structured_log( - logger, - "exception", - "scheduler.run_failed", - user_id=candidate.user_id, - ) - return None - - async def _run_candidate(self, candidate: _AutoRunCandidate) -> None: - run_summary = await self._run_candidate_ingestion(candidate=candidate) - if run_summary is None: - return - structured_log( - logger, - "info", - "scheduler.run_completed", - user_id=candidate.user_id, - run_id=run_summary.crawl_run_id, - status=run_summary.status.value, - scholar_count=run_summary.scholar_count, - new_publication_count=run_summary.new_publication_count, - ) - - async def _drain_pdf_queue(self) -> None: - from app.services.publications.pdf_queue import drain_ready_jobs - from app.services.publications.pdf_queue_resolution import is_budget_cooldown_active - - if is_budget_cooldown_active(): - return - - async with background_session() as session: - try: - processed = await drain_ready_jobs( - session, - limit=settings.scheduler_pdf_queue_batch_size, - max_attempts=settings.pdf_auto_retry_max_attempts, - ) - if processed > 0: - structured_log( - logger, - "info", - "scheduler.pdf_queue_drain_completed", - processed_count=processed, - ) - except Exception: - structured_log( - logger, - "exception", - "scheduler.pdf_queue_drain_failed", - ) diff --git a/app/services/ingestion/scholar_outcomes.py b/app/services/ingestion/scholar_outcomes.py deleted file mode 100644 index 0871273..0000000 --- a/app/services/ingestion/scholar_outcomes.py +++ /dev/null @@ -1,308 +0,0 @@ -from __future__ import annotations - -import logging -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - RunStatus, - ScholarProfile, -) -from app.logging_utils import structured_log -from app.services.ingestion.run_completion import build_failure_debug_context -from app.services.ingestion.types import ( - PagedParseResult, - ScholarProcessingOutcome, -) -from app.services.scholar.parser import ParseState - -logger = logging.getLogger(__name__) - - -def assert_valid_paged_parse_result( - *, - scholar_id: str, - paged_parse_result: PagedParseResult, -) -> None: - parsed_page = paged_parse_result.parsed_page - if parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} and any( - code.startswith("layout_") for code in parsed_page.warnings - ): - raise RuntimeError(f"Layout warning marked as terminal for scholar_id={scholar_id}.") - for publication in paged_parse_result.publications: - if not publication.title.strip(): - raise RuntimeError(f"Malformed publication title for scholar_id={scholar_id}.") - if publication.citation_count is not None and int(publication.citation_count) < 0: - raise RuntimeError(f"Negative citation count for scholar_id={scholar_id}.") - - -def apply_first_page_profile_metadata( - *, - scholar: ScholarProfile, - paged_parse_result: PagedParseResult, - run_dt: datetime, -) -> None: - first_page = paged_parse_result.first_page_parsed_page - if first_page.profile_name and not (scholar.display_name or "").strip(): - scholar.display_name = first_page.profile_name - if first_page.profile_image_url: - scholar.profile_image_url = first_page.profile_image_url - scholar.last_initial_page_checked_at = run_dt - - -def build_result_entry( - *, - scholar: ScholarProfile, - start_cstart: int, - paged_parse_result: PagedParseResult, -) -> dict[str, Any]: - parsed_page = paged_parse_result.parsed_page - return { - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "outcome": "failed", - "attempt_count": len(paged_parse_result.attempt_log), - "publication_count": len(paged_parse_result.publications), - "start_cstart": start_cstart, - "articles_range": parsed_page.articles_range, - "warnings": parsed_page.warnings, - "has_show_more_button": parsed_page.has_show_more_button, - "pages_fetched": paged_parse_result.pages_fetched, - "pages_attempted": paged_parse_result.pages_attempted, - "has_more_remaining": paged_parse_result.has_more_remaining, - "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, - "continuation_cstart": paged_parse_result.continuation_cstart, - "skipped_no_change": paged_parse_result.skipped_no_change, - "initial_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, - } - - -def skipped_no_change_outcome( - *, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], -) -> ScholarProcessingOutcome: - first_page = paged_parse_result.first_page_parsed_page - scholar.last_run_status = RunStatus.SUCCESS - scholar.last_run_dt = run_dt - result_entry["state"] = first_page.state.value - result_entry["state_reason"] = "no_change_initial_page_signature" - result_entry["outcome"] = "success" - result_entry["publication_count"] = 0 - result_entry["warnings"] = first_page.warnings - result_entry["debug"] = { - "state_reason": "no_change_initial_page_signature", - "first_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, - "attempt_log": paged_parse_result.attempt_log, - "page_logs": paged_parse_result.page_logs, - } - return ScholarProcessingOutcome( - result_entry=result_entry, - succeeded_count_delta=1, - failed_count_delta=0, - partial_count_delta=0, - discovered_publication_count=0, - ) - - -async def upsert_publications_outcome( - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], -) -> ScholarProcessingOutcome: - parsed_page = paged_parse_result.parsed_page - publications = paged_parse_result.publications - had_page_failure = parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS} - has_partial_set = len(publications) > 0 and had_page_failure - if (not had_page_failure) or has_partial_set: - return await _upsert_success_or_exception( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - has_partial_publication_set=has_partial_set, - ) - return _parse_failure_outcome( - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - - -async def _upsert_success_or_exception( - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - has_partial_publication_set: bool, -) -> ScholarProcessingOutcome: - try: - return _upsert_success( - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - has_partial_publication_set=has_partial_publication_set, - ) - except Exception as exc: - return _upsert_exception_outcome( - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - exc=exc, - ) - - -def _upsert_success( - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - has_partial_publication_set: bool, -) -> ScholarProcessingOutcome: - discovered_count = paged_parse_result.discovered_publication_count - is_partial = ( - paged_parse_result.has_more_remaining - or paged_parse_result.pagination_truncated_reason is not None - or has_partial_publication_set - ) - scholar.last_run_status = RunStatus.PARTIAL_FAILURE if is_partial else RunStatus.SUCCESS - scholar.last_run_dt = run_dt - if not is_partial and paged_parse_result.first_page_fingerprint_sha256: - scholar.last_initial_page_fingerprint_sha256 = paged_parse_result.first_page_fingerprint_sha256 - result_entry["outcome"] = "partial" if is_partial else "success" - if is_partial: - result_entry["debug"] = build_failure_debug_context( - fetch_result=paged_parse_result.fetch_result, - parsed_page=paged_parse_result.parsed_page, - attempt_log=paged_parse_result.attempt_log, - page_logs=paged_parse_result.page_logs, - ) - return ScholarProcessingOutcome( - result_entry=result_entry, - succeeded_count_delta=1, - failed_count_delta=0, - partial_count_delta=1 if is_partial else 0, - discovered_publication_count=discovered_count, - ) - - -def _upsert_exception_outcome( - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - exc: Exception, -) -> ScholarProcessingOutcome: - scholar.last_run_status = RunStatus.FAILED - scholar.last_run_dt = run_dt - result_entry["state"] = "ingestion_error" - result_entry["state_reason"] = "publication_upsert_exception" - result_entry["outcome"] = "failed" - result_entry["error"] = str(exc) - result_entry["debug"] = build_failure_debug_context( - fetch_result=paged_parse_result.fetch_result, - parsed_page=paged_parse_result.parsed_page, - attempt_log=paged_parse_result.attempt_log, - page_logs=paged_parse_result.page_logs, - exception=exc, - ) - structured_log( - logger, - "exception", - "ingestion.scholar_failed", - crawl_run_id=run.id, - scholar_profile_id=scholar.id, - scholar_id=scholar.scholar_id, - ) - return ScholarProcessingOutcome(result_entry, 0, 1, 0, 0) - - -def _parse_failure_outcome( - *, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], -) -> ScholarProcessingOutcome: - scholar.last_run_status = RunStatus.FAILED - scholar.last_run_dt = run_dt - result_entry["debug"] = build_failure_debug_context( - fetch_result=paged_parse_result.fetch_result, - parsed_page=paged_parse_result.parsed_page, - attempt_log=paged_parse_result.attempt_log, - page_logs=paged_parse_result.page_logs, - ) - structured_log( - logger, - "warning", - "ingestion.scholar_parse_failed", - scholar_profile_id=scholar.id, - scholar_id=scholar.scholar_id, - state=paged_parse_result.parsed_page.state.value, - state_reason=paged_parse_result.parsed_page.state_reason, - status_code=paged_parse_result.fetch_result.status_code, - ) - return ScholarProcessingOutcome(result_entry, 0, 1, 0, 0) - - -def unexpected_scholar_exception_outcome( - *, - run: CrawlRun, - scholar: ScholarProfile, - start_cstart: int, - exc: Exception, -) -> ScholarProcessingOutcome: - scholar.last_run_status = RunStatus.FAILED - scholar.last_run_dt = datetime.now(UTC) - structured_log( - logger, - "exception", - "ingestion.scholar_unexpected_failure", - crawl_run_id=run.id, - scholar_profile_id=scholar.id, - scholar_id=scholar.scholar_id, - ) - return ScholarProcessingOutcome( - result_entry={ - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - "state": "ingestion_error", - "state_reason": "scholar_processing_exception", - "outcome": "failed", - "attempt_count": 0, - "publication_count": 0, - "start_cstart": start_cstart, - "warnings": [], - "error": str(exc), - "debug": {"exception_type": type(exc).__name__, "exception_message": str(exc)}, - }, - succeeded_count_delta=0, - failed_count_delta=1, - partial_count_delta=0, - discovered_publication_count=0, - ) diff --git a/app/services/ingestion/scholar_processing.py b/app/services/ingestion/scholar_processing.py deleted file mode 100644 index 1735915..0000000 --- a/app/services/ingestion/scholar_processing.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import random -from collections.abc import Callable, Coroutine -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy.exc import InterfaceError, OperationalError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - RunStatus, - ScholarProfile, -) -from app.logging_utils import structured_log -from app.services.ingestion import queue as queue_service -from app.services.ingestion.constants import ( - RESUMABLE_PARTIAL_REASON_PREFIXES, - RESUMABLE_PARTIAL_REASONS, -) -from app.services.ingestion.pagination import PaginationEngine -from app.services.ingestion.publication_upsert import upsert_profile_publications -from app.services.ingestion.run_completion import apply_outcome_to_progress -from app.services.ingestion.scholar_outcomes import ( - apply_first_page_profile_metadata, - assert_valid_paged_parse_result, - build_result_entry, - skipped_no_change_outcome, - unexpected_scholar_exception_outcome, - upsert_publications_outcome, -) -from app.services.ingestion.types import ( - PagedParseResult, - RunProgress, - ScholarProcessingOutcome, -) -from app.services.scholar.parser import ParseState -from app.services.scholar.state_detection import is_hard_challenge_reason - -logger = logging.getLogger(__name__) - - -async def sync_continuation_queue( - db_session: AsyncSession, - *, - user_id: int, - scholar: ScholarProfile, - run: CrawlRun, - start_cstart: int, - result_entry: dict[str, Any], - paged_parse_result: PagedParseResult, - auto_queue_continuations: bool, - queue_delay_seconds: int, -) -> None: - queue_reason, queue_cstart = resolve_continuation_queue_target( - outcome=str(result_entry.get("outcome", "")), - state=str(result_entry.get("state", "")), - pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, - continuation_cstart=paged_parse_result.continuation_cstart, - fallback_cstart=start_cstart, - ) - if auto_queue_continuations and queue_reason is not None: - await queue_service.upsert_job( - db_session, - user_id=user_id, - scholar_profile_id=scholar.id, - resume_cstart=queue_cstart, - reason=queue_reason, - run_id=run.id, - delay_seconds=queue_delay_seconds, - ) - result_entry["continuation_enqueued"] = True - result_entry["continuation_reason"] = queue_reason - result_entry["continuation_cstart"] = queue_cstart - return - if await queue_service.clear_job_for_scholar(db_session, user_id=user_id, scholar_profile_id=scholar.id): - result_entry["continuation_cleared"] = True - - -def resolve_continuation_queue_target( - *, - outcome: str, - state: str, - pagination_truncated_reason: str | None, - continuation_cstart: int | None, - fallback_cstart: int, -) -> tuple[str | None, int]: - if outcome == "partial": - reason = (pagination_truncated_reason or "").strip() - if reason in RESUMABLE_PARTIAL_REASONS or reason.startswith(RESUMABLE_PARTIAL_REASON_PREFIXES): - return reason, queue_service.normalize_cstart( - continuation_cstart if continuation_cstart is not None else fallback_cstart - ) - return None, queue_service.normalize_cstart(fallback_cstart) - - if outcome == "failed" and state == ParseState.NETWORK_ERROR.value: - return "network_error_retry", queue_service.normalize_cstart( - continuation_cstart if continuation_cstart is not None else fallback_cstart - ) - - return None, queue_service.normalize_cstart(fallback_cstart) - - -async def process_scholar( - db_session: AsyncSession, - *, - pagination: PaginationEngine, - run: CrawlRun, - scholar: ScholarProfile, - user_id: int, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - start_cstart: int, - auto_queue_continuations: bool, - queue_delay_seconds: int, -) -> ScholarProcessingOutcome: - try: - run_dt, paged_parse_result, result_entry = await _fetch_and_prepare_scholar_result( - db_session, - pagination=pagination, - run=run, - scholar=scholar, - user_id=user_id, - start_cstart=start_cstart, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - ) - outcome = await _resolve_scholar_outcome( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - await sync_continuation_queue( - db_session, - user_id=user_id, - scholar=scholar, - run=run, - start_cstart=start_cstart, - result_entry=outcome.result_entry, - paged_parse_result=paged_parse_result, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - ) - return outcome - except (OperationalError, InterfaceError): - raise - except Exception as exc: - return unexpected_scholar_exception_outcome( - run=run, - scholar=scholar, - start_cstart=start_cstart, - exc=exc, - ) - - -async def _fetch_and_prepare_scholar_result( - db_session: AsyncSession, - *, - pagination: PaginationEngine, - run: CrawlRun, - scholar: ScholarProfile, - user_id: int, - start_cstart: int, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, -) -> tuple[datetime, PagedParseResult, dict[str, Any]]: - run_dt = datetime.now(UTC) - paged_parse_result = await pagination.fetch_and_parse_all_pages( - scholar=scholar, - run=run, - db_session=db_session, - start_cstart=start_cstart, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - rate_limit_retries=rate_limit_retries, - rate_limit_backoff_seconds=rate_limit_backoff_seconds, - max_pages=max_pages_per_scholar, - page_size=page_size, - previous_initial_page_fingerprint_sha256=scholar.last_initial_page_fingerprint_sha256, - upsert_publications_fn=upsert_profile_publications, - ) - assert_valid_paged_parse_result(scholar_id=scholar.scholar_id, paged_parse_result=paged_parse_result) - apply_first_page_profile_metadata(scholar=scholar, paged_parse_result=paged_parse_result, run_dt=run_dt) - parsed_page = paged_parse_result.parsed_page - structured_log( - logger, - "info", - "ingestion.scholar_parsed", - user_id=user_id, - crawl_run_id=run.id, - scholar_profile_id=scholar.id, - scholar_id=scholar.scholar_id, - state=parsed_page.state.value, - publication_count=len(paged_parse_result.publications), - has_show_more_button=parsed_page.has_show_more_button, - pages_fetched=paged_parse_result.pages_fetched, - pages_attempted=paged_parse_result.pages_attempted, - has_more_remaining=paged_parse_result.has_more_remaining, - pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, - warning_count=len(parsed_page.warnings), - skipped_no_change=paged_parse_result.skipped_no_change, - ) - result_entry = build_result_entry( - scholar=scholar, - start_cstart=start_cstart, - paged_parse_result=paged_parse_result, - ) - return run_dt, paged_parse_result, result_entry - - -async def _resolve_scholar_outcome( - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], -) -> ScholarProcessingOutcome: - if paged_parse_result.skipped_no_change: - return skipped_no_change_outcome( - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - return await upsert_publications_outcome( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - - -def _is_hard_challenge_outcome(outcome: ScholarProcessingOutcome) -> bool: - entry = outcome.result_entry - return str(entry.get("state", "")) == ParseState.BLOCKED_OR_CAPTCHA.value and is_hard_challenge_reason( - str(entry.get("state_reason", "")) - ) - - -async def _run_first_pass( - db_session: AsyncSession, - *, - scholars: list[ScholarProfile], - pagination: PaginationEngine, - run: CrawlRun, - user_id: int, - start_cstart_map: dict[int, int], - scholar_kwargs: dict[str, Any], - request_delay_seconds: int, - queue_delay_seconds: int, - progress: RunProgress, - on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None, -) -> dict[int, int]: - first_pass_cstarts: dict[int, int] = {} - for index, scholar in enumerate(scholars): - await db_session.refresh(run) - if run.status == RunStatus.CANCELED: - structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id) - return first_pass_cstarts - if index > 0 and request_delay_seconds > 0: - jitter = random.uniform(0.0, min(float(request_delay_seconds), 2.0)) - await asyncio.sleep(float(request_delay_seconds) + jitter) - start_cstart = int(start_cstart_map.get(int(scholar.id), 0)) - outcome = await process_scholar( - db_session, - pagination=pagination, - run=run, - scholar=scholar, - user_id=user_id, - start_cstart=start_cstart, - max_pages_per_scholar=1, - auto_queue_continuations=False, - queue_delay_seconds=queue_delay_seconds, - **scholar_kwargs, - ) - apply_outcome_to_progress(progress=progress, outcome=outcome) - if _is_hard_challenge_outcome(outcome): - structured_log( - logger, - "warning", - "ingestion.run_aborted_hard_challenge", - run_id=run.id, - user_id=user_id, - scholar_id=scholar.scholar_id, - state_reason=outcome.result_entry.get("state_reason"), - scholars_remaining=len(scholars) - index - 1, - ) - return first_pass_cstarts - if on_progress is not None: - await on_progress(1, 0) - resume_cstart = outcome.result_entry.get("continuation_cstart") - if resume_cstart is not None and int(resume_cstart) > start_cstart: - first_pass_cstarts[int(scholar.id)] = int(resume_cstart) - return first_pass_cstarts - - -async def _run_depth_pass( - db_session: AsyncSession, - *, - scholars: list[ScholarProfile], - first_pass_cstarts: dict[int, int], - pagination: PaginationEngine, - run: CrawlRun, - user_id: int, - scholar_kwargs: dict[str, Any], - request_delay_seconds: int, - remaining_max: int, - auto_queue_continuations: bool, - queue_delay_seconds: int, - progress: RunProgress, - on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None, -) -> None: - for index, scholar in enumerate(scholars): - resume_cstart = first_pass_cstarts.get(int(scholar.id)) - if resume_cstart is None: - continue - await db_session.refresh(run) - if run.status == RunStatus.CANCELED: - structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id) - break - if index > 0 and request_delay_seconds > 0: - jitter = random.uniform(0.0, min(float(request_delay_seconds), 2.0)) - await asyncio.sleep(float(request_delay_seconds) + jitter) - outcome = await process_scholar( - db_session, - pagination=pagination, - run=run, - scholar=scholar, - user_id=user_id, - start_cstart=resume_cstart, - max_pages_per_scholar=remaining_max, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - **scholar_kwargs, - ) - apply_outcome_to_progress(progress=progress, outcome=outcome) - if _is_hard_challenge_outcome(outcome): - structured_log( - logger, - "warning", - "ingestion.run_aborted_hard_challenge", - run_id=run.id, - user_id=user_id, - scholar_id=scholar.scholar_id, - state_reason=outcome.result_entry.get("state_reason"), - scholars_remaining=len(scholars) - index - 1, - ) - break - if on_progress is not None: - await on_progress(0, 1) - - -async def run_scholar_iteration( - db_session: AsyncSession, - *, - pagination: PaginationEngine, - run: CrawlRun, - scholars: list[ScholarProfile], - user_id: int, - start_cstart_map: dict[int, int], - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - rate_limit_retries: int, - rate_limit_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - auto_queue_continuations: bool, - queue_delay_seconds: int, -) -> RunProgress: - from app.services.runs.events import run_events - - progress = RunProgress() - scholar_kwargs: dict[str, Any] = { - "request_delay_seconds": request_delay_seconds, - "network_error_retries": network_error_retries, - "retry_backoff_seconds": retry_backoff_seconds, - "rate_limit_retries": rate_limit_retries, - "rate_limit_backoff_seconds": rate_limit_backoff_seconds, - "page_size": page_size, - } - - visited = 0 - finished = 0 - total = len(scholars) - - async def _emit(v: int = 0, f: int = 0) -> None: - nonlocal visited, finished - visited += v - finished += f - await run_events.publish( - run.id, - "scholar_progress", - {"visited": visited, "finished": finished, "total": total}, - ) - - await _emit() - first_pass_cstarts = await _run_first_pass( - db_session, - scholars=scholars, - pagination=pagination, - run=run, - user_id=user_id, - start_cstart_map=start_cstart_map, - scholar_kwargs=scholar_kwargs, - request_delay_seconds=request_delay_seconds, - queue_delay_seconds=queue_delay_seconds, - progress=progress, - on_progress=_emit, - ) - remaining_max = max(max_pages_per_scholar - 1, 0) - scholars_finished_in_first_pass = len(scholars) - len(first_pass_cstarts) - if scholars_finished_in_first_pass > 0: - await _emit(f=scholars_finished_in_first_pass) - if remaining_max <= 0: - return progress - await _run_depth_pass( - db_session, - scholars=scholars, - first_pass_cstarts=first_pass_cstarts, - pagination=pagination, - run=run, - user_id=user_id, - scholar_kwargs=scholar_kwargs, - request_delay_seconds=request_delay_seconds, - remaining_max=remaining_max, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - progress=progress, - on_progress=_emit, - ) - return progress diff --git a/app/services/ingestion/types.py b/app/services/ingestion/types.py deleted file mode 100644 index 1f6aae8..0000000 --- a/app/services/ingestion/types.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from app.db.models import RunStatus -from app.services.scholar.parser import ParsedProfilePage, PublicationCandidate -from app.services.scholar.source import FetchResult - - -@dataclass(frozen=True) -class RunExecutionSummary: - crawl_run_id: int - status: RunStatus - scholar_count: int - succeeded_count: int - failed_count: int - partial_count: int - new_publication_count: int - - -@dataclass(frozen=True) -class PagedParseResult: - fetch_result: FetchResult - parsed_page: ParsedProfilePage - first_page_fetch_result: FetchResult - first_page_parsed_page: ParsedProfilePage - first_page_fingerprint_sha256: str | None - publications: list[PublicationCandidate] - attempt_log: list[dict[str, Any]] - page_logs: list[dict[str, Any]] - pages_fetched: int - pages_attempted: int - has_more_remaining: bool - pagination_truncated_reason: str | None - continuation_cstart: int | None - skipped_no_change: bool - discovered_publication_count: int - - -@dataclass -class RunProgress: - succeeded_count: int = 0 - failed_count: int = 0 - partial_count: int = 0 - scholar_results: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass(frozen=True) -class ScholarProcessingOutcome: - result_entry: dict[str, Any] - succeeded_count_delta: int - failed_count_delta: int - partial_count_delta: int - discovered_publication_count: int - - -@dataclass(frozen=True) -class RunFailureSummary: - failed_state_counts: dict[str, int] - failed_reason_counts: dict[str, int] - scrape_failure_counts: dict[str, int] - retries_scheduled_count: int - scholars_with_retries_count: int - retry_exhausted_count: int - - -@dataclass(frozen=True) -class RunAlertSummary: - blocked_failure_count: int - network_failure_count: int - blocked_failure_threshold: int - network_failure_threshold: int - retry_scheduled_threshold: int - alert_flags: dict[str, bool] - - -@dataclass -class PagedLoopState: - fetch_result: FetchResult - parsed_page: ParsedProfilePage - attempt_log: list[dict[str, Any]] - page_logs: list[dict[str, Any]] - publications: list[PublicationCandidate] - pages_fetched: int - pages_attempted: int - current_cstart: int - next_cstart: int - has_more_remaining: bool = False - pagination_truncated_reason: str | None = None - continuation_cstart: int | None = None - discovered_publication_count: int = 0 - - -class RunAlreadyInProgressError(RuntimeError): - """Raised when a run lock for a user is already held by another process.""" - - -class RunBlockedBySafetyPolicyError(RuntimeError): - def __init__( - self, - *, - code: str, - message: str, - safety_state: dict[str, Any], - ) -> None: - super().__init__(message) - self.code = code - self.message = message - self.safety_state = safety_state diff --git a/app/services/openalex/__init__.py b/app/services/openalex/__init__.py deleted file mode 100644 index a51555a..0000000 --- a/app/services/openalex/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -import logging - -logger = logging.getLogger(__name__) diff --git a/app/services/openalex/client.py b/app/services/openalex/client.py deleted file mode 100644 index 13db8ab..0000000 --- a/app/services/openalex/client.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging - -import httpx -from tenacity import ( - retry, - retry_if_exception_type, - stop_after_attempt, - wait_exponential, -) - -from app.logging_utils import structured_log -from app.services.openalex.types import OpenAlexWork - -logger = logging.getLogger(__name__) - -OPENALEX_BASE_URL = "https://api.openalex.org" - - -class OpenAlexClientError(Exception): - pass - - -class OpenAlexRateLimitError(OpenAlexClientError): - """Transient rate limit (too many requests per second).""" - - pass - - -class OpenAlexBudgetExhaustedError(OpenAlexClientError): - """Daily API budget exhausted — retrying is futile until midnight UTC.""" - - pass - - -class OpenAlexClient: - def __init__( - self, - api_key: str | None = None, - mailto: str | None = None, - timeout: float = 10.0, - ) -> None: - self.api_key = api_key - self.mailto = mailto - self.timeout = timeout - - @property - def _base_params(self) -> dict[str, str]: - params = {} - if self.mailto: - params["mailto"] = self.mailto - if self.api_key: - params["api_key"] = self.api_key - return params - - @retry( - retry=retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException)), - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=2, max=10), - reraise=True, - ) - async def get_work_by_doi(self, doi: str) -> OpenAlexWork | None: - """Fetch a single work by DOI directly.""" - clean_doi = doi.replace("https://doi.org/", "") - if not clean_doi: - return None - - url = f"{OPENALEX_BASE_URL}/works/{clean_doi}" - - headers = {} - if self.mailto: - headers["User-Agent"] = f"scholar-scraper/1.0 (mailto:{self.mailto})" - else: - headers["User-Agent"] = "scholar-scraper/1.0" - - async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, headers=headers) as client: - response = await client.get(url, params=self._base_params) - - if response.status_code == 404: - return None - if response.status_code == 429: - remaining = response.headers.get("X-RateLimit-Remaining-USD", "") - if remaining == "0" or remaining.startswith("-"): - raise OpenAlexBudgetExhaustedError("Daily API budget exhausted; retrying won't help until midnight UTC") - raise OpenAlexRateLimitError("Rate limit exceeded fetching OpenAlex work by DOI") - if response.status_code >= 400: - structured_log( - logger, - "warning", - "openalex.api_error", - status_code=response.status_code, - response_preview=response.text[:500], - ) - raise OpenAlexClientError(f"API Error {response.status_code}") - - data = response.json() - return OpenAlexWork.from_api_dict(data) - - @retry( - retry=retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException)), - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=2, max=10), - reraise=True, - ) - async def get_works_by_filter( - self, - filters: dict[str, str], - limit: int = 50, - ) -> list[OpenAlexWork]: - """ - Fetch works using the ?filter= query parameter. - Supports fetching multiple records by joining filters with | (OR logic). - """ - if not filters: - return [] - - # Example: {"doi": "10.foo|10.bar", "title.search": "query"} - filter_str = ",".join(f"{k}:{v}" for k, v in filters.items()) - - params = self._base_params.copy() - params["filter"] = filter_str - params["per-page"] = str(limit) - - url = f"{OPENALEX_BASE_URL}/works" - - headers = {} - if self.mailto: - headers["User-Agent"] = f"scholar-scraper/1.0 (mailto:{self.mailto})" - else: - headers["User-Agent"] = "scholar-scraper/1.0" - - async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, headers=headers) as client: - response = await client.get(url, params=params) - - if response.status_code == 429: - remaining = response.headers.get("X-RateLimit-Remaining-USD", "") - if remaining == "0" or remaining.startswith("-"): - raise OpenAlexBudgetExhaustedError("Daily API budget exhausted; retrying won't help until midnight UTC") - raise OpenAlexRateLimitError("Rate limit exceeded fetching OpenAlex works list") - if response.status_code >= 400: - structured_log( - logger, - "warning", - "openalex.api_error_with_filters", - filters=filters, - status_code=response.status_code, - response_preview=response.text[:500], - ) - raise OpenAlexClientError(f"API Error {response.status_code}") - - data = response.json() - results = data.get("results") or [] - - parsed_works = [] - for raw_work in results: - try: - parsed_works.append(OpenAlexWork.from_api_dict(raw_work)) - except Exception as exc: - structured_log( - logger, - "warning", - "openalex.parse_failed", - error=str(exc), - ) - continue - - return parsed_works diff --git a/app/services/openalex/matching.py b/app/services/openalex/matching.py deleted file mode 100644 index 34e1927..0000000 --- a/app/services/openalex/matching.py +++ /dev/null @@ -1,110 +0,0 @@ -import logging -import re - -from rapidfuzz import fuzz - -from app.services.openalex.types import OpenAlexWork - -logger = logging.getLogger(__name__) - -# A minimum similarity score out of 100 for a title to be considered a match candidate. -TITLE_MATCH_THRESHOLD = 90.0 -# The margin within the top score where a secondary tiebreaker (author/year) is necessary. -TIEBREAKER_MARGIN = 5.0 - - -def _clean_string(s: str | None) -> str: - if not s: - return "" - # Strip non-alphanumeric (keep spaces), lowercase, and collapse whitespace - cleaned = re.sub(r"[^a-z0-9\s]", " ", s.lower()) - return " ".join(cleaned.split()) - - -def _author_overlap_score(target_authors: str | None, candidate_authors: list[str]) -> bool: - if not target_authors or not candidate_authors: - return False - - target_clean = _clean_string(target_authors) - if not target_clean: - return False - - for candidate in candidate_authors: - cand_clean = _clean_string(candidate) - if cand_clean and (cand_clean in target_clean or target_clean in cand_clean): - return True - # Alternatively check rapidfuzz token_set_ratio - if cand_clean and fuzz.token_set_ratio(target_clean, cand_clean) > 80: - return True - - return False - - -def find_best_match( - target_title: str, - target_year: int | None, - target_authors: str | None, - candidates: list[OpenAlexWork], -) -> OpenAlexWork | None: - """ - Finds the best matching OpenAlexWork from a list of candidates, prioritizing title similarity (>90%) - with year and author overlap as tiebreakers for close candidates. - """ - if not target_title or not candidates: - return None - - clean_target = _clean_string(target_title) - if not clean_target: - return None - - scored_candidates: list[tuple[float, OpenAlexWork]] = [] - - for cand in candidates: - if not cand.title: - continue - - clean_cand = _clean_string(cand.title) - - # Primary sort: string similarity ratio - score = fuzz.ratio(clean_target, clean_cand) - - if score >= TITLE_MATCH_THRESHOLD: - scored_candidates.append((score, cand)) - - if not scored_candidates: - return None - - # Sort descending by score - scored_candidates.sort(key=lambda x: x[0], reverse=True) - - best_score = scored_candidates[0][0] - - # Extract all candidates within the tiebreaker margin - top_scored_candidates = [ - (score, cand) for score, cand in scored_candidates if best_score - score <= TIEBREAKER_MARGIN - ] - - if len(top_scored_candidates) == 1: - return top_scored_candidates[0][1] - - # We have a tie or near-tie. Use year and author overlap to break the tie. - # Score candidates: +1 for year match (within 1 year), +1 for author overlap - tiebreaker_scores: list[tuple[int, float, OpenAlexWork]] = [] - - for original_score, cand in top_scored_candidates: - tb_score = 0 - if ( - target_year is not None - and cand.publication_year is not None - and abs(target_year - cand.publication_year) <= 1 - ): - tb_score += 1 - - candidate_author_names = [a.display_name for a in cand.authors if a.display_name] - if _author_overlap_score(target_authors, candidate_author_names): - tb_score += 1 - - tiebreaker_scores.append((tb_score, original_score, cand)) - - tiebreaker_scores.sort(key=lambda x: (x[0], x[1]), reverse=True) - return tiebreaker_scores[0][2] diff --git a/app/services/openalex/types.py b/app/services/openalex/types.py deleted file mode 100644 index 8abf375..0000000 --- a/app/services/openalex/types.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any - - -@dataclass(frozen=True) -class OpenAlexAuthor: - openalex_id: str | None - display_name: str | None - - -@dataclass(frozen=True) -class OpenAlexWork: - openalex_id: str - doi: str | None - pmid: str | None - pmcid: str | None - title: str | None - publication_year: int | None - cited_by_count: int - is_oa: bool - oa_url: str | None - authors: list[OpenAlexAuthor] = field(default_factory=list) - raw_data: Mapping[str, Any] = field(default_factory=dict, repr=False) - - @classmethod - def from_api_dict(cls, data: Mapping[str, Any]) -> OpenAlexWork: - ids = data.get("ids") or {} - - # Extract DOI without the https://doi.org/ prefix - doi = ids.get("doi") - if doi and doi.startswith("https://doi.org/"): - doi = doi[16:] - - # Extract PMID without the url prefix - pmid = ids.get("pmid") - if pmid and pmid.startswith("https://pubmed.ncbi.nlm.nih.gov/"): - pmid = pmid[32:] - - # Extract PMCID without the url prefix - pmcid = ids.get("pmcid") - if pmcid and pmcid.startswith("https://www.ncbi.nlm.nih.gov/pmc/articles/"): - pmcid = pmcid[42:] - - open_access = data.get("open_access") or {} - - authors = [] - for authorship in data.get("authorships") or []: - author_data = authorship.get("author") or {} - authors.append( - OpenAlexAuthor( - openalex_id=author_data.get("id"), - display_name=author_data.get("display_name"), - ) - ) - - return cls( - openalex_id=data.get("id", ""), - doi=doi, - pmid=pmid, - pmcid=pmcid, - title=data.get("title"), - publication_year=data.get("publication_year"), - cited_by_count=data.get("cited_by_count", 0), - is_oa=bool(open_access.get("is_oa")), - oa_url=open_access.get("oa_url"), - authors=authors, - raw_data=dict(data), - ) diff --git a/app/services/portability/__init__.py b/app/services/portability/__init__.py deleted file mode 100644 index 9f63c8f..0000000 --- a/app/services/portability/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from app.services.portability.application import ( - EXPORT_SCHEMA_VERSION as EXPORT_SCHEMA_VERSION, -) -from app.services.portability.application import ( - MAX_IMPORT_PUBLICATIONS as MAX_IMPORT_PUBLICATIONS, -) -from app.services.portability.application import ( - MAX_IMPORT_SCHOLARS as MAX_IMPORT_SCHOLARS, -) -from app.services.portability.application import ( - ImportedPublicationInput as ImportedPublicationInput, -) -from app.services.portability.application import ( - ImportExportError as ImportExportError, -) -from app.services.portability.application import ( - export_user_data as export_user_data, -) -from app.services.portability.application import ( - import_user_data as import_user_data, -) diff --git a/app/services/portability/application.py b/app/services/portability/application.py deleted file mode 100644 index c696f4d..0000000 --- a/app/services/portability/application.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import Publication -from app.services.portability.constants import ( - EXPORT_SCHEMA_VERSION, - MAX_IMPORT_PUBLICATIONS, - MAX_IMPORT_SCHOLARS, -) -from app.services.portability.exporting import export_user_data -from app.services.portability.normalize import _validate_import_sizes -from app.services.portability.publication_import import ( - _build_imported_publication_input, - _initialize_import_counters, - _upsert_imported_publication, -) -from app.services.portability.scholar_import import _upsert_imported_scholars -from app.services.portability.types import ImportedPublicationInput, ImportExportError - - -async def import_user_data( - db_session: AsyncSession, - *, - user_id: int, - scholars: list[dict[str, Any]], - publications: list[dict[str, Any]], -) -> dict[str, int]: - _validate_import_sizes(scholars=scholars, publications=publications) - scholar_map, counters = await _upsert_imported_scholars( - db_session, - user_id=user_id, - scholars=scholars, - ) - cluster_cache: dict[str, Publication | None] = {} - fingerprint_cache: dict[str, Publication | None] = {} - _initialize_import_counters(counters) - for item in publications: - parsed_item = _build_imported_publication_input( - item=item, - scholar_map=scholar_map, - ) - if parsed_item is None: - counters["skipped_records"] += 1 - continue - await _upsert_imported_publication( - db_session, - payload=parsed_item, - cluster_cache=cluster_cache, - fingerprint_cache=fingerprint_cache, - counters=counters, - ) - await db_session.commit() - return counters - - -__all__ = [ - "EXPORT_SCHEMA_VERSION", - "MAX_IMPORT_PUBLICATIONS", - "MAX_IMPORT_SCHOLARS", - "ImportExportError", - "ImportedPublicationInput", - "export_user_data", - "import_user_data", -] diff --git a/app/services/portability/constants.py b/app/services/portability/constants.py deleted file mode 100644 index 6815347..0000000 --- a/app/services/portability/constants.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -import re - -EXPORT_SCHEMA_VERSION = 1 -MAX_IMPORT_SCHOLARS = 10_000 -MAX_IMPORT_PUBLICATIONS = 100_000 -WORD_RE = re.compile(r"[a-z0-9]+") -SHA256_RE = re.compile(r"^[0-9a-f]{64}$") diff --git a/app/services/portability/exporting.py b/app/services/portability/exporting.py deleted file mode 100644 index a11eaef..0000000 --- a/app/services/portability/exporting.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import Publication, ScholarProfile, ScholarPublication -from app.services.portability.constants import EXPORT_SCHEMA_VERSION - - -def _exported_at_iso() -> str: - return datetime.now(UTC).replace(microsecond=0).isoformat() - - -def _serialize_export_scholar(profile: ScholarProfile) -> dict[str, Any]: - return { - "scholar_id": profile.scholar_id, - "display_name": profile.display_name, - "is_enabled": bool(profile.is_enabled), - "profile_image_override_url": profile.profile_image_override_url, - } - - -def _serialize_export_publication(row: Any) -> dict[str, Any]: - ( - scholar_id, - cluster_id, - fingerprint_sha256, - title_raw, - year, - citation_count, - author_text, - venue_text, - pub_url, - pdf_url, - is_read, - ) = row - return { - "scholar_id": scholar_id, - "cluster_id": cluster_id, - "fingerprint_sha256": fingerprint_sha256, - "title": title_raw, - "year": year, - "citation_count": int(citation_count or 0), - "author_text": author_text, - "venue_text": venue_text, - "pub_url": pub_url, - "pdf_url": pdf_url, - "is_read": bool(is_read), - } - - -async def export_user_data( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_ids: list[int] | None = None, -) -> dict[str, Any]: - scholar_query = select(ScholarProfile).where(ScholarProfile.user_id == user_id) - if scholar_profile_ids: - scholar_query = scholar_query.where(ScholarProfile.id.in_(scholar_profile_ids)) - scholars_result = await db_session.execute(scholar_query.order_by(ScholarProfile.id.asc())) - - pub_query = ( - select( - ScholarProfile.scholar_id, - Publication.cluster_id, - Publication.fingerprint_sha256, - Publication.title_raw, - Publication.year, - Publication.citation_count, - Publication.author_text, - Publication.venue_text, - Publication.pub_url, - Publication.pdf_url, - ScholarPublication.is_read, - ) - .join(ScholarPublication, ScholarPublication.scholar_profile_id == ScholarProfile.id) - .join(Publication, Publication.id == ScholarPublication.publication_id) - .where(ScholarProfile.user_id == user_id) - ) - if scholar_profile_ids: - pub_query = pub_query.where(ScholarProfile.id.in_(scholar_profile_ids)) - publication_result = await db_session.execute( - pub_query.order_by(ScholarPublication.created_at.desc(), Publication.id.desc()) - ) - - scholars = [_serialize_export_scholar(profile) for profile in scholars_result.scalars().all()] - publications = [_serialize_export_publication(row) for row in publication_result.all()] - return { - "schema_version": EXPORT_SCHEMA_VERSION, - "exported_at": _exported_at_iso(), - "scholars": scholars, - "publications": publications, - } diff --git a/app/services/portability/normalize.py b/app/services/portability/normalize.py deleted file mode 100644 index 9353d2a..0000000 --- a/app/services/portability/normalize.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -import hashlib -from typing import Any - -from app.services.ingestion.fingerprints import normalize_title -from app.services.portability.constants import ( - MAX_IMPORT_PUBLICATIONS, - MAX_IMPORT_SCHOLARS, - SHA256_RE, - WORD_RE, -) -from app.services.portability.types import ImportExportError - - -def _normalize_optional_text(value: Any) -> str | None: - if value is None: - return None - normalized = str(value).strip() - return normalized or None - - -def _normalize_optional_year(value: Any) -> int | None: - if value is None: - return None - try: - year = int(value) - except (TypeError, ValueError): - return None - if year < 1500 or year > 3000: - return None - return year - - -def _normalize_citation_count(value: Any) -> int: - try: - parsed = int(value) - except (TypeError, ValueError): - return 0 - return max(0, parsed) - - -def _first_author_last_name(authors_text: str | None) -> str: - if not authors_text: - return "" - first_author = authors_text.split(",", maxsplit=1)[0].strip().lower() - words = WORD_RE.findall(first_author) - if not words: - return "" - return words[-1] - - -def _first_venue_word(venue_text: str | None) -> str: - if not venue_text: - return "" - words = WORD_RE.findall(venue_text.lower()) - if not words: - return "" - return words[0] - - -def _build_fingerprint( - *, - title: str, - year: int | None, - author_text: str | None, - venue_text: str | None, -) -> str: - canonical = "|".join( - [ - normalize_title(title), - str(year) if year is not None else "", - _first_author_last_name(author_text), - _first_venue_word(venue_text), - ] - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def _resolve_fingerprint( - *, - title: str, - year: int | None, - author_text: str | None, - venue_text: str | None, - provided_fingerprint: Any, -) -> str: - normalized = _normalize_optional_text(provided_fingerprint) - if normalized and SHA256_RE.fullmatch(normalized.lower()): - return normalized.lower() - return _build_fingerprint( - title=title, - year=year, - author_text=author_text, - venue_text=venue_text, - ) - - -def _validate_import_sizes( - *, - scholars: list[dict[str, Any]], - publications: list[dict[str, Any]], -) -> None: - if len(scholars) > MAX_IMPORT_SCHOLARS: - raise ImportExportError(f"Import exceeds max scholars ({MAX_IMPORT_SCHOLARS}).") - if len(publications) > MAX_IMPORT_PUBLICATIONS: - raise ImportExportError(f"Import exceeds max publications ({MAX_IMPORT_PUBLICATIONS}).") diff --git a/app/services/portability/publication_import.py b/app/services/portability/publication_import.py deleted file mode 100644 index e2eb96a..0000000 --- a/app/services/portability/publication_import.py +++ /dev/null @@ -1,366 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import Publication, ScholarProfile, ScholarPublication -from app.services.doi.normalize import normalize_doi -from app.services.ingestion.fingerprints import build_publication_url, normalize_title -from app.services.portability.normalize import ( - _normalize_citation_count, - _normalize_optional_text, - _normalize_optional_year, - _resolve_fingerprint, -) -from app.services.portability.types import ImportedPublicationInput - - -async def _find_publication_by_cluster( - db_session: AsyncSession, - *, - cluster_id: str, -) -> Publication | None: - result = await db_session.execute(select(Publication).where(Publication.cluster_id == cluster_id)) - return result.scalar_one_or_none() - - -async def _find_publication_by_fingerprint( - db_session: AsyncSession, - *, - fingerprint_sha256: str, -) -> Publication | None: - result = await db_session.execute(select(Publication).where(Publication.fingerprint_sha256 == fingerprint_sha256)) - return result.scalar_one_or_none() - - -async def _find_linked_publication_by_title( - db_session: AsyncSession, - *, - scholar_profile_id: int, - title: str, -) -> Publication | None: - normalized_title = normalize_title(title) - result = await db_session.execute( - select(Publication) - .join( - ScholarPublication, - ScholarPublication.publication_id == Publication.id, - ) - .where( - ScholarPublication.scholar_profile_id == scholar_profile_id, - Publication.title_normalized == normalized_title, - ) - .order_by(Publication.id.asc()) - .limit(1) - ) - return result.scalar_one_or_none() - - -def _apply_imported_publication_values( - *, - publication: Publication, - title: str, - year: int | None, - citation_count: int, - author_text: str | None, - venue_text: str | None, - pub_url: str | None, - pdf_url: str | None, - cluster_id: str | None, -) -> bool: - updated = False - if cluster_id and publication.cluster_id != cluster_id: - publication.cluster_id = cluster_id - updated = True - if publication.title_raw != title: - publication.title_raw = title - publication.title_normalized = normalize_title(title) - updated = True - if publication.year != year: - publication.year = year - updated = True - if int(publication.citation_count or 0) != citation_count: - publication.citation_count = citation_count - updated = True - if publication.author_text != author_text: - publication.author_text = author_text - updated = True - if publication.venue_text != venue_text: - publication.venue_text = venue_text - updated = True - if pub_url and publication.pub_url != pub_url: - publication.pub_url = pub_url - updated = True - if pdf_url and publication.pdf_url != pdf_url: - publication.pdf_url = pdf_url - updated = True - return updated - - -def _new_publication( - *, - cluster_id: str | None, - fingerprint_sha256: str, - title: str, - year: int | None, - citation_count: int, - author_text: str | None, - venue_text: str | None, - pub_url: str | None, - pdf_url: str | None, -) -> Publication: - return Publication( - cluster_id=cluster_id, - fingerprint_sha256=fingerprint_sha256, - title_raw=title, - title_normalized=normalize_title(title), - year=year, - citation_count=citation_count, - author_text=author_text, - venue_text=venue_text, - pub_url=pub_url, - pdf_url=pdf_url, - ) - - -async def _resolve_publication_for_import( - db_session: AsyncSession, - *, - scholar_profile_id: int, - title: str, - cluster_id: str | None, - fingerprint_sha256: str, - cluster_cache: dict[str, Publication | None], - fingerprint_cache: dict[str, Publication | None], -) -> Publication | None: - if cluster_id: - if cluster_id not in cluster_cache: - cluster_cache[cluster_id] = await _find_publication_by_cluster( - db_session, - cluster_id=cluster_id, - ) - if cluster_cache[cluster_id] is not None: - return cluster_cache[cluster_id] - if fingerprint_sha256 not in fingerprint_cache: - fingerprint_cache[fingerprint_sha256] = await _find_publication_by_fingerprint( - db_session, - fingerprint_sha256=fingerprint_sha256, - ) - if fingerprint_cache[fingerprint_sha256] is not None: - return fingerprint_cache[fingerprint_sha256] - return await _find_linked_publication_by_title( - db_session, - scholar_profile_id=scholar_profile_id, - title=title, - ) - - -async def _upsert_scholar_publication_link( - db_session: AsyncSession, - *, - scholar_profile_id: int, - publication_id: int, - is_read: bool, -) -> tuple[bool, bool]: - result = await db_session.execute( - select(ScholarPublication).where( - ScholarPublication.scholar_profile_id == scholar_profile_id, - ScholarPublication.publication_id == publication_id, - ) - ) - link = result.scalar_one_or_none() - if link is None: - db_session.add( - ScholarPublication( - scholar_profile_id=scholar_profile_id, - publication_id=publication_id, - is_read=bool(is_read), - ) - ) - return True, False - if bool(link.is_read) == bool(is_read): - return False, False - link.is_read = bool(is_read) - return False, True - - -def _initialize_import_counters(counters: dict[str, int]) -> None: - counters.update( - { - "publications_created": 0, - "publications_updated": 0, - "links_created": 0, - "links_updated": 0, - } - ) - - -def _build_imported_publication_input( - *, - item: dict[str, Any], - scholar_map: dict[str, ScholarProfile], -) -> ImportedPublicationInput | None: - scholar_id = _normalize_optional_text(item.get("scholar_id")) - title = _normalize_optional_text(item.get("title")) - if not scholar_id or not title: - return None - - profile = scholar_map.get(scholar_id) - if profile is None: - return None - - year = _normalize_optional_year(item.get("year")) - author_text = _normalize_optional_text(item.get("author_text")) - venue_text = _normalize_optional_text(item.get("venue_text")) - return ImportedPublicationInput( - profile=profile, - title=title, - year=year, - citation_count=_normalize_citation_count(item.get("citation_count")), - author_text=author_text, - venue_text=venue_text, - cluster_id=_normalize_optional_text(item.get("cluster_id")), - pub_url=build_publication_url(_normalize_optional_text(item.get("pub_url"))), - doi=normalize_doi(_normalize_optional_text(item.get("doi"))), - pdf_url=build_publication_url(_normalize_optional_text(item.get("pdf_url"))), - fingerprint=_resolve_fingerprint( - title=title, - year=year, - author_text=author_text, - venue_text=venue_text, - provided_fingerprint=item.get("fingerprint_sha256"), - ), - is_read=bool(item.get("is_read", False)), - ) - - -def _update_link_counters( - *, - counters: dict[str, int], - link_created: bool, - link_updated: bool, -) -> None: - if link_created: - counters["links_created"] += 1 - if link_updated: - counters["links_updated"] += 1 - - -def _cache_resolved_publication( - *, - publication: Publication, - cluster_id: str | None, - fingerprint_sha256: str, - cluster_cache: dict[str, Publication | None], - fingerprint_cache: dict[str, Publication | None], -) -> None: - if cluster_id: - cluster_cache[cluster_id] = publication - fingerprint_cache[fingerprint_sha256] = publication - - -async def _create_import_publication( - db_session: AsyncSession, - *, - payload: ImportedPublicationInput, -) -> Publication: - publication = _new_publication( - cluster_id=payload.cluster_id, - fingerprint_sha256=payload.fingerprint, - title=payload.title, - year=payload.year, - citation_count=payload.citation_count, - author_text=payload.author_text, - venue_text=payload.venue_text, - pub_url=payload.pub_url, - pdf_url=payload.pdf_url, - ) - db_session.add(publication) - await db_session.flush() - return publication - - -def _update_import_publication( - *, - publication: Publication, - payload: ImportedPublicationInput, -) -> bool: - return _apply_imported_publication_values( - publication=publication, - title=payload.title, - year=payload.year, - citation_count=payload.citation_count, - author_text=payload.author_text, - venue_text=payload.venue_text, - pub_url=payload.pub_url, - pdf_url=payload.pdf_url, - cluster_id=payload.cluster_id, - ) - - -async def _upsert_publication_entity( - db_session: AsyncSession, - *, - payload: ImportedPublicationInput, - cluster_cache: dict[str, Publication | None], - fingerprint_cache: dict[str, Publication | None], -) -> tuple[Publication, bool, bool]: - publication = await _resolve_publication_for_import( - db_session, - scholar_profile_id=int(payload.profile.id), - title=payload.title, - cluster_id=payload.cluster_id, - fingerprint_sha256=payload.fingerprint, - cluster_cache=cluster_cache, - fingerprint_cache=fingerprint_cache, - ) - created = False - updated = False - if publication is None: - publication = await _create_import_publication(db_session, payload=payload) - created = True - else: - updated = _update_import_publication(publication=publication, payload=payload) - - _cache_resolved_publication( - publication=publication, - cluster_id=payload.cluster_id, - fingerprint_sha256=payload.fingerprint, - cluster_cache=cluster_cache, - fingerprint_cache=fingerprint_cache, - ) - return publication, created, updated - - -async def _upsert_imported_publication( - db_session: AsyncSession, - *, - payload: ImportedPublicationInput, - cluster_cache: dict[str, Publication | None], - fingerprint_cache: dict[str, Publication | None], - counters: dict[str, int], -) -> None: - publication, created, updated = await _upsert_publication_entity( - db_session, - payload=payload, - cluster_cache=cluster_cache, - fingerprint_cache=fingerprint_cache, - ) - if created: - counters["publications_created"] += 1 - if updated: - counters["publications_updated"] += 1 - - link_created, link_updated = await _upsert_scholar_publication_link( - db_session, - scholar_profile_id=int(payload.profile.id), - publication_id=int(publication.id), - is_read=payload.is_read, - ) - _update_link_counters( - counters=counters, - link_created=link_created, - link_updated=link_updated, - ) diff --git a/app/services/portability/scholar_import.py b/app/services/portability/scholar_import.py deleted file mode 100644 index 274e7fd..0000000 --- a/app/services/portability/scholar_import.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ScholarProfile -from app.services.portability.normalize import _normalize_optional_text -from app.services.scholars import application as scholar_service - - -async def _load_user_scholar_map( - db_session: AsyncSession, - *, - user_id: int, -) -> dict[str, ScholarProfile]: - result = await db_session.execute(select(ScholarProfile).where(ScholarProfile.user_id == user_id)) - profiles = list(result.scalars().all()) - return {profile.scholar_id: profile for profile in profiles} - - -def _apply_imported_scholar_values( - *, - profile: ScholarProfile, - display_name: str | None, - profile_image_override_url: str | None, - is_enabled: bool, -) -> bool: - updated = False - if display_name and profile.display_name != display_name: - profile.display_name = display_name - updated = True - if profile.profile_image_override_url != profile_image_override_url: - profile.profile_image_override_url = profile_image_override_url - updated = True - if bool(profile.is_enabled) != bool(is_enabled): - profile.is_enabled = bool(is_enabled) - updated = True - return updated - - -def _new_scholar_profile( - *, - user_id: int, - scholar_id: str, - display_name: str | None, - profile_image_override_url: str | None, - is_enabled: bool, -) -> ScholarProfile: - return ScholarProfile( - user_id=user_id, - scholar_id=scholar_id, - display_name=display_name, - profile_image_override_url=profile_image_override_url, - is_enabled=bool(is_enabled), - ) - - -async def _upsert_imported_scholars( - db_session: AsyncSession, - *, - user_id: int, - scholars: list[dict[str, Any]], -) -> tuple[dict[str, ScholarProfile], dict[str, int]]: - scholar_map = await _load_user_scholar_map(db_session, user_id=user_id) - counters = {"scholars_created": 0, "scholars_updated": 0, "skipped_records": 0} - for item in scholars: - try: - scholar_id = scholar_service.validate_scholar_id(str(item["scholar_id"])) - display_name = scholar_service.normalize_display_name(str(item.get("display_name") or "")) - override_url = scholar_service.normalize_profile_image_url( - _normalize_optional_text(item.get("profile_image_override_url")) - ) - except (KeyError, scholar_service.ScholarServiceError): - counters["skipped_records"] += 1 - continue - - is_enabled = bool(item.get("is_enabled", True)) - existing = scholar_map.get(scholar_id) - if existing is None: - profile = _new_scholar_profile( - user_id=user_id, - scholar_id=scholar_id, - display_name=display_name, - profile_image_override_url=override_url, - is_enabled=is_enabled, - ) - db_session.add(profile) - scholar_map[scholar_id] = profile - counters["scholars_created"] += 1 - continue - - if _apply_imported_scholar_values( - profile=existing, - display_name=display_name, - profile_image_override_url=override_url, - is_enabled=is_enabled, - ): - counters["scholars_updated"] += 1 - - await db_session.flush() - return scholar_map, counters diff --git a/app/services/portability/types.py b/app/services/portability/types.py deleted file mode 100644 index 2a7f6e8..0000000 --- a/app/services/portability/types.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.db.models import ScholarProfile - - -class ImportExportError(ValueError): - """Raised when import/export payload constraints are violated.""" - - -@dataclass(frozen=True) -class ImportedPublicationInput: - profile: ScholarProfile - title: str - year: int | None - citation_count: int - author_text: str | None - venue_text: str | None - cluster_id: str | None - pub_url: str | None - doi: str | None - pdf_url: str | None - fingerprint: str - is_read: bool diff --git a/app/services/publication_identifiers/__init__.py b/app/services/publication_identifiers/__init__.py deleted file mode 100644 index c64a01f..0000000 --- a/app/services/publication_identifiers/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from app.services.publication_identifiers.application import ( - DisplayIdentifier, - derive_display_identifier_from_values, - display_identifier_for_publication_id, - overlay_pdf_queue_items_with_display_identifiers, - overlay_publication_items_with_display_identifiers, - sync_identifiers_for_publication_fields, - sync_identifiers_for_publication_resolution, -) - -__all__ = [ - "DisplayIdentifier", - "derive_display_identifier_from_values", - "display_identifier_for_publication_id", - "overlay_pdf_queue_items_with_display_identifiers", - "overlay_publication_items_with_display_identifiers", - "sync_identifiers_for_publication_fields", - "sync_identifiers_for_publication_resolution", -] diff --git a/app/services/publication_identifiers/application.py b/app/services/publication_identifiers/application.py deleted file mode 100644 index 560211e..0000000 --- a/app/services/publication_identifiers/application.py +++ /dev/null @@ -1,525 +0,0 @@ -from __future__ import annotations - -from dataclasses import replace -from typing import TYPE_CHECKING - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import Publication, PublicationIdentifier -from app.services.arxiv.guards import arxiv_skip_reason_for_item -from app.services.doi.normalize import normalize_doi -from app.services.publication_identifiers.normalize import ( - normalize_arxiv_id, - normalize_pmcid, - normalize_pmid, -) -from app.services.publication_identifiers.types import ( - DisplayIdentifier, - IdentifierCandidate, - IdentifierKind, -) - -if TYPE_CHECKING: - from app.services.publications.pdf_queue_queries import PdfQueueListItem - from app.services.publications.types import PublicationListItem, UnreadPublicationItem - -CONFIDENCE_HIGH = 0.98 -CONFIDENCE_MEDIUM = 0.9 -CONFIDENCE_LOW = 0.6 -CONFIDENCE_FALLBACK = 0.4 -PRIORITY_DOI = 400 -PRIORITY_ARXIV = 300 -PRIORITY_PMCID = 200 -PRIORITY_PMID = 100 - - -def derive_display_identifier_from_values( - *, - doi: str | None, - pub_url: str | None = None, - pdf_url: str | None = None, -) -> DisplayIdentifier | None: - candidates = _fallback_candidates_from_values(doi=doi, pub_url=pub_url, pdf_url=pdf_url) - return _best_display_identifier(candidates) - - -def _fallback_candidates_from_values( - *, - doi: str | None, - pub_url: str | None, - pdf_url: str | None, -) -> list[IdentifierCandidate]: - values = [value for value in [pub_url, pdf_url] if value] - candidates = [] - if doi: - normalized_doi = normalize_doi(doi) - if normalized_doi: - candidates.append( - _candidate(IdentifierKind.DOI, doi, normalized_doi, "legacy_doi", CONFIDENCE_HIGH, pub_url) - ) - candidates.extend(_url_identifier_candidates(values=values, source="legacy_urls")) - return _dedup_candidates(candidates) - - -def _url_identifier_candidates(*, values: list[str], source: str) -> list[IdentifierCandidate]: - candidates: list[IdentifierCandidate] = [] - for value in values: - candidates.extend(_url_candidates_for_value(value=value, source=source)) - return candidates - - -def _url_candidates_for_value(*, value: str, source: str) -> list[IdentifierCandidate]: - candidates: list[IdentifierCandidate] = [] - arxiv = normalize_arxiv_id(value) - if arxiv: - candidates.append(_candidate(IdentifierKind.ARXIV, value, arxiv, source, CONFIDENCE_MEDIUM, value)) - pmcid = normalize_pmcid(value) - if pmcid: - candidates.append(_candidate(IdentifierKind.PMCID, value, pmcid, source, CONFIDENCE_LOW, value)) - pmid = normalize_pmid(value) - if pmid: - candidates.append(_candidate(IdentifierKind.PMID, value, pmid, source, CONFIDENCE_FALLBACK, value)) - return candidates - - -def _candidate( - kind: IdentifierKind, - value_raw: str, - value_normalized: str, - source: str, - confidence_score: float, - evidence_url: str | None, -) -> IdentifierCandidate: - return IdentifierCandidate( - kind=kind, - value_raw=value_raw, - value_normalized=value_normalized, - source=source, - confidence_score=float(confidence_score), - evidence_url=evidence_url, - ) - - -def _dedup_candidates(candidates: list[IdentifierCandidate]) -> list[IdentifierCandidate]: - deduped: dict[tuple[str, str], IdentifierCandidate] = {} - for candidate in candidates: - key = (candidate.kind.value, candidate.value_normalized) - current = deduped.get(key) - if current is None or candidate.confidence_score > current.confidence_score: - deduped[key] = candidate - return list(deduped.values()) - - -async def sync_identifiers_for_publication_fields( - db_session: AsyncSession, - *, - publication: Publication, -) -> None: - candidates = _publication_field_candidates(publication) - await _upsert_publication_candidates(db_session, publication_id=int(publication.id), candidates=candidates) - - -async def discover_and_sync_identifiers_for_publication( - db_session: AsyncSession, - *, - publication: Publication, - scholar_label: str, -) -> None: - await sync_identifiers_for_publication_fields(db_session, publication=publication) - - publication_id = int(publication.id) - if await _has_confident_identifier( - db_session, - publication_id=publication_id, - kind=IdentifierKind.DOI.value, - confidence_floor=0.0, - ): - return - - item = _identifier_lookup_item(publication=publication, scholar_label=scholar_label) - has_strong_doi = await _discover_crossref_doi( - db_session, - publication_id=publication_id, - item=item, - ) - existing_arxiv = await _existing_identifier_by_kind( - db_session, - publication_id=publication_id, - kind=IdentifierKind.ARXIV.value, - ) - skip_reason = arxiv_skip_reason_for_item( - item=item, - has_strong_doi=has_strong_doi, - has_existing_arxiv=existing_arxiv is not None, - ) - if skip_reason is not None: - return - await _discover_arxiv_identifier(db_session, publication_id=publication_id, item=item) - - -def _identifier_lookup_item( - *, - publication: Publication, - scholar_label: str, -) -> UnreadPublicationItem: - from app.services.publications.types import UnreadPublicationItem - - return UnreadPublicationItem( - publication_id=int(publication.id), - scholar_profile_id=0, - scholar_label=scholar_label, - title=str(publication.title_raw or ""), - year=publication.year, - citation_count=publication.citation_count, - venue_text=publication.venue_text, - pub_url=publication.pub_url, - pdf_url=publication.pdf_url, - ) - - -async def _discover_crossref_doi( - db_session: AsyncSession, - *, - publication_id: int, - item: UnreadPublicationItem, -) -> bool: - from app.services.crossref import application as crossref_service - - discovered_doi = await crossref_service.discover_doi_for_publication(item=item) - normalized_doi = normalize_doi(discovered_doi) - if discovered_doi is None or normalized_doi is None: - return False - candidate = _candidate( - IdentifierKind.DOI, - discovered_doi, - normalized_doi, - "crossref_api", - CONFIDENCE_MEDIUM, - None, - ) - await _upsert_publication_candidate( - db_session, - publication_id=publication_id, - candidate=candidate, - ) - return candidate.confidence_score >= CONFIDENCE_MEDIUM - - -async def _discover_arxiv_identifier( - db_session: AsyncSession, - *, - publication_id: int, - item: UnreadPublicationItem, -) -> None: - from app.services.arxiv import application as arxiv_service - - discovered_arxiv = await arxiv_service.discover_arxiv_id_for_publication(item=item) - normalized_arxiv = normalize_arxiv_id(discovered_arxiv) - if discovered_arxiv is None or normalized_arxiv is None: - return - candidate = _candidate( - IdentifierKind.ARXIV, - discovered_arxiv, - normalized_arxiv, - "arxiv_api", - CONFIDENCE_MEDIUM, - None, - ) - await _upsert_publication_candidate( - db_session, - publication_id=publication_id, - candidate=candidate, - ) - - -async def _has_confident_identifier( - db_session: AsyncSession, - *, - publication_id: int, - kind: str, - confidence_floor: float, -) -> bool: - existing = await _existing_identifier_by_kind( - db_session, - publication_id=publication_id, - kind=kind, - ) - if existing is None: - return False - return float(existing.confidence_score) >= float(confidence_floor) - - -def _publication_field_candidates(publication: Publication) -> list[IdentifierCandidate]: - return _fallback_candidates_from_values( - doi=None, - pub_url=publication.pub_url, - pdf_url=publication.pdf_url, - ) - - -async def sync_identifiers_for_publication_resolution( - db_session: AsyncSession, - *, - publication: Publication, - source: str | None, -) -> None: - candidates = _publication_field_candidates(publication) - rewritten = [_candidate_with_source(candidate, source=source) for candidate in candidates] - await _upsert_publication_candidates(db_session, publication_id=int(publication.id), candidates=rewritten) - - -def _candidate_with_source(candidate: IdentifierCandidate, *, source: str | None) -> IdentifierCandidate: - if not source: - return candidate - return IdentifierCandidate( - kind=candidate.kind, - value_raw=candidate.value_raw, - value_normalized=candidate.value_normalized, - source=source, - confidence_score=candidate.confidence_score, - evidence_url=candidate.evidence_url, - ) - - -async def _upsert_publication_candidates( - db_session: AsyncSession, - *, - publication_id: int, - candidates: list[IdentifierCandidate], -) -> None: - for candidate in _dedup_candidates(candidates): - await _upsert_publication_candidate(db_session, publication_id=publication_id, candidate=candidate) - - -async def _upsert_publication_candidate( - db_session: AsyncSession, - *, - publication_id: int, - candidate: IdentifierCandidate, -) -> None: - existing = await _existing_identifier( - db_session, - publication_id=publication_id, - kind=candidate.kind.value, - value_normalized=candidate.value_normalized, - ) - if existing is None: - db_session.add(_new_identifier_row(publication_id=publication_id, candidate=candidate)) - return - _merge_identifier_row(existing, candidate=candidate) - - -async def _existing_identifier( - db_session: AsyncSession, - *, - publication_id: int, - kind: str, - value_normalized: str, -) -> PublicationIdentifier | None: - result = await db_session.execute( - select(PublicationIdentifier).where( - PublicationIdentifier.publication_id == publication_id, - PublicationIdentifier.kind == kind, - PublicationIdentifier.value_normalized == value_normalized, - ) - ) - return result.scalar_one_or_none() - - -async def _existing_identifier_by_kind( - db_session: AsyncSession, - *, - publication_id: int, - kind: str, -) -> PublicationIdentifier | None: - result = await db_session.execute( - select(PublicationIdentifier) - .where( - PublicationIdentifier.publication_id == publication_id, - PublicationIdentifier.kind == kind, - ) - .order_by(PublicationIdentifier.confidence_score.desc()) - .limit(1) - ) - return result.scalar_one_or_none() - - -def _new_identifier_row( - *, - publication_id: int, - candidate: IdentifierCandidate, -) -> PublicationIdentifier: - return PublicationIdentifier( - publication_id=publication_id, - kind=candidate.kind.value, - value_raw=candidate.value_raw, - value_normalized=candidate.value_normalized, - source=candidate.source, - confidence_score=candidate.confidence_score, - evidence_url=candidate.evidence_url, - ) - - -def _merge_identifier_row(existing: PublicationIdentifier, *, candidate: IdentifierCandidate) -> None: - if candidate.confidence_score >= float(existing.confidence_score): - existing.value_raw = candidate.value_raw - existing.source = candidate.source - existing.confidence_score = candidate.confidence_score - if candidate.evidence_url: - existing.evidence_url = candidate.evidence_url - - -async def overlay_publication_items_with_display_identifiers( - db_session: AsyncSession, - *, - items: list[PublicationListItem], -) -> list[PublicationListItem]: - if not items: - return [] - mapping = await _display_identifier_map(db_session, publication_ids=[item.publication_id for item in items]) - return [_overlay_publication_item(item, mapping.get(item.publication_id)) for item in items] - - -def _overlay_publication_item( - item: PublicationListItem, - display_identifier: DisplayIdentifier | None, -) -> PublicationListItem: - fallback = display_identifier or derive_display_identifier_from_values( - doi=None, pub_url=item.pub_url, pdf_url=item.pdf_url - ) - return replace(item, display_identifier=fallback) - - -async def overlay_pdf_queue_items_with_display_identifiers( - db_session: AsyncSession, - *, - items: list[PdfQueueListItem], -) -> list[PdfQueueListItem]: - if not items: - return [] - mapping = await _display_identifier_map(db_session, publication_ids=[item.publication_id for item in items]) - return [_overlay_queue_item(item, mapping.get(item.publication_id)) for item in items] - - -def _overlay_queue_item( - item: PdfQueueListItem, - display_identifier: DisplayIdentifier | None, -) -> PdfQueueListItem: - fallback = display_identifier or derive_display_identifier_from_values(doi=None, pdf_url=item.pdf_url) - return replace(item, display_identifier=fallback) - - -async def display_identifier_for_publication_id( - db_session: AsyncSession, - *, - publication_id: int, -) -> DisplayIdentifier | None: - normalized_id = int(publication_id) - if normalized_id <= 0: - raise ValueError("publication_id must be positive.") - mapping = await _display_identifier_map(db_session, publication_ids=[normalized_id]) - display = mapping.get(normalized_id) - if display is not None: - return display - publication = await db_session.get(Publication, normalized_id) - if publication is None: - return None - return derive_display_identifier_from_values( - doi=None, - pub_url=publication.pub_url, - pdf_url=publication.pdf_url, - ) - - -async def _display_identifier_map( - db_session: AsyncSession, - *, - publication_ids: list[int], -) -> dict[int, DisplayIdentifier]: - normalized_ids = sorted({int(value) for value in publication_ids if int(value) > 0}) - if not normalized_ids: - return {} - result = await db_session.execute( - select(PublicationIdentifier).where(PublicationIdentifier.publication_id.in_(normalized_ids)) - ) - rows = list(result.scalars().all()) - return _best_display_identifier_map(rows) - - -def _best_display_identifier_map(rows: list[PublicationIdentifier]) -> dict[int, DisplayIdentifier]: - grouped: dict[int, list[IdentifierCandidate]] = {} - for row in rows: - grouped.setdefault(int(row.publication_id), []).append(_candidate_from_row(row)) - return { - publication_id: display - for publication_id, display in ( - (publication_id, _best_display_identifier(candidates)) for publication_id, candidates in grouped.items() - ) - if display is not None - } - - -def _candidate_from_row(row: PublicationIdentifier) -> IdentifierCandidate: - return IdentifierCandidate( - kind=IdentifierKind(str(row.kind)), - value_raw=str(row.value_raw), - value_normalized=str(row.value_normalized), - source=str(row.source), - confidence_score=float(row.confidence_score), - evidence_url=row.evidence_url, - ) - - -def _best_display_identifier(candidates: list[IdentifierCandidate]) -> DisplayIdentifier | None: - if not candidates: - return None - ordered = sorted(candidates, key=_display_sort_key, reverse=True) - return _display_identifier_from_candidate(ordered[0]) - - -def _display_sort_key(candidate: IdentifierCandidate) -> tuple[int, float]: - return (_kind_priority(candidate.kind), float(candidate.confidence_score)) - - -def _kind_priority(kind: IdentifierKind) -> int: - if kind == IdentifierKind.DOI: - return PRIORITY_DOI - if kind == IdentifierKind.ARXIV: - return PRIORITY_ARXIV - if kind == IdentifierKind.PMCID: - return PRIORITY_PMCID - return PRIORITY_PMID - - -def _display_identifier_from_candidate(candidate: IdentifierCandidate) -> DisplayIdentifier: - value = candidate.value_normalized - return DisplayIdentifier( - kind=candidate.kind.value, - value=value, - label=_display_label(candidate.kind, value), - url=_identifier_url(candidate.kind, value), - confidence_score=float(candidate.confidence_score), - ) - - -def _display_label(kind: IdentifierKind, value: str) -> str: - if kind == IdentifierKind.DOI: - return f"DOI: {value}" - if kind == IdentifierKind.ARXIV: - return f"arXiv: {value}" - if kind == IdentifierKind.PMCID: - return f"PMCID: {value}" - return f"PMID: {value}" - - -def _identifier_url(kind: IdentifierKind, value: str) -> str | None: - if kind == IdentifierKind.DOI: - return f"https://doi.org/{value}" - if kind == IdentifierKind.ARXIV: - return f"https://arxiv.org/abs/{value}" - if kind == IdentifierKind.PMCID: - return f"https://pmc.ncbi.nlm.nih.gov/articles/{value}/" - if kind == IdentifierKind.PMID: - return f"https://pubmed.ncbi.nlm.nih.gov/{value}/" - return None diff --git a/app/services/publication_identifiers/normalize.py b/app/services/publication_identifiers/normalize.py deleted file mode 100644 index 1308a8d..0000000 --- a/app/services/publication_identifiers/normalize.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import re -from urllib.parse import urlparse - -from app.services.doi.normalize import normalize_doi -from app.services.publication_identifiers.types import IdentifierKind - -ARXIV_ABS_RE = re.compile(r"\barxiv:\s*([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?\b", re.I) -ARXIV_RAW_RE = re.compile(r"^([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?$", re.I) -ARXIV_PATH_RE = re.compile(r"^/(?:abs|pdf|html|ps|format)/([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?(?:\.pdf)?/?$", re.I) -PMCID_RE = re.compile(r"\b(PMC\d+)\b", re.I) -PUBMED_PATH_RE = re.compile(r"^/(\d+)/?$") - - -def normalize_identifier(kind: IdentifierKind, value: str | None) -> str | None: - if kind == IdentifierKind.DOI: - return normalize_doi(value) - if kind == IdentifierKind.ARXIV: - return normalize_arxiv_id(value) - if kind == IdentifierKind.PMCID: - return normalize_pmcid(value) - if kind == IdentifierKind.PMID: - return normalize_pmid(value) - return None - - -def normalize_arxiv_id(value: str | None) -> str | None: - text = (value or "").strip() - if not text: - return None - parsed = urlparse(text) - if parsed.scheme in {"http", "https"} and "arxiv.org" in parsed.netloc.lower(): - return _arxiv_from_path(parsed.path) - raw_match = ARXIV_RAW_RE.match(text) - if raw_match: - version = (raw_match.group(2) or "").lower() - return f"{raw_match.group(1).lower()}{version}" - match = ARXIV_ABS_RE.search(text) - if not match: - return None - version = (match.group(2) or "").lower() - return f"{match.group(1).lower()}{version}" - - -def _arxiv_from_path(path: str) -> str | None: - match = ARXIV_PATH_RE.match(path or "") - if not match: - return None - version = (match.group(2) or "").lower() - return f"{match.group(1).lower()}{version}" - - -def normalize_pmcid(value: str | None) -> str | None: - text = (value or "").strip() - if not text: - return None - parsed = urlparse(text) - if parsed.scheme in {"http", "https"} and "ncbi.nlm.nih.gov" in parsed.netloc.lower(): - return _first_match(PMCID_RE, parsed.path) - return _first_match(PMCID_RE, text) - - -def normalize_pmid(value: str | None) -> str | None: - text = (value or "").strip() - if not text: - return None - parsed = urlparse(text) - if parsed.scheme in {"http", "https"} and "pubmed.ncbi.nlm.nih.gov" in parsed.netloc.lower(): - match = PUBMED_PATH_RE.match(parsed.path or "") - if not match: - return None - return match.group(1) - return None - - -def _first_match(pattern: re.Pattern[str], value: str) -> str | None: - match = pattern.search(value) - if not match: - return None - return match.group(1).upper() diff --git a/app/services/publication_identifiers/types.py b/app/services/publication_identifiers/types.py deleted file mode 100644 index 34b20d0..0000000 --- a/app/services/publication_identifiers/types.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum - - -class IdentifierKind(StrEnum): - DOI = "doi" - ARXIV = "arxiv" - PMCID = "pmcid" - PMID = "pmid" - - -@dataclass(frozen=True) -class DisplayIdentifier: - kind: str - value: str - label: str - url: str | None - confidence_score: float - - -@dataclass(frozen=True) -class IdentifierCandidate: - kind: IdentifierKind - value_raw: str - value_normalized: str - source: str - confidence_score: float - evidence_url: str | None diff --git a/app/services/publications.py b/app/services/publications.py new file mode 100644 index 0000000..f4dbaff --- /dev/null +++ b/app/services/publications.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy import Select, func, select, tuple_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + Publication, + RunStatus, + ScholarProfile, + ScholarPublication, +) + +MODE_ALL = "all" +MODE_UNREAD = "unread" +MODE_LATEST = "latest" +MODE_NEW = "new" # compatibility alias for MODE_LATEST + + +@dataclass(frozen=True) +class PublicationListItem: + publication_id: int + scholar_profile_id: int + scholar_label: str + title: str + year: int | None + citation_count: int + venue_text: str | None + pub_url: str | None + is_read: bool + first_seen_at: datetime + is_new_in_latest_run: bool + + +@dataclass(frozen=True) +class UnreadPublicationItem: + publication_id: int + scholar_profile_id: int + scholar_label: str + title: str + year: int | None + citation_count: int + venue_text: str | None + pub_url: str | None + + +def resolve_publication_view_mode(value: str | None) -> str: + if value == MODE_UNREAD: + return MODE_UNREAD + if value in {MODE_LATEST, MODE_NEW}: + return MODE_LATEST + return MODE_ALL + + +def resolve_mode(value: str | None) -> str: + return resolve_publication_view_mode(value) + + +async def get_latest_completed_run_id_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> int | None: + result = await db_session.execute( + select(func.max(CrawlRun.id)).where( + CrawlRun.user_id == user_id, + CrawlRun.status != RunStatus.RUNNING, + ) + ) + latest_run_id = result.scalar_one_or_none() + return int(latest_run_id) if latest_run_id is not None else None + + +def publications_query( + *, + user_id: int, + mode: str, + latest_run_id: int | None, + scholar_profile_id: int | None, + limit: int, +) -> Select[tuple]: + scholar_label = ScholarProfile.display_name + + stmt = ( + select( + Publication.id, + ScholarProfile.id, + scholar_label, + ScholarProfile.scholar_id, + Publication.title_raw, + Publication.year, + Publication.citation_count, + Publication.venue_text, + Publication.pub_url, + ScholarPublication.is_read, + ScholarPublication.first_seen_run_id, + ScholarPublication.created_at, + ) + .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where(ScholarProfile.user_id == user_id) + .order_by(ScholarPublication.created_at.desc(), Publication.id.desc()) + .limit(limit) + ) + if scholar_profile_id is not None: + stmt = stmt.where(ScholarProfile.id == scholar_profile_id) + if mode == MODE_UNREAD: + stmt = stmt.where(ScholarPublication.is_read.is_(False)) + if mode == MODE_LATEST: + # "Latest" means discovered in the latest completed run. + if latest_run_id is None: + stmt = stmt.where(False) + else: + stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) + return stmt + + +async def list_for_user( + db_session: AsyncSession, + *, + user_id: int, + mode: str = MODE_ALL, + scholar_profile_id: int | None = None, + limit: int = 300, +) -> list[PublicationListItem]: + resolved_mode = resolve_publication_view_mode(mode) + latest_run_id = await get_latest_completed_run_id_for_user( + db_session, + user_id=user_id, + ) + result = await db_session.execute( + publications_query( + user_id=user_id, + mode=resolved_mode, + latest_run_id=latest_run_id, + scholar_profile_id=scholar_profile_id, + limit=limit, + ) + ) + + rows = result.all() + items: list[PublicationListItem] = [] + for row in rows: + ( + publication_id, + scholar_profile_id, + display_name, + scholar_id, + title_raw, + year, + citation_count, + venue_text, + pub_url, + is_read, + first_seen_run_id, + created_at, + ) = row + items.append( + PublicationListItem( + publication_id=int(publication_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + title=title_raw, + year=year, + citation_count=int(citation_count or 0), + venue_text=venue_text, + pub_url=pub_url, + is_read=bool(is_read), + first_seen_at=created_at, + is_new_in_latest_run=( + latest_run_id is not None and int(first_seen_run_id or 0) == latest_run_id + ), + ) + ) + return items + + +async def list_unread_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 100, +) -> list[UnreadPublicationItem]: + result = await db_session.execute( + publications_query( + user_id=user_id, + mode=MODE_UNREAD, + latest_run_id=None, + scholar_profile_id=None, + limit=limit, + ) + ) + rows = result.all() + items: list[UnreadPublicationItem] = [] + for row in rows: + ( + publication_id, + scholar_profile_id, + display_name, + scholar_id, + title_raw, + year, + citation_count, + venue_text, + pub_url, + _is_read, + _first_seen_run_id, + _created_at, + ) = row + items.append( + UnreadPublicationItem( + publication_id=int(publication_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + title=title_raw, + year=year, + citation_count=int(citation_count or 0), + venue_text=venue_text, + pub_url=pub_url, + ) + ) + return items + + +async def list_new_for_latest_run_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 100, +) -> list[UnreadPublicationItem]: + rows = await list_for_user( + db_session, + user_id=user_id, + mode=MODE_LATEST, + scholar_profile_id=None, + limit=limit, + ) + return [ + UnreadPublicationItem( + publication_id=row.publication_id, + scholar_profile_id=row.scholar_profile_id, + scholar_label=row.scholar_label, + title=row.title, + year=row.year, + citation_count=row.citation_count, + venue_text=row.venue_text, + pub_url=row.pub_url, + ) + for row in rows + ] + + +async def count_for_user( + db_session: AsyncSession, + *, + user_id: int, + mode: str = MODE_ALL, + scholar_profile_id: int | None = None, +) -> int: + resolved_mode = resolve_publication_view_mode(mode) + latest_run_id = await get_latest_completed_run_id_for_user( + db_session, + user_id=user_id, + ) + stmt = ( + select(func.count()) + .select_from(ScholarPublication) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where(ScholarProfile.user_id == user_id) + ) + if scholar_profile_id is not None: + stmt = stmt.where(ScholarProfile.id == scholar_profile_id) + if resolved_mode == MODE_UNREAD: + stmt = stmt.where(ScholarPublication.is_read.is_(False)) + if resolved_mode == MODE_LATEST: + if latest_run_id is None: + return 0 + stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) + result = await db_session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def count_unread_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int | None = None, +) -> int: + return await count_for_user( + db_session, + user_id=user_id, + mode=MODE_UNREAD, + scholar_profile_id=scholar_profile_id, + ) + + +async def count_latest_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int | None = None, +) -> int: + return await count_for_user( + db_session, + user_id=user_id, + mode=MODE_LATEST, + scholar_profile_id=scholar_profile_id, + ) + + +async def mark_all_unread_as_read_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> int: + scholar_ids = ( + select(ScholarProfile.id) + .where(ScholarProfile.user_id == user_id) + .scalar_subquery() + ) + + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + ScholarPublication.is_read.is_(False), + ) + .values(is_read=True) + ) + result = await db_session.execute(stmt) + await db_session.commit() + + rowcount = result.rowcount + return int(rowcount or 0) + + +async def mark_selected_as_read_for_user( + db_session: AsyncSession, + *, + user_id: int, + selections: list[tuple[int, int]], +) -> int: + normalized_pairs = { + (int(scholar_profile_id), int(publication_id)) + for scholar_profile_id, publication_id in selections + if int(scholar_profile_id) > 0 and int(publication_id) > 0 + } + if not normalized_pairs: + return 0 + + scholar_ids = ( + select(ScholarProfile.id) + .where(ScholarProfile.user_id == user_id) + .scalar_subquery() + ) + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + tuple_( + ScholarPublication.scholar_profile_id, + ScholarPublication.publication_id, + ).in_(list(normalized_pairs)), + ScholarPublication.is_read.is_(False), + ) + .values(is_read=True) + ) + result = await db_session.execute(stmt) + await db_session.commit() + return int(result.rowcount or 0) diff --git a/app/services/publications/__init__.py b/app/services/publications/__init__.py deleted file mode 100644 index c03a02a..0000000 --- a/app/services/publications/__init__.py +++ /dev/null @@ -1,84 +0,0 @@ -from app.services.publications.application import ( - MODE_ALL as MODE_ALL, -) -from app.services.publications.application import ( - MODE_LATEST as MODE_LATEST, -) -from app.services.publications.application import ( - MODE_NEW as MODE_NEW, -) -from app.services.publications.application import ( - MODE_UNREAD as MODE_UNREAD, -) -from app.services.publications.application import ( - PublicationListItem as PublicationListItem, -) -from app.services.publications.application import ( - UnreadPublicationItem as UnreadPublicationItem, -) -from app.services.publications.application import ( - count_favorite_for_user as count_favorite_for_user, -) -from app.services.publications.application import ( - count_for_user as count_for_user, -) -from app.services.publications.application import ( - count_latest_for_user as count_latest_for_user, -) -from app.services.publications.application import ( - count_pdf_queue_items as count_pdf_queue_items, -) -from app.services.publications.application import ( - count_unread_for_user as count_unread_for_user, -) -from app.services.publications.application import ( - enqueue_all_missing_pdf_jobs as enqueue_all_missing_pdf_jobs, -) -from app.services.publications.application import ( - enqueue_retry_pdf_job_for_publication_id as enqueue_retry_pdf_job_for_publication_id, -) -from app.services.publications.application import ( - get_latest_run_id_for_user as get_latest_run_id_for_user, -) -from app.services.publications.application import ( - get_publication_item_for_user as get_publication_item_for_user, -) -from app.services.publications.application import ( - hydrate_pdf_enrichment_state as hydrate_pdf_enrichment_state, -) -from app.services.publications.application import ( - list_for_user as list_for_user, -) -from app.services.publications.application import ( - list_pdf_queue_items as list_pdf_queue_items, -) -from app.services.publications.application import ( - list_pdf_queue_page as list_pdf_queue_page, -) -from app.services.publications.application import ( - list_unread_for_user as list_unread_for_user, -) -from app.services.publications.application import ( - mark_all_unread_as_read_for_user as mark_all_unread_as_read_for_user, -) -from app.services.publications.application import ( - mark_selected_as_read_for_user as mark_selected_as_read_for_user, -) -from app.services.publications.application import ( - publications_query as publications_query, -) -from app.services.publications.application import ( - resolve_publication_view_mode as resolve_publication_view_mode, -) -from app.services.publications.application import ( - retry_pdf_for_user as retry_pdf_for_user, -) -from app.services.publications.application import ( - schedule_missing_pdf_enrichment_for_user as schedule_missing_pdf_enrichment_for_user, -) -from app.services.publications.application import ( - schedule_retry_pdf_enrichment_for_row as schedule_retry_pdf_enrichment_for_row, -) -from app.services.publications.application import ( - set_publication_favorite_for_user as set_publication_favorite_for_user, -) diff --git a/app/services/publications/application.py b/app/services/publications/application.py deleted file mode 100644 index 61cf9ad..0000000 --- a/app/services/publications/application.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from app.services.publications.counts import ( - count_favorite_for_user, - count_for_user, - count_latest_for_user, - count_unread_for_user, -) -from app.services.publications.enrichment import ( - hydrate_pdf_enrichment_state, - schedule_missing_pdf_enrichment_for_user, - schedule_retry_pdf_enrichment_for_row, -) -from app.services.publications.listing import ( - list_for_user, - list_unread_for_user, - retry_pdf_for_user, -) -from app.services.publications.modes import ( - MODE_ALL, - MODE_LATEST, - MODE_NEW, - MODE_UNREAD, - resolve_publication_view_mode, -) -from app.services.publications.pdf_queue import ( - enqueue_all_missing_pdf_jobs, - enqueue_retry_pdf_job_for_publication_id, -) -from app.services.publications.pdf_queue_queries import ( - count_pdf_queue_items, - list_pdf_queue_items, - list_pdf_queue_page, -) -from app.services.publications.queries import ( - get_latest_run_id_for_user, - get_publication_item_for_user, - publications_query, -) -from app.services.publications.read_state import ( - mark_all_unread_as_read_for_user, - mark_selected_as_read_for_user, - set_publication_favorite_for_user, -) -from app.services.publications.types import PublicationListItem, UnreadPublicationItem - -__all__ = [ - "MODE_ALL", - "MODE_LATEST", - "MODE_NEW", - "MODE_UNREAD", - "PublicationListItem", - "UnreadPublicationItem", - "count_favorite_for_user", - "count_for_user", - "count_latest_for_user", - "count_pdf_queue_items", - "count_unread_for_user", - "enqueue_all_missing_pdf_jobs", - "enqueue_retry_pdf_job_for_publication_id", - "get_latest_run_id_for_user", - "get_publication_item_for_user", - "hydrate_pdf_enrichment_state", - "list_for_user", - "list_pdf_queue_items", - "list_pdf_queue_page", - "list_unread_for_user", - "mark_all_unread_as_read_for_user", - "mark_selected_as_read_for_user", - "publications_query", - "resolve_publication_view_mode", - "retry_pdf_for_user", - "schedule_missing_pdf_enrichment_for_user", - "schedule_retry_pdf_enrichment_for_row", - "set_publication_favorite_for_user", -] diff --git a/app/services/publications/counts.py b/app/services/publications/counts.py deleted file mode 100644 index fc09930..0000000 --- a/app/services/publications/counts.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from sqlalchemy import distinct, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import Publication, ScholarProfile, ScholarPublication -from app.services.publications.modes import ( - MODE_ALL, - MODE_LATEST, - MODE_UNREAD, - resolve_publication_view_mode, -) -from app.services.publications.queries import get_latest_run_id_for_user - - -async def count_for_user( - db_session: AsyncSession, - *, - user_id: int, - mode: str = MODE_ALL, - scholar_profile_id: int | None = None, - favorite_only: bool = False, - search: str | None = None, - snapshot_before: datetime | None = None, -) -> int: - resolved_mode = resolve_publication_view_mode(mode) - latest_run_id = await get_latest_run_id_for_user(db_session, user_id=user_id) - stmt = ( - select(func.count(distinct(ScholarPublication.publication_id))) - .select_from(ScholarPublication) - .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) - .join(Publication, Publication.id == ScholarPublication.publication_id) - .where(ScholarProfile.user_id == user_id) - ) - stmt = _apply_search_filter(stmt, search=search) - if scholar_profile_id is not None: - stmt = stmt.where(ScholarProfile.id == scholar_profile_id) - if favorite_only: - stmt = stmt.where(ScholarPublication.is_favorite.is_(True)) - if snapshot_before is not None: - stmt = stmt.where(ScholarPublication.created_at <= snapshot_before) - if resolved_mode == MODE_UNREAD: - stmt = stmt.where(ScholarPublication.is_read.is_(False)) - if resolved_mode == MODE_LATEST: - if latest_run_id is None: - return 0 - stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) - result = await db_session.execute(stmt) - return int(result.scalar_one() or 0) - - -def _apply_search_filter(stmt, *, search: str | None): - if not search: - return stmt - safe_search = search.replace("%", r"\%").replace("_", r"\_") - pattern = f"%{safe_search}%" - return stmt.where( - Publication.title_raw.ilike(pattern) - | ScholarProfile.display_name.ilike(pattern) - | Publication.venue_text.ilike(pattern) - ) - - -async def count_unread_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int | None = None, - favorite_only: bool = False, - search: str | None = None, - snapshot_before: datetime | None = None, -) -> int: - return await count_for_user( - db_session, - user_id=user_id, - mode=MODE_UNREAD, - scholar_profile_id=scholar_profile_id, - favorite_only=favorite_only, - search=search, - snapshot_before=snapshot_before, - ) - - -async def count_latest_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int | None = None, - favorite_only: bool = False, - search: str | None = None, - snapshot_before: datetime | None = None, -) -> int: - return await count_for_user( - db_session, - user_id=user_id, - mode=MODE_LATEST, - scholar_profile_id=scholar_profile_id, - favorite_only=favorite_only, - search=search, - snapshot_before=snapshot_before, - ) - - -async def count_favorite_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int | None = None, - search: str | None = None, - snapshot_before: datetime | None = None, -) -> int: - return await count_for_user( - db_session, - user_id=user_id, - mode=MODE_ALL, - scholar_profile_id=scholar_profile_id, - favorite_only=True, - search=search, - snapshot_before=snapshot_before, - ) diff --git a/app/services/publications/dedup.py b/app/services/publications/dedup.py deleted file mode 100644 index d078f97..0000000 --- a/app/services/publications/dedup.py +++ /dev/null @@ -1,502 +0,0 @@ -from __future__ import annotations - -import hashlib -import logging -from collections.abc import Iterable -from dataclasses import dataclass - -from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import aliased - -from app.db.models import Publication, PublicationIdentifier, ScholarPublication -from app.logging_utils import structured_log -from app.services.ingestion.fingerprints import ( - canonical_title_text_for_dedup, - canonical_title_tokens_for_dedup, - normalize_title, -) - -logger = logging.getLogger(__name__) - -NEAR_DUP_DEFAULT_SIMILARITY_THRESHOLD = 0.78 -NEAR_DUP_DEFAULT_CONTAINMENT_THRESHOLD = 0.92 -NEAR_DUP_DEFAULT_MIN_SHARED_TOKENS = 3 -NEAR_DUP_DEFAULT_MAX_YEAR_DELTA = 1 -NEAR_DUP_MIN_TOKEN_LENGTH = 3 -NEAR_DUP_CLUSTER_KEY_LENGTH = 16 -NEAR_DUP_STOPWORDS = { - "a", - "an", - "and", - "approach", - "for", - "in", - "method", - "of", - "on", - "the", - "to", - "using", - "via", - "with", -} - - -@dataclass(frozen=True) -class NearDuplicateMember: - publication_id: int - title: str - year: int | None - citation_count: int - - -@dataclass(frozen=True) -class NearDuplicateCluster: - cluster_key: str - winner_publication_id: int - similarity_score: float - members: tuple[NearDuplicateMember, ...] - - -@dataclass(frozen=True) -class _NearDuplicateCandidate: - publication_id: int - title: str - year: int | None - citation_count: int - canonical_text: str - tokens: frozenset[str] - - -async def find_identifier_duplicate_pairs( - db_session: AsyncSession, -) -> list[tuple[int, int]]: - """Return (winner_id, dup_id) pairs where two publications share the same identifier.""" - pi1 = aliased(PublicationIdentifier, name="pi1") - pi2 = aliased(PublicationIdentifier, name="pi2") - rows = await db_session.execute( - select(pi1.publication_id, pi2.publication_id) - .join( - pi2, - (pi1.kind == pi2.kind) - & (pi1.value_normalized == pi2.value_normalized) - & (pi1.publication_id < pi2.publication_id), - ) - .distinct() - ) - return [(winner_id, dup_id) for winner_id, dup_id in rows] - - -async def merge_duplicate_publication( - db_session: AsyncSession, - *, - winner_id: int, - dup_id: int, -) -> None: - """Merge dup_id into winner_id: migrate metadata/links/identifiers, then delete dup.""" - if winner_id == dup_id: - raise ValueError("winner_id and dup_id must differ.") - winner = await _load_publication(db_session, publication_id=winner_id) - dup = await _load_publication(db_session, publication_id=dup_id) - if winner is None or dup is None: - raise ValueError("winner_id and dup_id must both exist.") - _merge_publication_metadata(winner=winner, dup=dup) - await _migrate_scholar_links(db_session, winner_id=winner_id, dup_id=dup_id) - await _migrate_identifiers(db_session, winner_id=winner_id, dup_id=dup_id) - await db_session.execute(delete(Publication).where(Publication.id == dup_id)) - structured_log(logger, "info", "publications.identifier_merge", winner_id=winner_id, dup_id=dup_id) - - -async def _load_publication( - db_session: AsyncSession, - *, - publication_id: int, -) -> Publication | None: - result = await db_session.execute(select(Publication).where(Publication.id == publication_id)) - return result.scalar_one_or_none() - - -def _merge_publication_metadata(*, winner: Publication, dup: Publication) -> None: - if winner.year is None and dup.year is not None: - winner.year = dup.year - winner.citation_count = max(int(winner.citation_count or 0), int(dup.citation_count or 0)) - if not winner.author_text and dup.author_text: - winner.author_text = dup.author_text - if not winner.venue_text and dup.venue_text: - winner.venue_text = dup.venue_text - if not winner.pub_url and dup.pub_url: - winner.pub_url = dup.pub_url - if not winner.pdf_url and dup.pdf_url: - winner.pdf_url = dup.pdf_url - if not winner.cluster_id and dup.cluster_id: - winner.cluster_id = dup.cluster_id - if not winner.canonical_title_hash and dup.canonical_title_hash: - winner.canonical_title_hash = dup.canonical_title_hash - winner.title_raw = _preferred_title_text(winner=winner.title_raw, dup=dup.title_raw) - winner.title_normalized = normalize_title(winner.title_raw) - - -def _preferred_title_text(*, winner: str, dup: str) -> str: - winner_score = len(canonical_title_text_for_dedup(winner)) - dup_score = len(canonical_title_text_for_dedup(dup)) - if dup_score > winner_score: - return dup - return winner - - -async def _migrate_scholar_links( - db_session: AsyncSession, - *, - winner_id: int, - dup_id: int, -) -> None: - """Move ScholarPublication links from dup to winner, dropping conflicts.""" - dup_links_result = await db_session.execute( - select(ScholarPublication).where(ScholarPublication.publication_id == dup_id) - ) - dup_links = dup_links_result.scalars().all() - - winner_profiles_result = await db_session.execute( - select(ScholarPublication.scholar_profile_id).where(ScholarPublication.publication_id == winner_id) - ) - winner_profiles: set[int] = {row for (row,) in winner_profiles_result} - - for link in dup_links: - if link.scholar_profile_id in winner_profiles: - await db_session.delete(link) - else: - link.publication_id = winner_id - - -async def _migrate_identifiers( - db_session: AsyncSession, - *, - winner_id: int, - dup_id: int, -) -> None: - result = await db_session.execute( - select(PublicationIdentifier).where(PublicationIdentifier.publication_id == dup_id) - ) - dup_identifiers = result.scalars().all() - for identifier in dup_identifiers: - existing = await _find_identifier( - db_session, - publication_id=winner_id, - kind=identifier.kind, - value_normalized=identifier.value_normalized, - ) - if existing is None: - identifier.publication_id = winner_id - continue - _merge_identifier(existing=existing, dup=identifier) - await db_session.delete(identifier) - - -async def _find_identifier( - db_session: AsyncSession, - *, - publication_id: int, - kind: str, - value_normalized: str, -) -> PublicationIdentifier | None: - result = await db_session.execute( - select(PublicationIdentifier).where( - PublicationIdentifier.publication_id == publication_id, - PublicationIdentifier.kind == kind, - PublicationIdentifier.value_normalized == value_normalized, - ) - ) - return result.scalar_one_or_none() - - -def _merge_identifier(*, existing: PublicationIdentifier, dup: PublicationIdentifier) -> None: - existing.confidence_score = max( - float(existing.confidence_score), - float(dup.confidence_score), - ) - if not existing.evidence_url and dup.evidence_url: - existing.evidence_url = dup.evidence_url - if not existing.value_raw and dup.value_raw: - existing.value_raw = dup.value_raw - - -async def sweep_identifier_duplicates(db_session: AsyncSession) -> int: - """Find publications sharing an identifier and merge duplicates into the winner.""" - pairs = await find_identifier_duplicate_pairs(db_session) - if not pairs: - return 0 - - processed_dups: set[int] = set() - for winner_id, dup_id in pairs: - if dup_id in processed_dups: - continue - processed_dups.add(dup_id) - await merge_duplicate_publication(db_session, winner_id=winner_id, dup_id=dup_id) - - await db_session.flush() - return len(processed_dups) - - -async def find_near_duplicate_clusters( - db_session: AsyncSession, - *, - similarity_threshold: float = NEAR_DUP_DEFAULT_SIMILARITY_THRESHOLD, - min_shared_tokens: int = NEAR_DUP_DEFAULT_MIN_SHARED_TOKENS, - max_year_delta: int = NEAR_DUP_DEFAULT_MAX_YEAR_DELTA, -) -> list[NearDuplicateCluster]: - candidates = await _load_near_duplicate_candidates(db_session) - if len(candidates) < 2: - return [] - groups = _cluster_candidate_groups( - candidates, - similarity_threshold=similarity_threshold, - min_shared_tokens=min_shared_tokens, - max_year_delta=max_year_delta, - ) - clusters = [_near_duplicate_cluster(group) for group in groups] - return sorted(clusters, key=lambda item: (-len(item.members), item.winner_publication_id)) - - -async def merge_near_duplicate_cluster( - db_session: AsyncSession, - *, - cluster: NearDuplicateCluster, -) -> int: - winner_id = int(cluster.winner_publication_id) - merged = 0 - for member in cluster.members: - if int(member.publication_id) == winner_id: - continue - await merge_duplicate_publication( - db_session, - winner_id=winner_id, - dup_id=int(member.publication_id), - ) - merged += 1 - return merged - - -def near_duplicate_cluster_payload(cluster: NearDuplicateCluster) -> dict[str, object]: - members = [ - { - "publication_id": int(member.publication_id), - "title": member.title, - "year": member.year, - "citation_count": int(member.citation_count), - } - for member in cluster.members - ] - return { - "cluster_key": cluster.cluster_key, - "winner_publication_id": int(cluster.winner_publication_id), - "member_count": len(cluster.members), - "similarity_score": float(cluster.similarity_score), - "members": members, - } - - -async def _load_near_duplicate_candidates( - db_session: AsyncSession, -) -> list[_NearDuplicateCandidate]: - result = await db_session.execute( - select( - Publication.id, - Publication.title_raw, - Publication.year, - Publication.citation_count, - ) - ) - records = [ - _candidate_from_row( - publication_id=int(publication_id), - title=str(title_raw or ""), - year=year, - citation_count=int(citation_count or 0), - ) - for publication_id, title_raw, year, citation_count in result.all() - ] - return [record for record in records if record is not None] - - -def _candidate_from_row( - *, - publication_id: int, - title: str, - year: int | None, - citation_count: int, -) -> _NearDuplicateCandidate | None: - canonical = canonical_title_text_for_dedup(title) - raw_tokens = canonical_title_tokens_for_dedup(title) - tokens = _normalized_tokens(raw_tokens) - if not canonical or not tokens: - return None - return _NearDuplicateCandidate( - publication_id=publication_id, - title=title, - year=year, - citation_count=citation_count, - canonical_text=canonical, - tokens=frozenset(tokens), - ) - - -def _normalized_tokens(tokens: Iterable[str]) -> set[str]: - return {token for token in tokens if len(token) >= NEAR_DUP_MIN_TOKEN_LENGTH and token not in NEAR_DUP_STOPWORDS} - - -def _cluster_candidate_groups( - candidates: list[_NearDuplicateCandidate], - *, - similarity_threshold: float, - min_shared_tokens: int, - max_year_delta: int, -) -> list[list[_NearDuplicateCandidate]]: - by_id = {candidate.publication_id: candidate for candidate in candidates} - token_index = _candidate_token_index(candidates) - parent = {candidate.publication_id: candidate.publication_id for candidate in candidates} - for candidate in candidates: - peers = _candidate_peer_ids(candidate=candidate, token_index=token_index) - for peer_id in sorted(peers): - if peer_id <= candidate.publication_id: - continue - peer = by_id[peer_id] - if _is_near_duplicate_pair( - candidate, - peer, - similarity_threshold=similarity_threshold, - min_shared_tokens=min_shared_tokens, - max_year_delta=max_year_delta, - ): - _union(parent, candidate.publication_id, peer_id) - return _grouped_candidates(candidates, parent) - - -def _candidate_token_index( - candidates: list[_NearDuplicateCandidate], -) -> dict[str, set[int]]: - index: dict[str, set[int]] = {} - for candidate in candidates: - for token in candidate.tokens: - index.setdefault(token, set()).add(candidate.publication_id) - return index - - -def _candidate_peer_ids( - *, - candidate: _NearDuplicateCandidate, - token_index: dict[str, set[int]], -) -> set[int]: - peers: set[int] = set() - for token in candidate.tokens: - peers.update(token_index.get(token, set())) - peers.discard(candidate.publication_id) - return peers - - -def _is_near_duplicate_pair( - left: _NearDuplicateCandidate, - right: _NearDuplicateCandidate, - *, - similarity_threshold: float, - min_shared_tokens: int, - max_year_delta: int, -) -> bool: - if left.canonical_text == right.canonical_text: - return True - if not _years_compatible(left.year, right.year, max_year_delta=max_year_delta): - return False - shared_tokens = len(left.tokens & right.tokens) - if shared_tokens < min_shared_tokens: - return False - jaccard = _jaccard(left.tokens, right.tokens) - containment = shared_tokens / max(1, min(len(left.tokens), len(right.tokens))) - return jaccard >= similarity_threshold or containment >= NEAR_DUP_DEFAULT_CONTAINMENT_THRESHOLD - - -def _years_compatible(left: int | None, right: int | None, *, max_year_delta: int) -> bool: - if left is None or right is None: - return True - return abs(int(left) - int(right)) <= int(max_year_delta) - - -def _jaccard(left: frozenset[str], right: frozenset[str]) -> float: - if not left or not right: - return 0.0 - return len(left & right) / len(left | right) - - -def _find_root(parent: dict[int, int], value: int) -> int: - root = parent[value] - while root != parent[root]: - root = parent[root] - while value != root: - next_value = parent[value] - parent[value] = root - value = next_value - return root - - -def _union(parent: dict[int, int], left: int, right: int) -> None: - left_root = _find_root(parent, left) - right_root = _find_root(parent, right) - if left_root == right_root: - return - if left_root < right_root: - parent[right_root] = left_root - return - parent[left_root] = right_root - - -def _grouped_candidates( - candidates: list[_NearDuplicateCandidate], - parent: dict[int, int], -) -> list[list[_NearDuplicateCandidate]]: - groups: dict[int, list[_NearDuplicateCandidate]] = {} - for candidate in candidates: - root = _find_root(parent, candidate.publication_id) - groups.setdefault(root, []).append(candidate) - clustered = [members for members in groups.values() if len(members) > 1] - for members in clustered: - members.sort(key=lambda item: item.publication_id) - return clustered - - -def _near_duplicate_cluster(members: list[_NearDuplicateCandidate]) -> NearDuplicateCluster: - winner = _winner_candidate(members) - member_ids = [member.publication_id for member in members] - joined = ",".join(str(publication_id) for publication_id in member_ids) - cluster_key = hashlib.sha256(joined.encode("utf-8")).hexdigest()[:NEAR_DUP_CLUSTER_KEY_LENGTH] - similarity_score = _cluster_similarity_score(members) - return NearDuplicateCluster( - cluster_key=cluster_key, - winner_publication_id=winner.publication_id, - similarity_score=similarity_score, - members=tuple( - NearDuplicateMember( - publication_id=member.publication_id, - title=member.title, - year=member.year, - citation_count=member.citation_count, - ) - for member in members - ), - ) - - -def _winner_candidate(members: list[_NearDuplicateCandidate]) -> _NearDuplicateCandidate: - return min( - members, - key=lambda member: (-int(member.citation_count), member.publication_id), - ) - - -def _cluster_similarity_score(members: list[_NearDuplicateCandidate]) -> float: - best = 0.0 - for index, left in enumerate(members): - for right in members[index + 1 :]: - shared_tokens = len(left.tokens & right.tokens) - jaccard = _jaccard(left.tokens, right.tokens) - containment = shared_tokens / max(1, min(len(left.tokens), len(right.tokens))) - best = max(best, jaccard, containment) - return round(best, 4) diff --git a/app/services/publications/enrichment.py b/app/services/publications/enrichment.py deleted file mode 100644 index 3980d05..0000000 --- a/app/services/publications/enrichment.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import logging - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.logging_utils import structured_log -from app.services.publications.pdf_queue import ( - enqueue_missing_pdf_jobs, - enqueue_retry_pdf_job, - overlay_pdf_job_state, -) -from app.services.publications.types import PublicationListItem - -logger = logging.getLogger(__name__) - - -async def schedule_missing_pdf_enrichment_for_user( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - items: list[PublicationListItem], - max_items: int, -) -> int: - queued_ids = await enqueue_missing_pdf_jobs( - db_session, - user_id=user_id, - request_email=request_email, - rows=items, - max_items=max_items, - ) - structured_log( - logger, "info", "publications.enrichment.scheduled", user_id=user_id, publication_count=len(queued_ids) - ) - return len(queued_ids) - - -async def schedule_retry_pdf_enrichment_for_row( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - item: PublicationListItem, -) -> bool: - queued = await enqueue_retry_pdf_job( - db_session, - user_id=user_id, - request_email=request_email, - row=item, - ) - structured_log( - logger, - "info", - "publications.enrichment.retry_scheduled", - user_id=user_id, - publication_id=item.publication_id, - queued=queued, - ) - return queued - - -async def hydrate_pdf_enrichment_state( - db_session: AsyncSession, - *, - items: list[PublicationListItem], -) -> list[PublicationListItem]: - return await overlay_pdf_job_state(db_session, rows=items) diff --git a/app/services/publications/listing.py b/app/services/publications/listing.py deleted file mode 100644 index 6bb50b2..0000000 --- a/app/services/publications/listing.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.services.publication_identifiers import application as identifier_service -from app.services.publications.modes import ( - MODE_ALL, - MODE_UNREAD, - resolve_publication_view_mode, -) -from app.services.publications.queries import ( - get_latest_run_id_for_user, - get_publication_item_for_user, - publication_list_item_from_row, - publications_query, - unread_item_from_row, -) -from app.services.publications.types import PublicationListItem, UnreadPublicationItem - - -async def list_for_user( - db_session: AsyncSession, - *, - user_id: int, - mode: str = MODE_ALL, - scholar_profile_id: int | None = None, - favorite_only: bool = False, - search: str | None = None, - sort_by: str = "first_seen", - sort_dir: str = "desc", - limit: int = 100, - offset: int = 0, - snapshot_before: datetime | None = None, -) -> list[PublicationListItem]: - resolved_mode = resolve_publication_view_mode(mode) - latest_run_id = await get_latest_run_id_for_user(db_session, user_id=user_id) - result = await db_session.execute( - publications_query( - user_id=user_id, - mode=resolved_mode, - latest_run_id=latest_run_id, - scholar_profile_id=scholar_profile_id, - favorite_only=favorite_only, - search=search, - sort_by=sort_by, - sort_dir=sort_dir, - limit=limit, - offset=offset, - snapshot_before=snapshot_before, - ) - ) - rows = [publication_list_item_from_row(row, latest_run_id=latest_run_id) for row in result.all()] - return await identifier_service.overlay_publication_items_with_display_identifiers( - db_session, - items=rows, - ) - - -async def retry_pdf_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, - publication_id: int, -) -> PublicationListItem | None: - item = await get_publication_item_for_user( - db_session, - user_id=user_id, - scholar_profile_id=scholar_profile_id, - publication_id=publication_id, - ) - if item is None: - return None - hydrated = await identifier_service.overlay_publication_items_with_display_identifiers( - db_session, - items=[item], - ) - return hydrated[0] if hydrated else item - - -async def list_unread_for_user( - db_session: AsyncSession, - *, - user_id: int, - limit: int = 100, -) -> list[UnreadPublicationItem]: - result = await db_session.execute( - publications_query( - user_id=user_id, - mode=MODE_UNREAD, - latest_run_id=None, - scholar_profile_id=None, - favorite_only=False, - limit=limit, - offset=0, - ) - ) - return [unread_item_from_row(row) for row in result.all()] diff --git a/app/services/publications/modes.py b/app/services/publications/modes.py deleted file mode 100644 index 2975169..0000000 --- a/app/services/publications/modes.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -MODE_ALL = "all" -MODE_UNREAD = "unread" -MODE_LATEST = "latest" -MODE_NEW = "new" # compatibility alias for MODE_LATEST - - -def resolve_publication_view_mode(value: str | None) -> str: - if value == MODE_UNREAD: - return MODE_UNREAD - if value in {MODE_LATEST, MODE_NEW}: - return MODE_LATEST - return MODE_ALL diff --git a/app/services/publications/pdf_queue.py b/app/services/publications/pdf_queue.py deleted file mode 100644 index 25f2437..0000000 --- a/app/services/publications/pdf_queue.py +++ /dev/null @@ -1,330 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from datetime import datetime - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - PublicationPdfJob, - User, -) -from app.services.publications.pdf_queue_common import ( - PDF_STATUS_FAILED, - PDF_STATUS_QUEUED, - PDF_STATUS_RESOLVED, - PDF_STATUS_RUNNING, - event_row, - queued_job, - utcnow, -) -from app.services.publications.pdf_queue_queries import ( - missing_pdf_candidates, - retry_item_for_publication_id, -) -from app.services.publications.pdf_queue_resolution import schedule_rows -from app.services.publications.types import PublicationListItem -from app.settings import settings - -PDF_STATUS_UNTRACKED = "untracked" - -PDF_EVENT_QUEUED = "queued" - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PdfRequeueResult: - publication_exists: bool - queued: bool - - -@dataclass(frozen=True) -class PdfBulkQueueResult: - requested_count: int - queued_count: int - - -def _publication_ids(rows: list[PublicationListItem]) -> list[int]: - return sorted({row.publication_id for row in rows}) - - -def _status_from_job(row: PublicationListItem, job: PublicationPdfJob | None) -> str: - if row.pdf_url: - return PDF_STATUS_RESOLVED - if job is None: - return PDF_STATUS_UNTRACKED - return job.status - - -def _item_from_row_and_job( - row: PublicationListItem, - job: PublicationPdfJob | None, -) -> PublicationListItem: - return PublicationListItem( - publication_id=row.publication_id, - scholar_profile_id=row.scholar_profile_id, - scholar_label=row.scholar_label, - title=row.title, - year=row.year, - citation_count=row.citation_count, - venue_text=row.venue_text, - pub_url=row.pub_url, - pdf_url=row.pdf_url, - is_read=row.is_read, - is_favorite=row.is_favorite, - first_seen_at=row.first_seen_at, - is_new_in_latest_run=row.is_new_in_latest_run, - pdf_status=_status_from_job(row, job), - pdf_attempt_count=int(job.attempt_count) if job is not None else 0, - pdf_failure_reason=job.last_failure_reason if job is not None else None, - pdf_failure_detail=job.last_failure_detail if job is not None else None, - display_identifier=row.display_identifier, - ) - - -def _queueable_rows( - rows: list[PublicationListItem], - *, - max_items: int, -) -> list[PublicationListItem]: - bounded = max(0, int(max_items)) - if bounded == 0: - return [] - candidates = [row for row in rows if not row.pdf_url] - return candidates[:bounded] - - -def _auto_retry_interval_seconds() -> int: - return max(int(settings.pdf_auto_retry_interval_seconds), 1) - - -def _auto_retry_first_interval_seconds() -> int: - return max(int(settings.pdf_auto_retry_first_interval_seconds), 1) - - -def _auto_retry_max_attempts() -> int: - return max(int(settings.pdf_auto_retry_max_attempts), 1) - - -def _retry_interval_seconds_for_attempt_count(attempt_count: int) -> int: - if int(attempt_count) <= 1: - return _auto_retry_first_interval_seconds() - return _auto_retry_interval_seconds() - - -def _cooldown_active( - *, - last_attempt_at: datetime | None, - attempt_count: int, -) -> bool: - if last_attempt_at is None: - return False - elapsed = (utcnow() - last_attempt_at).total_seconds() - return elapsed < _retry_interval_seconds_for_attempt_count(int(attempt_count)) - - -def _can_enqueue_job( - job: PublicationPdfJob | None, - *, - force_retry: bool, -) -> bool: - if job is None: - return True - if job.status in {PDF_STATUS_QUEUED, PDF_STATUS_RUNNING}: - return False - if force_retry: - return job.status in {PDF_STATUS_FAILED, PDF_STATUS_RESOLVED, PDF_STATUS_UNTRACKED} - if job.status == PDF_STATUS_RESOLVED: - return False - if int(job.attempt_count) >= _auto_retry_max_attempts(): - return False - return not _cooldown_active( - last_attempt_at=job.last_attempt_at, - attempt_count=int(job.attempt_count), - ) - - -def _mark_job_queued(job: PublicationPdfJob, *, user_id: int) -> None: - now = utcnow() - job.status = PDF_STATUS_QUEUED - job.queued_at = now - job.last_requested_by_user_id = user_id - job.last_failure_reason = None - job.last_failure_detail = None - job.last_source = None - - -def _state_map(jobs: list[PublicationPdfJob]) -> dict[int, PublicationPdfJob]: - return {int(job.publication_id): job for job in jobs} - - -async def _jobs_for_publication_ids( - db_session: AsyncSession, - *, - publication_ids: list[int], -) -> dict[int, PublicationPdfJob]: - if not publication_ids: - return {} - result = await db_session.execute( - select(PublicationPdfJob).where(PublicationPdfJob.publication_id.in_(publication_ids)) - ) - return _state_map(list(result.scalars())) - - -async def overlay_pdf_job_state( - db_session: AsyncSession, - *, - rows: list[PublicationListItem], -) -> list[PublicationListItem]: - if not rows: - return [] - jobs = await _jobs_for_publication_ids( - db_session, - publication_ids=_publication_ids(rows), - ) - return [_item_from_row_and_job(row, jobs.get(row.publication_id)) for row in rows] - - -async def _enqueue_rows( - db_session: AsyncSession, - *, - user_id: int, - rows: list[PublicationListItem], - force_retry: bool, -) -> list[PublicationListItem]: - if not rows: - return [] - queued: list[PublicationListItem] = [] - jobs = await _jobs_for_publication_ids( - db_session, - publication_ids=_publication_ids(rows), - ) - for row in rows: - job = jobs.get(row.publication_id) - if not _can_enqueue_job(job, force_retry=force_retry): - continue - if job is None: - job = queued_job(publication_id=row.publication_id, user_id=user_id) - jobs[row.publication_id] = job - db_session.add(job) - else: - _mark_job_queued(job, user_id=user_id) - db_session.add( - event_row( - publication_id=row.publication_id, - user_id=user_id, - event_type=PDF_EVENT_QUEUED, - status=PDF_STATUS_QUEUED, - ) - ) - queued.append(row) - if queued: - await db_session.commit() - return queued - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -async def enqueue_missing_pdf_jobs( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - rows: list[PublicationListItem], - max_items: int, -) -> list[int]: - queueable = _queueable_rows(rows, max_items=max_items) - queued_rows = await _enqueue_rows( - db_session, - user_id=user_id, - rows=queueable, - force_retry=False, - ) - schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) - return [row.publication_id for row in queued_rows] - - -async def enqueue_retry_pdf_job( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - row: PublicationListItem, -) -> bool: - queued_rows = await _enqueue_rows( - db_session, - user_id=user_id, - rows=[row], - force_retry=True, - ) - schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) - return bool(queued_rows) - - -async def enqueue_retry_pdf_job_for_publication_id( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - publication_id: int, -) -> PdfRequeueResult: - row = await retry_item_for_publication_id( - db_session, - publication_id=publication_id, - ) - if row is None: - return PdfRequeueResult(publication_exists=False, queued=False) - queued = await enqueue_retry_pdf_job( - db_session, - user_id=user_id, - request_email=request_email, - row=row, - ) - return PdfRequeueResult(publication_exists=True, queued=queued) - - -async def enqueue_all_missing_pdf_jobs( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - limit: int = 1000, -) -> PdfBulkQueueResult: - candidates = await missing_pdf_candidates(db_session, limit=limit) - queued_rows = await _enqueue_rows( - db_session, - user_id=user_id, - rows=candidates, - force_retry=True, - ) - schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) - return PdfBulkQueueResult( - requested_count=len(candidates), - queued_count=len(queued_rows), - ) - - -async def drain_ready_jobs( - db_session: AsyncSession, - *, - limit: int, - max_attempts: int, -) -> int: - result = await db_session.execute(select(User.id).where(User.is_active.is_(True)).order_by(User.id.asc()).limit(1)) - system_user_id = result.scalar_one_or_none() - if system_user_id is None: - return 0 - - bulk_result = await enqueue_all_missing_pdf_jobs( - db_session, - user_id=system_user_id, - request_email=settings.unpaywall_email, - limit=limit, - ) - return bulk_result.queued_count diff --git a/app/services/publications/pdf_queue_common.py b/app/services/publications/pdf_queue_common.py deleted file mode 100644 index 0bff88b..0000000 --- a/app/services/publications/pdf_queue_common.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime - -from app.db.models import PublicationPdfJob, PublicationPdfJobEvent - -PDF_STATUS_UNTRACKED = "untracked" -PDF_STATUS_QUEUED = "queued" -PDF_STATUS_RUNNING = "running" -PDF_STATUS_RESOLVED = "resolved" -PDF_STATUS_FAILED = "failed" - - -def utcnow() -> datetime: - return datetime.now(UTC) - - -def event_row( - *, - publication_id: int, - user_id: int | None, - event_type: str, - status: str | None, - source: str | None = None, - failure_reason: str | None = None, - message: str | None = None, -) -> PublicationPdfJobEvent: - return PublicationPdfJobEvent( - publication_id=publication_id, - user_id=user_id, - event_type=event_type, - status=status, - source=source, - failure_reason=failure_reason, - message=message, - ) - - -def queued_job( - *, - publication_id: int, - user_id: int, -) -> PublicationPdfJob: - now = utcnow() - return PublicationPdfJob( - publication_id=publication_id, - status=PDF_STATUS_QUEUED, - queued_at=now, - last_requested_by_user_id=user_id, - attempt_count=0, - ) diff --git a/app/services/publications/pdf_queue_queries.py b/app/services/publications/pdf_queue_queries.py deleted file mode 100644 index 31260ea..0000000 --- a/app/services/publications/pdf_queue_queries.py +++ /dev/null @@ -1,395 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import Any - -from sqlalchemy import Select, and_, func, literal, or_, select, union_all -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - Publication, - PublicationPdfJob, - ScholarProfile, - ScholarPublication, - User, -) -from app.services.publication_identifiers import application as identifier_service -from app.services.publication_identifiers.types import DisplayIdentifier -from app.services.publications.pdf_queue_common import ( - PDF_STATUS_QUEUED, - PDF_STATUS_RUNNING, - PDF_STATUS_UNTRACKED, -) -from app.services.publications.types import PublicationListItem - - -def _bounded_limit(limit: int, *, max_value: int = 500) -> int: - return max(1, min(int(limit), max_value)) - - -def _bounded_offset(offset: int) -> int: - return max(int(offset), 0) - - -def _utcnow() -> datetime: - return datetime.now(UTC) - - -def _retry_item_label(display_name: str | None, scholar_id: str | None) -> str: - return str(display_name or scholar_id or "unknown") - - -def _retry_item_from_publication( - publication: Publication, - *, - link_row: Any | None, -) -> PublicationListItem: - if link_row is None: - scholar_profile_id = 0 - scholar_label = "unknown" - is_read = True - first_seen_at = publication.created_at or _utcnow() - else: - scholar_profile_id = int(link_row[0]) - scholar_label = _retry_item_label(link_row[1], link_row[2]) - is_read = bool(link_row[3]) - first_seen_at = link_row[4] or publication.created_at or _utcnow() - return PublicationListItem( - publication_id=int(publication.id), - scholar_profile_id=scholar_profile_id, - scholar_label=scholar_label, - title=publication.title_raw, - year=publication.year, - citation_count=int(publication.citation_count or 0), - venue_text=publication.venue_text, - pub_url=publication.pub_url, - pdf_url=publication.pdf_url, - is_read=is_read, - first_seen_at=first_seen_at, - is_new_in_latest_run=False, - ) - - -async def retry_item_for_publication_id( - db_session: AsyncSession, - *, - publication_id: int, -) -> PublicationListItem | None: - publication = await db_session.get(Publication, publication_id) - if publication is None: - return None - result = await db_session.execute( - select( - ScholarProfile.id, - ScholarProfile.display_name, - ScholarProfile.scholar_id, - ScholarPublication.is_read, - ScholarPublication.created_at, - ) - .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) - .where(ScholarPublication.publication_id == publication_id) - .order_by(ScholarPublication.created_at.asc()) - .limit(1) - ) - return _retry_item_from_publication(publication, link_row=result.one_or_none()) - - -def _queue_candidate_from_publication(publication: Publication) -> PublicationListItem: - return PublicationListItem( - publication_id=int(publication.id), - scholar_profile_id=0, - scholar_label="", - title=publication.title_raw, - year=publication.year, - citation_count=int(publication.citation_count or 0), - venue_text=publication.venue_text, - pub_url=publication.pub_url, - pdf_url=publication.pdf_url, - is_read=True, - first_seen_at=publication.created_at or _utcnow(), - is_new_in_latest_run=False, - ) - - -async def missing_pdf_candidates( - db_session: AsyncSession, - *, - limit: int, -) -> list[PublicationListItem]: - bounded_limit = max(1, min(int(limit), 5000)) - now = datetime.now(UTC) - cooldown_threshold = now - timedelta(days=7) - - result = await db_session.execute( - select(Publication) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(Publication.pdf_url.is_(None)) - .where( - or_( - PublicationPdfJob.publication_id.is_(None), - and_( - PublicationPdfJob.status.notin_([PDF_STATUS_QUEUED, PDF_STATUS_RUNNING]), - or_( - PublicationPdfJob.last_attempt_at.is_(None), - PublicationPdfJob.last_attempt_at < cooldown_threshold, - ), - ), - ) - ) - .order_by(Publication.updated_at.desc(), Publication.id.desc()) - .limit(bounded_limit) - ) - return [_queue_candidate_from_publication(publication) for publication in result.scalars()] - - -# --------------------------------------------------------------------------- -# Tracked / untracked queue SQL builders -# --------------------------------------------------------------------------- - - -def _tracked_queue_select_base(*, status: str | None) -> Select[tuple]: - stmt = ( - select( - PublicationPdfJob.publication_id, - Publication.title_raw, - Publication.pdf_url, - PublicationPdfJob.status, - PublicationPdfJob.attempt_count, - PublicationPdfJob.last_failure_reason, - PublicationPdfJob.last_failure_detail, - PublicationPdfJob.last_source, - PublicationPdfJob.last_requested_by_user_id, - User.email, - PublicationPdfJob.queued_at, - PublicationPdfJob.last_attempt_at, - PublicationPdfJob.resolved_at, - PublicationPdfJob.updated_at, - ) - .join(Publication, Publication.id == PublicationPdfJob.publication_id) - .outerjoin(User, User.id == PublicationPdfJob.last_requested_by_user_id) - ) - if status: - stmt = stmt.where(PublicationPdfJob.status == status) - return stmt - - -def _tracked_queue_select(*, limit: int, offset: int, status: str | None) -> Select[tuple]: - return ( - _tracked_queue_select_base(status=status) - .order_by(PublicationPdfJob.updated_at.desc()) - .limit(_bounded_limit(limit)) - .offset(_bounded_offset(offset)) - ) - - -def _untracked_queue_select_base() -> Select[tuple]: - return ( - select( - Publication.id, - Publication.title_raw, - Publication.pdf_url, - literal(PDF_STATUS_UNTRACKED), - literal(0), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - Publication.updated_at, - ) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(Publication.pdf_url.is_(None)) - .where(PublicationPdfJob.publication_id.is_(None)) - ) - - -def _untracked_queue_select(*, limit: int, offset: int) -> Select[tuple]: - return ( - _untracked_queue_select_base() - .order_by(Publication.updated_at.desc(), Publication.id.desc()) - .limit(_bounded_limit(limit)) - .offset(_bounded_offset(offset)) - ) - - -def _all_queue_select(*, limit: int, offset: int) -> Select[tuple]: - union_stmt = union_all( - _tracked_queue_select_base(status=None), - _untracked_queue_select_base(), - ).subquery() - return ( - select(union_stmt) - .order_by(union_stmt.c.updated_at.desc()) - .limit(_bounded_limit(limit)) - .offset(_bounded_offset(offset)) - ) - - -def _tracked_queue_count_select(*, status: str | None) -> Select[tuple]: - stmt = select(func.count()).select_from(PublicationPdfJob) - if status: - stmt = stmt.where(PublicationPdfJob.status == status) - return stmt - - -def _untracked_queue_count_select() -> Select[tuple]: - return ( - select(func.count()) - .select_from(Publication) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(Publication.pdf_url.is_(None)) - .where(PublicationPdfJob.publication_id.is_(None)) - ) - - -# --------------------------------------------------------------------------- -# Row hydration -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class PdfQueueListItem: - publication_id: int - title: str - pdf_url: str | None - status: str - attempt_count: int - last_failure_reason: str | None - last_failure_detail: str | None - last_source: str | None - requested_by_user_id: int | None - requested_by_email: str | None - queued_at: datetime | None - last_attempt_at: datetime | None - resolved_at: datetime | None - updated_at: datetime - display_identifier: DisplayIdentifier | None = None - - -def _queue_item_from_row(row: Any) -> PdfQueueListItem: - return PdfQueueListItem( - publication_id=int(row[0]), - title=str(row[1] or ""), - pdf_url=row[2], - status=str(row[3] or PDF_STATUS_UNTRACKED), - attempt_count=int(row[4] or 0), - last_failure_reason=row[5], - last_failure_detail=row[6], - last_source=row[7], - requested_by_user_id=int(row[8]) if row[8] is not None else None, - requested_by_email=row[9], - queued_at=row[10], - last_attempt_at=row[11], - resolved_at=row[12], - updated_at=row[13], - ) - - -async def _hydrated_queue_items( - db_session: AsyncSession, - *, - rows: list[Any], -) -> list[PdfQueueListItem]: - items = [_queue_item_from_row(row) for row in rows] - return await identifier_service.overlay_pdf_queue_items_with_display_identifiers( - db_session, - items=items, - ) - - -# --------------------------------------------------------------------------- -# Public listing / counting -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class PdfQueuePage: - items: list[PdfQueueListItem] - total_count: int - limit: int - offset: int - - -async def list_pdf_queue_items( - db_session: AsyncSession, - *, - limit: int = 100, - offset: int = 0, - status: str | None = None, -) -> list[PdfQueueListItem]: - bounded_limit = _bounded_limit(limit) - bounded_offset = _bounded_offset(offset) - normalized_status = (status or "").strip().lower() or None - if normalized_status == PDF_STATUS_UNTRACKED: - result = await db_session.execute( - _untracked_queue_select( - limit=bounded_limit, - offset=bounded_offset, - ) - ) - return await _hydrated_queue_items(db_session, rows=list(result.all())) - if normalized_status is None: - result = await db_session.execute( - _all_queue_select( - limit=bounded_limit, - offset=bounded_offset, - ) - ) - return await _hydrated_queue_items(db_session, rows=list(result.all())) - result = await db_session.execute( - _tracked_queue_select( - limit=bounded_limit, - offset=bounded_offset, - status=normalized_status, - ) - ) - return await _hydrated_queue_items(db_session, rows=list(result.all())) - - -async def count_pdf_queue_items( - db_session: AsyncSession, - *, - status: str | None = None, -) -> int: - normalized_status = (status or "").strip().lower() or None - if normalized_status == PDF_STATUS_UNTRACKED: - result = await db_session.execute(_untracked_queue_count_select()) - return int(result.scalar_one() or 0) - tracked_result = await db_session.execute(_tracked_queue_count_select(status=normalized_status)) - tracked_count = int(tracked_result.scalar_one() or 0) - if normalized_status is not None: - return tracked_count - untracked_result = await db_session.execute(_untracked_queue_count_select()) - untracked_count = int(untracked_result.scalar_one() or 0) - return tracked_count + untracked_count - - -async def list_pdf_queue_page( - db_session: AsyncSession, - *, - limit: int = 100, - offset: int = 0, - status: str | None = None, -) -> PdfQueuePage: - bounded_limit = _bounded_limit(limit) - bounded_offset = _bounded_offset(offset) - items = await list_pdf_queue_items( - db_session, - limit=bounded_limit, - offset=bounded_offset, - status=status, - ) - total_count = await count_pdf_queue_items( - db_session, - status=status, - ) - return PdfQueuePage( - items=items, - total_count=total_count, - limit=bounded_limit, - offset=bounded_offset, - ) diff --git a/app/services/publications/pdf_queue_resolution.py b/app/services/publications/pdf_queue_resolution.py deleted file mode 100644 index ac71edd..0000000 --- a/app/services/publications/pdf_queue_resolution.py +++ /dev/null @@ -1,305 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from datetime import UTC, datetime, timedelta - -from app.db.background_session import background_session -from app.db.models import Publication, PublicationPdfJob -from app.logging_utils import structured_log -from app.services.publication_identifiers import application as identifier_service -from app.services.publications.pdf_queue_common import ( - PDF_STATUS_FAILED, - PDF_STATUS_RESOLVED, - PDF_STATUS_RUNNING, - event_row, - queued_job, - utcnow, -) -from app.services.publications.pdf_resolution_pipeline import ( - resolve_publication_pdf_outcome_for_row, -) -from app.services.publications.types import PublicationListItem -from app.services.unpaywall.application import ( - FAILURE_RESOLUTION_EXCEPTION, - OaResolutionOutcome, -) -from app.settings import settings - -PDF_EVENT_ATTEMPT_STARTED = "attempt_started" -PDF_EVENT_RESOLVED = "resolved" -PDF_EVENT_FAILED = "failed" - -_BUDGET_COOLDOWN_MINUTES = 15 - -logger = logging.getLogger(__name__) -_scheduled_tasks: set[asyncio.Task[None]] = set() -_budget_cooldown_until: datetime | None = None - - -def is_budget_cooldown_active() -> bool: - return _budget_cooldown_until is not None and datetime.now(UTC) < _budget_cooldown_until - - -def _enter_budget_cooldown() -> None: - global _budget_cooldown_until - _budget_cooldown_until = datetime.now(UTC) + timedelta(minutes=_BUDGET_COOLDOWN_MINUTES) - - -async def _mark_attempt_started( - *, - publication_id: int, - user_id: int, -) -> None: - async with background_session() as db_session: - job = await db_session.get(PublicationPdfJob, publication_id) - if job is None: - job = queued_job(publication_id=publication_id, user_id=user_id) - db_session.add(job) - job.status = PDF_STATUS_RUNNING - job.last_attempt_at = utcnow() - job.attempt_count = int(job.attempt_count or 0) + 1 - db_session.add( - event_row( - publication_id=publication_id, - user_id=user_id, - event_type=PDF_EVENT_ATTEMPT_STARTED, - status=PDF_STATUS_RUNNING, - ) - ) - await db_session.commit() - - -def _failed_outcome( - *, - row: PublicationListItem, -) -> OaResolutionOutcome: - return OaResolutionOutcome( - publication_id=row.publication_id, - doi=None, - pdf_url=None, - failure_reason=FAILURE_RESOLUTION_EXCEPTION, - source=None, - used_crossref=False, - ) - - -async def _fetch_outcome_for_row( - *, - row: PublicationListItem, - request_email: str | None, - openalex_api_key: str | None = None, - allow_arxiv_lookup: bool = True, -) -> tuple[OaResolutionOutcome, bool]: - pipeline_result = await resolve_publication_pdf_outcome_for_row( - row=row, - request_email=request_email, - openalex_api_key=openalex_api_key, - allow_arxiv_lookup=allow_arxiv_lookup, - ) - outcome = pipeline_result.outcome - if outcome is not None: - return outcome, bool(pipeline_result.arxiv_rate_limited) - return _failed_outcome(row=row), bool(pipeline_result.arxiv_rate_limited) - - -def _apply_publication_update( - publication: Publication, - *, - pdf_url: str | None, -) -> None: - if pdf_url and publication.pdf_url != pdf_url: - publication.pdf_url = pdf_url - - -def _apply_job_outcome(job: PublicationPdfJob, *, outcome: OaResolutionOutcome) -> None: - job.last_source = outcome.source - if outcome.pdf_url: - job.status = PDF_STATUS_RESOLVED - job.resolved_at = utcnow() - job.last_failure_reason = None - job.last_failure_detail = None - return - job.status = PDF_STATUS_FAILED - job.last_failure_reason = outcome.failure_reason - job.last_failure_detail = outcome.failure_reason - - -def _result_event(outcome: OaResolutionOutcome) -> tuple[str, str]: - if outcome.pdf_url: - return PDF_EVENT_RESOLVED, PDF_STATUS_RESOLVED - return PDF_EVENT_FAILED, PDF_STATUS_FAILED - - -async def _persist_outcome( - *, - publication_id: int, - user_id: int, - outcome: OaResolutionOutcome, -) -> None: - async with background_session() as db_session: - publication = await db_session.get(Publication, publication_id) - job = await db_session.get(PublicationPdfJob, publication_id) - if publication is None or job is None: - return - _apply_publication_update(publication, pdf_url=outcome.pdf_url) - await identifier_service.sync_identifiers_for_publication_resolution( - db_session, - publication=publication, - source=outcome.source, - ) - _apply_job_outcome(job, outcome=outcome) - event_type, status = _result_event(outcome) - db_session.add( - event_row( - publication_id=publication_id, - user_id=user_id, - event_type=event_type, - status=status, - source=outcome.source, - failure_reason=outcome.failure_reason, - message=outcome.failure_reason, - ) - ) - await db_session.commit() - - -async def _resolve_publication_row( - *, - user_id: int, - request_email: str | None, - row: PublicationListItem, - openalex_api_key: str | None = None, - allow_arxiv_lookup: bool = True, -) -> bool: - from app.services.openalex.client import OpenAlexBudgetExhaustedError - - await _mark_attempt_started(publication_id=row.publication_id, user_id=user_id) - try: - outcome, arxiv_rate_limited = await _fetch_outcome_for_row( - row=row, - request_email=request_email, - openalex_api_key=openalex_api_key, - allow_arxiv_lookup=allow_arxiv_lookup, - ) - except OpenAlexBudgetExhaustedError: - await _persist_outcome( - publication_id=row.publication_id, - user_id=user_id, - outcome=_failed_outcome(row=row), - ) - raise - except Exception as exc: # pragma: no cover - defensive network boundary - structured_log( - logger, - "warning", - "publications.pdf_queue.resolve_failed", - publication_id=row.publication_id, - error=str(exc), - ) - outcome = _failed_outcome(row=row) - arxiv_rate_limited = False - await _persist_outcome( - publication_id=row.publication_id, - user_id=user_id, - outcome=outcome, - ) - return bool(arxiv_rate_limited) - - -async def _run_resolution_task( - *, - user_id: int, - request_email: str | None, - rows: list[PublicationListItem], -) -> None: - from app.services.openalex.client import OpenAlexBudgetExhaustedError - from app.services.settings import application as user_settings_service - - openalex_api_key: str | None = None - try: - async with background_session() as key_session: - user_settings = await user_settings_service.get_or_create_settings(key_session, user_id=user_id) - openalex_api_key = getattr(user_settings, "openalex_api_key", None) or settings.openalex_api_key - except Exception: - openalex_api_key = settings.openalex_api_key - - arxiv_lookup_allowed = True - for row in rows: - try: - arxiv_rate_limited = await _resolve_publication_row( - user_id=user_id, - request_email=request_email, - row=row, - openalex_api_key=openalex_api_key, - allow_arxiv_lookup=arxiv_lookup_allowed, - ) - if arxiv_rate_limited and arxiv_lookup_allowed: - arxiv_lookup_allowed = False - structured_log( - logger, - "warning", - "pdf_queue.arxiv_batch_disabled", - detail="arXiv temporarily disabled for remaining batch after rate limit", - ) - except OpenAlexBudgetExhaustedError: - _enter_budget_cooldown() - structured_log( - logger, - "warning", - "pdf_queue.budget_exhausted", - detail="Stopping PDF resolution batch — OpenAlex daily budget exhausted", - cooldown_minutes=_BUDGET_COOLDOWN_MINUTES, - ) - break - except Exception: - structured_log( - logger, - "exception", - "pdf_queue.row_failed", - publication_id=row.publication_id, - ) - try: - await _persist_outcome( - publication_id=row.publication_id, - user_id=user_id, - outcome=_failed_outcome(row=row), - ) - except Exception: - structured_log( - logger, - "exception", - "pdf_queue.row_fail_persist_error", - publication_id=row.publication_id, - ) - - -def _register_task(task: asyncio.Task[None]) -> None: - _scheduled_tasks.add(task) - - -def _drop_finished_task(task: asyncio.Task[None]) -> None: - _scheduled_tasks.discard(task) - try: - task.result() - except Exception: - structured_log(logger, "exception", "publications.pdf_queue.task_failed") - - -def schedule_rows( - *, - user_id: int, - request_email: str | None, - rows: list[PublicationListItem], -) -> None: - if not rows: - return - task = asyncio.create_task( - _run_resolution_task( - user_id=user_id, - request_email=request_email, - rows=rows, - ) - ) - _register_task(task) - task.add_done_callback(_drop_finished_task) diff --git a/app/services/publications/pdf_resolution_pipeline.py b/app/services/publications/pdf_resolution_pipeline.py deleted file mode 100644 index b36777d..0000000 --- a/app/services/publications/pdf_resolution_pipeline.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Any - -from app.logging_utils import structured_log -from app.services.arxiv.errors import ArxivRateLimitError -from app.services.arxiv.guards import arxiv_skip_reason_for_item -from app.services.openalex.client import OpenAlexBudgetExhaustedError -from app.services.publications.types import PublicationListItem -from app.services.unpaywall.application import OaResolutionOutcome, resolve_publication_oa_outcomes -from app.settings import settings - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PipelineOutcome: - outcome: OaResolutionOutcome | None - scholar_candidates: Any | None # Kept for backward compatibility with calling signatures - arxiv_rate_limited: bool = False - - -async def resolve_publication_pdf_outcome_for_row( - *, - row: PublicationListItem, - request_email: str | None, - openalex_api_key: str | None = None, - allow_arxiv_lookup: bool = True, -) -> PipelineOutcome: - # 1. OpenAlex OA — raises OpenAlexBudgetExhaustedError if budget is gone - openalex_outcome = await _openalex_outcome(row, request_email=request_email, openalex_api_key=openalex_api_key) - if openalex_outcome and openalex_outcome.pdf_url: - return PipelineOutcome(openalex_outcome, None) - - # 2. arXiv - arxiv_rate_limited = False - try: - arxiv_outcome = await _arxiv_outcome( - row, - request_email=request_email, - allow_lookup=allow_arxiv_lookup, - ) - except ArxivRateLimitError: - arxiv_rate_limited = True - arxiv_outcome = None - structured_log(logger, "warning", "pdf_resolution.arxiv_rate_limited", publication_id=int(row.publication_id)) - if arxiv_outcome and arxiv_outcome.pdf_url: - return PipelineOutcome(arxiv_outcome, None, arxiv_rate_limited=arxiv_rate_limited) - - # 3. Unpaywall (which falls back to Crossref) - oa_outcome = await _oa_outcome(row=row, request_email=request_email) - return PipelineOutcome(oa_outcome, None, arxiv_rate_limited=arxiv_rate_limited) - - -async def _openalex_outcome( - row: PublicationListItem, - request_email: str | None, - openalex_api_key: str | None = None, -) -> OaResolutionOutcome | None: - from app.services.openalex.client import OpenAlexClient - from app.services.openalex.matching import find_best_match - - if not row.title: - return None - - import re - - safe_title = re.sub(r"[^\w\s]", " ", row.title) - safe_title = " ".join(safe_title.split()) - if not safe_title: - return None - - api_key = openalex_api_key or settings.openalex_api_key - client = OpenAlexClient(api_key=api_key, mailto=request_email or settings.crossref_api_mailto) - try: - openalex_works = await client.get_works_by_filter({"title.search": safe_title}, limit=5) - match = find_best_match( - target_title=row.title, - target_year=row.year, - target_authors=row.scholar_label, - candidates=openalex_works, - ) - if match and match.oa_url: - return OaResolutionOutcome( - publication_id=row.publication_id, - doi=match.doi, - pdf_url=match.oa_url, - failure_reason=None, - source="openalex", - used_crossref=False, - ) - except OpenAlexBudgetExhaustedError: - # Re-raise so the caller's batch loop can stop hitting the API. - raise - except Exception as exc: - structured_log(logger, "warning", "pdf_resolution.openalex_failed", error=str(exc)) - return None - - -async def _arxiv_outcome( - row: PublicationListItem, - *, - request_email: str | None, - allow_lookup: bool = True, -) -> OaResolutionOutcome | None: - from app.services.arxiv.application import discover_arxiv_id_for_publication - - if not allow_lookup: - structured_log( - logger, - "info", - "pdf_resolution.arxiv_skipped", - publication_id=int(row.publication_id), - skip_reason="batch_arxiv_cooldown_active", - ) - return None - - skip_reason = arxiv_skip_reason_for_item(item=row) - if skip_reason is not None: - structured_log( - logger, - "info", - "pdf_resolution.arxiv_skipped", - publication_id=int(row.publication_id), - skip_reason=skip_reason, - ) - return None - - try: - arxiv_id = await discover_arxiv_id_for_publication(item=row, request_email=request_email) - if arxiv_id: - pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" - return OaResolutionOutcome( - publication_id=row.publication_id, - doi=None, - pdf_url=pdf_url, - failure_reason=None, - source="arxiv", - used_crossref=False, - ) - except ArxivRateLimitError: - raise # propagate so orchestration can switch to non-arXiv fallback - except Exception as exc: - structured_log(logger, "warning", "pdf_resolution.arxiv_failed", error=str(exc)) - return None - - -async def _oa_outcome( - *, - row: PublicationListItem, - request_email: str | None, -) -> OaResolutionOutcome | None: - outcomes = await resolve_publication_oa_outcomes([row], request_email=request_email) - return outcomes.get(row.publication_id) diff --git a/app/services/publications/queries.py b/app/services/publications/queries.py deleted file mode 100644 index f113080..0000000 --- a/app/services/publications/queries.py +++ /dev/null @@ -1,270 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from sqlalchemy import Select, case, func, select -from sqlalchemy import false as sa_false -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - Publication, - PublicationPdfJob, - RunStatus, - ScholarProfile, - ScholarPublication, -) -from app.services.publications.modes import MODE_LATEST, MODE_UNREAD -from app.services.publications.pdf_queue_common import ( - PDF_STATUS_FAILED, - PDF_STATUS_QUEUED, - PDF_STATUS_RESOLVED, - PDF_STATUS_RUNNING, -) -from app.services.publications.types import PublicationListItem, UnreadPublicationItem - - -def _normalized_citation_count(value: object) -> int: - try: - return int(value or 0) # type: ignore[call-overload] # intentionally accepts any object - except (TypeError, ValueError): - return 0 - - -def _pdf_status_sort_rank(): - return case( - (Publication.pdf_url.is_not(None), 4), - (PublicationPdfJob.status == PDF_STATUS_RESOLVED, 4), - (PublicationPdfJob.status == PDF_STATUS_RUNNING, 3), - (PublicationPdfJob.status == PDF_STATUS_QUEUED, 2), - (PublicationPdfJob.status == PDF_STATUS_FAILED, 0), - else_=1, - ) - - -def _sort_column(sort_by: str): - sort_columns = { - "first_seen": ScholarPublication.created_at, - "title": Publication.title_raw, - "year": Publication.year, - "citations": Publication.citation_count, - "scholar": ScholarProfile.display_name, - "pdf_status": _pdf_status_sort_rank(), - } - return sort_columns.get(sort_by, ScholarPublication.created_at) - - -async def get_latest_run_id_for_user( - db_session: AsyncSession, - *, - user_id: int, -) -> int | None: - # We include RUNNING and RESOLVING statuses so that the "New" tab shows - # results in real-time as they are discovered. - result = await db_session.execute( - select(func.max(CrawlRun.id)).where( - CrawlRun.user_id == user_id, - CrawlRun.status.in_( - [ - RunStatus.RUNNING, - RunStatus.RESOLVING, - RunStatus.SUCCESS, - RunStatus.PARTIAL_FAILURE, - ] - ), - ) - ) - latest_run_id = result.scalar_one_or_none() - return int(latest_run_id) if latest_run_id is not None else None - - -def publications_query( - *, - user_id: int, - mode: str, - latest_run_id: int | None, - scholar_profile_id: int | None, - favorite_only: bool, - limit: int, - offset: int = 0, - search: str | None = None, - sort_by: str = "first_seen", - sort_dir: str = "desc", - snapshot_before: datetime | None = None, -) -> Select[tuple]: - scholar_label = ScholarProfile.display_name - stmt = ( - select( - Publication.id, - ScholarProfile.id, - scholar_label, - ScholarProfile.scholar_id, - Publication.title_raw, - Publication.year, - Publication.citation_count, - Publication.venue_text, - Publication.pub_url, - Publication.pdf_url, - ScholarPublication.is_read, - ScholarPublication.is_favorite, - ScholarPublication.first_seen_run_id, - ScholarPublication.created_at, - ) - .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) - .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(ScholarProfile.user_id == user_id) - ) - if search: - safe_search = search.replace("%", r"\%").replace("_", r"\_") - pat = f"%{safe_search}%" - stmt = stmt.where( - Publication.title_raw.ilike(pat) - | ScholarProfile.display_name.ilike(pat) - | Publication.venue_text.ilike(pat) - ) - if scholar_profile_id is not None: - stmt = stmt.where(ScholarProfile.id == scholar_profile_id) - if favorite_only: - stmt = stmt.where(ScholarPublication.is_favorite.is_(True)) - if mode == MODE_UNREAD: - stmt = stmt.where(ScholarPublication.is_read.is_(False)) - if mode == MODE_LATEST: - if latest_run_id is None: - return stmt.where(sa_false()) - stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) - if snapshot_before is not None: - stmt = stmt.where(ScholarPublication.created_at <= snapshot_before) - - sort_col = _sort_column(sort_by) - order = sort_col.desc() if sort_dir == "desc" else sort_col.asc() - stmt = stmt.order_by(order, Publication.id.desc()) - - if limit is not None: - stmt = stmt.offset(max(int(offset), 0)).limit(limit) - - return stmt - - -def publication_query_for_user( - *, - user_id: int, - scholar_profile_id: int, - publication_id: int, -) -> Select[tuple]: - return ( - select( - Publication.id, - ScholarProfile.id, - ScholarProfile.display_name, - ScholarProfile.scholar_id, - Publication.title_raw, - Publication.year, - Publication.citation_count, - Publication.venue_text, - Publication.pub_url, - Publication.pdf_url, - ScholarPublication.is_read, - ScholarPublication.is_favorite, - ScholarPublication.first_seen_run_id, - ScholarPublication.created_at, - ) - .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) - .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) - .where( - ScholarProfile.user_id == user_id, - ScholarProfile.id == scholar_profile_id, - Publication.id == publication_id, - ) - .limit(1) - ) - - -async def get_publication_item_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, - publication_id: int, -) -> PublicationListItem | None: - latest_run_id = await get_latest_run_id_for_user(db_session, user_id=user_id) - result = await db_session.execute( - publication_query_for_user( - user_id=user_id, - scholar_profile_id=scholar_profile_id, - publication_id=publication_id, - ) - ) - row = result.one_or_none() - if row is None: - return None - return publication_list_item_from_row(row, latest_run_id=latest_run_id) - - -def publication_list_item_from_row( - row: Any, - *, - latest_run_id: int | None, -) -> PublicationListItem: - ( - publication_id, - scholar_profile_id, - display_name, - scholar_id, - title_raw, - year, - citation_count, - venue_text, - pub_url, - pdf_url, - is_read, - is_favorite, - first_seen_run_id, - created_at, - ) = row - return PublicationListItem( - publication_id=int(publication_id), - scholar_profile_id=int(scholar_profile_id), - scholar_label=(display_name or scholar_id), - title=title_raw, - year=year, - citation_count=_normalized_citation_count(citation_count), - venue_text=venue_text, - pub_url=pub_url, - pdf_url=pdf_url, - is_read=bool(is_read), - is_favorite=bool(is_favorite), - first_seen_at=created_at, - is_new_in_latest_run=(latest_run_id is not None and int(first_seen_run_id or 0) == latest_run_id), - ) - - -def unread_item_from_row(row: Any) -> UnreadPublicationItem: - ( - publication_id, - scholar_profile_id, - display_name, - scholar_id, - title_raw, - year, - citation_count, - venue_text, - pub_url, - pdf_url, - _is_read, - _is_favorite, - _first_seen_run_id, - _created_at, - ) = row - return UnreadPublicationItem( - publication_id=int(publication_id), - scholar_profile_id=int(scholar_profile_id), - scholar_label=(display_name or scholar_id), - title=title_raw, - year=year, - citation_count=_normalized_citation_count(citation_count), - venue_text=venue_text, - pub_url=pub_url, - pdf_url=pdf_url, - ) diff --git a/app/services/publications/read_state.py b/app/services/publications/read_state.py deleted file mode 100644 index 2a56b61..0000000 --- a/app/services/publications/read_state.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from sqlalchemy import CursorResult, select, tuple_, update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ScholarProfile, ScholarPublication - - -def _normalized_selection_pairs(selections: list[tuple[int, int]]) -> set[tuple[int, int]]: - pairs: set[tuple[int, int]] = set() - for scholar_profile_id, publication_id in selections: - normalized = (int(scholar_profile_id), int(publication_id)) - if normalized[0] <= 0 or normalized[1] <= 0: - continue - pairs.add(normalized) - return pairs - - -def _scoped_scholar_ids_query(*, user_id: int): - return select(ScholarProfile.id).where(ScholarProfile.user_id == user_id).scalar_subquery() - - -async def mark_all_unread_as_read_for_user( - db_session: AsyncSession, - *, - user_id: int, -) -> int: - scholar_ids = _scoped_scholar_ids_query(user_id=user_id) - stmt = ( - update(ScholarPublication) - .where( - ScholarPublication.scholar_profile_id.in_(scholar_ids), - ScholarPublication.is_read.is_(False), - ) - .values(is_read=True) - ) - result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] - await db_session.commit() - return int(result.rowcount or 0) - - -async def mark_selected_as_read_for_user( - db_session: AsyncSession, - *, - user_id: int, - selections: list[tuple[int, int]], -) -> int: - normalized_pairs = _normalized_selection_pairs(selections) - if not normalized_pairs: - return 0 - - scholar_ids = _scoped_scholar_ids_query(user_id=user_id) - stmt = ( - update(ScholarPublication) - .where( - ScholarPublication.scholar_profile_id.in_(scholar_ids), - tuple_( - ScholarPublication.scholar_profile_id, - ScholarPublication.publication_id, - ).in_(list(normalized_pairs)), - ScholarPublication.is_read.is_(False), - ) - .values(is_read=True) - ) - result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] - await db_session.commit() - return int(result.rowcount or 0) - - -async def set_publication_favorite_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, - publication_id: int, - is_favorite: bool, -) -> int: - scholar_ids = _scoped_scholar_ids_query(user_id=user_id) - stmt = ( - update(ScholarPublication) - .where( - ScholarPublication.scholar_profile_id.in_(scholar_ids), - ScholarPublication.scholar_profile_id == int(scholar_profile_id), - ScholarPublication.publication_id == int(publication_id), - ) - .values(is_favorite=bool(is_favorite)) - ) - result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] - await db_session.commit() - return int(result.rowcount or 0) diff --git a/app/services/publications/types.py b/app/services/publications/types.py deleted file mode 100644 index c8b1ad1..0000000 --- a/app/services/publications/types.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime - -from app.services.publication_identifiers.types import DisplayIdentifier - - -@dataclass(frozen=True) -class PublicationListItem: - publication_id: int - scholar_profile_id: int - scholar_label: str - title: str - year: int | None - citation_count: int - venue_text: str | None - pub_url: str | None - pdf_url: str | None - is_read: bool - first_seen_at: datetime - is_new_in_latest_run: bool - is_favorite: bool = False - pdf_status: str = "untracked" - pdf_attempt_count: int = 0 - pdf_failure_reason: str | None = None - pdf_failure_detail: str | None = None - display_identifier: DisplayIdentifier | None = None - - -@dataclass(frozen=True) -class UnreadPublicationItem: - publication_id: int - scholar_profile_id: int - scholar_label: str - title: str - year: int | None - citation_count: int - venue_text: str | None - pub_url: str | None - pdf_url: str | None diff --git a/app/services/ingestion/safety.py b/app/services/run_safety.py similarity index 73% rename from app/services/ingestion/safety.py rename to app/services/run_safety.py index a8385a9..210c688 100644 --- a/app/services/ingestion/safety.py +++ b/app/services/run_safety.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from app.db.models import UserSetting @@ -18,7 +18,7 @@ _COUNTER_LAST_EVALUATED_RUN_ID = "last_evaluated_run_id" def _utcnow() -> datetime: - return datetime.now(UTC) + return datetime.now(timezone.utc) def _safe_int(value: Any, default: int = 0) -> int: @@ -41,8 +41,8 @@ def _normalize_datetime(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value.astimezone(UTC) + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) def _state_dict(settings: UserSetting) -> dict[str, Any]: @@ -100,7 +100,9 @@ def _recommended_action(reason: str | None) -> str | None: "increase request delay, and avoid repeated manual retries." ) if reason == COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD: - return "Network failures crossed the threshold. Verify connectivity and retry after cooldown." + return ( + "Network failures crossed the threshold. Verify connectivity and retry after cooldown." + ) return None @@ -144,59 +146,6 @@ def register_cooldown_blocked_start( return get_safety_state_payload(settings, now_utc=now) -def _update_run_counters( - *, - counters: dict[str, Any], - run_id: int, - blocked_failure_count: int, - network_failure_count: int, -) -> tuple[int, int]: - bounded_blocked_failures = max(0, int(blocked_failure_count)) - bounded_network_failures = max(0, int(network_failure_count)) - counters[_COUNTER_LAST_BLOCKED_FAILURE_COUNT] = bounded_blocked_failures - counters[_COUNTER_LAST_NETWORK_FAILURE_COUNT] = bounded_network_failures - counters[_COUNTER_LAST_EVALUATED_RUN_ID] = int(run_id) - counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS] = ( - int(counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS]) + 1 if bounded_blocked_failures > 0 else 0 - ) - counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS] = ( - int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1 if bounded_network_failures > 0 else 0 - ) - return bounded_blocked_failures, bounded_network_failures - - -def _resolve_cooldown_trigger( - *, - blocked_failures: int, - network_failures: int, - blocked_failure_threshold: int, - network_failure_threshold: int, - blocked_cooldown_seconds: int, - network_cooldown_seconds: int, -) -> tuple[str | None, int]: - if blocked_failures >= max(1, int(blocked_failure_threshold)): - return COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD, max(60, int(blocked_cooldown_seconds)) - if network_failures >= max(1, int(network_failure_threshold)): - return COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD, max(60, int(network_cooldown_seconds)) - return None, 0 - - -def _apply_cooldown_decision( - *, - settings: UserSetting, - counters: dict[str, Any], - now: datetime, - reason: str | None, - cooldown_seconds: int, -) -> None: - if reason is None: - clear_expired_cooldown(settings, now_utc=now) - return - settings.scrape_cooldown_reason = reason - settings.scrape_cooldown_until = now + timedelta(seconds=max(60, int(cooldown_seconds))) - counters[_COUNTER_COOLDOWN_ENTRY_COUNT] = int(counters[_COUNTER_COOLDOWN_ENTRY_COUNT]) + 1 - - def apply_run_safety_outcome( settings: UserSetting, *, @@ -211,27 +160,41 @@ def apply_run_safety_outcome( ) -> tuple[dict[str, Any], str | None]: now = now_utc or _utcnow() counters = _counters_from_state(settings) - blocked_failures, network_failures = _update_run_counters( - counters=counters, - run_id=run_id, - blocked_failure_count=blocked_failure_count, - network_failure_count=network_failure_count, + + bounded_blocked_failures = max(0, int(blocked_failure_count)) + bounded_network_failures = max(0, int(network_failure_count)) + + counters[_COUNTER_LAST_BLOCKED_FAILURE_COUNT] = bounded_blocked_failures + counters[_COUNTER_LAST_NETWORK_FAILURE_COUNT] = bounded_network_failures + counters[_COUNTER_LAST_EVALUATED_RUN_ID] = int(run_id) + counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS] = ( + int(counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS]) + 1 + if bounded_blocked_failures > 0 + else 0 ) - reason, cooldown_seconds = _resolve_cooldown_trigger( - blocked_failures=blocked_failures, - network_failures=network_failures, - blocked_failure_threshold=blocked_failure_threshold, - network_failure_threshold=network_failure_threshold, - blocked_cooldown_seconds=blocked_cooldown_seconds, - network_cooldown_seconds=network_cooldown_seconds, - ) - _apply_cooldown_decision( - settings=settings, - counters=counters, - now=now, - reason=reason, - cooldown_seconds=cooldown_seconds, + counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS] = ( + int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1 + if bounded_network_failures > 0 + else 0 ) + + reason: str | None = None + cooldown_seconds = 0 + + if bounded_blocked_failures >= max(1, int(blocked_failure_threshold)): + reason = COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD + cooldown_seconds = max(60, int(blocked_cooldown_seconds)) + elif bounded_network_failures >= max(1, int(network_failure_threshold)): + reason = COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD + cooldown_seconds = max(60, int(network_cooldown_seconds)) + + if reason is not None: + settings.scrape_cooldown_reason = reason + settings.scrape_cooldown_until = now + timedelta(seconds=cooldown_seconds) + counters[_COUNTER_COOLDOWN_ENTRY_COUNT] = int(counters[_COUNTER_COOLDOWN_ENTRY_COUNT]) + 1 + else: + clear_expired_cooldown(settings, now_utc=now) + settings.scrape_safety_state = counters return get_safety_state_payload(settings, now_utc=now), reason diff --git a/app/services/runs.py b/app/services/runs.py new file mode 100644 index 0000000..368815f --- /dev/null +++ b/app/services/runs.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sqlalchemy import and_, case, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + IngestionQueueItem, + RunStatus, + RunTriggerType, + ScholarProfile, +) +from app.services import continuation_queue as queue_service + +QUEUE_STATUS_QUEUED = "queued" +QUEUE_STATUS_RETRYING = "retrying" +QUEUE_STATUS_DROPPED = "dropped" + + +@dataclass(frozen=True) +class QueueListItem: + id: int + scholar_profile_id: int + scholar_label: str + status: str + reason: str + dropped_reason: str | None + attempt_count: int + resume_cstart: int + next_attempt_dt: datetime | None + updated_at: datetime + last_error: str | None + last_run_id: int | None + + +@dataclass(frozen=True) +class QueueClearResult: + queue_item_id: int + previous_status: str + + +class QueueTransitionError(RuntimeError): + def __init__(self, *, code: str, message: str, current_status: str) -> None: + super().__init__(message) + self.code = code + self.message = message + self.current_status = current_status + + +def _safe_int(value: object, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _summary_dict(error_log: object) -> dict[str, Any]: + if not isinstance(error_log, dict): + return {} + summary = error_log.get("summary") + if not isinstance(summary, dict): + return {} + return summary + + +def _summary_int_dict(summary: dict[str, Any], key: str) -> dict[str, int]: + value = summary.get(key) + if not isinstance(value, dict): + return {} + return { + str(item_key): _safe_int(item_value, 0) + for item_key, item_value in value.items() + if isinstance(item_key, str) + } + + +def _summary_bool_dict(summary: dict[str, Any], key: str) -> dict[str, bool]: + value = summary.get(key) + if not isinstance(value, dict): + return {} + return { + str(item_key): bool(item_value) + for item_key, item_value in value.items() + if isinstance(item_key, str) + } + + +def extract_run_summary(error_log: object) -> dict[str, Any]: + summary = _summary_dict(error_log) + return { + "succeeded_count": _safe_int(summary.get("succeeded_count", 0)), + "failed_count": _safe_int(summary.get("failed_count", 0)), + "partial_count": _safe_int(summary.get("partial_count", 0)), + "failed_state_counts": _summary_int_dict(summary, "failed_state_counts"), + "failed_reason_counts": _summary_int_dict(summary, "failed_reason_counts"), + "scrape_failure_counts": _summary_int_dict(summary, "scrape_failure_counts"), + "retry_counts": { + "retries_scheduled_count": _safe_int( + ( + summary.get("retry_counts", {}).get("retries_scheduled_count") + if isinstance(summary.get("retry_counts"), dict) + else 0 + ), + 0, + ), + "scholars_with_retries_count": _safe_int( + ( + summary.get("retry_counts", {}).get("scholars_with_retries_count") + if isinstance(summary.get("retry_counts"), dict) + else 0 + ), + 0, + ), + "retry_exhausted_count": _safe_int( + ( + summary.get("retry_counts", {}).get("retry_exhausted_count") + if isinstance(summary.get("retry_counts"), dict) + else 0 + ), + 0, + ), + }, + "alert_thresholds": _summary_int_dict(summary, "alert_thresholds"), + "alert_flags": _summary_bool_dict(summary, "alert_flags"), + } + + +async def list_recent_runs_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 20, +) -> list[CrawlRun]: + result = await db_session.execute( + select(CrawlRun) + .where(CrawlRun.user_id == user_id) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + +async def list_runs_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 100, + failed_only: bool = False, +) -> list[CrawlRun]: + stmt = ( + select(CrawlRun) + .where(CrawlRun.user_id == user_id) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(limit) + ) + if failed_only: + stmt = stmt.where( + CrawlRun.status.in_([RunStatus.FAILED, RunStatus.PARTIAL_FAILURE]) + ) + result = await db_session.execute(stmt) + return list(result.scalars().all()) + + +async def get_run_for_user( + db_session: AsyncSession, + *, + user_id: int, + run_id: int, +) -> CrawlRun | None: + result = await db_session.execute( + select(CrawlRun).where( + CrawlRun.user_id == user_id, + CrawlRun.id == run_id, + ) + ) + return result.scalar_one_or_none() + + +async def get_manual_run_by_idempotency_key( + db_session: AsyncSession, + *, + user_id: int, + idempotency_key: str, +) -> CrawlRun | None: + result = await db_session.execute( + select(CrawlRun) + .where( + CrawlRun.user_id == user_id, + CrawlRun.trigger_type == RunTriggerType.MANUAL, + or_( + CrawlRun.idempotency_key == idempotency_key, + CrawlRun.error_log["meta"]["idempotency_key"].astext == idempotency_key, + ), + ) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def list_queue_items_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 200, +) -> list[QueueListItem]: + result = await db_session.execute( + select( + IngestionQueueItem.id, + IngestionQueueItem.scholar_profile_id, + ScholarProfile.display_name, + ScholarProfile.scholar_id, + IngestionQueueItem.status, + IngestionQueueItem.reason, + IngestionQueueItem.dropped_reason, + IngestionQueueItem.attempt_count, + IngestionQueueItem.resume_cstart, + IngestionQueueItem.next_attempt_dt, + IngestionQueueItem.updated_at, + IngestionQueueItem.last_error, + IngestionQueueItem.last_run_id, + ) + .join( + ScholarProfile, + and_( + ScholarProfile.id == IngestionQueueItem.scholar_profile_id, + ScholarProfile.user_id == IngestionQueueItem.user_id, + ), + ) + .where(IngestionQueueItem.user_id == user_id) + .order_by( + case((IngestionQueueItem.status == "dropped", 1), else_=0).asc(), + IngestionQueueItem.next_attempt_dt.asc(), + IngestionQueueItem.id.asc(), + ) + .limit(limit) + ) + + items: list[QueueListItem] = [] + for row in result.all(): + ( + item_id, + scholar_profile_id, + display_name, + scholar_id, + status, + reason, + dropped_reason, + attempt_count, + resume_cstart, + next_attempt_dt, + updated_at, + last_error, + last_run_id, + ) = row + items.append( + QueueListItem( + id=int(item_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + status=str(status), + reason=str(reason), + dropped_reason=dropped_reason, + attempt_count=int(attempt_count or 0), + resume_cstart=int(resume_cstart or 0), + next_attempt_dt=next_attempt_dt, + updated_at=updated_at, + last_error=last_error, + last_run_id=int(last_run_id) if last_run_id is not None else None, + ) + ) + return items + + +async def get_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueListItem | None: + result = await db_session.execute( + select( + IngestionQueueItem.id, + IngestionQueueItem.scholar_profile_id, + ScholarProfile.display_name, + ScholarProfile.scholar_id, + IngestionQueueItem.status, + IngestionQueueItem.reason, + IngestionQueueItem.dropped_reason, + IngestionQueueItem.attempt_count, + IngestionQueueItem.resume_cstart, + IngestionQueueItem.next_attempt_dt, + IngestionQueueItem.updated_at, + IngestionQueueItem.last_error, + IngestionQueueItem.last_run_id, + ) + .join( + ScholarProfile, + and_( + ScholarProfile.id == IngestionQueueItem.scholar_profile_id, + ScholarProfile.user_id == IngestionQueueItem.user_id, + ), + ) + .where( + IngestionQueueItem.user_id == user_id, + IngestionQueueItem.id == queue_item_id, + ) + .limit(1) + ) + row = result.one_or_none() + if row is None: + return None + ( + item_id, + scholar_profile_id, + display_name, + scholar_id, + status, + reason, + dropped_reason, + attempt_count, + resume_cstart, + next_attempt_dt, + updated_at, + last_error, + last_run_id, + ) = row + return QueueListItem( + id=int(item_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + status=str(status), + reason=str(reason), + dropped_reason=dropped_reason, + attempt_count=int(attempt_count or 0), + resume_cstart=int(resume_cstart or 0), + next_attempt_dt=next_attempt_dt, + updated_at=updated_at, + last_error=last_error, + last_run_id=int(last_run_id) if last_run_id is not None else None, + ) + + +async def retry_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueListItem | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.id == queue_item_id, + IngestionQueueItem.user_id == user_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status == QUEUE_STATUS_QUEUED: + raise QueueTransitionError( + code="queue_item_already_queued", + message="Queue item is already queued.", + current_status=item.status, + ) + if item.status == QUEUE_STATUS_RETRYING: + raise QueueTransitionError( + code="queue_item_retrying", + message="Queue item is currently retrying.", + current_status=item.status, + ) + + await queue_service.mark_queued_now( + db_session, + job_id=item.id, + reason="manual_retry", + reset_attempt_count=(item.status == QUEUE_STATUS_DROPPED), + ) + await db_session.commit() + + return await get_queue_item_for_user( + db_session, + user_id=user_id, + queue_item_id=queue_item_id, + ) + + +async def drop_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueListItem | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.id == queue_item_id, + IngestionQueueItem.user_id == user_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status == QUEUE_STATUS_DROPPED: + raise QueueTransitionError( + code="queue_item_already_dropped", + message="Queue item is already dropped.", + current_status=item.status, + ) + + await queue_service.mark_dropped( + db_session, + job_id=int(item.id), + reason="manual_drop", + ) + await db_session.commit() + return await get_queue_item_for_user( + db_session, + user_id=user_id, + queue_item_id=queue_item_id, + ) + + +async def clear_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueClearResult | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.id == queue_item_id, + IngestionQueueItem.user_id == user_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status != QUEUE_STATUS_DROPPED: + raise QueueTransitionError( + code="queue_item_not_dropped", + message="Queue item can only be cleared after it is dropped.", + current_status=item.status, + ) + + item_id = int(item.id) + previous_status = str(item.status) + deleted = await queue_service.delete_job_by_id( + db_session, + job_id=item_id, + ) + await db_session.commit() + if not deleted: + return None + return QueueClearResult( + queue_item_id=item_id, + previous_status=previous_status, + ) + + +async def queue_status_counts_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> dict[str, int]: + result = await db_session.execute( + select( + IngestionQueueItem.status, + func.count(IngestionQueueItem.id), + ) + .where(IngestionQueueItem.user_id == user_id) + .group_by(IngestionQueueItem.status) + ) + counts: dict[str, int] = {"queued": 0, "retrying": 0, "dropped": 0} + for status, count in result.all(): + counts[str(status)] = int(count or 0) + return counts diff --git a/app/services/runs/__init__.py b/app/services/runs/__init__.py deleted file mode 100644 index 5bed6a2..0000000 --- a/app/services/runs/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from app.services.runs.application import ( - QUEUE_STATUS_DROPPED as QUEUE_STATUS_DROPPED, -) -from app.services.runs.application import ( - QUEUE_STATUS_QUEUED as QUEUE_STATUS_QUEUED, -) -from app.services.runs.application import ( - QUEUE_STATUS_RETRYING as QUEUE_STATUS_RETRYING, -) -from app.services.runs.application import ( - QueueClearResult as QueueClearResult, -) -from app.services.runs.application import ( - QueueListItem as QueueListItem, -) -from app.services.runs.application import ( - QueueTransitionError as QueueTransitionError, -) -from app.services.runs.application import ( - clear_queue_item_for_user as clear_queue_item_for_user, -) -from app.services.runs.application import ( - drop_queue_item_for_user as drop_queue_item_for_user, -) -from app.services.runs.application import ( - extract_run_summary as extract_run_summary, -) -from app.services.runs.application import ( - get_manual_run_by_idempotency_key as get_manual_run_by_idempotency_key, -) -from app.services.runs.application import ( - get_queue_item_for_user as get_queue_item_for_user, -) -from app.services.runs.application import ( - get_run_for_user as get_run_for_user, -) -from app.services.runs.application import ( - list_queue_items_for_user as list_queue_items_for_user, -) -from app.services.runs.application import ( - list_recent_runs_for_user as list_recent_runs_for_user, -) -from app.services.runs.application import ( - list_runs_for_user as list_runs_for_user, -) -from app.services.runs.application import ( - queue_status_counts_for_user as queue_status_counts_for_user, -) -from app.services.runs.application import ( - retry_queue_item_for_user as retry_queue_item_for_user, -) diff --git a/app/services/runs/application.py b/app/services/runs/application.py deleted file mode 100644 index e0b464a..0000000 --- a/app/services/runs/application.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from app.services.runs.queue_service import ( - clear_queue_item_for_user, - drop_queue_item_for_user, - get_queue_item_for_user, - list_queue_items_for_user, - queue_status_counts_for_user, - retry_queue_item_for_user, -) -from app.services.runs.runs_service import ( - get_manual_run_by_idempotency_key, - get_run_for_user, - list_recent_runs_for_user, - list_runs_for_user, -) -from app.services.runs.summary import extract_run_summary -from app.services.runs.types import ( - QUEUE_STATUS_DROPPED, - QUEUE_STATUS_QUEUED, - QUEUE_STATUS_RETRYING, - QueueClearResult, - QueueListItem, - QueueTransitionError, -) - -__all__ = [ - "QUEUE_STATUS_DROPPED", - "QUEUE_STATUS_QUEUED", - "QUEUE_STATUS_RETRYING", - "QueueClearResult", - "QueueListItem", - "QueueTransitionError", - "clear_queue_item_for_user", - "drop_queue_item_for_user", - "extract_run_summary", - "get_manual_run_by_idempotency_key", - "get_queue_item_for_user", - "get_run_for_user", - "list_queue_items_for_user", - "list_recent_runs_for_user", - "list_runs_for_user", - "queue_status_counts_for_user", - "retry_queue_item_for_user", -] diff --git a/app/services/runs/events.py b/app/services/runs/events.py deleted file mode 100644 index dbcac2d..0000000 --- a/app/services/runs/events.py +++ /dev/null @@ -1,87 +0,0 @@ -import asyncio -import json -import logging -from collections.abc import AsyncGenerator -from typing import Any - -from app.logging_utils import structured_log - -logger = logging.getLogger(__name__) - -_SUBSCRIBER_QUEUE_MAXSIZE = 256 - - -class RunEventPublisher: - def __init__(self) -> None: - # Maps run_id to a set of subscriber queues - self._subscribers: dict[int, set[asyncio.Queue]] = {} - - def subscribe(self, run_id: int) -> asyncio.Queue: - if run_id not in self._subscribers: - self._subscribers[run_id] = set() - queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_MAXSIZE) - self._subscribers[run_id].add(queue) - structured_log( - logger, - "debug", - "runs.event_subscriber_added", - run_id=run_id, - subscriber_count=len(self._subscribers[run_id]), - ) - return queue - - def unsubscribe(self, run_id: int, queue: asyncio.Queue) -> None: - if run_id in self._subscribers: - self._subscribers[run_id].discard(queue) - if not self._subscribers[run_id]: - self._subscribers.pop(run_id, None) - - async def publish(self, run_id: int, event_type: str, data: dict[str, Any]) -> None: - if run_id not in self._subscribers: - return - - message = {"type": event_type, "data": data} - - # Fan-out to all active subscribers for this run - for queue in list(self._subscribers[run_id]): - try: - queue.put_nowait(message) - except asyncio.QueueFull: - structured_log( - logger, - "warning", - "runs.event_subscriber_queue_full", - run_id=run_id, - ) - self._subscribers[run_id].discard(queue) - - async def publish_run_complete(self, run_id: int) -> None: - await self.publish(run_id, "run_complete", {}) - - -run_events = RunEventPublisher() - - -async def event_generator(run_id: int) -> AsyncGenerator[str, None]: - queue = run_events.subscribe(run_id) - try: - while True: - # Wait for a new event - message = await queue.get() - event_type = message["type"] - if event_type == "run_complete": - yield f"event: {event_type}\ndata: {{}}\n\n" - break - # Server-Sent Events format: "event: \ndata: \n\n" - data_str = json.dumps(message["data"]) - yield f"event: {event_type}\ndata: {data_str}\n\n" - except asyncio.CancelledError: - structured_log( - logger, - "debug", - "runs.event_stream_disconnected", - run_id=run_id, - ) - raise - finally: - run_events.unsubscribe(run_id, queue) diff --git a/app/services/runs/queue_queries.py b/app/services/runs/queue_queries.py deleted file mode 100644 index 2d1127a..0000000 --- a/app/services/runs/queue_queries.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from sqlalchemy import and_, select - -from app.db.models import IngestionQueueItem, ScholarProfile -from app.services.runs.types import QueueListItem - - -def queue_item_columns() -> tuple: - return ( - IngestionQueueItem.id, - IngestionQueueItem.scholar_profile_id, - ScholarProfile.display_name, - ScholarProfile.scholar_id, - IngestionQueueItem.status, - IngestionQueueItem.reason, - IngestionQueueItem.dropped_reason, - IngestionQueueItem.attempt_count, - IngestionQueueItem.resume_cstart, - IngestionQueueItem.next_attempt_dt, - IngestionQueueItem.updated_at, - IngestionQueueItem.last_error, - IngestionQueueItem.last_run_id, - ) - - -def queue_item_select(*, user_id: int): - return ( - select(*queue_item_columns()) - .join( - ScholarProfile, - and_( - ScholarProfile.id == IngestionQueueItem.scholar_profile_id, - ScholarProfile.user_id == IngestionQueueItem.user_id, - ), - ) - .where(IngestionQueueItem.user_id == user_id) - ) - - -def queue_list_item_from_row(row: Any) -> QueueListItem: - ( - item_id, - scholar_profile_id, - display_name, - scholar_id, - status, - reason, - dropped_reason, - attempt_count, - resume_cstart, - next_attempt_dt, - updated_at, - last_error, - last_run_id, - ) = row - return QueueListItem( - id=int(item_id), - scholar_profile_id=int(scholar_profile_id), - scholar_label=(display_name or scholar_id), - status=str(status), - reason=str(reason), - dropped_reason=dropped_reason, - attempt_count=int(attempt_count or 0), - resume_cstart=int(resume_cstart or 0), - next_attempt_dt=next_attempt_dt, - updated_at=updated_at, - last_error=last_error, - last_run_id=int(last_run_id) if last_run_id is not None else None, - ) diff --git a/app/services/runs/queue_service.py b/app/services/runs/queue_service.py deleted file mode 100644 index 855171f..0000000 --- a/app/services/runs/queue_service.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import case, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import IngestionQueueItem -from app.services.ingestion import queue as queue_mutations -from app.services.runs.queue_queries import queue_item_select, queue_list_item_from_row -from app.services.runs.types import ( - QUEUE_STATUS_DROPPED, - QUEUE_STATUS_QUEUED, - QUEUE_STATUS_RETRYING, - QueueClearResult, - QueueListItem, - QueueTransitionError, -) - - -async def list_queue_items_for_user( - db_session: AsyncSession, - *, - user_id: int, - limit: int = 200, -) -> list[QueueListItem]: - result = await db_session.execute( - queue_item_select(user_id=user_id) - .order_by( - case((IngestionQueueItem.status == QUEUE_STATUS_DROPPED, 1), else_=0).asc(), - IngestionQueueItem.next_attempt_dt.asc(), - IngestionQueueItem.id.asc(), - ) - .limit(limit) - ) - return [queue_list_item_from_row(row) for row in result.all()] - - -async def get_queue_item_for_user( - db_session: AsyncSession, - *, - user_id: int, - queue_item_id: int, -) -> QueueListItem | None: - result = await db_session.execute( - queue_item_select(user_id=user_id).where(IngestionQueueItem.id == queue_item_id).limit(1) - ) - row = result.one_or_none() - if row is None: - return None - return queue_list_item_from_row(row) - - -async def retry_queue_item_for_user( - db_session: AsyncSession, - *, - user_id: int, - queue_item_id: int, -) -> QueueListItem | None: - result = await db_session.execute( - select(IngestionQueueItem).where( - IngestionQueueItem.id == queue_item_id, - IngestionQueueItem.user_id == user_id, - ) - ) - item = result.scalar_one_or_none() - if item is None: - return None - if item.status == QUEUE_STATUS_QUEUED: - raise QueueTransitionError( - code="queue_item_already_queued", - message="Queue item is already queued.", - current_status=item.status, - ) - if item.status == QUEUE_STATUS_RETRYING: - raise QueueTransitionError( - code="queue_item_retrying", - message="Queue item is currently retrying.", - current_status=item.status, - ) - - await queue_mutations.mark_queued_now( - db_session, - job_id=item.id, - reason="manual_retry", - reset_attempt_count=(item.status == QUEUE_STATUS_DROPPED), - ) - await db_session.commit() - return await get_queue_item_for_user( - db_session, - user_id=user_id, - queue_item_id=queue_item_id, - ) - - -async def drop_queue_item_for_user( - db_session: AsyncSession, - *, - user_id: int, - queue_item_id: int, -) -> QueueListItem | None: - result = await db_session.execute( - select(IngestionQueueItem).where( - IngestionQueueItem.id == queue_item_id, - IngestionQueueItem.user_id == user_id, - ) - ) - item = result.scalar_one_or_none() - if item is None: - return None - if item.status == QUEUE_STATUS_DROPPED: - raise QueueTransitionError( - code="queue_item_already_dropped", - message="Queue item is already dropped.", - current_status=item.status, - ) - - await queue_mutations.mark_dropped( - db_session, - job_id=int(item.id), - reason="manual_drop", - ) - await db_session.commit() - return await get_queue_item_for_user( - db_session, - user_id=user_id, - queue_item_id=queue_item_id, - ) - - -async def clear_queue_item_for_user( - db_session: AsyncSession, - *, - user_id: int, - queue_item_id: int, -) -> QueueClearResult | None: - result = await db_session.execute( - select(IngestionQueueItem).where( - IngestionQueueItem.id == queue_item_id, - IngestionQueueItem.user_id == user_id, - ) - ) - item = result.scalar_one_or_none() - if item is None: - return None - if item.status != QUEUE_STATUS_DROPPED: - raise QueueTransitionError( - code="queue_item_not_dropped", - message="Queue item can only be cleared after it is dropped.", - current_status=item.status, - ) - - item_id = int(item.id) - previous_status = str(item.status) - deleted = await queue_mutations.delete_job_by_id(db_session, job_id=item_id) - await db_session.commit() - if not deleted: - return None - return QueueClearResult(queue_item_id=item_id, previous_status=previous_status) - - -async def queue_status_counts_for_user( - db_session: AsyncSession, - *, - user_id: int, -) -> dict[str, int]: - result = await db_session.execute( - select( - IngestionQueueItem.status, - func.count(IngestionQueueItem.id), - ) - .where(IngestionQueueItem.user_id == user_id) - .group_by(IngestionQueueItem.status) - ) - counts: dict[str, int] = { - QUEUE_STATUS_QUEUED: 0, - QUEUE_STATUS_RETRYING: 0, - QUEUE_STATUS_DROPPED: 0, - } - for status, count in result.all(): - counts[str(status)] = int(count or 0) - return counts diff --git a/app/services/runs/runs_service.py b/app/services/runs/runs_service.py deleted file mode 100644 index 72901ec..0000000 --- a/app/services/runs/runs_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import CrawlRun, RunStatus, RunTriggerType - - -async def list_recent_runs_for_user( - db_session: AsyncSession, - *, - user_id: int, - limit: int = 20, -) -> list[CrawlRun]: - result = await db_session.execute( - select(CrawlRun) - .where(CrawlRun.user_id == user_id) - .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) - .limit(limit) - ) - return list(result.scalars().all()) - - -async def list_runs_for_user( - db_session: AsyncSession, - *, - user_id: int, - limit: int = 100, - failed_only: bool = False, -) -> list[CrawlRun]: - stmt = ( - select(CrawlRun) - .where(CrawlRun.user_id == user_id) - .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) - .limit(limit) - ) - if failed_only: - stmt = stmt.where(CrawlRun.status.in_([RunStatus.FAILED, RunStatus.PARTIAL_FAILURE])) - result = await db_session.execute(stmt) - return list(result.scalars().all()) - - -async def get_run_for_user( - db_session: AsyncSession, - *, - user_id: int, - run_id: int, -) -> CrawlRun | None: - result = await db_session.execute( - select(CrawlRun).where( - CrawlRun.user_id == user_id, - CrawlRun.id == run_id, - ) - ) - return result.scalar_one_or_none() - - -async def get_manual_run_by_idempotency_key( - db_session: AsyncSession, - *, - user_id: int, - idempotency_key: str, -) -> CrawlRun | None: - result = await db_session.execute( - select(CrawlRun) - .where( - CrawlRun.user_id == user_id, - CrawlRun.trigger_type == RunTriggerType.MANUAL, - or_( - CrawlRun.idempotency_key == idempotency_key, - CrawlRun.error_log["meta"]["idempotency_key"].astext == idempotency_key, - ), - ) - .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) - .limit(1) - ) - return result.scalar_one_or_none() diff --git a/app/services/runs/summary.py b/app/services/runs/summary.py deleted file mode 100644 index 58cb7e3..0000000 --- a/app/services/runs/summary.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from typing import Any, cast - - -def _safe_int(value: object, default: int = 0) -> int: - try: - return int(cast(Any, value)) - except (TypeError, ValueError): - return default - - -def _summary_dict(error_log: object) -> dict[str, Any]: - if not isinstance(error_log, dict): - return {} - summary = error_log.get("summary") - if not isinstance(summary, dict): - return {} - return summary - - -def _summary_int_dict(summary: dict[str, Any], key: str) -> dict[str, int]: - value = summary.get(key) - if not isinstance(value, dict): - return {} - return { - str(item_key): _safe_int(item_value, 0) for item_key, item_value in value.items() if isinstance(item_key, str) - } - - -def _summary_bool_dict(summary: dict[str, Any], key: str) -> dict[str, bool]: - value = summary.get(key) - if not isinstance(value, dict): - return {} - return {str(item_key): bool(item_value) for item_key, item_value in value.items() if isinstance(item_key, str)} - - -def _retry_counts(summary: dict[str, Any]) -> dict[str, int]: - retry_counts = summary.get("retry_counts") - if not isinstance(retry_counts, dict): - retry_counts = {} - return { - "retries_scheduled_count": _safe_int( - retry_counts.get("retries_scheduled_count", 0), - 0, - ), - "scholars_with_retries_count": _safe_int( - retry_counts.get("scholars_with_retries_count", 0), - 0, - ), - "retry_exhausted_count": _safe_int( - retry_counts.get("retry_exhausted_count", 0), - 0, - ), - } - - -def extract_run_summary(error_log: object) -> dict[str, Any]: - summary = _summary_dict(error_log) - return { - "succeeded_count": _safe_int(summary.get("succeeded_count", 0)), - "failed_count": _safe_int(summary.get("failed_count", 0)), - "partial_count": _safe_int(summary.get("partial_count", 0)), - "failed_state_counts": _summary_int_dict(summary, "failed_state_counts"), - "failed_reason_counts": _summary_int_dict(summary, "failed_reason_counts"), - "scrape_failure_counts": _summary_int_dict(summary, "scrape_failure_counts"), - "retry_counts": _retry_counts(summary), - "alert_thresholds": _summary_int_dict(summary, "alert_thresholds"), - "alert_flags": _summary_bool_dict(summary, "alert_flags"), - } diff --git a/app/services/runs/types.py b/app/services/runs/types.py deleted file mode 100644 index 2524269..0000000 --- a/app/services/runs/types.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime - -QUEUE_STATUS_QUEUED = "queued" -QUEUE_STATUS_RETRYING = "retrying" -QUEUE_STATUS_DROPPED = "dropped" - - -@dataclass(frozen=True) -class QueueListItem: - id: int - scholar_profile_id: int - scholar_label: str - status: str - reason: str - dropped_reason: str | None - attempt_count: int - resume_cstart: int - next_attempt_dt: datetime | None - updated_at: datetime - last_error: str | None - last_run_id: int | None - - -@dataclass(frozen=True) -class QueueClearResult: - queue_item_id: int - previous_status: str - - -class QueueTransitionError(RuntimeError): - def __init__(self, *, code: str, message: str, current_status: str) -> None: - super().__init__(message) - self.code = code - self.message = message - self.current_status = current_status diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..96dd5f1 --- /dev/null +++ b/app/services/scheduler.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + QueueItemStatus, + RunTriggerType, + ScholarProfile, + User, + UserSetting, +) +from app.db.session import get_session_factory +from app.services import continuation_queue as queue_service +from app.services.ingestion import ( + RunAlreadyInProgressError, + RunBlockedBySafetyPolicyError, + ScholarIngestionService, +) +from app.services.scholar_source import LiveScholarSource +from app.settings import settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _AutoRunCandidate: + user_id: int + run_interval_minutes: int + request_delay_seconds: int + cooldown_until: datetime | None + cooldown_reason: str | None + + +class SchedulerService: + def __init__( + self, + *, + enabled: bool, + tick_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + max_pages_per_scholar: int, + page_size: int, + continuation_queue_enabled: bool, + continuation_base_delay_seconds: int, + continuation_max_delay_seconds: int, + continuation_max_attempts: int, + queue_batch_size: int, + ) -> None: + self._enabled = enabled + self._tick_seconds = max(5, int(tick_seconds)) + self._network_error_retries = max(0, int(network_error_retries)) + self._retry_backoff_seconds = max(0.0, float(retry_backoff_seconds)) + self._max_pages_per_scholar = max(1, int(max_pages_per_scholar)) + self._page_size = max(1, int(page_size)) + self._continuation_queue_enabled = bool(continuation_queue_enabled) + self._continuation_base_delay_seconds = max(1, int(continuation_base_delay_seconds)) + self._continuation_max_delay_seconds = max( + self._continuation_base_delay_seconds, + int(continuation_max_delay_seconds), + ) + self._continuation_max_attempts = max(1, int(continuation_max_attempts)) + self._queue_batch_size = max(1, int(queue_batch_size)) + self._task: asyncio.Task[None] | None = None + self._source = LiveScholarSource() + + async def start(self) -> None: + if not self._enabled: + logger.info( + "scheduler.disabled", + extra={ + "event": "scheduler.disabled", + }, + ) + return + if self._task is not None: + return + self._task = asyncio.create_task(self._run_loop(), name="scholarr-scheduler") + logger.info( + "scheduler.started", + extra={ + "event": "scheduler.started", + "tick_seconds": self._tick_seconds, + "network_error_retries": self._network_error_retries, + "retry_backoff_seconds": self._retry_backoff_seconds, + "max_pages_per_scholar": self._max_pages_per_scholar, + "page_size": self._page_size, + "continuation_queue_enabled": self._continuation_queue_enabled, + "continuation_base_delay_seconds": self._continuation_base_delay_seconds, + "continuation_max_delay_seconds": self._continuation_max_delay_seconds, + "continuation_max_attempts": self._continuation_max_attempts, + "queue_batch_size": self._queue_batch_size, + }, + ) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + finally: + self._task = None + logger.info("scheduler.stopped", extra={"event": "scheduler.stopped"}) + + async def _run_loop(self) -> None: + while True: + try: + await self._tick_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception( + "scheduler.tick_failed", + extra={ + "event": "scheduler.tick_failed", + }, + ) + await asyncio.sleep(float(self._tick_seconds)) + + async def _tick_once(self) -> None: + if self._continuation_queue_enabled: + await self._drain_continuation_queue() + candidates = await self._load_candidates() + if not candidates: + return + now = datetime.now(timezone.utc) + for candidate in candidates: + if not await self._is_due(candidate, now=now): + continue + await self._run_candidate(candidate) + + async def _load_candidates(self) -> list[_AutoRunCandidate]: + if not settings.ingestion_automation_allowed: + return [] + + session_factory = get_session_factory() + async with session_factory() as session: + result = await session.execute( + select( + UserSetting.user_id, + UserSetting.run_interval_minutes, + UserSetting.request_delay_seconds, + UserSetting.scrape_cooldown_until, + UserSetting.scrape_cooldown_reason, + ) + .join(User, User.id == UserSetting.user_id) + .where( + User.is_active.is_(True), + UserSetting.auto_run_enabled.is_(True), + ) + .order_by(UserSetting.user_id.asc()) + ) + rows = result.all() + now_utc = datetime.now(timezone.utc) + candidates: list[_AutoRunCandidate] = [] + for user_id, run_interval_minutes, request_delay_seconds, cooldown_until, cooldown_reason in rows: + if cooldown_until is not None and cooldown_until.tzinfo is None: + cooldown_until = cooldown_until.replace(tzinfo=timezone.utc) + if cooldown_until is not None and cooldown_until > now_utc: + logger.info( + "scheduler.run_skipped_safety_cooldown_precheck", + extra={ + "event": "scheduler.run_skipped_safety_cooldown_precheck", + "user_id": int(user_id), + "reason": cooldown_reason, + "cooldown_until": cooldown_until, + "cooldown_remaining_seconds": int((cooldown_until - now_utc).total_seconds()), + "metric_name": "scheduler_run_skipped_safety_cooldown_total", + "metric_value": 1, + }, + ) + continue + candidates.append( + _AutoRunCandidate( + user_id=int(user_id), + run_interval_minutes=int(run_interval_minutes), + request_delay_seconds=int(request_delay_seconds), + cooldown_until=cooldown_until, + cooldown_reason=(str(cooldown_reason).strip() if cooldown_reason else None), + ) + ) + return candidates + + async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool: + session_factory = get_session_factory() + async with session_factory() as session: + result = await session.execute( + select(CrawlRun.start_dt) + .where( + CrawlRun.user_id == candidate.user_id, + ) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(1) + ) + last_run = result.scalar_one_or_none() + + if last_run is None: + return True + + next_due_dt = last_run + timedelta( + minutes=candidate.run_interval_minutes + ) + return now >= next_due_dt + + async def _run_candidate(self, candidate: _AutoRunCandidate) -> None: + session_factory = get_session_factory() + async with session_factory() as session: + ingestion = ScholarIngestionService(source=self._source) + try: + run_summary = await ingestion.run_for_user( + session, + user_id=candidate.user_id, + trigger_type=RunTriggerType.SCHEDULED, + request_delay_seconds=candidate.request_delay_seconds, + network_error_retries=self._network_error_retries, + retry_backoff_seconds=self._retry_backoff_seconds, + max_pages_per_scholar=self._max_pages_per_scholar, + page_size=self._page_size, + auto_queue_continuations=self._continuation_queue_enabled, + queue_delay_seconds=self._continuation_base_delay_seconds, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) + except RunAlreadyInProgressError: + await session.rollback() + logger.info( + "scheduler.run_skipped_locked", + extra={ + "event": "scheduler.run_skipped_locked", + "user_id": candidate.user_id, + }, + ) + return + except RunBlockedBySafetyPolicyError as exc: + await session.rollback() + logger.info( + "scheduler.run_skipped_safety_cooldown", + extra={ + "event": "scheduler.run_skipped_safety_cooldown", + "user_id": candidate.user_id, + "reason": exc.safety_state.get("cooldown_reason"), + "cooldown_until": exc.safety_state.get("cooldown_until"), + "cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"), + "metric_name": "scheduler_run_skipped_safety_cooldown_total", + "metric_value": 1, + }, + ) + return + except Exception: + await session.rollback() + logger.exception( + "scheduler.run_failed", + extra={ + "event": "scheduler.run_failed", + "user_id": candidate.user_id, + }, + ) + return + + logger.info( + "scheduler.run_completed", + extra={ + "event": "scheduler.run_completed", + "user_id": candidate.user_id, + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + "scholar_count": run_summary.scholar_count, + "new_publication_count": run_summary.new_publication_count, + }, + ) + + async def _drain_continuation_queue(self) -> None: + now = datetime.now(timezone.utc) + session_factory = get_session_factory() + async with session_factory() as session: + jobs = await queue_service.list_due_jobs( + session, + now=now, + limit=self._queue_batch_size, + ) + for job in jobs: + await self._run_queue_job(job) + + async def _run_queue_job(self, job: queue_service.ContinuationQueueJob) -> None: + if job.attempt_count >= self._continuation_max_attempts: + session_factory = get_session_factory() + async with session_factory() as session: + dropped = await queue_service.mark_dropped( + session, + job_id=job.id, + reason="max_attempts_reached", + ) + await session.commit() + if dropped is not None: + logger.warning( + "scheduler.queue_item_dropped_max_attempts", + extra={ + "event": "scheduler.queue_item_dropped_max_attempts", + "queue_item_id": job.id, + "user_id": job.user_id, + "scholar_profile_id": job.scholar_profile_id, + "attempt_count": job.attempt_count, + "max_attempts": self._continuation_max_attempts, + }, + ) + return + + session_factory = get_session_factory() + async with session_factory() as session: + queue_item = await queue_service.mark_retrying(session, job_id=job.id) + if queue_item is None: + await session.commit() + return + if queue_item.status == QueueItemStatus.DROPPED.value: + await session.commit() + return + await session.commit() + + async with session_factory() as session: + scholar_result = await session.execute( + select(ScholarProfile.id).where( + ScholarProfile.user_id == job.user_id, + ScholarProfile.id == job.scholar_profile_id, + ScholarProfile.is_enabled.is_(True), + ) + ) + scholar_id = scholar_result.scalar_one_or_none() + if scholar_id is None: + dropped = await queue_service.mark_dropped( + session, + job_id=job.id, + reason="scholar_unavailable", + ) + await session.commit() + if dropped is not None: + logger.info( + "scheduler.queue_item_dropped_scholar_unavailable", + extra={ + "event": "scheduler.queue_item_dropped_scholar_unavailable", + "queue_item_id": job.id, + "user_id": job.user_id, + "scholar_profile_id": job.scholar_profile_id, + }, + ) + return + + async with session_factory() as session: + request_delay_seconds = await self._load_request_delay_for_user( + session, + user_id=job.user_id, + ) + ingestion = ScholarIngestionService(source=self._source) + try: + run_summary = await ingestion.run_for_user( + session, + user_id=job.user_id, + trigger_type=RunTriggerType.SCHEDULED, + request_delay_seconds=request_delay_seconds, + network_error_retries=self._network_error_retries, + retry_backoff_seconds=self._retry_backoff_seconds, + max_pages_per_scholar=self._max_pages_per_scholar, + page_size=self._page_size, + scholar_profile_ids={job.scholar_profile_id}, + start_cstart_by_scholar_id={ + job.scholar_profile_id: job.resume_cstart, + }, + auto_queue_continuations=self._continuation_queue_enabled, + queue_delay_seconds=self._continuation_base_delay_seconds, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) + except RunAlreadyInProgressError: + await session.rollback() + async with session_factory() as recovery_session: + await queue_service.reschedule_job( + recovery_session, + job_id=job.id, + delay_seconds=max(self._tick_seconds, 15), + reason="user_run_lock_active", + error="run_already_in_progress", + ) + await recovery_session.commit() + logger.info( + "scheduler.queue_item_deferred_lock", + extra={ + "event": "scheduler.queue_item_deferred_lock", + "queue_item_id": job.id, + "user_id": job.user_id, + }, + ) + return + except RunBlockedBySafetyPolicyError as exc: + await session.rollback() + cooldown_remaining_seconds = max( + self._tick_seconds, + int(exc.safety_state.get("cooldown_remaining_seconds") or 0), + ) + async with session_factory() as recovery_session: + await queue_service.reschedule_job( + recovery_session, + job_id=job.id, + delay_seconds=max(self._tick_seconds, cooldown_remaining_seconds), + reason="scrape_safety_cooldown", + error=str(exc.message), + ) + await recovery_session.commit() + logger.info( + "scheduler.queue_item_deferred_safety_cooldown", + extra={ + "event": "scheduler.queue_item_deferred_safety_cooldown", + "queue_item_id": job.id, + "user_id": job.user_id, + "reason": exc.safety_state.get("cooldown_reason"), + "cooldown_remaining_seconds": cooldown_remaining_seconds, + "metric_name": "scheduler_queue_item_deferred_safety_cooldown_total", + "metric_value": 1, + }, + ) + return + except Exception as exc: + await session.rollback() + async with session_factory() as recovery_session: + queue_item = await queue_service.increment_attempt_count( + recovery_session, + job_id=job.id, + ) + if queue_item is None: + await recovery_session.commit() + return + if int(queue_item.attempt_count) >= self._continuation_max_attempts: + await queue_service.mark_dropped( + recovery_session, + job_id=job.id, + reason="scheduler_exception_max_attempts", + error=str(exc), + ) + await recovery_session.commit() + logger.warning( + "scheduler.queue_item_dropped_after_exception", + extra={ + "event": "scheduler.queue_item_dropped_after_exception", + "queue_item_id": job.id, + "user_id": job.user_id, + "attempt_count": queue_item.attempt_count, + }, + ) + return + delay_seconds = queue_service.compute_backoff_seconds( + base_seconds=self._continuation_base_delay_seconds, + attempt_count=int(queue_item.attempt_count), + max_seconds=self._continuation_max_delay_seconds, + ) + await queue_service.reschedule_job( + recovery_session, + job_id=job.id, + delay_seconds=delay_seconds, + reason="scheduler_exception", + error=str(exc), + ) + await recovery_session.commit() + logger.exception( + "scheduler.queue_item_run_failed", + extra={ + "event": "scheduler.queue_item_run_failed", + "queue_item_id": job.id, + "user_id": job.user_id, + }, + ) + return + + async with session_factory() as session: + # Failed-attempt budget should advance only when continuation execution fails. + if int(run_summary.failed_count) <= 0: + queue_item = await queue_service.reset_attempt_count( + session, + job_id=job.id, + ) + if queue_item is None: + await session.commit() + logger.info( + "scheduler.queue_item_resolved", + extra={ + "event": "scheduler.queue_item_resolved", + "queue_item_id": job.id, + "user_id": job.user_id, + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + return + await session.commit() + logger.info( + "scheduler.queue_item_progressed", + extra={ + "event": "scheduler.queue_item_progressed", + "queue_item_id": job.id, + "user_id": job.user_id, + "attempt_count": int(queue_item.attempt_count), + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + return + + queue_item = await queue_service.increment_attempt_count( + session, + job_id=job.id, + ) + if queue_item is None: + await session.commit() + logger.info( + "scheduler.queue_item_resolved", + extra={ + "event": "scheduler.queue_item_resolved", + "queue_item_id": job.id, + "user_id": job.user_id, + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + return + + if int(queue_item.attempt_count) >= self._continuation_max_attempts: + await queue_service.mark_dropped( + session, + job_id=job.id, + reason="max_attempts_after_run", + ) + await session.commit() + logger.warning( + "scheduler.queue_item_dropped_max_attempts_after_run", + extra={ + "event": "scheduler.queue_item_dropped_max_attempts_after_run", + "queue_item_id": job.id, + "user_id": job.user_id, + "attempt_count": queue_item.attempt_count, + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + return + + delay_seconds = queue_service.compute_backoff_seconds( + base_seconds=self._continuation_base_delay_seconds, + attempt_count=int(queue_item.attempt_count), + max_seconds=self._continuation_max_delay_seconds, + ) + await queue_service.reschedule_job( + session, + job_id=job.id, + delay_seconds=delay_seconds, + reason=queue_item.reason, + error=queue_item.last_error, + ) + await session.commit() + logger.info( + "scheduler.queue_item_rescheduled_failed", + extra={ + "event": "scheduler.queue_item_rescheduled_failed", + "queue_item_id": job.id, + "user_id": job.user_id, + "attempt_count": queue_item.attempt_count, + "delay_seconds": delay_seconds, + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + + async def _load_request_delay_for_user( + self, + db_session: AsyncSession, + *, + user_id: int, + ) -> int: + result = await db_session.execute( + select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id) + ) + delay = result.scalar_one_or_none() + if delay is None: + return 10 + return max(1, int(delay)) diff --git a/app/services/scholar/__init__.py b/app/services/scholar/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/app/services/scholar/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/app/services/scholar/author_rows.py b/app/services/scholar/author_rows.py deleted file mode 100644 index a0705fb..0000000 --- a/app/services/scholar/author_rows.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import re -from html.parser import HTMLParser -from typing import Any -from urllib.parse import parse_qs, urlparse - -from app.services.scholar.parser_constants import AUTHOR_SEARCH_MARKER_KEYS -from app.services.scholar.parser_types import ScholarSearchCandidate -from app.services.scholar.parser_utils import ( - attr_class, - attr_href, - attr_src, - build_absolute_scholar_url, - normalize_space, -) - - -def parse_scholar_id_from_href(href: str | None) -> str | None: - if not href: - return None - parsed = urlparse(href) - query = parse_qs(parsed.query) - user_values = query.get("user") - if not user_values: - return None - candidate = user_values[0].strip() - return candidate or None - - -def _extract_verified_email_domain(value: str | None) -> str | None: - if not value: - return None - match = re.search(r"verified email at\s+(.+)$", value.strip(), re.I) - if not match: - return None - domain = normalize_space(match.group(1)) - return domain or None - - -class ScholarAuthorSearchParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.candidates: list[ScholarSearchCandidate] = [] - self._candidate: dict[str, Any] | None = None - - def _begin_candidate(self) -> None: - self._candidate = { - "depth": 1, - "name_href": None, - "name_parts": [], - "aff_depth": 0, - "aff_parts": [], - "name_depth": 0, - "eml_depth": 0, - "eml_parts": [], - "cby_depth": 0, - "cby_parts": [], - "interest_depth": 0, - "interest_parts": [], - "interests": [], - "image_src": None, - } - - def _increment_capture_depths(self) -> None: - if self._candidate is None: - return - for key in ("name_depth", "aff_depth", "eml_depth", "cby_depth", "interest_depth"): - if self._candidate[key] > 0: - self._candidate[key] += 1 - - def _finalize_candidate(self) -> None: - if self._candidate is None: - return - - name = normalize_space("".join(self._candidate["name_parts"])) - scholar_id = parse_scholar_id_from_href(self._candidate["name_href"]) - if not name or not scholar_id: - return - - affiliation = normalize_space("".join(self._candidate["aff_parts"])) or None - email_domain = _extract_verified_email_domain(normalize_space("".join(self._candidate["eml_parts"])) or None) - cited_by_text = normalize_space("".join(self._candidate["cby_parts"])) - cited_by_match = re.search(r"\d+", cited_by_text) - cited_by_count = int(cited_by_match.group(0)) if cited_by_match else None - - seen_interests: set[str] = set() - interests: list[str] = [] - for interest in self._candidate["interests"]: - normalized = normalize_space(interest) - if not normalized or normalized in seen_interests: - continue - seen_interests.add(normalized) - interests.append(normalized) - - profile_url = build_absolute_scholar_url(self._candidate["name_href"]) - if not profile_url: - profile_url = f"https://scholar.google.com/citations?hl=en&user={scholar_id}" - - self.candidates.append( - ScholarSearchCandidate( - scholar_id=scholar_id, - display_name=name, - affiliation=affiliation, - email_domain=email_domain, - cited_by_count=cited_by_count, - interests=interests, - profile_url=profile_url, - profile_image_url=build_absolute_scholar_url(self._candidate["image_src"]), - ) - ) - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - classes = attr_class(attrs) - - if self._candidate is None: - if tag == "div" and "gsc_1usr" in classes: - self._begin_candidate() - return - - self._candidate["depth"] += 1 - self._increment_capture_depths() - - if tag == "a" and "gs_ai_name" in classes: - self._candidate["name_depth"] = 1 - self._candidate["name_href"] = attr_href(attrs) - return - - if tag == "div" and "gs_ai_aff" in classes: - self._candidate["aff_depth"] = 1 - return - - if tag == "div" and "gs_ai_eml" in classes: - self._candidate["eml_depth"] = 1 - return - - if tag == "div" and "gs_ai_cby" in classes: - self._candidate["cby_depth"] = 1 - return - - if tag == "a" and "gs_ai_one_int" in classes: - self._candidate["interest_depth"] = 1 - self._candidate["interest_parts"] = [] - return - - if tag == "img" and self._candidate["image_src"] is None: - self._candidate["image_src"] = attr_src(attrs) - - def handle_data(self, data: str) -> None: - if self._candidate is None: - return - if self._candidate["name_depth"] > 0: - self._candidate["name_parts"].append(data) - if self._candidate["aff_depth"] > 0: - self._candidate["aff_parts"].append(data) - if self._candidate["eml_depth"] > 0: - self._candidate["eml_parts"].append(data) - if self._candidate["cby_depth"] > 0: - self._candidate["cby_parts"].append(data) - if self._candidate["interest_depth"] > 0: - self._candidate["interest_parts"].append(data) - - def _decrement_capture_depth(self, key: str) -> bool: - if self._candidate is None: - return False - if self._candidate[key] <= 0: - return False - self._candidate[key] -= 1 - return self._candidate[key] == 0 - - def handle_endtag(self, _tag: str) -> None: - if self._candidate is None: - return - - interest_closed = self._decrement_capture_depth("interest_depth") - self._decrement_capture_depth("name_depth") - self._decrement_capture_depth("aff_depth") - self._decrement_capture_depth("eml_depth") - self._decrement_capture_depth("cby_depth") - - if interest_closed: - interest_text = normalize_space("".join(self._candidate["interest_parts"])) - if interest_text: - self._candidate["interests"].append(interest_text) - self._candidate["interest_parts"] = [] - - self._candidate["depth"] -= 1 - if self._candidate["depth"] > 0: - return - - self._finalize_candidate() - self._candidate = None - - -def count_author_search_markers(html: str) -> dict[str, int]: - lowered = html.lower() - return {key: lowered.count(key.lower()) for key in AUTHOR_SEARCH_MARKER_KEYS} diff --git a/app/services/scholar/parser.py b/app/services/scholar/parser.py deleted file mode 100644 index 1718b3f..0000000 --- a/app/services/scholar/parser.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -from app.services.scholar.author_rows import ( - ScholarAuthorSearchParser, - count_author_search_markers, -) -from app.services.scholar.parser_constants import SCRIPT_STYLE_RE -from app.services.scholar.parser_types import ( - ParsedAuthorSearchPage, - ParsedProfilePage, - PublicationCandidate, - ScholarDomInvariantError, - ScholarMalformedDataError, -) -from app.services.scholar.parser_types import ( - ParseState as ParseState, -) -from app.services.scholar.parser_types import ( - ScholarParserError as ScholarParserError, -) -from app.services.scholar.parser_types import ( - ScholarSearchCandidate as ScholarSearchCandidate, -) -from app.services.scholar.parser_utils import ( - strip_tags, -) -from app.services.scholar.profile_rows import ( - count_markers, - extract_articles_range, - extract_profile_image_url, - extract_profile_name, - has_operation_error_banner, - has_show_more_button, - parse_publications, -) -from app.services.scholar.source import FetchResult -from app.services.scholar.state_detection import ( - detect_author_search_state, - detect_state, -) - - -def _raise_dom_error(code: str, message: str) -> None: - raise ScholarDomInvariantError(code=code, message=message) - - -def _assert_profile_dom_invariants( - *, - fetch_result: FetchResult, - marker_counts: dict[str, int], - publications: list[PublicationCandidate], - warnings: list[str], - has_show_more_button_flag: bool, - articles_range: str | None, -) -> None: - if fetch_result.status_code is None: - return - final_url = (fetch_result.final_url or "").lower() - if "accounts.google.com" in final_url or "sorry/index" in final_url: - return - if any(code.startswith("layout_") for code in warnings): - reason = next(code for code in warnings if code.startswith("layout_")) - _raise_dom_error(reason, f"Detected layout warning: {reason}") - if has_show_more_button_flag and not articles_range: - _raise_dom_error( - "layout_show_more_without_articles_range", - "Show-more control exists without an articles range marker.", - ) - if marker_counts.get("gsc_a_tr", 0) > 0 and marker_counts.get("gsc_a_at", 0) <= 0: - _raise_dom_error( - "layout_missing_publication_title_anchor", - "Publication rows were present but title anchors were absent.", - ) - if not publications: - has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0 - has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0 - if not has_profile_markers and not has_table_markers: - _raise_dom_error("layout_markers_missing", "Expected scholar profile markers were absent.") - for publication in publications: - if not publication.title.strip(): - raise ScholarMalformedDataError( - code="malformed_publication_title", - message="Encountered a publication candidate with an empty title.", - ) - if publication.citation_count is not None and int(publication.citation_count) < 0: - raise ScholarMalformedDataError( - code="malformed_publication_negative_citations", - message="Encountered a publication candidate with negative citations.", - ) - - -def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: - publications, warnings = parse_publications(fetch_result.body) - marker_counts = count_markers(fetch_result.body) - visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() - - show_more = has_show_more_button(fetch_result.body) - operation_error_banner = has_operation_error_banner(fetch_result.body) - articles_range = extract_articles_range(fetch_result.body) - - if show_more: - warnings.append("possible_partial_page_show_more_present") - if operation_error_banner: - warnings.append("operation_error_banner_present") - - warnings = sorted(set(warnings)) - _assert_profile_dom_invariants( - fetch_result=fetch_result, - marker_counts=marker_counts, - publications=publications, - warnings=warnings, - has_show_more_button_flag=show_more, - articles_range=articles_range, - ) - - state, state_reason = detect_state( - fetch_result, - publications, - marker_counts, - warnings=warnings, - has_show_more_button_flag=show_more, - articles_range=articles_range, - visible_text=visible_text, - ) - - return ParsedProfilePage( - state=state, - state_reason=state_reason, - profile_name=extract_profile_name(fetch_result.body), - profile_image_url=extract_profile_image_url(fetch_result.body), - publications=publications, - marker_counts=marker_counts, - warnings=warnings, - has_show_more_button=show_more, - has_operation_error_banner=operation_error_banner, - articles_range=articles_range, - ) - - -def parse_author_search_page(fetch_result: FetchResult) -> ParsedAuthorSearchPage: - parser = ScholarAuthorSearchParser() - parser.feed(fetch_result.body) - - marker_counts = count_author_search_markers(fetch_result.body) - visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() - warnings: list[str] = [] - if not parser.candidates: - warnings.append("no_author_candidates_detected") - - state, state_reason = detect_author_search_state( - fetch_result, - parser.candidates, - marker_counts, - visible_text=visible_text, - ) - - return ParsedAuthorSearchPage( - state=state, - state_reason=state_reason, - candidates=parser.candidates, - marker_counts=marker_counts, - warnings=warnings, - ) diff --git a/app/services/scholar/parser_constants.py b/app/services/scholar/parser_constants.py deleted file mode 100644 index c3c7a15..0000000 --- a/app/services/scholar/parser_constants.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import re - -BLOCKED_KEYWORDS = [ - "unusual traffic", - "sorry/index", - "not a robot", - "our systems have detected", - "automated queries", - "recaptcha", - "captcha", -] - -NO_RESULTS_KEYWORDS = [ - "didn't match any articles", - "did not match any articles", - "no articles", - "no documents", -] - -NO_AUTHOR_RESULTS_KEYWORDS = [ - "didn't match any user profiles", - "did not match any user profiles", - "didn't match any scholars", - "did not match any scholars", - "no user profiles", -] - -MARKER_KEYS = [ - "gsc_a_tr", - "gsc_a_at", - "gsc_a_ac", - "gsc_a_h", - "gsc_a_y", - "gs_gray", - "gsc_prf_in", - "gsc_rsb_st", -] - -AUTHOR_SEARCH_MARKER_KEYS = [ - "gsc_1usr", - "gs_ai_name", - "gs_ai_aff", - "gs_ai_eml", - "gs_ai_cby", - "gs_ai_one_int", -] - -NETWORK_DNS_ERROR_KEYWORDS = [ - "temporary failure in name resolution", - "name or service not known", - "nodename nor servname provided", - "getaddrinfo failed", -] - -NETWORK_TIMEOUT_KEYWORDS = [ - "timed out", - "timeout", -] - -NETWORK_TLS_ERROR_KEYWORDS = [ - "ssl", - "tls", - "certificate verify failed", -] - -TAG_RE = re.compile(r"<[^>]+>", re.S) -SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?", re.I | re.S) -SHOW_MORE_BUTTON_RE = re.compile( - r"]*\bid\s*=\s*['\"]gsc_bpf_more['\"][^>]*>", - re.I | re.S, -) - -PROFILE_ROW_PARSER_DIRECT_MARKERS = ( - "gs_ggs", - "gs_ggsd", - "gs_ggsa", - "gs_or_ggsm", -) - -PROFILE_ROW_DIRECT_LABEL_TOKENS = ( - "pdf", - "[pdf]", - "full text", - "download", -) diff --git a/app/services/scholar/parser_types.py b/app/services/scholar/parser_types.py deleted file mode 100644 index 7b657d8..0000000 --- a/app/services/scholar/parser_types.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum - - -class ParseState(StrEnum): - OK = "ok" - NO_RESULTS = "no_results" - BLOCKED_OR_CAPTCHA = "blocked_or_captcha" - LAYOUT_CHANGED = "layout_changed" - NETWORK_ERROR = "network_error" - - -class ScholarParserError(RuntimeError): - code: str - - def __init__(self, *, code: str, message: str) -> None: - super().__init__(message) - self.code = code - - -class ScholarDomInvariantError(ScholarParserError): - pass - - -class ScholarMalformedDataError(ScholarParserError): - pass - - -@dataclass(frozen=True) -class PublicationCandidate: - title: str - title_url: str | None - cluster_id: str | None - year: int | None - citation_count: int | None - authors_text: str | None - venue_text: str | None - pdf_url: str | None - - -@dataclass(frozen=True) -class ScholarSearchCandidate: - scholar_id: str - display_name: str - affiliation: str | None - email_domain: str | None - cited_by_count: int | None - interests: list[str] - profile_url: str - profile_image_url: str | None - - -@dataclass(frozen=True) -class ParsedProfilePage: - state: ParseState - state_reason: str - profile_name: str | None - profile_image_url: str | None - publications: list[PublicationCandidate] - marker_counts: dict[str, int] - warnings: list[str] - has_show_more_button: bool - has_operation_error_banner: bool - articles_range: str | None - - -@dataclass(frozen=True) -class ParsedAuthorSearchPage: - state: ParseState - state_reason: str - candidates: list[ScholarSearchCandidate] - marker_counts: dict[str, int] - warnings: list[str] diff --git a/app/services/scholar/parser_utils.py b/app/services/scholar/parser_utils.py deleted file mode 100644 index 0bc3b77..0000000 --- a/app/services/scholar/parser_utils.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from html import unescape - -from app.services.scholar.parser_constants import TAG_RE - - -def normalize_space(value: str) -> str: - return " ".join(unescape(value).split()) - - -def strip_tags(value: str) -> str: - return normalize_space(TAG_RE.sub(" ", value)) - - -def attr_class(attrs: list[tuple[str, str | None]]) -> str: - for name, raw_value in attrs: - if name.lower() == "class": - return raw_value or "" - return "" - - -def attr_href(attrs: list[tuple[str, str | None]]) -> str | None: - for name, raw_value in attrs: - if name.lower() == "href": - return raw_value - return None - - -def attr_src(attrs: list[tuple[str, str | None]]) -> str | None: - for name, raw_value in attrs: - if name.lower() == "src": - return raw_value - return None - - -def build_absolute_scholar_url(path_or_url: str | None) -> str | None: - if not path_or_url: - return None - from urllib.parse import urljoin - - return urljoin("https://scholar.google.com", path_or_url) diff --git a/app/services/scholar/profile_rows.py b/app/services/scholar/profile_rows.py deleted file mode 100644 index d291dbf..0000000 --- a/app/services/scholar/profile_rows.py +++ /dev/null @@ -1,271 +0,0 @@ -from __future__ import annotations - -import re -from html.parser import HTMLParser -from typing import Any -from urllib.parse import parse_qs, urlparse - -from app.services.scholar.parser_constants import ( - MARKER_KEYS, - SHOW_MORE_BUTTON_RE, -) -from app.services.scholar.parser_types import PublicationCandidate -from app.services.scholar.parser_utils import ( - attr_class, - attr_href, - build_absolute_scholar_url, - normalize_space, - strip_tags, -) - - -class ScholarRowParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.title_href: str | None = None - self.title_parts: list[str] = [] - self.citation_parts: list[str] = [] - self.year_parts: list[str] = [] - self.gray_texts: list[str] = [] - - self._title_depth = 0 - self._citation_depth = 0 - self._year_depth = 0 - self._gray_stack: list[dict[str, Any]] = [] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - if self._title_depth > 0: - self._title_depth += 1 - if self._citation_depth > 0: - self._citation_depth += 1 - if self._year_depth > 0: - self._year_depth += 1 - if self._gray_stack: - self._gray_stack[-1]["depth"] += 1 - - classes = attr_class(attrs) - - if tag == "a" and "gsc_a_at" in classes: - self._title_depth = 1 - self.title_href = attr_href(attrs) - return - - if tag == "a" and "gsc_a_ac" in classes: - self._citation_depth = 1 - return - - if tag in {"span", "a"} and ("gsc_a_h" in classes or "gsc_a_y" in classes): - self._year_depth = 1 - return - - if tag == "div" and "gs_gray" in classes: - self._gray_stack.append({"depth": 1, "parts": []}) - return - - def handle_data(self, data: str) -> None: - if self._title_depth > 0: - self.title_parts.append(data) - if self._citation_depth > 0: - self.citation_parts.append(data) - if self._year_depth > 0: - self.year_parts.append(data) - if self._gray_stack: - self._gray_stack[-1]["parts"].append(data) - - def handle_endtag(self, tag: str) -> None: - if self._title_depth > 0: - self._title_depth -= 1 - if self._citation_depth > 0: - self._citation_depth -= 1 - if self._year_depth > 0: - self._year_depth -= 1 - if self._gray_stack: - self._gray_stack[-1]["depth"] -= 1 - if self._gray_stack[-1]["depth"] == 0: - text_value = normalize_space("".join(self._gray_stack[-1]["parts"])) - if text_value: - self.gray_texts.append(text_value) - self._gray_stack.pop() - - -def extract_rows(html: str) -> list[str]: - pattern = re.compile( - r"]*\bclass\s*=\s*['\"][^'\"]*\bgsc_a_tr\b[^'\"]*['\"])[^>]*>(.*?)", - re.I | re.S, - ) - return [match.group(1) for match in pattern.finditer(html)] - - -def parse_cluster_id_from_href(href: str | None) -> str | None: - if not href: - return None - parsed = urlparse(href) - query = parse_qs(parsed.query) - - citation_for_view = query.get("citation_for_view") - if citation_for_view: - token = citation_for_view[0].strip() - if token: - return f"cfv:{token}" - - cluster = query.get("cluster") - if cluster: - token = cluster[0].strip() - if token: - return f"cluster:{token}" - return None - - -def parse_year(parts: list[str]) -> int | None: - text = normalize_space(" ".join(parts)) - match = re.search(r"\b(19|20)\d{2}\b", text) - if not match: - return None - try: - return int(match.group(0)) - except ValueError: - return None - - -def parse_citation_count(parts: list[str]) -> int | None: - text = normalize_space(" ".join(parts)) - if not text: - return 0 - match = re.search(r"[\d,]+", text) - if not match: - return None - digits = match.group(0).replace(",", "") - if not digits: - return None - return int(digits) - - -def _parse_publication_row(row_html: str) -> tuple[PublicationCandidate | None, list[str]]: - parser = ScholarRowParser() - parser.feed(row_html) - warnings: list[str] = [] - title = normalize_space("".join(parser.title_parts)) - if not title: - warnings.append("row_missing_title") - return None, warnings - if not parser.title_href: - warnings.append("row_missing_title_href") - - citation_text = normalize_space(" ".join(parser.citation_parts)) - citation_count = parse_citation_count(parser.citation_parts) - if citation_text and citation_count is None: - warnings.append("layout_row_citation_unparseable") - - year_text = normalize_space(" ".join(parser.year_parts)) - year = parse_year(parser.year_parts) - if year_text and year is None: - warnings.append("layout_row_year_unparseable") - - authors_text = parser.gray_texts[0] if len(parser.gray_texts) > 0 else None - venue_text = parser.gray_texts[1] if len(parser.gray_texts) > 1 else None - return ( - PublicationCandidate( - title=title, - title_url=parser.title_href, - cluster_id=parse_cluster_id_from_href(parser.title_href), - year=year, - citation_count=citation_count, - authors_text=authors_text, - venue_text=venue_text, - pdf_url=None, - ), - warnings, - ) - - -def parse_publications(html: str) -> tuple[list[PublicationCandidate], list[str]]: - rows = extract_rows(html) - warnings: list[str] = [] - publications: list[PublicationCandidate] = [] - - for row_html in rows: - publication, row_warnings = _parse_publication_row(row_html) - warnings.extend(row_warnings) - if publication is None: - continue - publications.append(publication) - - if not rows: - warnings.append("no_rows_detected") - if rows and not publications: - warnings.append("layout_all_rows_unparseable") - - return publications, sorted(set(warnings)) - - -def extract_profile_name(html: str) -> str | None: - pattern = re.compile( - r"<[^>]*\bid\s*=\s*['\"]gsc_prf_in['\"][^>]*>(.*?)]+>", - re.I | re.S, - ) - match = pattern.search(html) - if not match: - return None - value = strip_tags(match.group(1)) - return value or None - - -def extract_profile_image_url(html: str) -> str | None: - og_image_pattern = re.compile( - r"]+property=['\"]og:image['\"][^>]+content=['\"]([^'\"]+)['\"][^>]*>", - re.I | re.S, - ) - og_match = og_image_pattern.search(html) - if og_match: - value = normalize_space(og_match.group(1)) - absolute = build_absolute_scholar_url(value) - if absolute: - return absolute - - image_pattern = re.compile( - r"]*\bid=['\"]gsc_prf_pup-img['\"][^>]*\bsrc=['\"]([^'\"]+)['\"][^>]*>", - re.I | re.S, - ) - image_match = image_pattern.search(html) - if not image_match: - return None - - value = normalize_space(image_match.group(1)) - return build_absolute_scholar_url(value) - - -def extract_articles_range(html: str) -> str | None: - pattern = re.compile( - r"<[^>]*\bid\s*=\s*['\"]gsc_a_nn['\"][^>]*>(.*?)]+>", - re.I | re.S, - ) - match = pattern.search(html) - if not match: - return None - value = strip_tags(match.group(1)) - return value or None - - -def has_show_more_button(html: str) -> bool: - match = SHOW_MORE_BUTTON_RE.search(html) - if match is None: - return False - - button_tag = match.group(0).lower() - if "disabled" in button_tag: - return False - if 'aria-disabled="true"' in button_tag or "aria-disabled='true'" in button_tag: - return False - return "gs_dis" not in button_tag - - -def has_operation_error_banner(html: str) -> bool: - lowered = html.lower() - if 'id="gsc_a_err"' not in lowered and "id='gsc_a_err'" not in lowered: - return False - return "can't perform the operation now" in lowered or "cannot perform the operation now" in lowered - - -def count_markers(html: str) -> dict[str, int]: - lowered = html.lower() - return {key: lowered.count(key.lower()) for key in MARKER_KEYS} diff --git a/app/services/scholar/rate_limit.py b/app/services/scholar/rate_limit.py deleted file mode 100644 index fa708b6..0000000 --- a/app/services/scholar/rate_limit.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import asyncio -import time - -_REQUEST_LOCK = asyncio.Lock() -_LAST_REQUEST_AT = 0.0 - - -def _normalize_interval_seconds(value: float) -> float: - return max(float(value), 0.0) - - -def remaining_scholar_slot_seconds(*, min_interval_seconds: float) -> float: - interval_seconds = _normalize_interval_seconds(min_interval_seconds) - if interval_seconds <= 0: - return 0.0 - elapsed_seconds = time.monotonic() - _LAST_REQUEST_AT - return max(interval_seconds - elapsed_seconds, 0.0) - - -async def wait_for_scholar_slot(*, min_interval_seconds: float) -> None: - global _LAST_REQUEST_AT - interval = _normalize_interval_seconds(min_interval_seconds) - async with _REQUEST_LOCK: - remaining = remaining_scholar_slot_seconds(min_interval_seconds=interval) - if remaining > 0: - await asyncio.sleep(remaining) - _LAST_REQUEST_AT = time.monotonic() - - -def reset_scholar_rate_limit_state_for_tests() -> None: - global _LAST_REQUEST_AT - _LAST_REQUEST_AT = 0.0 diff --git a/app/services/scholar/source.py b/app/services/scholar/source.py deleted file mode 100644 index afafb1c..0000000 --- a/app/services/scholar/source.py +++ /dev/null @@ -1,263 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import random -from dataclasses import dataclass -from typing import Protocol -from urllib.error import HTTPError, URLError -from urllib.parse import urlencode -from urllib.request import Request, urlopen - -from app.logging_utils import structured_log -from app.services.scholar import rate_limit as scholar_rate_limit -from app.settings import settings - -SCHOLAR_PROFILE_URL = "https://scholar.google.com/citations" -DEFAULT_PAGE_SIZE = 100 - -DEFAULT_USER_AGENTS = [ - ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"), - ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 " - "(KHTML, like Gecko) Version/18.1 Safari/605.1.15" - ), - ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0"), -] - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class FetchResult: - requested_url: str - status_code: int | None - final_url: str | None - body: str - error: str | None - - -class ScholarSource(Protocol): - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: ... - - async def fetch_profile_page_html( - self, - scholar_id: str, - *, - cstart: int, - pagesize: int, - ) -> FetchResult: ... - - async def fetch_author_search_html( - self, - query: str, - *, - start: int, - ) -> FetchResult: ... - - -class LiveScholarSource: - def __init__( - self, - *, - timeout_seconds: float = 25.0, - min_interval_seconds: float | None = None, - rotate_user_agents: bool | None = None, - user_agents: list[str] | None = None, - ) -> None: - self._timeout_seconds = timeout_seconds - configured_interval = ( - float(settings.ingestion_min_request_delay_seconds) - if min_interval_seconds is None - else float(min_interval_seconds) - ) - self._min_interval_seconds = max(configured_interval, 0.0) - self._user_agents = user_agents or DEFAULT_USER_AGENTS - self._rotate_user_agents = ( - bool(settings.scholar_http_rotate_user_agent) if rotate_user_agents is None else bool(rotate_user_agents) - ) - configured_user_agent = settings.scholar_http_user_agent.strip() - self._configured_user_agent = configured_user_agent or None - self._accept_language = settings.scholar_http_accept_language.strip() or "en-US,en;q=0.9" - self._cookie_header = settings.scholar_http_cookie.strip() or None - self._stable_user_agent = self._resolve_initial_user_agent() - - def _resolve_initial_user_agent(self) -> str: - if self._configured_user_agent is not None: - return self._configured_user_agent - return random.choice(self._user_agents) - - def _resolve_user_agent_for_request(self) -> str: - if self._configured_user_agent is not None: - return self._configured_user_agent - if self._rotate_user_agents: - return random.choice(self._user_agents) - return self._stable_user_agent - - def _request_headers(self) -> dict[str, str]: - headers = { - "User-Agent": self._resolve_user_agent_for_request(), - "Accept": "text/html,application/xhtml+xml", - "Accept-Language": self._accept_language, - } - if self._cookie_header is not None: - headers["Cookie"] = self._cookie_header - return headers - - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: - return await self.fetch_profile_page_html( - scholar_id, - cstart=0, - pagesize=DEFAULT_PAGE_SIZE, - ) - - async def fetch_profile_page_html( - self, - scholar_id: str, - *, - cstart: int, - pagesize: int = DEFAULT_PAGE_SIZE, - ) -> FetchResult: - requested_url = _build_profile_url( - scholar_id=scholar_id, - cstart=cstart, - pagesize=pagesize, - ) - return await self._fetch_with_global_throttle(requested_url) - - async def fetch_author_search_html( - self, - query: str, - *, - start: int = 0, - ) -> FetchResult: - requested_url = _build_author_search_url( - query=query, - start=start, - ) - return await self._fetch_with_global_throttle(requested_url) - - async def fetch_publication_html(self, publication_url: str) -> FetchResult: - return await self._fetch_with_global_throttle(publication_url) - - async def _fetch_with_global_throttle(self, requested_url: str) -> FetchResult: - await scholar_rate_limit.wait_for_scholar_slot( - min_interval_seconds=self._min_interval_seconds, - ) - return await asyncio.to_thread(self._fetch_sync, requested_url) - - def _build_request(self, requested_url: str) -> Request: - return Request(requested_url, headers=self._request_headers()) - - @staticmethod - def _http_error_reason(*, status_code: int, final_url: str, body: str) -> str: - lowered_url = final_url.lower() - lowered_body = body.lower() - if "sorry/index" in lowered_url or "sorry/index" in lowered_body: - return "blocked_google_sorry_challenge" - if "our systems have detected" in lowered_body or "unusual traffic" in lowered_body: - return "blocked_unusual_traffic_detected" - if "automated queries" in lowered_body: - return "blocked_automated_queries_detected" - if status_code == 429: - return "blocked_http_429_rate_limited" - return f"http_error_status_{status_code}" - - @staticmethod - def _http_error_body(exc: HTTPError) -> str: - try: - return exc.read().decode("utf-8", errors="replace") - except Exception: - return "" - - @staticmethod - def _network_error_result(requested_url: str, exc: URLError) -> FetchResult: - structured_log( - logger, - "warning", - "scholar_source.fetch_network_error", - requested_url=requested_url, - ) - return FetchResult( - requested_url=requested_url, - status_code=None, - final_url=None, - body="", - error=str(exc), - ) - - @staticmethod - def _http_error_result(requested_url: str, exc: HTTPError) -> FetchResult: - final_url = exc.geturl() - body = LiveScholarSource._http_error_body(exc) - block_reason = LiveScholarSource._http_error_reason( - status_code=exc.code, - final_url=final_url, - body=body, - ) - structured_log( - logger, - "warning", - "scholar_source.fetch_http_error", - requested_url=requested_url, - status_code=exc.code, - final_url=final_url, - block_reason=block_reason, - ) - return FetchResult( - requested_url=requested_url, - status_code=exc.code, - final_url=final_url, - body=body, - error=str(exc), - ) - - @staticmethod - def _success_result(requested_url: str, response) -> FetchResult: - body = response.read().decode("utf-8", errors="replace") - status_code = getattr(response, "status", 200) - structured_log( - logger, - "debug", - "scholar_source.fetch_succeeded", - requested_url=requested_url, - status_code=status_code, - ) - return FetchResult( - requested_url=requested_url, - status_code=status_code, - final_url=response.geturl(), - body=body, - error=None, - ) - - def _fetch_sync(self, requested_url: str) -> FetchResult: - request = self._build_request(requested_url) - - try: - with urlopen(request, timeout=self._timeout_seconds) as response: - return self._success_result(requested_url, response) - except HTTPError as exc: - return self._http_error_result(requested_url, exc) - except URLError as exc: - return self._network_error_result(requested_url, exc) - - -def _build_profile_url(*, scholar_id: str, cstart: int, pagesize: int) -> str: - query: dict[str, int | str] = {"hl": "en", "user": scholar_id} - if cstart > 0: - query["cstart"] = int(cstart) - if pagesize > 0: - query["pagesize"] = int(pagesize) - return f"{SCHOLAR_PROFILE_URL}?{urlencode(query)}" - - -def _build_author_search_url(*, query: str, start: int) -> str: - params: dict[str, int | str] = { - "hl": "en", - "view_op": "search_authors", - "mauthors": query, - } - if start > 0: - params["astart"] = int(start) - return f"{SCHOLAR_PROFILE_URL}?{urlencode(params)}" diff --git a/app/services/scholar/state_detection.py b/app/services/scholar/state_detection.py deleted file mode 100644 index 28a413d..0000000 --- a/app/services/scholar/state_detection.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -from app.services.scholar.parser_constants import ( - BLOCKED_KEYWORDS, - NETWORK_DNS_ERROR_KEYWORDS, - NETWORK_TIMEOUT_KEYWORDS, - NETWORK_TLS_ERROR_KEYWORDS, - NO_AUTHOR_RESULTS_KEYWORDS, - NO_RESULTS_KEYWORDS, -) -from app.services.scholar.parser_types import ParseState, PublicationCandidate, ScholarSearchCandidate -from app.services.scholar.source import FetchResult - - -def classify_network_error_reason(fetch_error: str | None) -> str: - lowered = (fetch_error or "").lower() - if lowered: - if any(keyword in lowered for keyword in NETWORK_DNS_ERROR_KEYWORDS): - return "network_dns_resolution_failed" - if any(keyword in lowered for keyword in NETWORK_TIMEOUT_KEYWORDS): - return "network_timeout" - if any(keyword in lowered for keyword in NETWORK_TLS_ERROR_KEYWORDS): - return "network_tls_error" - if "connection reset" in lowered: - return "network_connection_reset" - if "connection refused" in lowered: - return "network_connection_refused" - if "network is unreachable" in lowered: - return "network_unreachable" - return "network_error_missing_status_code" - - -def is_hard_challenge_reason(state_reason: str) -> bool: - """True if the block reason indicates an active Scholar challenge (not retryable in short loops).""" - return state_reason.startswith("blocked_") and state_reason != "blocked_http_429_rate_limited" - - -def classify_block_or_captcha_reason( - *, - status_code: int, - final_url: str, - body_lowered: str, -) -> str | None: - if "accounts.google.com" in final_url and ("signin" in final_url or "servicelogin" in final_url): - return "blocked_accounts_redirect" - if "sorry/index" in final_url or "sorry/index" in body_lowered: - return "blocked_google_sorry_challenge" - if "our systems have detected" in body_lowered or "unusual traffic" in body_lowered: - return "blocked_unusual_traffic_detected" - if "automated queries" in body_lowered: - return "blocked_automated_queries_detected" - if status_code == 429: - return "blocked_http_429_rate_limited" - if status_code == 403: - if "recaptcha" in body_lowered or "captcha" in body_lowered: - return "blocked_http_403_captcha_challenge" - return "blocked_http_403_forbidden" - if "not a robot" in body_lowered: - return "blocked_not_a_robot_challenge" - if "recaptcha" in body_lowered: - return "blocked_recaptcha_challenge" - if "captcha" in body_lowered: - return "blocked_captcha_challenge" - if any(keyword in body_lowered for keyword in BLOCKED_KEYWORDS): - return "blocked_keyword_detected" - return None - - -def _warnings_contain(warnings: list[str], code: str) -> bool: - return any(item == code for item in warnings) - - -def _has_layout_row_failure(marker_counts: dict[str, int], warnings: list[str]) -> bool: - if _warnings_contain(warnings, "layout_all_rows_unparseable"): - return True - if marker_counts.get("gsc_a_tr", 0) <= 0: - return False - if _warnings_contain(warnings, "row_missing_title"): - return True - return marker_counts.get("gsc_a_at", 0) <= 0 - - -def _first_layout_warning(warnings: list[str]) -> str | None: - for warning in warnings: - if warning.startswith("layout_"): - return warning - return None - - -def detect_state( - fetch_result: FetchResult, - publications: list[PublicationCandidate], - marker_counts: dict[str, int], - *, - warnings: list[str], - has_show_more_button_flag: bool, - articles_range: str | None, - visible_text: str, -) -> tuple[ParseState, str]: - if fetch_result.status_code is None: - return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) - - lowered = fetch_result.body.lower() - final = (fetch_result.final_url or "").lower() - status_code = int(fetch_result.status_code) - - block_reason = classify_block_or_captcha_reason( - status_code=status_code, - final_url=final, - body_lowered=lowered, - ) - if block_reason is not None: - return ParseState.BLOCKED_OR_CAPTCHA, block_reason - - if not publications and any(keyword in visible_text for keyword in NO_RESULTS_KEYWORDS): - return ParseState.NO_RESULTS, "no_results_keyword_detected" - - layout_warning = _first_layout_warning(warnings) - if layout_warning is not None: - return ParseState.LAYOUT_CHANGED, layout_warning - - if _has_layout_row_failure(marker_counts, warnings): - return ParseState.LAYOUT_CHANGED, "layout_publication_rows_unparseable" - - if has_show_more_button_flag and not articles_range: - return ParseState.LAYOUT_CHANGED, "layout_show_more_without_articles_range" - - if not publications: - has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0 - has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0 - if not has_profile_markers and not has_table_markers: - return ParseState.LAYOUT_CHANGED, "layout_markers_missing" - return ParseState.OK, "no_rows_with_known_markers" - - return ParseState.OK, "publications_extracted" - - -def detect_author_search_state( - fetch_result: FetchResult, - candidates: list[ScholarSearchCandidate], - marker_counts: dict[str, int], - *, - visible_text: str, -) -> tuple[ParseState, str]: - if fetch_result.status_code is None: - return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) - - lowered = fetch_result.body.lower() - final = (fetch_result.final_url or "").lower() - status_code = int(fetch_result.status_code) - - block_reason = classify_block_or_captcha_reason( - status_code=status_code, - final_url=final, - body_lowered=lowered, - ) - if block_reason is not None: - return ParseState.BLOCKED_OR_CAPTCHA, block_reason - - if not candidates and any(keyword in visible_text for keyword in NO_AUTHOR_RESULTS_KEYWORDS): - return ParseState.NO_RESULTS, "no_results_keyword_detected" - - if not candidates: - has_search_markers = marker_counts.get("gsc_1usr", 0) > 0 or marker_counts.get("gs_ai_name", 0) > 0 - if not has_search_markers: - return ParseState.NO_RESULTS, "no_search_candidates_detected" - return ParseState.LAYOUT_CHANGED, "layout_author_candidates_unparseable" - - return ParseState.OK, "author_candidates_extracted" diff --git a/app/services/scholar_parser.py b/app/services/scholar_parser.py new file mode 100644 index 0000000..de2daa5 --- /dev/null +++ b/app/services/scholar_parser.py @@ -0,0 +1,766 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import StrEnum +from html import unescape +from html.parser import HTMLParser +from typing import Any +from urllib.parse import parse_qs, urljoin, urlparse + +from app.services.scholar_source import FetchResult + +BLOCKED_KEYWORDS = [ + "unusual traffic", + "sorry/index", + "not a robot", + "our systems have detected", + "automated queries", + "recaptcha", + "captcha", +] + +NO_RESULTS_KEYWORDS = [ + "didn't match any articles", + "did not match any articles", + "no articles", + "no documents", +] + +NO_AUTHOR_RESULTS_KEYWORDS = [ + "didn't match any user profiles", + "did not match any user profiles", + "didn't match any scholars", + "did not match any scholars", + "no user profiles", +] + +MARKER_KEYS = [ + "gsc_a_tr", + "gsc_a_at", + "gsc_a_ac", + "gsc_a_h", + "gsc_a_y", + "gs_gray", + "gsc_prf_in", + "gsc_rsb_st", +] + +AUTHOR_SEARCH_MARKER_KEYS = [ + "gsc_1usr", + "gs_ai_name", + "gs_ai_aff", + "gs_ai_eml", + "gs_ai_cby", + "gs_ai_one_int", +] + +NETWORK_DNS_ERROR_KEYWORDS = [ + "temporary failure in name resolution", + "name or service not known", + "nodename nor servname provided", + "getaddrinfo failed", +] + +NETWORK_TIMEOUT_KEYWORDS = [ + "timed out", + "timeout", +] + +NETWORK_TLS_ERROR_KEYWORDS = [ + "ssl", + "tls", + "certificate verify failed", +] + +TAG_RE = re.compile(r"<[^>]+>", re.S) +SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?", re.I | re.S) +SHOW_MORE_BUTTON_RE = re.compile( + r"]*\bid\s*=\s*['\"]gsc_bpf_more['\"][^>]*>", + re.I | re.S, +) + + +class ParseState(StrEnum): + OK = "ok" + NO_RESULTS = "no_results" + BLOCKED_OR_CAPTCHA = "blocked_or_captcha" + LAYOUT_CHANGED = "layout_changed" + NETWORK_ERROR = "network_error" + + +@dataclass(frozen=True) +class PublicationCandidate: + title: str + title_url: str | None + cluster_id: str | None + year: int | None + citation_count: int | None + authors_text: str | None + venue_text: str | None + + +@dataclass(frozen=True) +class ScholarSearchCandidate: + scholar_id: str + display_name: str + affiliation: str | None + email_domain: str | None + cited_by_count: int | None + interests: list[str] + profile_url: str + profile_image_url: str | None + + +@dataclass(frozen=True) +class ParsedProfilePage: + state: ParseState + state_reason: str + profile_name: str | None + profile_image_url: str | None + publications: list[PublicationCandidate] + marker_counts: dict[str, int] + warnings: list[str] + has_show_more_button: bool + has_operation_error_banner: bool + articles_range: str | None + + +@dataclass(frozen=True) +class ParsedAuthorSearchPage: + state: ParseState + state_reason: str + candidates: list[ScholarSearchCandidate] + marker_counts: dict[str, int] + warnings: list[str] + + +def normalize_space(value: str) -> str: + return " ".join(unescape(value).split()) + + +def strip_tags(value: str) -> str: + return normalize_space(TAG_RE.sub(" ", value)) + + +def attr_class(attrs: list[tuple[str, str | None]]) -> str: + for name, raw_value in attrs: + if name.lower() == "class": + return raw_value or "" + return "" + + +def attr_href(attrs: list[tuple[str, str | None]]) -> str | None: + for name, raw_value in attrs: + if name.lower() == "href": + return raw_value + return None + + +def attr_src(attrs: list[tuple[str, str | None]]) -> str | None: + for name, raw_value in attrs: + if name.lower() == "src": + return raw_value + return None + + +def build_absolute_scholar_url(path_or_url: str | None) -> str | None: + if not path_or_url: + return None + return urljoin("https://scholar.google.com", path_or_url) + + +class ScholarRowParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.title_href: str | None = None + self.title_parts: list[str] = [] + self.citation_parts: list[str] = [] + self.year_parts: list[str] = [] + self.gray_texts: list[str] = [] + + self._title_depth = 0 + self._citation_depth = 0 + self._year_depth = 0 + self._gray_stack: list[dict[str, Any]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if self._title_depth > 0: + self._title_depth += 1 + if self._citation_depth > 0: + self._citation_depth += 1 + if self._year_depth > 0: + self._year_depth += 1 + if self._gray_stack: + self._gray_stack[-1]["depth"] += 1 + + classes = attr_class(attrs) + + if tag == "a" and "gsc_a_at" in classes: + self._title_depth = 1 + self.title_href = attr_href(attrs) + return + + if tag == "a" and "gsc_a_ac" in classes: + self._citation_depth = 1 + return + + if tag in {"span", "a"} and ("gsc_a_h" in classes or "gsc_a_y" in classes): + self._year_depth = 1 + return + + if tag == "div" and "gs_gray" in classes: + self._gray_stack.append({"depth": 1, "parts": []}) + return + + def handle_data(self, data: str) -> None: + if self._title_depth > 0: + self.title_parts.append(data) + if self._citation_depth > 0: + self.citation_parts.append(data) + if self._year_depth > 0: + self.year_parts.append(data) + if self._gray_stack: + self._gray_stack[-1]["parts"].append(data) + + def handle_endtag(self, _tag: str) -> None: + if self._title_depth > 0: + self._title_depth -= 1 + if self._citation_depth > 0: + self._citation_depth -= 1 + if self._year_depth > 0: + self._year_depth -= 1 + if self._gray_stack: + self._gray_stack[-1]["depth"] -= 1 + if self._gray_stack[-1]["depth"] == 0: + text = normalize_space("".join(self._gray_stack[-1]["parts"])) + if text: + self.gray_texts.append(text) + self._gray_stack.pop() + + +def extract_rows(html: str) -> list[str]: + pattern = re.compile( + r"]*\bclass\s*=\s*['\"][^'\"]*\bgsc_a_tr\b[^'\"]*['\"])[^>]*>(.*?)", + re.I | re.S, + ) + return [match.group(1) for match in pattern.finditer(html)] + + +def parse_cluster_id_from_href(href: str | None) -> str | None: + if not href: + return None + parsed = urlparse(href) + query = parse_qs(parsed.query) + + citation_for_view = query.get("citation_for_view") + if citation_for_view: + token = citation_for_view[0].strip() + if token: + if ":" in token: + return token.rsplit(":", 1)[-1] or None + return token + + cluster = query.get("cluster") + if cluster: + token = cluster[0].strip() + if token: + return token + return None + + +def parse_scholar_id_from_href(href: str | None) -> str | None: + if not href: + return None + parsed = urlparse(href) + query = parse_qs(parsed.query) + user_values = query.get("user") + if not user_values: + return None + candidate = user_values[0].strip() + return candidate or None + + +def parse_year(parts: list[str]) -> int | None: + text = normalize_space(" ".join(parts)) + match = re.search(r"\b(19|20)\d{2}\b", text) + if not match: + return None + try: + return int(match.group(0)) + except ValueError: + return None + + +def parse_citation_count(parts: list[str]) -> int | None: + text = normalize_space(" ".join(parts)) + if not text: + return 0 + match = re.search(r"\d+", text) + if not match: + return None + return int(match.group(0)) + + +def parse_publications(html: str) -> tuple[list[PublicationCandidate], list[str]]: + rows = extract_rows(html) + warnings: list[str] = [] + publications: list[PublicationCandidate] = [] + + for row_html in rows: + parser = ScholarRowParser() + parser.feed(row_html) + + title = normalize_space("".join(parser.title_parts)) + if not title: + warnings.append("row_missing_title") + continue + + authors_text = parser.gray_texts[0] if len(parser.gray_texts) > 0 else None + venue_text = parser.gray_texts[1] if len(parser.gray_texts) > 1 else None + + publications.append( + PublicationCandidate( + title=title, + title_url=parser.title_href, + cluster_id=parse_cluster_id_from_href(parser.title_href), + year=parse_year(parser.year_parts), + citation_count=parse_citation_count(parser.citation_parts), + authors_text=authors_text, + venue_text=venue_text, + ) + ) + + if not rows: + warnings.append("no_rows_detected") + + return publications, sorted(set(warnings)) + + +def extract_profile_name(html: str) -> str | None: + pattern = re.compile( + r"<[^>]*\bid\s*=\s*['\"]gsc_prf_in['\"][^>]*>(.*?)]+>", + re.I | re.S, + ) + match = pattern.search(html) + if not match: + return None + value = strip_tags(match.group(1)) + return value or None + + +def extract_profile_image_url(html: str) -> str | None: + og_image_pattern = re.compile( + r"]+property=['\"]og:image['\"][^>]+content=['\"]([^'\"]+)['\"][^>]*>", + re.I | re.S, + ) + og_match = og_image_pattern.search(html) + if og_match: + value = normalize_space(og_match.group(1)) + absolute = build_absolute_scholar_url(value) + if absolute: + return absolute + + image_pattern = re.compile( + r"]*\bid=['\"]gsc_prf_pup-img['\"][^>]*\bsrc=['\"]([^'\"]+)['\"][^>]*>", + re.I | re.S, + ) + image_match = image_pattern.search(html) + if not image_match: + return None + + value = normalize_space(image_match.group(1)) + return build_absolute_scholar_url(value) + + +def extract_articles_range(html: str) -> str | None: + pattern = re.compile( + r"<[^>]*\bid\s*=\s*['\"]gsc_a_nn['\"][^>]*>(.*?)]+>", + re.I | re.S, + ) + match = pattern.search(html) + if not match: + return None + value = strip_tags(match.group(1)) + return value or None + + +def has_show_more_button(html: str) -> bool: + match = SHOW_MORE_BUTTON_RE.search(html) + if match is None: + return False + + button_tag = match.group(0).lower() + if "disabled" in button_tag: + return False + if 'aria-disabled="true"' in button_tag or "aria-disabled='true'" in button_tag: + return False + if "gs_dis" in button_tag: + return False + return True + + +def has_operation_error_banner(html: str) -> bool: + lowered = html.lower() + if "id=\"gsc_a_err\"" not in lowered and "id='gsc_a_err'" not in lowered: + return False + return "can't perform the operation now" in lowered or "cannot perform the operation now" in lowered + + +def count_markers(html: str) -> dict[str, int]: + lowered = html.lower() + return {key: lowered.count(key.lower()) for key in MARKER_KEYS} + + +def count_author_search_markers(html: str) -> dict[str, int]: + lowered = html.lower() + return {key: lowered.count(key.lower()) for key in AUTHOR_SEARCH_MARKER_KEYS} + + +def _extract_verified_email_domain(value: str | None) -> str | None: + if not value: + return None + match = re.search(r"verified email at\s+(.+)$", value.strip(), re.I) + if not match: + return None + domain = normalize_space(match.group(1)) + return domain or None + + +class ScholarAuthorSearchParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.candidates: list[ScholarSearchCandidate] = [] + self._candidate: dict[str, Any] | None = None + + def _begin_candidate(self) -> None: + self._candidate = { + "depth": 1, + "name_href": None, + "name_parts": [], + "aff_depth": 0, + "aff_parts": [], + "name_depth": 0, + "eml_depth": 0, + "eml_parts": [], + "cby_depth": 0, + "cby_parts": [], + "interest_depth": 0, + "interest_parts": [], + "interests": [], + "image_src": None, + } + + def _increment_capture_depths(self) -> None: + if self._candidate is None: + return + for key in ("name_depth", "aff_depth", "eml_depth", "cby_depth", "interest_depth"): + if self._candidate[key] > 0: + self._candidate[key] += 1 + + def _finalize_candidate(self) -> None: + if self._candidate is None: + return + + name = normalize_space("".join(self._candidate["name_parts"])) + scholar_id = parse_scholar_id_from_href(self._candidate["name_href"]) + if not name or not scholar_id: + return + + affiliation = normalize_space("".join(self._candidate["aff_parts"])) or None + email_domain = _extract_verified_email_domain( + normalize_space("".join(self._candidate["eml_parts"])) or None + ) + cited_by_text = normalize_space("".join(self._candidate["cby_parts"])) + cited_by_match = re.search(r"\d+", cited_by_text) + cited_by_count = int(cited_by_match.group(0)) if cited_by_match else None + + seen_interests: set[str] = set() + interests: list[str] = [] + for interest in self._candidate["interests"]: + normalized = normalize_space(interest) + if not normalized or normalized in seen_interests: + continue + seen_interests.add(normalized) + interests.append(normalized) + + profile_url = build_absolute_scholar_url(self._candidate["name_href"]) + if not profile_url: + profile_url = ( + "https://scholar.google.com/citations" + f"?hl=en&user={scholar_id}" + ) + + self.candidates.append( + ScholarSearchCandidate( + scholar_id=scholar_id, + display_name=name, + affiliation=affiliation, + email_domain=email_domain, + cited_by_count=cited_by_count, + interests=interests, + profile_url=profile_url, + profile_image_url=build_absolute_scholar_url(self._candidate["image_src"]), + ) + ) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + classes = attr_class(attrs) + + if self._candidate is None: + if tag == "div" and "gsc_1usr" in classes: + self._begin_candidate() + return + + self._candidate["depth"] += 1 + self._increment_capture_depths() + + if tag == "a" and "gs_ai_name" in classes: + self._candidate["name_depth"] = 1 + self._candidate["name_href"] = attr_href(attrs) + return + + if tag == "div" and "gs_ai_aff" in classes: + self._candidate["aff_depth"] = 1 + return + + if tag == "div" and "gs_ai_eml" in classes: + self._candidate["eml_depth"] = 1 + return + + if tag == "div" and "gs_ai_cby" in classes: + self._candidate["cby_depth"] = 1 + return + + if tag == "a" and "gs_ai_one_int" in classes: + self._candidate["interest_depth"] = 1 + self._candidate["interest_parts"] = [] + return + + if tag == "img" and self._candidate["image_src"] is None: + self._candidate["image_src"] = attr_src(attrs) + + def handle_data(self, data: str) -> None: + if self._candidate is None: + return + if self._candidate["name_depth"] > 0: + self._candidate["name_parts"].append(data) + if self._candidate["aff_depth"] > 0: + self._candidate["aff_parts"].append(data) + if self._candidate["eml_depth"] > 0: + self._candidate["eml_parts"].append(data) + if self._candidate["cby_depth"] > 0: + self._candidate["cby_parts"].append(data) + if self._candidate["interest_depth"] > 0: + self._candidate["interest_parts"].append(data) + + def _decrement_capture_depth(self, key: str) -> bool: + if self._candidate is None: + return False + if self._candidate[key] <= 0: + return False + self._candidate[key] -= 1 + return self._candidate[key] == 0 + + def handle_endtag(self, _tag: str) -> None: + if self._candidate is None: + return + + interest_closed = self._decrement_capture_depth("interest_depth") + self._decrement_capture_depth("name_depth") + self._decrement_capture_depth("aff_depth") + self._decrement_capture_depth("eml_depth") + self._decrement_capture_depth("cby_depth") + + if interest_closed: + interest_text = normalize_space("".join(self._candidate["interest_parts"])) + if interest_text: + self._candidate["interests"].append(interest_text) + self._candidate["interest_parts"] = [] + + self._candidate["depth"] -= 1 + if self._candidate["depth"] > 0: + return + + self._finalize_candidate() + self._candidate = None + + +def classify_network_error_reason(fetch_error: str | None) -> str: + lowered = (fetch_error or "").lower() + if lowered: + if any(keyword in lowered for keyword in NETWORK_DNS_ERROR_KEYWORDS): + return "network_dns_resolution_failed" + if any(keyword in lowered for keyword in NETWORK_TIMEOUT_KEYWORDS): + return "network_timeout" + if any(keyword in lowered for keyword in NETWORK_TLS_ERROR_KEYWORDS): + return "network_tls_error" + if "connection reset" in lowered: + return "network_connection_reset" + if "connection refused" in lowered: + return "network_connection_refused" + if "network is unreachable" in lowered: + return "network_unreachable" + return "network_error_missing_status_code" + + +def classify_block_or_captcha_reason( + *, + status_code: int, + final_url: str, + body_lowered: str, +) -> str | None: + if "accounts.google.com" in final_url and ("signin" in final_url or "servicelogin" in final_url): + return "blocked_accounts_redirect" + if status_code == 429: + return "blocked_http_429_rate_limited" + if status_code == 403: + if "recaptcha" in body_lowered or "captcha" in body_lowered or "sorry/index" in final_url: + return "blocked_http_403_captcha_challenge" + return "blocked_http_403_forbidden" + if "sorry/index" in final_url or "sorry/index" in body_lowered: + return "blocked_google_sorry_challenge" + if "our systems have detected" in body_lowered or "unusual traffic" in body_lowered: + return "blocked_unusual_traffic_detected" + if "automated queries" in body_lowered: + return "blocked_automated_queries_detected" + if "not a robot" in body_lowered: + return "blocked_not_a_robot_challenge" + if "recaptcha" in body_lowered: + return "blocked_recaptcha_challenge" + if "captcha" in body_lowered: + return "blocked_captcha_challenge" + if any(keyword in body_lowered for keyword in BLOCKED_KEYWORDS): + return "blocked_keyword_detected" + return None + + +def detect_state( + fetch_result: FetchResult, + publications: list[PublicationCandidate], + marker_counts: dict[str, int], + *, + visible_text: str, +) -> tuple[ParseState, str]: + if fetch_result.status_code is None: + return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) + + lowered = fetch_result.body.lower() + final = (fetch_result.final_url or "").lower() + status_code = int(fetch_result.status_code) + + block_reason = classify_block_or_captcha_reason( + status_code=status_code, + final_url=final, + body_lowered=lowered, + ) + if block_reason is not None: + return ParseState.BLOCKED_OR_CAPTCHA, block_reason + + if not publications and any(keyword in visible_text for keyword in NO_RESULTS_KEYWORDS): + return ParseState.NO_RESULTS, "no_results_keyword_detected" + + if not publications: + has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0 + has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0 + if not has_profile_markers and not has_table_markers: + return ParseState.LAYOUT_CHANGED, "layout_markers_missing" + return ParseState.OK, "no_rows_with_known_markers" + + return ParseState.OK, "publications_extracted" + + +def detect_author_search_state( + fetch_result: FetchResult, + candidates: list[ScholarSearchCandidate], + marker_counts: dict[str, int], + *, + visible_text: str, +) -> tuple[ParseState, str]: + if fetch_result.status_code is None: + return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) + + lowered = fetch_result.body.lower() + final = (fetch_result.final_url or "").lower() + status_code = int(fetch_result.status_code) + + block_reason = classify_block_or_captcha_reason( + status_code=status_code, + final_url=final, + body_lowered=lowered, + ) + if block_reason is not None: + return ParseState.BLOCKED_OR_CAPTCHA, block_reason + + if not candidates and any(keyword in visible_text for keyword in NO_AUTHOR_RESULTS_KEYWORDS): + return ParseState.NO_RESULTS, "no_results_keyword_detected" + + if not candidates: + has_search_markers = marker_counts.get("gsc_1usr", 0) > 0 or marker_counts.get("gs_ai_name", 0) > 0 + if not has_search_markers: + return ParseState.NO_RESULTS, "no_search_candidates_detected" + return ParseState.OK, "search_markers_present_with_empty_results" + + return ParseState.OK, "author_candidates_extracted" + + +def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: + publications, warnings = parse_publications(fetch_result.body) + marker_counts = count_markers(fetch_result.body) + visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() + + show_more = has_show_more_button(fetch_result.body) + operation_error_banner = has_operation_error_banner(fetch_result.body) + + if show_more: + warnings.append("possible_partial_page_show_more_present") + if operation_error_banner: + warnings.append("operation_error_banner_present") + + warnings = sorted(set(warnings)) + + state, state_reason = detect_state( + fetch_result, + publications, + marker_counts, + visible_text=visible_text, + ) + + return ParsedProfilePage( + state=state, + state_reason=state_reason, + profile_name=extract_profile_name(fetch_result.body), + profile_image_url=extract_profile_image_url(fetch_result.body), + publications=publications, + marker_counts=marker_counts, + warnings=warnings, + has_show_more_button=show_more, + has_operation_error_banner=operation_error_banner, + articles_range=extract_articles_range(fetch_result.body), + ) + + +def parse_author_search_page(fetch_result: FetchResult) -> ParsedAuthorSearchPage: + parser = ScholarAuthorSearchParser() + parser.feed(fetch_result.body) + + marker_counts = count_author_search_markers(fetch_result.body) + visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() + warnings: list[str] = [] + if not parser.candidates: + warnings.append("no_author_candidates_detected") + + state, state_reason = detect_author_search_state( + fetch_result, + parser.candidates, + marker_counts, + visible_text=visible_text, + ) + + return ParsedAuthorSearchPage( + state=state, + state_reason=state_reason, + candidates=parser.candidates, + marker_counts=marker_counts, + warnings=warnings, + ) diff --git a/app/services/scholar_source.py b/app/services/scholar_source.py new file mode 100644 index 0000000..a1d4b47 --- /dev/null +++ b/app/services/scholar_source.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import asyncio +import logging +import random +from dataclasses import dataclass +from typing import Protocol +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +SCHOLAR_PROFILE_URL = "https://scholar.google.com/citations" +DEFAULT_PAGE_SIZE = 100 + +DEFAULT_USER_AGENTS = [ + ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/18.1 Safari/605.1.15" + ), + ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) " + "Gecko/20100101 Firefox/131.0" + ), +] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class FetchResult: + requested_url: str + status_code: int | None + final_url: str | None + body: str + error: str | None + + +class ScholarSource(Protocol): + async def fetch_profile_html(self, scholar_id: str) -> FetchResult: + ... + + async def fetch_profile_page_html( + self, + scholar_id: str, + *, + cstart: int, + pagesize: int, + ) -> FetchResult: + ... + + async def fetch_author_search_html( + self, + query: str, + *, + start: int, + ) -> FetchResult: + ... + + +class LiveScholarSource: + def __init__( + self, + *, + timeout_seconds: float = 25.0, + user_agents: list[str] | None = None, + ) -> None: + self._timeout_seconds = timeout_seconds + self._user_agents = user_agents or DEFAULT_USER_AGENTS + + async def fetch_profile_html(self, scholar_id: str) -> FetchResult: + return await self.fetch_profile_page_html( + scholar_id, + cstart=0, + pagesize=DEFAULT_PAGE_SIZE, + ) + + async def fetch_profile_page_html( + self, + scholar_id: str, + *, + cstart: int, + pagesize: int = DEFAULT_PAGE_SIZE, + ) -> FetchResult: + requested_url = _build_profile_url( + scholar_id=scholar_id, + cstart=cstart, + pagesize=pagesize, + ) + logger.debug( + "scholar_source.fetch_started", + extra={ + "event": "scholar_source.fetch_started", + "scholar_id": scholar_id, + "requested_url": requested_url, + "cstart": cstart, + "pagesize": pagesize, + }, + ) + return await asyncio.to_thread(self._fetch_sync, requested_url) + + async def fetch_author_search_html( + self, + query: str, + *, + start: int = 0, + ) -> FetchResult: + requested_url = _build_author_search_url( + query=query, + start=start, + ) + logger.debug( + "scholar_source.search_fetch_started", + extra={ + "event": "scholar_source.search_fetch_started", + "query": query, + "requested_url": requested_url, + "start": start, + }, + ) + return await asyncio.to_thread(self._fetch_sync, requested_url) + + def _fetch_sync(self, requested_url: str) -> FetchResult: + request = Request( + requested_url, + headers={ + "User-Agent": random.choice(self._user_agents), + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "Connection": "close", + }, + ) + + try: + with urlopen(request, timeout=self._timeout_seconds) as response: + body = response.read().decode("utf-8", errors="replace") + status_code = getattr(response, "status", 200) + logger.debug( + "scholar_source.fetch_succeeded", + extra={ + "event": "scholar_source.fetch_succeeded", + "requested_url": requested_url, + "status_code": status_code, + }, + ) + return FetchResult( + requested_url=requested_url, + status_code=status_code, + final_url=response.geturl(), + body=body, + error=None, + ) + except HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8", errors="replace") + except Exception: + body = "" + logger.warning( + "scholar_source.fetch_http_error", + extra={ + "event": "scholar_source.fetch_http_error", + "requested_url": requested_url, + "status_code": exc.code, + }, + ) + return FetchResult( + requested_url=requested_url, + status_code=exc.code, + final_url=exc.geturl(), + body=body, + error=str(exc), + ) + except URLError as exc: + logger.warning( + "scholar_source.fetch_network_error", + extra={ + "event": "scholar_source.fetch_network_error", + "requested_url": requested_url, + }, + ) + return FetchResult( + requested_url=requested_url, + status_code=None, + final_url=None, + body="", + error=str(exc), + ) + + +def _build_profile_url(*, scholar_id: str, cstart: int, pagesize: int) -> str: + query: dict[str, int | str] = {"hl": "en", "user": scholar_id} + if cstart > 0: + query["cstart"] = int(cstart) + if pagesize > 0: + query["pagesize"] = int(pagesize) + return f"{SCHOLAR_PROFILE_URL}?{urlencode(query)}" + + +def _build_author_search_url(*, query: str, start: int) -> str: + params: dict[str, int | str] = { + "hl": "en", + "view_op": "search_authors", + "mauthors": query, + } + if start > 0: + params["astart"] = int(start) + return f"{SCHOLAR_PROFILE_URL}?{urlencode(params)}" diff --git a/app/services/scholars.py b/app/services/scholars.py new file mode 100644 index 0000000..1abee78 --- /dev/null +++ b/app/services/scholars.py @@ -0,0 +1,926 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from datetime import datetime +from datetime import timedelta +from datetime import timezone +import logging +import os +import random +import re +from pathlib import Path +from urllib.parse import urlparse +from uuid import uuid4 + +from sqlalchemy import delete, func, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import AuthorSearchCacheEntry, AuthorSearchRuntimeState, ScholarProfile +from app.services.scholar_parser import ( + ParseState, + ParsedAuthorSearchPage, + parse_author_search_page, + parse_profile_page, +) +from app.services.scholar_source import ScholarSource + +SCHOLAR_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{12}$") +MAX_IMAGE_URL_LENGTH = 2048 +MAX_AUTHOR_SEARCH_LIMIT = 25 +DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES = 512 +DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS = 300 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD = 1 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800 +DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0 +DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0 +DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD = 2 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD = 3 +AUTHOR_SEARCH_RUNTIME_STATE_KEY = "global" +AUTHOR_SEARCH_LOCK_NAMESPACE = 3901 +AUTHOR_SEARCH_LOCK_KEY = 1 +ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", +} +SEARCH_DISABLED_REASON = "search_disabled_by_configuration" +SEARCH_COOLDOWN_REASON = "search_temporarily_disabled_due_to_repeated_blocks" +SEARCH_CACHED_BLOCK_REASON = "search_temporarily_disabled_from_cached_blocked_response" + +STATE_REASON_HINTS: dict[str, str] = { + SEARCH_DISABLED_REASON: ( + "Scholar name search is currently disabled by service policy. " + "Add scholars by profile URL or Scholar ID." + ), + SEARCH_COOLDOWN_REASON: ( + "Scholar name search is temporarily paused after repeated block responses. " + "Use Scholar URL/ID adds until cooldown expires." + ), + SEARCH_CACHED_BLOCK_REASON: ( + "A recent blocked response was cached to reduce traffic. " + "Retry later or add by Scholar URL/ID." + ), + "network_dns_resolution_failed": ( + "DNS resolution failed while reaching scholar.google.com. " + "Verify container DNS/network and retry." + ), + "network_timeout": ( + "Request timed out before Google Scholar responded. " + "Increase delay/backoff and retry." + ), + "network_tls_error": ( + "TLS handshake/certificate validation failed. " + "Verify outbound TLS/network configuration." + ), + "blocked_http_429_rate_limited": ( + "Google Scholar rate-limited the request. " + "Slow request cadence and retry later." + ), + "blocked_unusual_traffic_detected": ( + "Google Scholar flagged traffic as unusual. " + "Increase delay/jitter and reduce concurrent scraping." + ), + "blocked_accounts_redirect": ( + "Request was redirected to Google Account sign-in. " + "Treat as access block and retry later." + ), +} +logger = logging.getLogger(__name__) + + +class ScholarServiceError(ValueError): + """Raised for expected scholar-management validation failures.""" + + +def validate_scholar_id(value: str) -> str: + scholar_id = value.strip() + if not SCHOLAR_ID_PATTERN.fullmatch(scholar_id): + raise ScholarServiceError("Scholar ID must match [a-zA-Z0-9_-]{12}.") + return scholar_id + + +def normalize_display_name(value: str) -> str | None: + normalized = value.strip() + return normalized if normalized else None + + +def normalize_profile_image_url(value: str | None) -> str | None: + if value is None: + return None + + candidate = value.strip() + if not candidate: + return None + + if len(candidate) > MAX_IMAGE_URL_LENGTH: + raise ScholarServiceError( + f"Image URL must be {MAX_IMAGE_URL_LENGTH} characters or fewer." + ) + + parsed = urlparse(candidate) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ScholarServiceError("Image URL must be an absolute http(s) URL.") + + return candidate + + +def _ensure_upload_root(upload_dir: str, *, create: bool) -> Path: + root = Path(upload_dir).expanduser().resolve() + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def _resolve_upload_path(upload_root: Path, relative_path: str) -> Path: + candidate = (upload_root / relative_path).resolve() + if upload_root != candidate and upload_root not in candidate.parents: + raise ScholarServiceError("Invalid scholar image path.") + return candidate + + +def _safe_remove_upload(upload_root: Path, relative_path: str | None) -> None: + if not relative_path: + return + try: + file_path = _resolve_upload_path(upload_root, relative_path) + except ScholarServiceError: + return + + try: + if file_path.exists() and file_path.is_file(): + file_path.unlink() + except OSError: + return + + +def resolve_profile_image( + profile: ScholarProfile, + *, + uploaded_image_url: str | None, +) -> tuple[str | None, str]: + if profile.profile_image_upload_path and uploaded_image_url: + return uploaded_image_url, "upload" + if profile.profile_image_override_url: + return profile.profile_image_override_url, "override" + if profile.profile_image_url: + return profile.profile_image_url, "scraped" + return None, "none" + + +def resolve_upload_file_path(*, upload_dir: str, relative_path: str) -> Path: + root = _ensure_upload_root(upload_dir, create=False) + return _resolve_upload_path(root, relative_path) + + +def scrape_state_hint(*, state: ParseState, state_reason: str) -> str | None: + if state not in {ParseState.NETWORK_ERROR, ParseState.BLOCKED_OR_CAPTCHA}: + return None + return STATE_REASON_HINTS.get(state_reason) + + +def _merge_warnings(base: list[str], extra: list[str]) -> list[str]: + if not extra: + return sorted(set(base)) + return sorted(set(base + extra)) + + +def _trim_author_search_result( + parsed: ParsedAuthorSearchPage, + *, + limit: int, + extra_warnings: list[str] | None = None, + state_reason_override: str | None = None, +) -> ParsedAuthorSearchPage: + return ParsedAuthorSearchPage( + state=parsed.state, + state_reason=state_reason_override or parsed.state_reason, + candidates=parsed.candidates[: max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT))], + marker_counts=parsed.marker_counts, + warnings=_merge_warnings(parsed.warnings, extra_warnings or []), + ) + + +def _policy_blocked_author_search_result( + *, + reason: str, + warning_codes: list[str], + limit: int, +) -> ParsedAuthorSearchPage: + _ = limit + return ParsedAuthorSearchPage( + state=ParseState.BLOCKED_OR_CAPTCHA, + state_reason=reason, + candidates=[], + marker_counts={}, + warnings=_merge_warnings([], warning_codes), + ) + + +async def _acquire_author_search_lock(db_session: AsyncSession) -> None: + await db_session.execute( + text("SELECT pg_advisory_xact_lock(:namespace, :lock_key)"), + { + "namespace": AUTHOR_SEARCH_LOCK_NAMESPACE, + "lock_key": AUTHOR_SEARCH_LOCK_KEY, + }, + ) + + +async def _load_runtime_state_for_update( + db_session: AsyncSession, +) -> AuthorSearchRuntimeState: + result = await db_session.execute( + select(AuthorSearchRuntimeState) + .where(AuthorSearchRuntimeState.state_key == AUTHOR_SEARCH_RUNTIME_STATE_KEY) + .with_for_update() + ) + state = result.scalar_one_or_none() + if state is not None: + return state + + state = AuthorSearchRuntimeState(state_key=AUTHOR_SEARCH_RUNTIME_STATE_KEY) + db_session.add(state) + await db_session.flush() + return state + + +def _serialize_parsed_author_search_page(parsed: ParsedAuthorSearchPage) -> dict: + return { + "state": parsed.state.value, + "state_reason": parsed.state_reason, + "marker_counts": {str(key): int(value) for key, value in parsed.marker_counts.items()}, + "warnings": [str(value) for value in parsed.warnings], + "candidates": [ + { + "scholar_id": candidate.scholar_id, + "display_name": candidate.display_name, + "affiliation": candidate.affiliation, + "email_domain": candidate.email_domain, + "cited_by_count": candidate.cited_by_count, + "interests": [str(interest) for interest in candidate.interests], + "profile_url": candidate.profile_url, + "profile_image_url": candidate.profile_image_url, + } + for candidate in parsed.candidates + ], + } + + +def _deserialize_parsed_author_search_page(payload: object) -> ParsedAuthorSearchPage | None: + if not isinstance(payload, dict): + return None + + state_raw = str(payload.get("state", "")).strip() + try: + state = ParseState(state_raw) + except ValueError: + return None + + marker_counts_payload = payload.get("marker_counts") + marker_counts = ( + {str(key): int(value) for key, value in marker_counts_payload.items()} + if isinstance(marker_counts_payload, dict) + else {} + ) + warnings_payload = payload.get("warnings") + warnings = ( + [str(value) for value in warnings_payload if isinstance(value, str)] + if isinstance(warnings_payload, list) + else [] + ) + from app.services.scholar_parser import ScholarSearchCandidate + + candidates_payload = payload.get("candidates") + normalized_candidates = [] + for value in candidates_payload if isinstance(candidates_payload, list) else []: + if not isinstance(value, dict): + continue + scholar_id = str(value.get("scholar_id", "")).strip() + display_name = str(value.get("display_name", "")).strip() + profile_url = str(value.get("profile_url", "")).strip() + if not scholar_id or not display_name or not profile_url: + continue + interests_payload = value.get("interests") + interests = ( + [str(item) for item in interests_payload if isinstance(item, str)] + if isinstance(interests_payload, list) + else [] + ) + cited_by_count = value.get("cited_by_count") + parsed_cited_by_count: int | None = None + if isinstance(cited_by_count, int): + parsed_cited_by_count = cited_by_count + elif isinstance(cited_by_count, str) and cited_by_count.strip(): + try: + parsed_cited_by_count = int(cited_by_count) + except ValueError: + parsed_cited_by_count = None + normalized_candidates.append( + { + "scholar_id": scholar_id, + "display_name": display_name, + "affiliation": str(value.get("affiliation")).strip() + if value.get("affiliation") is not None + else None, + "email_domain": str(value.get("email_domain")).strip() + if value.get("email_domain") is not None + else None, + "cited_by_count": parsed_cited_by_count, + "interests": interests, + "profile_url": profile_url, + "profile_image_url": str(value.get("profile_image_url")).strip() + if value.get("profile_image_url") is not None + else None, + } + ) + + return ParsedAuthorSearchPage( + state=state, + state_reason=str(payload.get("state_reason", "")).strip() or "unknown", + candidates=[ + ScholarSearchCandidate( + scholar_id=item["scholar_id"], + display_name=item["display_name"], + affiliation=item["affiliation"], + email_domain=item["email_domain"], + cited_by_count=item["cited_by_count"], + interests=item["interests"], + profile_url=item["profile_url"], + profile_image_url=item["profile_image_url"], + ) + for item in normalized_candidates + ], + marker_counts=marker_counts, + warnings=warnings, + ) + + +async def _cache_get_author_search_result( + db_session: AsyncSession, + *, + query_key: str, + now_utc: datetime, +) -> ParsedAuthorSearchPage | None: + result = await db_session.execute( + select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) + ) + entry = result.scalar_one_or_none() + if entry is None: + return None + expires_at = entry.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at <= now_utc: + await db_session.delete(entry) + return None + parsed = _deserialize_parsed_author_search_page(entry.payload) + if parsed is None: + await db_session.delete(entry) + return None + return parsed + + +async def _cache_set_author_search_result( + db_session: AsyncSession, + *, + query_key: str, + parsed: ParsedAuthorSearchPage, + ttl_seconds: float, + max_entries: int, + now_utc: datetime, +) -> None: + ttl = max(float(ttl_seconds), 0.0) + existing_result = await db_session.execute( + select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) + ) + existing = existing_result.scalar_one_or_none() + + if ttl <= 0.0: + if existing is not None: + await db_session.delete(existing) + return + + expires_at = now_utc + timedelta(seconds=ttl) + payload = _serialize_parsed_author_search_page(parsed) + if existing is None: + db_session.add( + AuthorSearchCacheEntry( + query_key=query_key, + payload=payload, + expires_at=expires_at, + cached_at=now_utc, + updated_at=now_utc, + ) + ) + else: + existing.payload = payload + existing.expires_at = expires_at + existing.cached_at = now_utc + existing.updated_at = now_utc + + await _prune_author_search_cache(db_session, now_utc=now_utc, max_entries=max_entries) + + +async def _prune_author_search_cache( + db_session: AsyncSession, + *, + now_utc: datetime, + max_entries: int, +) -> None: + await db_session.execute( + delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.expires_at <= now_utc) + ) + bounded_max_entries = max(1, int(max_entries)) + count_result = await db_session.execute( + select(func.count()).select_from(AuthorSearchCacheEntry) + ) + entry_count = int(count_result.scalar_one() or 0) + overflow = max(0, entry_count - bounded_max_entries) + if overflow <= 0: + return + stale_keys_result = await db_session.execute( + select(AuthorSearchCacheEntry.query_key) + .order_by(AuthorSearchCacheEntry.cached_at.asc()) + .limit(overflow) + ) + stale_keys = [str(row[0]) for row in stale_keys_result.all()] + if stale_keys: + await db_session.execute( + delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key.in_(stale_keys)) + ) + + +def _is_author_search_block_state(parsed: ParsedAuthorSearchPage) -> bool: + return parsed.state == ParseState.BLOCKED_OR_CAPTCHA + + +def _author_search_cooldown_remaining_seconds( + runtime_state: AuthorSearchRuntimeState, + now_utc: datetime, +) -> int: + cooldown_until = runtime_state.cooldown_until + if cooldown_until is None: + return 0 + if cooldown_until.tzinfo is None: + cooldown_until = cooldown_until.replace(tzinfo=timezone.utc) + remaining_seconds = int((cooldown_until - now_utc).total_seconds()) + return max(0, remaining_seconds) + + +def _reset_author_search_runtime_state_for_tests() -> None: + # Runtime state now lives in the database; tests should reset via DB fixtures. + return None + + +async def list_scholars_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> list[ScholarProfile]: + result = await db_session.execute( + select(ScholarProfile) + .where(ScholarProfile.user_id == user_id) + .order_by(ScholarProfile.created_at.desc(), ScholarProfile.id.desc()) + ) + return list(result.scalars().all()) + + +async def create_scholar_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_id: str, + display_name: str, + profile_image_url: str | None = None, +) -> ScholarProfile: + profile = ScholarProfile( + user_id=user_id, + scholar_id=validate_scholar_id(scholar_id), + display_name=normalize_display_name(display_name), + profile_image_url=normalize_profile_image_url(profile_image_url), + ) + db_session.add(profile) + try: + await db_session.commit() + except IntegrityError as exc: + await db_session.rollback() + raise ScholarServiceError("That scholar is already tracked for this account.") from exc + await db_session.refresh(profile) + return profile + + +async def get_user_scholar_by_id( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, +) -> ScholarProfile | None: + result = await db_session.execute( + select(ScholarProfile).where( + ScholarProfile.id == scholar_profile_id, + ScholarProfile.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + +async def toggle_scholar_enabled( + db_session: AsyncSession, + *, + profile: ScholarProfile, +) -> ScholarProfile: + profile.is_enabled = not profile.is_enabled + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def delete_scholar( + db_session: AsyncSession, + *, + profile: ScholarProfile, + upload_dir: str | None = None, +) -> None: + if upload_dir: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + await db_session.delete(profile) + await db_session.commit() + + +async def search_author_candidates( + *, + source: ScholarSource, + db_session: AsyncSession, + query: str, + limit: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + search_enabled: bool = True, + cache_ttl_seconds: int = 21_600, + blocked_cache_ttl_seconds: int = DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, + cache_max_entries: int = DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, + min_interval_seconds: float = DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, + interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, + cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, + cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, + retry_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, + cooldown_rejection_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD, +) -> ParsedAuthorSearchPage: + normalized_query = query.strip() + if len(normalized_query) < 2: + raise ScholarServiceError("Search query must be at least 2 characters.") + bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) + query_key = normalized_query.casefold() + + if not search_enabled: + logger.warning( + "scholar_search.disabled_by_configuration", + extra={ + "event": "scholar_search.disabled_by_configuration", + "query": normalized_query, + }, + ) + return _policy_blocked_author_search_result( + reason=SEARCH_DISABLED_REASON, + warning_codes=["author_search_disabled_by_configuration"], + limit=bounded_limit, + ) + + await _acquire_author_search_lock(db_session) + runtime_state = await _load_runtime_state_for_update(db_session) + runtime_state_updated = False + now_utc = datetime.now(timezone.utc) + + if runtime_state.cooldown_until is not None: + cooldown_until = runtime_state.cooldown_until + if cooldown_until.tzinfo is None: + cooldown_until = cooldown_until.replace(tzinfo=timezone.utc) + runtime_state.cooldown_until = cooldown_until + runtime_state_updated = True + if now_utc >= cooldown_until: + logger.info( + "scholar_search.cooldown_expired", + extra={ + "event": "scholar_search.cooldown_expired", + "cooldown_until_utc": cooldown_until.isoformat(), + }, + ) + runtime_state.cooldown_until = None + runtime_state.cooldown_rejection_count = 0 + runtime_state.cooldown_alert_emitted = False + runtime_state_updated = True + + cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(runtime_state, now_utc) + if cooldown_remaining_seconds > 0: + runtime_state.cooldown_rejection_count = int(runtime_state.cooldown_rejection_count) + 1 + bounded_cooldown_rejection_alert_threshold = max( + 1, + int(cooldown_rejection_alert_threshold), + ) + if ( + int(runtime_state.cooldown_rejection_count) >= bounded_cooldown_rejection_alert_threshold + and not bool(runtime_state.cooldown_alert_emitted) + ): + logger.error( + "scholar_search.cooldown_rejection_threshold_exceeded", + extra={ + "event": "scholar_search.cooldown_rejection_threshold_exceeded", + "query": normalized_query, + "cooldown_rejection_count": int(runtime_state.cooldown_rejection_count), + "threshold": bounded_cooldown_rejection_alert_threshold, + "cooldown_until_utc": runtime_state.cooldown_until.isoformat() + if runtime_state.cooldown_until + else None, + }, + ) + runtime_state.cooldown_alert_emitted = True + runtime_state_updated = True + + logger.warning( + "scholar_search.cooldown_active", + extra={ + "event": "scholar_search.cooldown_active", + "query": normalized_query, + "cooldown_remaining_seconds": cooldown_remaining_seconds, + "cooldown_until_utc": runtime_state.cooldown_until.isoformat() + if runtime_state.cooldown_until + else None, + }, + ) + warning_codes = [ + "author_search_cooldown_active", + f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", + ] + if bool(runtime_state.cooldown_alert_emitted): + warning_codes.append("author_search_cooldown_alert_threshold_exceeded") + if runtime_state_updated: + await db_session.commit() + return _policy_blocked_author_search_result( + reason=SEARCH_COOLDOWN_REASON, + warning_codes=warning_codes, + limit=bounded_limit, + ) + + cached = await _cache_get_author_search_result( + db_session, + query_key=query_key, + now_utc=now_utc, + ) + if cached is not None: + state_reason_override = ( + SEARCH_CACHED_BLOCK_REASON if _is_author_search_block_state(cached) else None + ) + logger.info( + "scholar_search.cache_hit", + extra={ + "event": "scholar_search.cache_hit", + "query": normalized_query, + "state": cached.state.value, + "state_reason": cached.state_reason, + }, + ) + await db_session.commit() + return _trim_author_search_result( + cached, + limit=bounded_limit, + extra_warnings=["author_search_served_from_cache"], + state_reason_override=state_reason_override, + ) + + if runtime_state.last_live_request_at is not None: + last_live_request_at = runtime_state.last_live_request_at + if last_live_request_at.tzinfo is None: + last_live_request_at = last_live_request_at.replace(tzinfo=timezone.utc) + runtime_state.last_live_request_at = last_live_request_at + runtime_state_updated = True + enforced_wait_seconds = ( + last_live_request_at + timedelta(seconds=max(float(min_interval_seconds), 0.0)) - now_utc + ).total_seconds() + else: + enforced_wait_seconds = 0.0 + + jitter_seconds = random.uniform(0.0, max(float(interval_jitter_seconds), 0.0)) + sleep_seconds = max(0.0, float(enforced_wait_seconds)) + jitter_seconds + if sleep_seconds > 0.0: + logger.info( + "scholar_search.throttle_wait", + extra={ + "event": "scholar_search.throttle_wait", + "query": normalized_query, + "sleep_seconds": round(sleep_seconds, 3), + }, + ) + await asyncio.sleep(sleep_seconds) + + max_attempts = max(1, int(network_error_retries) + 1) + parsed: ParsedAuthorSearchPage | None = None + retry_warnings: list[str] = [] + retry_scheduled_count = 0 + + for attempt_index in range(max_attempts): + fetch_result = await source.fetch_author_search_html(normalized_query, start=0) + parsed = parse_author_search_page(fetch_result) + if parsed.state != ParseState.NETWORK_ERROR or attempt_index >= max_attempts - 1: + break + + retry_warnings.append("network_retry_scheduled_for_author_search") + retry_scheduled_count += 1 + retry_sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) + if retry_sleep_seconds > 0: + await asyncio.sleep(retry_sleep_seconds) + + runtime_state.last_live_request_at = datetime.now(timezone.utc) + runtime_state_updated = True + + if parsed is None: + raise ScholarServiceError("Unable to complete scholar author search.") + + merged_parsed = replace( + parsed, + warnings=_merge_warnings(parsed.warnings, retry_warnings), + ) + + bounded_retry_alert_threshold = max(1, int(retry_alert_threshold)) + if retry_scheduled_count >= bounded_retry_alert_threshold: + logger.warning( + "scholar_search.retry_threshold_exceeded", + extra={ + "event": "scholar_search.retry_threshold_exceeded", + "query": normalized_query, + "retry_scheduled_count": retry_scheduled_count, + "threshold": bounded_retry_alert_threshold, + "final_state": merged_parsed.state.value, + "final_state_reason": merged_parsed.state_reason, + }, + ) + merged_parsed = replace( + merged_parsed, + warnings=_merge_warnings( + merged_parsed.warnings, + [f"author_search_retry_threshold_exceeded_{retry_scheduled_count}"], + ), + ) + + if _is_author_search_block_state(merged_parsed): + runtime_state.consecutive_blocked_count = int(runtime_state.consecutive_blocked_count) + 1 + logger.warning( + "scholar_search.block_detected", + extra={ + "event": "scholar_search.block_detected", + "query": normalized_query, + "state_reason": merged_parsed.state_reason, + "consecutive_blocked_count": int(runtime_state.consecutive_blocked_count), + }, + ) + if int(runtime_state.consecutive_blocked_count) >= max(1, int(cooldown_block_threshold)): + runtime_state.cooldown_until = datetime.now(timezone.utc) + timedelta( + seconds=max(60, int(cooldown_seconds)) + ) + runtime_state.consecutive_blocked_count = 0 + runtime_state.cooldown_rejection_count = 0 + runtime_state.cooldown_alert_emitted = False + merged_parsed = replace( + merged_parsed, + warnings=_merge_warnings( + merged_parsed.warnings, + ["author_search_circuit_breaker_armed"], + ), + ) + logger.error( + "scholar_search.cooldown_activated", + extra={ + "event": "scholar_search.cooldown_activated", + "query": normalized_query, + "cooldown_until_utc": runtime_state.cooldown_until.isoformat() + if runtime_state.cooldown_until + else None, + }, + ) + else: + runtime_state.consecutive_blocked_count = 0 + + ttl_seconds = ( + min(max(1, int(blocked_cache_ttl_seconds)), max(1, int(cache_ttl_seconds))) + if _is_author_search_block_state(merged_parsed) + else max(1, int(cache_ttl_seconds)) + ) + await _cache_set_author_search_result( + db_session, + query_key=query_key, + parsed=merged_parsed, + ttl_seconds=float(ttl_seconds), + max_entries=cache_max_entries, + now_utc=datetime.now(timezone.utc), + ) + if runtime_state_updated: + await db_session.commit() + + return _trim_author_search_result( + merged_parsed, + limit=bounded_limit, + ) + + +async def hydrate_profile_metadata( + db_session: AsyncSession, + *, + profile: ScholarProfile, + source: ScholarSource, +) -> ScholarProfile: + fetch_result = await source.fetch_profile_html(profile.scholar_id) + parsed_page = parse_profile_page(fetch_result) + + if parsed_page.profile_name and not (profile.display_name or "").strip(): + profile.display_name = parsed_page.profile_name + if parsed_page.profile_image_url and not profile.profile_image_url: + profile.profile_image_url = parsed_page.profile_image_url + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def set_profile_image_override_url( + db_session: AsyncSession, + *, + profile: ScholarProfile, + image_url: str | None, + upload_dir: str, +) -> ScholarProfile: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + profile.profile_image_upload_path = None + profile.profile_image_override_url = normalize_profile_image_url(image_url) + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def clear_profile_image_customization( + db_session: AsyncSession, + *, + profile: ScholarProfile, + upload_dir: str, +) -> ScholarProfile: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + profile.profile_image_upload_path = None + profile.profile_image_override_url = None + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def set_profile_image_upload( + db_session: AsyncSession, + *, + profile: ScholarProfile, + content_type: str | None, + image_bytes: bytes, + upload_dir: str, + max_upload_bytes: int, +) -> ScholarProfile: + normalized_content_type = (content_type or "").strip().lower() + extension = ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES.get(normalized_content_type) + if extension is None: + raise ScholarServiceError( + "Unsupported image type. Use JPEG, PNG, WEBP, or GIF." + ) + + if not image_bytes: + raise ScholarServiceError("Uploaded image file is empty.") + + if len(image_bytes) > max_upload_bytes: + raise ScholarServiceError( + f"Uploaded image exceeds {max_upload_bytes} bytes." + ) + + upload_root = _ensure_upload_root(upload_dir, create=True) + user_dir = upload_root / str(profile.user_id) + user_dir.mkdir(parents=True, exist_ok=True) + + filename = f"{profile.id}_{uuid4().hex}{extension}" + relative_path = os.path.join(str(profile.user_id), filename) + absolute_path = _resolve_upload_path(upload_root, relative_path) + absolute_path.write_bytes(image_bytes) + + old_path = profile.profile_image_upload_path + profile.profile_image_upload_path = relative_path + profile.profile_image_override_url = None + + await db_session.commit() + await db_session.refresh(profile) + + if old_path and old_path != relative_path: + _safe_remove_upload(upload_root, old_path) + + return profile diff --git a/app/services/scholars/__init__.py b/app/services/scholars/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/app/services/scholars/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/app/services/scholars/application.py b/app/services/scholars/application.py deleted file mode 100644 index 3496732..0000000 --- a/app/services/scholars/application.py +++ /dev/null @@ -1,288 +0,0 @@ -from __future__ import annotations - -import os -from typing import Any -from uuid import uuid4 - -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ScholarProfile -from app.services.scholar.parser import ScholarParserError, parse_profile_page -from app.services.scholar.source import ScholarSource -from app.services.scholars.author_search import search_author_candidates -from app.services.scholars.constants import ( - ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES, - SEARCH_COOLDOWN_REASON, - SEARCH_DISABLED_REASON, -) -from app.services.scholars.exceptions import ScholarServiceError -from app.services.scholars.uploads import ( - _ensure_upload_root, - _resolve_upload_path, - _safe_remove_upload, -) -from app.services.scholars.validators import ( - normalize_display_name, - normalize_profile_image_url, - validate_scholar_id, -) - - -async def bulk_delete_scholars( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_ids: list[int], - upload_dir: str | None = None, -) -> int: - from sqlalchemy import select - - result = await db_session.execute( - select(ScholarProfile).where( - ScholarProfile.id.in_(scholar_profile_ids), - ScholarProfile.user_id == user_id, - ) - ) - profiles = list(result.scalars().all()) - if not profiles: - return 0 - if upload_dir: - upload_root = _ensure_upload_root(upload_dir, create=True) - for profile in profiles: - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - for profile in profiles: - await db_session.delete(profile) - try: - await db_session.commit() - except IntegrityError as exc: - await db_session.rollback() - raise ScholarServiceError("Unable to bulk-delete scholars due to a database constraint.") from exc - return len(profiles) - - -async def bulk_toggle_scholars( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_ids: list[int], - is_enabled: bool, -) -> int: - from sqlalchemy import CursorResult, update - - cursor: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] - update(ScholarProfile) - .where( - ScholarProfile.id.in_(scholar_profile_ids), - ScholarProfile.user_id == user_id, - ) - .values(is_enabled=is_enabled) - ) - await db_session.commit() - return int(cursor.rowcount or 0) - - -__all__ = [ - "SEARCH_COOLDOWN_REASON", - "SEARCH_DISABLED_REASON", - "ScholarServiceError", - "bulk_delete_scholars", - "bulk_toggle_scholars", - "clear_profile_image_customization", - "create_scholar_for_user", - "delete_scholar", - "get_user_scholar_by_id", - "hydrate_profile_metadata", - "list_scholars_for_user", - "normalize_display_name", - "normalize_profile_image_url", - "search_author_candidates", - "set_profile_image_override_url", - "set_profile_image_upload", - "toggle_scholar_enabled", - "validate_scholar_id", -] - - -async def list_scholars_for_user( - db_session: AsyncSession, - *, - user_id: int, -) -> list[ScholarProfile]: - from sqlalchemy import select - - result = await db_session.execute( - select(ScholarProfile) - .where(ScholarProfile.user_id == user_id) - .order_by(ScholarProfile.created_at.desc(), ScholarProfile.id.desc()) - ) - return list(result.scalars().all()) - - -async def create_scholar_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_id: str, - display_name: str, - profile_image_url: str | None = None, -) -> ScholarProfile: - profile = ScholarProfile( - user_id=user_id, - scholar_id=validate_scholar_id(scholar_id), - display_name=normalize_display_name(display_name), - profile_image_url=normalize_profile_image_url(profile_image_url), - ) - db_session.add(profile) - try: - await db_session.commit() - except IntegrityError as exc: - await db_session.rollback() - raise ScholarServiceError("That scholar is already tracked for this account.") from exc - await db_session.refresh(profile) - return profile - - -async def get_user_scholar_by_id( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, -) -> ScholarProfile | None: - from sqlalchemy import select - - result = await db_session.execute( - select(ScholarProfile).where( - ScholarProfile.id == scholar_profile_id, - ScholarProfile.user_id == user_id, - ) - ) - return result.scalar_one_or_none() - - -async def toggle_scholar_enabled( - db_session: AsyncSession, - *, - profile: ScholarProfile, -) -> ScholarProfile: - profile.is_enabled = not profile.is_enabled - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def delete_scholar( - db_session: AsyncSession, - *, - profile: ScholarProfile, - upload_dir: str | None = None, -) -> None: - if upload_dir: - upload_root = _ensure_upload_root(upload_dir, create=True) - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - - await db_session.delete(profile) - try: - await db_session.commit() - except IntegrityError as exc: - await db_session.rollback() - raise ScholarServiceError("Unable to delete scholar due to a database constraint.") from exc - - -async def hydrate_profile_metadata( - db_session: AsyncSession, - *, - profile: ScholarProfile, - source: ScholarSource, -) -> ScholarProfile: - fetch_result = await source.fetch_profile_html(profile.scholar_id) - try: - parsed_page = parse_profile_page(fetch_result) - except ScholarParserError: - return profile - - if parsed_page.profile_name and not (profile.display_name or "").strip(): - profile.display_name = parsed_page.profile_name - if parsed_page.profile_image_url and not profile.profile_image_url: - profile.profile_image_url = parsed_page.profile_image_url - - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def set_profile_image_override_url( - db_session: AsyncSession, - *, - profile: ScholarProfile, - image_url: str | None, - upload_dir: str, -) -> ScholarProfile: - upload_root = _ensure_upload_root(upload_dir, create=True) - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - - profile.profile_image_upload_path = None - profile.profile_image_override_url = normalize_profile_image_url(image_url) - - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def clear_profile_image_customization( - db_session: AsyncSession, - *, - profile: ScholarProfile, - upload_dir: str, -) -> ScholarProfile: - upload_root = _ensure_upload_root(upload_dir, create=True) - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - - profile.profile_image_upload_path = None - profile.profile_image_override_url = None - - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def set_profile_image_upload( - db_session: AsyncSession, - *, - profile: ScholarProfile, - content_type: str | None, - image_bytes: bytes, - upload_dir: str, - max_upload_bytes: int, -) -> ScholarProfile: - normalized_content_type = (content_type or "").strip().lower() - extension = ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES.get(normalized_content_type) - if extension is None: - raise ScholarServiceError("Unsupported image type. Use JPEG, PNG, WEBP, or GIF.") - - if not image_bytes: - raise ScholarServiceError("Uploaded image file is empty.") - - if len(image_bytes) > max_upload_bytes: - raise ScholarServiceError(f"Uploaded image exceeds {max_upload_bytes} bytes.") - - upload_root = _ensure_upload_root(upload_dir, create=True) - user_dir = upload_root / str(profile.user_id) - user_dir.mkdir(parents=True, exist_ok=True) - - filename = f"{profile.id}_{uuid4().hex}{extension}" - relative_path = os.path.join(str(profile.user_id), filename) - absolute_path = _resolve_upload_path(upload_root, relative_path) - absolute_path.write_bytes(image_bytes) - - old_path = profile.profile_image_upload_path - profile.profile_image_upload_path = relative_path - profile.profile_image_override_url = None - - await db_session.commit() - await db_session.refresh(profile) - - if old_path and old_path != relative_path: - _safe_remove_upload(upload_root, old_path) - - return profile diff --git a/app/services/scholars/author_search.py b/app/services/scholars/author_search.py deleted file mode 100644 index a9a9a02..0000000 --- a/app/services/scholars/author_search.py +++ /dev/null @@ -1,580 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import random -from dataclasses import replace -from datetime import UTC, datetime, timedelta - -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import AuthorSearchRuntimeState -from app.logging_utils import structured_log -from app.services.scholar.parser import ( - ParsedAuthorSearchPage, - ParseState, - ScholarParserError, - parse_author_search_page, -) -from app.services.scholar.source import ScholarSource -from app.services.scholars.author_search_cache import ( - cache_get_author_search_result, - cache_set_author_search_result, -) -from app.services.scholars.constants import ( - AUTHOR_SEARCH_LOCK_KEY, - AUTHOR_SEARCH_LOCK_NAMESPACE, - AUTHOR_SEARCH_RUNTIME_STATE_KEY, - DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, - DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, - DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, - DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD, - DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, - DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, - DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, - DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, - MAX_AUTHOR_SEARCH_LIMIT, - SEARCH_CACHED_BLOCK_REASON, - SEARCH_COOLDOWN_REASON, - SEARCH_DISABLED_REASON, -) -from app.services.scholars.exceptions import ScholarServiceError -from app.services.scholars.search_hints import ( - _merge_warnings, - _policy_blocked_author_search_result, - _trim_author_search_result, -) - -logger = logging.getLogger(__name__) - - -async def _acquire_author_search_lock(db_session: AsyncSession) -> None: - await db_session.execute( - text("SELECT pg_advisory_xact_lock(:namespace, :lock_key)"), - { - "namespace": AUTHOR_SEARCH_LOCK_NAMESPACE, - "lock_key": AUTHOR_SEARCH_LOCK_KEY, - }, - ) - - -async def _load_runtime_state_for_update( - db_session: AsyncSession, -) -> AuthorSearchRuntimeState: - result = await db_session.execute( - select(AuthorSearchRuntimeState) - .where(AuthorSearchRuntimeState.state_key == AUTHOR_SEARCH_RUNTIME_STATE_KEY) - .with_for_update() - ) - state = result.scalar_one_or_none() - if state is not None: - return state - - state = AuthorSearchRuntimeState(state_key=AUTHOR_SEARCH_RUNTIME_STATE_KEY) - db_session.add(state) - await db_session.flush() - return state - - -def _is_author_search_block_state(parsed: ParsedAuthorSearchPage) -> bool: - return parsed.state == ParseState.BLOCKED_OR_CAPTCHA - - -def _author_search_cooldown_remaining_seconds( - runtime_state: AuthorSearchRuntimeState, - now_utc: datetime, -) -> int: - cooldown_until = runtime_state.cooldown_until - if cooldown_until is None: - return 0 - if cooldown_until.tzinfo is None: - cooldown_until = cooldown_until.replace(tzinfo=UTC) - remaining_seconds = int((cooldown_until - now_utc).total_seconds()) - return max(0, remaining_seconds) - - -def _normalize_author_search_inputs(query: str, limit: int) -> tuple[str, int, str]: - normalized_query = query.strip() - if len(normalized_query) < 2: - raise ScholarServiceError("Search query must be at least 2 characters.") - bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) - return normalized_query, bounded_limit, normalized_query.casefold() - - -def _disabled_search_result(*, normalized_query: str, bounded_limit: int) -> ParsedAuthorSearchPage: - structured_log( - logger, - "warning", - "scholar_search.disabled_by_configuration", - query=normalized_query, - ) - return _policy_blocked_author_search_result( - reason=SEARCH_DISABLED_REASON, - warning_codes=["author_search_disabled_by_configuration"], - limit=bounded_limit, - ) - - -def _normalize_runtime_cooldown_state( - runtime_state: AuthorSearchRuntimeState, - *, - now_utc: datetime, -) -> bool: - if runtime_state.cooldown_until is None: - return False - cooldown_until = runtime_state.cooldown_until - updated = False - if cooldown_until.tzinfo is None: - cooldown_until = cooldown_until.replace(tzinfo=UTC) - runtime_state.cooldown_until = cooldown_until - updated = True - if now_utc < cooldown_until: - return updated - structured_log( - logger, - "info", - "scholar_search.cooldown_expired", - cooldown_until_utc=cooldown_until.isoformat(), - ) - runtime_state.cooldown_until = None - runtime_state.cooldown_rejection_count = 0 - runtime_state.cooldown_alert_emitted = False - return True - - -def _cooldown_warning_codes( - *, - runtime_state: AuthorSearchRuntimeState, - cooldown_remaining_seconds: int, -) -> list[str]: - warning_codes = [ - "author_search_cooldown_active", - f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", - ] - if bool(runtime_state.cooldown_alert_emitted): - warning_codes.append("author_search_cooldown_alert_threshold_exceeded") - return warning_codes - - -def _emit_cooldown_threshold_alert( - *, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - cooldown_rejection_alert_threshold: int, -) -> bool: - runtime_state.cooldown_rejection_count = int(runtime_state.cooldown_rejection_count) + 1 - threshold = max(1, int(cooldown_rejection_alert_threshold)) - if int(runtime_state.cooldown_rejection_count) < threshold: - return True - if bool(runtime_state.cooldown_alert_emitted): - return True - structured_log( - logger, - "error", - "scholar_search.cooldown_rejection_threshold_exceeded", - query=normalized_query, - cooldown_rejection_count=int(runtime_state.cooldown_rejection_count), - threshold=threshold, - cooldown_until_utc=runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, - ) - runtime_state.cooldown_alert_emitted = True - return True - - -def _cooldown_block_result( - *, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - bounded_limit: int, - cooldown_rejection_alert_threshold: int, - cooldown_remaining_seconds: int, -) -> ParsedAuthorSearchPage: - _emit_cooldown_threshold_alert( - runtime_state=runtime_state, - normalized_query=normalized_query, - cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, - ) - structured_log( - logger, - "warning", - "scholar_search.cooldown_active", - query=normalized_query, - cooldown_remaining_seconds=cooldown_remaining_seconds, - cooldown_until_utc=runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, - ) - return _policy_blocked_author_search_result( - reason=SEARCH_COOLDOWN_REASON, - warning_codes=_cooldown_warning_codes( - runtime_state=runtime_state, - cooldown_remaining_seconds=cooldown_remaining_seconds, - ), - limit=bounded_limit, - ) - - -async def _cache_hit_result( - db_session: AsyncSession, - *, - query_key: str, - now_utc: datetime, - normalized_query: str, - bounded_limit: int, -) -> ParsedAuthorSearchPage | None: - cached = await cache_get_author_search_result( - db_session, - query_key=query_key, - now_utc=now_utc, - ) - if cached is None: - return None - structured_log( - logger, - "info", - "scholar_search.cache_hit", - query=normalized_query, - state=cached.state.value, - state_reason=cached.state_reason, - ) - state_reason_override = SEARCH_CACHED_BLOCK_REASON if _is_author_search_block_state(cached) else None - return _trim_author_search_result( - cached, - limit=bounded_limit, - extra_warnings=["author_search_served_from_cache"], - state_reason_override=state_reason_override, - ) - - -def _throttle_sleep_seconds( - *, - runtime_state: AuthorSearchRuntimeState, - now_utc: datetime, - min_interval_seconds: float, - interval_jitter_seconds: float, -) -> tuple[float, bool]: - updated = False - if runtime_state.last_live_request_at is None: - enforced_wait_seconds = 0.0 - else: - last_live_request_at = runtime_state.last_live_request_at - if last_live_request_at.tzinfo is None: - last_live_request_at = last_live_request_at.replace(tzinfo=UTC) - runtime_state.last_live_request_at = last_live_request_at - updated = True - enforced_wait_seconds = ( - last_live_request_at + timedelta(seconds=max(float(min_interval_seconds), 0.0)) - now_utc - ).total_seconds() - jitter_seconds = random.uniform(0.0, max(float(interval_jitter_seconds), 0.0)) - return max(0.0, float(enforced_wait_seconds)) + jitter_seconds, updated - - -async def _wait_for_author_search_throttle( - *, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - now_utc: datetime, - min_interval_seconds: float, - interval_jitter_seconds: float, -) -> bool: - sleep_seconds, updated = _throttle_sleep_seconds( - runtime_state=runtime_state, - now_utc=now_utc, - min_interval_seconds=min_interval_seconds, - interval_jitter_seconds=interval_jitter_seconds, - ) - if sleep_seconds <= 0.0: - return updated - structured_log( - logger, - "info", - "scholar_search.throttle_wait", - query=normalized_query, - sleep_seconds=round(sleep_seconds, 3), - ) - await asyncio.sleep(sleep_seconds) - return True - - -async def _fetch_author_search_with_retries( - *, - source: ScholarSource, - normalized_query: str, - network_error_retries: int, - retry_backoff_seconds: float, -) -> tuple[ParsedAuthorSearchPage, int, list[str]]: - max_attempts = max(1, int(network_error_retries) + 1) - parsed: ParsedAuthorSearchPage | None = None - retry_warnings: list[str] = [] - retry_scheduled_count = 0 - for attempt_index in range(max_attempts): - fetch_result = await source.fetch_author_search_html(normalized_query, start=0) - try: - parsed = parse_author_search_page(fetch_result) - except ScholarParserError as exc: - parsed = ParsedAuthorSearchPage( - state=ParseState.LAYOUT_CHANGED, - state_reason=exc.code, - candidates=[], - marker_counts={}, - warnings=[exc.code], - ) - if parsed.state != ParseState.NETWORK_ERROR or attempt_index >= max_attempts - 1: - break - retry_warnings.append("network_retry_scheduled_for_author_search") - retry_scheduled_count += 1 - retry_sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) - if retry_sleep_seconds > 0: - await asyncio.sleep(retry_sleep_seconds) - if parsed is None: - raise ScholarServiceError("Unable to complete scholar author search.") - return parsed, retry_scheduled_count, retry_warnings - - -def _with_retry_warnings( - parsed: ParsedAuthorSearchPage, - *, - retry_warnings: list[str], - retry_scheduled_count: int, - retry_alert_threshold: int, - normalized_query: str, -) -> ParsedAuthorSearchPage: - merged = replace(parsed, warnings=_merge_warnings(parsed.warnings, retry_warnings)) - threshold = max(1, int(retry_alert_threshold)) - if retry_scheduled_count < threshold: - return merged - structured_log( - logger, - "warning", - "scholar_search.retry_threshold_exceeded", - query=normalized_query, - retry_scheduled_count=retry_scheduled_count, - threshold=threshold, - final_state=merged.state.value, - final_state_reason=merged.state_reason, - ) - return replace( - merged, - warnings=_merge_warnings( - merged.warnings, - [f"author_search_retry_threshold_exceeded_{retry_scheduled_count}"], - ), - ) - - -def _apply_block_circuit_breaker( - *, - runtime_state: AuthorSearchRuntimeState, - merged_parsed: ParsedAuthorSearchPage, - cooldown_block_threshold: int, - cooldown_seconds: int, - normalized_query: str, -) -> ParsedAuthorSearchPage: - if not _is_author_search_block_state(merged_parsed): - runtime_state.consecutive_blocked_count = 0 - return merged_parsed - runtime_state.consecutive_blocked_count = int(runtime_state.consecutive_blocked_count) + 1 - structured_log( - logger, - "warning", - "scholar_search.block_detected", - query=normalized_query, - state_reason=merged_parsed.state_reason, - consecutive_blocked_count=int(runtime_state.consecutive_blocked_count), - ) - if int(runtime_state.consecutive_blocked_count) < max(1, int(cooldown_block_threshold)): - return merged_parsed - runtime_state.cooldown_until = datetime.now(UTC) + timedelta(seconds=max(60, int(cooldown_seconds))) - runtime_state.consecutive_blocked_count = 0 - runtime_state.cooldown_rejection_count = 0 - runtime_state.cooldown_alert_emitted = False - structured_log( - logger, - "error", - "scholar_search.cooldown_activated", - query=normalized_query, - cooldown_until_utc=runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, - ) - return replace( - merged_parsed, - warnings=_merge_warnings(merged_parsed.warnings, ["author_search_circuit_breaker_armed"]), - ) - - -def _resolve_author_search_cache_ttl_seconds( - *, - merged_parsed: ParsedAuthorSearchPage, - blocked_cache_ttl_seconds: int, - cache_ttl_seconds: int, -) -> int: - if _is_author_search_block_state(merged_parsed): - return min(max(1, int(blocked_cache_ttl_seconds)), max(1, int(cache_ttl_seconds))) - return max(1, int(cache_ttl_seconds)) - - -async def _load_locked_runtime_state( - db_session: AsyncSession, -) -> AuthorSearchRuntimeState: - await _acquire_author_search_lock(db_session) - return await _load_runtime_state_for_update(db_session) - - -async def _cooldown_or_cache_result( - db_session: AsyncSession, - *, - runtime_state: AuthorSearchRuntimeState, - query_key: str, - normalized_query: str, - bounded_limit: int, - cooldown_rejection_alert_threshold: int, -) -> tuple[ParsedAuthorSearchPage | None, bool]: - runtime_state_updated = _normalize_runtime_cooldown_state( - runtime_state, - now_utc=datetime.now(UTC), - ) - cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds( - runtime_state, - datetime.now(UTC), - ) - if cooldown_remaining_seconds > 0: - return ( - _cooldown_block_result( - runtime_state=runtime_state, - normalized_query=normalized_query, - bounded_limit=bounded_limit, - cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, - cooldown_remaining_seconds=cooldown_remaining_seconds, - ), - True, - ) - cached_result = await _cache_hit_result( - db_session, - query_key=query_key, - now_utc=datetime.now(UTC), - normalized_query=normalized_query, - bounded_limit=bounded_limit, - ) - return cached_result, runtime_state_updated - - -async def _perform_live_author_search( - db_session: AsyncSession, - *, - source: ScholarSource, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - query_key: str, - network_error_retries: int, - retry_backoff_seconds: float, - min_interval_seconds: float, - interval_jitter_seconds: float, - retry_alert_threshold: int, - cooldown_block_threshold: int, - cooldown_seconds: int, - blocked_cache_ttl_seconds: int, - cache_ttl_seconds: int, - cache_max_entries: int, -) -> tuple[ParsedAuthorSearchPage, bool]: - await _wait_for_author_search_throttle( - runtime_state=runtime_state, - normalized_query=normalized_query, - now_utc=datetime.now(UTC), - min_interval_seconds=min_interval_seconds, - interval_jitter_seconds=interval_jitter_seconds, - ) - parsed, retry_count, retry_warnings = await _fetch_author_search_with_retries( - source=source, - normalized_query=normalized_query, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - ) - runtime_state.last_live_request_at = datetime.now(UTC) - merged = _with_retry_warnings( - parsed, - retry_warnings=retry_warnings, - retry_scheduled_count=retry_count, - retry_alert_threshold=retry_alert_threshold, - normalized_query=normalized_query, - ) - merged = _apply_block_circuit_breaker( - runtime_state=runtime_state, - merged_parsed=merged, - cooldown_block_threshold=cooldown_block_threshold, - cooldown_seconds=cooldown_seconds, - normalized_query=normalized_query, - ) - ttl_seconds = _resolve_author_search_cache_ttl_seconds( - merged_parsed=merged, - blocked_cache_ttl_seconds=blocked_cache_ttl_seconds, - cache_ttl_seconds=cache_ttl_seconds, - ) - await cache_set_author_search_result( - db_session, - query_key=query_key, - parsed=merged, - ttl_seconds=float(ttl_seconds), - max_entries=cache_max_entries, - now_utc=datetime.now(UTC), - ) - return merged, True - - -async def search_author_candidates( - *, - source: ScholarSource, - db_session: AsyncSession, - query: str, - limit: int, - network_error_retries: int = 1, - retry_backoff_seconds: float = 1.0, - search_enabled: bool = True, - cache_ttl_seconds: int = 21_600, - blocked_cache_ttl_seconds: int = DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, - cache_max_entries: int = DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, - min_interval_seconds: float = DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, - interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, - cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, - cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, - retry_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, - cooldown_rejection_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD, -) -> ParsedAuthorSearchPage: - normalized_query, bounded_limit, query_key = _normalize_author_search_inputs(query, limit) - if not search_enabled: - return _disabled_search_result( - normalized_query=normalized_query, - bounded_limit=bounded_limit, - ) - - runtime_state = await _load_locked_runtime_state(db_session) - early_result, runtime_state_updated = await _cooldown_or_cache_result( - db_session, - runtime_state=runtime_state, - query_key=query_key, - normalized_query=normalized_query, - bounded_limit=bounded_limit, - cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, - ) - if early_result is not None: - await db_session.commit() - return early_result - - merged_parsed, live_runtime_updated = await _perform_live_author_search( - db_session, - source=source, - runtime_state=runtime_state, - normalized_query=normalized_query, - query_key=query_key, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - min_interval_seconds=min_interval_seconds, - interval_jitter_seconds=interval_jitter_seconds, - retry_alert_threshold=retry_alert_threshold, - cooldown_block_threshold=cooldown_block_threshold, - cooldown_seconds=cooldown_seconds, - blocked_cache_ttl_seconds=blocked_cache_ttl_seconds, - cache_ttl_seconds=cache_ttl_seconds, - cache_max_entries=cache_max_entries, - ) - runtime_state_updated = runtime_state_updated or live_runtime_updated - if runtime_state_updated: - await db_session.commit() - return _trim_author_search_result(merged_parsed, limit=bounded_limit) diff --git a/app/services/scholars/author_search_cache.py b/app/services/scholars/author_search_cache.py deleted file mode 100644 index a0bab2f..0000000 --- a/app/services/scholars/author_search_cache.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from typing import cast - -from sqlalchemy import delete, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import AuthorSearchCacheEntry -from app.services.scholar.parser import ( - ParsedAuthorSearchPage, - ParseState, - ScholarSearchCandidate, -) - - -def _serialize_parsed_author_search_page(parsed: ParsedAuthorSearchPage) -> dict: - return { - "state": parsed.state.value, - "state_reason": parsed.state_reason, - "marker_counts": {str(key): int(value) for key, value in parsed.marker_counts.items()}, - "warnings": [str(value) for value in parsed.warnings], - "candidates": [ - { - "scholar_id": candidate.scholar_id, - "display_name": candidate.display_name, - "affiliation": candidate.affiliation, - "email_domain": candidate.email_domain, - "cited_by_count": candidate.cited_by_count, - "interests": [str(interest) for interest in candidate.interests], - "profile_url": candidate.profile_url, - "profile_image_url": candidate.profile_image_url, - } - for candidate in parsed.candidates - ], - } - - -def _payload_state(payload: dict[str, object]) -> ParseState | None: - state_raw = str(payload.get("state", "")).strip() - try: - return ParseState(state_raw) - except ValueError: - return None - - -def _payload_marker_counts(payload: dict[str, object]) -> dict[str, int]: - marker_counts_payload = payload.get("marker_counts") - if not isinstance(marker_counts_payload, dict): - return {} - parsed: dict[str, int] = {} - for key, value in marker_counts_payload.items(): - try: - parsed[str(key)] = int(value) - except (TypeError, ValueError): - continue - return parsed - - -def _payload_warnings(payload: dict[str, object]) -> list[str]: - warnings_payload = payload.get("warnings") - if not isinstance(warnings_payload, list): - return [] - return [str(value) for value in warnings_payload if isinstance(value, str)] - - -def _parse_optional_string(value: object) -> str | None: - if value is None: - return None - normalized = str(value).strip() - return normalized or None - - -def _parse_optional_int(value: object) -> int | None: - if isinstance(value, int): - return value - if isinstance(value, str) and value.strip(): - try: - return int(value) - except ValueError: - return None - return None - - -def _normalize_interests(value: object) -> list[str]: - if not isinstance(value, list): - return [] - return [str(item) for item in value if isinstance(item, str)] - - -def _deserialize_candidate_payload(value: object) -> dict[str, object] | None: - if not isinstance(value, dict): - return None - scholar_id = str(value.get("scholar_id", "")).strip() - display_name = str(value.get("display_name", "")).strip() - profile_url = str(value.get("profile_url", "")).strip() - if not scholar_id or not display_name or not profile_url: - return None - return { - "scholar_id": scholar_id, - "display_name": display_name, - "affiliation": _parse_optional_string(value.get("affiliation")), - "email_domain": _parse_optional_string(value.get("email_domain")), - "cited_by_count": _parse_optional_int(value.get("cited_by_count")), - "interests": _normalize_interests(value.get("interests")), - "profile_url": profile_url, - "profile_image_url": _parse_optional_string(value.get("profile_image_url")), - } - - -def _deserialize_candidates(payload: dict[str, object]) -> list[dict[str, object]]: - candidates_payload = payload.get("candidates") - if not isinstance(candidates_payload, list): - return [] - normalized: list[dict[str, object]] = [] - for value in candidates_payload: - candidate = _deserialize_candidate_payload(value) - if candidate is not None: - normalized.append(candidate) - return normalized - - -def _deserialize_parsed_author_search_page(payload: object) -> ParsedAuthorSearchPage | None: - if not isinstance(payload, dict): - return None - - state = _payload_state(payload) - if state is None: - return None - - marker_counts = _payload_marker_counts(payload) - warnings = _payload_warnings(payload) - normalized_candidates = _deserialize_candidates(payload) - - return ParsedAuthorSearchPage( - state=state, - state_reason=str(payload.get("state_reason", "")).strip() or "unknown", - candidates=[ - ScholarSearchCandidate( - scholar_id=cast(str, item["scholar_id"]), - display_name=cast(str, item["display_name"]), - affiliation=cast("str | None", item["affiliation"]), - email_domain=cast("str | None", item["email_domain"]), - cited_by_count=cast("int | None", item["cited_by_count"]), - interests=cast("list[str]", item["interests"]), - profile_url=cast(str, item["profile_url"]), - profile_image_url=cast("str | None", item["profile_image_url"]), - ) - for item in normalized_candidates - ], - marker_counts=marker_counts, - warnings=warnings, - ) - - -async def cache_get_author_search_result( - db_session: AsyncSession, - *, - query_key: str, - now_utc: datetime, -) -> ParsedAuthorSearchPage | None: - result = await db_session.execute( - select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) - ) - entry = result.scalar_one_or_none() - if entry is None: - return None - expires_at = entry.expires_at - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=UTC) - if expires_at <= now_utc: - await db_session.delete(entry) - return None - parsed = _deserialize_parsed_author_search_page(entry.payload) - if parsed is None: - await db_session.delete(entry) - return None - return parsed - - -async def cache_set_author_search_result( - db_session: AsyncSession, - *, - query_key: str, - parsed: ParsedAuthorSearchPage, - ttl_seconds: float, - max_entries: int, - now_utc: datetime, -) -> None: - ttl = max(float(ttl_seconds), 0.0) - existing_result = await db_session.execute( - select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) - ) - existing = existing_result.scalar_one_or_none() - - if ttl <= 0.0: - if existing is not None: - await db_session.delete(existing) - return - - expires_at = now_utc + timedelta(seconds=ttl) - payload = _serialize_parsed_author_search_page(parsed) - if existing is None: - db_session.add( - AuthorSearchCacheEntry( - query_key=query_key, - payload=payload, - expires_at=expires_at, - cached_at=now_utc, - updated_at=now_utc, - ) - ) - else: - existing.payload = payload - existing.expires_at = expires_at - existing.cached_at = now_utc - existing.updated_at = now_utc - - await prune_author_search_cache(db_session, now_utc=now_utc, max_entries=max_entries) - - -async def prune_author_search_cache( - db_session: AsyncSession, - *, - now_utc: datetime, - max_entries: int, -) -> None: - await db_session.execute(delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.expires_at <= now_utc)) - bounded_max_entries = max(1, int(max_entries)) - count_result = await db_session.execute(select(func.count()).select_from(AuthorSearchCacheEntry)) - entry_count = int(count_result.scalar_one() or 0) - overflow = max(0, entry_count - bounded_max_entries) - if overflow <= 0: - return - stale_keys_result = await db_session.execute( - select(AuthorSearchCacheEntry.query_key).order_by(AuthorSearchCacheEntry.cached_at.asc()).limit(overflow) - ) - stale_keys = [str(row[0]) for row in stale_keys_result.all()] - if stale_keys: - await db_session.execute(delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key.in_(stale_keys))) diff --git a/app/services/scholars/constants.py b/app/services/scholars/constants.py deleted file mode 100644 index 0602acd..0000000 --- a/app/services/scholars/constants.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import re - -SCHOLAR_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{12}$") -MAX_IMAGE_URL_LENGTH = 2048 -MAX_AUTHOR_SEARCH_LIMIT = 25 - -DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES = 512 -DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS = 300 -DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD = 1 -DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800 -DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0 -DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0 -DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD = 2 -DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD = 3 - -AUTHOR_SEARCH_RUNTIME_STATE_KEY = "global" -AUTHOR_SEARCH_LOCK_NAMESPACE = 3901 -AUTHOR_SEARCH_LOCK_KEY = 1 - -ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/gif": ".gif", -} - -SEARCH_DISABLED_REASON = "search_disabled_by_configuration" -SEARCH_COOLDOWN_REASON = "search_temporarily_disabled_due_to_repeated_blocks" -SEARCH_CACHED_BLOCK_REASON = "search_temporarily_disabled_from_cached_blocked_response" - -STATE_REASON_HINTS: dict[str, str] = { - SEARCH_DISABLED_REASON: ( - "Scholar name search is currently disabled by service policy. Add scholars by profile URL or Scholar ID." - ), - SEARCH_COOLDOWN_REASON: ( - "Scholar name search is temporarily paused after repeated block responses. " - "Use Scholar URL/ID adds until cooldown expires." - ), - SEARCH_CACHED_BLOCK_REASON: ( - "A recent blocked response was cached to reduce traffic. Retry later or add by Scholar URL/ID." - ), - "network_dns_resolution_failed": ( - "DNS resolution failed while reaching scholar.google.com. Verify container DNS/network and retry." - ), - "network_timeout": ("Request timed out before Google Scholar responded. Increase delay/backoff and retry."), - "network_tls_error": ("TLS handshake/certificate validation failed. Verify outbound TLS/network configuration."), - "blocked_http_429_rate_limited": ("Google Scholar rate-limited the request. Slow request cadence and retry later."), - "blocked_unusual_traffic_detected": ( - "Google Scholar flagged traffic as unusual. Increase delay/jitter and reduce concurrent scraping." - ), - "blocked_accounts_redirect": ( - "Request was redirected to Google Account sign-in. Treat as access block and retry later." - ), -} diff --git a/app/services/scholars/exceptions.py b/app/services/scholars/exceptions.py deleted file mode 100644 index f1383e3..0000000 --- a/app/services/scholars/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - - -class ScholarServiceError(ValueError): - """Raised for expected scholar-management validation failures.""" diff --git a/app/services/scholars/search_hints.py b/app/services/scholars/search_hints.py deleted file mode 100644 index 7c5cf15..0000000 --- a/app/services/scholars/search_hints.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from app.db.models import ScholarProfile -from app.services.scholar.parser import ParsedAuthorSearchPage, ParseState -from app.services.scholars.constants import ( - MAX_AUTHOR_SEARCH_LIMIT, - STATE_REASON_HINTS, -) - - -def resolve_profile_image( - profile: ScholarProfile, - *, - uploaded_image_url: str | None, -) -> tuple[str | None, str]: - if profile.profile_image_upload_path and uploaded_image_url: - return uploaded_image_url, "upload" - if profile.profile_image_override_url: - return profile.profile_image_override_url, "override" - if profile.profile_image_url: - return profile.profile_image_url, "scraped" - return None, "none" - - -def scrape_state_hint(*, state: ParseState, state_reason: str) -> str | None: - if state not in {ParseState.NETWORK_ERROR, ParseState.BLOCKED_OR_CAPTCHA}: - return None - return STATE_REASON_HINTS.get(state_reason) - - -def _merge_warnings(base: list[str], extra: list[str]) -> list[str]: - if not extra: - return sorted(set(base)) - return sorted(set(base + extra)) - - -def _trim_author_search_result( - parsed: ParsedAuthorSearchPage, - *, - limit: int, - extra_warnings: list[str] | None = None, - state_reason_override: str | None = None, -) -> ParsedAuthorSearchPage: - bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) - return ParsedAuthorSearchPage( - state=parsed.state, - state_reason=state_reason_override or parsed.state_reason, - candidates=parsed.candidates[:bounded_limit], - marker_counts=parsed.marker_counts, - warnings=_merge_warnings(parsed.warnings, extra_warnings or []), - ) - - -def _policy_blocked_author_search_result( - *, - reason: str, - warning_codes: list[str], - limit: int, -) -> ParsedAuthorSearchPage: - _ = limit - return ParsedAuthorSearchPage( - state=ParseState.BLOCKED_OR_CAPTCHA, - state_reason=reason, - candidates=[], - marker_counts={}, - warnings=_merge_warnings([], warning_codes), - ) diff --git a/app/services/scholars/uploads.py b/app/services/scholars/uploads.py deleted file mode 100644 index b0cf766..0000000 --- a/app/services/scholars/uploads.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from app.services.scholars.exceptions import ScholarServiceError - - -def _ensure_upload_root(upload_dir: str, *, create: bool) -> Path: - root = Path(upload_dir).expanduser().resolve() - if create: - root.mkdir(parents=True, exist_ok=True) - return root - - -def _resolve_upload_path(upload_root: Path, relative_path: str) -> Path: - candidate = (upload_root / relative_path).resolve() - if upload_root != candidate and upload_root not in candidate.parents: - raise ScholarServiceError("Invalid scholar image path.") - return candidate - - -def _safe_remove_upload(upload_root: Path, relative_path: str | None) -> None: - if not relative_path: - return - try: - file_path = _resolve_upload_path(upload_root, relative_path) - except ScholarServiceError: - return - - try: - if file_path.exists() and file_path.is_file(): - file_path.unlink() - except OSError: - return - - -def resolve_upload_file_path(*, upload_dir: str, relative_path: str) -> Path: - root = _ensure_upload_root(upload_dir, create=False) - return _resolve_upload_path(root, relative_path) diff --git a/app/services/scholars/validators.py b/app/services/scholars/validators.py deleted file mode 100644 index 38ee42c..0000000 --- a/app/services/scholars/validators.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from urllib.parse import urlparse - -from app.services.scholars.constants import MAX_IMAGE_URL_LENGTH, SCHOLAR_ID_PATTERN -from app.services.scholars.exceptions import ScholarServiceError - - -def validate_scholar_id(value: str) -> str: - scholar_id = value.strip() - if not SCHOLAR_ID_PATTERN.fullmatch(scholar_id): - raise ScholarServiceError("Scholar ID must match [a-zA-Z0-9_-]{12}.") - return scholar_id - - -def normalize_display_name(value: str) -> str | None: - normalized = value.strip() - return normalized if normalized else None - - -def normalize_profile_image_url(value: str | None) -> str | None: - if value is None: - return None - - candidate = value.strip() - if not candidate: - return None - - if len(candidate) > MAX_IMAGE_URL_LENGTH: - raise ScholarServiceError(f"Image URL must be {MAX_IMAGE_URL_LENGTH} characters or fewer.") - - parsed = urlparse(candidate) - if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: - raise ScholarServiceError("Image URL must be an absolute http(s) URL.") - - return candidate diff --git a/app/services/settings/__init__.py b/app/services/settings/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/app/services/settings/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/app/services/unpaywall/__init__.py b/app/services/unpaywall/__init__.py deleted file mode 100644 index ecfec29..0000000 --- a/app/services/unpaywall/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - - -async def resolve_publication_pdf_urls(*args, **kwargs): - from app.services.unpaywall.application import resolve_publication_pdf_urls as _impl - - return await _impl(*args, **kwargs) - - -__all__ = ["resolve_publication_pdf_urls"] diff --git a/app/services/unpaywall/application.py b/app/services/unpaywall/application.py deleted file mode 100644 index f7d23f8..0000000 --- a/app/services/unpaywall/application.py +++ /dev/null @@ -1,428 +0,0 @@ -from __future__ import annotations - -import logging -import re -from dataclasses import dataclass -from typing import TYPE_CHECKING -from urllib.parse import unquote - -from app.logging_utils import structured_log -from app.services.crossref.application import discover_doi_for_publication -from app.services.doi.normalize import normalize_doi -from app.services.unpaywall.pdf_discovery import ( - looks_like_pdf_url, - resolve_pdf_from_landing_page, -) -from app.services.unpaywall.rate_limit import wait_for_unpaywall_slot -from app.settings import settings - -if TYPE_CHECKING: - from app.services.publications.types import PublicationListItem, UnreadPublicationItem - -DOI_PATTERN = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I) -DOI_PREFIX_RE = re.compile(r"\bdoi\s*[:=]\s*(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I) -DOI_URL_RE = re.compile(r"(?:https?://)?(?:dx\.)?doi\.org/(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I) -UNPAYWALL_URL_TEMPLATE = "https://api.unpaywall.org/v2/{doi}" -FAILURE_MISSING_DOI = "missing_doi" -FAILURE_NO_RECORD = "no_unpaywall_record" -FAILURE_NO_PDF = "no_pdf_found" -FAILURE_RESOLUTION_EXCEPTION = "resolution_exception" -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class OaResolutionOutcome: - publication_id: int - doi: str | None - pdf_url: str | None - failure_reason: str | None - source: str | None - used_crossref: bool - - -def _extract_doi_candidate(text: str | None) -> str | None: - if not text: - return None - decoded = unquote(text) - match = DOI_PATTERN.search(decoded) - if not match: - return None - return match.group(0).rstrip(" .;,)") - - -def _extract_explicit_doi(text: str | None) -> str | None: - if not text: - return None - decoded = unquote(text) - url_match = DOI_URL_RE.search(decoded) - if url_match: - return normalize_doi(url_match.group(1)) - prefix_match = DOI_PREFIX_RE.search(decoded) - if prefix_match: - return normalize_doi(prefix_match.group(1)) - return None - - -def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | None: - stored = None - di = getattr(item, "display_identifier", None) - if di is not None and di.kind == "doi": - stored = normalize_doi(di.value) - - explicit_doi = _extract_explicit_doi(item.pub_url) or _extract_explicit_doi(item.venue_text) - if explicit_doi: - return explicit_doi - pub_url_doi = _extract_doi_candidate(item.pub_url) - if pub_url_doi: - return normalize_doi(pub_url_doi) - return stored - - -def _payload_locations(payload: dict) -> list[dict]: - locations: list[dict] = [] - best = payload.get("best_oa_location") - if isinstance(best, dict): - locations.append(best) - oa_locations = payload.get("oa_locations") - if isinstance(oa_locations, list): - locations.extend(location for location in oa_locations if isinstance(location, dict)) - return locations - - -def _location_value(location: dict, key: str) -> str | None: - value = location.get(key) - if not isinstance(value, str): - return None - normalized = value.strip() - return normalized or None - - -def _payload_pdf_candidates(payload: dict) -> list[str]: - candidates: list[str] = [] - seen: set[str] = set() - for location in _payload_locations(payload): - candidate = _location_value(location, "url_for_pdf") - if candidate is None or candidate in seen: - continue - seen.add(candidate) - candidates.append(candidate) - return candidates - - -def _payload_landing_candidates(payload: dict) -> list[str]: - candidates: list[str] = [] - seen: set[str] = set() - for location in _payload_locations(payload): - candidate = _location_value(location, "url") - if candidate is None or candidate in seen: - continue - seen.add(candidate) - candidates.append(candidate) - return candidates - - -def _crawl_targets( - *, - payload: dict, - pdf_candidates: list[str], -) -> list[str]: - targets = _payload_landing_candidates(payload) - seen = set(targets) - for candidate in pdf_candidates: - if candidate in seen: - continue - targets.append(candidate) - seen.add(candidate) - doi = normalize_doi(str(payload.get("doi") or "")) - doi_landing_url = f"https://doi.org/{doi}" if doi else None - if doi_landing_url and doi_landing_url not in seen: - targets.append(doi_landing_url) - return targets - - -def _has_direct_payload_pdf(payload: dict) -> bool: - return any(looks_like_pdf_url(candidate) for candidate in _payload_pdf_candidates(payload)) - - -async def _resolved_pdf_url_from_payload( - payload: dict, - *, - client, -) -> str | None: - pdf_candidates = _payload_pdf_candidates(payload) - for candidate in pdf_candidates: - if looks_like_pdf_url(candidate): - return candidate - for page_url in _crawl_targets(payload=payload, pdf_candidates=pdf_candidates)[:3]: - discovered = await resolve_pdf_from_landing_page(client, page_url=page_url) - if discovered: - structured_log(logger, "info", "unpaywall.pdf_discovered_from_landing", landing_url=page_url) - return discovered - return None - - -async def _fetch_unpaywall_payload_by_doi( - *, - client, - doi: str, - email: str, -) -> dict | None: - await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) - headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{email})"} - response = await client.get( - UNPAYWALL_URL_TEMPLATE.format(doi=doi), - params={"email": email}, - headers=headers, - ) - if response.status_code != 200: - return None - payload = response.json() - if not isinstance(payload, dict): - return None - return payload - - -def _email_for_request(request_email: str | None) -> str | None: - email = (request_email or "").strip() or settings.unpaywall_email.strip() - return email or None - - -async def _resolve_item_payload( - *, - client, - item: PublicationListItem, - email: str, - allow_crossref: bool, -) -> tuple[dict | None, bool, str | None]: - doi = _publication_doi(item) - payload: dict | None = None - if doi: - payload = await _fetch_unpaywall_payload_by_doi(client=client, doi=doi, email=email) - if payload is not None and _has_direct_payload_pdf(payload): - return payload, False, doi - if not allow_crossref or not settings.crossref_enabled: - return payload, False, doi - crossref_doi = await discover_doi_for_publication( - item=item, - max_rows=settings.crossref_max_rows, - email=email, - ) - if crossref_doi is None or crossref_doi == doi: - return payload, crossref_doi is not None, doi or crossref_doi - crossref_payload = await _fetch_unpaywall_payload_by_doi( - client=client, - doi=crossref_doi, - email=email, - ) - if crossref_payload is not None: - return crossref_payload, True, crossref_doi - return payload, True, crossref_doi - - -async def _doi_and_pdf_from_payload( - payload: dict, - *, - client, -) -> tuple[str | None, str | None]: - doi = normalize_doi(str(payload.get("doi") or "")) - return doi, await _resolved_pdf_url_from_payload(payload, client=client) - - -def _resolution_targets(items: list[PublicationListItem]) -> list[PublicationListItem]: - return [item for item in items if not item.pdf_url] - - -def _crossref_budget_value() -> int: - return max(int(settings.crossref_max_lookups_per_request), 0) - - -def _outcome_with_failure( - *, - item: PublicationListItem, - failure_reason: str, - used_crossref: bool, - doi_override: str | None = None, -) -> OaResolutionOutcome: - return OaResolutionOutcome( - publication_id=item.publication_id, - doi=normalize_doi(doi_override) if doi_override is not None else _publication_doi(item), - pdf_url=None, - failure_reason=failure_reason, - source=None, - used_crossref=used_crossref, - ) - - -def _missing_payload_failure_reason(item: PublicationListItem, *, used_crossref: bool) -> str: - if _publication_doi(item): - return FAILURE_NO_RECORD - if used_crossref: - return FAILURE_NO_RECORD - return FAILURE_MISSING_DOI - - -def _source_name(*, used_crossref: bool) -> str: - return "crossref+unpaywall" if used_crossref else "unpaywall" - - -def _outcome_from_payload( - *, - item: PublicationListItem, - doi: str | None, - pdf_url: str | None, - used_crossref: bool, -) -> OaResolutionOutcome: - return OaResolutionOutcome( - publication_id=item.publication_id, - doi=doi, - pdf_url=pdf_url, - failure_reason=None if pdf_url else FAILURE_NO_PDF, - source=_source_name(used_crossref=used_crossref), - used_crossref=used_crossref, - ) - - -def _resolved_pdf_count(outcomes: dict[int, OaResolutionOutcome]) -> int: - return sum(1 for outcome in outcomes.values() if outcome.pdf_url) - - -def _outcome_metadata(outcomes: dict[int, OaResolutionOutcome]) -> dict[int, tuple[str | None, str | None]]: - return {publication_id: (outcome.doi, outcome.pdf_url) for publication_id, outcome in outcomes.items()} - - -async def _resolve_outcome_for_item( - *, - client, - item: PublicationListItem, - email: str, - allow_crossref: bool, -) -> OaResolutionOutcome: - payload, used_crossref, resolved_doi = await _resolve_item_payload( - client=client, - item=item, - email=email, - allow_crossref=allow_crossref, - ) - if not isinstance(payload, dict): - return _outcome_with_failure( - item=item, - failure_reason=_missing_payload_failure_reason(item, used_crossref=used_crossref), - used_crossref=used_crossref, - doi_override=resolved_doi, - ) - doi, pdf_url = await _doi_and_pdf_from_payload(payload, client=client) - return _outcome_from_payload( - item=item, - doi=doi, - pdf_url=pdf_url, - used_crossref=used_crossref, - ) - - -def _doi_input_count(items: list[PublicationListItem]) -> int: - return len([item for item in items if _publication_doi(item)]) - - -def _search_attempt_count(*, targets: list[PublicationListItem]) -> int: - return len([item for item in targets if not _publication_doi(item)]) - - -async def _safe_outcome_for_item( - *, - client, - item: PublicationListItem, - email: str, - allow_crossref: bool, -) -> OaResolutionOutcome: - try: - return await _resolve_outcome_for_item( - client=client, - item=item, - email=email, - allow_crossref=allow_crossref, - ) - except Exception as exc: # pragma: no cover - defensive network boundary - structured_log( - logger, "warning", "unpaywall.resolve_item_failed", publication_id=item.publication_id, error=str(exc) - ) - return _outcome_with_failure( - item=item, - failure_reason=FAILURE_RESOLUTION_EXCEPTION, - used_crossref=allow_crossref and settings.crossref_enabled, - ) - - -async def _resolve_outcomes_with_client( - *, - client, - targets: list[PublicationListItem], - email: str, -) -> dict[int, OaResolutionOutcome]: - outcomes: dict[int, OaResolutionOutcome] = {} - crossref_budget = _crossref_budget_value() - crossref_lookups = 0 - for item in targets: - allow_crossref = crossref_budget > 0 and crossref_lookups < crossref_budget - outcome = await _safe_outcome_for_item( - client=client, - item=item, - email=email, - allow_crossref=allow_crossref, - ) - if outcome.used_crossref: - crossref_lookups += 1 - outcomes[item.publication_id] = outcome - return outcomes - - -async def resolve_publication_oa_metadata( - items: list[PublicationListItem], - *, - request_email: str | None = None, -) -> dict[int, tuple[str | None, str | None]]: - outcomes = await resolve_publication_oa_outcomes(items, request_email=request_email) - return _outcome_metadata(outcomes) - - -async def resolve_publication_oa_outcomes( - items: list[PublicationListItem], - *, - request_email: str | None = None, -) -> dict[int, OaResolutionOutcome]: - if not settings.unpaywall_enabled: - return {} - email = _email_for_request(request_email) - if email is None: - structured_log(logger, "debug", "unpaywall.resolve_skipped_missing_email") - return {} - import httpx - - timeout_seconds = max(float(settings.unpaywall_timeout_seconds), 0.5) - targets = _resolution_targets(items)[: max(int(settings.unpaywall_max_items_per_request), 0)] - headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{email})"} - async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True, headers=headers) as client: - outcomes = await _resolve_outcomes_with_client( - client=client, - targets=targets, - email=email, - ) - structured_log( - logger, - "info", - "unpaywall.resolve_completed", - publication_count=len(items), - doi_input_count=_doi_input_count(items), - search_attempt_count=_search_attempt_count(targets=targets), - resolved_pdf_count=_resolved_pdf_count(outcomes), - email_domain=email.split("@", 1)[-1] if "@" in email else None, - ) - return outcomes - - -async def resolve_publication_pdf_urls( - items: list[PublicationListItem], - *, - request_email: str | None = None, -) -> dict[int, str | None]: - resolved = await resolve_publication_oa_metadata(items, request_email=request_email) - return {publication_id: pdf for publication_id, (_doi, pdf) in resolved.items()} diff --git a/app/services/unpaywall/pdf_discovery.py b/app/services/unpaywall/pdf_discovery.py deleted file mode 100644 index 01d568d..0000000 --- a/app/services/unpaywall/pdf_discovery.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import re -from html.parser import HTMLParser -from urllib.parse import urljoin, urlparse - -from app.services.unpaywall.rate_limit import wait_for_unpaywall_slot -from app.settings import settings - -PDF_MIME = "application/pdf" -URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.I) - - -class _LandingPdfParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.base_href: str | None = None - self.candidates: list[str] = [] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - attrs_map = {key.lower(): (value or "").strip() for key, value in attrs} - if tag == "base": - self.base_href = attrs_map.get("href") or self.base_href - return - if tag == "meta": - self._append_meta_candidate(attrs_map) - return - if tag == "link": - self._append_link_candidate(attrs_map) - return - if tag == "a": - self._append_anchor_candidate(attrs_map) - - def _append_meta_candidate(self, attrs_map: dict[str, str]) -> None: - meta_name = (attrs_map.get("name") or attrs_map.get("property") or "").lower() - if meta_name != "citation_pdf_url": - return - content = attrs_map.get("content") - if content: - self.candidates.append(content) - - def _append_link_candidate(self, attrs_map: dict[str, str]) -> None: - href = attrs_map.get("href") - link_type = (attrs_map.get("type") or "").lower() - if href and "pdf" in link_type: - self.candidates.append(href) - - def _append_anchor_candidate(self, attrs_map: dict[str, str]) -> None: - href = attrs_map.get("href") - if href: - self.candidates.append(href) - - -def looks_like_pdf_url(url: str | None) -> bool: - if not isinstance(url, str): - return False - value = url.strip() - if not value: - return False - parsed = urlparse(value) - path = (parsed.path or "").lower() - query = (parsed.query or "").lower() - return path.endswith(".pdf") or ".pdf" in query - - -def _normalized_candidate_urls(*, page_url: str, html: str) -> list[str]: - parser = _LandingPdfParser() - parser.feed(html) - parser.close() - base_url = urljoin(page_url, parser.base_href) if parser.base_href else page_url - raw_candidates = [*parser.candidates, *_text_url_candidates(html)] - deduped: list[str] = [] - seen: set[str] = set() - for raw in raw_candidates: - absolute = urljoin(base_url, raw.strip()) - parsed = urlparse(absolute) - if parsed.scheme not in {"http", "https"}: - continue - if absolute in seen: - continue - seen.add(absolute) - deduped.append(absolute) - return sorted(deduped, key=_candidate_sort_key) - - -def _text_url_candidates(html: str) -> list[str]: - candidates: list[str] = [] - for match in URL_RE.findall(html): - cleaned = match.rstrip(".,);]}>") - if "pdf" not in cleaned.lower(): - continue - candidates.append(cleaned) - return candidates - - -def _candidate_sort_key(candidate: str) -> tuple[int, str]: - lowered = candidate.lower() - if looks_like_pdf_url(candidate): - return (0, lowered) - if any(token in lowered for token in ("doi", "full", "article", "download")): - return (1, lowered) - return (2, lowered) - - -def _is_html_response(response) -> bool: - content_type = str(response.headers.get("content-type") or "").lower() - return "text/html" in content_type or "application/xhtml+xml" in content_type - - -async def _fetch_page_html(client, *, page_url: str) -> str | None: - await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) - response = await client.get(page_url, follow_redirects=True) - if response.status_code != 200 or not _is_html_response(response): - return None - text = response.text or "" - return text[: max(int(settings.unpaywall_pdf_discovery_max_html_bytes), 0)] - - -async def _candidate_is_pdf(client, *, candidate_url: str) -> bool: - if looks_like_pdf_url(candidate_url): - return True - await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) - response = await client.get(candidate_url, follow_redirects=True) - content_type = str(response.headers.get("content-type") or "").lower() - return response.status_code == 200 and PDF_MIME in content_type - - -def _candidate_limit() -> int: - return max(int(settings.unpaywall_pdf_discovery_max_candidates), 1) - - -async def _resolve_pdf_from_candidate_page(client, *, candidate_url: str) -> str | None: - html = await _fetch_page_html(client, page_url=candidate_url) - if not html: - return None - nested_candidates = _normalized_candidate_urls(page_url=candidate_url, html=html) - for nested in nested_candidates[: _candidate_limit()]: - if await _candidate_is_pdf(client, candidate_url=nested): - return nested - return None - - -async def resolve_pdf_from_landing_page(client, *, page_url: str) -> str | None: - if not settings.unpaywall_pdf_discovery_enabled: - return None - html = await _fetch_page_html(client, page_url=page_url) - if not html: - return None - candidates = _normalized_candidate_urls(page_url=page_url, html=html) - for candidate in candidates[: _candidate_limit()]: - if await _candidate_is_pdf(client, candidate_url=candidate): - return candidate - nested_pdf = await _resolve_pdf_from_candidate_page(client, candidate_url=candidate) - if nested_pdf: - return nested_pdf - return None diff --git a/app/services/unpaywall/rate_limit.py b/app/services/unpaywall/rate_limit.py deleted file mode 100644 index 76d8a3d..0000000 --- a/app/services/unpaywall/rate_limit.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -import asyncio -import time - -_REQUEST_LOCK = asyncio.Lock() -_LAST_REQUEST_AT = 0.0 - - -async def wait_for_unpaywall_slot(*, min_interval_seconds: float) -> None: - global _LAST_REQUEST_AT - interval = max(float(min_interval_seconds), 0.0) - async with _REQUEST_LOCK: - elapsed = time.monotonic() - _LAST_REQUEST_AT - remaining = interval - elapsed - if remaining > 0: - await asyncio.sleep(remaining) - _LAST_REQUEST_AT = time.monotonic() diff --git a/app/services/settings/application.py b/app/services/user_settings.py similarity index 80% rename from app/services/settings/application.py rename to app/services/user_settings.py index e35971c..3548e44 100644 --- a/app/services/settings/application.py +++ b/app/services/user_settings.py @@ -4,7 +4,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import UserSetting -from app.settings import settings as app_settings class UserSettingsServiceError(ValueError): @@ -61,7 +60,9 @@ def parse_run_interval_minutes(value: str, *, minimum: int = HARD_MIN_RUN_INTERV raise UserSettingsServiceError("Check interval must be a whole number.") from exc effective_minimum = resolve_run_interval_minimum(minimum) if parsed < effective_minimum: - raise UserSettingsServiceError(f"Check interval must be at least {effective_minimum} minutes.") + raise UserSettingsServiceError( + f"Check interval must be at least {effective_minimum} minutes." + ) return parsed @@ -72,7 +73,9 @@ def parse_request_delay_seconds(value: str, *, minimum: int = HARD_MIN_REQUEST_D raise UserSettingsServiceError("Request delay must be a whole number.") from exc effective_minimum = resolve_request_delay_minimum(minimum) if parsed < effective_minimum: - raise UserSettingsServiceError(f"Request delay must be at least {effective_minimum} seconds.") + raise UserSettingsServiceError( + f"Request delay must be at least {effective_minimum} seconds." + ) return parsed @@ -99,7 +102,9 @@ def parse_nav_visible_pages(value: object) -> list[str]: missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen] if missing_required: - raise UserSettingsServiceError("Dashboard, Scholars, and Settings must remain visible.") + raise UserSettingsServiceError( + "Dashboard, Scholars, and Settings must remain visible." + ) return deduped @@ -109,17 +114,14 @@ async def get_or_create_settings( *, user_id: int, ) -> UserSetting: - result = await db_session.execute(select(UserSetting).where(UserSetting.user_id == user_id)) + result = await db_session.execute( + select(UserSetting).where(UserSetting.user_id == user_id) + ) settings = result.scalar_one_or_none() if settings is not None: return settings - settings = UserSetting( - user_id=user_id, - openalex_api_key=app_settings.openalex_api_key, - crossref_api_token=app_settings.crossref_api_token, - crossref_api_mailto=app_settings.crossref_api_mailto, - ) + settings = UserSetting(user_id=user_id) db_session.add(settings) await db_session.commit() await db_session.refresh(settings) @@ -134,17 +136,11 @@ async def update_settings( run_interval_minutes: int, request_delay_seconds: int, nav_visible_pages: list[str], - openalex_api_key: str | None, - crossref_api_token: str | None, - crossref_api_mailto: str | None, ) -> UserSetting: settings.auto_run_enabled = auto_run_enabled settings.run_interval_minutes = run_interval_minutes settings.request_delay_seconds = request_delay_seconds settings.nav_visible_pages = nav_visible_pages - settings.openalex_api_key = openalex_api_key - settings.crossref_api_token = crossref_api_token - settings.crossref_api_mailto = crossref_api_mailto await db_session.commit() await db_session.refresh(settings) return settings diff --git a/app/services/users/application.py b/app/services/users.py similarity index 95% rename from app/services/users/application.py rename to app/services/users.py index e07f514..7f019c4 100644 --- a/app/services/users/application.py +++ b/app/services/users.py @@ -39,7 +39,9 @@ async def get_user_by_id(db_session: AsyncSession, user_id: int) -> User | None: async def get_user_by_email(db_session: AsyncSession, email: str) -> User | None: - result = await db_session.execute(select(User).where(User.email == normalize_email(email))) + result = await db_session.execute( + select(User).where(User.email == normalize_email(email)) + ) return result.scalar_one_or_none() @@ -93,3 +95,4 @@ async def set_user_password_hash( await db_session.commit() await db_session.refresh(user) return user + diff --git a/app/services/users/__init__.py b/app/services/users/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/app/services/users/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/app/settings.py b/app/settings.py index 40596d1..3656d74 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,5 +1,5 @@ -import os from dataclasses import dataclass +import os DEFAULT_SECURITY_PERMISSIONS_POLICY = ( "accelerometer=(), autoplay=(), camera=(), display-capture=(), " @@ -140,11 +140,6 @@ class Settings: "INGESTION_RETRY_BACKOFF_SECONDS", 1.0, ) - ingestion_rate_limit_retries: int = _env_int("INGESTION_RATE_LIMIT_RETRIES", 3) - ingestion_rate_limit_backoff_seconds: float = _env_float( - "INGESTION_RATE_LIMIT_BACKOFF_SECONDS", - 30.0, - ) ingestion_max_pages_per_scholar: int = _env_int( "INGESTION_MAX_PAGES_PER_SCHOLAR", 30, @@ -187,7 +182,6 @@ class Settings: 6, ) scheduler_queue_batch_size: int = _env_int("SCHEDULER_QUEUE_BATCH_SIZE", 10) - scheduler_pdf_queue_batch_size: int = _env_int("SCHEDULER_PDF_QUEUE_BATCH_SIZE", 15) frontend_enabled: bool = _env_bool("FRONTEND_ENABLED", True) frontend_dist_dir: str = _env_str("FRONTEND_DIST_DIR", "/app/frontend/dist") scholar_image_upload_dir: str = _env_str( @@ -235,50 +229,6 @@ class Settings: "SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD", 3, ) - scholar_http_user_agent: str = _env_str("SCHOLAR_HTTP_USER_AGENT", "") - scholar_http_rotate_user_agent: bool = _env_bool("SCHOLAR_HTTP_ROTATE_USER_AGENT", False) - scholar_http_accept_language: str = _env_str( - "SCHOLAR_HTTP_ACCEPT_LANGUAGE", - "en-US,en;q=0.9", - ) - scholar_http_cookie: str = _env_str("SCHOLAR_HTTP_COOKIE", "") - unpaywall_enabled: bool = _env_bool("UNPAYWALL_ENABLED", True) - unpaywall_email: str = _env_str("UNPAYWALL_EMAIL", "") - unpaywall_timeout_seconds: float = _env_float("UNPAYWALL_TIMEOUT_SECONDS", 4.0) - unpaywall_min_interval_seconds: float = _env_float("UNPAYWALL_MIN_INTERVAL_SECONDS", 0.6) - unpaywall_max_items_per_request: int = _env_int("UNPAYWALL_MAX_ITEMS_PER_REQUEST", 20) - unpaywall_retry_cooldown_seconds: int = _env_int("UNPAYWALL_RETRY_COOLDOWN_SECONDS", 1800) - pdf_auto_retry_interval_seconds: int = _env_int( - "PDF_AUTO_RETRY_INTERVAL_SECONDS", - 86_400, - ) - pdf_auto_retry_first_interval_seconds: int = _env_int( - "PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS", - 86_400, - ) - pdf_auto_retry_max_attempts: int = _env_int("PDF_AUTO_RETRY_MAX_ATTEMPTS", 2) - unpaywall_pdf_discovery_enabled: bool = _env_bool("UNPAYWALL_PDF_DISCOVERY_ENABLED", True) - unpaywall_pdf_discovery_max_candidates: int = _env_int("UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES", 5) - unpaywall_pdf_discovery_max_html_bytes: int = _env_int("UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES", 500_000) - arxiv_enabled: bool = _env_bool("ARXIV_ENABLED", True) - arxiv_timeout_seconds: float = _env_float("ARXIV_TIMEOUT_SECONDS", 3.0) - arxiv_min_interval_seconds: float = _env_float("ARXIV_MIN_INTERVAL_SECONDS", 4.0) - arxiv_rate_limit_cooldown_seconds: float = _env_float("ARXIV_RATE_LIMIT_COOLDOWN_SECONDS", 60.0) - arxiv_default_max_results: int = _env_int("ARXIV_DEFAULT_MAX_RESULTS", 3) - arxiv_cache_ttl_seconds: float = _env_float("ARXIV_CACHE_TTL_SECONDS", 900.0) - arxiv_cache_max_entries: int = _env_int("ARXIV_CACHE_MAX_ENTRIES", 512) - arxiv_mailto: str = _env_str("ARXIV_MAILTO", "") - crossref_enabled: bool = _env_bool("CROSSREF_ENABLED", True) - crossref_max_rows: int = _env_int("CROSSREF_MAX_ROWS", 10) - crossref_timeout_seconds: float = _env_float("CROSSREF_TIMEOUT_SECONDS", 8.0) - crossref_min_interval_seconds: float = _env_float("CROSSREF_MIN_INTERVAL_SECONDS", 0.6) - crossref_max_lookups_per_request: int = _env_int("CROSSREF_MAX_LOOKUPS_PER_REQUEST", 8) - - openalex_api_key: str | None = os.getenv("OPENALEX_API_KEY") - database_reserved_api_connections: int = _env_int("DATABASE_RESERVED_API_CONNECTIONS", 3) - - crossref_api_token: str | None = os.getenv("CROSSREF_API_TOKEN") - crossref_api_mailto: str | None = os.getenv("CROSSREF_API_MAILTO") settings = Settings() diff --git a/docs/assets/.gitkeep b/docs/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md deleted file mode 100644 index 392670e..0000000 --- a/docs/developer/architecture.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: Architecture -sidebar_position: 2 ---- - -# Architecture - -## Data Model Rules - -- **Scholar tracking is user-scoped.** Each user manages their own list of tracked scholars. Validate mapping/join tables; never assume global links between users and Scholar IDs. -- **Publications are global, deduplicated records.** Deduplicate via Scholar cluster ID and normalized fingerprinting prior to database insertion. -- **State lives on the link.** Read/unread, favorites, and visibility state exist exclusively on the scholar-publication link table, not the global publication table. - -## Domain Service Boundaries - -All business logic resides in `app/services//`. Flat files in the `app/services/` root are strictly prohibited. - -### Ingestion (`app/services/ingestion/`) - -Run orchestration, continuation queue, scheduler integration, and scrape safety policy enforcement. The `ScholarIngestionService` drives the primary data acquisition loop. - -Key modules: -- `application.py` - Main ingestion orchestrator -- `scheduler.py` - Background tick loop, queue batch processing -- `constants.py` - Safety policy constants and floor values -- `fingerprints.py` - Publication fingerprinting for deduplication -- `types.py` - Ingestion result types and state enums - -### Scholar Parsing (`app/services/scholar/`) - -Fail-fast Google Scholar HTML parsing and source fetch adapters. Handles paginated HTML feeds, extracts publication blocks via regex and DOM invariants (e.g., `gsc_vcd_cib`). - -Key modules: -- `parser.py` - HTML parser for publication extraction -- `parser_utils.py` - Parsing helpers and DOM selectors -- `source.py` - HTTP fetch adapters with browser headers -- `profile_rows.py` - Profile metadata extraction -- `author_rows.py` - Author citation row parsing -- `state_detection.py` - Blocked/CAPTCHA/rate-limit detection - -### Scholar Management (`app/services/scholars/`) - -Scholar CRUD, profile image management, and name-search controls with rate limiting. - -Key modules: -- `application.py` - Scholar lifecycle operations -- `uploads.py` - Image upload handling -- `search_hints.py` - Name search with caching and cooldowns -- `validators.py` - Input validation for scholar creation - -### Publications (`app/services/publications/`) - -Listing, read-state management, favorite toggles, enrichment scheduling, deduplication, and PDF queue management. - -Key modules: -- `application.py` - Publication service facade -- `listing.py` - Filtered listing with pagination (modes: all/unread/latest) -- `queries.py` - Database query builders -- `counts.py` - Aggregation counts for dashboard -- `dedup.py` - Duplicate detection and merging -- `enrichment.py` - Identifier and metadata enrichment orchestration -- `pdf_queue.py` - PDF resolution queue policy -- `pdf_resolution_pipeline.py` - Multi-source PDF resolution (Unpaywall, arXiv) -- `types.py` - Publication DTOs and response types - -### Publication Identifiers (`app/services/publication_identifiers/`) - -Multi-identifier resolution engine. A single publication can have multiple identifiers (DOI, arXiv ID, PMID, PMCID). This decoupled approach replaced the earlier hardcoded DOI-only model. - -Key modules: -- `application.py` - Identifier gathering orchestration -- `normalize.py` - Identifier normalization and validation - -### arXiv (`app/services/arxiv/`) - -Typed API client with global DB-backed throttle, query cache, and in-flight request coalescing. - -Key modules: -- `client.py` - HTTP client for arXiv export API -- `gateway.py` - Gateway with advisory lock, caching, and coalescing -- `cache.py` - Query cache with TTL and max-entry pruning -- `rate_limit.py` - Global rate limiter via `arxiv_runtime_state` table -- `guards.py` - Load-shedding guards (skip when DOI/arXiv evidence exists) -- `parser.py` - Atom XML response parser - -Safety features: -- Requests are globally serialized via a PostgreSQL advisory lock and shared runtime row (`arxiv_runtime_state`) -- Identical request payloads are fingerprinted and cached in `arxiv_query_cache_entries` with TTL + max-entry pruning -- Concurrent identical misses are coalesced in-process (one outbound call serves all waiters) - -### Crossref (`app/services/crossref/`) - -DOI lookup via Crossref REST API with bounded pacing and configurable batch limits. - -### Unpaywall (`app/services/unpaywall/`) - -Open-access PDF resolution via Unpaywall API, with HTML-based PDF link discovery as a fallback. - -Key modules: -- `application.py` - Unpaywall service facade -- `pdf_discovery.py` - HTML page scraping for PDF link candidates - -### OpenAlex (`app/services/openalex/`) - -Metadata matching via OpenAlex API for supplementary identifier resolution. - -Key modules: -- `client.py` - OpenAlex API client -- `matching.py` - Fuzzy title/author matching - -### Runs (`app/services/runs/`) - -Run history tracking and continuation queue operations. - -Key modules: -- `application.py` - Run lifecycle management -- `queue_service.py` - Continuation queue operations (retry, drop, clear) -- `queue_queries.py` - Queue item queries - -### Portability (`app/services/portability/`) - -Import/export workflows for scholar data with full publication state preservation. - -Key modules: -- `application.py` - Import/export orchestration -- `exporting.py` - Scholar export serialization -- `publication_import.py` - Publication import with deduplication -- `scholar_import.py` - Scholar import with link reconstruction -- `normalize.py` - Payload normalization and validation - -### Database Operations (`app/services/dbops/`) - -Integrity checking, link repair, and near-duplicate repair operations exposed via admin API and CLI scripts. - -Key modules: -- `__init__.py` - `collect_integrity_report`, `run_publication_link_repair` -- `near_duplicate_repair.py` - Near-duplicate publication detection and merging - -## Data Integration Flow - -```mermaid -graph TD - UI[Frontend Vue App] --> API[FastAPI Backend] - API --> Scheduler[Background Async Scheduler] - - Scheduler -->|1. Fetch HTML| Scholar[Google Scholar HTML Parser] - Scholar -->|2. Extract Metadata| IdentifierModule[Identifier Gathering] - - IdentifierModule -->|Search arXiv API| API1(arXiv) - IdentifierModule -->|Search Crossref API| API2(Crossref) - - IdentifierModule -->|3. Save Identifiers| DB[(PostgreSQL)] - - Scheduler -->|4. Resolve PDF| PDFPipeline[PDF Resolution Pipeline] - DB --> |Identified DOIs| PDFPipeline - PDFPipeline -->|Search Open Access APIs| Unpaywall(Unpaywall API) - - Unpaywall --> |Acquire PDF URL| PDFWorker[PDF Download Worker] - PDFWorker --> |Store PDF Metadata| DB -``` - -## API Layer - -Routes live in `app/api/routers/`. All responses under `/api/v1` use a strict envelope format. See [API Reference](../reference/api.md) for the full contract. - -## Middleware Stack - -Applied in `app/main.py`: - -1. **CSRF Protection** - Token-based CSRF validation -2. **Session Middleware** - Cookie-based session management (itsdangerous signing) -3. **Request Logging** - Structured request/response logging with configurable skip paths -4. **Security Headers** - Configurable HTTP security headers and CSP diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md deleted file mode 100644 index ec5ad5e..0000000 --- a/docs/developer/contributing.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Contributing -sidebar_position: 4 ---- - -# Contributing - -## PR Process - -1. Create a feature branch from `main`. -2. Make your changes following the code standards below. -3. Run tests inside the container (see [Testing](testing.md)). -4. Run `ruff check .` and `mypy app/` to catch lint and type errors. -5. Open a pull request with a clear title and description. - -## Commit Conventions - -This project uses [Conventional Commits](https://www.conventionalcommits.org/) with [python-semantic-release](https://python-semantic-release.readthedocs.io/) for automated versioning. - -Commit message format: - -``` -(): - -[optional body] -``` - -Types: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`, `perf`. - -Examples: -- `feat(scholars): add bulk import from CSV` -- `fix(ingestion): handle empty citation blocks` -- `docs: update configuration reference` - -## Code Standards - -### Function Length - -Maximum 50 lines per function. Break complex logic into small, testable, single-responsibility functions. - -### DRY - -Abstract repetitive logic immediately. No duplicate boilerplate for database queries, API responses, or error handling. - -### Negative Space Programming - -Use explicit assertions and constraints to define invalid states. Fail fast and early. Do not allow silent failures or cascading malformed data, especially in DOM parsing. - -### Cyclomatic Complexity - -Flatten logic. Use early returns and guard clauses instead of deep nesting. No magic numbers. - -### Domain Service Boundaries - -All business logic resides in `app/services//`. Flat files in the `app/services/` root are strictly prohibited. Each domain owns its own application service, types, and helpers. - -### API Envelope - -All `/api/v1` responses use the strict envelope format: - -- Success: `{"data": ..., "meta": {"request_id": "..."}}` -- Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` - -### Data Isolation - -- Scholar tracking is user-scoped. -- Publications are global, deduplicated records. -- Read/favorite/visibility state lives on scholar-publication link rows. - -### Scrape Safety - -Rate limits and cooldowns are immutable constraints. They prevent IP bans and must not be optimized away or set to zero. - -## UI Standards - -- Integrate Tailwind with the preset theming system (`frontend/src/theme/presets/`). -- Every UI element must have a clear purpose. -- Clarity through both styling and language. diff --git a/docs/developer/frontend-theme-inventory.md b/docs/developer/frontend-theme-inventory.md deleted file mode 100644 index 0233071..0000000 --- a/docs/developer/frontend-theme-inventory.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Frontend Theme Inventory -sidebar_position: 6 ---- - -# Frontend Theme Inventory - -## Token Domains - -The theme system uses semantic tokens organized into domains: - -### Scale Colors - -Color ramps from `50` to `950` for each intent: - -- `brand` - Primary brand color -- `info` - Informational elements -- `success` - Success states -- `warning` - Warning states -- `danger` - Error/destructive states - -### Surface - -Background colors for major UI areas: - -| Token | Usage | -|-------|-------| -| `app` | Application background | -| `nav` | Navigation bar | -| `nav_active` | Active navigation item | -| `card` | Card backgrounds | -| `card_muted` | Muted card variant | -| `table` | Table body | -| `table_header` | Table header | -| `input` | Form inputs | -| `overlay` | Modal/dialog overlays | - -### Text - -| Token | Usage | -|-------|-------| -| `primary` | Default text | -| `secondary` | Supporting text | -| `muted` | Disabled/placeholder text | -| `inverse` | Text on dark backgrounds | -| `link` | Hyperlinks | - -### Border - -| Token | Usage | -|-------|-------| -| `default` | Standard borders | -| `strong` | Emphasized borders | -| `subtle` | Light separators | -| `interactive` | Hover/focus borders | - -### Focus - -| Token | Usage | -|-------|-------| -| `ring` | Focus ring color | -| `ring_offset` | Focus ring offset color | - -### Action Variants - -Each action type (`primary`, `secondary`, `ghost`, `danger`) provides: - -`bg`, `border`, `text`, `hover_bg`, `hover_border`, `hover_text` - -### State Variants - -Each state (`info`, `success`, `warning`, `danger`) provides: - -`bg`, `border`, `text` - -## Theme Presets - -Presets are loaded from `frontend/src/theme/presets/*.{json,js}`. Each contains both `light` and `dark` mode token definitions. - -Current presets: - -| Preset | Format | -|--------|--------| -| `parchment` | JS | -| `lilac` | JS | -| `dune` | JS | -| `oatmeal` | JS | -| `scholarly` | JSON | -| `graphite` | JSON | -| `tide` | JSON | - -## Tokenized Components - -Foundation components using the token system: - -- `AppButton`, `AppCard`, `AppCheckbox`, `AppEmptyState` -- `AppHelpHint`, `AppInput`, `AppSelect`, `AppTable` -- `AppModal`, `AppHeader`, `AppNav`, `AppAlert` -- `AppBadge`, `RunStatusBadge`, `QueueHealthBadge` - -## Enforcement - -- Token policy check: `frontend/scripts/check_theme_tokens.mjs` -- CI step in `frontend-quality` workflow -- Preset integrity tests: `frontend/src/theme/presets.test.ts` diff --git a/docs/developer/ingestion.md b/docs/developer/ingestion.md deleted file mode 100644 index 12eb014..0000000 --- a/docs/developer/ingestion.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Ingestion Pipeline -sidebar_position: 5 ---- - -# Ingestion Pipeline - -The `ScholarIngestionService` drives the primary data acquisition loop. Google Scholar uses heavy bot protection, so the pipeline includes nuanced backoff strategies to protect user networks from IP bans. - -## Pipeline Overview - -1. The scheduler (or a manual trigger) starts a **run** for one or more scholars. -2. The service connects via HTTPX with strict browser headers. -3. Paginated HTML feeds are downloaded for each scholar profile. -4. A regex + DOM-invariant parser (`gsc_vcd_cib` selectors) extracts publication blocks. -5. Publications are fingerprinted and deduplicated against the global store. -6. External APIs resolve additional identifiers. -7. The PDF resolution pipeline runs asynchronously for publications with known DOIs. - -## Rate Limiting & Backoff - -### Network Errors - -Handled via `INGESTION_NETWORK_ERROR_RETRIES` (default: 1) with base backoff of `INGESTION_RETRY_BACKOFF_SECONDS` (default: 1.0s). - -### HTTP 429 (Rate Limited) - -When the parser detects `BLOCKED_OR_CAPTCHA` with `blocked_http_429_rate_limited`, a dedicated cooldown applies: - -- Retries: `INGESTION_RATE_LIMIT_RETRIES` (default: 3) -- Backoff per retry: `INGESTION_RATE_LIMIT_BACKOFF_SECONDS` (default: 30s) - -This pauses the pipeline gracefully instead of failing the entire run. - -### Safety Cooldowns - -Threshold-based cooldowns halt all ingestion after repeated failures: - -| Threshold | Variable | Default | Cooldown | -|-----------|----------|---------|----------| -| Blocked failures | `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | 1 | 1800s (30 min) | -| Network failures | `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | 2 | 900s (15 min) | - -## Continuation Queue - -Multi-page ingestion uses a continuation queue to spread load over time: - -- `INGESTION_CONTINUATION_QUEUE_ENABLED` (default: `1`) -- Base delay: `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` (default: 120s) -- Max delay: `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` (default: 3600s) -- Max attempts: `INGESTION_CONTINUATION_MAX_ATTEMPTS` (default: 6) - -Each continuation item is re-enqueued with exponential backoff. - -## Identifier Resolution - -After publication extraction, the `gather_identifiers_for_publication` module resolves identifiers: - -1. **Local parsing** - Searches HTML parameters for DOI patterns and arXiv regex matches. -2. **arXiv API** - Queries `export.arxiv.org/api/query` by title and author strings. -3. **Crossref API** - Queries Crossref REST API by title and author strings. - -Identifiers are stored in the `publication_identifiers` table rather than as hardcoded properties, maximizing matching resilience for the PDF resolution stage. - -## PDF Resolution - -Publications with resolved DOIs enter the PDF resolution pipeline: - -1. **Unpaywall** - Queries the Unpaywall API for open-access PDF URLs. -2. **PDF Discovery** - If Unpaywall returns an OA page URL without a direct PDF link, the service fetches the HTML and searches for PDF link candidates. -3. **arXiv Direct** - If an arXiv ID is known, the PDF URL is derived directly. - -Auto-retry is configured via `PDF_AUTO_RETRY_*` variables. - -## arXiv Request Controls - -- **Global throttle**: arXiv calls share `arxiv_runtime_state` so all workers respect one cooldown/interval clock. -- **Query cache**: Identical request parameters are fingerprinted and cached in `arxiv_query_cache_entries`. -- **In-flight coalescing**: Duplicate concurrent misses join one outbound request. -- **Load shedding**: arXiv lookups are skipped when high-confidence DOI/arXiv evidence already exists, or when title quality is below threshold. - -### Observability Events - -| Event | Description | -|-------|-------------| -| `arxiv.request_scheduled` | Emitted before a gated request. Includes `wait_seconds`, `cooldown_remaining_seconds`, `source_path`. | -| `arxiv.request_completed` | Emitted after response. Includes `status_code`, `wait_seconds`, `source_path`. | -| `arxiv.cooldown_activated` | Emitted when status `429` triggers cooldown. | -| `arxiv.cache_hit` / `arxiv.cache_miss` | Emitted on query cache lookup with `source_path`. | diff --git a/docs/developer/local-development.md b/docs/developer/local-development.md deleted file mode 100644 index 2adfab0..0000000 --- a/docs/developer/local-development.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Local Development -sidebar_position: 3 ---- - -# Local Development - -## Prerequisites - -- Docker and Docker Compose v2+ -- Python 3.12+ (for IDE support and local linting) -- Node.js 20+ (for frontend development) - -## Starting the Dev Stack - -The development compose file overlays the production config with hot-reload and a separate Vite dev server: - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -``` - -This starts three services: - -| Service | Port | Description | -|---------|------|-------------| -| `db` | 5432 (internal) | PostgreSQL 15 | -| `app` | 8000 | FastAPI backend with `APP_RELOAD=1` | -| `frontend` | 5173 | Vite dev server proxying API calls to `app:8000` | - -### Dev-Specific Overrides - -The `docker-compose.dev.yml` file applies these changes: - -- **`app`**: Uses `scholarr-dev:local` image built from the `dev` stage. Mounts the project root as `/app` for hot reload. Disables `SESSION_COOKIE_SECURE` and the built frontend. -- **`frontend`**: Node 20 container running `npm install && npm run dev`. Mounts `./frontend` with a named volume for `node_modules`. Uses polling for file watching (`CHOKIDAR_USEPOLLING=1`). - -## Environment Setup - -```bash -cp .env.example .env -``` - -Set at minimum: - -```bash -POSTGRES_PASSWORD=localdev -SESSION_SECRET_KEY=local-dev-secret-at-least-32-characters -SESSION_COOKIE_SECURE=0 -``` - -## Running Tests - -All tests run inside containers: - -```bash -# Run unit tests (default: excludes integration markers) -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest - -# Run integration tests -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest -m integration - -# Run a specific test file -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest tests/unit/test_fingerprints.py -v -``` - -See [Testing](testing.md) for markers, fixtures, and conventions. - -## Linting and Type Checking - -```bash -# Ruff linting -ruff check . - -# Ruff formatting check -ruff format --check . - -# Mypy type checking -mypy app/ -``` - -Ruff config is in `pyproject.toml`: target Python 3.12, line length 120, rules `E F W I UP B SIM RUF`. - -## Database Migrations - -Alembic migrations run automatically on startup when `MIGRATE_ON_START=1`. To run manually: - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - alembic upgrade head -``` - -To create a new migration: - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - alembic revision --autogenerate -m "description of change" -``` diff --git a/docs/developer/overview.md b/docs/developer/overview.md deleted file mode 100644 index 9974236..0000000 --- a/docs/developer/overview.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Developer Overview -sidebar_position: 1 ---- - -# Developer Overview - -Scholarr is a Python 3.12+ FastAPI backend with an async SQLAlchemy ORM layer, PostgreSQL database, and a Vue 3 + TypeScript + Vite frontend. - -## Stack - -| Layer | Technology | -|-------|------------| -| Backend | Python 3.12+, FastAPI, SQLAlchemy 2.0 (async/asyncpg), Alembic | -| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS | -| Database | PostgreSQL 15 | -| Infrastructure | Multi-stage Docker, Docker Compose | -| Linting | ruff (E, F, W, I, UP, B, SIM, RUF), mypy | -| Testing | pytest, pytest-asyncio | -| Versioning | python-semantic-release, conventional commits | - -## Quick Start - -```bash -# Build the dev image and start services -docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build - -# Backend: http://localhost:8000 (hot reload enabled) -# Frontend: http://localhost:5173 (Vite dev server with API proxy) -``` - -See [Local Development](local-development.md) for the full setup guide. - -## Project Layout - -``` -app/ -├── api/routers/ # FastAPI route handlers -├── auth/ # Session + CSRF middleware -├── db/ # SQLAlchemy models, session factory, migrations -├── services/ # Domain service modules (see Architecture) -│ ├── arxiv/ # arXiv API client, cache, rate limiting -│ ├── crossref/ # Crossref DOI lookups -│ ├── dbops/ # Database integrity + repair operations -│ ├── ingestion/ # Run orchestration, scheduler, safety gates -│ ├── openalex/ # OpenAlex metadata matching -│ ├── portability/ # Import/export workflows -│ ├── publication_identifiers/ # Multi-identifier resolution -│ ├── publications/ # Listing, enrichment, dedup, PDF queue -│ ├── runs/ # Run history, continuation queue -│ ├── scholar/ # HTML parser, source fetch adapters -│ ├── scholars/ # Scholar CRUD, image upload, name search -│ └── unpaywall/ # Unpaywall PDF discovery -├── main.py # App factory, lifespan, middleware stack -frontend/ -├── src/ -│ ├── components/ # Reusable Vue components -│ ├── theme/presets/ # Color theme presets (light + dark) -│ └── ... -scripts/db/ # Operational database scripts -tests/ -├── unit/ # Fast, no-database tests -├── integration/ # Tests requiring database + services -└── fixtures/ # Shared test fixtures -``` diff --git a/docs/developer/testing.md b/docs/developer/testing.md deleted file mode 100644 index 6440a3d..0000000 --- a/docs/developer/testing.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: Testing -sidebar_position: 7 ---- - -# Testing - -## Running Tests - -All tests must run inside containers: - -```bash -# Unit tests (default: excludes integration markers) -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest - -# Integration tests -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest -m integration - -# Specific marker -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest -m db - -# Verbose output for a specific file -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python -m pytest tests/unit/test_fingerprints.py -v -``` - -## Test Configuration - -From `pyproject.toml`: - -```toml -[tool.pytest.ini_options] -addopts = "-q -m \"not integration\" --import-mode=importlib" -asyncio_mode = "auto" -testpaths = ["tests"] -``` - -- Default run excludes `integration` marked tests -- Uses `importlib` import mode to resolve module name collisions -- Async tests run automatically (no `@pytest.mark.asyncio` needed) - -## Markers - -| Marker | Description | -|--------|-------------| -| `integration` | Tests requiring external services (database, network) | -| `db` | Tests that validate database behavior and constraints | -| `migrations` | Tests focused on Alembic schema migration correctness | -| `schema` | Tests focused on multi-tenant schema invariants | -| `smoke` | Smoke tests for containerized runtime | - -## Test Tiers - -### Unit Tests (`tests/unit/`) - -Fast, no-database tests. Mock external dependencies. These run by default. - -Examples: -- `test_fingerprints.py` - Publication fingerprinting logic -- `test_scholar_parser.py` - HTML parsing without network calls -- `test_doi_normalize.py` - DOI normalization rules -- `test_ingestion_arxiv_rate_limit.py` - Rate limiter behavior -- `test_publication_pdf_resolution_pipeline.py` - PDF pipeline logic - -Domain-specific unit tests are organized under `tests/unit/services/domains/`: -- `arxiv/` - Cache, client, gateway, guards, parser, rate limit tests -- `openalex/` - Client and matching tests -- `publications/` - Dedup tests - -### Integration Tests (`tests/integration/`) - -Require a running database. Test full request/response flows and data consistency. - -Examples: -- `test_api_v1.py` - API endpoint integration tests -- `test_db_integrity.py` - Database integrity checks -- `test_run_lifecycle_consistency.py` - Run state machine transitions -- `test_deferred_enrichment.py` - Enrichment pipeline with real data -- `test_fixture_probe_runs.py` - Fixture-based run probes - -### Smoke Tests - -Marked with `@pytest.mark.smoke`. Validate the containerized runtime starts and serves basic requests. - -## Fixtures - -Test fixtures live in `tests/fixtures/`: - -``` -tests/fixtures/ -└── scholar/ - ├── profile_ok_amIMrIEAAAAJ.html # Successful profile HTML - └── regression/ - ├── profile_P1RwlvoAAAAJ.html # Regression case - ├── profile_LZ5D_p4AAAAJ.html # Regression case - └── profile_AAAAAAAAAAAA.html # Regression case -``` - -Scholar HTML fixtures are real Google Scholar profile pages used to test parser robustness against DOM structure changes. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 94b563b..0000000 --- a/docs/index.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: Scholarr Documentation -sidebar_position: 1 ---- - -# Scholarr - -Scholarr is a self-hosted academic publication tracker. It monitors Google Scholar profiles, discovers new publications, resolves open-access PDFs, and presents everything through a clean Vue 3 dashboard. - -## Quick Links - -| Section | Description | -|---------|-------------| -| [User Guide](user/overview.md) | What scholarr does, installation, configuration | -| [Developer Guide](developer/overview.md) | Architecture, local development, contributing | -| [Operations](operations/overview.md) | Deployment, database runbook, scrape safety | -| [Reference](reference/overview.md) | API contract, environment variables, changelog | - -## Key Features - -- **Scholar Tracking** - Add Google Scholar profiles by ID, URL, or name search -- **Automated Ingestion** - Background scheduler fetches new publications on a configurable interval -- **Identifier Resolution** - Cross-references arXiv, Crossref, and OpenAlex for DOIs and metadata -- **PDF Discovery** - Resolves open-access PDFs via Unpaywall and arXiv -- **Import/Export** - Portable scholar data with full publication state -- **Multi-User** - Session-based auth with admin user management -- **Theming** - Multiple color presets with light/dark mode support diff --git a/docs/operations/arxiv-runbook.md b/docs/operations/arxiv-runbook.md deleted file mode 100644 index b49da90..0000000 --- a/docs/operations/arxiv-runbook.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: arXiv Runbook -sidebar_position: 5 ---- - -# arXiv Operations Runbook - -Use this runbook when arXiv lookups are slow, rate-limited, or behaving unexpectedly. - -## Signals to Check - -- Logs for `arxiv.request_scheduled`, `arxiv.request_completed`, `arxiv.cooldown_activated`, `arxiv.cache_hit`, `arxiv.cache_miss` -- `arxiv_runtime_state` row for cooldown and next-allowed timestamps -- `arxiv_query_cache_entries` size and expiry churn - -## Event Field Guide - -| Field | Description | -|-------|-------------| -| `wait_seconds` | Enforced pre-request delay from the global limiter | -| `status_code` | Upstream response code from arXiv | -| `cooldown_remaining_seconds` | Remaining cooldown when blocked or after 429 | -| `source_path` | Caller path (`search` or `lookup_ids`) | - -## Quick SQL Checks - -### Runtime State - -```sql -SELECT state_key, next_allowed_at, cooldown_until, updated_at -FROM arxiv_runtime_state; -``` - -### Cache Status - -```sql -SELECT count(*) AS cache_rows, - min(expires_at) AS earliest_expiry, - max(expires_at) AS latest_expiry -FROM arxiv_query_cache_entries; -``` - -## Common Scenarios - -### 1. Repeated `arxiv.cooldown_activated` Events - -- Confirm recent `429` statuses in `arxiv.request_completed` logs. -- Reduce caller pressure (check title-quality/identifier guards are active). -- Temporarily raise `ARXIV_RATE_LIMIT_COOLDOWN_SECONDS` if upstream remains strict. - -### 2. High Request Latency with Few Completions - -- Inspect `wait_seconds` in `arxiv.request_scheduled`. -- Verify only one process path is repeatedly hitting arXiv (`source_path`). -- Confirm cache is enabled (`ARXIV_CACHE_TTL_SECONDS > 0`) and effective (`cache_hit` appears). - -### 3. Low Cache Effectiveness - -- Validate normalized query behavior and caller churn. -- Increase `ARXIV_CACHE_TTL_SECONDS` for stable workloads. -- Increase `ARXIV_CACHE_MAX_ENTRIES` if heavy eviction is observed. - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `ARXIV_ENABLED` | `1` | Enable arXiv lookups | -| `ARXIV_TIMEOUT_SECONDS` | `3.0` | Request timeout | -| `ARXIV_MIN_INTERVAL_SECONDS` | `4.0` | Min interval between requests | -| `ARXIV_RATE_LIMIT_COOLDOWN_SECONDS` | `60.0` | Cooldown after 429 | -| `ARXIV_DEFAULT_MAX_RESULTS` | `3` | Max results per query | -| `ARXIV_CACHE_TTL_SECONDS` | `900` | Cache TTL (15 min) | -| `ARXIV_CACHE_MAX_ENTRIES` | `512` | Max cached queries | -| `ARXIV_MAILTO` | *(empty)* | Contact email for API headers | - -## Safe Recovery - -1. Pause automated ingestion if rate-limit storms persist. -2. Let cooldown expire naturally; avoid manual burst retries. -3. Resume and monitor event rates before restoring full load. diff --git a/docs/operations/database-runbook.md b/docs/operations/database-runbook.md deleted file mode 100644 index 434bc51..0000000 --- a/docs/operations/database-runbook.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -title: Database Runbook -sidebar_position: 3 ---- - -# Database Runbook - -## Backup - -Use `scripts/db/backup_full.sh` to create logical backups from the running `db` compose service. - -### Custom-Format Backup (Default) - -```bash -scripts/db/backup_full.sh -``` - -Creates a `.dump` file in `./backups/` (e.g., `scholarr_20260227T120000Z.dump`). - -### Plain SQL Backup - -```bash -scripts/db/backup_full.sh --plain -``` - -Creates a `.sql` file. - -### Options - -| Option | Description | -|--------|-------------| -| `--plain` | Write plain SQL instead of custom-format dump | - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `BACKUP_DIR` | `/backups` | Destination directory | -| `BACKUP_PREFIX` | `scholarr` | File prefix | -| `USE_DEV_COMPOSE` | `0` | Set to `1` to include `docker-compose.dev.yml` | - -## Restore - -Use `scripts/db/restore_dump.sh` to restore a backup into the running `db` service. - -### Restore a Custom-Format Dump - -```bash -scripts/db/restore_dump.sh --file backups/scholarr_20260227T120000Z.dump -``` - -### Restore with Schema Wipe - -```bash -scripts/db/restore_dump.sh --file backups/scholarr_20260227T120000Z.dump --wipe-public -``` - -This drops and recreates the `public` schema before restoring. Use with caution. - -### Options - -| Option | Description | -|--------|-------------| -| `--file ` | Required. Path to `.dump` or `.sql` backup file | -| `--wipe-public` | Drop and recreate `public` schema before restore | - -## Integrity Checks - -Use `scripts/db/check_integrity.py` to run database integrity checks. - -### Run Inside Container - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python scripts/db/check_integrity.py -``` - -### With Strict Warnings - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python scripts/db/check_integrity.py --strict-warnings -``` - -Returns non-zero exit code if any warning is present. - -### Output Format - -JSON report: - -```json -{ - "status": "passed", - "failures": [], - "warnings": [] -} -``` - -Exit codes: -- `0` - All checks passed -- `1` - Check failure -- `2` - Warnings present (with `--strict-warnings`) - -### Via Admin API - -``` -GET /api/v1/admin/db/integrity -``` - -Returns the same integrity report through the API (admin auth required). - -## Publication Link Repair - -Use `scripts/db/repair_publication_links.py` to repair scholar-publication links with audit logging. - -### Dry Run (Default) - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python scripts/db/repair_publication_links.py --user-id 1 -``` - -### Apply Changes - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ - python scripts/db/repair_publication_links.py --user-id 1 --apply \ - --requested-by "admin@example.com" -``` - -### Options - -| Option | Description | -|--------|-------------| -| `--user-id ` | Required. Target user ID | -| `--scholar-profile-id ` | Optional. Filter by scholar profile ID (repeatable) | -| `--apply` | Apply changes (default is dry-run) | -| `--gc-orphan-publications` | Delete publications with zero links after cleanup | -| `--requested-by ` | Operator identifier for audit logs | - -### Via Admin API - -``` -POST /api/v1/admin/db/repairs/publication-links -``` - -## Near-Duplicate Repair - -Detect and merge near-duplicate publications via the admin API: - -``` -POST /api/v1/admin/db/repairs/publication-near-duplicates -``` - -## PDF Queue Management - -### List Queue - -``` -GET /api/v1/admin/db/pdf-queue -``` - -### Requeue Single Item - -``` -POST /api/v1/admin/db/pdf-queue/{id}/requeue -``` - -### Bulk Requeue - -``` -POST /api/v1/admin/db/pdf-queue/requeue-all -``` - -## Migration Procedures - -Alembic migrations run automatically on startup when `MIGRATE_ON_START=1`. - -To run manually: - -```bash -docker compose exec app alembic upgrade head -``` - -To check current migration status: - -```bash -docker compose exec app alembic current -``` - -Recommended procedure for production: -1. Backup the database before upgrading. -2. Pull the new image. -3. Start the container; migrations run on startup. -4. Verify via `GET /healthz` and check logs for migration output. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md deleted file mode 100644 index 48e06d4..0000000 --- a/docs/operations/deployment.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: Deployment -sidebar_position: 2 ---- - -# Deployment - -## Production Docker Compose - -The default `docker-compose.yml` runs two services: - -| Service | Image | Description | -|---------|-------|-------------| -| `db` | `postgres:15-alpine` | PostgreSQL database with persistent volume | -| `app` | `justinzeus/scholarr:latest` | FastAPI application with embedded frontend | - -### Start - -```bash -docker compose up -d -``` - -### Stop - -```bash -docker compose down -``` - -### Update - -```bash -docker compose pull -docker compose up -d -``` - -### View Logs - -```bash -docker compose logs -f app -docker compose logs -f db -``` - -## Required Environment Variables - -These must be set in `.env` or the shell environment: - -```bash -POSTGRES_PASSWORD= -SESSION_SECRET_KEY= -``` - -## Volumes - -| Volume | Mount | Description | -|--------|-------|-------------| -| `postgres_data` | `/var/lib/postgresql/data` | Database files | -| `scholar_uploads` | `/var/lib/scholarr/uploads` | Scholar profile images | - -## Health Checks - -### Database - -```yaml -healthcheck: - test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] - interval: 5s - timeout: 5s - retries: 20 -``` - -### Application - -```yaml -healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"] - interval: 10s - timeout: 5s - retries: 12 -``` - -The app service depends on `db` with `condition: service_healthy`, so it waits for the database to be ready. - -## Database Readiness Wait - -The app has built-in database readiness polling: - -- `DB_WAIT_TIMEOUT_SECONDS` (default: 60) - Max wait time -- `DB_WAIT_INTERVAL_SECONDS` (default: 2) - Poll interval - -## Auto-Migration - -Set `MIGRATE_ON_START=1` (default) to run Alembic migrations automatically on startup. - -## Scaling Considerations - -- Run a single `app` instance to avoid scheduler conflicts (the scheduler is process-local). -- arXiv requests are globally serialized via a PostgreSQL advisory lock, so multiple instances safely share the rate limiter. -- Database pool defaults: 5 base connections + 10 overflow. Adjust `DATABASE_POOL_SIZE` and `DATABASE_POOL_MAX_OVERFLOW` for higher loads. - -## Admin Bootstrap - -For first-time setup without manual database access: - -```bash -BOOTSTRAP_ADMIN_ON_START=1 -BOOTSTRAP_ADMIN_EMAIL=admin@example.com -BOOTSTRAP_ADMIN_PASSWORD= -``` - -Set `BOOTSTRAP_ADMIN_FORCE_PASSWORD=1` to reset an existing admin password. - -## Security Hardening - -- Set `SESSION_COOKIE_SECURE=1` when serving over HTTPS. -- Enable HSTS: `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=1`. -- Review CSP policy in `SECURITY_CSP_POLICY` for your domain. -- Set `UNPAYWALL_EMAIL` and `ARXIV_MAILTO` for polite API pool access. diff --git a/docs/operations/overview.md b/docs/operations/overview.md deleted file mode 100644 index 7d2f997..0000000 --- a/docs/operations/overview.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: Operations Overview -sidebar_position: 1 ---- - -# Operations Overview - -This section covers production deployment, database administration, and scrape safety operations. - -## Quick Links - -| Guide | Description | -|-------|-------------| -| [Deployment](deployment.md) | Production Docker setup, scaling, health checks | -| [Database Runbook](database-runbook.md) | Backup, restore, integrity checks, repair procedures | -| [Scrape Safety Runbook](scrape-safety-runbook.md) | Rate limiting, cooldowns, CAPTCHA handling | -| [arXiv Runbook](arxiv-runbook.md) | arXiv rate limits, cache tuning, query patterns | - -## Health Check - -The app exposes `GET /healthz` for container orchestration: - -```bash -curl -fsS http://localhost:8000/healthz -``` - -Docker Compose healthcheck config: -- Interval: 10s -- Timeout: 5s -- Retries: 12 diff --git a/docs/operations/scrape-safety-runbook.md b/docs/operations/scrape-safety-runbook.md deleted file mode 100644 index 71f6769..0000000 --- a/docs/operations/scrape-safety-runbook.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Scrape Safety Runbook -sidebar_position: 4 ---- - -# Scrape Safety Runbook - -Operational handling for scrape safety cooldowns, threshold alerts, and blocked-IP events. - -## Key Signals - -Use structured log `event` fields (recommended with `LOG_FORMAT=json`): - -| Event | Description | -|-------|-------------| -| `ingestion.safety_policy_blocked_run_start` | Run blocked by safety policy | -| `ingestion.safety_cooldown_entered` | Cooldown activated | -| `ingestion.safety_cooldown_cleared` | Cooldown expired | -| `ingestion.alert_blocked_failure_threshold_exceeded` | Blocked failure threshold tripped | -| `ingestion.alert_network_failure_threshold_exceeded` | Network failure threshold tripped | -| `ingestion.alert_retry_scheduled_threshold_exceeded` | Retry threshold tripped | -| `api.runs.manual_blocked_policy` | Manual run blocked by policy | -| `api.runs.manual_blocked_safety` | Manual run blocked by safety cooldown | -| `scheduler.run_skipped_safety_cooldown` | Scheduled run skipped due to cooldown | -| `scheduler.run_skipped_safety_cooldown_precheck` | Scheduler precheck blocked | -| `scheduler.queue_item_deferred_safety_cooldown` | Queue item deferred due to cooldown | - -Each event includes metric-style fields (`metric_name`, `metric_value`) for log-based alert rules. - -## Recommended Alert Rules - -- **Cooldown enters**: Trigger on `event=ingestion.safety_cooldown_entered` -- **Repeated start blocks**: High rate of `event=api.runs.manual_blocked_safety` -- **Threshold trips**: Any of the `*_threshold_exceeded` events -- **Scheduler pressure**: Sustained `scheduler.queue_item_deferred_safety_cooldown` - -## If Your IP Appears Blocked - -### Symptoms - -- Cooldown reason: `blocked_failure_threshold_exceeded` -- Parse state: `blocked_or_captcha` -- Redirects toward Google account sign-in flows - -### Actions - -1. Stop manual retries immediately. -2. Let cooldown expire; do not spam retriggers. -3. Increase `INGESTION_MIN_REQUEST_DELAY_SECONDS` and user request delay values. -4. Reduce concurrency pressure (keep one scheduler instance). -5. Keep name-search disabled if login-gated responses persist. -6. Resume with a small monitored run and verify blocked rate drops. - -### Avoid - -- Aggressive rapid retries -- Rotating through risky scraping patterns that increase challenge rates -- Bypass/CAPTCHA-solving workflows that may violate source platform rules - -## Environment Controls - -Policy floors and safety controls: - -| Variable | Default | Description | -|----------|---------|-------------| -| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | `2` | Floor delay between requests | -| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | `15` | Minimum time between runs | -| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | Blocked failures before alert | -| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | Network failures before alert | -| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | Retries before alert | -| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | `1800` | Cooldown after blocked threshold (30 min) | -| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | `900` | Cooldown after network threshold (15 min) | -| `INGESTION_MANUAL_RUN_ALLOWED` | `1` | Enable manual runs | -| `INGESTION_AUTOMATION_ALLOWED` | `1` | Enable automated runs | - -Apply stricter values first, then relax slowly only after sustained healthy runs. diff --git a/docs/ops/scrape_safety_runbook.md b/docs/ops/scrape_safety_runbook.md new file mode 100644 index 0000000..db01064 --- /dev/null +++ b/docs/ops/scrape_safety_runbook.md @@ -0,0 +1,74 @@ +# Scrape Safety Runbook + +This runbook covers operational handling for scrape safety cooldowns, threshold alerts, and blocked-IP events. + +## Key Signals + +Use structured log `event` fields (recommended with `LOG_FORMAT=json`): + +- `ingestion.safety_policy_blocked_run_start` +- `ingestion.safety_cooldown_entered` +- `ingestion.safety_cooldown_cleared` +- `ingestion.alert_blocked_failure_threshold_exceeded` +- `ingestion.alert_network_failure_threshold_exceeded` +- `ingestion.alert_retry_scheduled_threshold_exceeded` +- `api.runs.manual_blocked_policy` +- `api.runs.manual_blocked_safety` +- `scheduler.run_skipped_safety_cooldown` +- `scheduler.run_skipped_safety_cooldown_precheck` +- `scheduler.queue_item_deferred_safety_cooldown` + +Each event includes metric-style fields (`metric_name`, `metric_value`) for straightforward log-based alert rules. + +## Recommended Alert Rules + +- Cooldown enters: + - Trigger on `event=ingestion.safety_cooldown_entered`. +- Repeated start blocks: + - Trigger on high rate of `event=api.runs.manual_blocked_safety`. +- Threshold trips: + - Trigger on any of: + - `ingestion.alert_blocked_failure_threshold_exceeded` + - `ingestion.alert_network_failure_threshold_exceeded` + - `ingestion.alert_retry_scheduled_threshold_exceeded` +- Scheduler pressure: + - Trigger on sustained `scheduler.queue_item_deferred_safety_cooldown`. + +## If Your IP Appears Blocked + +Symptoms: + +- cooldown reason `blocked_failure_threshold_exceeded` +- parse state `blocked_or_captcha` +- redirects toward Google account sign-in flows + +Actions: + +1. Stop manual retries immediately. +2. Let cooldown expire; do not spam retriggers. +3. Increase `INGESTION_MIN_REQUEST_DELAY_SECONDS` and user request delay values. +4. Reduce concurrency pressure (keep one scheduler instance). +5. Keep name-search disabled/WIP for now if login-gated responses persist. +6. Resume with a small monitored run and verify blocked rate drops. + +Avoid: + +- aggressive rapid retries +- rotating through risky scraping patterns that increase challenge rates +- bypass/captcha-solving workflows that may violate source platform rules + +## Environment Controls + +Policy floors and safety controls: + +- `INGESTION_MIN_REQUEST_DELAY_SECONDS` +- `INGESTION_MIN_RUN_INTERVAL_MINUTES` +- `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` +- `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` +- `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` +- `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` +- `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` +- `INGESTION_MANUAL_RUN_ALLOWED` +- `INGESTION_AUTOMATION_ALLOWED` + +Apply stricter values first, then relax slowly only after sustained healthy runs. diff --git a/docs/reference/api.md b/docs/reference/api.md deleted file mode 100644 index 668ab51..0000000 --- a/docs/reference/api.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: API Contract -sidebar_position: 2 ---- - -# API Contract - -## Envelope Invariant - -All API responses under `/api/v1` use one of these envelopes: - -### Success - -```json -{ - "data": "...", - "meta": { - "request_id": "..." - } -} -``` - -### Error - -```json -{ - "error": { - "code": "...", - "message": "...", - "details": "..." - }, - "meta": { - "request_id": "..." - } -} -``` - -`meta.request_id` is present on both success and error responses. - -### Binary Assets - -Binary media assets are served outside `/api/v1`: - -``` -GET /scholar-images/{scholar_profile_id}/upload -``` - -## DTO Structure - -Scholarr uses strictly typed Pydantic V2 models serialized through OpenAPI v3 via FastAPI. - -- `PublicationListItem` exposes a `.display_identifier` property that resolves the highest-confidence identifier regardless of backend origin, rather than a hardcoded `.doi`. -- Frontend TypeScript types are compiled from the OpenAPI spec to ensure type safety across the stack. - -## Endpoints - -### Auth - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/auth/login` | Login (rate-limited sliding window) | -| `GET` | `/api/v1/auth/me` | Current session user | -| `GET` | `/api/v1/auth/csrf` | Bootstrap CSRF token | -| `POST` | `/api/v1/auth/change-password` | Change password | -| `POST` | `/api/v1/auth/logout` | Logout | - -### Scholars - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/scholars` | List tracked scholars | -| `POST` | `/api/v1/scholars` | Create scholar (auto-enqueue, metadata hydration) | -| `GET` | `/api/v1/scholars/search` | Search author candidates by name | -| `PATCH` | `/api/v1/scholars/{id}/toggle` | Toggle enabled status | -| `DELETE` | `/api/v1/scholars/{id}` | Delete scholar | -| `PUT` | `/api/v1/scholars/{id}/image/url` | Update image URL | -| `POST` | `/api/v1/scholars/{id}/image/upload` | Upload image | -| `DELETE` | `/api/v1/scholars/{id}/image` | Clear image customization | - -### Scholar Portability - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/scholars/export` | Export tracked scholars and publication link state | -| `POST` | `/api/v1/scholars/import` | Import scholars with global publication deduplication | - -The export payload includes scholar metadata, tracked publication data, and link state (read/unread, favorites). Import preserves global deduplication. - -### Publications - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/publications` | List publications (filtered, paginated) | -| `POST` | `/api/v1/publications/mark-all-read` | Mark all as read | -| `POST` | `/api/v1/publications/mark-read` | Mark selected as read | -| `POST` | `/api/v1/publications/{id}/retry-pdf` | Retry PDF resolution | -| `POST` | `/api/v1/publications/{id}/favorite` | Toggle favorite | - -#### Publication Modes - -`GET /api/v1/publications` supports a `mode` parameter: - -| Mode | Description | -|------|-------------| -| `all` | All publications | -| `unread` | Publications with `is_read=false` | -| `latest` | Publications first seen in the latest completed run | - -`mode=new` is accepted as a compatibility alias for `latest`. - -#### Pagination - -Query parameters: `page`, `page_size` (with backward-compatible `limit`/`offset` support). - -Response pagination fields: - -```json -{ - "page": 1, - "page_size": 20, - "has_prev": false, - "has_next": true, - "total_count": 142 -} -``` - -#### Publication Payload Fields - -| Field | Description | -|-------|-------------| -| `pub_url` | Canonical scholar detail URL | -| `doi` | Normalized DOI | -| `pdf_url` | Resolved open-access PDF URL (when available) | -| `display_identifier` | Highest-confidence identifier regardless of source | - -### Runs - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/runs` | List runs with safety state | -| `GET` | `/api/v1/runs/{run_id}` | Run detail with scholar results | -| `POST` | `/api/v1/runs/{run_id}/cancel` | Cancel active run | -| `POST` | `/api/v1/runs/manual` | Trigger manual run (idempotent, safety-checked) | -| `GET` | `/api/v1/runs/queue/items` | List queue items | -| `POST` | `/api/v1/runs/queue/{id}/retry` | Retry queue item | -| `POST` | `/api/v1/runs/queue/{id}/drop` | Drop queue item | -| `DELETE` | `/api/v1/runs/queue/{id}` | Clear queue item | -| `GET` | `/api/v1/runs/{run_id}/stream` | Stream run events (SSE) | - -### Settings - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/settings` | Get user settings (with cooldown expiry check) | -| `PUT` | `/api/v1/settings` | Update settings (interval, delay, nav, API keys) | - -### Admin - User Management - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/admin/users` | List all users | -| `POST` | `/api/v1/admin/users` | Create user | -| `PATCH` | `/api/v1/admin/users/{id}/active` | Set user active status | -| `POST` | `/api/v1/admin/users/{id}/reset-password` | Reset password | - -### Admin - Database Operations - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/admin/db/integrity` | Get integrity report | -| `GET` | `/api/v1/admin/db/repair-jobs` | List repair jobs | -| `GET` | `/api/v1/admin/db/pdf-queue` | List PDF queue | -| `POST` | `/api/v1/admin/db/pdf-queue/{id}/requeue` | Requeue single PDF | -| `POST` | `/api/v1/admin/db/pdf-queue/requeue-all` | Bulk requeue missing PDFs | -| `POST` | `/api/v1/admin/db/repairs/publication-links` | Trigger link repair | -| `POST` | `/api/v1/admin/db/repairs/publication-near-duplicates` | Trigger dedup repair | -| `POST` | `/api/v1/admin/db/drop-all-publications` | Drop all publications (destructive) | diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md deleted file mode 100644 index 23a7a11..0000000 --- a/docs/reference/changelog.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Changelog -sidebar_position: 4 ---- - -# Changelog - -This changelog is auto-generated by [python-semantic-release](https://python-semantic-release.readthedocs.io/). - -Release versions follow [Semantic Versioning](https://semver.org/). Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/). - - diff --git a/docs/reference/environment.md b/docs/reference/environment.md deleted file mode 100644 index fc7e29a..0000000 --- a/docs/reference/environment.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Environment Variables -sidebar_position: 3 ---- - -# Environment Variables Quick Reference - -All environment variables are documented in detail in [Configuration](../user/configuration.md). - -## Required Variables - -| Variable | Description | -|----------|-------------| -| `POSTGRES_PASSWORD` | PostgreSQL password | -| `SESSION_SECRET_KEY` | Session cookie signing key (32+ characters) | - -## Categories - -| Category | Key Variables | -|----------|--------------| -| Database | `POSTGRES_DB`, `POSTGRES_USER`, `DATABASE_URL`, `DATABASE_POOL_*` | -| Runtime | `APP_HOST`, `APP_PORT`, `MIGRATE_ON_START`, `FRONTEND_ENABLED` | -| Auth | `SESSION_SECRET_KEY`, `SESSION_COOKIE_SECURE`, `LOGIN_RATE_LIMIT_*` | -| Security | `SECURITY_HEADERS_ENABLED`, `SECURITY_CSP_*`, `SECURITY_STRICT_TRANSPORT_*` | -| Logging | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_REQUESTS` | -| Scheduler | `SCHEDULER_ENABLED`, `SCHEDULER_TICK_SECONDS`, `SCHEDULER_*_BATCH_SIZE` | -| Ingestion | `INGESTION_*` (safety floors, cooldowns, retry policies) | -| Scholar | `SCHOLAR_IMAGE_*`, `SCHOLAR_NAME_SEARCH_*` | -| Enrichment | `UNPAYWALL_*`, `ARXIV_*`, `CROSSREF_*`, `OPENALEX_*`, `PDF_AUTO_RETRY_*` | -| Bootstrap | `BOOTSTRAP_ADMIN_*`, `DB_WAIT_*` | - -See [Configuration](../user/configuration.md) for the complete table with types, defaults, and descriptions. diff --git a/docs/reference/overview.md b/docs/reference/overview.md deleted file mode 100644 index c8249d9..0000000 --- a/docs/reference/overview.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Reference -sidebar_position: 1 ---- - -# Reference - -Quick-reference documentation for the scholarr API, environment variables, and release changelog. - -| Document | Description | -|----------|-------------| -| [API Contract](api.md) | Envelope spec, endpoints, DTO structure, publication semantics | -| [Environment Variables](environment.md) | Quick-reference table linking to full configuration docs | -| [Changelog](changelog.md) | Auto-generated release history | diff --git a/docs/user/configuration.md b/docs/user/configuration.md deleted file mode 100644 index d79a1b7..0000000 --- a/docs/user/configuration.md +++ /dev/null @@ -1,178 +0,0 @@ ---- -title: Configuration -sidebar_position: 3 ---- - -# Configuration - -All configuration is done through environment variables. Copy `.env.example` to `.env` and adjust values as needed. - -## Compose & Database - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `POSTGRES_DB` | string | `scholar` | PostgreSQL database name | -| `POSTGRES_USER` | string | `scholar` | PostgreSQL user | -| `POSTGRES_PASSWORD` | string | **required** | PostgreSQL password | -| `DATABASE_URL` | string | derived | SQLAlchemy async connection string | -| `TEST_DATABASE_URL` | string | derived | Override for test database. If empty, tests derive `_test` | -| `SCHOLARR_IMAGE` | string | `justinzeus/scholarr:latest` | Docker image for the app service | - -## App Runtime & Networking - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `APP_NAME` | string | `scholarr` | Application name used in logs and headers | -| `APP_HOST` | string | `0.0.0.0` | Bind address | -| `APP_PORT` | int | `8000` | Internal port | -| `APP_HOST_PORT` | int | `8000` | Host-mapped port | -| `APP_RELOAD` | bool | `0` | Enable uvicorn auto-reload (dev only) | -| `MIGRATE_ON_START` | bool | `1` | Run Alembic migrations on startup | -| `FRONTEND_ENABLED` | bool | `1` | Serve the built Vue frontend | -| `FRONTEND_DIST_DIR` | string | `/app/frontend/dist` | Path to compiled frontend assets | - -## Database Pool - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `DATABASE_POOL_MODE` | string | `auto` | Pool mode (`auto`, `fixed`, `null`) | -| `DATABASE_POOL_SIZE` | int | `5` | Base pool size | -| `DATABASE_POOL_MAX_OVERFLOW` | int | `10` | Maximum overflow connections | -| `DATABASE_POOL_TIMEOUT_SECONDS` | int | `30` | Connection acquisition timeout | - -## Frontend Dev Overrides - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `FRONTEND_HOST_PORT` | int | `5173` | Host port for Vite dev server | -| `CHOKIDAR_USEPOLLING` | bool | `1` | Enable polling for file watchers in containers | -| `VITE_DEV_API_PROXY_TARGET` | string | `http://app:8000` | Backend URL for Vite proxy | - -## Auth & Session - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `SESSION_SECRET_KEY` | string | **required** | Signing key for session cookies (32+ chars) | -| `SESSION_COOKIE_SECURE` | bool | `1` | Set `Secure` flag on session cookie (disable for local HTTP dev) | -| `LOGIN_RATE_LIMIT_ATTEMPTS` | int | `5` | Max login attempts per window | -| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | int | `60` | Sliding window for login rate limiting | - -## HTTP Security Headers & CSP - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `SECURITY_HEADERS_ENABLED` | bool | `1` | Enable security response headers | -| `SECURITY_X_CONTENT_TYPE_OPTIONS` | string | `nosniff` | X-Content-Type-Options header value | -| `SECURITY_X_FRAME_OPTIONS` | string | `DENY` | X-Frame-Options header value | -| `SECURITY_REFERRER_POLICY` | string | `strict-origin-when-cross-origin` | Referrer-Policy header value | -| `SECURITY_PERMISSIONS_POLICY` | string | *(restrictive)* | Permissions-Policy header value | -| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | string | `same-origin` | Cross-Origin-Opener-Policy header | -| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | string | `same-origin` | Cross-Origin-Resource-Policy header | -| `SECURITY_CSP_ENABLED` | bool | `1` | Enable Content-Security-Policy header | -| `SECURITY_CSP_POLICY` | string | *(restrictive)* | CSP for app routes | -| `SECURITY_CSP_DOCS_POLICY` | string | *(docs-specific)* | CSP for documentation routes | -| `SECURITY_CSP_REPORT_ONLY` | bool | `0` | Use report-only mode for CSP | -| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | bool | `0` | Enable HSTS header | -| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | int | `31536000` | HSTS max-age in seconds | -| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | bool | `1` | HSTS includeSubDomains directive | -| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | bool | `0` | HSTS preload directive | - -## Logging - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `LOG_LEVEL` | string | `INFO` | Root log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | -| `LOG_FORMAT` | string | `console` | Log format (`console` or `json`) | -| `LOG_REQUESTS` | bool | `1` | Log HTTP requests | -| `LOG_UVICORN_ACCESS` | bool | `0` | Enable uvicorn access log | -| `LOG_REQUEST_SKIP_PATHS` | string | `/healthz` | Comma-separated paths to exclude from request logging | -| `LOG_REDACT_FIELDS` | string | *(empty)* | Comma-separated field names to redact in logs | - -## Scheduler & Ingestion Safety - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `SCHEDULER_ENABLED` | bool | `1` | Enable the background scheduler | -| `SCHEDULER_TICK_SECONDS` | int | `60` | Scheduler poll interval | -| `SCHEDULER_QUEUE_BATCH_SIZE` | int | `10` | Max scholars processed per tick | -| `SCHEDULER_PDF_QUEUE_BATCH_SIZE` | int | `15` | Max PDF resolutions per tick | -| `INGESTION_AUTOMATION_ALLOWED` | bool | `1` | Allow automated (scheduled) runs | -| `INGESTION_MANUAL_RUN_ALLOWED` | bool | `1` | Allow manually triggered runs | -| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | int | `15` | Minimum time between runs | -| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | int | `2` | Floor delay between external requests | -| `INGESTION_NETWORK_ERROR_RETRIES` | int | `1` | Retries on network errors | -| `INGESTION_RETRY_BACKOFF_SECONDS` | float | `1.0` | Base backoff for network retries | -| `INGESTION_RATE_LIMIT_RETRIES` | int | `3` | Retries on 429 responses | -| `INGESTION_RATE_LIMIT_BACKOFF_SECONDS` | float | `30.0` | Backoff per 429 retry | -| `INGESTION_MAX_PAGES_PER_SCHOLAR` | int | `30` | Max paginated pages per scholar | -| `INGESTION_PAGE_SIZE` | int | `100` | Publications per page | -| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | int | `1` | Blocked failures before alert | -| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | int | `2` | Network failures before alert | -| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | int | `3` | Scheduled retries before alert | -| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | int | `1800` | Cooldown after blocked-failure threshold (30 min) | -| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | int | `900` | Cooldown after network-failure threshold (15 min) | -| `INGESTION_CONTINUATION_QUEUE_ENABLED` | bool | `1` | Enable continuation queue for multi-page ingestion | -| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | int | `120` | Base delay for continuation queue items | -| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | int | `3600` | Max delay for continuation queue items | -| `INGESTION_CONTINUATION_MAX_ATTEMPTS` | int | `6` | Max continuation attempts per scholar | - -## Scholar Images & Name Search Safety - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `SCHOLAR_IMAGE_UPLOAD_DIR` | string | `/var/lib/scholarr/uploads` | Directory for uploaded scholar images | -| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | int | `2000000` | Max image upload size (2 MB) | -| `SCHOLAR_NAME_SEARCH_ENABLED` | bool | `1` | Enable name-based scholar search | -| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | int | `21600` | Cache TTL for successful searches (6 hours) | -| `SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS` | int | `300` | Cache TTL for blocked search results (5 min) | -| `SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES` | int | `512` | Max cache entries for name search | -| `SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS` | float | `8.0` | Min interval between name searches | -| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | float | `2.0` | Random jitter added to search interval | -| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | int | `1` | Blocked results before cooldown | -| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | int | `1800` | Cooldown after blocked name search (30 min) | -| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | int | `2` | Retries before alert | -| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | int | `3` | Cooldown rejections before alert | - -## OA Enrichment & PDF Resolution - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `UNPAYWALL_ENABLED` | bool | `1` | Enable Unpaywall DOI lookups | -| `UNPAYWALL_EMAIL` | string | *(empty)* | Polite pool email for Unpaywall API | -| `UNPAYWALL_TIMEOUT_SECONDS` | float | `4.0` | Request timeout | -| `UNPAYWALL_MIN_INTERVAL_SECONDS` | float | `0.6` | Min interval between Unpaywall requests | -| `UNPAYWALL_MAX_ITEMS_PER_REQUEST` | int | `20` | Max items per batch | -| `UNPAYWALL_RETRY_COOLDOWN_SECONDS` | int | `1800` | Cooldown after repeated failures | -| `UNPAYWALL_PDF_DISCOVERY_ENABLED` | bool | `1` | Enable HTML-based PDF link discovery | -| `UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES` | int | `5` | Max candidate URLs to probe | -| `UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES` | int | `500000` | Max HTML response size to parse | -| `ARXIV_ENABLED` | bool | `1` | Enable arXiv API lookups | -| `ARXIV_TIMEOUT_SECONDS` | float | `3.0` | Request timeout | -| `ARXIV_MIN_INTERVAL_SECONDS` | float | `4.0` | Min interval between arXiv requests | -| `ARXIV_RATE_LIMIT_COOLDOWN_SECONDS` | float | `60.0` | Cooldown after arXiv 429 | -| `ARXIV_DEFAULT_MAX_RESULTS` | int | `3` | Default max results per query | -| `ARXIV_CACHE_TTL_SECONDS` | int | `900` | Query cache TTL (15 min) | -| `ARXIV_CACHE_MAX_ENTRIES` | int | `512` | Max cached queries | -| `ARXIV_MAILTO` | string | *(empty)* | Contact email for arXiv API headers | -| `PDF_AUTO_RETRY_INTERVAL_SECONDS` | int | `86400` | Auto-retry interval for failed PDFs (24 hours) | -| `PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS` | int | `3600` | First retry interval (1 hour) | -| `PDF_AUTO_RETRY_MAX_ATTEMPTS` | int | `3` | Max auto-retry attempts | -| `CROSSREF_ENABLED` | bool | `1` | Enable Crossref lookups | -| `CROSSREF_MAX_ROWS` | int | `10` | Max rows per Crossref query | -| `CROSSREF_TIMEOUT_SECONDS` | float | `8.0` | Request timeout | -| `CROSSREF_MIN_INTERVAL_SECONDS` | float | `0.6` | Min interval between Crossref requests | -| `CROSSREF_MAX_LOOKUPS_PER_REQUEST` | int | `8` | Max lookups per ingestion request | -| `OPENALEX_API_KEY` | string | *(empty)* | OpenAlex API key (optional) | -| `CROSSREF_API_TOKEN` | string | *(empty)* | Crossref Plus API token (optional) | -| `CROSSREF_API_MAILTO` | string | *(empty)* | Crossref polite pool email | - -## Startup Bootstrap & DB Wait - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `BOOTSTRAP_ADMIN_ON_START` | bool | `0` | Create admin user on startup | -| `BOOTSTRAP_ADMIN_EMAIL` | string | *(empty)* | Admin email address | -| `BOOTSTRAP_ADMIN_PASSWORD` | string | *(empty)* | Admin password | -| `BOOTSTRAP_ADMIN_FORCE_PASSWORD` | bool | `0` | Overwrite existing admin password | -| `DB_WAIT_TIMEOUT_SECONDS` | int | `60` | Max seconds to wait for database readiness | -| `DB_WAIT_INTERVAL_SECONDS` | int | `2` | Poll interval while waiting for database | diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md deleted file mode 100644 index 1090f86..0000000 --- a/docs/user/getting-started.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Getting Started -sidebar_position: 2 ---- - -# Getting Started - -## Prerequisites - -- Docker and Docker Compose v2+ -- A machine with at least 512 MB RAM - -## Installation - -1. Clone the repository: - -```bash -git clone https://github.com/JustinZeus/scholarr.git -cd scholarr -``` - -2. Copy the example environment file: - -```bash -cp .env.example .env -``` - -3. Edit `.env` and set the required secrets: - -```bash -# Required: database password -POSTGRES_PASSWORD=your-secure-password - -# Required: session signing key (32+ random characters) -SESSION_SECRET_KEY=your-random-secret-key -``` - -4. Start the stack: - -```bash -docker compose up -d -``` - -5. Verify the service is healthy: - -```bash -docker compose ps -curl http://localhost:8000/healthz -``` - -The app is now available at `http://localhost:8000`. - -## First Run - -### Bootstrap an Admin User - -Set these environment variables before first start (or add them to `.env`): - -```bash -BOOTSTRAP_ADMIN_ON_START=1 -BOOTSTRAP_ADMIN_EMAIL=admin@example.com -BOOTSTRAP_ADMIN_PASSWORD=your-admin-password -``` - -Restart the app container. The admin account is created on startup. - -### Add Your First Scholar - -1. Log in at `http://localhost:8000`. -2. Navigate to the Scholars page. -3. Click **Add Scholar**. -4. Enter a Google Scholar profile URL (e.g., `https://scholar.google.com/citations?user=XXXXXXXXXX`) or a Scholar ID. -5. The scheduler will begin fetching publications on the next tick (default: 60 seconds). - -### Manual Run - -To trigger an immediate ingestion run, use the **Manual Run** button on the Runs page. This respects the minimum run interval (`INGESTION_MIN_RUN_INTERVAL_MINUTES`, default 15 minutes). - -## Updating - -```bash -docker compose pull -docker compose up -d -``` - -Database migrations run automatically on startup when `MIGRATE_ON_START=1` (the default). diff --git a/docs/user/overview.md b/docs/user/overview.md deleted file mode 100644 index 50af970..0000000 --- a/docs/user/overview.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: Overview -sidebar_position: 1 ---- - -# What is Scholarr? - -Scholarr is a self-hosted service that tracks academic publications from Google Scholar. It runs as a Docker container with a PostgreSQL database and serves a Vue 3 frontend. - -## Key Concepts - -- **Scholar** - A tracked Google Scholar profile. Scholars are user-scoped: each user manages their own list. -- **Publication** - A globally deduplicated academic work. Publications are shared across all users to avoid duplicate storage. -- **Scholar-Publication Link** - Connects a scholar to a publication for a specific user. Read/unread state, favorites, and visibility live on this link, not on the publication itself. -- **Run** - A single ingestion cycle that fetches new publications for one or more scholars. -- **Identifier** - A DOI, arXiv ID, PMID, or other external key attached to a publication. Multiple identifiers can exist per publication. - -## How It Works - -1. You add a Google Scholar profile (by ID, URL, or name search). -2. The scheduler periodically scrapes the profile for new publications. -3. Each publication is fingerprinted and deduplicated against the global store. -4. External APIs (arXiv, Crossref, OpenAlex) are queried for identifiers. -5. Unpaywall and arXiv resolve open-access PDF URLs when a DOI is available. -6. The dashboard shows new, unread, and all publications with filtering and search. - -## Safety Model - -Scholarr enforces rate limits and cooldowns to prevent IP bans from upstream sources. These are not configurable to zero; they are safety floors. See [Scrape Safety Runbook](../operations/scrape-safety-runbook.md) for details. diff --git a/docs/website/docusaurus.config.js b/docs/website/docusaurus.config.js deleted file mode 100644 index 6156ad9..0000000 --- a/docs/website/docusaurus.config.js +++ /dev/null @@ -1,74 +0,0 @@ -// @ts-check - -/** @type {import('@docusaurus/types').Config} */ -const config = { - title: "Scholarr", - tagline: "Self-hosted academic publication tracker", - favicon: "img/favicon.ico", - - url: "https://justinzeus.github.io", - baseUrl: "/scholarr/", - - organizationName: "JustinZeus", - projectName: "scholarr", - - onBrokenLinks: "throw", - onBrokenMarkdownLinks: "warn", - - i18n: { - defaultLocale: "en", - locales: ["en"], - }, - - presets: [ - [ - "classic", - /** @type {import('@docusaurus/preset-classic').Options} */ - ({ - docs: { - path: "..", - routeBasePath: "/", - sidebarPath: require.resolve("./sidebars.js"), - exclude: ["website/**", "README.md"], - }, - blog: false, - theme: { - customCss: require.resolve("./src/css/custom.css"), - }, - }), - ], - ], - - themeConfig: - /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ - ({ - navbar: { - title: "Scholarr", - items: [ - { to: "/user/overview", label: "User Guide", position: "left" }, - { - to: "/developer/overview", - label: "Developer", - position: "left", - }, - { - to: "/operations/overview", - label: "Operations", - position: "left", - }, - { to: "/reference/overview", label: "Reference", position: "left" }, - { - href: "https://github.com/JustinZeus/scholarr", - label: "GitHub", - position: "right", - }, - ], - }, - footer: { - style: "dark", - copyright: `Copyright \u00a9 ${new Date().getFullYear()} Scholarr. Built with Docusaurus.`, - }, - }), -}; - -module.exports = config; diff --git a/docs/website/package-lock.json b/docs/website/package-lock.json deleted file mode 100644 index d327972..0000000 --- a/docs/website/package-lock.json +++ /dev/null @@ -1,18461 +0,0 @@ -{ - "name": "scholarr-docs", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "scholarr-docs", - "version": "0.1.0", - "dependencies": { - "@docusaurus/core": "^3.0.0", - "@docusaurus/preset-classic": "^3.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "^3.0.0" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.15.1.tgz", - "integrity": "sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", - "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", - "@algolia/autocomplete-shared": "1.19.2" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", - "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.19.2" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", - "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.49.1.tgz", - "integrity": "sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.49.1.tgz", - "integrity": "sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.49.1.tgz", - "integrity": "sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.49.1.tgz", - "integrity": "sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.49.1.tgz", - "integrity": "sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.49.1.tgz", - "integrity": "sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.49.1.tgz", - "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/events": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", - "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==", - "license": "MIT" - }, - "node_modules/@algolia/ingestion": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.49.1.tgz", - "integrity": "sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.49.1.tgz", - "integrity": "sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.49.1.tgz", - "integrity": "sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.1.tgz", - "integrity": "sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.49.1.tgz", - "integrity": "sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.49.1.tgz", - "integrity": "sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==", - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", - "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", - "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", - "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", - "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", - "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", - "license": "MIT", - "dependencies": { - "core-js-pure": "^3.48.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@csstools/cascade-layer-name-parser": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", - "integrity": "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/media-query-list-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", - "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/postcss-alpha-function": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-alpha-function/-/postcss-alpha-function-1.0.1.tgz", - "integrity": "sha512-isfLLwksH3yHkFXfCI2Gcaqg7wGGHZZwunoJzEZk0yKYIokgre6hYVFibKL3SYAoR1kBXova8LB+JoO5vZzi9w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-cascade-layers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", - "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "peer": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@csstools/postcss-color-function": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.12.tgz", - "integrity": "sha512-yx3cljQKRaSBc2hfh8rMZFZzChaFgwmO2JfFgFr1vMcF3C/uyy5I4RFIBOIWGq1D+XbKCG789CGkG6zzkLpagA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-color-function-display-p3-linear": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function-display-p3-linear/-/postcss-color-function-display-p3-linear-1.0.1.tgz", - "integrity": "sha512-E5qusdzhlmO1TztYzDIi8XPdPoYOjoTY6HBYBCYSj+Gn4gQRBlvjgPQXzfzuPQqt8EhkC/SzPKObg4Mbn8/xMg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-color-mix-function": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.12.tgz", - "integrity": "sha512-4STERZfCP5Jcs13P1U5pTvI9SkgLgfMUMhdXW8IlJWkzOOOqhZIjcNhWtNJZes2nkBDsIKJ0CJtFtuaZ00moag==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-1.0.2.tgz", - "integrity": "sha512-rM67Gp9lRAkTo+X31DUqMEq+iK+EFqsidfecmhrteErxJZb6tUoJBVQca1Vn1GpDql1s1rD1pKcuYzMsg7Z1KQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-content-alt-text": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.8.tgz", - "integrity": "sha512-9SfEW9QCxEpTlNMnpSqFaHyzsiRpZ5J5+KqCu1u5/eEJAWsMhzT40qf0FIbeeglEvrGRMdDzAxMIz3wqoGSb+Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-contrast-color-function": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-contrast-color-function/-/postcss-contrast-color-function-2.0.12.tgz", - "integrity": "sha512-YbwWckjK3qwKjeYz/CijgcS7WDUCtKTd8ShLztm3/i5dhh4NaqzsbYnhm4bjrpFpnLZ31jVcbK8YL77z3GBPzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-exponential-functions": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.9.tgz", - "integrity": "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-font-format-keywords": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-4.0.0.tgz", - "integrity": "sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-gamut-mapping": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.11.tgz", - "integrity": "sha512-fCpCUgZNE2piVJKC76zFsgVW1apF6dpYsqGyH8SIeCcM4pTEsRTWTLCaJIMKFEundsCKwY1rwfhtrio04RJ4Dw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-gradients-interpolation-method": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.12.tgz", - "integrity": "sha512-jugzjwkUY0wtNrZlFeyXzimUL3hN4xMvoPnIXxoZqxDvjZRiSh+itgHcVUWzJ2VwD/VAMEgCLvtaJHX+4Vj3Ow==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-hwb-function": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.12.tgz", - "integrity": "sha512-mL/+88Z53KrE4JdePYFJAQWFrcADEqsLprExCM04GDNgHIztwFzj0Mbhd/yxMBngq0NIlz58VVxjt5abNs1VhA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-ic-unit": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.4.tgz", - "integrity": "sha512-yQ4VmossuOAql65sCPppVO1yfb7hDscf4GseF0VCA/DTDaBc0Wtf8MTqVPfjGYlT5+2buokG0Gp7y0atYZpwjg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-initial": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-initial/-/postcss-initial-2.0.1.tgz", - "integrity": "sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz", - "integrity": "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "peer": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@csstools/postcss-light-dark-function": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.11.tgz", - "integrity": "sha512-fNJcKXJdPM3Lyrbmgw2OBbaioU7yuKZtiXClf4sGdQttitijYlZMD5K7HrC/eF83VRWRrYq6OZ0Lx92leV2LFA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-float-and-clear": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-3.0.0.tgz", - "integrity": "sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-overflow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-2.0.0.tgz", - "integrity": "sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-overscroll-behavior": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-2.0.0.tgz", - "integrity": "sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-resize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-3.0.0.tgz", - "integrity": "sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-viewport-units": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.4.tgz", - "integrity": "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-media-minmax": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.9.tgz", - "integrity": "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.5.tgz", - "integrity": "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-nested-calc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-4.0.0.tgz", - "integrity": "sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-normalize-display-values": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.1.tgz", - "integrity": "sha512-TQUGBuRvxdc7TgNSTevYqrL8oItxiwPDixk20qCB5me/W8uF7BPbhRrAvFuhEoywQp/woRsUZ6SJ+sU5idZAIA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-oklab-function": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.12.tgz", - "integrity": "sha512-HhlSmnE1NKBhXsTnNGjxvhryKtO7tJd1w42DKOGFD6jSHtYOrsJTQDKPMwvOfrzUAk8t7GcpIfRyM7ssqHpFjg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-position-area-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-position-area-property/-/postcss-position-area-property-1.0.0.tgz", - "integrity": "sha512-fUP6KR8qV2NuUZV3Cw8itx0Ep90aRjAZxAEzC3vrl6yjFv+pFsQbR18UuQctEKmA72K9O27CoYiKEgXxkqjg8Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.2.1.tgz", - "integrity": "sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-property-rule-prelude-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-property-rule-prelude-list/-/postcss-property-rule-prelude-list-1.0.0.tgz", - "integrity": "sha512-IxuQjUXq19fobgmSSvUDO7fVwijDJaZMvWQugxfEUxmjBeDCVaDuMpsZ31MsTm5xbnhA+ElDi0+rQ7sQQGisFA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-random-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-2.0.1.tgz", - "integrity": "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-relative-color-syntax": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.12.tgz", - "integrity": "sha512-0RLIeONxu/mtxRtf3o41Lq2ghLimw0w9ByLWnnEVuy89exmEEq8bynveBxNW3nyHqLAFEeNtVEmC1QK9MZ8Huw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-scope-pseudo-class": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-4.0.1.tgz", - "integrity": "sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@csstools/postcss-sign-functions": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", - "integrity": "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.9.tgz", - "integrity": "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-syntax-descriptor-syntax-production": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-syntax-descriptor-syntax-production/-/postcss-syntax-descriptor-syntax-production-1.0.1.tgz", - "integrity": "sha512-GneqQWefjM//f4hJ/Kbox0C6f2T7+pi4/fqTqOFGTL3EjnvOReTqO1qUQ30CaUjkwjYq9qZ41hzarrAxCc4gow==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-system-ui-font-family": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-system-ui-font-family/-/postcss-system-ui-font-family-1.0.0.tgz", - "integrity": "sha512-s3xdBvfWYfoPSBsikDXbuorcMG1nN1M6GdU0qBsGfcmNR0A/qhloQZpTxjA3Xsyrk1VJvwb2pOfiOT3at/DuIQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-text-decoration-shorthand": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-4.0.3.tgz", - "integrity": "sha512-KSkGgZfx0kQjRIYnpsD7X2Om9BUXX/Kii77VBifQW9Ih929hK0KNjVngHDH0bFB9GmfWcR9vJYJJRvw/NQjkrA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.9.tgz", - "integrity": "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-unset-value": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-4.0.0.tgz", - "integrity": "sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/utilities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-2.0.0.tgz", - "integrity": "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@docsearch/core": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.0.tgz", - "integrity": "sha512-IqG3oSd529jVRQ4dWZQKwZwQLVd//bWJTz2HiL0LkiHrI4U/vLrBasKB7lwQB/69nBAcCgs3TmudxTZSLH/ZQg==", - "license": "MIT", - "peerDependencies": { - "@types/react": ">= 16.8.0 < 20.0.0", - "react": ">= 16.8.0 < 20.0.0", - "react-dom": ">= 16.8.0 < 20.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/@docsearch/css": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.0.tgz", - "integrity": "sha512-YlcAimkXclvqta47g47efzCM5CFxDwv2ClkDfEs/fC/Ak0OxPH2b3czwa4o8O1TRBf+ujFF2RiUwszz2fPVNJQ==", - "license": "MIT" - }, - "node_modules/@docsearch/react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.0.tgz", - "integrity": "sha512-j8H5B4ArGxBPBWvw3X0J0Rm/Pjv2JDa2rV5OE0DLTp5oiBCptIJ/YlNOhZxuzbO2nwge+o3Z52nJRi3hryK9cA==", - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.19.2", - "@docsearch/core": "4.6.0", - "@docsearch/css": "4.6.0" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 20.0.0", - "react": ">= 16.8.0 < 20.0.0", - "react-dom": ">= 16.8.0 < 20.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@docusaurus/babel": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.9.2.tgz", - "integrity": "sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-runtime": "^7.25.9", - "@babel/preset-env": "^7.25.9", - "@babel/preset-react": "^7.25.9", - "@babel/preset-typescript": "^7.25.9", - "@babel/runtime": "^7.25.9", - "@babel/runtime-corejs3": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", - "babel-plugin-dynamic-import-node": "^2.3.3", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/bundler": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.9.2.tgz", - "integrity": "sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.9.2", - "@docusaurus/cssnano-preset": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "babel-loader": "^9.2.1", - "clean-css": "^5.3.3", - "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.11.0", - "css-minimizer-webpack-plugin": "^5.0.1", - "cssnano": "^6.1.2", - "file-loader": "^6.2.0", - "html-minifier-terser": "^7.2.0", - "mini-css-extract-plugin": "^2.9.2", - "null-loader": "^4.0.1", - "postcss": "^8.5.4", - "postcss-loader": "^7.3.4", - "postcss-preset-env": "^10.2.1", - "terser-webpack-plugin": "^5.3.9", - "tslib": "^2.6.0", - "url-loader": "^4.1.1", - "webpack": "^5.95.0", - "webpackbar": "^6.0.1" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "@docusaurus/faster": "*" - }, - "peerDependenciesMeta": { - "@docusaurus/faster": { - "optional": true - } - } - }, - "node_modules/@docusaurus/core": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.9.2.tgz", - "integrity": "sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.9.2", - "@docusaurus/bundler": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "boxen": "^6.2.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "cli-table3": "^0.6.3", - "combine-promises": "^1.1.0", - "commander": "^5.1.0", - "core-js": "^3.31.1", - "detect-port": "^1.5.1", - "escape-html": "^1.0.3", - "eta": "^2.2.0", - "eval": "^0.1.8", - "execa": "5.1.1", - "fs-extra": "^11.1.1", - "html-tags": "^3.3.1", - "html-webpack-plugin": "^5.6.0", - "leven": "^3.1.0", - "lodash": "^4.17.21", - "open": "^8.4.0", - "p-map": "^4.0.0", - "prompts": "^2.4.2", - "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", - "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", - "react-loadable-ssr-addon-v5-slorber": "^1.0.1", - "react-router": "^5.3.4", - "react-router-config": "^5.1.1", - "react-router-dom": "^5.3.4", - "semver": "^7.5.4", - "serve-handler": "^6.1.6", - "tinypool": "^1.0.2", - "tslib": "^2.6.0", - "update-notifier": "^6.0.2", - "webpack": "^5.95.0", - "webpack-bundle-analyzer": "^4.10.2", - "webpack-dev-server": "^5.2.2", - "webpack-merge": "^6.0.1" - }, - "bin": { - "docusaurus": "bin/docusaurus.mjs" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "@mdx-js/react": "^3.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/cssnano-preset": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz", - "integrity": "sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==", - "license": "MIT", - "dependencies": { - "cssnano-preset-advanced": "^6.1.2", - "postcss": "^8.5.4", - "postcss-sort-media-queries": "^5.2.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/logger": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.9.2.tgz", - "integrity": "sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/mdx-loader": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.9.2.tgz", - "integrity": "sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "@mdx-js/mdx": "^3.0.0", - "@slorber/remark-comment": "^1.0.0", - "escape-html": "^1.0.3", - "estree-util-value-to-estree": "^3.0.1", - "file-loader": "^6.2.0", - "fs-extra": "^11.1.1", - "image-size": "^2.0.2", - "mdast-util-mdx": "^3.0.0", - "mdast-util-to-string": "^4.0.0", - "rehype-raw": "^7.0.0", - "remark-directive": "^3.0.0", - "remark-emoji": "^4.0.0", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.0", - "stringify-object": "^3.3.0", - "tslib": "^2.6.0", - "unified": "^11.0.3", - "unist-util-visit": "^5.0.0", - "url-loader": "^4.1.1", - "vfile": "^6.0.1", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/module-type-aliases": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.2.tgz", - "integrity": "sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==", - "license": "MIT", - "dependencies": { - "@docusaurus/types": "3.9.2", - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router-config": "*", - "@types/react-router-dom": "*", - "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", - "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz", - "integrity": "sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "cheerio": "1.0.0-rc.12", - "feed": "^4.2.2", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "schema-dts": "^1.1.2", - "srcset": "^4.0.0", - "tslib": "^2.6.0", - "unist-util-visit": "^5.0.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "@docusaurus/plugin-content-docs": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", - "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "@types/react-router-config": "^5.0.7", - "combine-promises": "^1.1.0", - "fs-extra": "^11.1.1", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "schema-dts": "^1.1.2", - "tslib": "^2.6.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz", - "integrity": "sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.2.tgz", - "integrity": "sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/plugin-debug": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.9.2.tgz", - "integrity": "sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "fs-extra": "^11.1.1", - "react-json-view-lite": "^2.3.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.2.tgz", - "integrity": "sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.2.tgz", - "integrity": "sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "@types/gtag.js": "^0.0.12", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.2.tgz", - "integrity": "sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.2.tgz", - "integrity": "sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "fs-extra": "^11.1.1", - "sitemap": "^7.1.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-svgr": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.2.tgz", - "integrity": "sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "@svgr/core": "8.1.0", - "@svgr/webpack": "^8.1.0", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/preset-classic": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.9.2.tgz", - "integrity": "sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/plugin-content-blog": "3.9.2", - "@docusaurus/plugin-content-docs": "3.9.2", - "@docusaurus/plugin-content-pages": "3.9.2", - "@docusaurus/plugin-css-cascade-layers": "3.9.2", - "@docusaurus/plugin-debug": "3.9.2", - "@docusaurus/plugin-google-analytics": "3.9.2", - "@docusaurus/plugin-google-gtag": "3.9.2", - "@docusaurus/plugin-google-tag-manager": "3.9.2", - "@docusaurus/plugin-sitemap": "3.9.2", - "@docusaurus/plugin-svgr": "3.9.2", - "@docusaurus/theme-classic": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/theme-search-algolia": "3.9.2", - "@docusaurus/types": "3.9.2" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-classic": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.9.2.tgz", - "integrity": "sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/plugin-content-blog": "3.9.2", - "@docusaurus/plugin-content-docs": "3.9.2", - "@docusaurus/plugin-content-pages": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/theme-translations": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "@mdx-js/react": "^3.0.0", - "clsx": "^2.0.0", - "infima": "0.2.0-alpha.45", - "lodash": "^4.17.21", - "nprogress": "^0.2.0", - "postcss": "^8.5.4", - "prism-react-renderer": "^2.3.0", - "prismjs": "^1.29.0", - "react-router-dom": "^5.3.4", - "rtlcss": "^4.1.0", - "tslib": "^2.6.0", - "utility-types": "^3.10.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-common": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.9.2.tgz", - "integrity": "sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==", - "license": "MIT", - "dependencies": { - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router-config": "*", - "clsx": "^2.0.0", - "parse-numeric-range": "^1.3.0", - "prism-react-renderer": "^2.3.0", - "tslib": "^2.6.0", - "utility-types": "^3.10.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "@docusaurus/plugin-content-docs": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz", - "integrity": "sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==", - "license": "MIT", - "dependencies": { - "@docsearch/react": "^3.9.0 || ^4.1.0", - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/plugin-content-docs": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/theme-translations": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "algoliasearch": "^5.37.0", - "algoliasearch-helper": "^3.26.0", - "clsx": "^2.0.0", - "eta": "^2.2.0", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "tslib": "^2.6.0", - "utility-types": "^3.10.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-translations": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz", - "integrity": "sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==", - "license": "MIT", - "dependencies": { - "fs-extra": "^11.1.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/types": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.9.2.tgz", - "integrity": "sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==", - "license": "MIT", - "dependencies": { - "@mdx-js/mdx": "^3.0.0", - "@types/history": "^4.7.11", - "@types/mdast": "^4.0.2", - "@types/react": "*", - "commander": "^5.1.0", - "joi": "^17.9.2", - "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", - "utility-types": "^3.10.0", - "webpack": "^5.95.0", - "webpack-merge": "^5.9.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/types/node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@docusaurus/utils": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.9.2.tgz", - "integrity": "sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/logger": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "escape-string-regexp": "^4.0.0", - "execa": "5.1.1", - "file-loader": "^6.2.0", - "fs-extra": "^11.1.1", - "github-slugger": "^1.5.0", - "globby": "^11.1.0", - "gray-matter": "^4.0.3", - "jiti": "^1.20.0", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "micromatch": "^4.0.5", - "p-queue": "^6.6.2", - "prompts": "^2.4.2", - "resolve-pathname": "^3.0.0", - "tslib": "^2.6.0", - "url-loader": "^4.1.1", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/utils-common": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.9.2.tgz", - "integrity": "sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==", - "license": "MIT", - "dependencies": { - "@docusaurus/types": "3.9.2", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@docusaurus/utils-validation": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz", - "integrity": "sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==", - "license": "MIT", - "dependencies": { - "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "fs-extra": "^11.2.0", - "joi": "^17.9.2", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/buffers": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", - "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/codegen": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", - "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-core": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.56.10.tgz", - "integrity": "sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.56.10.tgz", - "integrity": "sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.56.10.tgz", - "integrity": "sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", - "glob-to-regex.js": "^1.0.0", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.56.10.tgz", - "integrity": "sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.56.10.tgz", - "integrity": "sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.56.10.tgz", - "integrity": "sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.56.10" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-print": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.56.10.tgz", - "integrity": "sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.56.10", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.56.10.tgz", - "integrity": "sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/json-pack": "^17.65.0", - "@jsonjoy.com/util": "^17.65.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", - "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", - "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", - "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "17.67.0", - "@jsonjoy.com/buffers": "17.67.0", - "@jsonjoy.com/codegen": "17.67.0", - "@jsonjoy.com/json-pointer": "17.67.0", - "@jsonjoy.com/util": "17.67.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", - "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/util": "17.67.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", - "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "17.67.0", - "@jsonjoy.com/codegen": "17.67.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.2.0", - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.2", - "@jsonjoy.com/util": "^1.9.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pointer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", - "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/util": "^1.9.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", - "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "^1.0.0", - "@jsonjoy.com/codegen": "^1.0.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "license": "MIT" - }, - "node_modules/@mdx-js/mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", - "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdx": "^2.0.0", - "acorn": "^8.0.0", - "collapse-white-space": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "estree-util-scope": "^1.0.0", - "estree-walker": "^3.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "markdown-extensions": "^2.0.0", - "recma-build-jsx": "^1.0.0", - "recma-jsx": "^1.0.0", - "recma-stringify": "^1.0.0", - "rehype-recma": "^1.0.0", - "remark-mdx": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "source-map": "^0.7.0", - "unified": "^11.0.0", - "unist-util-position-from-estree": "^2.0.0", - "unist-util-stringify-position": "^4.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@mdx-js/react": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", - "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdx": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", - "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/asn1-x509-attr": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", - "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", - "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pfx": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", - "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.1", - "@peculiar/asn1-pkcs8": "^2.6.1", - "@peculiar/asn1-rsa": "^2.6.1", - "@peculiar/asn1-schema": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", - "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", - "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.1", - "@peculiar/asn1-pfx": "^2.6.1", - "@peculiar/asn1-pkcs8": "^2.6.1", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/asn1-x509-attr": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-rsa": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", - "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", - "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", - "license": "MIT", - "dependencies": { - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", - "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", - "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", - "tslib": "^2.8.1", - "tsyringe": "^4.10.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "license": "MIT", - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", - "license": "MIT", - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "license": "MIT" - }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "license": "BSD-3-Clause" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@slorber/remark-comment": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@slorber/remark-comment/-/remark-comment-1.0.0.tgz", - "integrity": "sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==", - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.1.0", - "micromark-util-symbol": "^1.0.1" - } - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "license": "MIT", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" - } - }, - "node_modules/@svgr/plugin-svgo": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", - "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", - "license": "MIT", - "dependencies": { - "cosmiconfig": "^8.1.3", - "deepmerge": "^4.3.1", - "svgo": "^3.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" - } - }, - "node_modules/@svgr/webpack": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", - "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@babel/plugin-transform-react-constant-elements": "^7.21.3", - "@babel/preset-env": "^7.20.2", - "@babel/preset-react": "^7.18.6", - "@babel/preset-typescript": "^7.21.0", - "@svgr/core": "8.1.0", - "@svgr/plugin-jsx": "8.1.0", - "@svgr/plugin-svgo": "8.1.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/gtag.js": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", - "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==", - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/history": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", - "license": "MIT" - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "license": "MIT" - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "license": "MIT" - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/prismjs": { - "version": "1.26.6", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", - "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-router": { - "version": "5.1.20", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", - "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", - "license": "MIT", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*" - } - }, - "node_modules/@types/react-router-config": { - "version": "5.0.11", - "resolved": "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.11.tgz", - "integrity": "sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==", - "license": "MIT", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "^5.1.0" - } - }, - "node_modules/@types/react-router-dom": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", - "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", - "license": "MIT", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "*" - } - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "license": "MIT" - }, - "node_modules/@types/sax": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", - "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/algoliasearch": { - "version": "5.49.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.1.tgz", - "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/abtesting": "1.15.1", - "@algolia/client-abtesting": "5.49.1", - "@algolia/client-analytics": "5.49.1", - "@algolia/client-common": "5.49.1", - "@algolia/client-insights": "5.49.1", - "@algolia/client-personalization": "5.49.1", - "@algolia/client-query-suggestions": "5.49.1", - "@algolia/client-search": "5.49.1", - "@algolia/ingestion": "1.49.1", - "@algolia/monitoring": "1.49.1", - "@algolia/recommend": "5.49.1", - "@algolia/requester-browser-xhr": "5.49.1", - "@algolia/requester-fetch": "5.49.1", - "@algolia/requester-node-http": "5.49.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/algoliasearch-helper": { - "version": "3.28.0", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.28.0.tgz", - "integrity": "sha512-GBN0xsxGggaCPElZq24QzMdfphrjIiV2xA+hRXE4/UMpN3nsF2WrM8q+x80OGvGpJWtB7F+4Hq5eSfWwuejXrg==", - "license": "MIT", - "dependencies": { - "@algolia/events": "^4.0.1" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 6" - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/asn1js": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", - "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", - "license": "BSD-3-Clause", - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/astring": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", - "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", - "license": "MIT", - "bin": { - "astring": "bin/astring" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/babel-loader": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", - "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "license": "MIT", - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-plugin-dynamic-import-node": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", - "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", - "license": "MIT", - "dependencies": { - "object.assign": "^4.1.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "license": "MIT" - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" - }, - "node_modules/boxen": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-6.2.1.tgz", - "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^6.2.0", - "chalk": "^4.1.2", - "cli-boxes": "^3.0.0", - "string-width": "^5.0.1", - "type-fest": "^2.5.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/bytestreamjs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", - "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-api": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.0.0", - "caniuse-lite": "^1.0.0", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001775", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", - "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" - }, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", - "license": "MIT", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/clean-css/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-table3/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cli-table3/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/collapse-white-space": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", - "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT" - }, - "node_modules/combine-promises": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.2.0.tgz", - "integrity": "sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "license": "ISC" - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compressible/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/config-chain/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/configstore": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", - "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", - "license": "BSD-2-Clause", - "dependencies": { - "dot-prop": "^6.0.1", - "graceful-fs": "^4.2.6", - "unique-string": "^3.0.0", - "write-file-atomic": "^3.0.3", - "xdg-basedir": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/yeoman/configstore?sponsor=1" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/content-disposition": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", - "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/copy-webpack-plugin": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", - "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", - "license": "MIT", - "dependencies": { - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.1", - "globby": "^13.1.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", - "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", - "license": "MIT", - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-pure": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz", - "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/css-blank-pseudo": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-7.0.1.tgz", - "integrity": "sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/css-declaration-sorter": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", - "integrity": "sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==", - "license": "ISC", - "engines": { - "node": "^14 || ^16 || >=18" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/css-has-pseudo": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-7.0.3.tgz", - "integrity": "sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-has-pseudo/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "peer": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/css-loader": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", - "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", - "license": "MIT", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/css-minimizer-webpack-plugin": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-5.0.1.tgz", - "integrity": "sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "cssnano": "^6.0.1", - "jest-worker": "^29.4.3", - "postcss": "^8.4.24", - "schema-utils": "^4.0.1", - "serialize-javascript": "^6.0.1" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@parcel/css": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "lightningcss": { - "optional": true - } - } - }, - "node_modules/css-prefers-color-scheme": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-10.0.0.tgz", - "integrity": "sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssdb": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.8.0.tgz", - "integrity": "sha512-QbLeyz2Bgso1iRlh7IpWk6OKa3lLNGXsujVjDMPl9rOZpxKeiG69icLpbLCFxeURwmcdIfZqQyhlooKJYM4f8Q==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - } - ], - "license": "MIT-0" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssnano": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", - "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", - "license": "MIT", - "dependencies": { - "cssnano-preset-default": "^6.1.2", - "lilconfig": "^3.1.1" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/cssnano-preset-advanced": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", - "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", - "license": "MIT", - "dependencies": { - "autoprefixer": "^10.4.19", - "browserslist": "^4.23.0", - "cssnano-preset-default": "^6.1.2", - "postcss-discard-unused": "^6.0.5", - "postcss-merge-idents": "^6.0.3", - "postcss-reduce-idents": "^6.0.3", - "postcss-zindex": "^6.0.2" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/cssnano-preset-default": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", - "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^4.0.2", - "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.1.0", - "postcss-convert-values": "^6.1.0", - "postcss-discard-comments": "^6.0.2", - "postcss-discard-duplicates": "^6.0.3", - "postcss-discard-empty": "^6.0.3", - "postcss-discard-overridden": "^6.0.2", - "postcss-merge-longhand": "^6.0.5", - "postcss-merge-rules": "^6.1.1", - "postcss-minify-font-values": "^6.1.0", - "postcss-minify-gradients": "^6.0.3", - "postcss-minify-params": "^6.1.0", - "postcss-minify-selectors": "^6.0.4", - "postcss-normalize-charset": "^6.0.2", - "postcss-normalize-display-values": "^6.0.2", - "postcss-normalize-positions": "^6.0.2", - "postcss-normalize-repeat-style": "^6.0.2", - "postcss-normalize-string": "^6.0.2", - "postcss-normalize-timing-functions": "^6.0.2", - "postcss-normalize-unicode": "^6.1.0", - "postcss-normalize-url": "^6.0.2", - "postcss-normalize-whitespace": "^6.0.2", - "postcss-ordered-values": "^6.0.2", - "postcss-reduce-initial": "^6.1.0", - "postcss-reduce-transforms": "^6.0.2", - "postcss-svgo": "^6.0.3", - "postcss-unique-selectors": "^6.0.4" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/cssnano-utils": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", - "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "license": "CC0-1.0" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, - "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", - "license": "MIT", - "dependencies": { - "address": "^1.0.1", - "debug": "4" - }, - "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "license": "MIT", - "dependencies": { - "utila": "~0.4" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dot-prop/node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/emojilib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", - "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", - "license": "MIT" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/emoticon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz", - "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esast-util-from-estree": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", - "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-visit": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/esast-util-from-js": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", - "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "acorn": "^8.0.0", - "esast-util-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-util-attach-comments": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", - "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-build-jsx": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", - "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "estree-walker": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-scope": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", - "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-to-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", - "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "astring": "^1.8.0", - "source-map": "^0.7.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-value-to-estree": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.5.0.tgz", - "integrity": "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/remcohaszing" - } - }, - "node_modules/estree-util-visit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", - "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eta": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", - "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - }, - "funding": { - "url": "https://github.com/eta-dev/eta?sponsor=1" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eval": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", - "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", - "dependencies": { - "@types/node": "*", - "require-like": ">= 0.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/express/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fault": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", - "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", - "license": "MIT", - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/feed": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", - "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", - "license": "MIT", - "dependencies": { - "xml-js": "^1.6.11" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/file-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", - "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/file-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/file-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/file-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/file-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "license": "MIT", - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "license": "MIT", - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "license": "MIT", - "engines": { - "node": ">= 14.17" - } - }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", - "license": "ISC" - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/github-slugger": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", - "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "license": "MIT", - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", - "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got/node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-yarn": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", - "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-estree": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", - "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-attach-comments": "^3.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "license": "MIT" - }, - "node_modules/html-minifier-terser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", - "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "~5.3.2", - "commander": "^10.0.0", - "entities": "^4.4.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.15.1" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": "^14.13.1 || >=16.0.0" - } - }, - "node_modules/html-minifier-terser/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/html-tags": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/html-webpack-plugin": { - "version": "5.6.6", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz", - "integrity": "sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw==", - "license": "MIT", - "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.20.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/html-webpack-plugin/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/html-webpack-plugin/node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause" - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/http2-wrapper": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", - "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "license": "MIT", - "engines": { - "node": ">=10.18" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/infima": { - "version": "0.2.0-alpha.45", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.45.tgz", - "integrity": "sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container/node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "license": "MIT", - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-npm": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", - "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-yarn-global": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", - "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/latest-version": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", - "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", - "license": "MIT", - "dependencies": { - "package-json": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/launch-editor": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.1.tgz", - "integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==", - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "license": "MIT", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "license": "MIT" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "license": "MIT" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/markdown-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", - "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdast-util-directive": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", - "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-frontmatter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", - "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "escape-string-regexp": "^5.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", - "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.10.tgz", - "integrity": "sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==", - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-to-fsa": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", - "@jsonjoy.com/json-pack": "^1.11.0", - "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", - "thingies": "^2.5.0", - "tree-dump": "^1.0.3", - "tslib": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-directive": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", - "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-frontmatter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", - "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", - "license": "MIT", - "dependencies": { - "fault": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-mdx-expression": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", - "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-expression/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-mdx-jsx": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", - "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-mdx-md": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", - "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", - "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", - "license": "MIT", - "dependencies": { - "acorn": "^8.0.0", - "acorn-jsx": "^5.0.0", - "micromark-extension-mdx-expression": "^3.0.0", - "micromark-extension-mdx-jsx": "^3.0.0", - "micromark-extension-mdx-md": "^2.0.0", - "micromark-extension-mdxjs-esm": "^3.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs-esm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", - "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-mdx-expression": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", - "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, - "node_modules/micromark-factory-mdx-expression/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-space/node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-character/node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-events-to-acorn": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", - "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "estree-util-visit": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, - "node_modules/micromark-util-events-to-acorn/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-normalize-identifier/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", - "license": "MIT", - "dependencies": { - "mime-db": "~1.33.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", - "integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==", - "license": "MIT", - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-emoji": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", - "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.6.0", - "char-regex": "^1.0.2", - "emojilib": "^2.4.0", - "skin-tone": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", - "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nprogress": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", - "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", - "license": "MIT" - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/null-loader": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", - "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/null-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/null-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/null-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/null-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "license": "MIT", - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", - "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", - "license": "MIT", - "dependencies": { - "got": "^12.1.0", - "registry-auth-token": "^5.0.1", - "registry-url": "^6.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-numeric-range": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", - "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==", - "license": "ISC" - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", - "license": "(WTFPL OR MIT)" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", - "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", - "license": "MIT", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "license": "MIT", - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkijs": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz", - "integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==", - "license": "BSD-3-Clause", - "dependencies": { - "@noble/hashes": "1.4.0", - "asn1js": "^3.0.6", - "bytestreamjs": "^2.0.1", - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-attribute-case-insensitive": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-7.0.1.tgz", - "integrity": "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-calc": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", - "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.2.2" - } - }, - "node_modules/postcss-clamp": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", - "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=7.6.0" - }, - "peerDependencies": { - "postcss": "^8.4.6" - } - }, - "node_modules/postcss-color-functional-notation": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.12.tgz", - "integrity": "sha512-TLCW9fN5kvO/u38/uesdpbx3e8AkTYhMvDZYa9JpmImWuTE99bDQ7GU7hdOADIZsiI9/zuxfAJxny/khknp1Zw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-color-hex-alpha": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-10.0.0.tgz", - "integrity": "sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-color-rebeccapurple": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-10.0.0.tgz", - "integrity": "sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-colormin": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", - "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-api": "^3.0.0", - "colord": "^2.9.3", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-convert-values": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", - "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-custom-media": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.6.tgz", - "integrity": "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.5", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-properties": { - "version": "14.0.6", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz", - "integrity": "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.5", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-selectors": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.5.tgz", - "integrity": "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.5", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-dir-pseudo-class": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-9.0.1.tgz", - "integrity": "sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-discard-comments": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", - "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-duplicates": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz", - "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-empty": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz", - "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-overridden": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz", - "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-unused": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-6.0.5.tgz", - "integrity": "sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-double-position-gradients": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.4.tgz", - "integrity": "sha512-m6IKmxo7FxSP5nF2l63QbCC3r+bWpFUWmZXZf096WxG0m7Vl1Q1+ruFOhpdDRmKrRS+S3Jtk+TVk/7z0+BVK6g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-visible": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-10.0.1.tgz", - "integrity": "sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-focus-within": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-9.0.1.tgz", - "integrity": "sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-font-variant": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", - "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "license": "MIT", - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-gap-properties": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-6.0.0.tgz", - "integrity": "sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-image-set-function": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-7.0.0.tgz", - "integrity": "sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-lab-function": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.12.tgz", - "integrity": "sha512-tUcyRk1ZTPec3OuKFsqtRzW2Go5lehW29XA21lZ65XmzQkz43VY2tyWEC202F7W3mILOjw0voOiuxRGTsN+J9w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-loader": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", - "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", - "license": "MIT", - "dependencies": { - "cosmiconfig": "^8.3.5", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - } - }, - "node_modules/postcss-logical": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-8.1.0.tgz", - "integrity": "sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-merge-idents": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-6.0.3.tgz", - "integrity": "sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==", - "license": "MIT", - "dependencies": { - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-merge-longhand": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz", - "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^6.1.1" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-merge-rules": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz", - "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^4.0.2", - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-font-values": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", - "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-gradients": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz", - "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", - "license": "MIT", - "dependencies": { - "colord": "^2.9.3", - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-params": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz", - "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-selectors": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz", - "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "license": "MIT", - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "license": "ISC", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-nesting": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", - "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/selector-resolve-nested": "^3.1.0", - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", - "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "peer": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-normalize-charset": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", - "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-display-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz", - "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-positions": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz", - "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-repeat-style": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz", - "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-string": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz", - "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-timing-functions": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz", - "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-unicode": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz", - "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-url": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz", - "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-whitespace": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz", - "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-opacity-percentage": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-3.0.0.tgz", - "integrity": "sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==", - "funding": [ - { - "type": "kofi", - "url": "https://ko-fi.com/mrcgrtz" - }, - { - "type": "liberapay", - "url": "https://liberapay.com/mrcgrtz" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-ordered-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz", - "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", - "license": "MIT", - "dependencies": { - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-overflow-shorthand": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-6.0.0.tgz", - "integrity": "sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-page-break": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", - "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "license": "MIT", - "peerDependencies": { - "postcss": "^8" - } - }, - "node_modules/postcss-place": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-10.0.0.tgz", - "integrity": "sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-preset-env": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.6.1.tgz", - "integrity": "sha512-yrk74d9EvY+W7+lO9Aj1QmjWY9q5NsKjK2V9drkOPZB/X6KZ0B3igKsHUYakb7oYVhnioWypQX3xGuePf89f3g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "@csstools/postcss-alpha-function": "^1.0.1", - "@csstools/postcss-cascade-layers": "^5.0.2", - "@csstools/postcss-color-function": "^4.0.12", - "@csstools/postcss-color-function-display-p3-linear": "^1.0.1", - "@csstools/postcss-color-mix-function": "^3.0.12", - "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.2", - "@csstools/postcss-content-alt-text": "^2.0.8", - "@csstools/postcss-contrast-color-function": "^2.0.12", - "@csstools/postcss-exponential-functions": "^2.0.9", - "@csstools/postcss-font-format-keywords": "^4.0.0", - "@csstools/postcss-gamut-mapping": "^2.0.11", - "@csstools/postcss-gradients-interpolation-method": "^5.0.12", - "@csstools/postcss-hwb-function": "^4.0.12", - "@csstools/postcss-ic-unit": "^4.0.4", - "@csstools/postcss-initial": "^2.0.1", - "@csstools/postcss-is-pseudo-class": "^5.0.3", - "@csstools/postcss-light-dark-function": "^2.0.11", - "@csstools/postcss-logical-float-and-clear": "^3.0.0", - "@csstools/postcss-logical-overflow": "^2.0.0", - "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", - "@csstools/postcss-logical-resize": "^3.0.0", - "@csstools/postcss-logical-viewport-units": "^3.0.4", - "@csstools/postcss-media-minmax": "^2.0.9", - "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", - "@csstools/postcss-nested-calc": "^4.0.0", - "@csstools/postcss-normalize-display-values": "^4.0.1", - "@csstools/postcss-oklab-function": "^4.0.12", - "@csstools/postcss-position-area-property": "^1.0.0", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/postcss-property-rule-prelude-list": "^1.0.0", - "@csstools/postcss-random-function": "^2.0.1", - "@csstools/postcss-relative-color-syntax": "^3.0.12", - "@csstools/postcss-scope-pseudo-class": "^4.0.1", - "@csstools/postcss-sign-functions": "^1.1.4", - "@csstools/postcss-stepped-value-functions": "^4.0.9", - "@csstools/postcss-syntax-descriptor-syntax-production": "^1.0.1", - "@csstools/postcss-system-ui-font-family": "^1.0.0", - "@csstools/postcss-text-decoration-shorthand": "^4.0.3", - "@csstools/postcss-trigonometric-functions": "^4.0.9", - "@csstools/postcss-unset-value": "^4.0.0", - "autoprefixer": "^10.4.23", - "browserslist": "^4.28.1", - "css-blank-pseudo": "^7.0.1", - "css-has-pseudo": "^7.0.3", - "css-prefers-color-scheme": "^10.0.0", - "cssdb": "^8.6.0", - "postcss-attribute-case-insensitive": "^7.0.1", - "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^7.0.12", - "postcss-color-hex-alpha": "^10.0.0", - "postcss-color-rebeccapurple": "^10.0.0", - "postcss-custom-media": "^11.0.6", - "postcss-custom-properties": "^14.0.6", - "postcss-custom-selectors": "^8.0.5", - "postcss-dir-pseudo-class": "^9.0.1", - "postcss-double-position-gradients": "^6.0.4", - "postcss-focus-visible": "^10.0.1", - "postcss-focus-within": "^9.0.1", - "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^6.0.0", - "postcss-image-set-function": "^7.0.0", - "postcss-lab-function": "^7.0.12", - "postcss-logical": "^8.1.0", - "postcss-nesting": "^13.0.2", - "postcss-opacity-percentage": "^3.0.0", - "postcss-overflow-shorthand": "^6.0.0", - "postcss-page-break": "^3.0.4", - "postcss-place": "^10.0.0", - "postcss-pseudo-class-any-link": "^10.0.1", - "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^8.0.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-pseudo-class-any-link": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-10.0.1.tgz", - "integrity": "sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-reduce-idents": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz", - "integrity": "sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-reduce-initial": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz", - "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-api": "^3.0.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-reduce-transforms": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz", - "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-replace-overflow-wrap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", - "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "license": "MIT", - "peerDependencies": { - "postcss": "^8.0.3" - } - }, - "node_modules/postcss-selector-not": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-8.0.1.tgz", - "integrity": "sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-sort-media-queries": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz", - "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", - "license": "MIT", - "dependencies": { - "sort-css-media-queries": "2.2.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.4.23" - } - }, - "node_modules/postcss-svgo": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", - "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "svgo": "^3.2.0" - }, - "engines": { - "node": "^14 || ^16 || >= 18" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-unique-selectors": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz", - "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, - "node_modules/postcss-zindex": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-6.0.2.tgz", - "integrity": "sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==", - "license": "MIT", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" - } - }, - "node_modules/pretty-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", - "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/prism-react-renderer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", - "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", - "license": "MIT", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "license": "ISC" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pupa": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", - "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", - "license": "MIT", - "dependencies": { - "escape-goat": "^4.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", - "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", - "license": "MIT" - }, - "node_modules/react-helmet-async": { - "name": "@slorber/react-helmet-async", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@slorber/react-helmet-async/-/react-helmet-async-1.3.0.tgz", - "integrity": "sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "invariant": "^2.2.4", - "prop-types": "^15.7.2", - "react-fast-compare": "^3.2.0", - "shallowequal": "^1.1.0" - }, - "peerDependencies": { - "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-json-view-lite": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz", - "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-loadable": { - "name": "@docusaurus/react-loadable", - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", - "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/react": "*" - }, - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-loadable-ssr-addon-v5-slorber": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz", - "integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.3" - }, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "react-loadable": "*", - "webpack": ">=4.41.1 || 5.x" - } - }, - "node_modules/react-router": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", - "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/react-router-config": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/react-router-config/-/react-router-config-5.1.1.tgz", - "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2" - }, - "peerDependencies": { - "react": ">=15", - "react-router": ">=5" - } - }, - "node_modules/react-router-dom": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", - "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.4", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/recma-build-jsx": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", - "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-util-build-jsx": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/recma-jsx": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", - "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", - "license": "MIT", - "dependencies": { - "acorn-jsx": "^5.0.0", - "estree-util-to-js": "^2.0.0", - "recma-parse": "^1.0.0", - "recma-stringify": "^1.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/recma-parse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", - "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "esast-util-from-js": "^2.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/recma-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", - "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-util-to-js": "^2.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/registry-auth-token": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", - "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", - "license": "MIT", - "dependencies": { - "@pnpm/npm-conf": "^3.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/registry-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", - "license": "MIT", - "dependencies": { - "rc": "1.2.8" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-recma": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", - "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "hast-util-to-estree": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/remark-directive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", - "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^3.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-emoji": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-4.0.1.tgz", - "integrity": "sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.2", - "emoticon": "^4.0.1", - "mdast-util-find-and-replace": "^3.0.1", - "node-emoji": "^2.1.0", - "unified": "^11.0.4" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/remark-frontmatter": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", - "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-frontmatter": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", - "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", - "license": "MIT", - "dependencies": { - "mdast-util-mdx": "^3.0.0", - "micromark-extension-mdxjs": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", - "license": "MIT", - "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" - } - }, - "node_modules/renderkid/node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/renderkid/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-like": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", - "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", - "engines": { - "node": "*" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pathname": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", - "license": "MIT" - }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rtlcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", - "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==", - "license": "MIT", - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0", - "postcss": "^8.4.21", - "strip-json-comments": "^3.1.1" - }, - "bin": { - "rtlcss": "bin/rtlcss.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/schema-dts": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", - "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", - "license": "Apache-2.0" - }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "license": "MIT", - "peer": true - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "license": "MIT" - }, - "node_modules/selfsigned": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", - "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", - "license": "MIT", - "dependencies": { - "@peculiar/x509": "^1.14.2", - "pkijs": "^3.3.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", - "license": "MIT", - "dependencies": { - "bytes": "3.0.0", - "content-disposition": "0.5.2", - "mime-types": "2.1.18", - "minimatch": "3.1.2", - "path-is-inside": "1.0.2", - "path-to-regexp": "3.3.0", - "range-parser": "1.2.0" - } - }, - "node_modules/serve-handler/node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "license": "MIT" - }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, - "node_modules/sitemap": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.3.tgz", - "integrity": "sha512-tAjEd+wt/YwnEbfNB2ht51ybBJxbEWwe5ki/Z//Wh0rpBFTCUSj46GnxUKEWzhfuJTsee8x3lybHxFgUMig2hw==", - "license": "MIT", - "dependencies": { - "@types/node": "^17.0.5", - "@types/sax": "^1.2.1", - "arg": "^5.0.0", - "sax": "^1.2.4" - }, - "bin": { - "sitemap": "dist/cli.js" - }, - "engines": { - "node": ">=12.0.0", - "npm": ">=5.6.0" - } - }, - "node_modules/sitemap/node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "license": "MIT" - }, - "node_modules/skin-tone": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", - "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", - "license": "MIT", - "dependencies": { - "unicode-emoji-modifier-base": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "node_modules/sort-css-media-queries": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.2.0.tgz", - "integrity": "sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==", - "license": "MIT", - "engines": { - "node": ">= 6.3.0" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/srcset": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", - "integrity": "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "license": "BSD-2-Clause", - "dependencies": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, - "node_modules/stylehacks": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz", - "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.0", - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", - "license": "MIT" - }, - "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", - "license": "MIT", - "dependencies": { - "@trysound/sax": "0.2.0", - "commander": "^7.2.0", - "css-select": "^5.1.0", - "css-tree": "^2.3.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.0.0" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/svgo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/thingies": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", - "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", - "license": "MIT", - "engines": { - "node": ">=10.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "license": "MIT" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true - }, - "node_modules/tsyringe": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "license": "MIT", - "dependencies": { - "tslib": "^1.9.3" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-emoji-modifier-base": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", - "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position-from-estree": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", - "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/update-notifier": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", - "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", - "license": "BSD-2-Clause", - "dependencies": { - "boxen": "^7.0.0", - "chalk": "^5.0.1", - "configstore": "^6.0.0", - "has-yarn": "^3.0.0", - "import-lazy": "^4.0.0", - "is-ci": "^3.0.1", - "is-installed-globally": "^0.4.0", - "is-npm": "^6.0.0", - "is-yarn-global": "^0.4.0", - "latest-version": "^7.0.0", - "pupa": "^3.1.0", - "semver": "^7.3.7", - "semver-diff": "^4.0.0", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" - } - }, - "node_modules/update-notifier/node_modules/boxen": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", - "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^7.0.1", - "chalk": "^5.2.0", - "cli-boxes": "^3.0.0", - "string-width": "^5.1.2", - "type-fest": "^2.13.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/camelcase": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", - "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-loader": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", - "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "mime-types": "^2.1.27", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "file-loader": "*", - "webpack": "^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "file-loader": { - "optional": true - } - } - }, - "node_modules/url-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/url-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/url-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/url-loader/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/url-loader/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/url-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", - "license": "MIT" - }, - "node_modules/utility-types": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/value-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", - "license": "MIT" - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webpack": { - "version": "5.105.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.3.tgz", - "integrity": "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-middleware/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz", - "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.25", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.8.1", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.22.1", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^5.5.0", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-dev-server/node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpackbar": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", - "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "consola": "^3.2.3", - "figures": "^3.2.0", - "markdown-table": "^2.0.0", - "pretty-time": "^1.1.0", - "std-env": "^3.7.0", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "webpack": "3 || 4 || 5" - } - }, - "node_modules/webpackbar/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/webpackbar/node_modules/markdown-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", - "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", - "license": "MIT", - "dependencies": { - "repeat-string": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webpackbar/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpackbar/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/widest-line": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", - "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", - "license": "MIT", - "dependencies": { - "string-width": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wsl-utils/node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml-js": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", - "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "license": "MIT", - "dependencies": { - "sax": "^1.2.4" - }, - "bin": { - "xml-js": "bin/cli.js" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/docs/website/package.json b/docs/website/package.json deleted file mode 100644 index 85208b0..0000000 --- a/docs/website/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "scholarr-docs", - "version": "0.1.0", - "private": true, - "scripts": { - "docusaurus": "docusaurus", - "start": "docusaurus start", - "build": "docusaurus build", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve" - }, - "dependencies": { - "@docusaurus/core": "^3.0.0", - "@docusaurus/preset-classic": "^3.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "^3.0.0" - }, - "browserslist": { - "production": [">0.5%", "not dead", "not op_mini all"], - "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] - } -} diff --git a/docs/website/sidebars.js b/docs/website/sidebars.js deleted file mode 100644 index 2b97349..0000000 --- a/docs/website/sidebars.js +++ /dev/null @@ -1,51 +0,0 @@ -/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ -const sidebars = { - docs: [ - "index", - { - type: "category", - label: "User Guide", - items: [ - "user/overview", - "user/getting-started", - "user/configuration", - ], - }, - { - type: "category", - label: "Developer", - items: [ - "developer/overview", - "developer/architecture", - "developer/local-development", - "developer/contributing", - "developer/ingestion", - "developer/frontend-theme-inventory", - "developer/testing", - ], - }, - { - type: "category", - label: "Operations", - items: [ - "operations/overview", - "operations/deployment", - "operations/database-runbook", - "operations/scrape-safety-runbook", - "operations/arxiv-runbook", - ], - }, - { - type: "category", - label: "Reference", - items: [ - "reference/overview", - "reference/api", - "reference/environment", - "reference/changelog", - ], - }, - ], -}; - -module.exports = sidebars; diff --git a/docs/website/src/css/custom.css b/docs/website/src/css/custom.css deleted file mode 100644 index c875358..0000000 --- a/docs/website/src/css/custom.css +++ /dev/null @@ -1,22 +0,0 @@ -:root { - --ifm-color-primary: #2e8555; - --ifm-color-primary-dark: #29784c; - --ifm-color-primary-darker: #277148; - --ifm-color-primary-darkest: #205d3b; - --ifm-color-primary-light: #33925d; - --ifm-color-primary-lighter: #359962; - --ifm-color-primary-lightest: #3cad6e; - --ifm-code-font-size: 95%; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); -} - -[data-theme="dark"] { - --ifm-color-primary: #25c2a0; - --ifm-color-primary-dark: #21af90; - --ifm-color-primary-darker: #1fa588; - --ifm-color-primary-darkest: #1a8870; - --ifm-color-primary-light: #29d5b0; - --ifm-color-primary-lighter: #32d8b4; - --ifm-color-primary-lightest: #4fddbf; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); -} diff --git a/frontend/index.html b/frontend/index.html index c23810e..91a3496 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,12 +3,6 @@ - - - - - - scholarr