diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e801a7c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,33 @@ +{ + "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 ec10203..9e4a0a9 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,53 @@ +# ------------------------------ +# 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 @@ -36,22 +63,32 @@ 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 -LOGIN_RATE_LIMIT_ATTEMPTS=5 -LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 + +# ------------------------------ +# Logging +# ------------------------------ 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 @@ -63,7 +100,10 @@ INGESTION_CONTINUATION_QUEUE_ENABLED=1 INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_ATTEMPTS=6 -SCHEDULER_QUEUE_BATCH_SIZE=10 + +# ------------------------------ +# Scholar Images + Name Search Safety +# ------------------------------ SCHOLAR_IMAGE_UPLOAD_DIR=/var/lib/scholarr/uploads SCHOLAR_IMAGE_UPLOAD_MAX_BYTES=2000000 SCHOLAR_NAME_SEARCH_ENABLED=1 @@ -76,11 +116,49 @@ 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 new file mode 100644 index 0000000..f5f3785 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: justinzeus diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3dbdcd9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..a3e5424 Binary files /dev/null and b/.github/logo-dark.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ff6965..b5c86ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,31 @@ 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 @@ -88,7 +109,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: npm install + run: npm ci - name: Frontend theme token policy working-directory: frontend @@ -106,12 +127,33 @@ 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 new file mode 100644 index 0000000..64a22d1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..24ebe7a --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..f43fb27 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +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 faab882..7c06050 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ htmlcov/ *.log .DS_Store planning/ +build/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +docs/website/node_modules/ +docs/website/build/ +docs/website/.docusaurus/ +.claude/ diff --git a/AGENTS.MD b/AGENTS.MD deleted file mode 100644 index 4a9cbec..0000000 --- a/AGENTS.MD +++ /dev/null @@ -1,314 +0,0 @@ -# scholarr Agent Handbook - -This file is the consolidated source of truth for project scope, constraints, scrape contract, and UI implementation flow. - -## 1. Product Objective - -- Build a self-hosted scholar tracking system ("scholarr") with reliable, low-cost scraping and clear, actionable UI. -- Keep the MVP scope intentionally small. -- Prioritize reliability and ease of use over advanced/fancy processing. - -## 2. Locked Constraints and Preferences - -- Multi-user system with strict tenant isolation. -- Users are admin-created only. -- Users can change their own password. -- Admin features are shown only to admin users. -- Same-origin cookie session model with CSRF protection. -- Container-first development workflow (`docker compose`, `uv`). -- Test while developing; avoid shipping untested flows. -- Keep backend modular and DRY. -- UI is being rebuilt from scratch against API contracts. - -## 3. Current Backend Architecture - -- Runtime: Python + FastAPI. -- DB: PostgreSQL + SQLAlchemy + Alembic migrations. -- Scheduler: background scheduler + continuation queue processing. -- Auth: - - Argon2 password hashing. - - Session cookie (`HttpOnly`, `SameSite=Lax`, secure flag configurable). - - CSRF required for unsafe methods via `X-CSRF-Token`. -- Logging: - - structured request logging with request IDs. - - redaction controls for sensitive fields. - -## 4. Scraping Contract (Probe-Based) - -Status: sufficient for MVP implementation with graceful degradation. - -Target endpoint: -- `https://scholar.google.com/citations?hl=en&user=` - -Primary selectors: -- Row: `tr.gsc_a_tr` -- Title/link: `a.gsc_a_at` -- Citation count: `a.gsc_a_ac` -- Year: `span.gsc_a_h` (fallback regex on year cell text) -- Metadata lines: first/second `div.gs_gray` -> authors/venue -- Cluster ID: from `citation_for_view=:` in title URL - -Required parser states: -- `ok` -- `no_results` -- `blocked_or_captcha` -- `layout_changed` -- `network_error` - -Required page flags: -- `has_show_more_button` -- `articles_range` - -Quality assumptions from probe: -- title/cluster_id/citation/authors coverage observed near 100% -- year and venue may be missing and must remain nullable - -Guardrails: -- Never crash the full run when one scholar fails. -- Persist structured failure/debug information for diagnosis. -- Handle inaccessible/redirected IDs as states, not exceptions. - -## 5. Current API Scope - -Base path: `/api/v1` - -Envelope model: -- success: `{"data": ..., "meta": {"request_id": "..."}}` -- error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` - -Auth/session: -- `GET /api/v1/auth/csrf` -- `POST /api/v1/auth/login` -- `GET /api/v1/auth/me` -- `POST /api/v1/auth/change-password` -- `POST /api/v1/auth/logout` - -Admin users: -- `GET /api/v1/admin/users` -- `POST /api/v1/admin/users` -- `PATCH /api/v1/admin/users/{id}/active` -- `POST /api/v1/admin/users/{id}/reset-password` - -Scholars: -- `GET /api/v1/scholars` -- `POST /api/v1/scholars` -- `GET /api/v1/scholars/search` -- `PATCH /api/v1/scholars/{id}/toggle` -- `PUT /api/v1/scholars/{id}/image/url` -- `POST /api/v1/scholars/{id}/image/upload` -- `GET /api/v1/scholars/{id}/image/upload` -- `DELETE /api/v1/scholars/{id}/image` -- `DELETE /api/v1/scholars/{id}` - -Settings: -- `GET /api/v1/settings` -- `PUT /api/v1/settings` - -Runs/diagnostics/queue: -- `GET /api/v1/runs` -- `GET /api/v1/runs/{id}` -- `POST /api/v1/runs/manual` (`Idempotency-Key` supported) -- `GET /api/v1/runs/queue/items` -- `POST /api/v1/runs/queue/{id}/retry` -- `POST /api/v1/runs/queue/{id}/drop` -- `DELETE /api/v1/runs/queue/{id}` - -Publications: -- `GET /api/v1/publications` -- `POST /api/v1/publications/mark-read` -- `POST /api/v1/publications/mark-all-read` - -Ops: -- `GET /healthz` - -## 5.1 Runtime Semantics (Locked) - -Manual run idempotency: -- `Idempotency-Key` is optional on `POST /api/v1/runs/manual`. -- Idempotency scope is `(user_id, idempotency_key)`. -- Replaying the same key for the same user returns the same run payload after completion. -- If the keyed run is still in progress, respond with `409 run_in_progress` and include `run_id`. -- Enforcement is database-backed, not best-effort in-memory logic. -- Frontend triggers generate keys internally; key input is not user-facing. - -Continuation queue attempt policy: -- `attempt_count` is a failed-attempt budget, not a total execution counter. -- Increment attempts only when a continuation execution fails to make progress. -- Reset attempts to `0` after successful progress (success or resumable partial progress). -- Drop queue items only after failed-attempt threshold is reached. - -Scholar no-change skip policy: -- Persist first-page fingerprint snapshots per scholar. -- If the first-page fingerprint is unchanged for a normal run (`cstart=0`), skip deep pagination/upsert work. -- Record run scholar result `state_reason` as `no_change_initial_page_signature`. - -Scholar create naming policy: -- Do not accept custom display names during scholar create. -- Hydrate names from scraped profile metadata. - -Scholar name-search safety policy: -- Treat name search as best-effort only. -- Apply single-flight execution, pacing/jitter, cache, and cooldown circuit breaker after repeated blocked responses. -- Prefer Scholar URL/ID adds for reliable ingestion onboarding. - -## 6. Required UI Behavior (MVP) - -Core UX entities: -- scholars -- publications (`all` and `new`) -- runs + run diagnostics -- queue visibility/actions -- settings/account -- admin users (role-gated) - -Critical semantics: -- Keep `is_new_in_latest_run` separate from `is_read`. -- First-time ingestion should not imply user read-state. -- Surface partial runs and warnings clearly. -- Show loading, empty, and error states explicitly. -- Support both light and dark modes with consistent status semantics. - -## 7. UI Information Architecture - -Public: -- Login - -Authenticated: -- Dashboard -- Scholars -- Publications -- Settings -- Admin Users (admin only) - -## 8. End-to-End UI Flow Plan - -## 8.1 Session bootstrap - -1. On load call `GET /api/v1/auth/csrf`. -2. Call `GET /api/v1/auth/me`. -3. Route to login or dashboard based on auth state. - -## 8.2 Login/logout - -1. Login form submits `POST /api/v1/auth/login` with `X-CSRF-Token`. -2. Store CSRF token from response for future unsafe requests. -3. Logout calls `POST /api/v1/auth/logout`. - -## 8.3 Dashboard -1. Fetch latest publications summary from `GET /api/v1/publications?mode=new&limit=20`. -2. Fetch recent runs from `GET /api/v1/runs?limit=5`. -3. Fetch queue status from `GET /api/v1/runs/queue/items?limit=200` (aggregate in UI). -4. Show: - - new publications count (`new_count`) - - total tracked publications (`total_count`) - - latest run status/started time - - queue badges (`queued`, `retrying`, `dropped`) -5. Admin users additionally see a diagnostics shortcut block to full runs/queue page. -6. Every dashboard error panel must display request-id for support. - - -## 8.4 Scholars - -1. List via `GET /api/v1/scholars`. -2. Add/toggle/delete actions mapped to scholar endpoints. -3. Search by name via `GET /api/v1/scholars/search` with candidate add actions. -4. Profile image controls: - - scraped image fallback from Scholar metadata - - custom URL override - - image upload + reset to scraped/default -5. Per-row quick links: - - all publications filtered by scholar - - new publications filtered by scholar - -## 8.5 Publications - -1. Default view mode is `new` (`GET /api/v1/publications?mode=new`). -2. Support mode toggle: - - `new`: scoped to latest completed run - - `all`: full history -3. Support scholar filter via `scholar_profile_id` query param. -4. Render both badges independently: - - `is_new_in_latest_run` - - `is_read` -5. "Mark all read" action calls `POST /api/v1/publications/mark-all-read` with CSRF. -6. Show explicit empty states: - - no publications tracked - - no new publications in latest run - - filtered scholar has no items -7. Surface run recency context when mode is `new` (latest completed run semantics). - -## 8.6 Runs and diagnostics (admin) - -1. List runs from `GET /api/v1/runs`. -2. Run details from `GET /api/v1/runs/{id}`: - - scholar result state/reason - - warnings - - page logs + attempt logs - - debug metadata -3. Queue operations from queue endpoints (`retry`, `drop`, `clear`). - -## 8.7 Settings + account - -1. User ingest settings from `/api/v1/settings`. -2. Password change via `/api/v1/auth/change-password`. - -## 8.8 Admin user management - -1. Show section only when `current_user.is_admin=true`. -2. Use admin user endpoints for create/activate/deactivate/reset password. - -## 9. Development Method - -- API-contract-first frontend. -- Vertical slices (complete flow before moving on). -- Shared API client module: - - always send credentials - - inject CSRF header for unsafe methods - - normalize envelope parsing and error handling -- Keep frontend modules separate (`auth`, `scholars`, `publications`, `runs`, `settings`, `admin`). -- Write tests while implementing each slice. - -## 10. Definition of Done (UI Readiness) - -- All main flows above implemented and mapped to existing API endpoints. -- Role-gated admin experience enforced in UI. -- Distinct new-vs-read publication semantics visible. -- Run/queue diagnostics visible and actionable. -- Reliable loading/empty/error states with request-id surfaced for support. -- No dependence on removed server-rendered UI layer. - -## 11. Open Items (Known Future Work) - -- API contract freeze/changelog discipline for external UI clients. -- Additional API-first smoke coverage for end-to-end UI critical flows. -- Optional background image enrichment/refresh jobs for already-tracked scholars. - -## 12. UI Rebuild Program Artifacts - -- Phase 0 through Phase 3 decisions are consolidated into this handbook and `README.md`. -- Local scratch notes, probes, and temporary planning artifacts are intentionally not tracked in git. - -## 13. Frontend Scaffold Status (Implemented) - -- Frontend scaffold path: `frontend/` -- Implemented core: - - App shell + routing + auth/role route guards - - Shared API client with envelope parsing, credentials, and CSRF header injection - - Pinia stores for auth, theme, and global UI errors - - Tailwind-first design foundation with light/dark themes and pre-paint theme initialization - - Page shells for login, dashboard, scholars, publications, runs, run detail, settings, and admin users - - Initial feature modules under `frontend/src/features/*` mapped to current API endpoints -- Deployment boundary: - - No in-repo reverse-proxy implementation - - Frontend edge/proxy routing is managed externally (for example, Caddy) - -## 14. Frontend Quality Gates (Phase 3 Implemented) - -- CI now includes frontend quality gates in `.github/workflows/ci.yml`: - - API contract drift check (`scripts/check_frontend_api_contract.py`) - - frontend typecheck (`npm run typecheck`) - - frontend unit tests (`npm run test:run`) - - frontend build (`npm run build`) -- Frontend test harness: - - `vitest` configured in `frontend/vitest.config.ts` - - baseline tests in `frontend/src/lib/api/envelope.test.ts` and `frontend/src/lib/api/errors.test.ts` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index ab44797..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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 new file mode 100644 index 0000000..6d0fc52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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 56ce6ce..e2bf4f6 100644 --- a/README.md +++ b/README.md @@ -1,268 +1,149 @@ -# scholarr -
-Self-hosted scholar tracking with a single app image (API + frontend). + + + + Scholarr + -[![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) +# 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) [![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/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) +[![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/)
+--- + + + +

+ 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 values in `.env`: -- `POSTGRES_PASSWORD` -- `SESSION_SECRET_KEY` +# 2. Set required secrets in .env +# POSTGRES_PASSWORD= +# SESSION_SECRET_KEY= -3. Choose a deploy method below. - -## Deploy Method A: Prebuilt Image (Recommended) - -Use this for normal self-hosted deployment. - -```bash -docker compose pull +# 3. Start docker compose up -d + +# 4. Open http://localhost:8000 ``` -Open: -- App/API: `http://localhost:8000` -- Health: `http://localhost:8000/healthz` - -Upgrade: +To bootstrap an admin account on first run, add to `.env`: ```bash -docker compose pull -docker compose up -d +BOOTSTRAP_ADMIN_ON_START=1 +BOOTSTRAP_ADMIN_EMAIL=admin@example.com +BOOTSTRAP_ADMIN_PASSWORD= ``` -## Deploy Method B: Local Source Build + Dev Frontend +## How It Works -Use this for development on this repository. +```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. ```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build +# 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 ``` -Open: -- API (FastAPI): `http://localhost:8000` -- Frontend dev server (Vite): `http://localhost:5173` +## License -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": "..."}}` +See [LICENSE](LICENSE) for details. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..1767078 --- /dev/null +++ b/agents.md @@ -0,0 +1,123 @@ +# 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 17575e4..127078d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,13 +1,13 @@ -from logging.config import fileConfig import os +from logging.config import fileConfig -from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from app.db.base import metadata +from alembic import context from app.db import models # noqa: F401 +from app.db.base import metadata config = context.config diff --git a/alembic/versions/20260216_0001_multi_user_schema.py b/alembic/versions/20260216_0001_multi_user_schema.py index 221bbf7..6c9375e 100644 --- a/alembic/versions/20260216_0001_multi_user_schema.py +++ b/alembic/versions/20260216_0001_multi_user_schema.py @@ -8,10 +8,11 @@ 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 e60de0f..5ea932d 100644 --- a/alembic/versions/20260216_0002_add_user_admin_flag.py +++ b/alembic/versions/20260216_0002_add_user_admin_flag.py @@ -7,9 +7,10 @@ Create Date: 2026-02-16 17:40:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260216_0002" down_revision: str | Sequence[str] | None = "20260216_0001" @@ -31,4 +32,3 @@ 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 cc03867..4c81fa0 100644 --- a/alembic/versions/20260216_0003_add_ingestion_queue.py +++ b/alembic/versions/20260216_0003_add_ingestion_queue.py @@ -7,9 +7,10 @@ Create Date: 2026-02-16 23:45:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260216_0003" down_revision: str | Sequence[str] | None = "20260216_0002" diff --git a/alembic/versions/20260217_0004_queue_status_fields.py b/alembic/versions/20260217_0004_queue_status_fields.py index 68da9dd..f6c29e6 100644 --- a/alembic/versions/20260217_0004_queue_status_fields.py +++ b/alembic/versions/20260217_0004_queue_status_fields.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 10:15:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0004" down_revision: str | Sequence[str] | None = "20260216_0003" @@ -22,9 +23,7 @@ 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( @@ -80,9 +79,7 @@ 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 0375569..ad0269b 100644 --- a/alembic/versions/20260217_0005_manual_run_idempotency.py +++ b/alembic/versions/20260217_0005_manual_run_idempotency.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 16:20:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0005" down_revision: str | Sequence[str] | None = "20260217_0004" @@ -70,9 +71,7 @@ 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 08035c8..f736737 100644 --- a/alembic/versions/20260217_0006_scholar_profile_images.py +++ b/alembic/versions/20260217_0006_scholar_profile_images.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 18:30:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0006" down_revision: str | Sequence[str] | None = "20260217_0005" diff --git a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py index dcfd0f7..6022c4c 100644 --- a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py +++ b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 18:55:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0007" down_revision: str | Sequence[str] | None = "20260217_0006" 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 df481fd..ef38677 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 9ea658b..175b6b7 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 new file mode 100644 index 0000000..0a07a42 --- /dev/null +++ b/alembic/versions/20260219_0010_author_search_shared_state.py @@ -0,0 +1,115 @@ +"""Add shared author-search runtime and cache tables. + +Revision ID: 20260219_0010 +Revises: 20260219_0009 +Create Date: 2026-02-19 22: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 = "20260219_0010" +down_revision: str | Sequence[str] | None = "20260219_0009" +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 "author_search_runtime_state" not in table_names: + op.create_table( + "author_search_runtime_state", + sa.Column("state_key", sa.String(length=64), nullable=False), + sa.Column("last_live_request_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cooldown_until", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "consecutive_blocked_count", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "cooldown_rejection_count", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "cooldown_alert_emitted", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + 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_author_search_runtime_state")), + ) + + if "author_search_cache_entries" not in table_names: + op.create_table( + "author_search_cache_entries", + sa.Column("query_key", sa.String(length=256), 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_key", name=op.f("pk_author_search_cache_entries")), + ) + op.create_index( + "ix_author_search_cache_expires_at", + "author_search_cache_entries", + ["expires_at"], + unique=False, + ) + op.create_index( + "ix_author_search_cache_cached_at", + "author_search_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 "author_search_cache_entries" in table_names: + op.drop_index("ix_author_search_cache_cached_at", table_name="author_search_cache_entries") + op.drop_index("ix_author_search_cache_expires_at", table_name="author_search_cache_entries") + op.drop_table("author_search_cache_entries") + + if "author_search_runtime_state" in table_names: + op.drop_table("author_search_runtime_state") diff --git a/alembic/versions/20260220_0011_add_publication_doi.py b/alembic/versions/20260220_0011_add_publication_doi.py new file mode 100644 index 0000000..c286a00 --- /dev/null +++ b/alembic/versions/20260220_0011_add_publication_doi.py @@ -0,0 +1,50 @@ +"""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 new file mode 100644 index 0000000..6e7b53f --- /dev/null +++ b/alembic/versions/20260220_0012_add_data_repair_jobs.py @@ -0,0 +1,106 @@ +"""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 new file mode 100644 index 0000000..d7046d9 --- /dev/null +++ b/alembic/versions/20260221_0013_add_publication_pdf_jobs.py @@ -0,0 +1,179 @@ +"""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 new file mode 100644 index 0000000..7c06d52 --- /dev/null +++ b/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py @@ -0,0 +1,44 @@ +"""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 new file mode 100644 index 0000000..122f055 --- /dev/null +++ b/alembic/versions/20260221_0015_enforce_request_delay_floor.py @@ -0,0 +1,71 @@ +"""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 new file mode 100644 index 0000000..9730a2d --- /dev/null +++ b/alembic/versions/20260222_0016_add_publication_identifiers.py @@ -0,0 +1,104 @@ +"""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 new file mode 100644 index 0000000..ee669fe --- /dev/null +++ b/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py @@ -0,0 +1,33 @@ +"""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 new file mode 100644 index 0000000..163747c --- /dev/null +++ b/alembic/versions/20260224_0019_add_resolving_to_runstatus.py @@ -0,0 +1,31 @@ +"""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 new file mode 100644 index 0000000..1b239f6 --- /dev/null +++ b/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py @@ -0,0 +1,27 @@ +"""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 new file mode 100644 index 0000000..f5b20f2 --- /dev/null +++ b/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py @@ -0,0 +1,68 @@ +"""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 new file mode 100644 index 0000000..97ba39d --- /dev/null +++ b/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py @@ -0,0 +1,35 @@ +"""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 new file mode 100644 index 0000000..b9efd0d --- /dev/null +++ b/alembic/versions/20260226_0023_add_arxiv_runtime_state.py @@ -0,0 +1,55 @@ +"""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 new file mode 100644 index 0000000..8fddda4 --- /dev/null +++ b/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py @@ -0,0 +1,70 @@ +"""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 new file mode 100644 index 0000000..f47adbd --- /dev/null +++ b/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py @@ -0,0 +1,27 @@ +"""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 new file mode 100644 index 0000000..627a18a --- /dev/null +++ b/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py @@ -0,0 +1,39 @@ +"""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 3ce55a6..9d48db4 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,2 +1 @@ from __future__ import annotations - diff --git a/app/api/errors.py b/app/api/errors.py index 976ccb8..d926690 100644 --- a/app/api/errors.py +++ b/app/api/errors.py @@ -82,4 +82,3 @@ 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 new file mode 100644 index 0000000..3bed5f8 --- /dev/null +++ b/app/api/media.py @@ -0,0 +1,64 @@ +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 09d49b2..1b97d07 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,11 +2,13 @@ from __future__ import annotations from fastapi import APIRouter -from app.api.routers import admin, auth, publications, runs, scholars, settings +from app.api.routers import admin, admin_dbops, admin_settings, 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 3ce55a6..9d48db4 100644 --- a/app/api/routers/__init__.py +++ b/app/api/routers/__init__.py @@ -1,2 +1 @@ from __future__ import annotations - diff --git a/app/api/routers/admin.py b/app/api/routers/admin.py index dbf00bf..35c61b6 100644 --- a/app/api/routers/admin.py +++ b/app/api/routers/admin.py @@ -20,7 +20,8 @@ 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.services import users as user_service +from app.logging_utils import structured_log +from app.services.users import application as user_service logger = logging.getLogger(__name__) @@ -48,14 +49,7 @@ async def list_users( admin_user: User = Depends(get_api_admin_user), ): users = await user_service.list_users(db_session) - logger.info( - "api.admin.users_listed", - extra={ - "event": "api.admin.users_listed", - "admin_user_id": int(admin_user.id), - "user_count": len(users), - }, - ) + structured_log(logger, "info", "api.admin.users_listed", admin_user_id=int(admin_user.id), user_count=len(users)) return success_payload( request, data={ @@ -92,14 +86,13 @@ async def create_user( message=str(exc), ) from exc - logger.info( + structured_log( + logger, + "info", "api.admin.user_created", - 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), - }, + 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, @@ -125,11 +118,7 @@ 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", @@ -140,14 +129,13 @@ async def set_user_active( user=target_user, is_active=bool(payload.is_active), ) - logger.info( + structured_log( + logger, + "info", "api.admin.user_active_updated", - 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), - }, + 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, @@ -188,13 +176,12 @@ async def reset_user_password( user=target_user, password_hash=auth_service.hash_password(validated_password), ) - logger.info( + structured_log( + logger, + "info", "api.admin.user_password_reset", - extra={ - "event": "api.admin.user_password_reset", - "admin_user_id": int(admin_user.id), - "target_user_id": int(target_user.id), - }, + 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 new file mode 100644 index 0000000..048be70 --- /dev/null +++ b/app/api/routers/admin_dbops.py @@ -0,0 +1,445 @@ +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 new file mode 100644 index 0000000..bbdaad3 --- /dev/null +++ b/app/api/routers/admin_settings.py @@ -0,0 +1,104 @@ +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 1a76ced..a46d71b 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -1,12 +1,13 @@ 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.errors import ApiException 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.schemas import ( AuthMeEnvelope, @@ -16,21 +17,60 @@ 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 import users as user_service +from app.services.users import application 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, @@ -42,24 +82,10 @@ async def login( auth_service: AuthService = Depends(get_auth_service), rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), ): - limiter_key = auth_runtime.login_rate_limit_key(request, payload.email) + limiter_key, normalized_email = _login_limiter_key_and_email(request, payload) decision = rate_limiter.check(limiter_key) - normalized_email = payload.email.strip().lower() if not decision.allowed: - 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}, - ) + _raise_rate_limited(normalized_email, int(decision.retry_after_seconds)) user = await auth_service.authenticate_user( db_session, @@ -68,18 +94,7 @@ async def login( ) if user is None: rate_limiter.record_failure(limiter_key) - 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.", - ) + _raise_invalid_credentials(normalized_email=normalized_email) rate_limiter.reset(limiter_key) set_session_user( @@ -88,25 +103,13 @@ async def login( email=user.email, 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, - }, - ) + structured_log(logger, "info", "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": { - "id": int(user.id), - "email": user.email, - "is_admin": bool(user.is_admin), - "is_active": bool(user.is_active), - }, + "user": _serialize_user_payload(user), }, ) @@ -124,12 +127,7 @@ async def get_current_session( data={ "authenticated": True, "csrf_token": ensure_csrf_token(request), - "user": { - "id": int(current_user.id), - "email": current_user.email, - "is_admin": bool(current_user.is_admin), - "is_active": bool(current_user.is_active), - }, + "user": _serialize_user_payload(current_user), }, ) @@ -192,13 +190,7 @@ async def change_password( user=current_user, password_hash=auth_service.hash_password(validated_password), ) - logger.info( - "api.auth.password_changed", - extra={ - "event": "api.auth.password_changed", - "user_id": int(current_user.id), - }, - ) + structured_log(logger, "info", "api.auth.password_changed", user_id=int(current_user.id)) return success_payload( request, data={"message": "Password updated successfully."}, @@ -215,12 +207,8 @@ async def logout( ): current_user = await auth_runtime.get_authenticated_user(request, db_session) auth_runtime.invalidate_session(request) - logger.info( - "api.auth.logout", - extra={ - "event": "api.auth.logout", - "user_id": int(current_user.id) if current_user is not None else None, - }, + structured_log( + logger, "info", "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 9933fe6..1d8ec53 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +from datetime import UTC, datetime from typing import Literal -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user @@ -14,88 +15,396 @@ 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.services import publications as publication_service -from app.services import scholars as scholar_service +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 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, ) async def list_publications( request: Request, - mode: Literal["all", "new"] | None = Query(default=None), + 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), - limit: int = Query(default=300, ge=1, le=1000), + 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), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - resolved_mode = publication_service.resolve_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, - mode=resolved_mode, - scholar_profile_id=selected_scholar_id, + resolved_page, resolved_limit, resolved_offset = _resolve_publications_paging( + page=page, + page_size=page_size, limit=limit, + offset=offset, ) - new_count = await publication_service.count_for_user( + 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( db_session, user_id=current_user.id, - mode=publication_service.MODE_NEW, - scholar_profile_id=selected_scholar_id, + selected_scholar_id=selected_scholar_id, + favorite_only=favorite_only, + search=normalized_search, + snapshot_before=snapshot_before, ) - 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, - "new_count": new_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 - ], - }, + 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, ) + return success_payload(request, data=data) @router.post( @@ -111,13 +420,8 @@ async def mark_all_publications_read( db_session, user_id=current_user.id, ) - logger.info( - "api.publications.mark_all_read", - extra={ - "event": "api.publications.mark_all_read", - "user_id": current_user.id, - "updated_count": updated_count, - }, + structured_log( + logger, "info", "api.publications.mark_all_read", user_id=current_user.id, updated_count=updated_count ) return success_payload( request, @@ -138,25 +442,19 @@ 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, ) - logger.info( + structured_log( + logger, + "info", "api.publications.mark_selected_read", - extra={ - "event": "api.publications.mark_selected_read", - "user_id": current_user.id, - "requested_count": len(selection_pairs), - "updated_count": updated_count, - }, + user_id=current_user.id, + requested_count=len(selection_pairs), + updated_count=updated_count, ) return success_payload( request, @@ -166,3 +464,84 @@ 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 new file mode 100644 index 0000000..cdb5e99 --- /dev/null +++ b/app/api/routers/run_manual.py @@ -0,0 +1,243 @@ +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 new file mode 100644 index 0000000..53db3b5 --- /dev/null +++ b/app/api/routers/run_serializers.py @@ -0,0 +1,238 @@ +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 ae9d803..87dfa0a 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -1,16 +1,33 @@ 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, @@ -20,282 +37,28 @@ from app.api.schemas import ( RunsListEnvelope, ) from app.db.models import RunStatus, RunTriggerType, User -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.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.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"]) - -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) +ACTIVE_RUN_STATUSES = {RunStatus.RUNNING, RunStatus.RESOLVING} @router.get( @@ -315,14 +78,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, }, ) @@ -353,21 +116,137 @@ 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, @@ -378,83 +257,57 @@ async def run_manual( current_user: User = Depends(get_api_current_user), ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service), ): - safety_state = await _load_safety_state( - db_session, - user_id=current_user.id, - ) + 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: - 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, - }, - ) - 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, - }, - ) + raise_manual_runs_disabled(user_id=user_id, 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( + idempotency_key = normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER)) + reused_payload = await reused_manual_run_payload( db_session, - user_id=current_user.id, + request=request, + user_id=user_id, + idempotency_key=idempotency_key, + safety_state=safety_state, ) + if reused_payload is not None: + return reused_payload + try: - run_summary = await ingest_service.run_for_user( + user_settings, run, scholars, start_cstart_map = await _start_manual_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, - auto_queue_continuations=settings.ingestion_continuation_queue_enabled, - queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, + current_user=current_user, + ingest_service=ingest_service, 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, ) + 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), + }, + ) + except ingestion_service.RunBlockedBySafetyPolicyError as exc: + await db_session.rollback() + raise_manual_blocked_safety(exc=exc, user_id=user_id) except ingestion_service.RunAlreadyInProgressError as exc: await db_session.rollback() raise ApiException( @@ -462,105 +315,18 @@ 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() - 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, - }, + return await recover_integrity_error( + db_session, + request=request, + user_id=user_id, + idempotency_key=idempotency_key, + original_exc=exc, ) - raise ApiException( - status_code=500, - code="manual_run_failed", - message="Manual run failed.", - ) from exc except Exception as exc: await db_session.rollback() - 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, - }, - ) + raise_manual_failed(exc=exc, user_id=user_id) @router.get( @@ -581,7 +347,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], }, ) @@ -617,7 +383,7 @@ async def retry_queue_item( ) return success_payload( request, - data=_serialize_queue_item(queue_item), + data=serialize_queue_item(queue_item), ) @@ -652,7 +418,7 @@ async def drop_queue_item( ) return success_payload( request, - data=_serialize_queue_item(dropped), + data=serialize_queue_item(dropped), ) @@ -694,3 +460,24 @@ 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 new file mode 100644 index 0000000..4023a3a --- /dev/null +++ b/app/api/routers/scholar_helpers.py @@ -0,0 +1,234 @@ +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 7ce8fc5..2a0c276 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -1,19 +1,31 @@ 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, @@ -22,8 +34,10 @@ from app.api.schemas import ( ) from app.db.models import User from app.db.session import get_db_session -from app.services import scholars as scholar_service -from app.services.scholar_source import ScholarSource +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.settings import settings logger = logging.getLogger(__name__) @@ -31,33 +45,20 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/scholars", tags=["api-scholars"]) -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 - ), - } +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 @router.get( @@ -76,11 +77,130 @@ 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, @@ -107,37 +227,22 @@ async def create_scholar( code="invalid_scholar", message=str(exc), ) from exc - logger.info( - "api.scholars.created", - extra={ - "event": "api.scholars.created", - "user_id": current_user.id, - "scholar_profile_id": created.id, - }, + 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, ) - 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), ) @@ -149,28 +254,17 @@ async def search_scholars( request: Request, query: str = Query(..., min_length=2, max_length=120), limit: int = Query(10, ge=1, le=25), + db_session: AsyncSession = Depends(get_db_session), source: ScholarSource = Depends(get_scholar_source), current_user: User = Depends(get_api_current_user), ): try: parsed = await scholar_service.search_author_candidates( source=source, + db_session=db_session, query=query, limit=limit, - 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 - ), + **search_kwargs(), ) except scholar_service.ScholarServiceError as exc: raise ApiException( @@ -179,41 +273,18 @@ async def search_scholars( message=str(exc), ) from exc - logger.info( + structured_log( + logger, + "info", "api.scholars.search.completed", - extra={ - "event": "api.scholars.search.completed", - "user_id": current_user.id, - "query": query.strip(), - "candidate_count": len(parsed.candidates), - "state": parsed.state.value, - }, + user_id=current_user.id, + query=query.strip(), + candidate_count=len(parsed.candidates), + state=parsed.state.value, ) return success_payload( request, - 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, - }, + data=search_response_data(query, parsed), ) @@ -239,18 +310,17 @@ async def toggle_scholar( message="Scholar not found.", ) updated = await scholar_service.toggle_scholar_enabled(db_session, profile=profile) - logger.info( + structured_log( + logger, + "info", "api.scholars.toggled", - extra={ - "event": "api.scholars.toggled", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - "is_enabled": updated.is_enabled, - }, + 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), ) @@ -275,18 +345,20 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - 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, - }, + 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 ) return success_payload( request, @@ -331,17 +403,12 @@ async def update_scholar_image_url( message=str(exc), ) from exc - logger.info( - "api.scholars.image_url_updated", - extra={ - "event": "api.scholars.image_url_updated", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - }, + structured_log( + logger, "info", "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), ) @@ -356,20 +423,14 @@ async def upload_scholar_image( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - profile = await scholar_service.get_user_scholar_by_id( + profile = await require_user_profile( 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, @@ -384,22 +445,20 @@ async def upload_scholar_image( code="invalid_scholar_image", message=str(exc), ) from exc - finally: - await image.close() - logger.info( + image_size = len(image_bytes) + structured_log( + logger, + "info", "api.scholars.image_uploaded", - 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), - }, + user_id=current_user.id, + scholar_profile_id=updated.id, + content_type=image.content_type, + size_bytes=image_size, ) return success_payload( request, - data=_serialize_scholar(updated), + data=serialize_scholar(updated), ) @@ -430,64 +489,8 @@ async def clear_scholar_image_customization( profile=profile, upload_dir=settings.scholar_image_upload_dir, ) - logger.info( - "api.scholars.image_customization_cleared", - extra={ - "event": "api.scholars.image_customization_cleared", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - }, - ) + structured_log(logger, "info", "api.scholars.image_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 f4b36a5..a3cc88f 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -11,8 +11,9 @@ 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.services import run_safety as run_safety_service -from app.services import user_settings as user_settings_service +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.settings import settings as settings_module logger = logging.getLogger(__name__) @@ -21,12 +22,7 @@ router = APIRouter(prefix="/settings", tags=["api-settings"]) def _serialize_settings(user_settings) -> dict[str, object]: - 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 - ) + min_run_interval_minutes, min_request_delay_seconds = _minimum_policy() 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), @@ -43,9 +39,65 @@ 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, @@ -59,21 +111,11 @@ async def get_settings( db_session, user_id=current_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.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, - }, - ) + await _clear_expired_cooldown_with_log( + db_session, + user_id=current_user.id, + user_settings=user_settings, + ) return success_payload( request, data=_serialize_settings(user_settings), @@ -95,26 +137,10 @@ 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 = 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) + parsed_interval, parsed_delay, parsed_nav_visible_pages = _parse_settings_payload( + payload, + user_settings, ) except user_settings_service.UserSettingsServiceError as exc: raise ApiException( @@ -123,43 +149,32 @@ 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, + auto_run_enabled=_effective_auto_run_enabled(payload), 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, ) - 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( + await _clear_expired_cooldown_with_log( + db_session, + user_id=current_user.id, + user_settings=updated, + ) + structured_log( + logger, + "info", "api.settings.updated", - 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, - }, + 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", ) return success_payload( request, diff --git a/app/api/runtime_deps.py b/app/api/runtime_deps.py index 167e2ef..5a6e9de 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 import ingestion as ingestion_service -from app.services import scholar_source as scholar_source_service +from app.services.ingestion import application as ingestion_service +from app.services.scholar import 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 deleted file mode 100644 index d7424f2..0000000 --- a/app/api/schemas.py +++ /dev/null @@ -1,571 +0,0 @@ -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 - 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 new file mode 100644 index 0000000..019d86a --- /dev/null +++ b/app/api/schemas/__init__.py @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..932f93c --- /dev/null +++ b/app/api/schemas/admin.py @@ -0,0 +1,314 @@ +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 new file mode 100644 index 0000000..14efa57 --- /dev/null +++ b/app/api/schemas/auth.py @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..cc0805f --- /dev/null +++ b/app/api/schemas/common.py @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..6ae7c2e --- /dev/null +++ b/app/api/schemas/publications.py @@ -0,0 +1,151 @@ +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 new file mode 100644 index 0000000..e9e7791 --- /dev/null +++ b/app/api/schemas/runs.py @@ -0,0 +1,226 @@ +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 new file mode 100644 index 0000000..bc1035d --- /dev/null +++ b/app/api/schemas/scholars.py @@ -0,0 +1,189 @@ +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 new file mode 100644 index 0000000..cc06ee8 --- /dev/null +++ b/app/api/schemas/settings.py @@ -0,0 +1,54 @@ +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 79644a4..b2c2619 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -1,2 +1 @@ """Authentication package for scholarr.""" - diff --git a/app/auth/deps.py b/app/auth/deps.py index 50424ae..ec7613c 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -24,4 +24,3 @@ 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/rate_limit.py b/app/auth/rate_limit.py index a397265..c3f5e8a 100644 --- a/app/auth/rate_limit.py +++ b/app/auth/rate_limit.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections import defaultdict, deque +from collections import deque from collections.abc import Callable from dataclasses import dataclass from math import ceil @@ -25,14 +25,19 @@ class SlidingWindowRateLimiter: self._max_attempts = max_attempts self._window_seconds = window_seconds self._now = now - self._attempts: dict[str, deque[float]] = defaultdict(deque) + self._attempts: dict[str, deque[float]] = {} self._lock = Lock() def check(self, key: str) -> RateLimitDecision: with self._lock: now_value = self._now() - attempts = self._attempts[key] + attempts = self._attempts.get(key) + if attempts is None: + return RateLimitDecision(allowed=True) self._trim_expired(attempts, now_value) + if not attempts: + self._attempts.pop(key, None) + return RateLimitDecision(allowed=True) if len(attempts) >= self._max_attempts: retry_after = self._window_seconds - (now_value - attempts[0]) return RateLimitDecision( @@ -44,7 +49,10 @@ class SlidingWindowRateLimiter: def record_failure(self, key: str) -> None: with self._lock: now_value = self._now() - attempts = self._attempts[key] + attempts = self._attempts.get(key) + if attempts is None: + attempts = deque() + self._attempts[key] = attempts self._trim_expired(attempts, now_value) attempts.append(now_value) @@ -59,4 +67,3 @@ class SlidingWindowRateLimiter: def _trim_expired(self, attempts: deque[float], now_value: float) -> None: while attempts and now_value - attempts[0] >= self._window_seconds: attempts.popleft() - diff --git a/app/auth/runtime.py b/app/auth/runtime.py index 5bb1a74..267325a 100644 --- a/app/auth/runtime.py +++ b/app/auth/runtime.py @@ -7,8 +7,9 @@ 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 import users as user_service +from app.services.users import application as user_service logger = logging.getLogger(__name__) @@ -28,13 +29,7 @@ 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: - logger.info( - "auth.session_invalidated", - extra={ - "event": "auth.session_invalidated", - "session_user_id": session_user.id, - }, - ) + structured_log(logger, "info", "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 f89b122..befcd7c 100644 --- a/app/auth/security.py +++ b/app/auth/security.py @@ -16,4 +16,3 @@ 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 9772358..e13e3fe 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -21,9 +21,7 @@ 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 8c83197..cf52836 100644 --- a/app/auth/session.py +++ b/app/auth/session.py @@ -4,7 +4,6 @@ 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 new file mode 100644 index 0000000..745f155 --- /dev/null +++ b/app/db/background_session.py @@ -0,0 +1,57 @@ +"""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 6fa2049..78cb78d 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,9 +1,8 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime 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", @@ -21,7 +20,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(timezone.utc) + target.updated_at = datetime.now(UTC) metadata = Base.metadata diff --git a/app/db/models.py b/app/db/models.py index d7d8e8e..66c6ff8 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -8,6 +8,7 @@ from sqlalchemy import ( CheckConstraint, DateTime, Enum, + Float, ForeignKey, Index, Integer, @@ -30,9 +31,11 @@ class RunTriggerType(StrEnum): class RunStatus(StrEnum): RUNNING = "running" + RESOLVING = "resolving" SUCCESS = "success" PARTIAL_FAILURE = "partial_failure" FAILED = "failed" + CANCELED = "canceled" class QueueItemStatus(StrEnum): @@ -59,18 +62,10 @@ 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): @@ -81,23 +76,15 @@ class UserSetting(Base): name="run_interval_minutes_min_15", ), CheckConstraint( - "request_delay_seconds >= 1", - name="request_delay_seconds_min_1", + "request_delay_seconds >= 2", + name="request_delay_seconds_min_2", ), ) - 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, @@ -112,12 +99,13 @@ class UserSetting(Base): ) scrape_cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scrape_cooldown_reason: Mapped[str | None] = mapped_column(String(64)) - 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() - ) + + 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()) class ScholarProfile(Base): @@ -128,9 +116,7 @@ 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) @@ -138,69 +124,47 @@ 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): @@ -221,25 +185,129 @@ 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) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + 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", + ), ) - updated_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, ) + 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( @@ -250,15 +318,10 @@ class ScholarPublication(Base): ForeignKey("publications.id", ondelete="CASCADE"), primary_key=True, ) - 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() - ) + 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()) class IngestionQueueItem(Base): @@ -313,9 +376,121 @@ 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() + 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"), ) - updated_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"), ) + 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): + __tablename__ = "author_search_runtime_state" + + state_key: Mapped[str] = mapped_column(String(64), primary_key=True) + last_live_request_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + consecutive_blocked_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + cooldown_rejection_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + cooldown_alert_emitted: 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 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"), + ) + + query_fingerprint: Mapped[str] = mapped_column(String(64), primary_key=True) + payload: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + ) + 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): + __tablename__ = "author_search_cache_entries" + __table_args__ = ( + Index("ix_author_search_cache_expires_at", "expires_at"), + Index("ix_author_search_cache_cached_at", "cached_at"), + ) + + query_key: Mapped[str] = mapped_column(String(256), primary_key=True) + payload: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + ) + 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()) diff --git a/app/db/session.py b/app/db/session.py index d1ccc04..59a1745 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,5 +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 ( @@ -10,6 +11,7 @@ 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__) @@ -18,16 +20,48 @@ _engine: AsyncEngine | None = None _session_factory: async_sessionmaker[AsyncSession] | None = None +def _normalized_pool_mode(raw_mode: str) -> str: + mode = (raw_mode or "").strip().lower() + if mode == "auto": + if os.getenv("PYTEST_CURRENT_TEST"): + return "null" + app_env = (os.getenv("APP_ENV") or "").strip().lower() + if app_env in {"test", "development", "dev", "local"}: + return "null" + return "queue" + if mode in {"null", "queue"}: + return mode + structured_log( + logger, + "warning", + "db.invalid_pool_mode_fallback", + database_pool_mode=raw_mode, + fallback_mode="queue", + ) + return "queue" + + def get_engine() -> AsyncEngine: global _engine if _engine is None: - # NullPool avoids cross-event-loop connection reuse in tests and dev tools. - _engine = create_async_engine( - settings.database_url, - pool_pre_ping=True, - poolclass=NullPool, + pool_mode = _normalized_pool_mode(settings.database_pool_mode) + engine_kwargs: dict[str, object] = { + "pool_pre_ping": True, + } + if pool_mode == "null": + engine_kwargs["poolclass"] = NullPool + else: + engine_kwargs["pool_size"] = max(1, int(settings.database_pool_size)) + engine_kwargs["max_overflow"] = max(0, int(settings.database_pool_max_overflow)) + 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", + "db.engine_initialized", + pool_mode=pool_mode, ) - logger.info("db.engine_initialized", extra={"event": "db.engine_initialized"}) return _engine @@ -51,7 +85,7 @@ async def check_database() -> bool: result = await conn.execute(text("SELECT 1")) return result.scalar_one() == 1 except Exception: - logger.exception("db.healthcheck_failed", extra={"event": "db.healthcheck_failed"}) + structured_log(logger, "exception", "db.healthcheck_failed") return False @@ -59,6 +93,6 @@ async def close_engine() -> None: global _engine, _session_factory if _engine is not None: await _engine.dispose() - logger.info("db.engine_disposed", extra={"event": "db.engine_disposed"}) + structured_log(logger, "info", "db.engine_disposed") _engine = None _session_factory = None diff --git a/app/http/middleware.py b/app/http/middleware.py index 49a40a4..65607ed 100644 --- a/app/http/middleware.py +++ b/app/http/middleware.py @@ -1,14 +1,15 @@ 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 = ( @@ -61,42 +62,39 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): start = time.perf_counter() should_log = self._log_requests and not self._is_skipped_path(request.url.path) if should_log: - logger.info( + structured_log( + logger, + "debug", "request.started", - extra={ - "event": "request.started", - "method": request.method, - "path": request.url.path, - }, + method=request.method, + path=request.url.path, ) try: response = await call_next(request) except Exception: duration_ms = int((time.perf_counter() - start) * 1000) - logger.exception( + structured_log( + logger, + "exception", "request.failed", - extra={ - "event": "request.failed", - "method": request.method, - "path": request.url.path, - "duration_ms": duration_ms, - }, + 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: - logger.info( + structured_log( + logger, + "debug", "request.completed", - extra={ - "event": "request.completed", - "method": request.method, - "path": request.url.path, - "status_code": response.status_code, - "duration_ms": duration_ms, - }, + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=duration_ms, ) return response finally: @@ -170,11 +168,7 @@ 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 22907be..805fcbf 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,12 +102,22 @@ 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__() @@ -140,7 +150,8 @@ class ConsoleLogFormatter(logging.Formatter): for key in sorted(payload.keys()): if key in {"timestamp", "level", "logger", "event", "exception"}: continue - parts.append(f"{key}={payload[key]}") + display_key = _CONSOLE_SHORT_KEYS.get(key, key) + parts.append(f"{display_key}={payload[key]}") if "exception" in payload: parts.append(f"exception={payload['exception']}") @@ -168,7 +179,7 @@ def _normalize_level(level: str) -> int: def _format_timestamp(created_ts: float) -> str: - dt = datetime.fromtimestamp(created_ts, tz=timezone.utc) + dt = datetime.fromtimestamp(created_ts, tz=UTC) return dt.strftime("%Y-%m-%d %H:%M:%SZ") diff --git a/app/logging_context.py b/app/logging_context.py index ee578fb..a45136b 100644 --- a/app/logging_context.py +++ b/app/logging_context.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextvars import ContextVar - _request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None) @@ -12,4 +11,3 @@ 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 new file mode 100644 index 0000000..e81628e --- /dev/null +++ b/app/logging_utils.py @@ -0,0 +1,29 @@ +"""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 7fbef93..f8610e1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from contextlib import asynccontextmanager from pathlib import Path @@ -9,20 +10,23 @@ 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.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.db.session import check_database, 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.scheduler import SchedulerService +from app.services.ingestion.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, @@ -47,6 +51,56 @@ 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() @@ -82,12 +136,11 @@ 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 4033692..3cf1e91 100644 --- a/app/security/__init__.py +++ b/app/security/__init__.py @@ -1,2 +1 @@ """Security middleware and helpers for scholarr.""" - diff --git a/app/security/csrf.py b/app/security/csrf.py index 70eecac..822be3b 100644 --- a/app/security/csrf.py +++ b/app/security/csrf.py @@ -6,9 +6,11 @@ from urllib.parse import parse_qs from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse, PlainTextResponse, Response +from starlette.responses import 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" @@ -36,13 +38,12 @@ class CSRFMiddleware(BaseHTTPMiddleware): session_token = request.session.get(CSRF_SESSION_KEY) if not session_token: - logger.warning( + structured_log( + logger, + "warning", "csrf.missing_session_token", - extra={ - "event": "csrf.missing_session_token", - "method": request.method, - "path": request.url.path, - }, + method=request.method, + path=request.url.path, ) return self._csrf_error_response( request, @@ -54,16 +55,15 @@ 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) # type: ignore[attr-defined] + request._receive = self._build_receive(body) if not request_token or not compare_digest(str(session_token), str(request_token)): - logger.warning( + structured_log( + logger, + "warning", "csrf.invalid_token", - extra={ - "event": "csrf.invalid_token", - "method": request.method, - "path": request.url.path, - }, + method=request.method, + path=request.url.path, ) return self._csrf_error_response( request, @@ -89,7 +89,10 @@ class CSRFMiddleware(BaseHTTPMiddleware): content_type = request.headers.get("content-type", "") if not content_type.startswith("application/x-www-form-urlencoded"): return None - parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + try: + parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + except (UnicodeDecodeError, ValueError): + return None values = parsed.get(CSRF_FORM_FIELD) if not values: return None @@ -115,19 +118,11 @@ class CSRFMiddleware(BaseHTTPMiddleware): message: str, ) -> Response: if request.url.path.startswith("/api/"): - 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, - }, - }, + return error_response( + request, 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 167f869..6093d3d 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,2 +1 @@ """Service layer for scholarr application workflows.""" - diff --git a/app/services/arxiv/application.py b/app/services/arxiv/application.py new file mode 100644 index 0000000..ff53632 --- /dev/null +++ b/app/services/arxiv/application.py @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..1e9dac9 --- /dev/null +++ b/app/services/arxiv/cache.py @@ -0,0 +1,318 @@ +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 new file mode 100644 index 0000000..41de63d --- /dev/null +++ b/app/services/arxiv/client.py @@ -0,0 +1,278 @@ +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 new file mode 100644 index 0000000..eab9293 --- /dev/null +++ b/app/services/arxiv/constants.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..3561f61 --- /dev/null +++ b/app/services/arxiv/errors.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..b352894 --- /dev/null +++ b/app/services/arxiv/gateway.py @@ -0,0 +1,142 @@ +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 new file mode 100644 index 0000000..db88b3f --- /dev/null +++ b/app/services/arxiv/guards.py @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..f563493 --- /dev/null +++ b/app/services/arxiv/parser.py @@ -0,0 +1,111 @@ +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 new file mode 100644 index 0000000..9d262d4 --- /dev/null +++ b/app/services/arxiv/rate_limit.py @@ -0,0 +1,221 @@ +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 new file mode 100644 index 0000000..574ed93 --- /dev/null +++ b/app/services/arxiv/types.py @@ -0,0 +1,34 @@ +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/crossref/__init__.py b/app/services/crossref/__init__.py new file mode 100644 index 0000000..98882f0 --- /dev/null +++ b/app/services/crossref/__init__.py @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..a546a9f --- /dev/null +++ b/app/services/crossref/application.py @@ -0,0 +1,388 @@ +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 new file mode 100644 index 0000000..3209dec --- /dev/null +++ b/app/services/dbops/__init__.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..a5fa077 --- /dev/null +++ b/app/services/dbops/application.py @@ -0,0 +1,353 @@ +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 new file mode 100644 index 0000000..f05d9a3 --- /dev/null +++ b/app/services/dbops/integrity.py @@ -0,0 +1,175 @@ +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 new file mode 100644 index 0000000..70c902c --- /dev/null +++ b/app/services/dbops/near_duplicate_repair.py @@ -0,0 +1,228 @@ +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 new file mode 100644 index 0000000..f65c582 --- /dev/null +++ b/app/services/dbops/query.py @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..4719cf3 --- /dev/null +++ b/app/services/doi/normalize.py @@ -0,0 +1,23 @@ +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 deleted file mode 100644 index f8d8b60..0000000 --- a/app/services/ingestion.py +++ /dev/null @@ -1,1356 +0,0 @@ -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 new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/services/ingestion/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/services/ingestion/application.py b/app/services/ingestion/application.py new file mode 100644 index 0000000..1a6293e --- /dev/null +++ b/app/services/ingestion/application.py @@ -0,0 +1,550 @@ +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 new file mode 100644 index 0000000..cf371c6 --- /dev/null +++ b/app/services/ingestion/constants.py @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..181ff41 --- /dev/null +++ b/app/services/ingestion/enrichment.py @@ -0,0 +1,409 @@ +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 new file mode 100644 index 0000000..0d04f01 --- /dev/null +++ b/app/services/ingestion/fingerprints.py @@ -0,0 +1,381 @@ +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 new file mode 100644 index 0000000..3a57206 --- /dev/null +++ b/app/services/ingestion/page_fetch.py @@ -0,0 +1,223 @@ +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 new file mode 100644 index 0000000..91bc984 --- /dev/null +++ b/app/services/ingestion/pagination.py @@ -0,0 +1,504 @@ +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 new file mode 100644 index 0000000..44441e3 --- /dev/null +++ b/app/services/ingestion/preflight.py @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..ae7b1b8 --- /dev/null +++ b/app/services/ingestion/publication_upsert.py @@ -0,0 +1,244 @@ +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/continuation_queue.py b/app/services/ingestion/queue.py similarity index 72% rename from app/services/continuation_queue.py rename to app/services/ingestion/queue.py index 7d7d93e..8bdddbc 100644 --- a/app/services/continuation_queue.py +++ b/app/services/ingestion/queue.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -41,45 +41,57 @@ def compute_backoff_seconds(*, base_seconds: int, attempt_count: int, max_second return min(seconds, maximum) -async def upsert_job( +async def _get_item_for_user_scholar( 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(timezone.utc) - next_attempt_dt = now + timedelta(seconds=max(0, int(delay_seconds))) +) -> IngestionQueueItem | None: result = await db_session.execute( select(IngestionQueueItem).where( IngestionQueueItem.user_id == user_id, IngestionQueueItem.scholar_profile_id == scholar_profile_id, ) ) - 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 + return result.scalar_one_or_none() + +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: @@ -91,6 +103,47 @@ async def upsert_job( 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 @@ -118,9 +171,7 @@ 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 @@ -145,6 +196,7 @@ async def list_due_jobs( IngestionQueueItem.id.asc(), ) .limit(limit) + .with_for_update(skip_locked=True) ) rows = list(result.scalars().all()) jobs: list[ContinuationQueueJob] = [] @@ -169,10 +221,8 @@ async def increment_attempt_count( *, job_id: int, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(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 @@ -186,10 +236,8 @@ async def reset_attempt_count( *, job_id: int, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(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 @@ -206,10 +254,8 @@ async def reschedule_job( reason: str, error: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(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 @@ -229,10 +275,8 @@ async def mark_retrying( job_id: int, reason: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(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 @@ -252,10 +296,8 @@ async def mark_dropped( reason: str, error: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(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 @@ -276,10 +318,8 @@ async def mark_queued_now( reason: str, reset_attempt_count: bool = False, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(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/ingestion/queue_runner.py b/app/services/ingestion/queue_runner.py new file mode 100644 index 0000000..9f7bd1b --- /dev/null +++ b/app/services/ingestion/queue_runner.py @@ -0,0 +1,381 @@ +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 new file mode 100644 index 0000000..d22b8fc --- /dev/null +++ b/app/services/ingestion/run_completion.py @@ -0,0 +1,457 @@ +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 new file mode 100644 index 0000000..94ed0f4 --- /dev/null +++ b/app/services/ingestion/run_enrichment.py @@ -0,0 +1,110 @@ +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 new file mode 100644 index 0000000..fafc405 --- /dev/null +++ b/app/services/ingestion/run_guards.py @@ -0,0 +1,135 @@ +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/run_safety.py b/app/services/ingestion/safety.py similarity index 73% rename from app/services/run_safety.py rename to app/services/ingestion/safety.py index 210c688..a8385a9 100644 --- a/app/services/run_safety.py +++ b/app/services/ingestion/safety.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta 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(timezone.utc) + return datetime.now(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=timezone.utc) - return value.astimezone(timezone.utc) + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) def _state_dict(settings: UserSetting) -> dict[str, Any]: @@ -100,9 +100,7 @@ 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 @@ -146,6 +144,59 @@ 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, *, @@ -160,41 +211,27 @@ def apply_run_safety_outcome( ) -> tuple[dict[str, Any], str | None]: now = now_utc or _utcnow() counters = _counters_from_state(settings) - - 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 + 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, ) - counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS] = ( - int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1 - if bounded_network_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, ) - - 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/ingestion/scheduler.py b/app/services/ingestion/scheduler.py new file mode 100644 index 0000000..e44cb45 --- /dev/null +++ b/app/services/ingestion/scheduler.py @@ -0,0 +1,313 @@ +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 new file mode 100644 index 0000000..0871273 --- /dev/null +++ b/app/services/ingestion/scholar_outcomes.py @@ -0,0 +1,308 @@ +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 new file mode 100644 index 0000000..1735915 --- /dev/null +++ b/app/services/ingestion/scholar_processing.py @@ -0,0 +1,458 @@ +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 new file mode 100644 index 0000000..1f6aae8 --- /dev/null +++ b/app/services/ingestion/types.py @@ -0,0 +1,110 @@ +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 new file mode 100644 index 0000000..a51555a --- /dev/null +++ b/app/services/openalex/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/app/services/openalex/client.py b/app/services/openalex/client.py new file mode 100644 index 0000000..13db8ab --- /dev/null +++ b/app/services/openalex/client.py @@ -0,0 +1,166 @@ +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 new file mode 100644 index 0000000..34e1927 --- /dev/null +++ b/app/services/openalex/matching.py @@ -0,0 +1,110 @@ +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 new file mode 100644 index 0000000..8abf375 --- /dev/null +++ b/app/services/openalex/types.py @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..9f63c8f --- /dev/null +++ b/app/services/portability/__init__.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..c696f4d --- /dev/null +++ b/app/services/portability/application.py @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..6815347 --- /dev/null +++ b/app/services/portability/constants.py @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..a11eaef --- /dev/null +++ b/app/services/portability/exporting.py @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..9353d2a --- /dev/null +++ b/app/services/portability/normalize.py @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..e2eb96a --- /dev/null +++ b/app/services/portability/publication_import.py @@ -0,0 +1,366 @@ +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 new file mode 100644 index 0000000..274e7fd --- /dev/null +++ b/app/services/portability/scholar_import.py @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..2a7f6e8 --- /dev/null +++ b/app/services/portability/types.py @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..c64a01f --- /dev/null +++ b/app/services/publication_identifiers/__init__.py @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..560211e --- /dev/null +++ b/app/services/publication_identifiers/application.py @@ -0,0 +1,525 @@ +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 new file mode 100644 index 0000000..1308a8d --- /dev/null +++ b/app/services/publication_identifiers/normalize.py @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..34b20d0 --- /dev/null +++ b/app/services/publication_identifiers/types.py @@ -0,0 +1,30 @@ +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 deleted file mode 100644 index 3ba5f87..0000000 --- a/app/services/publications.py +++ /dev/null @@ -1,333 +0,0 @@ -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_NEW = "new" -MODE_ALL = "all" - - -@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_mode(value: str | None) -> str: - if value == MODE_ALL: - return MODE_ALL - return MODE_NEW - - -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_NEW: - # "New" 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_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_ALL, - latest_run_id=None, - scholar_profile_id=None, - limit=limit, - ).where(ScholarPublication.is_read.is_(False)) - ) - 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_NEW, - 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_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_NEW: - 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 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 new file mode 100644 index 0000000..c03a02a --- /dev/null +++ b/app/services/publications/__init__.py @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..61cf9ad --- /dev/null +++ b/app/services/publications/application.py @@ -0,0 +1,76 @@ +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 new file mode 100644 index 0000000..fc09930 --- /dev/null +++ b/app/services/publications/counts.py @@ -0,0 +1,122 @@ +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 new file mode 100644 index 0000000..d078f97 --- /dev/null +++ b/app/services/publications/dedup.py @@ -0,0 +1,502 @@ +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 new file mode 100644 index 0000000..3980d05 --- /dev/null +++ b/app/services/publications/enrichment.py @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..6bb50b2 --- /dev/null +++ b/app/services/publications/listing.py @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..2975169 --- /dev/null +++ b/app/services/publications/modes.py @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..25f2437 --- /dev/null +++ b/app/services/publications/pdf_queue.py @@ -0,0 +1,330 @@ +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 new file mode 100644 index 0000000..0bff88b --- /dev/null +++ b/app/services/publications/pdf_queue_common.py @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..31260ea --- /dev/null +++ b/app/services/publications/pdf_queue_queries.py @@ -0,0 +1,395 @@ +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 new file mode 100644 index 0000000..ac71edd --- /dev/null +++ b/app/services/publications/pdf_queue_resolution.py @@ -0,0 +1,305 @@ +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 new file mode 100644 index 0000000..b36777d --- /dev/null +++ b/app/services/publications/pdf_resolution_pipeline.py @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000..f113080 --- /dev/null +++ b/app/services/publications/queries.py @@ -0,0 +1,270 @@ +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 new file mode 100644 index 0000000..2a56b61 --- /dev/null +++ b/app/services/publications/read_state.py @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..c8b1ad1 --- /dev/null +++ b/app/services/publications/types.py @@ -0,0 +1,41 @@ +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/runs.py b/app/services/runs.py deleted file mode 100644 index 368815f..0000000 --- a/app/services/runs.py +++ /dev/null @@ -1,479 +0,0 @@ -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 new file mode 100644 index 0000000..5bed6a2 --- /dev/null +++ b/app/services/runs/__init__.py @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..e0b464a --- /dev/null +++ b/app/services/runs/application.py @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..dbcac2d --- /dev/null +++ b/app/services/runs/events.py @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..2d1127a --- /dev/null +++ b/app/services/runs/queue_queries.py @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..855171f --- /dev/null +++ b/app/services/runs/queue_service.py @@ -0,0 +1,180 @@ +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 new file mode 100644 index 0000000..72901ec --- /dev/null +++ b/app/services/runs/runs_service.py @@ -0,0 +1,77 @@ +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 new file mode 100644 index 0000000..58cb7e3 --- /dev/null +++ b/app/services/runs/summary.py @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..2524269 --- /dev/null +++ b/app/services/runs/types.py @@ -0,0 +1,38 @@ +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 deleted file mode 100644 index 96dd5f1..0000000 --- a/app/services/scheduler.py +++ /dev/null @@ -1,593 +0,0 @@ -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 new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/services/scholar/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/services/scholar/author_rows.py b/app/services/scholar/author_rows.py new file mode 100644 index 0000000..a0705fb --- /dev/null +++ b/app/services/scholar/author_rows.py @@ -0,0 +1,197 @@ +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 new file mode 100644 index 0000000..1718b3f --- /dev/null +++ b/app/services/scholar/parser.py @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..c3c7a15 --- /dev/null +++ b/app/services/scholar/parser_constants.py @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..7b657d8 --- /dev/null +++ b/app/services/scholar/parser_types.py @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..0bc3b77 --- /dev/null +++ b/app/services/scholar/parser_utils.py @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..d291dbf --- /dev/null +++ b/app/services/scholar/profile_rows.py @@ -0,0 +1,271 @@ +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 new file mode 100644 index 0000000..fa708b6 --- /dev/null +++ b/app/services/scholar/rate_limit.py @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..afafb1c --- /dev/null +++ b/app/services/scholar/source.py @@ -0,0 +1,263 @@ +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 new file mode 100644 index 0000000..28a413d --- /dev/null +++ b/app/services/scholar/state_detection.py @@ -0,0 +1,169 @@ +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 deleted file mode 100644 index de2daa5..0000000 --- a/app/services/scholar_parser.py +++ /dev/null @@ -1,766 +0,0 @@ -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 deleted file mode 100644 index 327d1c4..0000000 --- a/app/services/scholar_source.py +++ /dev/null @@ -1,211 +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 - -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 and cstart > 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 deleted file mode 100644 index 5bb88c1..0000000 --- a/app/services/scholars.py +++ /dev/null @@ -1,725 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import OrderedDict -from dataclasses import dataclass -from dataclasses import replace -from datetime import datetime -from datetime import timedelta -from datetime import timezone -import logging -import os -import random -import re -import time -from pathlib import Path -from urllib.parse import urlparse -from uuid import uuid4 - -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import 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 -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__) - - -@dataclass(frozen=True) -class _AuthorSearchCacheEntry: - parsed: ParsedAuthorSearchPage - expires_at_monotonic: float - cached_at_utc: datetime - - -_AUTHOR_SEARCH_EXECUTION_LOCK = asyncio.Lock() -_AUTHOR_SEARCH_CACHE: OrderedDict[str, _AuthorSearchCacheEntry] = OrderedDict() -_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 -_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC: datetime | None = None -_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 -_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0 -_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False - - -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), - ) - - -def _cache_get_author_search_result(query_key: str, now_monotonic: float) -> _AuthorSearchCacheEntry | None: - entry = _AUTHOR_SEARCH_CACHE.get(query_key) - if entry is None: - return None - if entry.expires_at_monotonic <= now_monotonic: - _AUTHOR_SEARCH_CACHE.pop(query_key, None) - return None - _AUTHOR_SEARCH_CACHE.move_to_end(query_key) - return entry - - -def _cache_set_author_search_result( - *, - query_key: str, - parsed: ParsedAuthorSearchPage, - ttl_seconds: float, - max_entries: int, -) -> None: - ttl = max(float(ttl_seconds), 0.0) - if ttl <= 0.0: - _AUTHOR_SEARCH_CACHE.pop(query_key, None) - return - - _AUTHOR_SEARCH_CACHE[query_key] = _AuthorSearchCacheEntry( - parsed=parsed, - expires_at_monotonic=time.monotonic() + ttl, - cached_at_utc=datetime.now(timezone.utc), - ) - _AUTHOR_SEARCH_CACHE.move_to_end(query_key) - - bounded_max_entries = max(1, int(max_entries)) - while len(_AUTHOR_SEARCH_CACHE) > bounded_max_entries: - _AUTHOR_SEARCH_CACHE.popitem(last=False) - - -def _is_author_search_block_state(parsed: ParsedAuthorSearchPage) -> bool: - return parsed.state == ParseState.BLOCKED_OR_CAPTCHA - - -def _author_search_cooldown_remaining_seconds(now_utc: datetime) -> int: - if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC is None: - return 0 - remaining_seconds = int((_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - now_utc).total_seconds()) - return max(0, remaining_seconds) - - -def _reset_author_search_runtime_state_for_tests() -> None: - global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC - global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT - global _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT - global _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED - _AUTHOR_SEARCH_CACHE.clear() - _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 - _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None - _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 - _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0 - _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False - - -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, - 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: - global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC - global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT - global _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT - global _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED - - 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, - ) - - async with _AUTHOR_SEARCH_EXECUTION_LOCK: - now_utc = datetime.now(timezone.utc) - now_monotonic = time.monotonic() - - if ( - _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC is not None - and now_utc >= _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - ): - logger.info( - "scholar_search.cooldown_expired", - extra={ - "event": "scholar_search.cooldown_expired", - "cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat(), - }, - ) - _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None - _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0 - _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False - - cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc) - if cooldown_remaining_seconds > 0: - _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT += 1 - bounded_cooldown_rejection_alert_threshold = max( - 1, - int(cooldown_rejection_alert_threshold), - ) - if ( - _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT - >= bounded_cooldown_rejection_alert_threshold - and not _AUTHOR_SEARCH_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": _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT, - "threshold": bounded_cooldown_rejection_alert_threshold, - "cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat() - if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - else None, - }, - ) - _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = 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": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat() - if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - else None, - }, - ) - warning_codes = [ - "author_search_cooldown_active", - f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", - ] - if _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED: - warning_codes.append("author_search_cooldown_alert_threshold_exceeded") - return _policy_blocked_author_search_result( - reason=SEARCH_COOLDOWN_REASON, - warning_codes=warning_codes, - limit=bounded_limit, - ) - - cached_entry = _cache_get_author_search_result(query_key, now_monotonic) - if cached_entry is not None: - cached = cached_entry.parsed - 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, - }, - ) - return _trim_author_search_result( - cached, - limit=bounded_limit, - extra_warnings=["author_search_served_from_cache"], - state_reason_override=state_reason_override, - ) - - enforced_wait_seconds = ( - (_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC + max(float(min_interval_seconds), 0.0)) - - now_monotonic - ) - jitter_seconds = random.uniform(0.0, max(float(interval_jitter_seconds), 0.0)) - sleep_seconds = max(0.0, 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 - sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) - if sleep_seconds > 0: - await asyncio.sleep(sleep_seconds) - - _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = time.monotonic() - - 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): - _AUTHOR_SEARCH_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": _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT, - }, - ) - if _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT >= max(1, int(cooldown_block_threshold)): - _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = datetime.now(timezone.utc) + timedelta( - seconds=max(60, int(cooldown_seconds)) - ) - _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 - _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0 - _AUTHOR_SEARCH_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": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat(), - }, - ) - else: - _AUTHOR_SEARCH_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)) - ) - _cache_set_author_search_result( - query_key=query_key, - parsed=merged_parsed, - ttl_seconds=float(ttl_seconds), - max_entries=cache_max_entries, - ) - - 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 new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/services/scholars/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/services/scholars/application.py b/app/services/scholars/application.py new file mode 100644 index 0000000..3496732 --- /dev/null +++ b/app/services/scholars/application.py @@ -0,0 +1,288 @@ +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 new file mode 100644 index 0000000..a9a9a02 --- /dev/null +++ b/app/services/scholars/author_search.py @@ -0,0 +1,580 @@ +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 new file mode 100644 index 0000000..a0bab2f --- /dev/null +++ b/app/services/scholars/author_search_cache.py @@ -0,0 +1,240 @@ +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 new file mode 100644 index 0000000..0602acd --- /dev/null +++ b/app/services/scholars/constants.py @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..f1383e3 --- /dev/null +++ b/app/services/scholars/exceptions.py @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..7c5cf15 --- /dev/null +++ b/app/services/scholars/search_hints.py @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..b0cf766 --- /dev/null +++ b/app/services/scholars/uploads.py @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..38ee42c --- /dev/null +++ b/app/services/scholars/validators.py @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/services/settings/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/services/user_settings.py b/app/services/settings/application.py similarity index 80% rename from app/services/user_settings.py rename to app/services/settings/application.py index 3548e44..e35971c 100644 --- a/app/services/user_settings.py +++ b/app/services/settings/application.py @@ -4,6 +4,7 @@ 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): @@ -60,9 +61,7 @@ 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 @@ -73,9 +72,7 @@ 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 @@ -102,9 +99,7 @@ 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 @@ -114,14 +109,17 @@ 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) + 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, + ) db_session.add(settings) await db_session.commit() await db_session.refresh(settings) @@ -136,11 +134,17 @@ 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/unpaywall/__init__.py b/app/services/unpaywall/__init__.py new file mode 100644 index 0000000..ecfec29 --- /dev/null +++ b/app/services/unpaywall/__init__.py @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..f7d23f8 --- /dev/null +++ b/app/services/unpaywall/application.py @@ -0,0 +1,428 @@ +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 new file mode 100644 index 0000000..01d568d --- /dev/null +++ b/app/services/unpaywall/pdf_discovery.py @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000..76d8a3d --- /dev/null +++ b/app/services/unpaywall/rate_limit.py @@ -0,0 +1,18 @@ +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/users/__init__.py b/app/services/users/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/services/users/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/services/users.py b/app/services/users/application.py similarity index 95% rename from app/services/users.py rename to app/services/users/application.py index 7f019c4..e07f514 100644 --- a/app/services/users.py +++ b/app/services/users/application.py @@ -39,9 +39,7 @@ 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() @@ -95,4 +93,3 @@ async def set_user_password_hash( await db_session.commit() await db_session.refresh(user) return user - diff --git a/app/settings.py b/app/settings.py index 4299405..40596d1 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass import os +from dataclasses import dataclass DEFAULT_SECURITY_PERMISSIONS_POLICY = ( "accelerometer=(), autoplay=(), camera=(), display-capture=(), " @@ -64,6 +64,10 @@ class Settings: "DATABASE_URL", "postgresql+asyncpg://scholar:scholar@db:5432/scholar", ) + database_pool_mode: str = _env_str("DATABASE_POOL_MODE", "auto") + database_pool_size: int = _env_int("DATABASE_POOL_SIZE", 5) + database_pool_max_overflow: int = _env_int("DATABASE_POOL_MAX_OVERFLOW", 10) + database_pool_timeout_seconds: int = _env_int("DATABASE_POOL_TIMEOUT_SECONDS", 30) session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key") session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False) security_headers_enabled: bool = _env_bool("SECURITY_HEADERS_ENABLED", True) @@ -136,6 +140,11 @@ 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, @@ -178,6 +187,7 @@ 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( @@ -225,6 +235,50 @@ 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 new file mode 100644 index 0000000..e69de29 diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md new file mode 100644 index 0000000..392670e --- /dev/null +++ b/docs/developer/architecture.md @@ -0,0 +1,173 @@ +--- +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 new file mode 100644 index 0000000..ec5ad5e --- /dev/null +++ b/docs/developer/contributing.md @@ -0,0 +1,78 @@ +--- +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 new file mode 100644 index 0000000..0233071 --- /dev/null +++ b/docs/developer/frontend-theme-inventory.md @@ -0,0 +1,105 @@ +--- +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 new file mode 100644 index 0000000..12eb014 --- /dev/null +++ b/docs/developer/ingestion.md @@ -0,0 +1,89 @@ +--- +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 new file mode 100644 index 0000000..2adfab0 --- /dev/null +++ b/docs/developer/local-development.md @@ -0,0 +1,100 @@ +--- +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 new file mode 100644 index 0000000..9974236 --- /dev/null +++ b/docs/developer/overview.md @@ -0,0 +1,65 @@ +--- +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 new file mode 100644 index 0000000..6440a3d --- /dev/null +++ b/docs/developer/testing.md @@ -0,0 +1,102 @@ +--- +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 new file mode 100644 index 0000000..94b563b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 0000000..b49da90 --- /dev/null +++ b/docs/operations/arxiv-runbook.md @@ -0,0 +1,80 @@ +--- +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 new file mode 100644 index 0000000..434bc51 --- /dev/null +++ b/docs/operations/database-runbook.md @@ -0,0 +1,195 @@ +--- +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 new file mode 100644 index 0000000..48e06d4 --- /dev/null +++ b/docs/operations/deployment.md @@ -0,0 +1,117 @@ +--- +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 new file mode 100644 index 0000000..7d2f997 --- /dev/null +++ b/docs/operations/overview.md @@ -0,0 +1,30 @@ +--- +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 new file mode 100644 index 0000000..71f6769 --- /dev/null +++ b/docs/operations/scrape-safety-runbook.md @@ -0,0 +1,76 @@ +--- +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 deleted file mode 100644 index db01064..0000000 --- a/docs/ops/scrape_safety_runbook.md +++ /dev/null @@ -1,74 +0,0 @@ -# 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 new file mode 100644 index 0000000..668ab51 --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,177 @@ +--- +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 new file mode 100644 index 0000000..23a7a11 --- /dev/null +++ b/docs/reference/changelog.md @@ -0,0 +1,12 @@ +--- +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 new file mode 100644 index 0000000..fc7e29a --- /dev/null +++ b/docs/reference/environment.md @@ -0,0 +1,32 @@ +--- +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 new file mode 100644 index 0000000..c8249d9 --- /dev/null +++ b/docs/reference/overview.md @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 0000000..d79a1b7 --- /dev/null +++ b/docs/user/configuration.md @@ -0,0 +1,178 @@ +--- +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 new file mode 100644 index 0000000..1090f86 --- /dev/null +++ b/docs/user/getting-started.md @@ -0,0 +1,86 @@ +--- +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 new file mode 100644 index 0000000..50af970 --- /dev/null +++ b/docs/user/overview.md @@ -0,0 +1,29 @@ +--- +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 new file mode 100644 index 0000000..6156ad9 --- /dev/null +++ b/docs/website/docusaurus.config.js @@ -0,0 +1,74 @@ +// @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 new file mode 100644 index 0000000..d327972 --- /dev/null +++ b/docs/website/package-lock.json @@ -0,0 +1,18461 @@ +{ + "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 new file mode 100644 index 0000000..85208b0 --- /dev/null +++ b/docs/website/package.json @@ -0,0 +1,27 @@ +{ + "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 new file mode 100644 index 0000000..2b97349 --- /dev/null +++ b/docs/website/sidebars.js @@ -0,0 +1,51 @@ +/** @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 new file mode 100644 index 0000000..c875358 --- /dev/null +++ b/docs/website/src/css/custom.css @@ -0,0 +1,22 @@ +: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 91a3496..c23810e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,12 @@ + + + + + + scholarr