From f71841e92275c92196d8dd61c01726e62c41a7c0 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Tue, 17 Feb 2026 15:21:08 +0100 Subject: [PATCH 1/3] removed old UI --- .env.example | 2 +- AGENTS.MD | 220 ++++ README.md | 223 ++-- app/api/deps.py | 5 +- app/api/routers/auth.py | 10 +- app/api/routers/runs.py | 2 +- app/{web/deps.py => api/runtime_deps.py} | 1 - app/auth/runtime.py | 55 + app/http/__init__.py | 1 + app/{web => http}/middleware.py | 2 +- app/main.py | 37 +- app/presentation/__init__.py | 2 - app/presentation/dashboard.py | 108 -- app/security/csrf.py | 2 - app/settings.py | 2 +- app/static/app.css | 501 ------- app/static/app.js | 76 -- app/static/theme.css | 98 -- app/static/theme.js | 56 - app/templates/account_password.html | 23 - app/templates/base.html | 66 - app/templates/dashboard/_run_controls.html | 23 - app/templates/dashboard/_run_history.html | 38 - .../dashboard/_unread_publications.html | 47 - app/templates/index.html | 27 - app/templates/login.html | 23 - app/templates/publications.html | 104 -- app/templates/run_detail.html | 98 -- app/templates/runs.html | 129 -- app/templates/scholars.html | 99 -- app/templates/settings.html | 44 - app/templates/users.html | 75 -- app/theme.py | 24 - app/web/__init__.py | 2 - app/web/common.py | 140 -- app/web/routers/__init__.py | 2 - app/web/routers/admin.py | 209 --- app/web/routers/auth.py | 214 --- app/web/routers/dashboard.py | 167 --- app/web/routers/health.py | 15 - app/web/routers/publications.py | 165 --- app/web/routers/runs.py | 274 ---- app/web/routers/scholars.py | 212 --- app/web/routers/settings.py | 94 -- docker-compose.yml | 7 +- planning/mvp_implementation_reminders.md | 92 -- planning/scholar_probe_tmp/README.md | 13 - .../notes/findings_phase1_input.md | 71 - pyproject.toml | 2 - tests/integration/helpers.py | 30 +- .../integration/test_admin_user_management.py | 200 --- tests/integration/test_auth_flow.py | 109 -- .../integration/test_manual_ingestion_flow.py | 1151 ----------------- tests/integration/test_queue_ui.py | 227 ---- tests/integration/test_tenant_routes.py | 197 --- tests/unit/test_auth.py | 204 --- tests/unit/test_dashboard_view_model.py | 88 -- tests/unit/test_healthz.py | 4 +- tests/unit/test_logging.py | 15 +- tests/unit/test_pages.py | 54 - tests/unit/test_phase2_pages.py | 20 - uv.lock | 25 - 62 files changed, 411 insertions(+), 5815 deletions(-) create mode 100644 AGENTS.MD rename app/{web/deps.py => api/runtime_deps.py} (99%) create mode 100644 app/auth/runtime.py create mode 100644 app/http/__init__.py rename app/{web => http}/middleware.py (100%) delete mode 100644 app/presentation/__init__.py delete mode 100644 app/presentation/dashboard.py delete mode 100644 app/static/app.css delete mode 100644 app/static/app.js delete mode 100644 app/static/theme.css delete mode 100644 app/static/theme.js delete mode 100644 app/templates/account_password.html delete mode 100644 app/templates/base.html delete mode 100644 app/templates/dashboard/_run_controls.html delete mode 100644 app/templates/dashboard/_run_history.html delete mode 100644 app/templates/dashboard/_unread_publications.html delete mode 100644 app/templates/index.html delete mode 100644 app/templates/login.html delete mode 100644 app/templates/publications.html delete mode 100644 app/templates/run_detail.html delete mode 100644 app/templates/runs.html delete mode 100644 app/templates/scholars.html delete mode 100644 app/templates/settings.html delete mode 100644 app/templates/users.html delete mode 100644 app/theme.py delete mode 100644 app/web/__init__.py delete mode 100644 app/web/common.py delete mode 100644 app/web/routers/__init__.py delete mode 100644 app/web/routers/admin.py delete mode 100644 app/web/routers/auth.py delete mode 100644 app/web/routers/dashboard.py delete mode 100644 app/web/routers/health.py delete mode 100644 app/web/routers/publications.py delete mode 100644 app/web/routers/runs.py delete mode 100644 app/web/routers/scholars.py delete mode 100644 app/web/routers/settings.py delete mode 100644 planning/mvp_implementation_reminders.md delete mode 100644 planning/scholar_probe_tmp/README.md delete mode 100644 planning/scholar_probe_tmp/notes/findings_phase1_input.md delete mode 100644 tests/integration/test_admin_user_management.py delete mode 100644 tests/integration/test_auth_flow.py delete mode 100644 tests/integration/test_manual_ingestion_flow.py delete mode 100644 tests/integration/test_queue_ui.py delete mode 100644 tests/integration/test_tenant_routes.py delete mode 100644 tests/unit/test_auth.py delete mode 100644 tests/unit/test_dashboard_view_model.py delete mode 100644 tests/unit/test_pages.py delete mode 100644 tests/unit/test_phase2_pages.py diff --git a/.env.example b/.env.example index 82480f1..dc1a02a 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,7 @@ LOG_LEVEL=INFO LOG_FORMAT=console LOG_REQUESTS=1 LOG_UVICORN_ACCESS=0 -LOG_REQUEST_SKIP_PATHS=/healthz,/static/ +LOG_REQUEST_SKIP_PATHS=/healthz LOG_REDACT_FIELDS= SCHEDULER_ENABLED=1 SCHEDULER_TICK_SECONDS=60 diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..b2aa84e --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,220 @@ +# scholarr Agent Handbook + +This file is the consolidated source of truth for project scope, constraints, scrape contract, and UI implementation flow. + +Raw probe artifacts in `planning/scholar_probe_tmp/notes/*.json` and `planning/scholar_probe_tmp/notes/*.md` remain historical evidence and fixture references. + +## 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` +- `PATCH /api/v1/scholars/{id}/toggle` +- `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-all-read` + +Ops: +- `GET /healthz` + +## 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. + +## 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 + + + +## 8.4 Scholars + +1. List via `GET /api/v1/scholars`. +2. Add/toggle/delete actions mapped to scholar endpoints. +3. Per-row quick links: + - all publications filtered by scholar + - new publications filtered by scholar + +## 8.5 Publications + +## 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) + +- Scholar discovery by real name (search Google Scholar candidates, select one, then add scholar ID). +- API contract freeze/changelog discipline for external UI clients. +- Additional API-first smoke coverage for end-to-end UI critical flows. diff --git a/README.md b/README.md index 21c0da2..934599a 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,80 @@ # scholarr -Container-first, self-hosted scholar tracking in the spirit of the `*arr` ecosystem, with strict multi-user isolation and an intentionally small operational footprint. +API-first, self-hosted scholar tracking backend in the spirit of the `*arr` ecosystem. + +The legacy server-rendered UI has been removed. This repository now focuses on core ingestion, scheduling, and multi-user API functionality. ## Current Scope -- Admin-managed users only (no public signup) -- Session auth with CSRF protection and login rate limiting -- Per-user scholar tracking list (add, enable/disable, delete) -- Per-user automation settings -- Manual per-user ingestion runs from dashboard -- Per-user run history and "new since last run" publication stream -- Publications library view with `new` and `all` modes plus scholar filtering -- Dedicated run diagnostics pages (`/runs`, `/runs/{id}`) with failed-only filtering -- Continuation queue visibility + controls on runs page (`retry`, `drop`, `clear`) -- Baseline-aware discovery tracking (`new` = discovered in latest completed run) -- Read state is user-controlled (`Mark All Read`), including baseline items -- Multi-page profile ingestion (`cstart`) to capture full publication lists -- Dashboard rendered via a presentation-layer view model and section template registry -- Structured application logging with request IDs and field redaction -- Ingestion overlap guard via Postgres advisory locks -- Network-error retry support for ingestion attempts -- Automatic continuation queue for resumable/limit-hit scholar runs -- Background scheduler for per-user automatic runs -- User self-service password changes -- Admin user management (create users, activate/deactivate, reset passwords) -- PostgreSQL + Alembic migrations + containerized test workflow -- Versioned backend API (`/api/v1`) for frontend decoupling (same-origin cookie session) +- Multi-user accounts with admin-managed user lifecycle +- Same-origin cookie sessions with CSRF enforcement +- API auth flows (login, logout, me, password change) +- Admin user management API +- Scholar CRUD per user +- Per-user ingestion settings +- Manual runs with idempotency support +- Run history, run detail diagnostics, and continuation queue actions +- Publication listing (`new` / `all`) and mark-all-read +- Ingestion scheduler + continuation queue retries +- Structured logging with request IDs + redaction +- PostgreSQL + Alembic migrations +- Container-first development workflow with `uv` -## Tech Stack +## Functionality Tracking -- Python 3.12+ -- FastAPI -- PostgreSQL 15+ -- SQLAlchemy 2 + Alembic -- Jinja2 templates + modular theme tokens -- `uv` for dependency and runtime workflow -- Docker / Docker Compose for development and CI parity +Planned and supported backend functionality is tracked in: + +- `AGENTS.MD` + +## API Base + +- Base path: `/api/v1` +- Success envelope: + - `{"data": ..., "meta": {"request_id": "..."}}` +- Error envelope: + - `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` + +## Auth & Session Model + +- Session transport: same-origin cookie session (`HttpOnly`, `SameSite=Lax`) +- CSRF: + - Required for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) via `X-CSRF-Token` + - Bootstrap token via `GET /api/v1/auth/csrf` + +## API Surface + +- Auth: + - `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` + - `PATCH /api/v1/scholars/{id}/toggle` + - `DELETE /api/v1/scholars/{id}` +- Settings: + - `GET /api/v1/settings` + - `PUT /api/v1/settings` +- Runs: + - `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-all-read` +- Ops: + - `GET /healthz` ## Quick Start @@ -45,7 +84,7 @@ Container-first, self-hosted scholar tracking in the spirit of the `*arr` ecosys cp .env.example .env ``` -2. Bootstrap the first admin on startup (recommended for first run): +2. Optional bootstrap admin on startup: ```bash export BOOTSTRAP_ADMIN_ON_START=1 @@ -53,28 +92,20 @@ export BOOTSTRAP_ADMIN_EMAIL=admin@example.com export BOOTSTRAP_ADMIN_PASSWORD=change-me-now ``` -3. Start the stack: +3. Start stack: ```bash docker compose up --build ``` -4. Open: +4. Health check: -- Login: `http://localhost:8000/login` -- Dashboard: `http://localhost:8000/` -- Healthcheck: `http://localhost:8000/healthz` - -5. After first successful admin login, disable auto-bootstrap: - -```bash -export BOOTSTRAP_ADMIN_ON_START=0 +```text +http://localhost:8000/healthz ``` ## Admin Bootstrap (Manual) -You can create/update an admin account without restarting the app: - ```bash docker compose run --rm app uv run python scripts/bootstrap_admin.py \ --email admin@example.com \ @@ -82,18 +113,8 @@ docker compose run --rm app uv run python scripts/bootstrap_admin.py \ --force-password ``` -Notes: -- If the user does not exist, this creates an active admin account. -- If the user exists, it ensures admin + active flags. -- `--force-password` resets the password for existing users. - ## Test Workflow -- Integration/smoke tests run against `TEST_DATABASE_URL`. -- If `TEST_DATABASE_URL` is empty, test runs derive an isolated database from `DATABASE_URL` by appending `_test`. -- This keeps app data in `DATABASE_URL` untouched during test runs. -- Parser regression tests are pinned to captured real-world fixture pages in `tests/fixtures/scholar/regression`. - - Unit tests: ```bash @@ -106,101 +127,29 @@ docker compose run --rm app uv run pytest tests/unit docker compose run --rm app uv run pytest -m integration ``` -- Smoke checks (container + DB + migration sanity): +- Smoke checks: ```bash ./scripts/smoke_compose.sh ``` -CI runs migration, unit, and integration checks on push/PR via `.github/workflows/ci.yml`. - -## Scholar Ingestion Notes - -- Current ingestion targets public profile pages: `/citations?user=`. -- Runs are intentionally conservative and prioritize reliability over aggressive scraping. -- Ingestion follows pagination (`cstart`) to collect all reachable profile pages. -- If pagination cannot be completed (max pages hit, cursor stall, or page-state failure), the scholar result is marked partial with explicit debug context. -- Resumable partial/failure states are automatically queued and retried by the scheduler with bounded backoff. -- Queue items keep explicit status (`queued`, `retrying`, `dropped`) and last-error context for UI diagnostics. -- Failed scholar attempts persist structured debug context in `crawl_runs.error_log` (state reason, fetch metadata, marker evidence, and compact response excerpt). -- Only resumable states are retried automatically (`network_error` and continuation-eligible pagination truncations); blocked/layout states are recorded immediately as failures. - -## API v1 (Frontend Prep) - -- Base path: `/api/v1` -- Auth model: same-origin cookie session (no token auth added) -- CSRF model: required for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) via `X-CSRF-Token` header. -- Bootstrap CSRF via `GET /api/v1/auth/csrf` (works for anonymous and authenticated sessions). -- You can also fetch `csrf_token` from `GET /api/v1/auth/me` after login. -- Manual run idempotency is supported via `Idempotency-Key` header on `POST /api/v1/runs/manual`. -- Response envelopes: - - Success: `{"data": ..., "meta": {"request_id": "..."}}` - - Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` -- Initial API domains exposed: - - `auth`: `/auth/csrf`, `/auth/login`, `/auth/me`, `/auth/change-password`, `/auth/logout` - - `admin users`: `/admin/users` (list/create), `/admin/users/{id}/active`, `/admin/users/{id}/reset-password` - - `scholars`: list/create/toggle/delete - - `settings`: get/update - - `runs`: list/detail/manual trigger + queue list/retry/drop/clear - - `publications`: list + mark-all-read -- Queue action semantics: - - `retry` is valid only for dropped items; retrying/queued states return `409`. - - `drop` returns the updated queue item (status `dropped`); dropping an already dropped item returns `409`. - - `clear` only works for dropped items and returns `{queue_item_id, previous_status, status="cleared"}`. - ## Logging -- Default output is concise console logs to stdout. -- Timestamp format is compact UTC: `YYYY-MM-DD HH:MM:SSZ`. -- Every request gets an `X-Request-ID` (propagated if caller provides one). -- Sensitive fields are redacted before log emission (`password`, `csrf_token`, `cookie`, etc.). -- Configure via env: - - `LOG_LEVEL` (default `INFO`) - - `LOG_FORMAT` (`console` or `json`, default `console`) - - `LOG_REQUESTS` (`1`/`0`, default `1`) - - `LOG_UVICORN_ACCESS` (`1`/`0`, default `0`) - - `LOG_REQUEST_SKIP_PATHS` (comma-separated prefixes, default `/healthz,/static/`) - - `LOG_REDACT_FIELDS` (comma-separated additional keys) - - `SCHEDULER_ENABLED` (`1`/`0`, default `1`) - - `SCHEDULER_TICK_SECONDS` (default `60`) - - `INGESTION_NETWORK_ERROR_RETRIES` (default `1`) - - `INGESTION_RETRY_BACKOFF_SECONDS` (default `1.0`) - - `INGESTION_MAX_PAGES_PER_SCHOLAR` (default `30`) - - `INGESTION_PAGE_SIZE` (default `100`) - - `INGESTION_CONTINUATION_QUEUE_ENABLED` (`1`/`0`, default `1`) - - `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` (default `120`) - - `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` (default `3600`) - - `INGESTION_CONTINUATION_MAX_ATTEMPTS` (default `6`) - - `SCHEDULER_QUEUE_BATCH_SIZE` (default `10`) +Configurable env vars: -## Optional Local `uv` Workflow - -```bash -uv sync --extra dev -uv run pytest -uv run uvicorn app.main:app --reload -``` - -Update the lockfile after dependency changes: - -```bash -uv lock -``` - -## Theming - -- Theme registry: `app/theme.py` -- Semantic tokens: `app/static/theme.css` -- App styling: `app/static/app.css` -- Theme persistence: `app/static/theme.js` -- Dashboard presentation contract: `app/presentation/dashboard.py` +- `LOG_LEVEL` (default `INFO`) +- `LOG_FORMAT` (`console` or `json`, default `console`) +- `LOG_REQUESTS` (`1`/`0`, default `1`) +- `LOG_UVICORN_ACCESS` (`1`/`0`, default `0`) +- `LOG_REQUEST_SKIP_PATHS` (default `/healthz`) +- `LOG_REDACT_FIELDS` (additional redact keys) ## Project Layout ```text -app/ FastAPI app, auth/security modules, templates, services, DB wiring, presentation view-models -app/web/ Router modules, shared web helpers, and request middleware +app/ FastAPI app (API routers, auth, services, db, middleware) alembic/ Migration environment and versions -scripts/ Entrypoint, DB wait, bootstrap, smoke automation +scripts/ Entrypoint, db wait/bootstrap, smoke automation tests/ Unit, integration, and smoke suites +planning/ Scope and implementation planning notes ``` diff --git a/app/api/deps.py b/app/api/deps.py index 9dd3a68..f9c5377 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -4,16 +4,16 @@ from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import ApiException +from app.auth import runtime as auth_runtime from app.db.models import User from app.db.session import get_db_session -from app.web import common as web_common async def get_api_current_user( request: Request, db_session: AsyncSession = Depends(get_db_session), ) -> User: - current_user = await web_common.get_authenticated_user(request, db_session) + current_user = await auth_runtime.get_authenticated_user(request, db_session) if current_user is None: raise ApiException( status_code=401, @@ -33,4 +33,3 @@ async def get_api_admin_user( message="Admin access required.", ) return current_user - diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 3b53dc8..1a76ced 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -18,13 +18,13 @@ from app.api.schemas import ( ) 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.security.csrf import ensure_csrf_token from app.services import users as user_service -from app.web import common as web_common logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ async def login( auth_service: AuthService = Depends(get_auth_service), rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), ): - limiter_key = web_common.login_rate_limit_key(request, payload.email) + limiter_key = auth_runtime.login_rate_limit_key(request, payload.email) decision = rate_limiter.check(limiter_key) normalized_email = payload.email.strip().lower() if not decision.allowed: @@ -142,7 +142,7 @@ async def get_csrf_bootstrap( request: Request, db_session: AsyncSession = Depends(get_db_session), ): - current_user = await web_common.get_authenticated_user(request, db_session) + current_user = await auth_runtime.get_authenticated_user(request, db_session) return success_payload( request, data={ @@ -213,8 +213,8 @@ async def logout( request: Request, db_session: AsyncSession = Depends(get_db_session), ): - current_user = await web_common.get_authenticated_user(request, db_session) - web_common.invalidate_session(request) + current_user = await auth_runtime.get_authenticated_user(request, db_session) + auth_runtime.invalidate_session(request) logger.info( "api.auth.logout", extra={ diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index 00f18d1..55bffa3 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -24,7 +24,7 @@ from app.services import ingestion as ingestion_service from app.services import runs as run_service from app.services import user_settings as user_settings_service from app.settings import settings -from app.web.deps import get_ingestion_service +from app.api.runtime_deps import get_ingestion_service logger = logging.getLogger(__name__) diff --git a/app/web/deps.py b/app/api/runtime_deps.py similarity index 99% rename from app/web/deps.py rename to app/api/runtime_deps.py index 001e2b8..167e2ef 100644 --- a/app/web/deps.py +++ b/app/api/runtime_deps.py @@ -14,4 +14,3 @@ def get_ingestion_service( source: scholar_source_service.ScholarSource = Depends(get_scholar_source), ) -> ingestion_service.ScholarIngestionService: return ingestion_service.ScholarIngestionService(source=source) - diff --git a/app/auth/runtime.py b/app/auth/runtime.py new file mode 100644 index 0000000..5bb1a74 --- /dev/null +++ b/app/auth/runtime.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import logging + +from fastapi import Request +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.security.csrf import CSRF_SESSION_KEY +from app.services import users as user_service + +logger = logging.getLogger(__name__) + + +def invalidate_session(request: Request) -> None: + clear_session_user(request) + request.session.pop(CSRF_SESSION_KEY, None) + + +async def get_authenticated_user( + request: Request, + db_session: AsyncSession, +) -> User | None: + session_user = get_session_user(request) + if session_user is None: + return None + + 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, + }, + ) + invalidate_session(request) + return None + + if user.email != session_user.email or user.is_admin != session_user.is_admin: + set_session_user( + request, + user_id=user.id, + email=user.email, + is_admin=user.is_admin, + ) + + return user + + +def login_rate_limit_key(request: Request, email: str) -> str: + client_host = request.client.host if request.client is not None else "unknown" + normalized_email = email.strip().lower() + return f"{client_host}:{normalized_email or ''}" diff --git a/app/http/__init__.py b/app/http/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/http/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/web/middleware.py b/app/http/middleware.py similarity index 100% rename from app/web/middleware.py rename to app/http/middleware.py index 9de69a6..9d85458 100644 --- a/app/web/middleware.py +++ b/app/http/middleware.py @@ -1,8 +1,8 @@ from __future__ import annotations from secrets import token_urlsafe -import time import logging +import time from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request diff --git a/app/main.py b/app/main.py index 75ee94d..b6942dc 100644 --- a/app/main.py +++ b/app/main.py @@ -2,30 +2,19 @@ from __future__ import annotations from contextlib import asynccontextmanager -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles +from fastapi import FastAPI, HTTPException from starlette.middleware.sessions import SessionMiddleware from app.api.errors import register_api_exception_handlers 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.http.middleware import RequestLoggingMiddleware, parse_skip_paths from app.logging_config import configure_logging, parse_redact_fields from app.security.csrf import CSRFMiddleware from app.services.scheduler import SchedulerService from app.settings import settings -from app.web import common as web_common -from app.web.deps import get_ingestion_service, get_scholar_source -from app.web.middleware import RequestLoggingMiddleware, parse_skip_paths -from app.web.routers import ( - admin, - auth, - dashboard, - health, - publications, - runs, - scholars, - settings as settings_router, -) configure_logging( level=settings.log_level, @@ -71,17 +60,11 @@ app.add_middleware( log_requests=settings.log_requests, skip_paths=parse_skip_paths(settings.log_request_skip_paths), ) -app.mount("/static", StaticFiles(directory="app/static"), name="static") - app.include_router(api_router) -app.include_router(auth.router) -app.include_router(admin.router) -app.include_router(scholars.router) -app.include_router(settings_router.router) -app.include_router(runs.router) -app.include_router(publications.router) -app.include_router(dashboard.router) -app.include_router(health.router) -# Backward-compatible export kept for tests and any existing local scripts. -_get_authenticated_user = web_common.get_authenticated_user + +@app.get("/healthz") +async def healthz() -> dict[str, str]: + if await check_database(): + return {"status": "ok"} + raise HTTPException(status_code=500, detail="database unavailable") diff --git a/app/presentation/__init__.py b/app/presentation/__init__.py deleted file mode 100644 index d3d8cc8..0000000 --- a/app/presentation/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Presentation-layer view models used by template routes.""" - diff --git a/app/presentation/dashboard.py b/app/presentation/dashboard.py deleted file mode 100644 index 2f5d609..0000000 --- a/app/presentation/dashboard.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Sequence - -from app.db.models import CrawlRun -from app.services.publications import UnreadPublicationItem - -SECTION_TEMPLATES: tuple[str, ...] = ( - "dashboard/_run_controls.html", - "dashboard/_unread_publications.html", - "dashboard/_run_history.html", -) - - -@dataclass(frozen=True) -class RunControlsViewModel: - request_delay_seconds: int - queue_queued_count: int - queue_retrying_count: int - queue_dropped_count: int - run_manual_action: str = "/runs/manual" - mark_all_read_action: str = "/publications/mark-all-read" - - -@dataclass(frozen=True) -class UnreadPublicationViewModel: - title: str - scholar_label: str - year_display: str - citation_count: int - venue_text: str | None - pub_url: str | None - - -@dataclass(frozen=True) -class RunHistoryItemViewModel: - run_id: int | None - detail_url: str | None - started_at_display: str - status_label: str - status_badge: str - scholar_count: int - new_publication_count: int - - -@dataclass(frozen=True) -class DashboardViewModel: - section_templates: tuple[str, ...] - run_controls: RunControlsViewModel - unread_publications: list[UnreadPublicationViewModel] - run_history: list[RunHistoryItemViewModel] - - -def build_dashboard_view_model( - *, - unread_publications: Sequence[UnreadPublicationItem], - recent_runs: Sequence[CrawlRun], - request_delay_seconds: int, - queue_counts: dict[str, int] | None = None, -) -> DashboardViewModel: - queue_counts = queue_counts or {} - return DashboardViewModel( - section_templates=SECTION_TEMPLATES, - run_controls=RunControlsViewModel( - request_delay_seconds=request_delay_seconds, - queue_queued_count=int(queue_counts.get("queued", 0)), - queue_retrying_count=int(queue_counts.get("retrying", 0)), - queue_dropped_count=int(queue_counts.get("dropped", 0)), - ), - unread_publications=[ - UnreadPublicationViewModel( - title=item.title, - scholar_label=item.scholar_label, - year_display=str(item.year) if item.year is not None else "-", - citation_count=item.citation_count, - venue_text=item.venue_text, - pub_url=item.pub_url, - ) - for item in unread_publications - ], - run_history=[ - RunHistoryItemViewModel( - run_id=run.id, - detail_url=f"/runs/{run.id}" if run.id is not None else None, - started_at_display=_format_started_at(run.start_dt), - status_label=run.status.value, - status_badge=_status_badge(run.status.value), - scholar_count=run.scholar_count, - new_publication_count=run.new_pub_count, - ) - for run in recent_runs - ], - ) - - -def _status_badge(status: str) -> str: - if status == "success": - return "ok" - if status == "failed": - return "danger" - return "warn" - - -def _format_started_at(dt: datetime) -> str: - local_aware = dt.astimezone(timezone.utc) - return local_aware.strftime("%Y-%m-%d %H:%M UTC") diff --git a/app/security/csrf.py b/app/security/csrf.py index 1f5c1a1..70eecac 100644 --- a/app/security/csrf.py +++ b/app/security/csrf.py @@ -77,8 +77,6 @@ class CSRFMiddleware(BaseHTTPMiddleware): if request.method in SAFE_METHODS: return True path = request.url.path - if path.startswith("/static/"): - return True return path in self._exempt_paths def _is_form_payload(self, request: Request) -> bool: diff --git a/app/settings.py b/app/settings.py index 826fa13..79fd40c 100644 --- a/app/settings.py +++ b/app/settings.py @@ -48,7 +48,7 @@ class Settings: log_format: str = _env_str("LOG_FORMAT", "console") log_requests: bool = _env_bool("LOG_REQUESTS", True) log_uvicorn_access: bool = _env_bool("LOG_UVICORN_ACCESS", False) - log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz,/static/") + log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz") log_redact_fields: str = os.getenv("LOG_REDACT_FIELDS", "") scheduler_enabled: bool = _env_bool("SCHEDULER_ENABLED", True) scheduler_tick_seconds: int = _env_int("SCHEDULER_TICK_SECONDS", 60) diff --git a/app/static/app.css b/app/static/app.css deleted file mode 100644 index 3b332c5..0000000 --- a/app/static/app.css +++ /dev/null @@ -1,501 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100vh; - color: var(--md-sys-color-on-surface); - font-family: var(--font-body); - background: - radial-gradient(circle at 10% 5%, var(--md-sys-color-primary-container) 0%, transparent 30%), - radial-gradient(circle at 90% 0%, var(--md-sys-color-secondary-container) 0%, transparent 28%), - var(--md-sys-color-surface); -} - -.loading-indicator { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 3px; - z-index: 40; - transform: scaleX(0); - transform-origin: left; - background: linear-gradient(90deg, var(--md-sys-color-primary), var(--md-sys-color-tertiary)); - opacity: 0; -} - -body.is-page-loading .loading-indicator { - opacity: 1; - animation: loading-bar 1.2s ease-in-out infinite; -} - -.app-backdrop { - position: fixed; - inset: 0; - pointer-events: none; - background: - linear-gradient(130deg, var(--md-sys-color-primary-tint), transparent 40%), - linear-gradient(230deg, var(--md-sys-color-surface-tint), transparent 45%); - opacity: 0.28; -} - -.top-app-bar { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - gap: 0.8rem; - flex-wrap: wrap; - padding: 0.8rem 1.1rem; - border-bottom: 1px solid var(--md-sys-color-outline-variant); - backdrop-filter: blur(9px); - background: color-mix(in srgb, var(--md-sys-color-surface) 88%, white 12%); -} - -.brand-wrap { - display: flex; - align-items: center; - gap: 0.65rem; -} - -.brand { - color: var(--md-sys-color-primary); - text-decoration: none; - font-family: var(--font-heading); - font-weight: 700; - font-size: 1.2rem; - letter-spacing: 0.02em; -} - -.session-user { - margin: 0; - padding: 0.24rem 0.58rem; - border-radius: 999px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-high); - color: var(--md-sys-color-on-surface-variant); - font-size: 0.78rem; -} - -.site-nav { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; -} - -.nav-link { - color: var(--md-sys-color-on-surface-variant); - text-decoration: none; - padding: 0.42rem 0.78rem; - border-radius: 999px; - border: 1px solid transparent; - font-size: 0.92rem; -} - -.nav-link:hover { - background: var(--md-sys-color-surface-container); - border-color: var(--md-sys-color-outline-variant); -} - -.nav-link.is-active { - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - border-color: transparent; -} - -.header-actions { - margin-left: auto; - display: flex; - align-items: center; - gap: 0.7rem; -} - -.theme-control-wrap { - display: flex; - align-items: center; - gap: 0.45rem; -} - -.theme-control-wrap label { - color: var(--md-sys-color-on-surface-variant); - font-size: 0.82rem; -} - -.theme-control { - border: 1px solid var(--md-sys-color-outline); - border-radius: 999px; - background: var(--md-sys-color-surface-container-low); - color: var(--md-sys-color-on-surface); - padding: 0.32rem 0.64rem; - font: inherit; -} - -.logout-form { - margin: 0; -} - -.page-shell { - position: relative; - display: grid; - gap: 0.9rem; - width: min(1100px, calc(100vw - 1.4rem)); - margin: 0 auto; - padding: 1.1rem 0 1.6rem; -} - -.flash { - margin: 0; - padding: 0.72rem 0.82rem; - border-radius: 14px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container); -} - -.flash-notice { - color: var(--md-sys-color-on-tertiary-container); - border-color: var(--md-sys-color-tertiary-container); - background: color-mix(in srgb, var(--md-sys-color-tertiary-container) 30%, white 70%); -} - -.flash-error { - color: var(--md-sys-color-on-error-container); - border-color: var(--md-sys-color-error-container); - background: color-mix(in srgb, var(--md-sys-color-error-container) 25%, white 75%); -} - -.hero, -.sandbox { - border-radius: 24px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-low); - box-shadow: var(--elevation-card); -} - -.hero { - padding: 1.3rem; -} - -.sandbox { - padding: 1.05rem; -} - -.eyebrow { - margin: 0; - font-size: 0.78rem; - color: var(--md-sys-color-primary); - text-transform: uppercase; - letter-spacing: 0.11em; - font-weight: 600; -} - -h1 { - margin: 0.28rem 0 0.5rem; - color: var(--md-sys-color-on-surface); - font-family: var(--font-heading); - font-size: clamp(1.45rem, 3vw, 2.05rem); - line-height: 1.15; -} - -h2 { - margin: 0; - color: var(--md-sys-color-on-surface); - font-family: var(--font-heading); - font-size: 1.08rem; -} - -.lede { - margin: 0; - color: var(--md-sys-color-on-surface-variant); - max-width: 68ch; -} - -.section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.55rem; - margin-bottom: 0.7rem; - flex-wrap: wrap; -} - -.stat-strip { - margin-top: 0.95rem; - display: grid; - gap: 0.75rem; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); -} - -.stat-item { - margin: 0; - padding: 0.72rem 0.78rem; - border-radius: 16px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container); -} - -.stat-label { - margin: 0; - color: var(--md-sys-color-on-surface-variant); - font-size: 0.8rem; -} - -.stat-value { - margin: 0.2rem 0 0; - color: var(--md-sys-color-on-surface); - font-size: 1.36rem; - font-weight: 700; -} - -form { - display: grid; - gap: 0.45rem; -} - -.stack-form { - max-width: 560px; -} - -label { - font-size: 0.9rem; - color: var(--md-sys-color-on-surface-variant); -} - -input, -select { - width: 100%; - border: 1px solid var(--md-sys-color-outline); - border-radius: 12px; - padding: 0.56rem 0.64rem; - font: inherit; - color: var(--md-sys-color-on-surface); - background: var(--md-sys-color-surface); -} - -input:focus, -select:focus { - outline: 2px solid color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent); - border-color: var(--md-sys-color-primary); -} - -.checkbox-row { - display: flex; - align-items: center; - gap: 0.54rem; -} - -.checkbox-row input { - width: auto; -} - -button, -.button { - border: 0; - border-radius: 999px; - padding: 0.54rem 0.86rem; - font: inherit; - font-weight: 600; - color: var(--md-sys-color-on-primary); - background: var(--md-sys-color-primary); - cursor: pointer; -} - -button:hover, -.button:hover { - filter: brightness(0.97); -} - -button:disabled, -.button:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.button-text { - color: var(--md-sys-color-primary); - background: transparent; - border: 1px solid var(--md-sys-color-outline-variant); -} - -.danger-button { - color: var(--md-sys-color-on-error); - background: var(--md-sys-color-error); -} - -.inline-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.45rem; -} - -.inline-actions form { - display: inline-flex; - gap: 0.42rem; - align-items: center; -} - -.inline-reset-form input { - min-width: 160px; -} - -form.is-loading button[type="submit"], -form.is-loading input[type="submit"] { - opacity: 0.75; -} - -table { - width: 100%; - border-collapse: collapse; - border-radius: 12px; - overflow: hidden; - background: var(--md-sys-color-surface); -} - -th, -td { - padding: 0.54rem; - text-align: left; - border-bottom: 1px solid var(--md-sys-color-outline-variant); - vertical-align: top; -} - -thead th { - color: var(--md-sys-color-on-surface-variant); - font-size: 0.84rem; - font-weight: 600; - background: var(--md-sys-color-surface-container-high); -} - -tbody tr:last-child td { - border-bottom: 0; -} - -a { - color: var(--md-sys-color-primary); -} - -.link-button { - display: inline-flex; - align-items: center; - padding: 0.45rem 0.78rem; - border: 1px solid var(--md-sys-color-outline-variant); - border-radius: 999px; - color: var(--md-sys-color-primary); - text-decoration: none; - background: var(--md-sys-color-surface-container-low); -} - -.link-button.is-active { - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - border-color: transparent; -} - -.badge { - display: inline-block; - border-radius: 999px; - padding: 0.16rem 0.48rem; - font-size: 0.76rem; - text-transform: lowercase; - border: 1px solid var(--md-sys-color-outline-variant); -} - -.badge.ok { - color: var(--md-sys-color-on-tertiary-container); - background: var(--md-sys-color-tertiary-container); - border-color: transparent; -} - -.badge.warn, -.badge.danger { - color: var(--md-sys-color-on-error-container); - background: var(--md-sys-color-error-container); - border-color: transparent; -} - -.helper-text { - margin: 0.24rem 0 0; - color: var(--md-sys-color-on-surface-variant); - font-size: 0.82rem; -} - -.auth-card { - max-width: 520px; -} - -.auth-form { - max-width: 360px; -} - -.form-error { - margin: 0.5rem 0 0; - color: var(--md-sys-color-error); - font-weight: 600; -} - -.form-note { - margin: 0.35rem 0 0; - color: var(--md-sys-color-on-surface-variant); -} - -code { - font-family: var(--font-code); - background: var(--md-sys-color-surface-container-high); - border-radius: 6px; - padding: 0.1rem 0.25rem; -} - -pre { - margin: 0.6rem 0 0; - border-radius: 12px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-high); - color: var(--md-sys-color-on-surface); - padding: 0.75rem; - white-space: pre-wrap; - word-break: break-word; -} - -@media (max-width: 880px) { - .top-app-bar { - padding: 0.7rem 0.8rem; - } - - .header-actions { - width: 100%; - justify-content: space-between; - margin-left: 0; - } - - .page-shell { - width: min(100vw, calc(100vw - 0.9rem)); - padding-top: 0.8rem; - } - - .hero, - .sandbox { - border-radius: 18px; - padding: 0.9rem; - } - - .inline-actions form { - width: 100%; - justify-content: flex-start; - } -} - -@keyframes loading-bar { - 0% { - transform: scaleX(0.1); - } - 50% { - transform: scaleX(0.7); - } - 100% { - transform: scaleX(1); - } -} diff --git a/app/static/app.js b/app/static/app.js deleted file mode 100644 index a2d6d80..0000000 --- a/app/static/app.js +++ /dev/null @@ -1,76 +0,0 @@ -(() => { - const body = document.body; - if (!body) { - return; - } - - const defaultLoadingText = "Working..."; - - const shouldHandleAnchor = (anchor) => { - if (!anchor || !anchor.getAttribute) { - return false; - } - const href = anchor.getAttribute("href"); - if (!href) { - return false; - } - if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) { - return false; - } - if (anchor.hasAttribute("download")) { - return false; - } - if ((anchor.getAttribute("target") || "").toLowerCase() === "_blank") { - return false; - } - return true; - }; - - document.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) { - return; - } - const anchor = target.closest("a"); - if (!shouldHandleAnchor(anchor)) { - return; - } - body.classList.add("is-page-loading"); - }); - - document.addEventListener("submit", (event) => { - const form = event.target; - if (!(form instanceof HTMLFormElement)) { - return; - } - if (form.dataset.noLoading === "1") { - return; - } - - body.classList.add("is-page-loading"); - form.classList.add("is-loading"); - - const submitElements = form.querySelectorAll("button[type='submit'], input[type='submit']"); - submitElements.forEach((element) => { - if (element.hasAttribute("disabled")) { - return; - } - element.setAttribute("disabled", "disabled"); - if (element instanceof HTMLInputElement) { - const loadingText = element.dataset.loadingText || defaultLoadingText; - element.dataset.originalValue = element.value; - element.value = loadingText; - return; - } - if (element instanceof HTMLButtonElement) { - const loadingText = element.dataset.loadingText || defaultLoadingText; - element.dataset.originalText = element.textContent || ""; - element.textContent = loadingText; - } - }); - }); - - window.addEventListener("pageshow", () => { - body.classList.remove("is-page-loading"); - }); -})(); diff --git a/app/static/theme.css b/app/static/theme.css deleted file mode 100644 index 8506a29..0000000 --- a/app/static/theme.css +++ /dev/null @@ -1,98 +0,0 @@ -:root { - --font-body: "Plus Jakarta Sans", "Segoe UI", sans-serif; - --font-heading: "DM Serif Text", "Georgia", serif; - --font-code: "JetBrains Mono", "Fira Code", monospace; - - --md-sys-color-primary: #73594d; - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: #fbded0; - --md-sys-color-on-primary-container: #2b160f; - --md-sys-color-primary-tint: rgba(115, 89, 77, 0.18); - - --md-sys-color-secondary: #6f5b52; - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: #f8ddd2; - --md-sys-color-on-secondary-container: #281913; - - --md-sys-color-tertiary: #5b6550; - --md-sys-color-on-tertiary: #ffffff; - --md-sys-color-tertiary-container: #ddebcf; - --md-sys-color-on-tertiary-container: #17200f; - - --md-sys-color-error: #b3261e; - --md-sys-color-on-error: #ffffff; - --md-sys-color-error-container: #f9dedc; - --md-sys-color-on-error-container: #410e0b; - - --md-sys-color-surface: #fff8f5; - --md-sys-color-surface-tint: rgba(111, 87, 74, 0.12); - --md-sys-color-surface-container-low: #fffaf8; - --md-sys-color-surface-container: #fdf2ec; - --md-sys-color-surface-container-high: #f8e9e1; - --md-sys-color-on-surface: #221a17; - --md-sys-color-on-surface-variant: #55423a; - --md-sys-color-outline: #85736b; - --md-sys-color-outline-variant: #d8c2b8; - - --elevation-card: 0 10px 30px rgba(55, 39, 28, 0.08); -} - -:root[data-theme="fjord"] { - --md-sys-color-primary: #2f6678; - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: #cde9f4; - --md-sys-color-on-primary-container: #051f28; - --md-sys-color-primary-tint: rgba(47, 102, 120, 0.2); - - --md-sys-color-secondary: #4d626b; - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: #d0e6f2; - --md-sys-color-on-secondary-container: #091f27; - - --md-sys-color-tertiary: #456179; - --md-sys-color-on-tertiary: #ffffff; - --md-sys-color-tertiary-container: #d2e5ff; - --md-sys-color-on-tertiary-container: #031d33; - - --md-sys-color-surface: #f5fbff; - --md-sys-color-surface-tint: rgba(47, 102, 120, 0.12); - --md-sys-color-surface-container-low: #fcfeff; - --md-sys-color-surface-container: #ecf5fa; - --md-sys-color-surface-container-high: #dfeef6; - --md-sys-color-on-surface: #172126; - --md-sys-color-on-surface-variant: #3f4f57; - --md-sys-color-outline: #6f7f88; - --md-sys-color-outline-variant: #becfd8; - - --elevation-card: 0 10px 30px rgba(22, 43, 53, 0.09); -} - -:root[data-theme="spruce"] { - --md-sys-color-primary: #39684c; - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: #bcefc9; - --md-sys-color-on-primary-container: #062111; - --md-sys-color-primary-tint: rgba(57, 104, 76, 0.18); - - --md-sys-color-secondary: #4f6354; - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: #d2e8d4; - --md-sys-color-on-secondary-container: #0d1f12; - - --md-sys-color-tertiary: #3f655f; - --md-sys-color-on-tertiary: #ffffff; - --md-sys-color-tertiary-container: #c2ece4; - --md-sys-color-on-tertiary-container: #021f1a; - - --md-sys-color-surface: #f5fbf5; - --md-sys-color-surface-tint: rgba(57, 104, 76, 0.1); - --md-sys-color-surface-container-low: #fcfffb; - --md-sys-color-surface-container: #ebf4ea; - --md-sys-color-surface-container-high: #deecdd; - --md-sys-color-on-surface: #172119; - --md-sys-color-on-surface-variant: #415246; - --md-sys-color-outline: #708174; - --md-sys-color-outline-variant: #c1d3c2; - - --elevation-card: 0 10px 30px rgba(24, 48, 32, 0.08); -} diff --git a/app/static/theme.js b/app/static/theme.js deleted file mode 100644 index 47b95a1..0000000 --- a/app/static/theme.js +++ /dev/null @@ -1,56 +0,0 @@ -(() => { - const STORAGE_KEY = "scholarr_theme"; - const LEGACY_STORAGE_KEY = "scholar_tracker_theme"; - const root = document.documentElement; - const themeControl = document.querySelector("[data-theme-control]"); - - if (!root) { - return; - } - - const defaultTheme = root.dataset.defaultTheme || "terracotta"; - const supportedThemes = themeControl - ? Array.from(themeControl.options).map((option) => option.value) - : []; - - const isSupported = (theme) => - supportedThemes.length === 0 || supportedThemes.includes(theme); - - const applyTheme = (theme) => { - if (!isSupported(theme)) { - return; - } - root.dataset.theme = theme; - }; - - let activeTheme = defaultTheme; - try { - const savedTheme = window.localStorage.getItem(STORAGE_KEY); - const legacyTheme = window.localStorage.getItem(LEGACY_STORAGE_KEY); - if (savedTheme && isSupported(savedTheme)) { - activeTheme = savedTheme; - } else if (legacyTheme && isSupported(legacyTheme)) { - activeTheme = legacyTheme; - window.localStorage.setItem(STORAGE_KEY, legacyTheme); - } - } catch { - activeTheme = defaultTheme; - } - - applyTheme(activeTheme); - - if (!themeControl) { - return; - } - - themeControl.value = activeTheme; - themeControl.addEventListener("change", (event) => { - const selectedTheme = event.target.value; - applyTheme(selectedTheme); - try { - window.localStorage.setItem(STORAGE_KEY, selectedTheme); - } catch { - // Ignore storage errors in locked-down browsers. - } - }); -})(); diff --git a/app/templates/account_password.html b/app/templates/account_password.html deleted file mode 100644 index 687ec98..0000000 --- a/app/templates/account_password.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Account

-

Change Password

-

Use a strong password. This updates only your own account.

-
- -
-

Password Update

-
- - - - - - - - -
-
-{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index fcdd653..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - {{ page_title }} | scholarr - - - - - - -
-
- scholarr - {% if session_user %} -

{{ session_user.email }}

- {% endif %} -
- - - -
-
- - -
- {% if session_user %} -
- - -
- {% endif %} -
-
-
- {% if notice %} -

{{ notice }}

- {% endif %} - {% if page_error %} -

{{ page_error }}

- {% endif %} - {% block content %}{% endblock %} -
- - - - diff --git a/app/templates/dashboard/_run_controls.html b/app/templates/dashboard/_run_controls.html deleted file mode 100644 index 24779cd..0000000 --- a/app/templates/dashboard/_run_controls.html +++ /dev/null @@ -1,23 +0,0 @@ -
- -

Manual runs use your request delay of {{ dashboard.run_controls.request_delay_seconds }} second(s).

-

- Queue state: - {{ dashboard.run_controls.queue_queued_count }} queued, - {{ dashboard.run_controls.queue_retrying_count }} retrying, - {{ dashboard.run_controls.queue_dropped_count }} dropped. -

-
- - -
-
diff --git a/app/templates/dashboard/_run_history.html b/app/templates/dashboard/_run_history.html deleted file mode 100644 index aa6f9f1..0000000 --- a/app/templates/dashboard/_run_history.html +++ /dev/null @@ -1,38 +0,0 @@ -
-
-

Run History

- -
- {% if dashboard.run_history %} - - - - - - - - - - - {% for run in dashboard.run_history %} - - - - - - - {% endfor %} - -
StartedStatusScholarsNew Publications
- {% if run.detail_url %} - {{ run.started_at_display }} - {% else %} - {{ run.started_at_display }} - {% endif %} - {{ run.status_label }}{{ run.scholar_count }}{{ run.new_publication_count }}
- {% else %} -

No runs yet.

- {% endif %} -
diff --git a/app/templates/dashboard/_unread_publications.html b/app/templates/dashboard/_unread_publications.html deleted file mode 100644 index 1354215..0000000 --- a/app/templates/dashboard/_unread_publications.html +++ /dev/null @@ -1,47 +0,0 @@ -
-
-

New Since Last Run

-
- View All -
- - - -
-
-
- - {% if dashboard.unread_publications %} - - - - - - - - - - - {% for item in dashboard.unread_publications %} - - - - - - - {% endfor %} - -
TitleScholarYearCitations
- {% if item.pub_url %} - {{ item.title }} - {% else %} - {{ item.title }} - {% endif %} - {% if item.venue_text %} -

{{ item.venue_text }}

- {% endif %} -
{{ item.scholar_label }}{{ item.year_display }}{{ item.citation_count }}
- {% else %} -

No new publications in the latest run.

- {% endif %} -
diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index f5fd22a..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Dashboard

-

Keep up with your tracked scholars

-

Run ingestion, review fresh publications, and inspect recent outcomes from one screen.

-
-
-

New Since Last Run

-

{{ dashboard.unread_publications | length }}

-
-
-

Recent Runs

-

{{ dashboard.run_history | length }}

-
-
-

Automation Delay

-

{{ dashboard.run_controls.request_delay_seconds }}s

-
-
-
- -{% for section_template in dashboard.section_templates %} -{% include section_template %} -{% endfor %} -{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html deleted file mode 100644 index 8b0f383..0000000 --- a/app/templates/login.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Secure Access

-

Sign in

-

Use an admin-created account to access your tracked scholars and settings.

- {% if error_message %} - - {% endif %} - {% if retry_after_seconds %} -

Retry after {{ retry_after_seconds }} seconds.

- {% endif %} -
- - - - - - -
-
-{% endblock %} diff --git a/app/templates/publications.html b/app/templates/publications.html deleted file mode 100644 index 89054a2..0000000 --- a/app/templates/publications.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Publications

-

Publications

-

- Browse publications discovered in the latest run or your complete tracked library. - {% if selected_scholar %} - Current scholar filter: {{ selected_scholar.display_name or selected_scholar.scholar_id }}. - {% endif %} -

-
- New Since Last Run ({{ new_count }}) - All ({{ total_count }}) - Manage Scholars - Run Diagnostics - {% if mode == 'new' and new_count > 0 %} -
- - - -
- {% endif %} -
-
- -
-
-

Filter

-
-
- - - - - - -
- -
-

Results

-
- {% if publications %} - - - - - - - - - - - - - {% for item in publications %} - - - - - - - - - {% endfor %} - -
TitleScholarYearCitationsNew This RunStatus
- {% if item.pub_url %} - {{ item.title }} - {% else %} - {{ item.title }} - {% endif %} - {% if item.venue_text %} -

{{ item.venue_text }}

- {% endif %} -
{{ item.scholar_label }}{{ item.year if item.year is not none else "-" }}{{ item.citation_count }} - {% if item.is_new_in_latest_run %} - new - {% else %} - older - {% endif %} - - {% if item.is_read %} - read - {% else %} - new - {% endif %} -
- {% else %} -

No publications match this mode yet.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/run_detail.html b/app/templates/run_detail.html deleted file mode 100644 index 06920ee..0000000 --- a/app/templates/run_detail.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Diagnostics

-

Run #{{ run.id }}

-

Detailed state and failure context for this execution.

-
- -
-

Summary

- - - - - - - - - - - - -
Started{{ run.started_at }}
Finished{{ run.finished_at }}
Status{{ run.status }}
Trigger{{ run.trigger_type }}
Scholars{{ run.scholar_count }}
New Publications{{ run.new_publication_count }}
Succeeded{{ run_summary.succeeded_count or 0 }}
Failed{{ run_summary.failed_count or 0 }}
Partial{{ run_summary.partial_count or 0 }}
- {% if run_summary.failed_state_counts or run_summary.failed_reason_counts %} -
- Failure Breakdown -
{{ {"failed_state_counts": run_summary.failed_state_counts, "failed_reason_counts": run_summary.failed_reason_counts} | tojson(indent=2) }}
-
- {% endif %} -
- -
-

Scholar Results

- {% if scholar_results %} - - - - - - - - - - - - - - - - {% for item in scholar_results %} - - - - - - - - - - - - {% if item.debug %} - - - - {% endif %} - {% endfor %} - -
ScholarOutcomeStateReasonPublicationsPagesAttemptsContinuationWarnings
{{ item.scholar_id }}{{ item.outcome or "-" }}{{ item.state }}{{ item.state_reason or "-" }}{{ item.publication_count }}{{ item.pages_fetched or 0 }} / {{ item.pages_attempted or 0 }}{{ item.attempt_count or 1 }} - {% if item.continuation_enqueued %} - queued -

- reason: {{ item.continuation_reason or "-" }}, - cstart: {{ item.continuation_cstart or "-" }} -

- {% elif item.continuation_cleared %} - cleared - {% else %} - - - {% endif %} -
- {% if item.warnings %} - {{ item.warnings | join(", ") }} - {% else %} - - - {% endif %} -
-
- Debug Context -
{{ item.debug | tojson(indent=2) }}
-
-
- {% else %} -

No scholar result details were captured.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/runs.html b/app/templates/runs.html deleted file mode 100644 index 6a153c1..0000000 --- a/app/templates/runs.html +++ /dev/null @@ -1,129 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Runs

-

Run History

-

Review execution outcomes and drill into run diagnostics when needed.

-
- -
-

Filters

-
- - - {% if failed_only %} - Clear - {% endif %} -
-
- -
-

Recent Runs

- {% if runs %} - - - - - - - - - - - - - - {% for run in runs %} - - - - - - - - - - {% endfor %} - -
IDStartedStatusTriggerScholarsNew PublicationsFailures
#{{ run.id }}{{ run.started_at }}{{ run.status }}{{ run.trigger_type }}{{ run.scholar_count }}{{ run.new_publication_count }}{{ run.failed_count }} failed / {{ run.partial_count }} partial
- {% else %} -

No runs match the current filter.

- {% endif %} -
- -
-
-

Continuation Queue

- Focus Failed Runs -
- {% if queue_items %} - - - - - - - - - - - - - - {% for item in queue_items %} - - - - - - - - - - {% endfor %} - -
ScholarStatusReasonAttemptsNext AttemptLast ErrorActions
- {{ item.scholar_label }} -

resume cstart: {{ item.resume_cstart }}

-
{{ item.status }} - {% if item.status == "dropped" and item.dropped_reason %} - {{ item.dropped_reason }} - {% else %} - {{ item.reason }} - {% endif %} - {{ item.attempt_count }}{{ item.next_attempt_at }} - {% if item.last_error %} -
- show -
{{ item.last_error }}
-
- {% else %} - - - {% endif %} -
-
-
- - -
- {% if item.status != "dropped" %} -
- - -
- {% endif %} -
- - -
-
-
- {% else %} -

No queued continuation items.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/scholars.html b/app/templates/scholars.html deleted file mode 100644 index 0cc7821..0000000 --- a/app/templates/scholars.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Scholars

-

Your Scholars

-

Add, disable, or remove profiles scoped to your account.

-
- -
-

Add Scholar

-
- - - - - - -
-
- -
-

Tracked Scholars

- {% if scholars %} - - - - - - - - - - - - - {% for scholar in scholars %} - - - - - - - - - {% endfor %} - -
NameScholar IDStatusLast RunPublicationsActions
{{ scholar.display_name }}{{ scholar.scholar_id }} - {% if scholar.is_enabled %} - enabled - {% else %} - disabled - {% endif %} - {% if scholar.baseline_completed %} -

baseline complete

- {% else %} -

awaiting first run

- {% endif %} -
- - {{ scholar.last_run_status }} - -

{{ scholar.last_run_dt }}

-
-
- New - All -
-
-
-
- - -
-
- - -
-
-
- {% else %} -

No scholars tracked yet.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html deleted file mode 100644 index c07d064..0000000 --- a/app/templates/settings.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Settings

-

Your Settings

-

Control automation cadence and request pacing for your own account.

-
- -
-

Automation Settings

-
- - - - - - - - - - -
-
-{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html deleted file mode 100644 index 34b4b7d..0000000 --- a/app/templates/users.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Admin

-

User Management

-

Admin-only controls for account creation, activation, and password resets.

-
- -
-

Create User

-
- - - - - - - -
-
- -
-

Users

- - - - - - - - - - - {% for user in users %} - - - - - - - {% endfor %} - -
EmailRoleStatusActions
{{ user.email }} - {% if user.is_admin %} - admin - {% else %} - user - {% endif %} - - {% if user.is_active %} - active - {% else %} - inactive - {% endif %} - -
-
- - -
-
- - - -
-
-
-
-{% endblock %} diff --git a/app/theme.py b/app/theme.py deleted file mode 100644 index 78e152b..0000000 --- a/app/theme.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import Final - - -@dataclass(frozen=True) -class ThemeDefinition: - slug: str - label: str - - -THEMES: Final[tuple[ThemeDefinition, ...]] = ( - ThemeDefinition(slug="terracotta", label="Terracotta"), - ThemeDefinition(slug="fjord", label="Fjord"), - ThemeDefinition(slug="spruce", label="Spruce"), -) -_THEME_SLUGS: Final[frozenset[str]] = frozenset(theme.slug for theme in THEMES) -DEFAULT_THEME: Final[str] = THEMES[0].slug - - -def resolve_theme(candidate: str | None) -> str: - if candidate and candidate in _THEME_SLUGS: - return candidate - return DEFAULT_THEME - diff --git a/app/web/__init__.py b/app/web/__init__.py deleted file mode 100644 index 4403b00..0000000 --- a/app/web/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Web-layer routers, middleware, and shared helpers.""" - diff --git a/app/web/common.py b/app/web/common.py deleted file mode 100644 index 8c03915..0000000 --- a/app/web/common.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import logging -from urllib.parse import urlencode - -from fastapi import Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.session import ( - SessionUser, - clear_session_user, - get_session_user, - set_session_user, -) -from app.db.models import User -from app.security.csrf import CSRF_SESSION_KEY, ensure_csrf_token -from app.services import users as user_service -from app.theme import THEMES - -logger = logging.getLogger(__name__) - -templates = Jinja2Templates(directory="app/templates") - - -def to_session_user(user: User | None) -> SessionUser | None: - if user is None: - return None - return SessionUser(id=user.id, email=user.email, is_admin=user.is_admin) - - -def build_template_context( - request: Request, - *, - page_title: str, - active_nav: str, - theme_name: str, - session_user: SessionUser | None, - notice: str | None = None, - page_error: str | None = None, -) -> dict[str, object]: - return { - "active_nav": active_nav, - "page_title": page_title, - "theme_name": theme_name, - "themes": THEMES, - "session_user": session_user, - "csrf_token": ensure_csrf_token(request), - "notice": notice, - "page_error": page_error, - } - - -def redirect_with_message( - path: str, - *, - notice: str | None = None, - error: str | None = None, -) -> RedirectResponse: - params: dict[str, str] = {} - if notice: - params["notice"] = notice - if error: - params["error"] = error - if params: - path = f"{path}?{urlencode(params)}" - return RedirectResponse(path, status_code=status.HTTP_303_SEE_OTHER) - - -def redirect_to_login() -> RedirectResponse: - return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) - - -def invalidate_session(request: Request) -> None: - clear_session_user(request) - request.session.pop(CSRF_SESSION_KEY, None) - - -async def get_authenticated_user( - request: Request, - db_session: AsyncSession, -) -> User | None: - session_user = get_session_user(request) - if session_user is None: - return None - - 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, - }, - ) - invalidate_session(request) - return None - - if user.email != session_user.email or user.is_admin != session_user.is_admin: - set_session_user( - request, - user_id=user.id, - email=user.email, - is_admin=user.is_admin, - ) - - return user - - -def login_rate_limit_key(request: Request, email: str) -> str: - client_host = request.client.host if request.client is not None else "unknown" - normalized_email = email.strip().lower() - return f"{client_host}:{normalized_email or ''}" - - -def render_login_page( - request: Request, - *, - theme_name: str, - error_message: str | None = None, - status_code: int = status.HTTP_200_OK, - retry_after_seconds: int | None = None, -) -> HTMLResponse: - context = build_template_context( - request, - page_title="Login", - active_nav="login", - theme_name=theme_name, - session_user=None, - ) - context["error_message"] = error_message - context["retry_after_seconds"] = retry_after_seconds - return templates.TemplateResponse( - request=request, - name="login.html", - context=context, - status_code=status_code, - ) - diff --git a/app/web/routers/__init__.py b/app/web/routers/__init__.py deleted file mode 100644 index cbf15d9..0000000 --- a/app/web/routers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Application routers grouped by concern.""" - diff --git a/app/web/routers/admin.py b/app/web/routers/admin.py deleted file mode 100644 index fda4f45..0000000 --- a/app/web/routers/admin.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.deps import get_auth_service -from app.auth.service import AuthService -from app.db.session import get_db_session -from app.services import users as user_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -async def _render_users_page( - request: Request, - *, - db_session: AsyncSession, - current_user, - theme_name: str, - notice: str | None = None, - page_error: str | None = None, - status_code: int = status.HTTP_200_OK, - form_email: str = "", - form_is_admin: bool = False, -) -> HTMLResponse: - users = await user_service.list_users(db_session) - context = common.build_template_context( - request, - page_title="Users", - active_nav="users", - theme_name=theme_name, - session_user=common.to_session_user(current_user), - notice=notice, - page_error=page_error, - ) - context["users"] = users - context["current_user_id"] = current_user.id - context["form_email"] = form_email - context["form_is_admin"] = form_is_admin - return common.templates.TemplateResponse( - request=request, - name="users.html", - context=context, - status_code=status_code, - ) - - -@router.get("/users", response_class=HTMLResponse) -async def users_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - return await _render_users_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=resolve_theme(theme), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - - -@router.post("/users") -async def create_user( - request: Request, - email: Annotated[str, Form()], - password: Annotated[str, Form()], - is_admin: Annotated[str | None, Form()] = None, - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - - active_theme = resolve_theme(None) - try: - validated_email = user_service.validate_email(email) - validated_password = user_service.validate_password(password) - created_user = await user_service.create_user( - db_session, - email=validated_email, - password_hash=auth_service.hash_password(validated_password), - is_admin=is_admin == "on", - ) - except user_service.UserServiceError as exc: - logger.info( - "admin.user_create_failed", - extra={ - "event": "admin.user_create_failed", - "admin_user_id": current_user.id, - "reason": "validation_or_conflict", - }, - ) - return await _render_users_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=active_theme, - page_error=str(exc), - status_code=status.HTTP_400_BAD_REQUEST, - form_email=email, - form_is_admin=is_admin == "on", - ) - - logger.info( - "admin.user_created", - extra={ - "event": "admin.user_created", - "admin_user_id": current_user.id, - "target_user_id": created_user.id, - "target_is_admin": created_user.is_admin, - }, - ) - return common.redirect_with_message( - "/users", - notice=f"User created: {created_user.email}", - ) - - -@router.post("/users/{user_id}/toggle-active") -async def toggle_user_active( - request: Request, - user_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - - target_user = await user_service.get_user_by_id(db_session, user_id) - if target_user is None: - return common.redirect_with_message("/users", error="User not found.") - if target_user.id == current_user.id and target_user.is_active: - return common.redirect_with_message("/users", error="You cannot deactivate your own account.") - - updated_user = await user_service.set_user_active( - db_session, - user=target_user, - is_active=not target_user.is_active, - ) - status_label = "activated" if updated_user.is_active else "deactivated" - logger.info( - "admin.user_active_toggled", - extra={ - "event": "admin.user_active_toggled", - "admin_user_id": current_user.id, - "target_user_id": updated_user.id, - "is_active": updated_user.is_active, - }, - ) - return common.redirect_with_message("/users", notice=f"User {status_label}: {updated_user.email}") - - -@router.post("/users/{user_id}/reset-password") -async def reset_user_password( - request: Request, - user_id: int, - new_password: Annotated[str, Form()], - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - - target_user = await user_service.get_user_by_id(db_session, user_id) - if target_user is None: - return common.redirect_with_message("/users", error="User not found.") - try: - validated_password = user_service.validate_password(new_password) - except user_service.UserServiceError as exc: - return common.redirect_with_message("/users", error=str(exc)) - - await user_service.set_user_password_hash( - db_session, - user=target_user, - password_hash=auth_service.hash_password(validated_password), - ) - logger.info( - "admin.user_password_reset", - extra={ - "event": "admin.user_password_reset", - "admin_user_id": current_user.id, - "target_user_id": target_user.id, - }, - ) - return common.redirect_with_message("/users", notice=f"Password reset: {target_user.email}") - diff --git a/app/web/routers/auth.py b/app/web/routers/auth.py deleted file mode 100644 index 8c1c819..0000000 --- a/app/web/routers/auth.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.deps import get_auth_service, get_login_rate_limiter -from app.auth.rate_limit import SlidingWindowRateLimiter -from app.auth.service import AuthService -from app.auth.session import get_session_user, set_session_user -from app.db.session import get_db_session -from app.security.csrf import ensure_csrf_token -from app.services import users as user_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.get("/login", response_class=HTMLResponse) -async def login_page(request: Request, theme: str | None = None) -> HTMLResponse: - active_theme = resolve_theme(theme) - ensure_csrf_token(request) - if get_session_user(request) is not None: - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - return common.render_login_page(request, theme_name=active_theme) - - -@router.post("/login", response_class=HTMLResponse) -async def login( - request: Request, - email: Annotated[str, Form()], - password: Annotated[str, Form()], - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), - rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), -) -> HTMLResponse: - active_theme = resolve_theme(None) - limiter_key = common.login_rate_limit_key(request, email) - decision = rate_limiter.check(limiter_key) - normalized_email = email.strip().lower() - if not decision.allowed: - logger.warning( - "auth.login_rate_limited", - extra={ - "event": "auth.login_rate_limited", - "email": normalized_email, - "retry_after_seconds": decision.retry_after_seconds, - }, - ) - response = common.render_login_page( - request, - theme_name=active_theme, - error_message="Too many login attempts. Please try again later.", - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - retry_after_seconds=decision.retry_after_seconds, - ) - response.headers["Retry-After"] = str(decision.retry_after_seconds) - return response - - user = await auth_service.authenticate_user( - db_session, - email=email, - password=password, - ) - if user is None: - rate_limiter.record_failure(limiter_key) - logger.info( - "auth.login_failed", - extra={ - "event": "auth.login_failed", - "email": normalized_email, - }, - ) - return common.render_login_page( - request, - theme_name=active_theme, - error_message="Invalid email or password.", - status_code=status.HTTP_401_UNAUTHORIZED, - ) - - rate_limiter.reset(limiter_key) - set_session_user( - request, - user_id=user.id, - email=user.email, - is_admin=user.is_admin, - ) - ensure_csrf_token(request) - logger.info( - "auth.login_succeeded", - extra={ - "event": "auth.login_succeeded", - "user_id": user.id, - "is_admin": user.is_admin, - }, - ) - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/logout") -async def logout(request: Request) -> RedirectResponse: - session_user = get_session_user(request) - common.invalidate_session(request) - logger.info( - "auth.logout", - extra={ - "event": "auth.logout", - "user_id": session_user.id if session_user else None, - }, - ) - return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) - - -@router.get("/account/password", response_class=HTMLResponse) -async def account_password_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - return common.templates.TemplateResponse( - request=request, - name="account_password.html", - context=common.build_template_context( - request, - page_title="Change Password", - active_nav="account_password", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ), - ) - - -@router.post("/account/password") -async def update_account_password( - request: Request, - current_password: Annotated[str, Form()], - new_password: Annotated[str, Form()], - confirm_password: Annotated[str, Form()], - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), -) -> RedirectResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not auth_service.verify_password( - password_hash=current_user.password_hash, - password=current_password, - ): - logger.info( - "account.password_change_failed", - extra={ - "event": "account.password_change_failed", - "user_id": current_user.id, - "reason": "invalid_current_password", - }, - ) - return common.redirect_with_message( - "/account/password", - error="Current password is incorrect.", - ) - if new_password != confirm_password: - logger.info( - "account.password_change_failed", - extra={ - "event": "account.password_change_failed", - "user_id": current_user.id, - "reason": "confirmation_mismatch", - }, - ) - return common.redirect_with_message( - "/account/password", - error="New password and confirmation do not match.", - ) - try: - validated_password = user_service.validate_password(new_password) - except user_service.UserServiceError as exc: - logger.info( - "account.password_change_failed", - extra={ - "event": "account.password_change_failed", - "user_id": current_user.id, - "reason": "validation_error", - }, - ) - return common.redirect_with_message("/account/password", error=str(exc)) - - await user_service.set_user_password_hash( - db_session, - user=current_user, - password_hash=auth_service.hash_password(validated_password), - ) - logger.info( - "account.password_changed", - extra={ - "event": "account.password_changed", - "user_id": current_user.id, - }, - ) - return common.redirect_with_message( - "/account/password", - notice="Password updated successfully.", - ) - diff --git a/app/web/routers/dashboard.py b/app/web/routers/dashboard.py deleted file mode 100644 index efbff30..0000000 --- a/app/web/routers/dashboard.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import logging - -from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import RunTriggerType -from app.db.session import get_db_session -from app.presentation import dashboard as dashboard_presenter -from app.services import ingestion as ingestion_service -from app.services import publications as publication_service -from app.services import runs as run_service -from app.services import user_settings as user_settings_service -from app.settings import settings -from app.theme import resolve_theme -from app.web import common -from app.web.deps import get_ingestion_service - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -async def _render_dashboard_page( - request: Request, - *, - db_session: AsyncSession, - current_user, - theme_name: str, - notice: str | None = None, - page_error: str | None = None, -) -> HTMLResponse: - unread_publications = await publication_service.list_new_for_latest_run_for_user( - db_session, - user_id=current_user.id, - limit=50, - ) - recent_runs = await run_service.list_recent_runs_for_user( - db_session, - user_id=current_user.id, - limit=20, - ) - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - queue_counts = await run_service.queue_status_counts_for_user( - db_session, - user_id=current_user.id, - ) - context = common.build_template_context( - request, - page_title="Dashboard", - active_nav="home", - theme_name=theme_name, - session_user=common.to_session_user(current_user), - notice=notice, - page_error=page_error, - ) - context["dashboard"] = dashboard_presenter.build_dashboard_view_model( - unread_publications=unread_publications, - recent_runs=recent_runs, - request_delay_seconds=user_settings.request_delay_seconds, - queue_counts=queue_counts, - ) - return common.templates.TemplateResponse( - request=request, - name="index.html", - context=context, - ) - - -@router.get("/", response_class=HTMLResponse) -async def home( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - return await _render_dashboard_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=resolve_theme(theme), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - - -@router.post("/runs/manual") -async def run_manual_ingestion( - request: Request, - db_session: AsyncSession = Depends(get_db_session), - ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - logger.info( - "runs.manual_started", - extra={ - "event": "runs.manual_started", - "user_id": current_user.id, - "request_delay_seconds": user_settings.request_delay_seconds, - "network_error_retries": settings.ingestion_network_error_retries, - "max_pages_per_scholar": settings.ingestion_max_pages_per_scholar, - "page_size": settings.ingestion_page_size, - }, - ) - try: - run_summary = await ingest_service.run_for_user( - db_session, - user_id=current_user.id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=user_settings.request_delay_seconds, - network_error_retries=settings.ingestion_network_error_retries, - retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, - max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, - page_size=settings.ingestion_page_size, - auto_queue_continuations=settings.ingestion_continuation_queue_enabled, - queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, - ) - except ingestion_service.RunAlreadyInProgressError: - await db_session.rollback() - return common.redirect_with_message( - "/", - error="A run is already in progress for this account.", - ) - except Exception: - await db_session.rollback() - logger.exception( - "runs.manual_failed", - extra={ - "event": "runs.manual_failed", - "user_id": current_user.id, - }, - ) - return common.redirect_with_message("/", error="Manual run failed. Check logs for details.") - - logger.info( - "runs.manual_completed", - extra={ - "event": "runs.manual_completed", - "user_id": current_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, - }, - ) - return common.redirect_with_message( - "/", - notice=( - f"Run #{run_summary.crawl_run_id} complete ({run_summary.status.value}). " - f"Scholars: {run_summary.scholar_count}, " - f"new publications: {run_summary.new_publication_count}." - ), - ) diff --git a/app/web/routers/health.py b/app/web/routers/health.py deleted file mode 100644 index 3c7c860..0000000 --- a/app/web/routers/health.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from fastapi import APIRouter, HTTPException - -from app.db.session import check_database - -router = APIRouter() - - -@router.get("/healthz") -async def healthz() -> dict[str, str]: - if await check_database(): - return {"status": "ok"} - raise HTTPException(status_code=500, detail="database unavailable") - diff --git a/app/web/routers/publications.py b/app/web/routers/publications.py deleted file mode 100644 index c4ce7a2..0000000 --- a/app/web/routers/publications.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -import logging -from urllib.parse import urlencode, urlparse - -from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -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.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - -MODE_NEW = "new" -MODE_ALL = "all" - - -def _resolve_mode(raw_mode: str | None) -> str: - return publication_service.resolve_mode(raw_mode) - - -def _parse_scholar_profile_id(value: str | None) -> int | None: - if not value: - return None - try: - parsed = int(value) - except ValueError: - return None - return parsed if parsed > 0 else None - - -def _build_publications_url(*, mode: str, scholar_profile_id: int | None) -> str: - params: dict[str, str] = {"mode": mode} - if scholar_profile_id is not None: - params["scholar_profile_id"] = str(scholar_profile_id) - return f"/publications?{urlencode(params)}" - - -def _is_safe_return_to(value: str | None) -> bool: - if not value: - return False - parsed = urlparse(value) - if parsed.scheme or parsed.netloc: - return False - if not value.startswith("/"): - return False - return value == "/" or value.startswith("/publications") - - -@router.get("/publications", response_class=HTMLResponse) -async def publications_page( - request: Request, - mode: str | None = None, - scholar_profile_id: str | None = None, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - resolved_mode = _resolve_mode(mode) - scholar_id_filter = _parse_scholar_profile_id(scholar_profile_id) - scholars = await scholar_service.list_scholars_for_user( - db_session, - user_id=current_user.id, - ) - scholar_lookup = {scholar.id: scholar for scholar in scholars} - if scholar_id_filter not in scholar_lookup: - scholar_id_filter = None - - publications = await publication_service.list_for_user( - db_session, - user_id=current_user.id, - mode=resolved_mode, - scholar_profile_id=scholar_id_filter, - limit=500, - ) - new_count = await publication_service.count_for_user( - db_session, - user_id=current_user.id, - mode=MODE_NEW, - scholar_profile_id=scholar_id_filter, - ) - total_count = await publication_service.count_for_user( - db_session, - user_id=current_user.id, - mode=MODE_ALL, - scholar_profile_id=scholar_id_filter, - ) - mode_new_url = _build_publications_url( - mode=MODE_NEW, - scholar_profile_id=scholar_id_filter, - ) - mode_all_url = _build_publications_url( - mode=MODE_ALL, - scholar_profile_id=scholar_id_filter, - ) - selected_scholar = scholar_lookup.get(scholar_id_filter) if scholar_id_filter else None - current_publications_url = _build_publications_url( - mode=resolved_mode, - scholar_profile_id=scholar_id_filter, - ) - - context = common.build_template_context( - request, - page_title="Publications", - active_nav="publications", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - context["mode"] = resolved_mode - context["publications"] = publications - context["new_count"] = new_count - context["total_count"] = total_count - context["mode_new_url"] = mode_new_url - context["mode_all_url"] = mode_all_url - context["scholars"] = scholars - context["selected_scholar_id"] = scholar_id_filter - context["selected_scholar"] = selected_scholar - context["current_publications_url"] = current_publications_url - return common.templates.TemplateResponse( - request=request, - name="publications.html", - context=context, - ) - - -@router.post("/publications/mark-all-read") -async def mark_all_publications_read( - request: Request, - return_to: str | None = Form(default=None), - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - updated_count = await publication_service.mark_all_unread_as_read_for_user( - db_session, - user_id=current_user.id, - ) - logger.info( - "publications.mark_all_read", - extra={ - "event": "publications.mark_all_read", - "user_id": current_user.id, - "updated_count": updated_count, - }, - ) - redirect_target = "/publications?mode=new" - if _is_safe_return_to(return_to): - redirect_target = return_to - return common.redirect_with_message( - redirect_target, - notice=f"Marked {updated_count} publication(s) as read.", - ) diff --git a/app/web/routers/runs.py b/app/web/routers/runs.py deleted file mode 100644 index c6e395f..0000000 --- a/app/web/routers/runs.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import runs as run_service -from app.theme import resolve_theme -from app.web import common - -router = APIRouter() - - -def _as_bool(value: str | None) -> bool: - if value is None: - return False - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def _status_badge(status_value: str) -> str: - if status_value == "success": - return "ok" - if status_value == "failed": - return "danger" - return "warn" - - -def _queue_status_badge(status_value: str) -> str: - if status_value == "dropped": - return "danger" - if status_value == "retrying": - return "ok" - return "warn" - - -def _format_dt(value: datetime | None) -> str: - if value is None: - return "-" - return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - - -def _summary_dict(error_log: object) -> dict[str, object]: - if not isinstance(error_log, dict): - return {} - summary = error_log.get("summary") - if not isinstance(summary, dict): - return {} - return summary - - -@router.get("/runs", response_class=HTMLResponse) -async def runs_page( - request: Request, - theme: str | None = None, - failed_only: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - failed_only_enabled = _as_bool(failed_only) - runs = await run_service.list_runs_for_user( - db_session, - user_id=current_user.id, - limit=200, - failed_only=failed_only_enabled, - ) - - run_items = [] - for run in runs: - summary = _summary_dict(run.error_log) - failed_count = summary.get("failed_count", 0) - partial_count = summary.get("partial_count", 0) - run_items.append( - { - "id": run.id, - "started_at": _format_dt(run.start_dt), - "finished_at": _format_dt(run.end_dt), - "status": run.status.value, - "status_badge": _status_badge(run.status.value), - "trigger_type": run.trigger_type.value, - "scholar_count": run.scholar_count, - "new_publication_count": run.new_pub_count, - "failed_count": failed_count, - "partial_count": partial_count, - } - ) - - queue_entries = await run_service.list_queue_items_for_user( - db_session, - user_id=current_user.id, - limit=200, - ) - queue_items = [] - for item in queue_entries: - queue_items.append( - { - "id": item.id, - "scholar_profile_id": item.scholar_profile_id, - "scholar_label": item.scholar_label, - "status": item.status, - "status_badge": _queue_status_badge(item.status), - "reason": item.reason, - "dropped_reason": item.dropped_reason, - "attempt_count": item.attempt_count, - "resume_cstart": item.resume_cstart, - "next_attempt_at": _format_dt(item.next_attempt_dt), - "updated_at": _format_dt(item.updated_at), - "last_error": item.last_error, - "last_run_id": item.last_run_id, - } - ) - - context = common.build_template_context( - request, - page_title="Runs", - active_nav="runs", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - context["runs"] = run_items - context["failed_only"] = failed_only_enabled - context["queue_items"] = queue_items - return common.templates.TemplateResponse( - request=request, - name="runs.html", - context=context, - ) - - -@router.get("/runs/{run_id}", response_class=HTMLResponse) -async def run_detail_page( - request: Request, - run_id: int, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - run = await run_service.get_run_for_user( - db_session, - user_id=current_user.id, - run_id=run_id, - ) - if run is None: - raise HTTPException(status_code=404, detail="Run not found.") - - 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 = [] - - context = common.build_template_context( - request, - page_title=f"Run #{run.id}", - active_nav="runs", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - ) - context["run"] = { - "id": run.id, - "started_at": _format_dt(run.start_dt), - "finished_at": _format_dt(run.end_dt), - "status": run.status.value, - "status_badge": _status_badge(run.status.value), - "trigger_type": run.trigger_type.value, - "scholar_count": run.scholar_count, - "new_publication_count": run.new_pub_count, - } - context["run_summary"] = _summary_dict(error_log) - context["scholar_results"] = scholar_results - return common.templates.TemplateResponse( - request=request, - name="run_detail.html", - context=context, - ) - - -@router.post("/runs/queue/{queue_item_id}/drop") -async def drop_queue_item( - request: Request, - queue_item_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - try: - dropped = await run_service.drop_queue_item_for_user( - db_session, - user_id=current_user.id, - queue_item_id=queue_item_id, - ) - except run_service.QueueTransitionError as exc: - return common.redirect_with_message( - "/runs", - error=f"Queue item #{queue_item_id}: {exc.message}", - ) - if dropped is None: - raise HTTPException(status_code=404, detail="Queue item not found.") - return common.redirect_with_message( - "/runs", - notice=f"Queue item #{queue_item_id} marked as dropped.", - ) - - -@router.post("/runs/queue/{queue_item_id}/clear") -async def clear_queue_item( - request: Request, - queue_item_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - try: - cleared = await run_service.clear_queue_item_for_user( - db_session, - user_id=current_user.id, - queue_item_id=queue_item_id, - ) - except run_service.QueueTransitionError as exc: - return common.redirect_with_message( - "/runs", - error=f"Queue item #{queue_item_id}: {exc.message}", - ) - if cleared is None: - raise HTTPException(status_code=404, detail="Queue item not found.") - return common.redirect_with_message( - "/runs", - notice=f"Queue item #{queue_item_id} cleared.", - ) - - -@router.post("/runs/queue/{queue_item_id}/retry") -async def retry_queue_item( - request: Request, - queue_item_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - try: - queue_item = await run_service.retry_queue_item_for_user( - db_session, - user_id=current_user.id, - queue_item_id=queue_item_id, - ) - except run_service.QueueTransitionError as exc: - return common.redirect_with_message( - "/runs", - error=f"Queue item #{queue_item_id}: {exc.message}", - ) - if queue_item is None: - raise HTTPException(status_code=404, detail="Queue item not found.") - return common.redirect_with_message( - "/runs", - notice=( - f"Queue item #{queue_item_id} queued for retry " - f"(scholar: {queue_item.scholar_label})." - ), - ) diff --git a/app/web/routers/scholars.py b/app/web/routers/scholars.py deleted file mode 100644 index a761f20..0000000 --- a/app/web/routers/scholars.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import scholars as scholar_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -def _format_dt(value: datetime | None) -> str: - if value is None: - return "-" - return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - - -async def _render_scholars_page( - request: Request, - *, - db_session: AsyncSession, - current_user, - theme_name: str, - notice: str | None = None, - page_error: str | None = None, - status_code: int = status.HTTP_200_OK, - form_scholar_id: str = "", - form_display_name: str = "", -) -> HTMLResponse: - scholars = await scholar_service.list_scholars_for_user( - db_session, - user_id=current_user.id, - ) - scholar_items = [] - for scholar in scholars: - scholar_items.append( - { - "id": scholar.id, - "scholar_id": scholar.scholar_id, - "display_name": scholar.display_name or "Unnamed", - "is_enabled": scholar.is_enabled, - "baseline_completed": scholar.baseline_completed, - "last_run_status": ( - scholar.last_run_status.value - if scholar.last_run_status is not None - else "never" - ), - "last_run_dt": _format_dt(scholar.last_run_dt), - } - ) - context = common.build_template_context( - request, - page_title="Scholars", - active_nav="scholars", - theme_name=theme_name, - session_user=common.to_session_user(current_user), - notice=notice, - page_error=page_error, - ) - context["scholars"] = scholar_items - context["form_scholar_id"] = form_scholar_id - context["form_display_name"] = form_display_name - return common.templates.TemplateResponse( - request=request, - name="scholars.html", - context=context, - status_code=status_code, - ) - - -@router.get("/scholars", response_class=HTMLResponse) -async def scholars_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - return await _render_scholars_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=resolve_theme(theme), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - - -@router.post("/scholars") -async def create_scholar( - request: Request, - scholar_id: Annotated[str, Form()], - display_name: Annotated[str, Form()] = "", - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - active_theme = resolve_theme(None) - try: - created_profile = await scholar_service.create_scholar_for_user( - db_session, - user_id=current_user.id, - scholar_id=scholar_id, - display_name=display_name, - ) - except scholar_service.ScholarServiceError as exc: - logger.info( - "scholars.create_failed", - extra={ - "event": "scholars.create_failed", - "user_id": current_user.id, - "scholar_id": scholar_id.strip(), - }, - ) - return await _render_scholars_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=active_theme, - page_error=str(exc), - status_code=status.HTTP_400_BAD_REQUEST, - form_scholar_id=scholar_id, - form_display_name=display_name, - ) - label = created_profile.display_name or created_profile.scholar_id - logger.info( - "scholars.created", - extra={ - "event": "scholars.created", - "user_id": current_user.id, - "scholar_profile_id": created_profile.id, - }, - ) - return common.redirect_with_message("/scholars", notice=f"Scholar added: {label}") - - -@router.post("/scholars/{scholar_profile_id}/toggle") -async def toggle_scholar( - request: Request, - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - 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 HTTPException(status_code=404, detail="Scholar not found.") - updated_profile = await scholar_service.toggle_scholar_enabled( - db_session, - profile=profile, - ) - status_label = "enabled" if updated_profile.is_enabled else "disabled" - logger.info( - "scholars.toggled", - extra={ - "event": "scholars.toggled", - "user_id": current_user.id, - "scholar_profile_id": updated_profile.id, - "is_enabled": updated_profile.is_enabled, - }, - ) - return common.redirect_with_message( - "/scholars", - notice=f"Scholar {status_label}: {updated_profile.scholar_id}", - ) - - -@router.post("/scholars/{scholar_profile_id}/delete") -async def delete_scholar( - request: Request, - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - 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 HTTPException(status_code=404, detail="Scholar not found.") - deleted_label = profile.display_name or profile.scholar_id - await scholar_service.delete_scholar(db_session, profile=profile) - logger.info( - "scholars.deleted", - extra={ - "event": "scholars.deleted", - "user_id": current_user.id, - "scholar_profile_id": scholar_profile_id, - }, - ) - return common.redirect_with_message("/scholars", notice=f"Scholar removed: {deleted_label}") diff --git a/app/web/routers/settings.py b/app/web/routers/settings.py deleted file mode 100644 index e5b7ba5..0000000 --- a/app/web/routers/settings.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import user_settings as user_settings_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.get("/settings", response_class=HTMLResponse) -async def settings_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - context = common.build_template_context( - request, - page_title="Settings", - active_nav="settings", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - context["user_settings"] = user_settings - return common.templates.TemplateResponse( - request=request, - name="settings.html", - context=context, - ) - - -@router.post("/settings") -async def update_settings( - request: Request, - run_interval_minutes: Annotated[str, Form()], - request_delay_seconds: Annotated[str, Form()], - auto_run_enabled: Annotated[str | None, Form()] = None, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - try: - parsed_interval = user_settings_service.parse_run_interval_minutes( - run_interval_minutes - ) - parsed_delay = user_settings_service.parse_request_delay_seconds( - request_delay_seconds - ) - except user_settings_service.UserSettingsServiceError as exc: - return common.redirect_with_message("/settings", error=str(exc)) - - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - await user_settings_service.update_settings( - db_session, - settings=user_settings, - auto_run_enabled=auto_run_enabled == "on", - run_interval_minutes=parsed_interval, - request_delay_seconds=parsed_delay, - ) - logger.info( - "settings.updated", - extra={ - "event": "settings.updated", - "user_id": current_user.id, - "auto_run_enabled": auto_run_enabled == "on", - "run_interval_minutes": parsed_interval, - "request_delay_seconds": parsed_delay, - }, - ) - return common.redirect_with_message("/settings", notice="Settings updated.") - diff --git a/docker-compose.yml b/docker-compose.yml index a6d4cda..b764f1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: LOG_FORMAT: ${LOG_FORMAT:-console} LOG_REQUESTS: ${LOG_REQUESTS:-1} LOG_UVICORN_ACCESS: ${LOG_UVICORN_ACCESS:-0} - LOG_REQUEST_SKIP_PATHS: ${LOG_REQUEST_SKIP_PATHS:-/healthz,/static/} + LOG_REQUEST_SKIP_PATHS: ${LOG_REQUEST_SKIP_PATHS:-/healthz} LOG_REDACT_FIELDS: ${LOG_REDACT_FIELDS:-} SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-1} SCHEDULER_TICK_SECONDS: ${SCHEDULER_TICK_SECONDS:-60} @@ -45,6 +45,11 @@ services: INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0} INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30} INGESTION_PAGE_SIZE: ${INGESTION_PAGE_SIZE:-100} + INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1} + INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120} + INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600} + INGESTION_CONTINUATION_MAX_ATTEMPTS: ${INGESTION_CONTINUATION_MAX_ATTEMPTS:-6} + SCHEDULER_QUEUE_BATCH_SIZE: ${SCHEDULER_QUEUE_BATCH_SIZE:-10} BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0} BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} diff --git a/planning/mvp_implementation_reminders.md b/planning/mvp_implementation_reminders.md deleted file mode 100644 index 87bc41f..0000000 --- a/planning/mvp_implementation_reminders.md +++ /dev/null @@ -1,92 +0,0 @@ -# MVP Implementation Reminders (From Scholar Probe) - -Last validated probe run: `planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.md`. - -## Decision: Are we ready? - -Yes, for the scoped MVP. - -- We have stable selectors and field coverage for first-page profile parsing. -- We have explicit failure modes for blocked/inaccessible pages. -- We have enough fixtures to start parser + ingestion tests immediately. - -Not guaranteed (and intentionally out of MVP): - -- Full historical completeness from profile pagination. -- Robots currently disallows `citations?*cstart=`; treat deep pagination as out of scope unless policy changes. - -## Non-negotiables - -- Keep app container-first (`docker compose`) and test while developing. -- Keep feature scope small; prefer deterministic behavior over clever scraping. -- Never crash a whole run because one scholar page fails. -- Preserve strict tenant isolation for all run/output/read-state data. - -## Scraping Contract - -Target URL: - -- `https://scholar.google.com/citations?hl=en&user=` - -Primary parse markers: - -- Row: `tr.gsc_a_tr` -- Title/link: `a.gsc_a_at` -- Citation count: `a.gsc_a_ac` -- Year: `span.gsc_a_h` (fallback year regex) -- Metadata lines: first/second `div.gs_gray` => authors/venue -- Cluster id from `citation_for_view=:` - -## Required Parse States - -Use and persist one of: - -- `ok` -- `no_results` -- `blocked_or_captcha` -- `layout_changed` -- `network_error` - -Plus these page-level flags: - -- `has_show_more_button` -- `articles_range` - -## Data Quality Expectations - -Observed from probe sample: - -- `title`: 100% -- `cluster_id`: 100% -- `citation_count`: 100% -- `authors_text`: 100% -- `year`: 98.2% -- `venue_text`: 94.7% - -Implications: - -- `year` and `venue_text` must remain nullable. -- Dedupe must not depend solely on `year`/`venue_text` presence. - -## Run Semantics - -- First successful run per scholar = baseline. -- If `has_show_more_button=true`, label result as partial in run status/UI. -- Invalid/inaccessible scholar IDs are expected and should become structured run errors, not exceptions. - -## Testing Priorities (Do Before Feature Expansion) - -- Fixture parser unit tests using probe HTML snapshots. -- Parse-state classification tests (ok/blocked/layout/no_results). -- Dedupe integration tests (`cluster_id` first, fingerprint fallback). -- Tenant isolation tests for run records and read-state. -- Smoke test for manual run path through Docker. - -## Stop Conditions - -Pause implementation and re-probe if: - -- Selector markers drop out unexpectedly. -- Login/redirect pages become frequent for valid IDs. -- Robots policy for profile endpoints changes. - diff --git a/planning/scholar_probe_tmp/README.md b/planning/scholar_probe_tmp/README.md deleted file mode 100644 index b6c6ad6..0000000 --- a/planning/scholar_probe_tmp/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Scholar Scrape Probe (Temporary) - -Purpose: short-lived workspace for information gathering and planning. - -Rules: -- No production code changes here. -- Use this for HTML samples, parsing experiments, and extraction notes. -- Delete this directory after we lock the scrape contract and parser plan. - -Structure: -- `fixtures/` saved HTML samples for parser validation -- `notes/` extraction decisions, edge cases, and risk log -- `scripts/` one-off probe scripts used only during planning diff --git a/planning/scholar_probe_tmp/notes/findings_phase1_input.md b/planning/scholar_probe_tmp/notes/findings_phase1_input.md deleted file mode 100644 index 4a404e1..0000000 --- a/planning/scholar_probe_tmp/notes/findings_phase1_input.md +++ /dev/null @@ -1,71 +0,0 @@ -# Probe Findings -> Phase 1 Input - -Generated from live probe run: `run_20260216T182334Z`. - -## What is stable enough to build on - -- Public profile endpoint works for anonymous access: - - `GET /citations?hl=en&user=` -- Core row structure is present and parseable: - - row: `tr.gsc_a_tr` - - title + detail URL: `a.gsc_a_at` - - citation count: `a.gsc_a_ac` - - year: `span.gsc_a_h` (fallback: `td.gsc_a_y` text regex) - - metadata lines: first/second `div.gs_gray` => authors/venue -- `cluster_id` can be extracted reliably from `citation_for_view=:` in title URLs. - -## What can break / where to degrade gracefully - -- Invalid or inaccessible profile IDs may redirect to Google sign-in page. - - Treat as `blocked_or_captcha` / `inaccessible` state, not parser crash. -- `Show more` is present for tested profiles. - - We currently parse first page only. - - Because robots currently disallows `citations?*cstart=`, deep pagination should not be assumed. -- Some rows are missing year or venue text. - - Keep nullable fields and avoid failing dedupe for these gaps. - -## Observed quality from probe - -- Parsed publication rows: 57 across 4 accessible profiles. -- Field coverage: - - title: 100% - - cluster_id: 100% - - citation_count: 100% - - authors_text: 100% - - year: 98.2% - - venue_text: 94.7% - -## Recommended parser contract for implementation - -- Input -> `PublicationCandidate` - - `title` (required) - - `cluster_id` (required when present in URL; expected high coverage) - - `year` (nullable) - - `citation_count` (nullable/int default fallback 0) - - `authors_text` (nullable) - - `venue_text` (nullable) - - `title_url` (nullable) -- Page-level parse status enum: - - `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error` -- Page-level flags: - - `has_show_more_button` - - `articles_range` string (e.g. `Articles 1–20`) - -## Immediate test plan unlocked by this probe - -- Add fixture-driven unit tests using captured HTML from `fixtures/run_20260216T182334Z`. -- Add assertions for: - - row count > 0 on known-good fixtures - - profile name extraction - - cluster_id extraction from title URLs - - nullable handling for year/venue - - blocked/inaccessible page classification - - show-more partial warning classification - -## Phase 1 implementation guardrails - -- Keep requests low-rate with jitter and explicit timeout. -- Persist parser status per scholar run. -- If `has_show_more_button=True`, mark run as partial and show this in UI. -- Never fail entire run because one scholar page is blocked or malformed. - diff --git a/pyproject.toml b/pyproject.toml index 0669801..40166f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,6 @@ dependencies = [ "asyncpg>=0.30,<0.31", "fastapi>=0.116,<0.117", "itsdangerous>=2.2,<3.0", - "jinja2>=3.1,<3.2", - "python-multipart>=0.0.20,<0.0.21", "sqlalchemy>=2.0,<2.1", "uvicorn[standard]>=0.34,<0.35", ] diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 49cf32f..d1f5c68 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,36 +1,29 @@ from __future__ import annotations -import re - from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.auth.security import PasswordService -CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"') - - -def extract_csrf_token(html: str) -> str: - match = CSRF_TOKEN_PATTERN.search(html) - assert match is not None - return match.group(1) - - def login_user(client: TestClient, *, email: str, password: str) -> None: - login_page = client.get("/login") - csrf_token = extract_csrf_token(login_page.text) + bootstrap_response = client.get("/api/v1/auth/csrf") + assert bootstrap_response.status_code == 200 + csrf_token = bootstrap_response.json()["data"]["csrf_token"] + assert isinstance(csrf_token, str) and csrf_token + response = client.post( - "/login", - data={ + "/api/v1/auth/login", + json={ "email": email, "password": password, - "csrf_token": csrf_token, }, + headers={"X-CSRF-Token": csrf_token}, follow_redirects=False, ) - assert response.status_code == 303 - assert response.headers["location"] == "/" + assert response.status_code == 200 + payload = response.json() + assert payload["data"]["authenticated"] is True async def insert_user( @@ -60,4 +53,3 @@ async def insert_user( user_id = int(result.scalar_one()) await db_session.commit() return user_id - diff --git a/tests/integration/test_admin_user_management.py b/tests/integration/test_admin_user_management.py deleted file mode 100644 index 7553317..0000000 --- a/tests/integration/test_admin_user_management.py +++ /dev/null @@ -1,200 +0,0 @@ -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.main import app -from tests.integration.helpers import extract_csrf_token, insert_user, login_user - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_users_page_is_admin_only(db_session: AsyncSession) -> None: - await insert_user( - db_session, - email="member@example.com", - password="member-pass", - is_admin=False, - ) - - client = TestClient(app) - login_user(client, email="member@example.com", password="member-pass") - - response = client.get("/users") - - assert response.status_code == 403 - assert response.json()["detail"] == "Admin access required." - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_non_admin_dashboard_hides_users_nav(db_session: AsyncSession) -> None: - await insert_user( - db_session, - email="member@example.com", - password="member-pass", - is_admin=False, - ) - - client = TestClient(app) - login_user(client, email="member@example.com", password="member-pass") - dashboard = client.get("/") - - assert dashboard.status_code == 200 - assert ">Users<" not in dashboard.text - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_admin_dashboard_shows_users_nav(db_session: AsyncSession) -> None: - await insert_user( - db_session, - email="admin@example.com", - password="admin-pass", - is_admin=True, - ) - - client = TestClient(app) - login_user(client, email="admin@example.com", password="admin-pass") - dashboard = client.get("/") - - assert dashboard.status_code == 200 - assert ">Users<" in dashboard.text - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_admin_can_create_and_deactivate_user(db_session: AsyncSession) -> None: - await insert_user( - db_session, - email="admin@example.com", - password="admin-pass", - is_admin=True, - ) - - client = TestClient(app) - login_user(client, email="admin@example.com", password="admin-pass") - - users_page = client.get("/users") - csrf_token = extract_csrf_token(users_page.text) - create_response = client.post( - "/users", - data={ - "email": "new-user@example.com", - "password": "new-user-pass", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - assert create_response.status_code == 303 - assert create_response.headers["location"].startswith("/users") - - created_user_id_result = await db_session.execute( - text("SELECT id FROM users WHERE email = 'new-user@example.com'") - ) - created_user_id = int(created_user_id_result.scalar_one()) - - users_page_after_create = client.get("/users") - csrf_after_create = extract_csrf_token(users_page_after_create.text) - toggle_response = client.post( - f"/users/{created_user_id}/toggle-active", - data={"csrf_token": csrf_after_create}, - follow_redirects=False, - ) - assert toggle_response.status_code == 303 - - status_result = await db_session.execute( - text("SELECT is_active FROM users WHERE id = :user_id"), - {"user_id": created_user_id}, - ) - assert status_result.scalar_one() is False - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_admin_can_reset_user_password(db_session: AsyncSession) -> None: - target_user_id = await insert_user( - db_session, - email="target@example.com", - password="old-password", - is_admin=False, - ) - await insert_user( - db_session, - email="admin@example.com", - password="admin-pass", - is_admin=True, - ) - - client = TestClient(app) - login_user(client, email="admin@example.com", password="admin-pass") - - users_page = client.get("/users") - csrf_token = extract_csrf_token(users_page.text) - reset_response = client.post( - f"/users/{target_user_id}/reset-password", - data={ - "new_password": "new-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - assert reset_response.status_code == 303 - - users_page_after_reset = client.get("/users") - logout_csrf = extract_csrf_token(users_page_after_reset.text) - client.post( - "/logout", - data={"csrf_token": logout_csrf}, - follow_redirects=False, - ) - - failed_login_page = client.get("/login") - failed_login_csrf = extract_csrf_token(failed_login_page.text) - failed_login = client.post( - "/login", - data={ - "email": "target@example.com", - "password": "old-password", - "csrf_token": failed_login_csrf, - }, - follow_redirects=False, - ) - assert failed_login.status_code == 401 - - login_user(client, email="target@example.com", password="new-password") - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_admin_cannot_deactivate_self(db_session: AsyncSession) -> None: - admin_user_id = await insert_user( - db_session, - email="admin@example.com", - password="admin-pass", - is_admin=True, - ) - client = TestClient(app) - login_user(client, email="admin@example.com", password="admin-pass") - - users_page = client.get("/users") - csrf_token = extract_csrf_token(users_page.text) - response = client.post( - f"/users/{admin_user_id}/toggle-active", - data={"csrf_token": csrf_token}, - follow_redirects=False, - ) - - assert response.status_code == 303 - assert response.headers["location"].startswith("/users") - result = await db_session.execute( - text("SELECT is_active FROM users WHERE id = :user_id"), - {"user_id": admin_user_id}, - ) - assert result.scalar_one() is True diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py deleted file mode 100644 index 2455b80..0000000 --- a/tests/integration/test_auth_flow.py +++ /dev/null @@ -1,109 +0,0 @@ -import re - -import pytest -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession -from fastapi.testclient import TestClient - -from app.auth.security import PasswordService -from app.main import app - -CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"') - - -def _extract_csrf_token(html: str) -> str: - match = CSRF_TOKEN_PATTERN.search(html) - assert match is not None - return match.group(1) - - -@pytest.mark.integration -def test_dashboard_requires_authentication() -> None: - client = TestClient(app) - - response = client.get("/", follow_redirects=False) - - assert response.status_code == 303 - assert response.headers["location"] == "/login" - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_login_with_valid_credentials_allows_access(db_session: AsyncSession) -> None: - password_service = PasswordService() - await db_session.execute( - text( - """ - INSERT INTO users (email, password_hash, is_active, is_admin) - VALUES (:email, :password_hash, :is_active, :is_admin) - """ - ), - { - "email": "reader@example.com", - "password_hash": password_service.hash_password("correct-password"), - "is_active": True, - "is_admin": False, - }, - ) - await db_session.commit() - - client = TestClient(app) - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - login_response = client.post( - "/login", - data={ - "email": "reader@example.com", - "password": "correct-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - - assert login_response.status_code == 303 - assert login_response.headers["location"] == "/" - - dashboard_response = client.get("/") - assert dashboard_response.status_code == 200 - assert "reader@example.com" in dashboard_response.text - assert 'data-test="home-hero"' in dashboard_response.text - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_login_rejects_inactive_user(db_session: AsyncSession) -> None: - password_service = PasswordService() - await db_session.execute( - text( - """ - INSERT INTO users (email, password_hash, is_active, is_admin) - VALUES (:email, :password_hash, :is_active, :is_admin) - """ - ), - { - "email": "inactive@example.com", - "password_hash": password_service.hash_password("correct-password"), - "is_active": False, - "is_admin": False, - }, - ) - await db_session.commit() - - client = TestClient(app) - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - login_response = client.post( - "/login", - data={ - "email": "inactive@example.com", - "password": "correct-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - - assert login_response.status_code == 401 - assert "Invalid email or password." in login_response.text - diff --git a/tests/integration/test_manual_ingestion_flow.py b/tests/integration/test_manual_ingestion_flow.py deleted file mode 100644 index 25de49a..0000000 --- a/tests/integration/test_manual_ingestion_flow.py +++ /dev/null @@ -1,1151 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.main import app, get_scholar_source -from app.db.models import RunTriggerType -from app.services.ingestion import RUN_LOCK_NAMESPACE, RunAlreadyInProgressError, ScholarIngestionService -from app.services.scheduler import SchedulerService -from app.services.scholar_source import FetchResult -from tests.integration.helpers import extract_csrf_token, insert_user, login_user - -HTML_BASELINE = """ - -
Fixture Scholar
- Articles 1-1 - - - - - - -
- Paper One -
A Author
-
Venue One, 2024
-
32024
- -""" - -HTML_INCREMENTAL = """ - -
Fixture Scholar
- Articles 1-2 - - - - - - - - - - - -
- Paper One -
A Author
-
Venue One, 2024
-
42024
- Paper Two -
B Author
-
Venue Two, 2025
-
12025
- -""" - -HTML_PAGED_ONE = """ - -
Paged Scholar
- Articles 1-1 - - - - - - -
- Paged Paper One -
P Author
-
Paged Venue, 2023
-
52023
-
- -
- -""" - -HTML_PAGED_TWO = """ - -
Paged Scholar
- Articles 2-2 - - - - - - -
- Paged Paper Two -
P Author
-
Paged Venue, 2024
-
22024
- -""" - -HTML_STALLED_TAIL_ONE = """ - -
Tail Scholar
- Articles 1-1 - - - - - - -
- Tail Paper One -
T Author
-
Tail Venue, 2022
-
92022
-
- -""" - -HTML_STALLED_TAIL_TWO = """ - -
Tail Scholar
-
No documents. Your search didn't match any articles.
-
- -""" - - -class StubScholarSource: - def __init__(self, html_bodies: list[str]) -> None: - self._html_bodies = html_bodies - self._index = 0 - - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: - if self._index >= len(self._html_bodies): - body = self._html_bodies[-1] - else: - body = self._html_bodies[self._index] - self._index += 1 - - url = f"https://scholar.google.com/citations?hl=en&user={scholar_id}" - return FetchResult( - requested_url=url, - status_code=200, - final_url=url, - body=body, - error=None, - ) - - -class StubScholarSourceResults: - def __init__(self, results: list[FetchResult]) -> None: - self._results = results - self._index = 0 - - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: - if self._index >= len(self._results): - result = self._results[-1] - else: - result = self._results[self._index] - self._index += 1 - return result - - -class CountingScholarSourceResults(StubScholarSourceResults): - def __init__(self, results: list[FetchResult]) -> None: - super().__init__(results) - self.calls = 0 - - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: - self.calls += 1 - return await super().fetch_profile_html(scholar_id) - - -class StubPagedScholarSource: - def __init__(self, pages: dict[int, str]) -> None: - self._pages = pages - self.calls: list[tuple[int, int]] = [] - - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: - return await self.fetch_profile_page_html( - scholar_id, - cstart=0, - pagesize=100, - ) - - async def fetch_profile_page_html( - self, - scholar_id: str, - *, - cstart: int, - pagesize: int, - ) -> FetchResult: - self.calls.append((cstart, pagesize)) - body = self._pages.get(cstart, "") - url = ( - "https://scholar.google.com/citations" - f"?hl=en&user={scholar_id}&cstart={cstart}&pagesize={pagesize}" - ) - return FetchResult( - requested_url=url, - status_code=200, - final_url=url, - body=body, - error=None, - ) - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_manual_ingestion_sets_baseline_then_adds_unread(db_session: AsyncSession) -> None: - user_id = await insert_user( - db_session, - email="ingest@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Fixture Scholar", - "is_enabled": True, - }, - ) - await db_session.commit() - - stub_source = StubScholarSource([HTML_BASELINE, HTML_INCREMENTAL]) - app.dependency_overrides[get_scholar_source] = lambda: stub_source - - client = TestClient(app) - try: - login_user(client, email="ingest@example.com", password="ingest-password") - - dashboard = client.get("/") - csrf = extract_csrf_token(dashboard.text) - run_one = client.post( - "/runs/manual", - data={"csrf_token": csrf}, - follow_redirects=False, - ) - assert run_one.status_code == 303 - assert run_one.headers["location"].startswith("/") - - baseline_status = await db_session.execute( - text("SELECT baseline_completed FROM scholar_profiles WHERE user_id = :user_id"), - {"user_id": user_id}, - ) - assert baseline_status.scalar_one() is True - - run_one_stats = await db_session.execute( - text( - """ - SELECT status::text, new_pub_count - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - assert run_one_stats.one() == ("success", 1) - - unread_after_baseline = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM scholar_publications sp - JOIN scholar_profiles s ON s.id = sp.scholar_profile_id - WHERE s.user_id = :user_id AND sp.is_read = false - """ - ), - {"user_id": user_id}, - ) - assert unread_after_baseline.scalar_one() == 1 - - dashboard_again = client.get("/") - csrf_two = extract_csrf_token(dashboard_again.text) - run_two = client.post( - "/runs/manual", - data={"csrf_token": csrf_two}, - follow_redirects=False, - ) - assert run_two.status_code == 303 - - run_two_stats = await db_session.execute( - text( - """ - SELECT status::text, new_pub_count - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - assert run_two_stats.one() == ("success", 1) - - unread_after_second = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM scholar_publications sp - JOIN scholar_profiles s ON s.id = sp.scholar_profile_id - WHERE s.user_id = :user_id AND sp.is_read = false - """ - ), - {"user_id": user_id}, - ) - assert unread_after_second.scalar_one() == 2 - - publications_new_page = client.get("/publications?mode=new") - assert publications_new_page.status_code == 200 - assert "Paper Two" in publications_new_page.text - assert "Paper One" not in publications_new_page.text - - publications_all_page = client.get("/publications?mode=all") - assert publications_all_page.status_code == 200 - assert "Paper One" in publications_all_page.text - assert "Paper Two" in publications_all_page.text - - dashboard_mark = client.get("/") - csrf_mark = extract_csrf_token(dashboard_mark.text) - mark_response = client.post( - "/publications/mark-all-read", - data={"csrf_token": csrf_mark}, - follow_redirects=False, - ) - assert mark_response.status_code == 303 - - unread_after_mark = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM scholar_publications sp - JOIN scholar_profiles s ON s.id = sp.scholar_profile_id - WHERE s.user_id = :user_id AND sp.is_read = false - """ - ), - {"user_id": user_id}, - ) - assert unread_after_mark.scalar_one() == 0 - - publications_new_after_mark = client.get("/publications?mode=new") - assert publications_new_after_mark.status_code == 200 - assert "Paper Two" in publications_new_after_mark.text - assert "Paper One" not in publications_new_after_mark.text - finally: - app.dependency_overrides.clear() - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_publications_page_supports_scholar_filtering_navigation( - db_session: AsyncSession, -) -> None: - user_id = await insert_user( - db_session, - email="filtering@example.com", - password="filter-password", - ) - - scholar_one_result = await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - RETURNING id - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Scholar One", - "is_enabled": True, - }, - ) - scholar_one_id = int(scholar_one_result.scalar_one()) - - scholar_two_result = await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - RETURNING id - """ - ), - { - "user_id": user_id, - "scholar_id": "uvwXYZ987654", - "display_name": "Scholar Two", - "is_enabled": True, - }, - ) - scholar_two_id = int(scholar_two_result.scalar_one()) - - run_result = await db_session.execute( - text( - """ - INSERT INTO crawl_runs ( - user_id, - trigger_type, - status, - start_dt, - end_dt, - scholar_count, - new_pub_count, - error_log - ) - VALUES ( - :user_id, - 'manual', - 'success', - NOW(), - NOW(), - 2, - 2, - '{}'::jsonb - ) - RETURNING id - """ - ), - {"user_id": user_id}, - ) - run_id = int(run_result.scalar_one()) - - publication_one_result = await db_session.execute( - text( - """ - INSERT INTO publications ( - cluster_id, - fingerprint_sha256, - title_raw, - title_normalized, - year, - citation_count, - author_text, - venue_text, - pub_url, - pdf_url - ) - VALUES ( - NULL, - :fingerprint, - :title_raw, - :title_normalized, - :year, - 0, - NULL, - NULL, - NULL, - NULL - ) - RETURNING id - """ - ), - { - "fingerprint": "f" * 64, - "title_raw": "Scholar One Paper", - "title_normalized": "scholar one paper", - "year": 2024, - }, - ) - publication_one_id = int(publication_one_result.scalar_one()) - - publication_two_result = await db_session.execute( - text( - """ - INSERT INTO publications ( - cluster_id, - fingerprint_sha256, - title_raw, - title_normalized, - year, - citation_count, - author_text, - venue_text, - pub_url, - pdf_url - ) - VALUES ( - NULL, - :fingerprint, - :title_raw, - :title_normalized, - :year, - 0, - NULL, - NULL, - NULL, - NULL - ) - RETURNING id - """ - ), - { - "fingerprint": "e" * 64, - "title_raw": "Scholar Two Paper", - "title_normalized": "scholar two paper", - "year": 2025, - }, - ) - publication_two_id = int(publication_two_result.scalar_one()) - - await db_session.execute( - text( - """ - INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, first_seen_run_id) - VALUES (:scholar_profile_id, :publication_id, :is_read, :first_seen_run_id) - """ - ), - { - "scholar_profile_id": scholar_one_id, - "publication_id": publication_one_id, - "is_read": False, - "first_seen_run_id": run_id, - }, - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, first_seen_run_id) - VALUES (:scholar_profile_id, :publication_id, :is_read, :first_seen_run_id) - """ - ), - { - "scholar_profile_id": scholar_two_id, - "publication_id": publication_two_id, - "is_read": False, - "first_seen_run_id": run_id, - }, - ) - await db_session.commit() - - client = TestClient(app) - login_user(client, email="filtering@example.com", password="filter-password") - - scholars_page = client.get("/scholars") - assert scholars_page.status_code == 200 - assert f"/publications?mode=new&scholar_profile_id={scholar_one_id}" in scholars_page.text - assert f"/publications?mode=all&scholar_profile_id={scholar_two_id}" in scholars_page.text - - filtered_all = client.get(f"/publications?mode=all&scholar_profile_id={scholar_one_id}") - assert filtered_all.status_code == 200 - assert "Scholar One Paper" in filtered_all.text - assert "Scholar Two Paper" not in filtered_all.text - - filtered_new = client.get(f"/publications?mode=new&scholar_profile_id={scholar_two_id}") - assert filtered_new.status_code == 200 - assert "Scholar Two Paper" in filtered_new.text - assert "Scholar One Paper" not in filtered_new.text - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_manual_ingestion_persists_failure_debug_context(db_session: AsyncSession) -> None: - user_id = await insert_user( - db_session, - email="ingest-failure@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Failure Fixture", - "is_enabled": True, - }, - ) - await db_session.commit() - - stub_source = StubScholarSourceResults( - [ - FetchResult( - requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456", - status_code=None, - final_url=None, - body="", - error="timed out", - ) - ] - ) - app.dependency_overrides[get_scholar_source] = lambda: stub_source - - client = TestClient(app) - try: - login_user(client, email="ingest-failure@example.com", password="ingest-password") - dashboard = client.get("/") - csrf = extract_csrf_token(dashboard.text) - run_response = client.post( - "/runs/manual", - data={"csrf_token": csrf}, - follow_redirects=False, - ) - assert run_response.status_code == 303 - - run_result = await db_session.execute( - text( - """ - SELECT status::text, error_log - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - status_text, error_log = run_result.one() - assert status_text == "failed" - assert error_log["summary"]["failed_count"] == 1 - assert error_log["summary"]["failed_state_counts"] == {"network_error": 1} - assert error_log["summary"]["failed_reason_counts"] == { - "network_error_missing_status_code": 1 - } - - scholar_result = error_log["scholar_results"][0] - assert scholar_result["state"] == "network_error" - assert scholar_result["state_reason"] == "network_error_missing_status_code" - debug = scholar_result["debug"] - assert debug["status_code"] is None - assert debug["fetch_error"] == "timed out" - assert debug["requested_url"].endswith("user=abcDEF123456") - assert debug["body_excerpt"] is None - - runs_page = client.get("/runs") - assert runs_page.status_code == 200 - assert "Run History" in runs_page.text - assert "failed /" in runs_page.text - - failed_only_page = client.get("/runs?failed_only=1") - assert failed_only_page.status_code == 200 - assert "Run History" in failed_only_page.text - - run_id_result = await db_session.execute( - text( - """ - SELECT id - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - run_id = run_id_result.scalar_one() - - detail_page = client.get(f"/runs/{run_id}") - assert detail_page.status_code == 200 - assert f"Run #{run_id}" in detail_page.text - assert "network_error_missing_status_code" in detail_page.text - finally: - app.dependency_overrides.clear() - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_manual_ingestion_fetches_all_pages_for_scholar(db_session: AsyncSession) -> None: - user_id = await insert_user( - db_session, - email="paged@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Paged Scholar", - "is_enabled": True, - }, - ) - await db_session.commit() - - source = StubPagedScholarSource( - { - 0: HTML_PAGED_ONE, - 1: HTML_PAGED_TWO, - } - ) - app.dependency_overrides[get_scholar_source] = lambda: source - - client = TestClient(app) - try: - login_user(client, email="paged@example.com", password="ingest-password") - dashboard = client.get("/") - csrf = extract_csrf_token(dashboard.text) - run_response = client.post( - "/runs/manual", - data={"csrf_token": csrf}, - follow_redirects=False, - ) - assert run_response.status_code == 303 - - assert source.calls[0][0] == 0 - assert source.calls[1][0] == 1 - - publications_count = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM scholar_publications sp - JOIN scholar_profiles s ON s.id = sp.scholar_profile_id - WHERE s.user_id = :user_id - """ - ), - {"user_id": user_id}, - ) - assert publications_count.scalar_one() == 2 - - run_result = await db_session.execute( - text( - """ - SELECT status::text, new_pub_count, error_log - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - status_text, new_pub_count, error_log = run_result.one() - assert status_text == "success" - assert new_pub_count == 2 - scholar_result = error_log["scholar_results"][0] - assert scholar_result["publication_count"] == 2 - assert scholar_result["pages_fetched"] == 2 - assert scholar_result["has_more_remaining"] is False - assert scholar_result["pagination_truncated_reason"] is None - finally: - app.dependency_overrides.clear() - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_manual_ingestion_handles_empty_no_results_tail_without_partial( - db_session: AsyncSession, -) -> None: - user_id = await insert_user( - db_session, - email="tail@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Tail Scholar", - "is_enabled": True, - }, - ) - await db_session.commit() - - source = StubPagedScholarSource( - { - 0: HTML_STALLED_TAIL_ONE, - 1: HTML_STALLED_TAIL_TWO, - } - ) - app.dependency_overrides[get_scholar_source] = lambda: source - - client = TestClient(app) - try: - login_user(client, email="tail@example.com", password="ingest-password") - dashboard = client.get("/") - csrf = extract_csrf_token(dashboard.text) - run_response = client.post( - "/runs/manual", - data={"csrf_token": csrf}, - follow_redirects=False, - ) - assert run_response.status_code == 303 - assert source.calls[0][0] == 0 - assert source.calls[1][0] == 1 - - run_result = await db_session.execute( - text( - """ - SELECT status::text, new_pub_count, error_log - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - status_text, new_pub_count, error_log = run_result.one() - assert status_text == "success" - assert new_pub_count == 1 - scholar_result = error_log["scholar_results"][0] - assert scholar_result["outcome"] == "success" - assert scholar_result["pagination_truncated_reason"] is None - - queue_count_result = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM ingestion_queue_items - WHERE user_id = :user_id - """ - ), - {"user_id": user_id}, - ) - assert queue_count_result.scalar_one() == 0 - finally: - app.dependency_overrides.clear() - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_ingestion_enqueues_continuation_when_max_pages_reached( - db_session: AsyncSession, -) -> None: - user_id = await insert_user( - db_session, - email="queued@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Queued Scholar", - "is_enabled": True, - }, - ) - await db_session.commit() - - source = StubPagedScholarSource({0: HTML_PAGED_ONE, 1: HTML_PAGED_TWO}) - service = ScholarIngestionService(source=source) - - summary = await service.run_for_user( - db_session, - user_id=user_id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=0, - network_error_retries=0, - retry_backoff_seconds=0, - max_pages_per_scholar=1, - page_size=100, - auto_queue_continuations=True, - queue_delay_seconds=0, - ) - - assert summary.status.value == "partial_failure" - queue_result = await db_session.execute( - text( - """ - SELECT reason, resume_cstart, attempt_count - FROM ingestion_queue_items - WHERE user_id = :user_id - """ - ), - {"user_id": user_id}, - ) - reason, resume_cstart, attempt_count = queue_result.one() - assert reason == "max_pages_reached" - assert int(resume_cstart) == 1 - assert int(attempt_count) == 0 - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_scheduler_processes_queued_continuation_items( - db_session: AsyncSession, -) -> None: - user_id = await insert_user( - db_session, - email="queue-scheduler@example.com", - password="ingest-password", - ) - scholar_result = await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - RETURNING id - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Queue Scheduler Scholar", - "is_enabled": True, - }, - ) - scholar_profile_id = int(scholar_result.scalar_one()) - await db_session.execute( - text( - """ - INSERT INTO ingestion_queue_items ( - user_id, - scholar_profile_id, - resume_cstart, - reason, - attempt_count, - next_attempt_dt - ) - VALUES ( - :user_id, - :scholar_profile_id, - :resume_cstart, - :reason, - :attempt_count, - NOW() - INTERVAL '1 minute' - ) - """ - ), - { - "user_id": user_id, - "scholar_profile_id": scholar_profile_id, - "resume_cstart": 1, - "reason": "max_pages_reached", - "attempt_count": 0, - }, - ) - await db_session.commit() - - source = StubPagedScholarSource({1: HTML_PAGED_TWO}) - scheduler = SchedulerService( - enabled=True, - tick_seconds=60, - network_error_retries=0, - retry_backoff_seconds=0, - max_pages_per_scholar=30, - page_size=100, - continuation_queue_enabled=True, - continuation_base_delay_seconds=1, - continuation_max_delay_seconds=60, - continuation_max_attempts=4, - queue_batch_size=10, - ) - scheduler._source = source - - await scheduler._tick_once() - - queue_count_result = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM ingestion_queue_items - WHERE user_id = :user_id - """ - ), - {"user_id": user_id}, - ) - assert queue_count_result.scalar_one() == 0 - - publications_count = await db_session.execute( - text( - """ - SELECT COUNT(*) - FROM scholar_publications sp - JOIN scholar_profiles s ON s.id = sp.scholar_profile_id - WHERE s.user_id = :user_id - """ - ), - {"user_id": user_id}, - ) - assert publications_count.scalar_one() == 1 - assert source.calls and source.calls[0][0] == 1 - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_ingestion_retries_network_error_and_recovers(db_session: AsyncSession) -> None: - user_id = await insert_user( - db_session, - email="retry@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Retry Fixture", - "is_enabled": True, - }, - ) - await db_session.commit() - - source = CountingScholarSourceResults( - [ - FetchResult( - requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456", - status_code=None, - final_url=None, - body="", - error="temporary timeout", - ), - FetchResult( - requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456", - status_code=200, - final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456", - body=HTML_BASELINE, - error=None, - ), - ] - ) - service = ScholarIngestionService(source=source) - - summary = await service.run_for_user( - db_session, - user_id=user_id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=0, - network_error_retries=1, - retry_backoff_seconds=0, - ) - - assert source.calls == 2 - assert summary.status.value == "success" - assert summary.failed_count == 0 - assert summary.succeeded_count == 1 - - run_error_log_result = await db_session.execute( - text( - """ - SELECT error_log - FROM crawl_runs - WHERE user_id = :user_id - ORDER BY id DESC - LIMIT 1 - """ - ), - {"user_id": user_id}, - ) - run_error_log = run_error_log_result.scalar_one() - scholar_result = run_error_log["scholar_results"][0] - assert scholar_result["attempt_count"] == 2 - assert scholar_result["state"] == "ok" - assert scholar_result["state_reason"] == "publications_extracted" - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_ingestion_rejects_overlapping_run_lock( - db_session: AsyncSession, - database_url: str, -) -> None: - user_id = await insert_user( - db_session, - email="lock@example.com", - password="ingest-password", - ) - await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Lock Fixture", - "is_enabled": True, - }, - ) - await db_session.commit() - - engine = create_async_engine(database_url, pool_pre_ping=True) - factory = async_sessionmaker(engine, expire_on_commit=False) - lock_session = factory() - try: - await lock_session.execute( - text("SELECT pg_advisory_lock(:namespace, :user_key)"), - { - "namespace": RUN_LOCK_NAMESPACE, - "user_key": user_id, - }, - ) - - service = ScholarIngestionService( - source=CountingScholarSourceResults( - [ - FetchResult( - requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456", - status_code=200, - final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456", - body=HTML_BASELINE, - error=None, - ) - ] - ) - ) - with pytest.raises(RunAlreadyInProgressError): - await service.run_for_user( - db_session, - user_id=user_id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=0, - network_error_retries=0, - retry_backoff_seconds=0, - ) - finally: - await lock_session.execute( - text("SELECT pg_advisory_unlock(:namespace, :user_key)"), - { - "namespace": RUN_LOCK_NAMESPACE, - "user_key": user_id, - }, - ) - await lock_session.close() - await engine.dispose() diff --git a/tests/integration/test_queue_ui.py b/tests/integration/test_queue_ui.py deleted file mode 100644 index 602ed60..0000000 --- a/tests/integration/test_queue_ui.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.main import app -from tests.integration.helpers import extract_csrf_token, insert_user, login_user - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_runs_page_queue_actions_lifecycle(db_session: AsyncSession) -> None: - user_id = await insert_user( - db_session, - email="queue-ui@example.com", - password="queue-ui-password", - ) - scholar_result = await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, true) - RETURNING id - """ - ), - { - "user_id": user_id, - "scholar_id": "abcDEF123456", - "display_name": "Queue UI Scholar", - }, - ) - scholar_profile_id = int(scholar_result.scalar_one()) - - queue_result = await db_session.execute( - text( - """ - INSERT INTO ingestion_queue_items ( - user_id, - scholar_profile_id, - resume_cstart, - reason, - status, - attempt_count, - next_attempt_dt, - last_error, - dropped_reason, - dropped_at - ) - VALUES ( - :user_id, - :scholar_profile_id, - 200, - 'dropped', - 'dropped', - 3, - NOW() + INTERVAL '30 minutes', - 'captcha challenge', - 'max_attempts_after_run', - NOW() - INTERVAL '1 minute' - ) - RETURNING id - """ - ), - { - "user_id": user_id, - "scholar_profile_id": scholar_profile_id, - }, - ) - queue_item_id = int(queue_result.scalar_one()) - await db_session.commit() - - client = TestClient(app) - login_user(client, email="queue-ui@example.com", password="queue-ui-password") - - runs_page = client.get("/runs") - assert runs_page.status_code == 200 - assert "Continuation Queue" in runs_page.text - assert "Queue UI Scholar" in runs_page.text - assert "dropped" in runs_page.text - assert "max_attempts_after_run" in runs_page.text - - csrf_retry = extract_csrf_token(runs_page.text) - retry_response = client.post( - f"/runs/queue/{queue_item_id}/retry", - data={"csrf_token": csrf_retry}, - follow_redirects=False, - ) - assert retry_response.status_code == 303 - - queue_after_retry = await db_session.execute( - text( - """ - SELECT status, reason, attempt_count - FROM ingestion_queue_items - WHERE id = :queue_item_id - """ - ), - {"queue_item_id": queue_item_id}, - ) - assert queue_after_retry.one() == ("queued", "manual_retry", 0) - - runs_page_after_retry = client.get("/runs") - csrf_drop = extract_csrf_token(runs_page_after_retry.text) - drop_response = client.post( - f"/runs/queue/{queue_item_id}/drop", - data={"csrf_token": csrf_drop}, - follow_redirects=False, - ) - assert drop_response.status_code == 303 - - queue_after_drop = await db_session.execute( - text( - """ - SELECT status, reason, dropped_reason - FROM ingestion_queue_items - WHERE id = :queue_item_id - """ - ), - {"queue_item_id": queue_item_id}, - ) - assert queue_after_drop.one() == ("dropped", "dropped", "manual_drop") - - runs_page_after_drop = client.get("/runs") - csrf_clear = extract_csrf_token(runs_page_after_drop.text) - clear_response = client.post( - f"/runs/queue/{queue_item_id}/clear", - data={"csrf_token": csrf_clear}, - follow_redirects=False, - ) - assert clear_response.status_code == 303 - - queue_after_clear = await db_session.execute( - text("SELECT COUNT(*) FROM ingestion_queue_items WHERE id = :queue_item_id"), - {"queue_item_id": queue_item_id}, - ) - assert queue_after_clear.scalar_one() == 0 - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_runs_queue_actions_are_tenant_scoped(db_session: AsyncSession) -> None: - user_a_id = await insert_user( - db_session, - email="queue-owner-a@example.com", - password="queue-owner-a-password", - ) - user_b_id = await insert_user( - db_session, - email="queue-owner-b@example.com", - password="queue-owner-b-password", - ) - - scholar_result = await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, true) - RETURNING id - """ - ), - { - "user_id": user_b_id, - "scholar_id": "zxyWVU654321", - "display_name": "Owner B Queue Scholar", - }, - ) - scholar_profile_id = int(scholar_result.scalar_one()) - queue_result = await db_session.execute( - text( - """ - INSERT INTO ingestion_queue_items ( - user_id, - scholar_profile_id, - resume_cstart, - reason, - status, - attempt_count, - next_attempt_dt - ) - VALUES ( - :user_id, - :scholar_profile_id, - 0, - 'max_pages_reached', - 'queued', - 0, - NOW() - ) - RETURNING id - """ - ), - { - "user_id": user_b_id, - "scholar_profile_id": scholar_profile_id, - }, - ) - queue_item_id = int(queue_result.scalar_one()) - await db_session.commit() - - client = TestClient(app) - login_user(client, email="queue-owner-a@example.com", password="queue-owner-a-password") - - runs_page = client.get("/runs") - csrf_token = extract_csrf_token(runs_page.text) - forbidden_retry = client.post( - f"/runs/queue/{queue_item_id}/retry", - data={"csrf_token": csrf_token}, - follow_redirects=False, - ) - assert forbidden_retry.status_code == 404 - - unchanged = await db_session.execute( - text( - """ - SELECT status, reason, user_id - FROM ingestion_queue_items - WHERE id = :queue_item_id - """ - ), - {"queue_item_id": queue_item_id}, - ) - assert unchanged.one() == ("queued", "max_pages_reached", user_b_id) - assert user_a_id != user_b_id diff --git a/tests/integration/test_tenant_routes.py b/tests/integration/test_tenant_routes.py deleted file mode 100644 index e5f7d59..0000000 --- a/tests/integration/test_tenant_routes.py +++ /dev/null @@ -1,197 +0,0 @@ -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.main import app -from tests.integration.helpers import extract_csrf_token, insert_user, login_user - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_user_can_change_own_password(db_session: AsyncSession) -> None: - await insert_user( - db_session, - email="reader@example.com", - password="old-password", - ) - - client = TestClient(app) - login_user(client, email="reader@example.com", password="old-password") - - account_page = client.get("/account/password") - csrf_token = extract_csrf_token(account_page.text) - wrong_current = client.post( - "/account/password", - data={ - "current_password": "wrong-password", - "new_password": "new-password", - "confirm_password": "new-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - assert wrong_current.status_code == 303 - assert wrong_current.headers["location"].startswith("/account/password") - - account_page_retry = client.get("/account/password") - retry_csrf = extract_csrf_token(account_page_retry.text) - success = client.post( - "/account/password", - data={ - "current_password": "old-password", - "new_password": "new-password", - "confirm_password": "new-password", - "csrf_token": retry_csrf, - }, - follow_redirects=False, - ) - assert success.status_code == 303 - - account_page_after = client.get("/account/password") - logout_csrf = extract_csrf_token(account_page_after.text) - client.post("/logout", data={"csrf_token": logout_csrf}, follow_redirects=False) - - failed_login_page = client.get("/login") - failed_login_csrf = extract_csrf_token(failed_login_page.text) - failed_login = client.post( - "/login", - data={ - "email": "reader@example.com", - "password": "old-password", - "csrf_token": failed_login_csrf, - }, - follow_redirects=False, - ) - assert failed_login.status_code == 401 - - login_user(client, email="reader@example.com", password="new-password") - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_scholar_routes_are_tenant_scoped(db_session: AsyncSession) -> None: - user_a_id = await insert_user( - db_session, - email="owner-a@example.com", - password="owner-a-password", - ) - user_b_id = await insert_user( - db_session, - email="owner-b@example.com", - password="owner-b-password", - ) - - client = TestClient(app) - login_user(client, email="owner-a@example.com", password="owner-a-password") - - scholars_page = client.get("/scholars") - csrf_token = extract_csrf_token(scholars_page.text) - create_response = client.post( - "/scholars", - data={ - "scholar_id": "abcDEF123456", - "display_name": "Owner A Scholar", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - assert create_response.status_code == 303 - - owner_a_row = await db_session.execute( - text( - """ - SELECT user_id - FROM scholar_profiles - WHERE scholar_id = 'abcDEF123456' - """ - ) - ) - assert owner_a_row.scalar_one() == user_a_id - - owner_b_profile = await db_session.execute( - text( - """ - INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled) - VALUES (:user_id, :scholar_id, :display_name, :is_enabled) - RETURNING id - """ - ), - { - "user_id": user_b_id, - "scholar_id": "zxcvbn654321", - "display_name": "Owner B Scholar", - "is_enabled": True, - }, - ) - owner_b_profile_id = int(owner_b_profile.scalar_one()) - await db_session.commit() - - scholars_page_after_insert = client.get("/scholars") - csrf_after_insert = extract_csrf_token(scholars_page_after_insert.text) - forbidden_toggle = client.post( - f"/scholars/{owner_b_profile_id}/toggle", - data={"csrf_token": csrf_after_insert}, - follow_redirects=False, - ) - assert forbidden_toggle.status_code == 404 - - owner_b_status = await db_session.execute( - text("SELECT is_enabled FROM scholar_profiles WHERE id = :profile_id"), - {"profile_id": owner_b_profile_id}, - ) - assert owner_b_status.scalar_one() is True - - -@pytest.mark.integration -@pytest.mark.db -@pytest.mark.asyncio -async def test_settings_updates_are_user_scoped(db_session: AsyncSession) -> None: - user_a_id = await insert_user( - db_session, - email="settings-a@example.com", - password="settings-a-password", - ) - user_b_id = await insert_user( - db_session, - email="settings-b@example.com", - password="settings-b-password", - ) - - client = TestClient(app) - login_user(client, email="settings-a@example.com", password="settings-a-password") - - settings_page = client.get("/settings") - csrf_token = extract_csrf_token(settings_page.text) - response = client.post( - "/settings", - data={ - "auto_run_enabled": "on", - "run_interval_minutes": "45", - "request_delay_seconds": "7", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - assert response.status_code == 303 - - user_a_settings = await db_session.execute( - text( - """ - SELECT auto_run_enabled, run_interval_minutes, request_delay_seconds - FROM user_settings - WHERE user_id = :user_id - """ - ), - {"user_id": user_a_id}, - ) - assert user_a_settings.one() == (True, 45, 7) - - user_b_settings = await db_session.execute( - text("SELECT COUNT(*) FROM user_settings WHERE user_id = :user_id"), - {"user_id": user_b_id}, - ) - assert user_b_settings.scalar_one() == 0 - diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py deleted file mode 100644 index d471996..0000000 --- a/tests/unit/test_auth.py +++ /dev/null @@ -1,204 +0,0 @@ -import re -from collections.abc import AsyncIterator -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from fastapi.testclient import TestClient -import pytest - -from app.auth.deps import get_auth_service, get_login_rate_limiter -from app.auth.rate_limit import SlidingWindowRateLimiter -from app.db.session import get_db_session -from app.main import app - -CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"') - - -class StubAuthService: - def __init__(self, *, user: object | None) -> None: - self._user = user - - async def authenticate_user(self, _db_session, *, email: str, password: str): - if self._user is None: - return None - if email.strip().lower() != str(self._user.email): - return None - if password != "correct-password": - return None - return self._user - - -def _extract_csrf_token(html: str) -> str: - match = CSRF_TOKEN_PATTERN.search(html) - assert match is not None - return match.group(1) - - -async def _override_db_session() -> AsyncIterator[object]: - yield object() - - -@pytest.fixture(autouse=True) -def clear_dependency_overrides() -> AsyncIterator[None]: - app.dependency_overrides.clear() - yield - app.dependency_overrides.clear() - - -def test_login_requires_csrf_token() -> None: - client = TestClient(app) - - response = client.post( - "/login", - data={"email": "user@example.com", "password": "correct-password"}, - follow_redirects=False, - ) - - assert response.status_code == 403 - assert response.text == "CSRF token missing." - - -def test_successful_login_creates_session_and_allows_dashboard(monkeypatch) -> None: - limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60) - app.dependency_overrides[get_db_session] = _override_db_session - app.dependency_overrides[get_auth_service] = lambda: StubAuthService( - user=SimpleNamespace(id=1, email="user@example.com", is_admin=False) - ) - app.dependency_overrides[get_login_rate_limiter] = lambda: limiter - client = TestClient(app) - monkeypatch.setattr( - "app.web.common.get_authenticated_user", - AsyncMock( - return_value=SimpleNamespace( - id=1, - email="user@example.com", - is_admin=False, - ) - ), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user", - AsyncMock(return_value=[]), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.run_service.list_recent_runs_for_user", - AsyncMock(return_value=[]), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.run_service.queue_status_counts_for_user", - AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.user_settings_service.get_or_create_settings", - AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)), - ) - - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - - login_response = client.post( - "/login", - data={ - "email": "user@example.com", - "password": "correct-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - - assert login_response.status_code == 303 - assert login_response.headers["location"] == "/" - - dashboard_response = client.get("/") - assert dashboard_response.status_code == 200 - assert 'data-test="home-hero"' in dashboard_response.text - assert 'data-test="session-user"' in dashboard_response.text - assert "user@example.com" in dashboard_response.text - - -def test_login_rate_limiting_returns_429_after_threshold() -> None: - limiter = SlidingWindowRateLimiter(max_attempts=2, window_seconds=60) - app.dependency_overrides[get_db_session] = _override_db_session - app.dependency_overrides[get_auth_service] = lambda: StubAuthService(user=None) - app.dependency_overrides[get_login_rate_limiter] = lambda: limiter - client = TestClient(app) - - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - payload = { - "email": "user@example.com", - "password": "wrong-password", - "csrf_token": csrf_token, - } - - first = client.post("/login", data=payload, follow_redirects=False) - second = client.post("/login", data=payload, follow_redirects=False) - third = client.post("/login", data=payload, follow_redirects=False) - - assert first.status_code == 401 - assert second.status_code == 401 - assert third.status_code == 429 - assert third.headers["Retry-After"] == "60" - - -def test_logout_requires_csrf_token_and_clears_session(monkeypatch) -> None: - limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60) - app.dependency_overrides[get_db_session] = _override_db_session - app.dependency_overrides[get_auth_service] = lambda: StubAuthService( - user=SimpleNamespace(id=1, email="user@example.com", is_admin=False) - ) - app.dependency_overrides[get_login_rate_limiter] = lambda: limiter - client = TestClient(app) - monkeypatch.setattr( - "app.web.common.get_authenticated_user", - AsyncMock( - side_effect=[ - SimpleNamespace(id=1, email="user@example.com", is_admin=False), - None, - ] - ), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user", - AsyncMock(return_value=[]), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.run_service.list_recent_runs_for_user", - AsyncMock(return_value=[]), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.run_service.queue_status_counts_for_user", - AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}), - ) - monkeypatch.setattr( - "app.web.routers.dashboard.user_settings_service.get_or_create_settings", - AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)), - ) - - login_page = client.get("/login") - csrf_token = _extract_csrf_token(login_page.text) - client.post( - "/login", - data={ - "email": "user@example.com", - "password": "correct-password", - "csrf_token": csrf_token, - }, - follow_redirects=False, - ) - - failed_logout = client.post("/logout", data={}, follow_redirects=False) - assert failed_logout.status_code == 403 - assert failed_logout.text == "CSRF token invalid." - - dashboard_page = client.get("/") - logout_token = _extract_csrf_token(dashboard_page.text) - successful_logout = client.post( - "/logout", - data={"csrf_token": logout_token}, - follow_redirects=False, - ) - - assert successful_logout.status_code == 303 - assert successful_logout.headers["location"] == "/login" - assert client.get("/", follow_redirects=False).headers["location"] == "/login" diff --git a/tests/unit/test_dashboard_view_model.py b/tests/unit/test_dashboard_view_model.py deleted file mode 100644 index 70bb34a..0000000 --- a/tests/unit/test_dashboard_view_model.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -from app.db.models import CrawlRun, RunStatus, RunTriggerType -from app.presentation.dashboard import SECTION_TEMPLATES, build_dashboard_view_model -from app.services.publications import UnreadPublicationItem - - -def test_build_dashboard_view_model_maps_sections_and_unread_items() -> None: - unread_items = [ - UnreadPublicationItem( - publication_id=10, - scholar_profile_id=3, - scholar_label="Ada Lovelace", - title="Analytical Engine Notes", - year=None, - citation_count=42, - venue_text="Computing Letters", - pub_url="https://example.test/pub-10", - ) - ] - runs = [ - CrawlRun( - id=77, - user_id=1, - trigger_type=RunTriggerType.MANUAL, - status=RunStatus.SUCCESS, - start_dt=datetime(2026, 2, 16, 18, 0, tzinfo=timezone.utc), - scholar_count=1, - new_pub_count=1, - error_log={}, - ) - ] - - vm = build_dashboard_view_model( - unread_publications=unread_items, - recent_runs=runs, - request_delay_seconds=7, - queue_counts={"queued": 2, "retrying": 1, "dropped": 3}, - ) - - assert vm.section_templates == SECTION_TEMPLATES - assert vm.run_controls.request_delay_seconds == 7 - assert vm.run_controls.queue_queued_count == 2 - assert vm.run_controls.queue_retrying_count == 1 - assert vm.run_controls.queue_dropped_count == 3 - assert vm.unread_publications[0].title == "Analytical Engine Notes" - assert vm.unread_publications[0].year_display == "-" - assert vm.unread_publications[0].citation_count == 42 - assert vm.run_history[0].status_label == "success" - assert vm.run_history[0].status_badge == "ok" - assert vm.run_history[0].started_at_display == "2026-02-16 18:00 UTC" - assert vm.run_history[0].detail_url == "/runs/77" - - -def test_build_dashboard_view_model_maps_failed_and_partial_statuses() -> None: - runs = [ - CrawlRun( - id=11, - user_id=1, - trigger_type=RunTriggerType.MANUAL, - status=RunStatus.FAILED, - start_dt=datetime(2026, 2, 16, 19, 0, tzinfo=timezone.utc), - scholar_count=2, - new_pub_count=0, - error_log={}, - ), - CrawlRun( - id=12, - user_id=1, - trigger_type=RunTriggerType.MANUAL, - status=RunStatus.PARTIAL_FAILURE, - start_dt=datetime(2026, 2, 16, 20, 0, tzinfo=timezone.utc), - scholar_count=2, - new_pub_count=1, - error_log={}, - ), - ] - - vm = build_dashboard_view_model( - unread_publications=[], - recent_runs=runs, - request_delay_seconds=1, - ) - - assert [item.status_badge for item in vm.run_history] == ["danger", "warn"] - assert [item.status_label for item in vm.run_history] == ["failed", "partial_failure"] diff --git a/tests/unit/test_healthz.py b/tests/unit/test_healthz.py index a1776c3..32a993c 100644 --- a/tests/unit/test_healthz.py +++ b/tests/unit/test_healthz.py @@ -6,7 +6,7 @@ from app.main import app def test_healthz_returns_200_when_database_is_available(monkeypatch) -> None: - monkeypatch.setattr("app.web.routers.health.check_database", AsyncMock(return_value=True)) + monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True)) client = TestClient(app) response = client.get("/healthz") @@ -16,7 +16,7 @@ def test_healthz_returns_200_when_database_is_available(monkeypatch) -> None: def test_healthz_returns_500_when_database_is_unavailable(monkeypatch) -> None: - monkeypatch.setattr("app.web.routers.health.check_database", AsyncMock(return_value=False)) + monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=False)) client = TestClient(app) response = client.get("/healthz") diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index ccd9293..84cf882 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -3,12 +3,13 @@ from __future__ import annotations import json import logging import re +from unittest.mock import AsyncMock from fastapi.testclient import TestClient from app.logging_config import JsonLogFormatter, parse_redact_fields from app.main import app -from app.web.middleware import REQUEST_ID_HEADER, parse_skip_paths +from app.http.middleware import REQUEST_ID_HEADER, parse_skip_paths def test_json_log_formatter_redacts_sensitive_fields() -> None: @@ -39,14 +40,16 @@ def test_json_log_formatter_redacts_sensitive_fields() -> None: assert "color_message" not in payload -def test_request_logging_middleware_sets_request_id_header() -> None: +def test_request_logging_middleware_sets_request_id_header(monkeypatch) -> None: + monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True)) client = TestClient(app) - - response = client.get("/login", headers={REQUEST_ID_HEADER: "request-123"}) - + response = client.get("/healthz", headers={REQUEST_ID_HEADER: "request-123"}) assert response.status_code == 200 assert response.headers[REQUEST_ID_HEADER] == "request-123" def test_parse_skip_paths_trims_and_discards_empty_segments() -> None: - assert parse_skip_paths(" /healthz , , /static/ ") == ("/healthz", "/static/") + assert parse_skip_paths(" /healthz , , /api/v1/metrics ") == ( + "/healthz", + "/api/v1/metrics", + ) diff --git a/tests/unit/test_pages.py b/tests/unit/test_pages.py deleted file mode 100644 index 2f0a003..0000000 --- a/tests/unit/test_pages.py +++ /dev/null @@ -1,54 +0,0 @@ -from fastapi.testclient import TestClient - -from app.main import app - - -def test_home_page_redirects_to_login_when_unauthenticated() -> None: - client = TestClient(app) - - response = client.get("/", follow_redirects=False) - - assert response.status_code == 303 - assert response.headers["location"] == "/login" - - -def test_dev_ui_page_is_removed() -> None: - client = TestClient(app) - - response = client.get("/dev/ui", follow_redirects=False) - - assert response.status_code == 404 - - -def test_login_page_renders_html() -> None: - client = TestClient(app) - - response = client.get("/login") - - assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/html") - assert "scholarr" in response.text - assert 'data-test="login-form"' in response.text - assert 'name="csrf_token"' in response.text - assert 'data-theme-control' in response.text - set_cookie = response.headers["set-cookie"].lower() - assert "httponly" in set_cookie - assert "samesite=lax" in set_cookie - - -def test_theme_query_parameter_accepts_supported_theme() -> None: - client = TestClient(app) - - response = client.get("/login?theme=spruce") - - assert response.status_code == 200 - assert 'data-theme="spruce"' in response.text - - -def test_theme_query_parameter_falls_back_for_unknown_theme() -> None: - client = TestClient(app) - - response = client.get("/login?theme=not-a-theme") - - assert response.status_code == 200 - assert 'data-theme="terracotta"' in response.text diff --git a/tests/unit/test_phase2_pages.py b/tests/unit/test_phase2_pages.py deleted file mode 100644 index cf2a308..0000000 --- a/tests/unit/test_phase2_pages.py +++ /dev/null @@ -1,20 +0,0 @@ -from fastapi.testclient import TestClient - -from app.main import app - - -def test_phase2_pages_redirect_to_login_when_unauthenticated() -> None: - client = TestClient(app) - - for path in ( - "/users", - "/runs", - "/runs/1", - "/publications", - "/scholars", - "/settings", - "/account/password", - ): - response = client.get(path, follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"] == "/login" diff --git a/uv.lock b/uv.lock index 571b45e..8fde75a 100644 --- a/uv.lock +++ b/uv.lock @@ -346,18 +346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, -] - [[package]] name = "mako" version = "1.3.10" @@ -588,15 +576,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, ] -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -653,8 +632,6 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -674,10 +651,8 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.116,<0.117" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28,<0.29" }, { name = "itsdangerous", specifier = ">=2.2,<3.0" }, - { name = "jinja2", specifier = ">=3.1,<3.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<9.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25,<0.26" }, - { name = "python-multipart", specifier = ">=0.0.20,<0.0.21" }, { name = "sqlalchemy", specifier = ">=2.0,<2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<0.35" }, ] -- 2.49.1 From efd21a7297f4e4fdc1a8e8518ab305cb57b8d36b Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Tue, 17 Feb 2026 20:23:48 +0100 Subject: [PATCH 2/3] initial UI commit --- .../profile_AAAAAAAAAAAA.html | 421 --------- .../profile_LZ5D_p4AAAAJ.html | 76 -- .../profile_P1RwlvoAAAAJ.html | 76 -- .../profile_RxmmtT8AAAAJ.html | 76 -- .../profile_amIMrIEAAAAJ.html | 76 -- .../fixtures/run_20260216T182029Z/robots.txt | 24 - .../profile_AAAAAAAAAAAA.html | 421 --------- .../profile_LZ5D_p4AAAAJ.html | 76 -- .../profile_P1RwlvoAAAAJ.html | 76 -- .../profile_RxmmtT8AAAAJ.html | 76 -- .../profile_amIMrIEAAAAJ.html | 76 -- .../fixtures/run_20260216T182157Z/robots.txt | 24 - .../profile_AAAAAAAAAAAA.html | 421 --------- .../profile_LZ5D_p4AAAAJ.html | 76 -- .../profile_P1RwlvoAAAAJ.html | 76 -- .../profile_RxmmtT8AAAAJ.html | 76 -- .../profile_amIMrIEAAAAJ.html | 76 -- .../fixtures/run_20260216T182334Z/robots.txt | 24 - .../probe_report_run_20260216T182029Z.json | 210 ----- .../probe_report_run_20260216T182029Z.md | 69 -- .../probe_report_run_20260216T182142Z.json | 658 -------------- .../probe_report_run_20260216T182142Z.md | 76 -- .../probe_report_run_20260216T182157Z.json | 719 --------------- .../probe_report_run_20260216T182157Z.md | 82 -- .../probe_report_run_20260216T182303Z.json | 831 ------------------ .../probe_report_run_20260216T182303Z.md | 83 -- .../probe_report_run_20260216T182320Z.json | 681 -------------- .../probe_report_run_20260216T182320Z.md | 78 -- .../probe_report_run_20260216T182334Z.json | 742 ---------------- .../probe_report_run_20260216T182334Z.md | 84 -- .../notes/seed_scholar_ids.txt | 6 - .../scripts/scholar_probe.py | 777 ---------------- 32 files changed, 7343 deletions(-) delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_AAAAAAAAAAAA.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_LZ5D_p4AAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_P1RwlvoAAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_RxmmtT8AAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_amIMrIEAAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/robots.txt delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_AAAAAAAAAAAA.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_LZ5D_p4AAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_P1RwlvoAAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_RxmmtT8AAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_amIMrIEAAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/robots.txt delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_AAAAAAAAAAAA.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_LZ5D_p4AAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_P1RwlvoAAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_RxmmtT8AAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_amIMrIEAAAAJ.html delete mode 100644 planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/robots.txt delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.json delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.md delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.json delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.md delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.json delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.md delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.json delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.md delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.json delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.md delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.json delete mode 100644 planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.md delete mode 100644 planning/scholar_probe_tmp/notes/seed_scholar_ids.txt delete mode 100755 planning/scholar_probe_tmp/scripts/scholar_probe.py diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_AAAAAAAAAAAA.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_AAAAAAAAAAAA.html deleted file mode 100644 index 3ddd673..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_AAAAAAAAAAAA.html +++ /dev/null @@ -1,421 +0,0 @@ -Google Scholar Citations

Sign in

to continue to Google Scholar Citations
CAPTCHA image of text used to distinguish humans from robots
Not your computer? Use Private Browsing windows to sign in. Learn more about using Guest mode
  • Afrikaans
  • azərbaycan
  • bosanski
  • català
  • Čeština
  • Cymraeg
  • Dansk
  • Deutsch
  • eesti
  • English (United Kingdom)
  • English (United States)
  • Español (España)
  • Español (Latinoamérica)
  • euskara
  • Filipino
  • Français (Canada)
  • Français (France)
  • Gaeilge
  • galego
  • Hrvatski
  • Indonesia
  • isiZulu
  • íslenska
  • Italiano
  • Kiswahili
  • latviešu
  • lietuvių
  • magyar
  • Melayu
  • Nederlands
  • norsk
  • o‘zbek
  • polski
  • Português (Brasil)
  • Português (Portugal)
  • română
  • shqip
  • Slovenčina
  • slovenščina
  • srpski (latinica)
  • Suomi
  • Svenska
  • Tiếng Việt
  • Türkçe
  • Ελληνικά
  • беларуская
  • български
  • кыргызча
  • қазақ тілі
  • македонски
  • монгол
  • Русский
  • српски (ћирилица)
  • Українська
  • ქართული
  • հայերեն
  • ‫עברית‬‎
  • ‫اردو‬‎
  • ‫العربية‬‎
  • ‫فارسی‬‎
  • አማርኛ
  • नेपाली
  • मराठी
  • हिन्दी
  • অসমীয়া
  • বাংলা
  • ਪੰਜਾਬੀ
  • ગુજરાતી
  • ଓଡ଼ିଆ
  • தமிழ்
  • తెలుగు
  • ಕನ್ನಡ
  • മലയാളം
  • සිංහල
  • ไทย
  • ລາວ
  • မြန်မာ
  • ខ្មែរ
  • 한국어
  • 中文(香港)
  • 日本語
  • 简体中文
  • 繁體中文
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_LZ5D_p4AAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_LZ5D_p4AAAAJ.html deleted file mode 100644 index a9fa214..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_LZ5D_p4AAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪Doaa Elmatary‬ - ‪Google Scholar‬
Follow
Doaa Elmatary
Doaa Elmatary
مدرس هندسة الاتصالات في معهد الصفوة العالي للهندسة
Verified email at alsafwa.edu.eg
Title
Cited by
Cited by
Year
Performance comparison of various machine learning approaches to identify the best one in predicting heart disease
EM Abd Allah, DE El-Matary, EM Eid, AST El Dien
Journal of Computer and Communications 10 (2), 1-18, 2022
252022
Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks
EAAA Hagras, D El-Saied, HH Aly
2011 28th National Radio Science Conference (NRSC), 1-9, 2011
152011
A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks
EA Hagras, D El-Saied, HH Aly
International Journal of Computer Science and Technology 2 (2), 19-23, 2011
62011
Performance of polar codes for OFDM-based UWB channel
DEE Matary, EA Hagras, HM Abdel-Kader
Journal of Computer and Communications 6 (03), 102-117, 2018
42018
Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel
DEE Matary, EAA Hagras, HM Abdel-Kader
American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017
32017
Doaa El-Saied, Dr. Hazem H. Aly,“A New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks”
EAAA Hagras
International Journal of Computer Science and Technology 2 (2), 19-23, 2011
22011
Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel
DE El-Matary, EAA Hagras, HM Abdel-Kader
12013
Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing
DE Elmatary
Journal of Computer and Communications, 120-134, 2023
2023
Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic
E M AbdAllah, D E El Matary
Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022
2022
Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system
DE El Matary, EAA Hagras, HM Abdel-Kader
2018 35th National Radio Science Conference (NRSC), 283-292, 2018
2018
Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel
HMAK Doaa E.El-Matary, Esam A.A. Hagras
International Journal of Scientific & Engineering Research, 2013
2013
Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System
DEE Matary, EAA Hagras, HM Abdel-Kader
The system can't perform the operation now. Try again later.
Articles 1–12
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_P1RwlvoAAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_P1RwlvoAAAAJ.html deleted file mode 100644 index fac3935..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_P1RwlvoAAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪WENRUI ZUO‬ - ‪Google Scholar‬
Follow
WENRUI ZUO
Title
Cited by
Cited by
Year
Set in stone: Analysis of an immutable web3 social media platform
W Zuo, A Raman, RJ Mondragón, G Tyson
Proceedings of the ACM Web Conference 2023, 1865-1874, 2023
182023
Understanding and improving content moderation in web3 platforms
W Zuo, RJ Mondragon, A Raman, G Tyson
Proceedings of the International AAAI Conference on Web and Social Media 18 …, 2024
102024
A first look at user-controlled moderation on web3 social media: The case of memo. cash
W Zuo, A Raman, RJ MondragÓN, G Tyson
Proceedings of the 3rd International Workshop on Open Challenges in Online …, 2023
72023
Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience
Y Liu, RM Williams, G Xie, Y Wang, W Zuo
arXiv preprint arXiv:2410.03532, 2024
42024
Understanding and Improving User-controlled Content Moderation Systems on Social Media
W Zuo
Queen Mary University of London, 2025
2025
The system can't perform the operation now. Try again later.
Articles 1–5
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_RxmmtT8AAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_RxmmtT8AAAAJ.html deleted file mode 100644 index 1ab0532..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_RxmmtT8AAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪K. Srinivasan‬ - ‪Google Scholar‬
Follow
K. Srinivasan
Title
Cited by
Cited by
Year
Black pepper and its pungent principle-piperine: a review of diverse physiological effects
K Srinivasan
Critical reviews in food science and nutrition 47 (8), 735-748, 2007
11852007
Digestive stimulant action of spices: a myth or reality?
K Platel, K Srinivasan
Indian Journal of Medical Research 119 (5), 167, 2004
7422004
Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats
K Platel, K Srinivasan
Food/Nahrung 44 (1), 42-46, 2000
6822000
Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects
K Srinivasan
Food reviews international 22 (2), 203-224, 2006
6632006
Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review
K Srinivasan
Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016
5742016
Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts
K Srinivasan
International journal of food sciences and nutrition 56 (6), 399-414, 2005
5552005
Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats
PS Babu, K Srinivasan
Molecular and cellular biochemistry 166 (1), 169-175, 1997
5391997
Spices as influencers of body metabolism: an overview of three decades of research
K Srinivasan
Food Research International 38 (1), 77-86, 2005
5132005
Role of spices beyond food flavoring: Nutraceuticals with multiple health effects
K Srinivasan
Food reviews international 21 (2), 167-188, 2005
4552005
Antioxidant potential of spices and their active constituents
K Srinivasan
Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013
4462013
Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.
D Suresh, K Srinivasan
Indian Journal of Medical Research 131, 682-691, 2010
3872010
In vitro influence of spices and spice‐active principles on digestive enzymes of rat pancreas and small intestine
R Ramakrishna Rao, K Platel, K Srinivasan
Food/Nahrung 47 (6), 408-412, 2003
3682003
Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects
K Srinivasan
Food quality and safety 2 (1), 1-16, 2018
3672018
Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials
K Srinivasan
PharmaNutrition 5 (1), 18-28, 2017
3642017
Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats
K Platel, K Srinivasan
International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996
3641996
Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents
K Platel, K Srinivasan
Food/Nahrung 41 (2), 68-74, 1997
2961997
The effect of spices on cholesterol 7α-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.
K Srinivasan, K Sambaiah
2921991
Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India
S Hemalatha, K Platel, K Srinivasan
Food chemistry 102 (4), 1328-1336, 2007
2692007
Studies on the influence of dietary spices on food transit time in experimental rats
K Platel, K Srinivasan
Nutrition Research 21 (9), 1309-1314, 2001
2582001
Bioavailability of micronutrients from plant foods: an update
K Platel, K Srinivasan
Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016
2402016
The system can't perform the operation now. Try again later.
Articles 1–20
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_amIMrIEAAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_amIMrIEAAAAJ.html deleted file mode 100644 index b1d4ad4..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/profile_amIMrIEAAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪Bangar Raju Cherukuri‬ - ‪Google Scholar‬
Follow
Bangar Raju Cherukuri
Bangar Raju Cherukuri
Senior .NET Web Developer
Verified email at dc.gov
Title
Cited by
Cited by
Year
Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence
BR Cherukuri, V Arulkumar
2024 IEEE International Conference on Computing, Power and Communication …, 2024
1312024
Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT
BR Cherukuri
2024 IEEE International Conference on Computing, Power and Communication …, 2024
812024
AI-powered personalization: How machine learning is shaping the future of user experience
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 12 (1), 3111–3126, 2024
332024
Future of cloud computing: Innovations in multi-cloud and hybrid architectures
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019
302019
Microservices and containerization: Accelerating web development cycles
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020
272020
Ethical AI in cloud: Mitigating risks in machine learning models
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 …, 2020
182020
Quantum machine learning: Transforming cloud-based AI solutions
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020
142020
Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization
BR Cherukuri
Journal of Machine and Computing 5 (1), 2025
132025
Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 13 (1), 3302–3315, 2024
132024
Serverless computing: How to build and deploy applications without managing infrastructure
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 …, 2024
132024
Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications
BR Cherukuri
International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024
122024
Serverless revolution: Redefining application scalability and cost efficiency
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019
92019
Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024
82024
Building Scalable Web Applications: Best Practices for Backend Architecture
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024
72024
Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network
BR Cherukuri
Journal of Machine and Computing 5 (2), 2025
52025
Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization
BR Cherukuri
2024 IEEE International Conference on Computing, Power and Communication …, 2024
32024
Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce
BR Cherukuri
International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022
32022
Enhancing Web Application Performance with AI - Driven Optimization Techniques
BR Cherukuri
International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021
32021
Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024
12024
Scalable machine learning model deployment using serverless cloud architectures
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 …, 2022
12022
The system can't perform the operation now. Try again later.
Articles 1–20
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/robots.txt b/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/robots.txt deleted file mode 100644 index 50e596d..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182029Z/robots.txt +++ /dev/null @@ -1,24 +0,0 @@ -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_AAAAAAAAAAAA.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_AAAAAAAAAAAA.html deleted file mode 100644 index 5eb3896..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_AAAAAAAAAAAA.html +++ /dev/null @@ -1,421 +0,0 @@ -Google Scholar Citations

Sign in

to continue to Google Scholar Citations
CAPTCHA image of text used to distinguish humans from robots
Not your computer? Use Private Browsing windows to sign in. Learn more about using Guest mode
  • Afrikaans
  • azərbaycan
  • bosanski
  • català
  • Čeština
  • Cymraeg
  • Dansk
  • Deutsch
  • eesti
  • English (United Kingdom)
  • English (United States)
  • Español (España)
  • Español (Latinoamérica)
  • euskara
  • Filipino
  • Français (Canada)
  • Français (France)
  • Gaeilge
  • galego
  • Hrvatski
  • Indonesia
  • isiZulu
  • íslenska
  • Italiano
  • Kiswahili
  • latviešu
  • lietuvių
  • magyar
  • Melayu
  • Nederlands
  • norsk
  • o‘zbek
  • polski
  • Português (Brasil)
  • Português (Portugal)
  • română
  • shqip
  • Slovenčina
  • slovenščina
  • srpski (latinica)
  • Suomi
  • Svenska
  • Tiếng Việt
  • Türkçe
  • Ελληνικά
  • беларуская
  • български
  • кыргызча
  • қазақ тілі
  • македонски
  • монгол
  • Русский
  • српски (ћирилица)
  • Українська
  • ქართული
  • հայերեն
  • ‫עברית‬‎
  • ‫اردو‬‎
  • ‫العربية‬‎
  • ‫فارسی‬‎
  • አማርኛ
  • नेपाली
  • मराठी
  • हिन्दी
  • অসমীয়া
  • বাংলা
  • ਪੰਜਾਬੀ
  • ગુજરાતી
  • ଓଡ଼ିଆ
  • தமிழ்
  • తెలుగు
  • ಕನ್ನಡ
  • മലയാളം
  • සිංහල
  • ไทย
  • ລາວ
  • မြန်မာ
  • ខ្មែរ
  • 한국어
  • 中文(香港)
  • 日本語
  • 简体中文
  • 繁體中文
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_LZ5D_p4AAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_LZ5D_p4AAAAJ.html deleted file mode 100644 index fa93dbd..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_LZ5D_p4AAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪Doaa Elmatary‬ - ‪Google Scholar‬
Follow
Doaa Elmatary
Doaa Elmatary
مدرس هندسة الاتصالات في معهد الصفوة العالي للهندسة
Verified email at alsafwa.edu.eg
Title
Cited by
Cited by
Year
Performance comparison of various machine learning approaches to identify the best one in predicting heart disease
EM Abd Allah, DE El-Matary, EM Eid, AST El Dien
Journal of Computer and Communications 10 (2), 1-18, 2022
252022
Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks
EAAA Hagras, D El-Saied, HH Aly
2011 28th National Radio Science Conference (NRSC), 1-9, 2011
152011
A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks
EA Hagras, D El-Saied, HH Aly
International Journal of Computer Science and Technology 2 (2), 19-23, 2011
62011
Performance of polar codes for OFDM-based UWB channel
DEE Matary, EA Hagras, HM Abdel-Kader
Journal of Computer and Communications 6 (03), 102-117, 2018
42018
Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel
DEE Matary, EAA Hagras, HM Abdel-Kader
American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017
32017
Doaa El-Saied, Dr. Hazem H. Aly,“A New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks”
EAAA Hagras
International Journal of Computer Science and Technology 2 (2), 19-23, 2011
22011
Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel
DE El-Matary, EAA Hagras, HM Abdel-Kader
12013
Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing
DE Elmatary
Journal of Computer and Communications, 120-134, 2023
2023
Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic
E M AbdAllah, D E El Matary
Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022
2022
Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system
DE El Matary, EAA Hagras, HM Abdel-Kader
2018 35th National Radio Science Conference (NRSC), 283-292, 2018
2018
Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel
HMAK Doaa E.El-Matary, Esam A.A. Hagras
International Journal of Scientific & Engineering Research, 2013
2013
Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System
DEE Matary, EAA Hagras, HM Abdel-Kader
The system can't perform the operation now. Try again later.
Articles 1–12
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_P1RwlvoAAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_P1RwlvoAAAAJ.html deleted file mode 100644 index eb61184..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_P1RwlvoAAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪WENRUI ZUO‬ - ‪Google Scholar‬
Follow
WENRUI ZUO
Title
Cited by
Cited by
Year
Set in stone: Analysis of an immutable web3 social media platform
W Zuo, A Raman, RJ Mondragón, G Tyson
Proceedings of the ACM Web Conference 2023, 1865-1874, 2023
182023
Understanding and improving content moderation in web3 platforms
W Zuo, RJ Mondragon, A Raman, G Tyson
Proceedings of the International AAAI Conference on Web and Social Media 18 …, 2024
102024
A first look at user-controlled moderation on web3 social media: The case of memo. cash
W Zuo, A Raman, RJ MondragÓN, G Tyson
Proceedings of the 3rd International Workshop on Open Challenges in Online …, 2023
72023
Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience
Y Liu, RM Williams, G Xie, Y Wang, W Zuo
arXiv preprint arXiv:2410.03532, 2024
42024
Understanding and Improving User-controlled Content Moderation Systems on Social Media
W Zuo
Queen Mary University of London, 2025
2025
The system can't perform the operation now. Try again later.
Articles 1–5
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_RxmmtT8AAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_RxmmtT8AAAAJ.html deleted file mode 100644 index f4e0a65..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_RxmmtT8AAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪K. Srinivasan‬ - ‪Google Scholar‬
Follow
K. Srinivasan
Title
Cited by
Cited by
Year
Black pepper and its pungent principle-piperine: a review of diverse physiological effects
K Srinivasan
Critical reviews in food science and nutrition 47 (8), 735-748, 2007
11852007
Digestive stimulant action of spices: a myth or reality?
K Platel, K Srinivasan
Indian Journal of Medical Research 119 (5), 167, 2004
7422004
Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats
K Platel, K Srinivasan
Food/Nahrung 44 (1), 42-46, 2000
6822000
Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects
K Srinivasan
Food reviews international 22 (2), 203-224, 2006
6632006
Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review
K Srinivasan
Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016
5742016
Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts
K Srinivasan
International journal of food sciences and nutrition 56 (6), 399-414, 2005
5552005
Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats
PS Babu, K Srinivasan
Molecular and cellular biochemistry 166 (1), 169-175, 1997
5391997
Spices as influencers of body metabolism: an overview of three decades of research
K Srinivasan
Food Research International 38 (1), 77-86, 2005
5132005
Role of spices beyond food flavoring: Nutraceuticals with multiple health effects
K Srinivasan
Food reviews international 21 (2), 167-188, 2005
4552005
Antioxidant potential of spices and their active constituents
K Srinivasan
Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013
4462013
Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.
D Suresh, K Srinivasan
Indian Journal of Medical Research 131, 682-691, 2010
3872010
In vitro influence of spices and spice‐active principles on digestive enzymes of rat pancreas and small intestine
R Ramakrishna Rao, K Platel, K Srinivasan
Food/Nahrung 47 (6), 408-412, 2003
3682003
Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects
K Srinivasan
Food quality and safety 2 (1), 1-16, 2018
3672018
Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials
K Srinivasan
PharmaNutrition 5 (1), 18-28, 2017
3642017
Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats
K Platel, K Srinivasan
International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996
3641996
Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents
K Platel, K Srinivasan
Food/Nahrung 41 (2), 68-74, 1997
2961997
The effect of spices on cholesterol 7α-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.
K Srinivasan, K Sambaiah
2921991
Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India
S Hemalatha, K Platel, K Srinivasan
Food chemistry 102 (4), 1328-1336, 2007
2692007
Studies on the influence of dietary spices on food transit time in experimental rats
K Platel, K Srinivasan
Nutrition Research 21 (9), 1309-1314, 2001
2582001
Bioavailability of micronutrients from plant foods: an update
K Platel, K Srinivasan
Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016
2402016
The system can't perform the operation now. Try again later.
Articles 1–20
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_amIMrIEAAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_amIMrIEAAAAJ.html deleted file mode 100644 index f9589a1..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/profile_amIMrIEAAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪Bangar Raju Cherukuri‬ - ‪Google Scholar‬
Follow
Bangar Raju Cherukuri
Bangar Raju Cherukuri
Senior .NET Web Developer
Verified email at dc.gov
Title
Cited by
Cited by
Year
Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence
BR Cherukuri, V Arulkumar
2024 IEEE International Conference on Computing, Power and Communication …, 2024
1312024
Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT
BR Cherukuri
2024 IEEE International Conference on Computing, Power and Communication …, 2024
812024
AI-powered personalization: How machine learning is shaping the future of user experience
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 12 (1), 3111–3126, 2024
332024
Future of cloud computing: Innovations in multi-cloud and hybrid architectures
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019
302019
Microservices and containerization: Accelerating web development cycles
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020
272020
Ethical AI in cloud: Mitigating risks in machine learning models
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 …, 2020
182020
Quantum machine learning: Transforming cloud-based AI solutions
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020
142020
Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization
BR Cherukuri
Journal of Machine and Computing 5 (1), 2025
132025
Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 13 (1), 3302–3315, 2024
132024
Serverless computing: How to build and deploy applications without managing infrastructure
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 …, 2024
132024
Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications
BR Cherukuri
International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024
122024
Serverless revolution: Redefining application scalability and cost efficiency
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019
92019
Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024
82024
Building Scalable Web Applications: Best Practices for Backend Architecture
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024
72024
Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network
BR Cherukuri
Journal of Machine and Computing 5 (2), 2025
52025
Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization
BR Cherukuri
2024 IEEE International Conference on Computing, Power and Communication …, 2024
32024
Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce
BR Cherukuri
International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022
32022
Enhancing Web Application Performance with AI - Driven Optimization Techniques
BR Cherukuri
International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021
32021
Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024
12024
Scalable machine learning model deployment using serverless cloud architectures
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 …, 2022
12022
The system can't perform the operation now. Try again later.
Articles 1–20
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/robots.txt b/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/robots.txt deleted file mode 100644 index 50e596d..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182157Z/robots.txt +++ /dev/null @@ -1,24 +0,0 @@ -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_AAAAAAAAAAAA.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_AAAAAAAAAAAA.html deleted file mode 100644 index b3707e0..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_AAAAAAAAAAAA.html +++ /dev/null @@ -1,421 +0,0 @@ -Google Scholar Citations

Sign in

to continue to Google Scholar Citations
CAPTCHA image of text used to distinguish humans from robots
Not your computer? Use Private Browsing windows to sign in. Learn more about using Guest mode
  • Afrikaans
  • azərbaycan
  • bosanski
  • català
  • Čeština
  • Cymraeg
  • Dansk
  • Deutsch
  • eesti
  • English (United Kingdom)
  • English (United States)
  • Español (España)
  • Español (Latinoamérica)
  • euskara
  • Filipino
  • Français (Canada)
  • Français (France)
  • Gaeilge
  • galego
  • Hrvatski
  • Indonesia
  • isiZulu
  • íslenska
  • Italiano
  • Kiswahili
  • latviešu
  • lietuvių
  • magyar
  • Melayu
  • Nederlands
  • norsk
  • o‘zbek
  • polski
  • Português (Brasil)
  • Português (Portugal)
  • română
  • shqip
  • Slovenčina
  • slovenščina
  • srpski (latinica)
  • Suomi
  • Svenska
  • Tiếng Việt
  • Türkçe
  • Ελληνικά
  • беларуская
  • български
  • кыргызча
  • қазақ тілі
  • македонски
  • монгол
  • Русский
  • српски (ћирилица)
  • Українська
  • ქართული
  • հայերեն
  • ‫עברית‬‎
  • ‫اردو‬‎
  • ‫العربية‬‎
  • ‫فارسی‬‎
  • አማርኛ
  • नेपाली
  • मराठी
  • हिन्दी
  • অসমীয়া
  • বাংলা
  • ਪੰਜਾਬੀ
  • ગુજરાતી
  • ଓଡ଼ିଆ
  • தமிழ்
  • తెలుగు
  • ಕನ್ನಡ
  • മലയാളം
  • සිංහල
  • ไทย
  • ລາວ
  • မြန်မာ
  • ខ្មែរ
  • 한국어
  • 中文(香港)
  • 日本語
  • 简体中文
  • 繁體中文
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_LZ5D_p4AAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_LZ5D_p4AAAAJ.html deleted file mode 100644 index 034d74e..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_LZ5D_p4AAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪Doaa Elmatary‬ - ‪Google Scholar‬
Follow
Doaa Elmatary
Doaa Elmatary
مدرس هندسة الاتصالات في معهد الصفوة العالي للهندسة
Verified email at alsafwa.edu.eg
Title
Cited by
Cited by
Year
Performance comparison of various machine learning approaches to identify the best one in predicting heart disease
EM Abd Allah, DE El-Matary, EM Eid, AST El Dien
Journal of Computer and Communications 10 (2), 1-18, 2022
252022
Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks
EAAA Hagras, D El-Saied, HH Aly
2011 28th National Radio Science Conference (NRSC), 1-9, 2011
152011
A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks
EA Hagras, D El-Saied, HH Aly
International Journal of Computer Science and Technology 2 (2), 19-23, 2011
62011
Performance of polar codes for OFDM-based UWB channel
DEE Matary, EA Hagras, HM Abdel-Kader
Journal of Computer and Communications 6 (03), 102-117, 2018
42018
Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel
DEE Matary, EAA Hagras, HM Abdel-Kader
American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017
32017
Doaa El-Saied, Dr. Hazem H. Aly,“A New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks”
EAAA Hagras
International Journal of Computer Science and Technology 2 (2), 19-23, 2011
22011
Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel
DE El-Matary, EAA Hagras, HM Abdel-Kader
12013
Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing
DE Elmatary
Journal of Computer and Communications, 120-134, 2023
2023
Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic
E M AbdAllah, D E El Matary
Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022
2022
Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system
DE El Matary, EAA Hagras, HM Abdel-Kader
2018 35th National Radio Science Conference (NRSC), 283-292, 2018
2018
Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel
HMAK Doaa E.El-Matary, Esam A.A. Hagras
International Journal of Scientific & Engineering Research, 2013
2013
Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System
DEE Matary, EAA Hagras, HM Abdel-Kader
The system can't perform the operation now. Try again later.
Articles 1–12
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_P1RwlvoAAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_P1RwlvoAAAAJ.html deleted file mode 100644 index 47072eb..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_P1RwlvoAAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪WENRUI ZUO‬ - ‪Google Scholar‬
Follow
WENRUI ZUO
Title
Cited by
Cited by
Year
Set in stone: Analysis of an immutable web3 social media platform
W Zuo, A Raman, RJ Mondragón, G Tyson
Proceedings of the ACM Web Conference 2023, 1865-1874, 2023
182023
Understanding and improving content moderation in web3 platforms
W Zuo, RJ Mondragon, A Raman, G Tyson
Proceedings of the International AAAI Conference on Web and Social Media 18 …, 2024
102024
A first look at user-controlled moderation on web3 social media: The case of memo. cash
W Zuo, A Raman, RJ MondragÓN, G Tyson
Proceedings of the 3rd International Workshop on Open Challenges in Online …, 2023
72023
Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience
Y Liu, RM Williams, G Xie, Y Wang, W Zuo
arXiv preprint arXiv:2410.03532, 2024
42024
Understanding and Improving User-controlled Content Moderation Systems on Social Media
W Zuo
Queen Mary University of London, 2025
2025
The system can't perform the operation now. Try again later.
Articles 1–5
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_RxmmtT8AAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_RxmmtT8AAAAJ.html deleted file mode 100644 index 7bd6c72..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_RxmmtT8AAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪K. Srinivasan‬ - ‪Google Scholar‬
Follow
K. Srinivasan
Title
Cited by
Cited by
Year
Black pepper and its pungent principle-piperine: a review of diverse physiological effects
K Srinivasan
Critical reviews in food science and nutrition 47 (8), 735-748, 2007
11852007
Digestive stimulant action of spices: a myth or reality?
K Platel, K Srinivasan
Indian Journal of Medical Research 119 (5), 167, 2004
7422004
Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats
K Platel, K Srinivasan
Food/Nahrung 44 (1), 42-46, 2000
6822000
Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects
K Srinivasan
Food reviews international 22 (2), 203-224, 2006
6632006
Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review
K Srinivasan
Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016
5742016
Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts
K Srinivasan
International journal of food sciences and nutrition 56 (6), 399-414, 2005
5552005
Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats
PS Babu, K Srinivasan
Molecular and cellular biochemistry 166 (1), 169-175, 1997
5391997
Spices as influencers of body metabolism: an overview of three decades of research
K Srinivasan
Food Research International 38 (1), 77-86, 2005
5132005
Role of spices beyond food flavoring: Nutraceuticals with multiple health effects
K Srinivasan
Food reviews international 21 (2), 167-188, 2005
4552005
Antioxidant potential of spices and their active constituents
K Srinivasan
Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013
4462013
Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.
D Suresh, K Srinivasan
Indian Journal of Medical Research 131, 682-691, 2010
3872010
In vitro influence of spices and spice‐active principles on digestive enzymes of rat pancreas and small intestine
R Ramakrishna Rao, K Platel, K Srinivasan
Food/Nahrung 47 (6), 408-412, 2003
3682003
Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects
K Srinivasan
Food quality and safety 2 (1), 1-16, 2018
3672018
Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials
K Srinivasan
PharmaNutrition 5 (1), 18-28, 2017
3642017
Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats
K Platel, K Srinivasan
International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996
3641996
Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents
K Platel, K Srinivasan
Food/Nahrung 41 (2), 68-74, 1997
2961997
The effect of spices on cholesterol 7α-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.
K Srinivasan, K Sambaiah
2921991
Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India
S Hemalatha, K Platel, K Srinivasan
Food chemistry 102 (4), 1328-1336, 2007
2692007
Studies on the influence of dietary spices on food transit time in experimental rats
K Platel, K Srinivasan
Nutrition Research 21 (9), 1309-1314, 2001
2582001
Bioavailability of micronutrients from plant foods: an update
K Platel, K Srinivasan
Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016
2402016
The system can't perform the operation now. Try again later.
Articles 1–20
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_amIMrIEAAAAJ.html b/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_amIMrIEAAAAJ.html deleted file mode 100644 index dfa4a70..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/profile_amIMrIEAAAAJ.html +++ /dev/null @@ -1,76 +0,0 @@ -‪Bangar Raju Cherukuri‬ - ‪Google Scholar‬
Follow
Bangar Raju Cherukuri
Bangar Raju Cherukuri
Senior .NET Web Developer
Verified email at dc.gov
Title
Cited by
Cited by
Year
Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence
BR Cherukuri, V Arulkumar
2024 IEEE International Conference on Computing, Power and Communication …, 2024
1312024
Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT
BR Cherukuri
2024 IEEE International Conference on Computing, Power and Communication …, 2024
812024
AI-powered personalization: How machine learning is shaping the future of user experience
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 12 (1), 3111–3126, 2024
332024
Future of cloud computing: Innovations in multi-cloud and hybrid architectures
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019
302019
Microservices and containerization: Accelerating web development cycles
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020
272020
Ethical AI in cloud: Mitigating risks in machine learning models
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 …, 2020
182020
Quantum machine learning: Transforming cloud-based AI solutions
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020
142020
Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization
BR Cherukuri
Journal of Machine and Computing 5 (1), 2025
132025
Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications
BR Cherukuri
International Journal of Science and Research Archive (IJSRA) 13 (1), 3302–3315, 2024
132024
Serverless computing: How to build and deploy applications without managing infrastructure
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 …, 2024
132024
Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications
BR Cherukuri
International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024
122024
Serverless revolution: Redefining application scalability and cost efficiency
BR Cherukuri
World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019
92019
Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024
82024
Building Scalable Web Applications: Best Practices for Backend Architecture
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024
72024
Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network
BR Cherukuri
Journal of Machine and Computing 5 (2), 2025
52025
Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization
BR Cherukuri
2024 IEEE International Conference on Computing, Power and Communication …, 2024
32024
Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce
BR Cherukuri
International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022
32022
Enhancing Web Application Performance with AI - Driven Optimization Techniques
BR Cherukuri
International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021
32021
Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments
BR Cherukuri
International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024
12024
Scalable machine learning model deployment using serverless cloud architectures
BR Cherukuri
World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 …, 2022
12022
The system can't perform the operation now. Try again later.
Articles 1–20
\ No newline at end of file diff --git a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/robots.txt b/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/robots.txt deleted file mode 100644 index 50e596d..0000000 --- a/planning/scholar_probe_tmp/fixtures/run_20260216T182334Z/robots.txt +++ /dev/null @@ -1,24 +0,0 @@ -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.json b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.json deleted file mode 100644 index 539ca7a..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.json +++ /dev/null @@ -1,210 +0,0 @@ -{ - "fetch_records": [ - { - "elapsed_seconds": 0.053, - "error": null, - "fetched_at_utc": "2026-02-16T18:20:29.406443+00:00", - "file_name": "robots.txt", - "final_url": "https://scholar.google.com/robots.txt", - "source": "robots", - "status_code": 200, - "url": "https://scholar.google.com/robots.txt" - }, - { - "elapsed_seconds": 0.817, - "error": null, - "fetched_at_utc": "2026-02-16T18:20:30.223671+00:00", - "file_name": "profile_amIMrIEAAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ", - "source": "profile_amIMrIEAAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ" - }, - { - "elapsed_seconds": 0.33, - "error": null, - "fetched_at_utc": "2026-02-16T18:20:35.496043+00:00", - "file_name": "profile_P1RwlvoAAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ", - "source": "profile_P1RwlvoAAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ" - }, - { - "elapsed_seconds": 0.816, - "error": null, - "fetched_at_utc": "2026-02-16T18:20:40.845606+00:00", - "file_name": "profile_RxmmtT8AAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ", - "source": "profile_RxmmtT8AAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ" - }, - { - "elapsed_seconds": 0.794, - "error": null, - "fetched_at_utc": "2026-02-16T18:20:46.112935+00:00", - "file_name": "profile_LZ5D_p4AAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ", - "source": "profile_LZ5D_p4AAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ" - }, - { - "elapsed_seconds": 1.07, - "error": null, - "fetched_at_utc": "2026-02-16T18:20:52.026510+00:00", - "file_name": "profile_AAAAAAAAAAAA.html", - "final_url": "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA&dsh=S1243570986%3A1771266051695499&hl=en&ifkv=ASfE1-pN_UpkKoR95DaV7cklGidF1KWKiZ1XifD65HzS719DIUlTCnwA_hegw9JWCAi7yLsRYXLCuA&service=citations&flowName=GlifWebSignIn&flowEntry=ServiceLogin", - "source": "profile_AAAAAAAAAAAA", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=AAAAAAAAAAAA" - } - ], - "generated_at_utc": "2026-02-16T18:20:52.057911+00:00", - "page_analyses": [ - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "layout_changed" - }, - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - } - ], - "publications_by_source": { - "profile_AAAAAAAAAAAA": [], - "profile_LZ5D_p4AAAAJ": [], - "profile_P1RwlvoAAAAJ": [], - "profile_RxmmtT8AAAAJ": [], - "profile_amIMrIEAAAAJ": [] - }, - "run_dir": "planning/scholar_probe_tmp/fixtures/run_20260216T182029Z" -} \ No newline at end of file diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.md b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.md deleted file mode 100644 index ebd7acb..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182029Z.md +++ /dev/null @@ -1,69 +0,0 @@ -# Scholar Scrape Probe Report - -Generated UTC: `2026-02-16T18:20:52.057911+00:00` -Run fixtures dir: `planning/scholar_probe_tmp/fixtures/run_20260216T182029Z` - -## Robots Snapshot - -```text -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / -``` - -## Fetch Summary - -| Source | Status Code | Status/Error | Final URL | -| --- | --- | --- | --- | -| `robots` | 200 | ok | `https://scholar.google.com/robots.txt` | -| `profile_amIMrIEAAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ` | -| `profile_P1RwlvoAAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ` | -| `profile_RxmmtT8AAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ` | -| `profile_LZ5D_p4AAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ` | -| `profile_AAAAAAAAAAAA` | 200 | ok | `https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA&dsh=S1243570986%3A1771266051695499&hl=en&ifkv=ASfE1-pN_UpkKoR95DaV7cklGidF1KWKiZ1XifD65HzS719DIUlTCnwA_hegw9JWCAi7yLsRYXLCuA&service=citations&flowName=GlifWebSignIn&flowEntry=ServiceLogin` | - -## Parse Summary - -| Source | Parse Status | Profile | Publications | Warnings | -| --- | --- | --- | --- | --- | -| `profile_AAAAAAAAAAAA` | `layout_changed` | - | 0 | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | - | 0 | no_rows_detected | -| `profile_P1RwlvoAAAAJ` | `ok` | - | 0 | no_rows_detected | -| `profile_RxmmtT8AAAAJ` | `ok` | - | 0 | no_rows_detected | -| `profile_amIMrIEAAAAJ` | `ok` | - | 0 | no_rows_detected | - -## Parser Contract Recommendation - -- Primary row marker: `tr.gsc_a_tr`. -- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token. -- Metadata text markers: first/second `div.gs_gray` per row for authors and venue. -- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex. -- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`. - -## Future-Proofing Notes - -- Keep raw HTML fixture snapshots and update parser tests on DOM drift. -- Treat blocked pages as retriable with backoff, not parser errors. -- Add marker-count assertions in CI to catch silent layout shifts early. -- Use explicit parse status per run/scholar so automation can degrade gracefully. diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.json b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.json deleted file mode 100644 index 177602e..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.json +++ /dev/null @@ -1,658 +0,0 @@ -{ - "fetch_records": [], - "generated_at_utc": "2026-02-16T18:21:42.721714+00:00", - "page_analyses": [ - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "layout_changed" - }, - { - "field_presence": { - "authors_text": 12, - "citation_count": 12, - "cluster_id": 12, - "title": 12, - "venue_text": 10, - "year": 11 - }, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "Doaa Elmatary", - "publication_count": 12, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 5, - "citation_count": 5, - "cluster_id": 5, - "title": 5, - "venue_text": 5, - "year": 5 - }, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "WENRUI ZUO", - "publication_count": 5, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 19, - "year": 20 - }, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "K. Srinivasan", - "publication_count": 20, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 20, - "year": 20 - }, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "Bangar Raju Cherukuri", - "publication_count": 20, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - } - ], - "publications_by_source": { - "profile_AAAAAAAAAAAA": [], - "profile_LZ5D_p4AAAAJ": [ - { - "authors_text": "EM Abd Allah, DE El-Matary, EM Eid, AST El Dien", - "citation_count": 25, - "cluster_id": "u-x6o8ySG0sC", - "title": "Performance comparison of various machine learning approaches to identify the best one in predicting heart disease", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u-x6o8ySG0sC", - "venue_text": "Journal of Computer and Communications 10 (2), 1-18, 2022", - "year": 2022 - }, - { - "authors_text": "EAAA Hagras, D El-Saied, HH Aly", - "citation_count": 15, - "cluster_id": "YsMSGLbcyi4C", - "title": "Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:YsMSGLbcyi4C", - "venue_text": "2011 28th National Radio Science Conference (NRSC), 1-9, 2011", - "year": 2011 - }, - { - "authors_text": "EA Hagras, D El-Saied, HH Aly", - "citation_count": 6, - "cluster_id": "Y0pCki6q_DkC", - "title": "A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DEE Matary, EA Hagras, HM Abdel-Kader", - "citation_count": 4, - "cluster_id": "d1gkVwhDpl0C", - "title": "Performance of polar codes for OFDM-based UWB channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:d1gkVwhDpl0C", - "venue_text": "Journal of Computer and Communications 6 (03), 102-117, 2018", - "year": 2018 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 3, - "cluster_id": "u5HHmVD_uO8C", - "title": "Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u5HHmVD_uO8C", - "venue_text": "American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017", - "year": 2017 - }, - { - "authors_text": "EAAA Hagras", - "citation_count": 2, - "cluster_id": "W7OEmFMy1HYC", - "title": "Doaa El-Saied, Dr. Hazem H. Aly,\u201cA New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks\u201d", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DE El-Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 1, - "cluster_id": "_FxGoFyzp5QC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:_FxGoFyzp5QC", - "venue_text": null, - "year": 2013 - }, - { - "authors_text": "DE Elmatary", - "citation_count": 0, - "cluster_id": "ufrVoPGSRksC", - "title": "Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:ufrVoPGSRksC", - "venue_text": "Journal of Computer and Communications, 120-134, 2023", - "year": 2023 - }, - { - "authors_text": "E M AbdAllah, D E El Matary", - "citation_count": 0, - "cluster_id": "WF5omc3nYNoC", - "title": "Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:WF5omc3nYNoC", - "venue_text": "Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022", - "year": 2022 - }, - { - "authors_text": "DE El Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "9yKSN-GCB0IC", - "title": "Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:9yKSN-GCB0IC", - "venue_text": "2018 35th National Radio Science Conference (NRSC), 283-292, 2018", - "year": 2018 - }, - { - "authors_text": "HMAK Doaa E.El-Matary, Esam A.A. Hagras", - "citation_count": 0, - "cluster_id": "zYLM7Y9cAGgC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Scientific & Engineering Research, 2013", - "year": 2013 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "eQOLeE2rZwMC", - "title": "Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:eQOLeE2rZwMC", - "venue_text": null, - "year": null - } - ], - "profile_P1RwlvoAAAAJ": [ - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00f3n, G Tyson", - "citation_count": 18, - "cluster_id": "u5HHmVD_uO8C", - "title": "Set in stone: Analysis of an immutable web3 social media platform", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u5HHmVD_uO8C", - "venue_text": "Proceedings of the ACM Web Conference 2023, 1865-1874, 2023", - "year": 2023 - }, - { - "authors_text": "W Zuo, RJ Mondragon, A Raman, G Tyson", - "citation_count": 10, - "cluster_id": "d1gkVwhDpl0C", - "title": "Understanding and improving content moderation in web3 platforms", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:d1gkVwhDpl0C", - "venue_text": "Proceedings of the International AAAI Conference on Web and Social Media 18 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00d3N, G Tyson", - "citation_count": 7, - "cluster_id": "u-x6o8ySG0sC", - "title": "A first look at user-controlled moderation on web3 social media: The case of memo. cash", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u-x6o8ySG0sC", - "venue_text": "Proceedings of the 3rd International Workshop on Open Challenges in Online \u2026, 2023", - "year": 2023 - }, - { - "authors_text": "Y Liu, RM Williams, G Xie, Y Wang, W Zuo", - "citation_count": 4, - "cluster_id": "9yKSN-GCB0IC", - "title": "Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:9yKSN-GCB0IC", - "venue_text": "arXiv preprint arXiv:2410.03532, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo", - "citation_count": 0, - "cluster_id": "2osOgNQ5qMEC", - "title": "Understanding and Improving User-controlled Content Moderation Systems on Social Media", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:2osOgNQ5qMEC", - "venue_text": "Queen Mary University of London, 2025", - "year": 2025 - } - ], - "profile_RxmmtT8AAAAJ": [ - { - "authors_text": "K Srinivasan", - "citation_count": 1185, - "cluster_id": "qjMakFHDy7sC", - "title": "Black pepper and its pungent principle-piperine: a review of diverse physiological effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:qjMakFHDy7sC", - "venue_text": "Critical reviews in food science and nutrition 47 (8), 735-748, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 742, - "cluster_id": "W7OEmFMy1HYC", - "title": "Digestive stimulant action of spices: a myth or reality?", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:W7OEmFMy1HYC", - "venue_text": "Indian Journal of Medical Research 119 (5), 167, 2004", - "year": 2004 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 682, - "cluster_id": "u-x6o8ySG0sC", - "title": "Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:u-x6o8ySG0sC", - "venue_text": "Food/Nahrung 44 (1), 42-46, 2000", - "year": 2000 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 663, - "cluster_id": "LkGwnXOMwfcC", - "title": "Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:LkGwnXOMwfcC", - "venue_text": "Food reviews international 22 (2), 203-224, 2006", - "year": 2006 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 574, - "cluster_id": "FPJr55Dyh1AC", - "title": "Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:FPJr55Dyh1AC", - "venue_text": "Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016", - "year": 2016 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 555, - "cluster_id": "9yKSN-GCB0IC", - "title": "Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:9yKSN-GCB0IC", - "venue_text": "International journal of food sciences and nutrition 56 (6), 399-414, 2005", - "year": 2005 - }, - { - "authors_text": "PS Babu, K Srinivasan", - "citation_count": 539, - "cluster_id": "ZfRJV9d4-WMC", - "title": "Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:ZfRJV9d4-WMC", - "venue_text": "Molecular and cellular biochemistry 166 (1), 169-175, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 513, - "cluster_id": "IjCSPb-OGe4C", - "title": "Spices as influencers of body metabolism: an overview of three decades of research", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:IjCSPb-OGe4C", - "venue_text": "Food Research International 38 (1), 77-86, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 455, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Role of spices beyond food flavoring: Nutraceuticals with multiple health effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:Tyk-4Ss8FVUC", - "venue_text": "Food reviews international 21 (2), 167-188, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 446, - "cluster_id": "tkaPQYYpVKoC", - "title": "Antioxidant potential of spices and their active constituents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:tkaPQYYpVKoC", - "venue_text": "Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013", - "year": 2013 - }, - { - "authors_text": "D Suresh, K Srinivasan", - "citation_count": 387, - "cluster_id": "5nxA0vEk-isC", - "title": "Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:5nxA0vEk-isC", - "venue_text": "Indian Journal of Medical Research 131, 682-691, 2010", - "year": 2010 - }, - { - "authors_text": "R Ramakrishna Rao, K Platel, K Srinivasan", - "citation_count": 368, - "cluster_id": "YsMSGLbcyi4C", - "title": "In vitro influence of spices and spice\u2010active principles on digestive enzymes of rat pancreas and small intestine", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:YsMSGLbcyi4C", - "venue_text": "Food/Nahrung 47 (6), 408-412, 2003", - "year": 2003 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 367, - "cluster_id": "zCSUwVk65WsC", - "title": "Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:zCSUwVk65WsC", - "venue_text": "Food quality and safety 2 (1), 1-16, 2018", - "year": 2018 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 364, - "cluster_id": "yqoGN6RLRZoC", - "title": "Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:yqoGN6RLRZoC", - "venue_text": "PharmaNutrition 5 (1), 18-28, 2017", - "year": 2017 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 364, - "cluster_id": "d1gkVwhDpl0C", - "title": "Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:d1gkVwhDpl0C", - "venue_text": "International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996", - "year": 1996 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 296, - "cluster_id": "UeHWp8X0CEIC", - "title": "Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UeHWp8X0CEIC", - "venue_text": "Food/Nahrung 41 (2), 68-74, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan, K Sambaiah", - "citation_count": 292, - "cluster_id": "2osOgNQ5qMEC", - "title": "The effect of spices on cholesterol 7\u03b1-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:2osOgNQ5qMEC", - "venue_text": null, - "year": 1991 - }, - { - "authors_text": "S Hemalatha, K Platel, K Srinivasan", - "citation_count": 269, - "cluster_id": "UebtZRa9Y70C", - "title": "Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UebtZRa9Y70C", - "venue_text": "Food chemistry 102 (4), 1328-1336, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 258, - "cluster_id": "_FxGoFyzp5QC", - "title": "Studies on the influence of dietary spices on food transit time in experimental rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:_FxGoFyzp5QC", - "venue_text": "Nutrition Research 21 (9), 1309-1314, 2001", - "year": 2001 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 240, - "cluster_id": "hCrLmN-GePgC", - "title": "Bioavailability of micronutrients from plant foods: an update", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:hCrLmN-GePgC", - "venue_text": "Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016", - "year": 2016 - } - ], - "profile_amIMrIEAAAAJ": [ - { - "authors_text": "BR Cherukuri, V Arulkumar", - "citation_count": 131, - "cluster_id": "u5HHmVD_uO8C", - "title": "Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u5HHmVD_uO8C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 81, - "cluster_id": "d1gkVwhDpl0C", - "title": "Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:d1gkVwhDpl0C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 33, - "cluster_id": "zYLM7Y9cAGgC", - "title": "AI-powered personalization: How machine learning is shaping the future of user experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 12 (1), 3111\u20133126, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 30, - "cluster_id": "roLk4NBRz8UC", - "title": "Future of cloud computing: Innovations in multi-cloud and hybrid architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:roLk4NBRz8UC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 27, - "cluster_id": "UebtZRa9Y70C", - "title": "Microservices and containerization: Accelerating web development cycles", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UebtZRa9Y70C", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 18, - "cluster_id": "hqOjcs7Dif8C", - "title": "Ethical AI in cloud: Mitigating risks in machine learning models", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:hqOjcs7Dif8C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 \u2026, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 14, - "cluster_id": "0EnyYjriUFMC", - "title": "Quantum machine learning: Transforming cloud-based AI solutions", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:0EnyYjriUFMC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "WF5omc3nYNoC", - "title": "Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:WF5omc3nYNoC", - "venue_text": "Journal of Machine and Computing 5 (1), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "W7OEmFMy1HYC", - "title": "Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 13 (1), 3302\u20133315, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "IjCSPb-OGe4C", - "title": "Serverless computing: How to build and deploy applications without managing infrastructure", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:IjCSPb-OGe4C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 12, - "cluster_id": "YsMSGLbcyi4C", - "title": "Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:YsMSGLbcyi4C", - "venue_text": "International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 9, - "cluster_id": "Se3iqnhoufwC", - "title": "Serverless revolution: Redefining application scalability and cost efficiency", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Se3iqnhoufwC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 8, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Tyk-4Ss8FVUC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 7, - "cluster_id": "UeHWp8X0CEIC", - "title": "Building Scalable Web Applications: Best Practices for Backend Architecture", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UeHWp8X0CEIC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 5, - "cluster_id": "5nxA0vEk-isC", - "title": "Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:5nxA0vEk-isC", - "venue_text": "Journal of Machine and Computing 5 (2), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "u-x6o8ySG0sC", - "title": "Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u-x6o8ySG0sC", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "LkGwnXOMwfcC", - "title": "Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:LkGwnXOMwfcC", - "venue_text": "International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022", - "year": 2022 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "_FxGoFyzp5QC", - "title": "Enhancing Web Application Performance with AI - Driven Optimization Techniques", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:_FxGoFyzp5QC", - "venue_text": "International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021", - "year": 2021 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "Y0pCki6q_DkC", - "title": "Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "ufrVoPGSRksC", - "title": "Scalable machine learning model deployment using serverless cloud architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:ufrVoPGSRksC", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 \u2026, 2022", - "year": 2022 - } - ] - }, - "run_dir": "planning/scholar_probe_tmp/fixtures/run_20260216T182142Z" -} \ No newline at end of file diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.md b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.md deleted file mode 100644 index 1a596f3..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182142Z.md +++ /dev/null @@ -1,76 +0,0 @@ -# Scholar Scrape Probe Report - -Generated UTC: `2026-02-16T18:21:42.721714+00:00` -Run fixtures dir: `planning/scholar_probe_tmp/fixtures/run_20260216T182142Z` - -## Robots Snapshot - -```text -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / -``` - -## Fetch Summary - -| Source | Status Code | Status/Error | Final URL | -| --- | --- | --- | --- | - -## Parse Summary - -| Source | Parse Status | Profile | Publications | Warnings | -| --- | --- | --- | --- | --- | -| `profile_AAAAAAAAAAAA` | `layout_changed` | - | 0 | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | Doaa Elmatary | 12 | - | -| `profile_P1RwlvoAAAAJ` | `ok` | WENRUI ZUO | 5 | - | -| `profile_RxmmtT8AAAAJ` | `ok` | K. Srinivasan | 20 | - | -| `profile_amIMrIEAAAAJ` | `ok` | Bangar Raju Cherukuri | 20 | - | - -## Field Coverage - -Total parsed publication rows: **57** - -| Field | Present | Coverage | -| --- | --- | --- | -| `title` | 57 | 100.0% | -| `cluster_id` | 57 | 100.0% | -| `year` | 56 | 98.2% | -| `citation_count` | 57 | 100.0% | -| `authors_text` | 57 | 100.0% | -| `venue_text` | 54 | 94.7% | - -## Parser Contract Recommendation - -- Primary row marker: `tr.gsc_a_tr`. -- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token. -- Metadata text markers: first/second `div.gs_gray` per row for authors and venue. -- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex. -- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`. - -## Future-Proofing Notes - -- Keep raw HTML fixture snapshots and update parser tests on DOM drift. -- Treat blocked pages as retriable with backoff, not parser errors. -- Add marker-count assertions in CI to catch silent layout shifts early. -- Use explicit parse status per run/scholar so automation can degrade gracefully. diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.json b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.json deleted file mode 100644 index 34b3767..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.json +++ /dev/null @@ -1,719 +0,0 @@ -{ - "fetch_records": [ - { - "elapsed_seconds": 0.072, - "error": null, - "fetched_at_utc": "2026-02-16T18:21:57.261516+00:00", - "file_name": "robots.txt", - "final_url": "https://scholar.google.com/robots.txt", - "source": "robots", - "status_code": 200, - "url": "https://scholar.google.com/robots.txt" - }, - { - "elapsed_seconds": 0.889, - "error": null, - "fetched_at_utc": "2026-02-16T18:21:58.150732+00:00", - "file_name": "profile_amIMrIEAAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ", - "source": "profile_amIMrIEAAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ" - }, - { - "elapsed_seconds": 0.797, - "error": null, - "fetched_at_utc": "2026-02-16T18:22:03.779375+00:00", - "file_name": "profile_P1RwlvoAAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ", - "source": "profile_P1RwlvoAAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ" - }, - { - "elapsed_seconds": 0.367, - "error": null, - "fetched_at_utc": "2026-02-16T18:22:08.622977+00:00", - "file_name": "profile_RxmmtT8AAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ", - "source": "profile_RxmmtT8AAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ" - }, - { - "elapsed_seconds": 0.281, - "error": null, - "fetched_at_utc": "2026-02-16T18:22:13.568478+00:00", - "file_name": "profile_LZ5D_p4AAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ", - "source": "profile_LZ5D_p4AAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ" - }, - { - "elapsed_seconds": 0.646, - "error": null, - "fetched_at_utc": "2026-02-16T18:22:18.796882+00:00", - "file_name": "profile_AAAAAAAAAAAA.html", - "final_url": "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA&dsh=S-568466874%3A1771266138494353&hl=en&ifkv=ASfE1-pK1BK8frcmUfumfUYfZZe1iJXbTGg6TCfyR-Q1ErPAopCpUGy_sEwLdvOICVCsiXsIJbFm1Q&service=citations&flowName=GlifWebSignIn&flowEntry=ServiceLogin", - "source": "profile_AAAAAAAAAAAA", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=AAAAAAAAAAAA" - } - ], - "generated_at_utc": "2026-02-16T18:22:18.839668+00:00", - "page_analyses": [ - { - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "blocked_or_captcha" - }, - { - "field_presence": { - "authors_text": 12, - "citation_count": 12, - "cluster_id": 12, - "title": 12, - "venue_text": 10, - "year": 11 - }, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "Doaa Elmatary", - "publication_count": 12, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 5, - "citation_count": 5, - "cluster_id": 5, - "title": 5, - "venue_text": 5, - "year": 5 - }, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "WENRUI ZUO", - "publication_count": 5, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 19, - "year": 20 - }, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "K. Srinivasan", - "publication_count": 20, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 20, - "year": 20 - }, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [], - "profile_name": "Bangar Raju Cherukuri", - "publication_count": 20, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - } - ], - "publications_by_source": { - "profile_AAAAAAAAAAAA": [], - "profile_LZ5D_p4AAAAJ": [ - { - "authors_text": "EM Abd Allah, DE El-Matary, EM Eid, AST El Dien", - "citation_count": 25, - "cluster_id": "u-x6o8ySG0sC", - "title": "Performance comparison of various machine learning approaches to identify the best one in predicting heart disease", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u-x6o8ySG0sC", - "venue_text": "Journal of Computer and Communications 10 (2), 1-18, 2022", - "year": 2022 - }, - { - "authors_text": "EAAA Hagras, D El-Saied, HH Aly", - "citation_count": 15, - "cluster_id": "YsMSGLbcyi4C", - "title": "Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:YsMSGLbcyi4C", - "venue_text": "2011 28th National Radio Science Conference (NRSC), 1-9, 2011", - "year": 2011 - }, - { - "authors_text": "EA Hagras, D El-Saied, HH Aly", - "citation_count": 6, - "cluster_id": "Y0pCki6q_DkC", - "title": "A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DEE Matary, EA Hagras, HM Abdel-Kader", - "citation_count": 4, - "cluster_id": "d1gkVwhDpl0C", - "title": "Performance of polar codes for OFDM-based UWB channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:d1gkVwhDpl0C", - "venue_text": "Journal of Computer and Communications 6 (03), 102-117, 2018", - "year": 2018 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 3, - "cluster_id": "u5HHmVD_uO8C", - "title": "Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u5HHmVD_uO8C", - "venue_text": "American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017", - "year": 2017 - }, - { - "authors_text": "EAAA Hagras", - "citation_count": 2, - "cluster_id": "W7OEmFMy1HYC", - "title": "Doaa El-Saied, Dr. Hazem H. Aly,\u201cA New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks\u201d", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DE El-Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 1, - "cluster_id": "_FxGoFyzp5QC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:_FxGoFyzp5QC", - "venue_text": null, - "year": 2013 - }, - { - "authors_text": "DE Elmatary", - "citation_count": 0, - "cluster_id": "ufrVoPGSRksC", - "title": "Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:ufrVoPGSRksC", - "venue_text": "Journal of Computer and Communications, 120-134, 2023", - "year": 2023 - }, - { - "authors_text": "E M AbdAllah, D E El Matary", - "citation_count": 0, - "cluster_id": "WF5omc3nYNoC", - "title": "Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:WF5omc3nYNoC", - "venue_text": "Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022", - "year": 2022 - }, - { - "authors_text": "DE El Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "9yKSN-GCB0IC", - "title": "Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:9yKSN-GCB0IC", - "venue_text": "2018 35th National Radio Science Conference (NRSC), 283-292, 2018", - "year": 2018 - }, - { - "authors_text": "HMAK Doaa E.El-Matary, Esam A.A. Hagras", - "citation_count": 0, - "cluster_id": "zYLM7Y9cAGgC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Scientific & Engineering Research, 2013", - "year": 2013 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "eQOLeE2rZwMC", - "title": "Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:eQOLeE2rZwMC", - "venue_text": null, - "year": null - } - ], - "profile_P1RwlvoAAAAJ": [ - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00f3n, G Tyson", - "citation_count": 18, - "cluster_id": "u5HHmVD_uO8C", - "title": "Set in stone: Analysis of an immutable web3 social media platform", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u5HHmVD_uO8C", - "venue_text": "Proceedings of the ACM Web Conference 2023, 1865-1874, 2023", - "year": 2023 - }, - { - "authors_text": "W Zuo, RJ Mondragon, A Raman, G Tyson", - "citation_count": 10, - "cluster_id": "d1gkVwhDpl0C", - "title": "Understanding and improving content moderation in web3 platforms", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:d1gkVwhDpl0C", - "venue_text": "Proceedings of the International AAAI Conference on Web and Social Media 18 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00d3N, G Tyson", - "citation_count": 7, - "cluster_id": "u-x6o8ySG0sC", - "title": "A first look at user-controlled moderation on web3 social media: The case of memo. cash", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u-x6o8ySG0sC", - "venue_text": "Proceedings of the 3rd International Workshop on Open Challenges in Online \u2026, 2023", - "year": 2023 - }, - { - "authors_text": "Y Liu, RM Williams, G Xie, Y Wang, W Zuo", - "citation_count": 4, - "cluster_id": "9yKSN-GCB0IC", - "title": "Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:9yKSN-GCB0IC", - "venue_text": "arXiv preprint arXiv:2410.03532, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo", - "citation_count": 0, - "cluster_id": "2osOgNQ5qMEC", - "title": "Understanding and Improving User-controlled Content Moderation Systems on Social Media", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:2osOgNQ5qMEC", - "venue_text": "Queen Mary University of London, 2025", - "year": 2025 - } - ], - "profile_RxmmtT8AAAAJ": [ - { - "authors_text": "K Srinivasan", - "citation_count": 1185, - "cluster_id": "qjMakFHDy7sC", - "title": "Black pepper and its pungent principle-piperine: a review of diverse physiological effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:qjMakFHDy7sC", - "venue_text": "Critical reviews in food science and nutrition 47 (8), 735-748, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 742, - "cluster_id": "W7OEmFMy1HYC", - "title": "Digestive stimulant action of spices: a myth or reality?", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:W7OEmFMy1HYC", - "venue_text": "Indian Journal of Medical Research 119 (5), 167, 2004", - "year": 2004 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 682, - "cluster_id": "u-x6o8ySG0sC", - "title": "Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:u-x6o8ySG0sC", - "venue_text": "Food/Nahrung 44 (1), 42-46, 2000", - "year": 2000 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 663, - "cluster_id": "LkGwnXOMwfcC", - "title": "Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:LkGwnXOMwfcC", - "venue_text": "Food reviews international 22 (2), 203-224, 2006", - "year": 2006 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 574, - "cluster_id": "FPJr55Dyh1AC", - "title": "Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:FPJr55Dyh1AC", - "venue_text": "Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016", - "year": 2016 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 555, - "cluster_id": "9yKSN-GCB0IC", - "title": "Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:9yKSN-GCB0IC", - "venue_text": "International journal of food sciences and nutrition 56 (6), 399-414, 2005", - "year": 2005 - }, - { - "authors_text": "PS Babu, K Srinivasan", - "citation_count": 539, - "cluster_id": "ZfRJV9d4-WMC", - "title": "Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:ZfRJV9d4-WMC", - "venue_text": "Molecular and cellular biochemistry 166 (1), 169-175, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 513, - "cluster_id": "IjCSPb-OGe4C", - "title": "Spices as influencers of body metabolism: an overview of three decades of research", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:IjCSPb-OGe4C", - "venue_text": "Food Research International 38 (1), 77-86, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 455, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Role of spices beyond food flavoring: Nutraceuticals with multiple health effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:Tyk-4Ss8FVUC", - "venue_text": "Food reviews international 21 (2), 167-188, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 446, - "cluster_id": "tkaPQYYpVKoC", - "title": "Antioxidant potential of spices and their active constituents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:tkaPQYYpVKoC", - "venue_text": "Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013", - "year": 2013 - }, - { - "authors_text": "D Suresh, K Srinivasan", - "citation_count": 387, - "cluster_id": "5nxA0vEk-isC", - "title": "Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:5nxA0vEk-isC", - "venue_text": "Indian Journal of Medical Research 131, 682-691, 2010", - "year": 2010 - }, - { - "authors_text": "R Ramakrishna Rao, K Platel, K Srinivasan", - "citation_count": 368, - "cluster_id": "YsMSGLbcyi4C", - "title": "In vitro influence of spices and spice\u2010active principles on digestive enzymes of rat pancreas and small intestine", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:YsMSGLbcyi4C", - "venue_text": "Food/Nahrung 47 (6), 408-412, 2003", - "year": 2003 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 367, - "cluster_id": "zCSUwVk65WsC", - "title": "Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:zCSUwVk65WsC", - "venue_text": "Food quality and safety 2 (1), 1-16, 2018", - "year": 2018 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 364, - "cluster_id": "yqoGN6RLRZoC", - "title": "Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:yqoGN6RLRZoC", - "venue_text": "PharmaNutrition 5 (1), 18-28, 2017", - "year": 2017 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 364, - "cluster_id": "d1gkVwhDpl0C", - "title": "Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:d1gkVwhDpl0C", - "venue_text": "International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996", - "year": 1996 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 296, - "cluster_id": "UeHWp8X0CEIC", - "title": "Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UeHWp8X0CEIC", - "venue_text": "Food/Nahrung 41 (2), 68-74, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan, K Sambaiah", - "citation_count": 292, - "cluster_id": "2osOgNQ5qMEC", - "title": "The effect of spices on cholesterol 7\u03b1-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:2osOgNQ5qMEC", - "venue_text": null, - "year": 1991 - }, - { - "authors_text": "S Hemalatha, K Platel, K Srinivasan", - "citation_count": 269, - "cluster_id": "UebtZRa9Y70C", - "title": "Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UebtZRa9Y70C", - "venue_text": "Food chemistry 102 (4), 1328-1336, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 258, - "cluster_id": "_FxGoFyzp5QC", - "title": "Studies on the influence of dietary spices on food transit time in experimental rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:_FxGoFyzp5QC", - "venue_text": "Nutrition Research 21 (9), 1309-1314, 2001", - "year": 2001 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 240, - "cluster_id": "hCrLmN-GePgC", - "title": "Bioavailability of micronutrients from plant foods: an update", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:hCrLmN-GePgC", - "venue_text": "Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016", - "year": 2016 - } - ], - "profile_amIMrIEAAAAJ": [ - { - "authors_text": "BR Cherukuri, V Arulkumar", - "citation_count": 131, - "cluster_id": "u5HHmVD_uO8C", - "title": "Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u5HHmVD_uO8C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 81, - "cluster_id": "d1gkVwhDpl0C", - "title": "Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:d1gkVwhDpl0C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 33, - "cluster_id": "zYLM7Y9cAGgC", - "title": "AI-powered personalization: How machine learning is shaping the future of user experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 12 (1), 3111\u20133126, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 30, - "cluster_id": "roLk4NBRz8UC", - "title": "Future of cloud computing: Innovations in multi-cloud and hybrid architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:roLk4NBRz8UC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 27, - "cluster_id": "UebtZRa9Y70C", - "title": "Microservices and containerization: Accelerating web development cycles", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UebtZRa9Y70C", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 18, - "cluster_id": "hqOjcs7Dif8C", - "title": "Ethical AI in cloud: Mitigating risks in machine learning models", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:hqOjcs7Dif8C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 \u2026, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 14, - "cluster_id": "0EnyYjriUFMC", - "title": "Quantum machine learning: Transforming cloud-based AI solutions", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:0EnyYjriUFMC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "WF5omc3nYNoC", - "title": "Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:WF5omc3nYNoC", - "venue_text": "Journal of Machine and Computing 5 (1), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "W7OEmFMy1HYC", - "title": "Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 13 (1), 3302\u20133315, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "IjCSPb-OGe4C", - "title": "Serverless computing: How to build and deploy applications without managing infrastructure", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:IjCSPb-OGe4C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 12, - "cluster_id": "YsMSGLbcyi4C", - "title": "Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:YsMSGLbcyi4C", - "venue_text": "International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 9, - "cluster_id": "Se3iqnhoufwC", - "title": "Serverless revolution: Redefining application scalability and cost efficiency", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Se3iqnhoufwC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 8, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Tyk-4Ss8FVUC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 7, - "cluster_id": "UeHWp8X0CEIC", - "title": "Building Scalable Web Applications: Best Practices for Backend Architecture", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UeHWp8X0CEIC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 5, - "cluster_id": "5nxA0vEk-isC", - "title": "Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:5nxA0vEk-isC", - "venue_text": "Journal of Machine and Computing 5 (2), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "u-x6o8ySG0sC", - "title": "Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u-x6o8ySG0sC", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "LkGwnXOMwfcC", - "title": "Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:LkGwnXOMwfcC", - "venue_text": "International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022", - "year": 2022 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "_FxGoFyzp5QC", - "title": "Enhancing Web Application Performance with AI - Driven Optimization Techniques", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:_FxGoFyzp5QC", - "venue_text": "International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021", - "year": 2021 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "Y0pCki6q_DkC", - "title": "Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "ufrVoPGSRksC", - "title": "Scalable machine learning model deployment using serverless cloud architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:ufrVoPGSRksC", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 \u2026, 2022", - "year": 2022 - } - ] - }, - "run_dir": "planning/scholar_probe_tmp/fixtures/run_20260216T182157Z" -} \ No newline at end of file diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.md b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.md deleted file mode 100644 index 883ce4d..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182157Z.md +++ /dev/null @@ -1,82 +0,0 @@ -# Scholar Scrape Probe Report - -Generated UTC: `2026-02-16T18:22:18.839668+00:00` -Run fixtures dir: `planning/scholar_probe_tmp/fixtures/run_20260216T182157Z` - -## Robots Snapshot - -```text -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / -``` - -## Fetch Summary - -| Source | Status Code | Status/Error | Final URL | -| --- | --- | --- | --- | -| `robots` | 200 | ok | `https://scholar.google.com/robots.txt` | -| `profile_amIMrIEAAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ` | -| `profile_P1RwlvoAAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ` | -| `profile_RxmmtT8AAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ` | -| `profile_LZ5D_p4AAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ` | -| `profile_AAAAAAAAAAAA` | 200 | ok | `https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA&dsh=S-568466874%3A1771266138494353&hl=en&ifkv=ASfE1-pK1BK8frcmUfumfUYfZZe1iJXbTGg6TCfyR-Q1ErPAopCpUGy_sEwLdvOICVCsiXsIJbFm1Q&service=citations&flowName=GlifWebSignIn&flowEntry=ServiceLogin` | - -## Parse Summary - -| Source | Parse Status | Profile | Publications | Warnings | -| --- | --- | --- | --- | --- | -| `profile_AAAAAAAAAAAA` | `blocked_or_captcha` | - | 0 | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | Doaa Elmatary | 12 | - | -| `profile_P1RwlvoAAAAJ` | `ok` | WENRUI ZUO | 5 | - | -| `profile_RxmmtT8AAAAJ` | `ok` | K. Srinivasan | 20 | - | -| `profile_amIMrIEAAAAJ` | `ok` | Bangar Raju Cherukuri | 20 | - | - -## Field Coverage - -Total parsed publication rows: **57** - -| Field | Present | Coverage | -| --- | --- | --- | -| `title` | 57 | 100.0% | -| `cluster_id` | 57 | 100.0% | -| `year` | 56 | 98.2% | -| `citation_count` | 57 | 100.0% | -| `authors_text` | 57 | 100.0% | -| `venue_text` | 54 | 94.7% | - -## Parser Contract Recommendation - -- Primary row marker: `tr.gsc_a_tr`. -- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token. -- Metadata text markers: first/second `div.gs_gray` per row for authors and venue. -- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex. -- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`. - -## Future-Proofing Notes - -- Keep raw HTML fixture snapshots and update parser tests on DOM drift. -- Treat blocked pages as retriable with backoff, not parser errors. -- Add marker-count assertions in CI to catch silent layout shifts early. -- Use explicit parse status per run/scholar so automation can degrade gracefully. diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.json b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.json deleted file mode 100644 index 87ac758..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.json +++ /dev/null @@ -1,831 +0,0 @@ -{ - "fetch_records": [], - "generated_at_utc": "2026-02-16T18:23:03.906832+00:00", - "page_analyses": [ - { - "articles_range": null, - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "has_operation_error_banner": false, - "has_show_more_button": false, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "layout_changed" - }, - { - "articles_range": "Articles 1\u201312", - "field_presence": { - "authors_text": 12, - "citation_count": 12, - "cluster_id": 12, - "title": 12, - "venue_text": 10, - "year": 11 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Doaa Elmatary", - "publication_count": 12, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u20135", - "field_presence": { - "authors_text": 5, - "citation_count": 5, - "cluster_id": 5, - "title": 5, - "venue_text": 5, - "year": 5 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "WENRUI ZUO", - "publication_count": 5, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 19, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "K. Srinivasan", - "publication_count": 20, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 20, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Bangar Raju Cherukuri", - "publication_count": 20, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - }, - { - "articles_range": null, - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "has_operation_error_banner": false, - "has_show_more_button": false, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "layout_changed" - }, - { - "articles_range": "Articles 1\u201312", - "field_presence": { - "authors_text": 12, - "citation_count": 12, - "cluster_id": 12, - "title": 12, - "venue_text": 10, - "year": 11 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Doaa Elmatary", - "publication_count": 12, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u20135", - "field_presence": { - "authors_text": 5, - "citation_count": 5, - "cluster_id": 5, - "title": 5, - "venue_text": 5, - "year": 5 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "WENRUI ZUO", - "publication_count": 5, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 19, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "K. Srinivasan", - "publication_count": 20, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 20, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Bangar Raju Cherukuri", - "publication_count": 20, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - } - ], - "publications_by_source": { - "profile_AAAAAAAAAAAA": [], - "profile_LZ5D_p4AAAAJ": [ - { - "authors_text": "EM Abd Allah, DE El-Matary, EM Eid, AST El Dien", - "citation_count": 25, - "cluster_id": "u-x6o8ySG0sC", - "title": "Performance comparison of various machine learning approaches to identify the best one in predicting heart disease", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u-x6o8ySG0sC", - "venue_text": "Journal of Computer and Communications 10 (2), 1-18, 2022", - "year": 2022 - }, - { - "authors_text": "EAAA Hagras, D El-Saied, HH Aly", - "citation_count": 15, - "cluster_id": "YsMSGLbcyi4C", - "title": "Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:YsMSGLbcyi4C", - "venue_text": "2011 28th National Radio Science Conference (NRSC), 1-9, 2011", - "year": 2011 - }, - { - "authors_text": "EA Hagras, D El-Saied, HH Aly", - "citation_count": 6, - "cluster_id": "Y0pCki6q_DkC", - "title": "A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DEE Matary, EA Hagras, HM Abdel-Kader", - "citation_count": 4, - "cluster_id": "d1gkVwhDpl0C", - "title": "Performance of polar codes for OFDM-based UWB channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:d1gkVwhDpl0C", - "venue_text": "Journal of Computer and Communications 6 (03), 102-117, 2018", - "year": 2018 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 3, - "cluster_id": "u5HHmVD_uO8C", - "title": "Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u5HHmVD_uO8C", - "venue_text": "American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017", - "year": 2017 - }, - { - "authors_text": "EAAA Hagras", - "citation_count": 2, - "cluster_id": "W7OEmFMy1HYC", - "title": "Doaa El-Saied, Dr. Hazem H. Aly,\u201cA New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks\u201d", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DE El-Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 1, - "cluster_id": "_FxGoFyzp5QC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:_FxGoFyzp5QC", - "venue_text": null, - "year": 2013 - }, - { - "authors_text": "DE Elmatary", - "citation_count": 0, - "cluster_id": "ufrVoPGSRksC", - "title": "Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:ufrVoPGSRksC", - "venue_text": "Journal of Computer and Communications, 120-134, 2023", - "year": 2023 - }, - { - "authors_text": "E M AbdAllah, D E El Matary", - "citation_count": 0, - "cluster_id": "WF5omc3nYNoC", - "title": "Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:WF5omc3nYNoC", - "venue_text": "Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022", - "year": 2022 - }, - { - "authors_text": "DE El Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "9yKSN-GCB0IC", - "title": "Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:9yKSN-GCB0IC", - "venue_text": "2018 35th National Radio Science Conference (NRSC), 283-292, 2018", - "year": 2018 - }, - { - "authors_text": "HMAK Doaa E.El-Matary, Esam A.A. Hagras", - "citation_count": 0, - "cluster_id": "zYLM7Y9cAGgC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Scientific & Engineering Research, 2013", - "year": 2013 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "eQOLeE2rZwMC", - "title": "Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:eQOLeE2rZwMC", - "venue_text": null, - "year": null - } - ], - "profile_P1RwlvoAAAAJ": [ - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00f3n, G Tyson", - "citation_count": 18, - "cluster_id": "u5HHmVD_uO8C", - "title": "Set in stone: Analysis of an immutable web3 social media platform", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u5HHmVD_uO8C", - "venue_text": "Proceedings of the ACM Web Conference 2023, 1865-1874, 2023", - "year": 2023 - }, - { - "authors_text": "W Zuo, RJ Mondragon, A Raman, G Tyson", - "citation_count": 10, - "cluster_id": "d1gkVwhDpl0C", - "title": "Understanding and improving content moderation in web3 platforms", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:d1gkVwhDpl0C", - "venue_text": "Proceedings of the International AAAI Conference on Web and Social Media 18 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00d3N, G Tyson", - "citation_count": 7, - "cluster_id": "u-x6o8ySG0sC", - "title": "A first look at user-controlled moderation on web3 social media: The case of memo. cash", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u-x6o8ySG0sC", - "venue_text": "Proceedings of the 3rd International Workshop on Open Challenges in Online \u2026, 2023", - "year": 2023 - }, - { - "authors_text": "Y Liu, RM Williams, G Xie, Y Wang, W Zuo", - "citation_count": 4, - "cluster_id": "9yKSN-GCB0IC", - "title": "Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:9yKSN-GCB0IC", - "venue_text": "arXiv preprint arXiv:2410.03532, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo", - "citation_count": 0, - "cluster_id": "2osOgNQ5qMEC", - "title": "Understanding and Improving User-controlled Content Moderation Systems on Social Media", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:2osOgNQ5qMEC", - "venue_text": "Queen Mary University of London, 2025", - "year": 2025 - } - ], - "profile_RxmmtT8AAAAJ": [ - { - "authors_text": "K Srinivasan", - "citation_count": 1185, - "cluster_id": "qjMakFHDy7sC", - "title": "Black pepper and its pungent principle-piperine: a review of diverse physiological effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:qjMakFHDy7sC", - "venue_text": "Critical reviews in food science and nutrition 47 (8), 735-748, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 742, - "cluster_id": "W7OEmFMy1HYC", - "title": "Digestive stimulant action of spices: a myth or reality?", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:W7OEmFMy1HYC", - "venue_text": "Indian Journal of Medical Research 119 (5), 167, 2004", - "year": 2004 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 682, - "cluster_id": "u-x6o8ySG0sC", - "title": "Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:u-x6o8ySG0sC", - "venue_text": "Food/Nahrung 44 (1), 42-46, 2000", - "year": 2000 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 663, - "cluster_id": "LkGwnXOMwfcC", - "title": "Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:LkGwnXOMwfcC", - "venue_text": "Food reviews international 22 (2), 203-224, 2006", - "year": 2006 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 574, - "cluster_id": "FPJr55Dyh1AC", - "title": "Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:FPJr55Dyh1AC", - "venue_text": "Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016", - "year": 2016 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 555, - "cluster_id": "9yKSN-GCB0IC", - "title": "Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:9yKSN-GCB0IC", - "venue_text": "International journal of food sciences and nutrition 56 (6), 399-414, 2005", - "year": 2005 - }, - { - "authors_text": "PS Babu, K Srinivasan", - "citation_count": 539, - "cluster_id": "ZfRJV9d4-WMC", - "title": "Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:ZfRJV9d4-WMC", - "venue_text": "Molecular and cellular biochemistry 166 (1), 169-175, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 513, - "cluster_id": "IjCSPb-OGe4C", - "title": "Spices as influencers of body metabolism: an overview of three decades of research", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:IjCSPb-OGe4C", - "venue_text": "Food Research International 38 (1), 77-86, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 455, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Role of spices beyond food flavoring: Nutraceuticals with multiple health effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:Tyk-4Ss8FVUC", - "venue_text": "Food reviews international 21 (2), 167-188, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 446, - "cluster_id": "tkaPQYYpVKoC", - "title": "Antioxidant potential of spices and their active constituents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:tkaPQYYpVKoC", - "venue_text": "Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013", - "year": 2013 - }, - { - "authors_text": "D Suresh, K Srinivasan", - "citation_count": 387, - "cluster_id": "5nxA0vEk-isC", - "title": "Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:5nxA0vEk-isC", - "venue_text": "Indian Journal of Medical Research 131, 682-691, 2010", - "year": 2010 - }, - { - "authors_text": "R Ramakrishna Rao, K Platel, K Srinivasan", - "citation_count": 368, - "cluster_id": "YsMSGLbcyi4C", - "title": "In vitro influence of spices and spice\u2010active principles on digestive enzymes of rat pancreas and small intestine", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:YsMSGLbcyi4C", - "venue_text": "Food/Nahrung 47 (6), 408-412, 2003", - "year": 2003 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 367, - "cluster_id": "zCSUwVk65WsC", - "title": "Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:zCSUwVk65WsC", - "venue_text": "Food quality and safety 2 (1), 1-16, 2018", - "year": 2018 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 364, - "cluster_id": "yqoGN6RLRZoC", - "title": "Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:yqoGN6RLRZoC", - "venue_text": "PharmaNutrition 5 (1), 18-28, 2017", - "year": 2017 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 364, - "cluster_id": "d1gkVwhDpl0C", - "title": "Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:d1gkVwhDpl0C", - "venue_text": "International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996", - "year": 1996 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 296, - "cluster_id": "UeHWp8X0CEIC", - "title": "Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UeHWp8X0CEIC", - "venue_text": "Food/Nahrung 41 (2), 68-74, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan, K Sambaiah", - "citation_count": 292, - "cluster_id": "2osOgNQ5qMEC", - "title": "The effect of spices on cholesterol 7\u03b1-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:2osOgNQ5qMEC", - "venue_text": null, - "year": 1991 - }, - { - "authors_text": "S Hemalatha, K Platel, K Srinivasan", - "citation_count": 269, - "cluster_id": "UebtZRa9Y70C", - "title": "Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UebtZRa9Y70C", - "venue_text": "Food chemistry 102 (4), 1328-1336, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 258, - "cluster_id": "_FxGoFyzp5QC", - "title": "Studies on the influence of dietary spices on food transit time in experimental rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:_FxGoFyzp5QC", - "venue_text": "Nutrition Research 21 (9), 1309-1314, 2001", - "year": 2001 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 240, - "cluster_id": "hCrLmN-GePgC", - "title": "Bioavailability of micronutrients from plant foods: an update", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:hCrLmN-GePgC", - "venue_text": "Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016", - "year": 2016 - } - ], - "profile_amIMrIEAAAAJ": [ - { - "authors_text": "BR Cherukuri, V Arulkumar", - "citation_count": 131, - "cluster_id": "u5HHmVD_uO8C", - "title": "Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u5HHmVD_uO8C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 81, - "cluster_id": "d1gkVwhDpl0C", - "title": "Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:d1gkVwhDpl0C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 33, - "cluster_id": "zYLM7Y9cAGgC", - "title": "AI-powered personalization: How machine learning is shaping the future of user experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 12 (1), 3111\u20133126, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 30, - "cluster_id": "roLk4NBRz8UC", - "title": "Future of cloud computing: Innovations in multi-cloud and hybrid architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:roLk4NBRz8UC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 27, - "cluster_id": "UebtZRa9Y70C", - "title": "Microservices and containerization: Accelerating web development cycles", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UebtZRa9Y70C", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 18, - "cluster_id": "hqOjcs7Dif8C", - "title": "Ethical AI in cloud: Mitigating risks in machine learning models", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:hqOjcs7Dif8C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 \u2026, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 14, - "cluster_id": "0EnyYjriUFMC", - "title": "Quantum machine learning: Transforming cloud-based AI solutions", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:0EnyYjriUFMC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "WF5omc3nYNoC", - "title": "Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:WF5omc3nYNoC", - "venue_text": "Journal of Machine and Computing 5 (1), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "W7OEmFMy1HYC", - "title": "Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 13 (1), 3302\u20133315, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "IjCSPb-OGe4C", - "title": "Serverless computing: How to build and deploy applications without managing infrastructure", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:IjCSPb-OGe4C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 12, - "cluster_id": "YsMSGLbcyi4C", - "title": "Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:YsMSGLbcyi4C", - "venue_text": "International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 9, - "cluster_id": "Se3iqnhoufwC", - "title": "Serverless revolution: Redefining application scalability and cost efficiency", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Se3iqnhoufwC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 8, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Tyk-4Ss8FVUC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 7, - "cluster_id": "UeHWp8X0CEIC", - "title": "Building Scalable Web Applications: Best Practices for Backend Architecture", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UeHWp8X0CEIC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 5, - "cluster_id": "5nxA0vEk-isC", - "title": "Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:5nxA0vEk-isC", - "venue_text": "Journal of Machine and Computing 5 (2), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "u-x6o8ySG0sC", - "title": "Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u-x6o8ySG0sC", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "LkGwnXOMwfcC", - "title": "Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:LkGwnXOMwfcC", - "venue_text": "International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022", - "year": 2022 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "_FxGoFyzp5QC", - "title": "Enhancing Web Application Performance with AI - Driven Optimization Techniques", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:_FxGoFyzp5QC", - "venue_text": "International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021", - "year": 2021 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "Y0pCki6q_DkC", - "title": "Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "ufrVoPGSRksC", - "title": "Scalable machine learning model deployment using serverless cloud architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:ufrVoPGSRksC", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 \u2026, 2022", - "year": 2022 - } - ] - }, - "run_dir": "planning/scholar_probe_tmp/fixtures/run_20260216T182303Z" -} \ No newline at end of file diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.md b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.md deleted file mode 100644 index 4f58d08..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182303Z.md +++ /dev/null @@ -1,83 +0,0 @@ -# Scholar Scrape Probe Report - -Generated UTC: `2026-02-16T18:23:03.906832+00:00` -Run fixtures dir: `planning/scholar_probe_tmp/fixtures/run_20260216T182303Z` - -## Robots Snapshot - -```text -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / -``` - -## Fetch Summary - -| Source | Status Code | Status/Error | Final URL | -| --- | --- | --- | --- | - -## Parse Summary - -| Source | Parse Status | Profile | Publications | Articles Range | Show More | Warnings | -| --- | --- | --- | --- | --- | --- | --- | -| `profile_AAAAAAAAAAAA` | `layout_changed` | - | 0 | - | no | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | Doaa Elmatary | 12 | Articles 1–12 | yes | possible_partial_page_show_more_present | -| `profile_P1RwlvoAAAAJ` | `ok` | WENRUI ZUO | 5 | Articles 1–5 | yes | possible_partial_page_show_more_present | -| `profile_RxmmtT8AAAAJ` | `ok` | K. Srinivasan | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | -| `profile_amIMrIEAAAAJ` | `ok` | Bangar Raju Cherukuri | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | -| `profile_AAAAAAAAAAAA` | `layout_changed` | - | 0 | - | no | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | Doaa Elmatary | 12 | Articles 1–12 | yes | possible_partial_page_show_more_present | -| `profile_P1RwlvoAAAAJ` | `ok` | WENRUI ZUO | 5 | Articles 1–5 | yes | possible_partial_page_show_more_present | -| `profile_RxmmtT8AAAAJ` | `ok` | K. Srinivasan | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | -| `profile_amIMrIEAAAAJ` | `ok` | Bangar Raju Cherukuri | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | - -## Field Coverage - -Total parsed publication rows: **57** - -| Field | Present | Coverage | -| --- | --- | --- | -| `title` | 57 | 100.0% | -| `cluster_id` | 57 | 100.0% | -| `year` | 56 | 98.2% | -| `citation_count` | 57 | 100.0% | -| `authors_text` | 57 | 100.0% | -| `venue_text` | 54 | 94.7% | - -## Parser Contract Recommendation - -- Primary row marker: `tr.gsc_a_tr`. -- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token. -- Metadata text markers: first/second `div.gs_gray` per row for authors and venue. -- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex. -- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`. - -## Future-Proofing Notes - -- Keep raw HTML fixture snapshots and update parser tests on DOM drift. -- Treat blocked pages as retriable with backoff, not parser errors. -- If `Show more` is present, treat first-page-only results as partial and surface that in run status/UI. -- Track robots policy changes because `/citations?*cstart=` is currently disallowed. -- Add marker-count assertions in CI to catch silent layout shifts early. -- Use explicit parse status per run/scholar so automation can degrade gracefully. diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.json b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.json deleted file mode 100644 index 7bd3e52..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.json +++ /dev/null @@ -1,681 +0,0 @@ -{ - "fetch_records": [], - "generated_at_utc": "2026-02-16T18:23:20.768051+00:00", - "page_analyses": [ - { - "articles_range": null, - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "has_operation_error_banner": false, - "has_show_more_button": false, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "layout_changed" - }, - { - "articles_range": "Articles 1\u201312", - "field_presence": { - "authors_text": 12, - "citation_count": 12, - "cluster_id": 12, - "title": 12, - "venue_text": 10, - "year": 11 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Doaa Elmatary", - "publication_count": 12, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u20135", - "field_presence": { - "authors_text": 5, - "citation_count": 5, - "cluster_id": 5, - "title": 5, - "venue_text": 5, - "year": 5 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "WENRUI ZUO", - "publication_count": 5, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 19, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "K. Srinivasan", - "publication_count": 20, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 20, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Bangar Raju Cherukuri", - "publication_count": 20, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - } - ], - "publications_by_source": { - "profile_AAAAAAAAAAAA": [], - "profile_LZ5D_p4AAAAJ": [ - { - "authors_text": "EM Abd Allah, DE El-Matary, EM Eid, AST El Dien", - "citation_count": 25, - "cluster_id": "u-x6o8ySG0sC", - "title": "Performance comparison of various machine learning approaches to identify the best one in predicting heart disease", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u-x6o8ySG0sC", - "venue_text": "Journal of Computer and Communications 10 (2), 1-18, 2022", - "year": 2022 - }, - { - "authors_text": "EAAA Hagras, D El-Saied, HH Aly", - "citation_count": 15, - "cluster_id": "YsMSGLbcyi4C", - "title": "Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:YsMSGLbcyi4C", - "venue_text": "2011 28th National Radio Science Conference (NRSC), 1-9, 2011", - "year": 2011 - }, - { - "authors_text": "EA Hagras, D El-Saied, HH Aly", - "citation_count": 6, - "cluster_id": "Y0pCki6q_DkC", - "title": "A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DEE Matary, EA Hagras, HM Abdel-Kader", - "citation_count": 4, - "cluster_id": "d1gkVwhDpl0C", - "title": "Performance of polar codes for OFDM-based UWB channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:d1gkVwhDpl0C", - "venue_text": "Journal of Computer and Communications 6 (03), 102-117, 2018", - "year": 2018 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 3, - "cluster_id": "u5HHmVD_uO8C", - "title": "Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u5HHmVD_uO8C", - "venue_text": "American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017", - "year": 2017 - }, - { - "authors_text": "EAAA Hagras", - "citation_count": 2, - "cluster_id": "W7OEmFMy1HYC", - "title": "Doaa El-Saied, Dr. Hazem H. Aly,\u201cA New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks\u201d", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DE El-Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 1, - "cluster_id": "_FxGoFyzp5QC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:_FxGoFyzp5QC", - "venue_text": null, - "year": 2013 - }, - { - "authors_text": "DE Elmatary", - "citation_count": 0, - "cluster_id": "ufrVoPGSRksC", - "title": "Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:ufrVoPGSRksC", - "venue_text": "Journal of Computer and Communications, 120-134, 2023", - "year": 2023 - }, - { - "authors_text": "E M AbdAllah, D E El Matary", - "citation_count": 0, - "cluster_id": "WF5omc3nYNoC", - "title": "Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:WF5omc3nYNoC", - "venue_text": "Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022", - "year": 2022 - }, - { - "authors_text": "DE El Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "9yKSN-GCB0IC", - "title": "Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:9yKSN-GCB0IC", - "venue_text": "2018 35th National Radio Science Conference (NRSC), 283-292, 2018", - "year": 2018 - }, - { - "authors_text": "HMAK Doaa E.El-Matary, Esam A.A. Hagras", - "citation_count": 0, - "cluster_id": "zYLM7Y9cAGgC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Scientific & Engineering Research, 2013", - "year": 2013 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "eQOLeE2rZwMC", - "title": "Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:eQOLeE2rZwMC", - "venue_text": null, - "year": null - } - ], - "profile_P1RwlvoAAAAJ": [ - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00f3n, G Tyson", - "citation_count": 18, - "cluster_id": "u5HHmVD_uO8C", - "title": "Set in stone: Analysis of an immutable web3 social media platform", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u5HHmVD_uO8C", - "venue_text": "Proceedings of the ACM Web Conference 2023, 1865-1874, 2023", - "year": 2023 - }, - { - "authors_text": "W Zuo, RJ Mondragon, A Raman, G Tyson", - "citation_count": 10, - "cluster_id": "d1gkVwhDpl0C", - "title": "Understanding and improving content moderation in web3 platforms", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:d1gkVwhDpl0C", - "venue_text": "Proceedings of the International AAAI Conference on Web and Social Media 18 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00d3N, G Tyson", - "citation_count": 7, - "cluster_id": "u-x6o8ySG0sC", - "title": "A first look at user-controlled moderation on web3 social media: The case of memo. cash", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u-x6o8ySG0sC", - "venue_text": "Proceedings of the 3rd International Workshop on Open Challenges in Online \u2026, 2023", - "year": 2023 - }, - { - "authors_text": "Y Liu, RM Williams, G Xie, Y Wang, W Zuo", - "citation_count": 4, - "cluster_id": "9yKSN-GCB0IC", - "title": "Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:9yKSN-GCB0IC", - "venue_text": "arXiv preprint arXiv:2410.03532, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo", - "citation_count": 0, - "cluster_id": "2osOgNQ5qMEC", - "title": "Understanding and Improving User-controlled Content Moderation Systems on Social Media", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:2osOgNQ5qMEC", - "venue_text": "Queen Mary University of London, 2025", - "year": 2025 - } - ], - "profile_RxmmtT8AAAAJ": [ - { - "authors_text": "K Srinivasan", - "citation_count": 1185, - "cluster_id": "qjMakFHDy7sC", - "title": "Black pepper and its pungent principle-piperine: a review of diverse physiological effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:qjMakFHDy7sC", - "venue_text": "Critical reviews in food science and nutrition 47 (8), 735-748, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 742, - "cluster_id": "W7OEmFMy1HYC", - "title": "Digestive stimulant action of spices: a myth or reality?", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:W7OEmFMy1HYC", - "venue_text": "Indian Journal of Medical Research 119 (5), 167, 2004", - "year": 2004 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 682, - "cluster_id": "u-x6o8ySG0sC", - "title": "Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:u-x6o8ySG0sC", - "venue_text": "Food/Nahrung 44 (1), 42-46, 2000", - "year": 2000 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 663, - "cluster_id": "LkGwnXOMwfcC", - "title": "Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:LkGwnXOMwfcC", - "venue_text": "Food reviews international 22 (2), 203-224, 2006", - "year": 2006 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 574, - "cluster_id": "FPJr55Dyh1AC", - "title": "Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:FPJr55Dyh1AC", - "venue_text": "Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016", - "year": 2016 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 555, - "cluster_id": "9yKSN-GCB0IC", - "title": "Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:9yKSN-GCB0IC", - "venue_text": "International journal of food sciences and nutrition 56 (6), 399-414, 2005", - "year": 2005 - }, - { - "authors_text": "PS Babu, K Srinivasan", - "citation_count": 539, - "cluster_id": "ZfRJV9d4-WMC", - "title": "Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:ZfRJV9d4-WMC", - "venue_text": "Molecular and cellular biochemistry 166 (1), 169-175, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 513, - "cluster_id": "IjCSPb-OGe4C", - "title": "Spices as influencers of body metabolism: an overview of three decades of research", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:IjCSPb-OGe4C", - "venue_text": "Food Research International 38 (1), 77-86, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 455, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Role of spices beyond food flavoring: Nutraceuticals with multiple health effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:Tyk-4Ss8FVUC", - "venue_text": "Food reviews international 21 (2), 167-188, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 446, - "cluster_id": "tkaPQYYpVKoC", - "title": "Antioxidant potential of spices and their active constituents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:tkaPQYYpVKoC", - "venue_text": "Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013", - "year": 2013 - }, - { - "authors_text": "D Suresh, K Srinivasan", - "citation_count": 387, - "cluster_id": "5nxA0vEk-isC", - "title": "Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:5nxA0vEk-isC", - "venue_text": "Indian Journal of Medical Research 131, 682-691, 2010", - "year": 2010 - }, - { - "authors_text": "R Ramakrishna Rao, K Platel, K Srinivasan", - "citation_count": 368, - "cluster_id": "YsMSGLbcyi4C", - "title": "In vitro influence of spices and spice\u2010active principles on digestive enzymes of rat pancreas and small intestine", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:YsMSGLbcyi4C", - "venue_text": "Food/Nahrung 47 (6), 408-412, 2003", - "year": 2003 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 367, - "cluster_id": "zCSUwVk65WsC", - "title": "Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:zCSUwVk65WsC", - "venue_text": "Food quality and safety 2 (1), 1-16, 2018", - "year": 2018 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 364, - "cluster_id": "yqoGN6RLRZoC", - "title": "Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:yqoGN6RLRZoC", - "venue_text": "PharmaNutrition 5 (1), 18-28, 2017", - "year": 2017 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 364, - "cluster_id": "d1gkVwhDpl0C", - "title": "Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:d1gkVwhDpl0C", - "venue_text": "International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996", - "year": 1996 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 296, - "cluster_id": "UeHWp8X0CEIC", - "title": "Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UeHWp8X0CEIC", - "venue_text": "Food/Nahrung 41 (2), 68-74, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan, K Sambaiah", - "citation_count": 292, - "cluster_id": "2osOgNQ5qMEC", - "title": "The effect of spices on cholesterol 7\u03b1-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:2osOgNQ5qMEC", - "venue_text": null, - "year": 1991 - }, - { - "authors_text": "S Hemalatha, K Platel, K Srinivasan", - "citation_count": 269, - "cluster_id": "UebtZRa9Y70C", - "title": "Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UebtZRa9Y70C", - "venue_text": "Food chemistry 102 (4), 1328-1336, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 258, - "cluster_id": "_FxGoFyzp5QC", - "title": "Studies on the influence of dietary spices on food transit time in experimental rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:_FxGoFyzp5QC", - "venue_text": "Nutrition Research 21 (9), 1309-1314, 2001", - "year": 2001 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 240, - "cluster_id": "hCrLmN-GePgC", - "title": "Bioavailability of micronutrients from plant foods: an update", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:hCrLmN-GePgC", - "venue_text": "Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016", - "year": 2016 - } - ], - "profile_amIMrIEAAAAJ": [ - { - "authors_text": "BR Cherukuri, V Arulkumar", - "citation_count": 131, - "cluster_id": "u5HHmVD_uO8C", - "title": "Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u5HHmVD_uO8C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 81, - "cluster_id": "d1gkVwhDpl0C", - "title": "Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:d1gkVwhDpl0C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 33, - "cluster_id": "zYLM7Y9cAGgC", - "title": "AI-powered personalization: How machine learning is shaping the future of user experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 12 (1), 3111\u20133126, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 30, - "cluster_id": "roLk4NBRz8UC", - "title": "Future of cloud computing: Innovations in multi-cloud and hybrid architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:roLk4NBRz8UC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 27, - "cluster_id": "UebtZRa9Y70C", - "title": "Microservices and containerization: Accelerating web development cycles", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UebtZRa9Y70C", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 18, - "cluster_id": "hqOjcs7Dif8C", - "title": "Ethical AI in cloud: Mitigating risks in machine learning models", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:hqOjcs7Dif8C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 \u2026, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 14, - "cluster_id": "0EnyYjriUFMC", - "title": "Quantum machine learning: Transforming cloud-based AI solutions", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:0EnyYjriUFMC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "WF5omc3nYNoC", - "title": "Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:WF5omc3nYNoC", - "venue_text": "Journal of Machine and Computing 5 (1), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "W7OEmFMy1HYC", - "title": "Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 13 (1), 3302\u20133315, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "IjCSPb-OGe4C", - "title": "Serverless computing: How to build and deploy applications without managing infrastructure", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:IjCSPb-OGe4C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 12, - "cluster_id": "YsMSGLbcyi4C", - "title": "Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:YsMSGLbcyi4C", - "venue_text": "International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 9, - "cluster_id": "Se3iqnhoufwC", - "title": "Serverless revolution: Redefining application scalability and cost efficiency", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Se3iqnhoufwC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 8, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Tyk-4Ss8FVUC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 7, - "cluster_id": "UeHWp8X0CEIC", - "title": "Building Scalable Web Applications: Best Practices for Backend Architecture", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UeHWp8X0CEIC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 5, - "cluster_id": "5nxA0vEk-isC", - "title": "Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:5nxA0vEk-isC", - "venue_text": "Journal of Machine and Computing 5 (2), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "u-x6o8ySG0sC", - "title": "Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u-x6o8ySG0sC", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "LkGwnXOMwfcC", - "title": "Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:LkGwnXOMwfcC", - "venue_text": "International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022", - "year": 2022 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "_FxGoFyzp5QC", - "title": "Enhancing Web Application Performance with AI - Driven Optimization Techniques", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:_FxGoFyzp5QC", - "venue_text": "International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021", - "year": 2021 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "Y0pCki6q_DkC", - "title": "Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "ufrVoPGSRksC", - "title": "Scalable machine learning model deployment using serverless cloud architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:ufrVoPGSRksC", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 \u2026, 2022", - "year": 2022 - } - ] - }, - "run_dir": "planning/scholar_probe_tmp/fixtures/run_20260216T182320Z" -} \ No newline at end of file diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.md b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.md deleted file mode 100644 index d59ec1d..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182320Z.md +++ /dev/null @@ -1,78 +0,0 @@ -# Scholar Scrape Probe Report - -Generated UTC: `2026-02-16T18:23:20.768051+00:00` -Run fixtures dir: `planning/scholar_probe_tmp/fixtures/run_20260216T182320Z` - -## Robots Snapshot - -```text -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / -``` - -## Fetch Summary - -| Source | Status Code | Status/Error | Final URL | -| --- | --- | --- | --- | - -## Parse Summary - -| Source | Parse Status | Profile | Publications | Articles Range | Show More | Warnings | -| --- | --- | --- | --- | --- | --- | --- | -| `profile_AAAAAAAAAAAA` | `layout_changed` | - | 0 | - | no | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | Doaa Elmatary | 12 | Articles 1–12 | yes | possible_partial_page_show_more_present | -| `profile_P1RwlvoAAAAJ` | `ok` | WENRUI ZUO | 5 | Articles 1–5 | yes | possible_partial_page_show_more_present | -| `profile_RxmmtT8AAAAJ` | `ok` | K. Srinivasan | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | -| `profile_amIMrIEAAAAJ` | `ok` | Bangar Raju Cherukuri | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | - -## Field Coverage - -Total parsed publication rows: **57** - -| Field | Present | Coverage | -| --- | --- | --- | -| `title` | 57 | 100.0% | -| `cluster_id` | 57 | 100.0% | -| `year` | 56 | 98.2% | -| `citation_count` | 57 | 100.0% | -| `authors_text` | 57 | 100.0% | -| `venue_text` | 54 | 94.7% | - -## Parser Contract Recommendation - -- Primary row marker: `tr.gsc_a_tr`. -- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token. -- Metadata text markers: first/second `div.gs_gray` per row for authors and venue. -- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex. -- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`. - -## Future-Proofing Notes - -- Keep raw HTML fixture snapshots and update parser tests on DOM drift. -- Treat blocked pages as retriable with backoff, not parser errors. -- If `Show more` is present, treat first-page-only results as partial and surface that in run status/UI. -- Track robots policy changes because `/citations?*cstart=` is currently disallowed. -- Add marker-count assertions in CI to catch silent layout shifts early. -- Use explicit parse status per run/scholar so automation can degrade gracefully. diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.json b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.json deleted file mode 100644 index 051a952..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.json +++ /dev/null @@ -1,742 +0,0 @@ -{ - "fetch_records": [ - { - "elapsed_seconds": 0.054, - "error": null, - "fetched_at_utc": "2026-02-16T18:23:34.228993+00:00", - "file_name": "robots.txt", - "final_url": "https://scholar.google.com/robots.txt", - "source": "robots", - "status_code": 200, - "url": "https://scholar.google.com/robots.txt" - }, - { - "elapsed_seconds": 0.792, - "error": null, - "fetched_at_utc": "2026-02-16T18:23:35.021068+00:00", - "file_name": "profile_amIMrIEAAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ", - "source": "profile_amIMrIEAAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ" - }, - { - "elapsed_seconds": 0.293, - "error": null, - "fetched_at_utc": "2026-02-16T18:23:39.900012+00:00", - "file_name": "profile_P1RwlvoAAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ", - "source": "profile_P1RwlvoAAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ" - }, - { - "elapsed_seconds": 0.808, - "error": null, - "fetched_at_utc": "2026-02-16T18:23:45.650527+00:00", - "file_name": "profile_RxmmtT8AAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ", - "source": "profile_RxmmtT8AAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ" - }, - { - "elapsed_seconds": 0.795, - "error": null, - "fetched_at_utc": "2026-02-16T18:23:50.907480+00:00", - "file_name": "profile_LZ5D_p4AAAAJ.html", - "final_url": "https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ", - "source": "profile_LZ5D_p4AAAAJ", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ" - }, - { - "elapsed_seconds": 0.995, - "error": null, - "fetched_at_utc": "2026-02-16T18:23:56.367359+00:00", - "file_name": "profile_AAAAAAAAAAAA.html", - "final_url": "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA&dsh=S-1576812270%3A1771266236087781&hl=en&ifkv=ASfE1-pcNcR5S-R4fUZcbEWwaMAwsKrYMzr8-7CFvIajHFoW7EhUN90SrdKqfzzkHfWDxL97xvHq&service=citations&flowName=GlifWebSignIn&flowEntry=ServiceLogin", - "source": "profile_AAAAAAAAAAAA", - "status_code": 200, - "url": "https://scholar.google.com/citations?hl=en&user=AAAAAAAAAAAA" - } - ], - "generated_at_utc": "2026-02-16T18:23:56.436305+00:00", - "page_analyses": [ - { - "articles_range": null, - "field_presence": { - "authors_text": 0, - "citation_count": 0, - "cluster_id": 0, - "title": 0, - "venue_text": 0, - "year": 0 - }, - "has_operation_error_banner": false, - "has_show_more_button": false, - "marker_counts": { - "gs_gray": 0, - "gsc_a_ac": 0, - "gsc_a_at": 0, - "gsc_a_h": 0, - "gsc_a_tr": 0, - "gsc_a_y": 0, - "gsc_prf_in": 0, - "gsc_rsb_st": 0 - }, - "parse_warnings": [ - "no_rows_detected" - ], - "profile_name": null, - "publication_count": 0, - "source": "profile_AAAAAAAAAAAA", - "status": "blocked_or_captcha" - }, - { - "articles_range": "Articles 1\u201312", - "field_presence": { - "authors_text": 12, - "citation_count": 12, - "cluster_id": 12, - "title": 12, - "venue_text": 10, - "year": 11 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 26, - "gsc_a_ac": 18, - "gsc_a_at": 15, - "gsc_a_h": 31, - "gsc_a_tr": 32, - "gsc_a_y": 26, - "gsc_prf_in": 17, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Doaa Elmatary", - "publication_count": 12, - "source": "profile_LZ5D_p4AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u20135", - "field_presence": { - "authors_text": 5, - "citation_count": 5, - "cluster_id": 5, - "title": 5, - "venue_text": 5, - "year": 5 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 14, - "gsc_a_ac": 11, - "gsc_a_at": 8, - "gsc_a_h": 17, - "gsc_a_tr": 25, - "gsc_a_y": 19, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "WENRUI ZUO", - "publication_count": 5, - "source": "profile_P1RwlvoAAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 19, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 20, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "K. Srinivasan", - "publication_count": 20, - "source": "profile_RxmmtT8AAAAJ", - "status": "ok" - }, - { - "articles_range": "Articles 1\u201320", - "field_presence": { - "authors_text": 20, - "citation_count": 20, - "cluster_id": 20, - "title": 20, - "venue_text": 20, - "year": 20 - }, - "has_operation_error_banner": false, - "has_show_more_button": true, - "marker_counts": { - "gs_gray": 42, - "gsc_a_ac": 26, - "gsc_a_at": 23, - "gsc_a_h": 47, - "gsc_a_tr": 40, - "gsc_a_y": 34, - "gsc_prf_in": 21, - "gsc_rsb_st": 30 - }, - "parse_warnings": [ - "possible_partial_page_show_more_present" - ], - "profile_name": "Bangar Raju Cherukuri", - "publication_count": 20, - "source": "profile_amIMrIEAAAAJ", - "status": "ok" - } - ], - "publications_by_source": { - "profile_AAAAAAAAAAAA": [], - "profile_LZ5D_p4AAAAJ": [ - { - "authors_text": "EM Abd Allah, DE El-Matary, EM Eid, AST El Dien", - "citation_count": 25, - "cluster_id": "u-x6o8ySG0sC", - "title": "Performance comparison of various machine learning approaches to identify the best one in predicting heart disease", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u-x6o8ySG0sC", - "venue_text": "Journal of Computer and Communications 10 (2), 1-18, 2022", - "year": 2022 - }, - { - "authors_text": "EAAA Hagras, D El-Saied, HH Aly", - "citation_count": 15, - "cluster_id": "YsMSGLbcyi4C", - "title": "Energy efficient key management scheme based on elliptic curve signcryption for wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:YsMSGLbcyi4C", - "venue_text": "2011 28th National Radio Science Conference (NRSC), 1-9, 2011", - "year": 2011 - }, - { - "authors_text": "EA Hagras, D El-Saied, HH Aly", - "citation_count": 6, - "cluster_id": "Y0pCki6q_DkC", - "title": "A new forward secure elliptic curve signcryption key management (fs-ecskm) scheme for heterogeneous wireless sensor networks", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DEE Matary, EA Hagras, HM Abdel-Kader", - "citation_count": 4, - "cluster_id": "d1gkVwhDpl0C", - "title": "Performance of polar codes for OFDM-based UWB channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:d1gkVwhDpl0C", - "venue_text": "Journal of Computer and Communications 6 (03), 102-117, 2018", - "year": 2018 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 3, - "cluster_id": "u5HHmVD_uO8C", - "title": "Multi-user Communication Based OFDM-UWB System under Gaussian and Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:u5HHmVD_uO8C", - "venue_text": "American Journal of Electrical and Electronic Engineering 5 (4), 136-143, 2017", - "year": 2017 - }, - { - "authors_text": "EAAA Hagras", - "citation_count": 2, - "cluster_id": "W7OEmFMy1HYC", - "title": "Doaa El-Saied, Dr. Hazem H. Aly,\u201cA New Forward Secure Elliptic Curve Signcryption Key Management (FS-ECSKM) Scheme for Heterogeneous Wireless Sensor Networks\u201d", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Computer Science and Technology 2 (2), 19-23, 2011", - "year": 2011 - }, - { - "authors_text": "DE El-Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 1, - "cluster_id": "_FxGoFyzp5QC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:_FxGoFyzp5QC", - "venue_text": null, - "year": 2013 - }, - { - "authors_text": "DE Elmatary", - "citation_count": 0, - "cluster_id": "ufrVoPGSRksC", - "title": "Intelligent Sign Multi-Language Real-Time Prediction System with Effective Data Preprocessing", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:ufrVoPGSRksC", - "venue_text": "Journal of Computer and Communications, 120-134, 2023", - "year": 2023 - }, - { - "authors_text": "E M AbdAllah, D E El Matary", - "citation_count": 0, - "cluster_id": "WF5omc3nYNoC", - "title": "Smart Healthcare System Based IoT with Frequency Analysis of Heart Rate Data across FFT Algorithmto be used During Covid-19 Pandemic", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:WF5omc3nYNoC", - "venue_text": "Engineering Research Journal (Shoubra) 51 (1), 88-94, 2022", - "year": 2022 - }, - { - "authors_text": "DE El Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "9yKSN-GCB0IC", - "title": "Polar coded Interleave Division Multiple Access Ultra Wide Band (IDMA-UWB) communication system", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:9yKSN-GCB0IC", - "venue_text": "2018 35th National Radio Science Conference (NRSC), 283-292, 2018", - "year": 2018 - }, - { - "authors_text": "HMAK Doaa E.El-Matary, Esam A.A. Hagras", - "citation_count": 0, - "cluster_id": "zYLM7Y9cAGgC", - "title": "Performance Analysis of LDPC-IDMA-UWB Signals in Non-Gaussian Noisy Channel", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Scientific & Engineering Research, 2013", - "year": 2013 - }, - { - "authors_text": "DEE Matary, EAA Hagras, HM Abdel-Kader", - "citation_count": 0, - "cluster_id": "eQOLeE2rZwMC", - "title": "Non-Gaussian Noisy Channel Effect on Multi-user IDMA-UWB Communication System", - "title_url": "/citations?view_op=view_citation&hl=en&user=LZ5D_p4AAAAJ&citation_for_view=LZ5D_p4AAAAJ:eQOLeE2rZwMC", - "venue_text": null, - "year": null - } - ], - "profile_P1RwlvoAAAAJ": [ - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00f3n, G Tyson", - "citation_count": 18, - "cluster_id": "u5HHmVD_uO8C", - "title": "Set in stone: Analysis of an immutable web3 social media platform", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u5HHmVD_uO8C", - "venue_text": "Proceedings of the ACM Web Conference 2023, 1865-1874, 2023", - "year": 2023 - }, - { - "authors_text": "W Zuo, RJ Mondragon, A Raman, G Tyson", - "citation_count": 10, - "cluster_id": "d1gkVwhDpl0C", - "title": "Understanding and improving content moderation in web3 platforms", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:d1gkVwhDpl0C", - "venue_text": "Proceedings of the International AAAI Conference on Web and Social Media 18 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo, A Raman, RJ Mondrag\u00d3N, G Tyson", - "citation_count": 7, - "cluster_id": "u-x6o8ySG0sC", - "title": "A first look at user-controlled moderation on web3 social media: The case of memo. cash", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:u-x6o8ySG0sC", - "venue_text": "Proceedings of the 3rd International Workshop on Open Challenges in Online \u2026, 2023", - "year": 2023 - }, - { - "authors_text": "Y Liu, RM Williams, G Xie, Y Wang, W Zuo", - "citation_count": 4, - "cluster_id": "9yKSN-GCB0IC", - "title": "Promoting the Culture of Qinhuai River Lantern Shadow Puppetry with a Digital Archive and Immersive Experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:9yKSN-GCB0IC", - "venue_text": "arXiv preprint arXiv:2410.03532, 2024", - "year": 2024 - }, - { - "authors_text": "W Zuo", - "citation_count": 0, - "cluster_id": "2osOgNQ5qMEC", - "title": "Understanding and Improving User-controlled Content Moderation Systems on Social Media", - "title_url": "/citations?view_op=view_citation&hl=en&user=P1RwlvoAAAAJ&citation_for_view=P1RwlvoAAAAJ:2osOgNQ5qMEC", - "venue_text": "Queen Mary University of London, 2025", - "year": 2025 - } - ], - "profile_RxmmtT8AAAAJ": [ - { - "authors_text": "K Srinivasan", - "citation_count": 1185, - "cluster_id": "qjMakFHDy7sC", - "title": "Black pepper and its pungent principle-piperine: a review of diverse physiological effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:qjMakFHDy7sC", - "venue_text": "Critical reviews in food science and nutrition 47 (8), 735-748, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 742, - "cluster_id": "W7OEmFMy1HYC", - "title": "Digestive stimulant action of spices: a myth or reality?", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:W7OEmFMy1HYC", - "venue_text": "Indian Journal of Medical Research 119 (5), 167, 2004", - "year": 2004 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 682, - "cluster_id": "u-x6o8ySG0sC", - "title": "Influence of dietary spices and their active principles on pancreatic digestive enzymes in albino rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:u-x6o8ySG0sC", - "venue_text": "Food/Nahrung 44 (1), 42-46, 2000", - "year": 2000 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 663, - "cluster_id": "LkGwnXOMwfcC", - "title": "Fenugreek (Trigonella foenum-graecum): A Review of Health Beneficial Physiological Effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:LkGwnXOMwfcC", - "venue_text": "Food reviews international 22 (2), 203-224, 2006", - "year": 2006 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 574, - "cluster_id": "FPJr55Dyh1AC", - "title": "Biological activities of red pepper (Capsicum annuum) and its pungent principle capsaicin: a review", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:FPJr55Dyh1AC", - "venue_text": "Critical reviews in food science and nutrition 56 (9), 1488-1500, 2016", - "year": 2016 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 555, - "cluster_id": "9yKSN-GCB0IC", - "title": "Plant foods in the management of diabetes mellitus: spices as beneficial antidiabetic food adjuncts", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:9yKSN-GCB0IC", - "venue_text": "International journal of food sciences and nutrition 56 (6), 399-414, 2005", - "year": 2005 - }, - { - "authors_text": "PS Babu, K Srinivasan", - "citation_count": 539, - "cluster_id": "ZfRJV9d4-WMC", - "title": "Hypolipidemic action of curcumin, the active principle of turmeric (Curcuma longa) in streptozotocin induced diabetic rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:ZfRJV9d4-WMC", - "venue_text": "Molecular and cellular biochemistry 166 (1), 169-175, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 513, - "cluster_id": "IjCSPb-OGe4C", - "title": "Spices as influencers of body metabolism: an overview of three decades of research", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:IjCSPb-OGe4C", - "venue_text": "Food Research International 38 (1), 77-86, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 455, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Role of spices beyond food flavoring: Nutraceuticals with multiple health effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:Tyk-4Ss8FVUC", - "venue_text": "Food reviews international 21 (2), 167-188, 2005", - "year": 2005 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 446, - "cluster_id": "tkaPQYYpVKoC", - "title": "Antioxidant potential of spices and their active constituents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:tkaPQYYpVKoC", - "venue_text": "Critical Reviews in Food Science and Nutrition 54 (3), 252-272, 2013", - "year": 2013 - }, - { - "authors_text": "D Suresh, K Srinivasan", - "citation_count": 387, - "cluster_id": "5nxA0vEk-isC", - "title": "Tissue distribution and elimination of capsaicin, piperine & curcumin following oral intake in rats.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:5nxA0vEk-isC", - "venue_text": "Indian Journal of Medical Research 131, 682-691, 2010", - "year": 2010 - }, - { - "authors_text": "R Ramakrishna Rao, K Platel, K Srinivasan", - "citation_count": 368, - "cluster_id": "YsMSGLbcyi4C", - "title": "In vitro influence of spices and spice\u2010active principles on digestive enzymes of rat pancreas and small intestine", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:YsMSGLbcyi4C", - "venue_text": "Food/Nahrung 47 (6), 408-412, 2003", - "year": 2003 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 367, - "cluster_id": "zCSUwVk65WsC", - "title": "Cumin (Cuminum cyminum) and black cumin (Nigella sativa) seeds: traditional uses, chemical constituents, and nutraceutical effects", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:zCSUwVk65WsC", - "venue_text": "Food quality and safety 2 (1), 1-16, 2018", - "year": 2018 - }, - { - "authors_text": "K Srinivasan", - "citation_count": 364, - "cluster_id": "yqoGN6RLRZoC", - "title": "Ginger rhizomes (Zingiber officinale): A spice with multiple health beneficial potentials", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:yqoGN6RLRZoC", - "venue_text": "PharmaNutrition 5 (1), 18-28, 2017", - "year": 2017 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 364, - "cluster_id": "d1gkVwhDpl0C", - "title": "Influence of dietary spices or their active principles on digestive enzymes of small intestinal mucosa in rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:d1gkVwhDpl0C", - "venue_text": "International Journal of Food Sciences and Nutrition 47 (1), 55-59, 1996", - "year": 1996 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 296, - "cluster_id": "UeHWp8X0CEIC", - "title": "Plant foods in the management of diabetes mellitus: vegetables as potential hypoglycaemic agents", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UeHWp8X0CEIC", - "venue_text": "Food/Nahrung 41 (2), 68-74, 1997", - "year": 1997 - }, - { - "authors_text": "K Srinivasan, K Sambaiah", - "citation_count": 292, - "cluster_id": "2osOgNQ5qMEC", - "title": "The effect of spices on cholesterol 7\u03b1-hydroxylase activity and on serum and hepatic cholesterol levels in the rat.", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:2osOgNQ5qMEC", - "venue_text": null, - "year": 1991 - }, - { - "authors_text": "S Hemalatha, K Platel, K Srinivasan", - "citation_count": 269, - "cluster_id": "UebtZRa9Y70C", - "title": "Zinc and iron contents and their bioaccessibility in cereals and pulses consumed in India", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:UebtZRa9Y70C", - "venue_text": "Food chemistry 102 (4), 1328-1336, 2007", - "year": 2007 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 258, - "cluster_id": "_FxGoFyzp5QC", - "title": "Studies on the influence of dietary spices on food transit time in experimental rats", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:_FxGoFyzp5QC", - "venue_text": "Nutrition Research 21 (9), 1309-1314, 2001", - "year": 2001 - }, - { - "authors_text": "K Platel, K Srinivasan", - "citation_count": 240, - "cluster_id": "hCrLmN-GePgC", - "title": "Bioavailability of micronutrients from plant foods: an update", - "title_url": "/citations?view_op=view_citation&hl=en&user=RxmmtT8AAAAJ&citation_for_view=RxmmtT8AAAAJ:hCrLmN-GePgC", - "venue_text": "Critical reviews in food science and nutrition 56 (10), 1608-1619, 2016", - "year": 2016 - } - ], - "profile_amIMrIEAAAAJ": [ - { - "authors_text": "BR Cherukuri, V Arulkumar", - "citation_count": 131, - "cluster_id": "u5HHmVD_uO8C", - "title": "Optimization of Data Structures and Trade-Offs with Concurrency Control in Multithread Software Structures Using Artificial Intelligence", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u5HHmVD_uO8C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 81, - "cluster_id": "d1gkVwhDpl0C", - "title": "Development of Design Patterns with Adaptive User Interface for Cloud Native Microservice Architecture Using Deep Learning With IoT", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:d1gkVwhDpl0C", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 33, - "cluster_id": "zYLM7Y9cAGgC", - "title": "AI-powered personalization: How machine learning is shaping the future of user experience", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:zYLM7Y9cAGgC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 12 (1), 3111\u20133126, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 30, - "cluster_id": "roLk4NBRz8UC", - "title": "Future of cloud computing: Innovations in multi-cloud and hybrid architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:roLk4NBRz8UC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 1 (1), 068-081, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 27, - "cluster_id": "UebtZRa9Y70C", - "title": "Microservices and containerization: Accelerating web development cycles", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UebtZRa9Y70C", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 6 (01), 283-296, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 18, - "cluster_id": "hqOjcs7Dif8C", - "title": "Ethical AI in cloud: Mitigating risks in machine learning models", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:hqOjcs7Dif8C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 1 \u2026, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 14, - "cluster_id": "0EnyYjriUFMC", - "title": "Quantum machine learning: Transforming cloud-based AI solutions", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:0EnyYjriUFMC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 1 (01), 110-122, 2020", - "year": 2020 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "WF5omc3nYNoC", - "title": "Enhanced Trimodal Emotion Recognition Using Multibranch Fusion Attention with Epistemic Neural Networks and Fire Hawk Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:WF5omc3nYNoC", - "venue_text": "Journal of Machine and Computing 5 (1), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "W7OEmFMy1HYC", - "title": "Containerization in cloud computing: comparing Docker and Kubernetes for scalable web applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:W7OEmFMy1HYC", - "venue_text": "International Journal of Science and Research Archive (IJSRA) 13 (1), 3302\u20133315, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 13, - "cluster_id": "IjCSPb-OGe4C", - "title": "Serverless computing: How to build and deploy applications without managing infrastructure", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:IjCSPb-OGe4C", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 11 \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 12, - "cluster_id": "YsMSGLbcyi4C", - "title": "Edge Computing vs. Cloud Computing: A Comparative Analysis for Real-Time AI Applications", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:YsMSGLbcyi4C", - "venue_text": "International Journal For Multidisciplinary Research (IJFMR) 6 (5), 1-17, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 9, - "cluster_id": "Se3iqnhoufwC", - "title": "Serverless revolution: Redefining application scalability and cost efficiency", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Se3iqnhoufwC", - "venue_text": "World Journal of Advanced Research and Reviews (WJARR) 2 (03), 039-053, 2019", - "year": 2019 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 8, - "cluster_id": "Tyk-4Ss8FVUC", - "title": "Progressive Web Apps (PWAs): Enhancing User Experience through Modern Web Development", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Tyk-4Ss8FVUC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1550-1560, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 7, - "cluster_id": "UeHWp8X0CEIC", - "title": "Building Scalable Web Applications: Best Practices for Backend Architecture", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:UeHWp8X0CEIC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 126-139, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 5, - "cluster_id": "5nxA0vEk-isC", - "title": "Advanced Multi Class Cyber Security Attack Classification in IoT Based Wireless Sensor Networks Using Context Aware Depthwise Separable Convolutional Neural Network", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:5nxA0vEk-isC", - "venue_text": "Journal of Machine and Computing 5 (2), 2025", - "year": 2025 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "u-x6o8ySG0sC", - "title": "Maintenance of Web Development Standard for Multiple Devices with Serverless Computing through Cross Browser Affinity Using Hybrid Optimization", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:u-x6o8ySG0sC", - "venue_text": "2024 IEEE International Conference on Computing, Power and Communication \u2026, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "LkGwnXOMwfcC", - "title": "Developing Intelligent Chatbots for Real-Time Customer Support in E-Commerce", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:LkGwnXOMwfcC", - "venue_text": "International Journal of Science and Research (IJSR) 11 (01), 1709-1719, 2022", - "year": 2022 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 3, - "cluster_id": "_FxGoFyzp5QC", - "title": "Enhancing Web Application Performance with AI - Driven Optimization Techniques", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:_FxGoFyzp5QC", - "venue_text": "International Journal of Science and Research (IJSR) 10 (2), 1779-1788, 2021", - "year": 2021 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "Y0pCki6q_DkC", - "title": "Federated Learning: Privacy-Preserving Machine Learning in Cloud Environments", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:Y0pCki6q_DkC", - "venue_text": "International Journal of Science and Research (IJSR) 13 (10), 1539-1549, 2024", - "year": 2024 - }, - { - "authors_text": "BR Cherukuri", - "citation_count": 1, - "cluster_id": "ufrVoPGSRksC", - "title": "Scalable machine learning model deployment using serverless cloud architectures", - "title_url": "/citations?view_op=view_citation&hl=en&user=amIMrIEAAAAJ&citation_for_view=amIMrIEAAAAJ:ufrVoPGSRksC", - "venue_text": "World Journal of Advanced Engineering Technology and Sciences ((WJAETS) 5 (1 \u2026, 2022", - "year": 2022 - } - ] - }, - "run_dir": "planning/scholar_probe_tmp/fixtures/run_20260216T182334Z" -} \ No newline at end of file diff --git a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.md b/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.md deleted file mode 100644 index 75804d8..0000000 --- a/planning/scholar_probe_tmp/notes/probe_report_run_20260216T182334Z.md +++ /dev/null @@ -1,84 +0,0 @@ -# Scholar Scrape Probe Report - -Generated UTC: `2026-02-16T18:23:56.436305+00:00` -Run fixtures dir: `planning/scholar_probe_tmp/fixtures/run_20260216T182334Z` - -## Robots Snapshot - -```text -User-agent: * -Disallow: /search -Disallow: /index.html -Disallow: /scholar -Disallow: /citations? -Allow: /citations?user= -Disallow: /citations?*cstart= -Disallow: /citations?user=*%40 -Disallow: /citations?user=*@ -Allow: /citations?view_op=list_classic_articles -Allow: /citations?view_op=mandates_leaderboard -Allow: /citations?view_op=metrics_intro -Allow: /citations?view_op=new_profile -Allow: /citations?view_op=sitemap -Allow: /citations?view_op=top_venues - -User-agent: Twitterbot -Disallow: - -User-agent: facebookexternalhit -Disallow: - -User-agent: PetalBot -Disallow: / -``` - -## Fetch Summary - -| Source | Status Code | Status/Error | Final URL | -| --- | --- | --- | --- | -| `robots` | 200 | ok | `https://scholar.google.com/robots.txt` | -| `profile_amIMrIEAAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=amIMrIEAAAAJ` | -| `profile_P1RwlvoAAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=P1RwlvoAAAAJ` | -| `profile_RxmmtT8AAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=RxmmtT8AAAAJ` | -| `profile_LZ5D_p4AAAAJ` | 200 | ok | `https://scholar.google.com/citations?hl=en&user=LZ5D_p4AAAAJ` | -| `profile_AAAAAAAAAAAA` | 200 | ok | `https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations%3Fhl%3Den%26user%3DAAAAAAAAAAAA&dsh=S-1576812270%3A1771266236087781&hl=en&ifkv=ASfE1-pcNcR5S-R4fUZcbEWwaMAwsKrYMzr8-7CFvIajHFoW7EhUN90SrdKqfzzkHfWDxL97xvHq&service=citations&flowName=GlifWebSignIn&flowEntry=ServiceLogin` | - -## Parse Summary - -| Source | Parse Status | Profile | Publications | Articles Range | Show More | Warnings | -| --- | --- | --- | --- | --- | --- | --- | -| `profile_AAAAAAAAAAAA` | `blocked_or_captcha` | - | 0 | - | no | no_rows_detected | -| `profile_LZ5D_p4AAAAJ` | `ok` | Doaa Elmatary | 12 | Articles 1–12 | yes | possible_partial_page_show_more_present | -| `profile_P1RwlvoAAAAJ` | `ok` | WENRUI ZUO | 5 | Articles 1–5 | yes | possible_partial_page_show_more_present | -| `profile_RxmmtT8AAAAJ` | `ok` | K. Srinivasan | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | -| `profile_amIMrIEAAAAJ` | `ok` | Bangar Raju Cherukuri | 20 | Articles 1–20 | yes | possible_partial_page_show_more_present | - -## Field Coverage - -Total parsed publication rows: **57** - -| Field | Present | Coverage | -| --- | --- | --- | -| `title` | 57 | 100.0% | -| `cluster_id` | 57 | 100.0% | -| `year` | 56 | 98.2% | -| `citation_count` | 57 | 100.0% | -| `authors_text` | 57 | 100.0% | -| `venue_text` | 54 | 94.7% | - -## Parser Contract Recommendation - -- Primary row marker: `tr.gsc_a_tr`. -- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token. -- Metadata text markers: first/second `div.gs_gray` per row for authors and venue. -- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex. -- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`. - -## Future-Proofing Notes - -- Keep raw HTML fixture snapshots and update parser tests on DOM drift. -- Treat blocked pages as retriable with backoff, not parser errors. -- If `Show more` is present, treat first-page-only results as partial and surface that in run status/UI. -- Track robots policy changes because `/citations?*cstart=` is currently disallowed. -- Add marker-count assertions in CI to catch silent layout shifts early. -- Use explicit parse status per run/scholar so automation can degrade gracefully. diff --git a/planning/scholar_probe_tmp/notes/seed_scholar_ids.txt b/planning/scholar_probe_tmp/notes/seed_scholar_ids.txt deleted file mode 100644 index 3ecab65..0000000 --- a/planning/scholar_probe_tmp/notes/seed_scholar_ids.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Seed IDs for probe capture (public profiles) -amIMrIEAAAAJ -P1RwlvoAAAAJ -RxmmtT8AAAAJ -LZ5D_p4AAAAJ -AAAAAAAAAAAA diff --git a/planning/scholar_probe_tmp/scripts/scholar_probe.py b/planning/scholar_probe_tmp/scripts/scholar_probe.py deleted file mode 100755 index 9216a3d..0000000 --- a/planning/scholar_probe_tmp/scripts/scholar_probe.py +++ /dev/null @@ -1,777 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import random -import re -import time -from dataclasses import dataclass, asdict -from datetime import datetime, timezone -from html import unescape -from html.parser import HTMLParser -from pathlib import Path -from typing import Any -from urllib.error import HTTPError, URLError -from urllib.parse import parse_qs, urlencode, urlparse -from urllib.request import Request, urlopen - -ROBOTS_URL = "https://scholar.google.com/robots.txt" -PROFILE_URL = "https://scholar.google.com/citations" - -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" - ), -] - -BLOCKED_KEYWORDS = [ - "unusual traffic", - "sorry/index", - "not a robot", - "our systems have detected", - "automated queries", -] - -NO_RESULTS_KEYWORDS = [ - "didn't match any articles", - "did not match any articles", - "no articles", - "no documents", -] - -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", -] - - -@dataclass -class FetchRecord: - source: str - url: str - file_name: str - status_code: int | None - fetched_at_utc: str - elapsed_seconds: float - final_url: str | None - error: str | None - - -@dataclass -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 -class PageAnalysis: - source: str - status: str - profile_name: str | None - publication_count: int - articles_range: str | None - has_show_more_button: bool - has_operation_error_banner: bool - marker_counts: dict[str, int] - field_presence: dict[str, int] - parse_warnings: list[str] - - -def normalize_space(value: str) -> str: - return " ".join(unescape(value).split()) - - -TAG_RE = re.compile(r"<[^>]+>", re.S) - - -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 - - -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_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_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: - lowered = html.lower() - return "id=\"gsc_bpf_more\"" in lowered or "id='gsc_bpf_more'" in lowered - - -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 detect_status( - *, - status_code: int | None, - final_url: str | None, - html: str, - publication_count: int, - marker_counts: dict[str, int], -) -> str: - if status_code is None: - return "network_error" - - lowered = html.lower() - final = (final_url or "").lower() - - if "accounts.google.com" in final and ("signin" in final or "servicelogin" in final): - return "blocked_or_captcha" - - if any(keyword in lowered for keyword in BLOCKED_KEYWORDS) or "sorry/index" in final: - return "blocked_or_captcha" - - if publication_count == 0 and any(keyword in lowered for keyword in NO_RESULTS_KEYWORDS): - return "no_results" - - if publication_count == 0: - 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 "layout_changed" - - return "ok" - - -def load_scholar_ids(id_file: Path | None, cli_ids: list[str]) -> list[str]: - seen: set[str] = set() - collected: list[str] = [] - - def maybe_add(raw: str) -> None: - item = raw.strip() - if not item: - return - if item.startswith("#"): - return - if item in seen: - return - seen.add(item) - collected.append(item) - - for item in cli_ids: - maybe_add(item) - - if id_file and id_file.exists(): - for line in id_file.read_text(encoding="utf-8").splitlines(): - maybe_add(line) - - return collected - - -def fetch_url(url: str, user_agent: str, timeout_seconds: float) -> tuple[int | None, str | None, str, str | None]: - request = Request( - url, - headers={ - "User-Agent": user_agent, - "Accept": "text/html,application/xhtml+xml", - "Accept-Language": "en-US,en;q=0.9", - "Connection": "close", - }, - ) - start = time.perf_counter() - - try: - with urlopen(request, timeout=timeout_seconds) as response: - body = response.read().decode("utf-8", errors="replace") - elapsed = time.perf_counter() - start - status = getattr(response, "status", 200) - final_url = response.geturl() - return status, final_url, body, None - except HTTPError as exc: - body = "" - try: - body = exc.read().decode("utf-8", errors="replace") - except Exception: - body = "" - elapsed = time.perf_counter() - start - _ = elapsed - return exc.code, exc.geturl(), body, str(exc) - except URLError as exc: - elapsed = time.perf_counter() - start - _ = elapsed - return None, None, "", str(exc) - - -def write_json(path: Path, payload: Any) -> None: - path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") - - -def render_markdown( - *, - generated_at: str, - run_dir: Path, - fetch_records: list[FetchRecord], - page_analyses: list[PageAnalysis], - publications_by_source: dict[str, list[PublicationCandidate]], - robots_excerpt: str, -) -> str: - lines: list[str] = [] - lines.append("# Scholar Scrape Probe Report") - lines.append("") - lines.append(f"Generated UTC: `{generated_at}`") - lines.append(f"Run fixtures dir: `{run_dir.as_posix()}`") - lines.append("") - - lines.append("## Robots Snapshot") - lines.append("") - lines.append("```text") - lines.append(robots_excerpt.rstrip() or "(robots fetch unavailable)") - lines.append("```") - lines.append("") - - lines.append("## Fetch Summary") - lines.append("") - lines.append("| Source | Status Code | Status/Error | Final URL |") - lines.append("| --- | --- | --- | --- |") - for record in fetch_records: - status = str(record.status_code) if record.status_code is not None else "-" - state = record.error or "ok" - final_url = record.final_url or "-" - lines.append(f"| `{record.source}` | {status} | {state} | `{final_url}` |") - lines.append("") - - lines.append("## Parse Summary") - lines.append("") - lines.append("| Source | Parse Status | Profile | Publications | Articles Range | Show More | Warnings |") - lines.append("| --- | --- | --- | --- | --- | --- | --- |") - for analysis in page_analyses: - warnings = ", ".join(analysis.parse_warnings) if analysis.parse_warnings else "-" - profile = analysis.profile_name or "-" - articles_range = analysis.articles_range or "-" - show_more = "yes" if analysis.has_show_more_button else "no" - lines.append( - f"| `{analysis.source}` | `{analysis.status}` | {profile} | {analysis.publication_count} | {articles_range} | {show_more} | {warnings} |" - ) - lines.append("") - - all_publications = [ - publication - for publications in publications_by_source.values() - for publication in publications - ] - - if all_publications: - total = len(all_publications) - def pct(count: int) -> str: - return f"{(count / total) * 100:.1f}%" - - title_count = sum(1 for item in all_publications if item.title) - cluster_count = sum(1 for item in all_publications if item.cluster_id) - year_count = sum(1 for item in all_publications if item.year is not None) - citation_count = sum(1 for item in all_publications if item.citation_count is not None) - author_count = sum(1 for item in all_publications if item.authors_text) - venue_count = sum(1 for item in all_publications if item.venue_text) - - lines.append("## Field Coverage") - lines.append("") - lines.append(f"Total parsed publication rows: **{total}**") - lines.append("") - lines.append("| Field | Present | Coverage |") - lines.append("| --- | --- | --- |") - lines.append(f"| `title` | {title_count} | {pct(title_count)} |") - lines.append(f"| `cluster_id` | {cluster_count} | {pct(cluster_count)} |") - lines.append(f"| `year` | {year_count} | {pct(year_count)} |") - lines.append(f"| `citation_count` | {citation_count} | {pct(citation_count)} |") - lines.append(f"| `authors_text` | {author_count} | {pct(author_count)} |") - lines.append(f"| `venue_text` | {venue_count} | {pct(venue_count)} |") - lines.append("") - - lines.append("## Parser Contract Recommendation") - lines.append("") - lines.append("- Primary row marker: `tr.gsc_a_tr`.") - lines.append("- Title anchor marker: `a.gsc_a_at`; derive `cluster_id` from `citation_for_view` query token.") - lines.append("- Metadata text markers: first/second `div.gs_gray` per row for authors and venue.") - lines.append("- Year marker fallback: classes containing `gsc_a_h` or `gsc_a_y` and 4-digit year regex.") - lines.append("- Failure states to persist: `ok`, `no_results`, `blocked_or_captcha`, `layout_changed`, `network_error`.") - lines.append("") - - lines.append("## Future-Proofing Notes") - lines.append("") - lines.append("- Keep raw HTML fixture snapshots and update parser tests on DOM drift.") - lines.append("- Treat blocked pages as retriable with backoff, not parser errors.") - lines.append("- If `Show more` is present, treat first-page-only results as partial and surface that in run status/UI.") - lines.append("- Track robots policy changes because `/citations?*cstart=` is currently disallowed.") - lines.append("- Add marker-count assertions in CI to catch silent layout shifts early.") - lines.append("- Use explicit parse status per run/scholar so automation can degrade gracefully.") - - lines.append("") - return "\n".join(lines) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Temporary probe: capture and analyze Google Scholar profile HTML for parser planning." - ) - parser.add_argument( - "--output-root", - default="planning/scholar_probe_tmp", - help="Root directory of temporary probe workspace.", - ) - parser.add_argument( - "--scholar-id", - action="append", - default=[], - help="Scholar profile id (can be passed multiple times).", - ) - parser.add_argument( - "--id-file", - default="planning/scholar_probe_tmp/notes/seed_scholar_ids.txt", - help="Path to newline-delimited scholar ids.", - ) - parser.add_argument( - "--max-profiles", - type=int, - default=5, - help="Maximum number of scholar profiles to fetch in this probe run.", - ) - parser.add_argument( - "--request-delay-seconds", - type=float, - default=8.0, - help="Base delay between live requests.", - ) - parser.add_argument( - "--request-jitter-seconds", - type=float, - default=2.0, - help="Randomized additional delay in seconds.", - ) - parser.add_argument( - "--timeout-seconds", - type=float, - default=25.0, - help="HTTP timeout seconds.", - ) - parser.add_argument( - "--allow-live-fetch", - action="store_true", - help="Enable outbound fetch for robots and scholar profile pages.", - ) - parser.add_argument( - "--analyze-existing-fixtures", - action="store_true", - help="Analyze existing fixtures in output-root/fixtures even if live fetch is disabled.", - ) - return parser - - -def main() -> int: - args = build_parser().parse_args() - - output_root = Path(args.output_root) - fixtures_root = output_root / "fixtures" - notes_root = output_root / "notes" - fixtures_root.mkdir(parents=True, exist_ok=True) - notes_root.mkdir(parents=True, exist_ok=True) - - now = datetime.now(timezone.utc) - run_id = now.strftime("run_%Y%m%dT%H%M%SZ") - run_dir = fixtures_root / run_id - run_dir.mkdir(parents=True, exist_ok=True) - - id_file = Path(args.id_file) - scholar_ids = load_scholar_ids(id_file, args.scholar_id) - if args.max_profiles > 0: - scholar_ids = scholar_ids[: args.max_profiles] - - fetch_records: list[FetchRecord] = [] - robots_excerpt = "" - - if args.allow_live_fetch: - robots_start = time.perf_counter() - robots_status, robots_final_url, robots_body, robots_error = fetch_url( - ROBOTS_URL, - user_agent=random.choice(DEFAULT_USER_AGENTS), - timeout_seconds=args.timeout_seconds, - ) - robots_elapsed = time.perf_counter() - robots_start - - robots_path = run_dir / "robots.txt" - robots_path.write_text(robots_body or "", encoding="utf-8") - robots_excerpt = "\n".join((robots_body or "").splitlines()[:50]) - - fetch_records.append( - FetchRecord( - source="robots", - url=ROBOTS_URL, - file_name=robots_path.name, - status_code=robots_status, - fetched_at_utc=datetime.now(timezone.utc).isoformat(), - elapsed_seconds=round(robots_elapsed, 3), - final_url=robots_final_url, - error=robots_error, - ) - ) - - for index, scholar_id in enumerate(scholar_ids): - params = {"hl": "en", "user": scholar_id} - url = f"{PROFILE_URL}?{urlencode(params)}" - - user_agent = DEFAULT_USER_AGENTS[index % len(DEFAULT_USER_AGENTS)] - start = time.perf_counter() - status_code, final_url, body, error = fetch_url( - url, - user_agent=user_agent, - timeout_seconds=args.timeout_seconds, - ) - elapsed = time.perf_counter() - start - - safe_source = f"profile_{scholar_id}" - html_name = f"{safe_source}.html" - html_path = run_dir / html_name - html_path.write_text(body or "", encoding="utf-8") - - fetch_records.append( - FetchRecord( - source=safe_source, - url=url, - file_name=html_name, - status_code=status_code, - fetched_at_utc=datetime.now(timezone.utc).isoformat(), - elapsed_seconds=round(elapsed, 3), - final_url=final_url, - error=error, - ) - ) - - if index < len(scholar_ids) - 1: - delay = max(0.0, args.request_delay_seconds) + random.uniform( - 0.0, - max(0.0, args.request_jitter_seconds), - ) - time.sleep(delay) - - if not robots_excerpt: - robots_candidates = sorted(fixtures_root.glob("run_*/robots.txt"), reverse=True) - if robots_candidates: - robots_excerpt = "\n".join( - robots_candidates[0].read_text(encoding="utf-8").splitlines()[:50] - ) - - html_files: list[Path] = [] - html_files.extend(run_dir.glob("profile_*.html")) - - if args.analyze_existing_fixtures: - latest_runs = sorted(fixtures_root.glob("run_*"), reverse=True) - for existing_run in latest_runs: - if existing_run == run_dir: - continue - html_files.extend(existing_run.glob("profile_*.html")) - - by_source: dict[str, Path] = {} - for candidate in sorted(html_files, reverse=True): - source = candidate.stem - if source in by_source: - continue - by_source[source] = candidate - deduped_files = [by_source[source] for source in sorted(by_source)] - - page_analyses: list[PageAnalysis] = [] - publications_by_source: dict[str, list[PublicationCandidate]] = {} - - fetch_record_by_source = {record.source: record for record in fetch_records} - - for html_path in deduped_files: - source = html_path.stem - html = html_path.read_text(encoding="utf-8") - publications, warnings = parse_publications(html) - markers = count_markers(html) - articles_range = extract_articles_range(html) - show_more = has_show_more_button(html) - operation_error_banner = has_operation_error_banner(html) - - 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)) - - record = fetch_record_by_source.get(source) - status = detect_status( - status_code=record.status_code if record else 200, - final_url=record.final_url if record else None, - html=html, - publication_count=len(publications), - marker_counts=markers, - ) - - field_presence = { - "title": sum(1 for item in publications if item.title), - "cluster_id": sum(1 for item in publications if item.cluster_id), - "year": sum(1 for item in publications if item.year is not None), - "citation_count": sum(1 for item in publications if item.citation_count is not None), - "authors_text": sum(1 for item in publications if item.authors_text), - "venue_text": sum(1 for item in publications if item.venue_text), - } - - analysis = PageAnalysis( - source=source, - status=status, - profile_name=extract_profile_name(html), - publication_count=len(publications), - articles_range=articles_range, - has_show_more_button=show_more, - has_operation_error_banner=operation_error_banner, - marker_counts=markers, - field_presence=field_presence, - parse_warnings=warnings, - ) - page_analyses.append(analysis) - publications_by_source[source] = publications - - report_payload = { - "generated_at_utc": datetime.now(timezone.utc).isoformat(), - "run_dir": run_dir.as_posix(), - "fetch_records": [asdict(record) for record in fetch_records], - "page_analyses": [asdict(analysis) for analysis in page_analyses], - "publications_by_source": { - source: [asdict(item) for item in publications] - for source, publications in publications_by_source.items() - }, - } - - json_report_path = notes_root / f"probe_report_{run_id}.json" - write_json(json_report_path, report_payload) - - markdown_report = render_markdown( - generated_at=report_payload["generated_at_utc"], - run_dir=run_dir, - fetch_records=fetch_records, - page_analyses=page_analyses, - publications_by_source=publications_by_source, - robots_excerpt=robots_excerpt, - ) - markdown_report_path = notes_root / f"probe_report_{run_id}.md" - markdown_report_path.write_text(markdown_report, encoding="utf-8") - - summary = { - "run_id": run_id, - "run_dir": run_dir.as_posix(), - "json_report": json_report_path.as_posix(), - "markdown_report": markdown_report_path.as_posix(), - "profiles_analyzed": len(page_analyses), - } - print(json.dumps(summary, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) -- 2.49.1 From 441676be27af6e70a74a3e36b753c6de285c29ea Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Tue, 17 Feb 2026 20:24:12 +0100 Subject: [PATCH 3/3] test --- .dockerignore | 3 + .env.example | 25 +- .github/workflows/ci.yml | 82 + .gitignore | 3 + AGENTS.MD | 104 +- Dockerfile | 15 +- README.md | 262 +- .../20260217_0005_manual_run_idempotency.py | 88 + .../20260217_0006_scholar_profile_images.py | 59 + ...7_0007_scholar_initial_page_fingerprint.py | 50 + app/api/routers/publications.py | 47 +- app/api/routers/runs.py | 49 +- app/api/routers/scholars.py | 326 +- app/api/schemas.py | 69 +- app/db/base.py | 11 +- app/db/models.py | 15 + app/main.py | 42 + app/services/continuation_queue.py | 17 + app/services/ingestion.py | 144 + app/services/publications.py | 38 +- app/services/runs.py | 34 +- app/services/scheduler.py | 37 +- app/services/scholar_parser.py | 408 ++- app/services/scholar_source.py | 40 + app/services/scholars.py | 548 +++ app/settings.py | 39 + docker-compose.dev.yml | 31 + docker-compose.yml | 34 +- frontend/.gitignore | 3 + frontend/index.html | 31 + frontend/package-lock.json | 2985 +++++++++++++++++ frontend/package.json | 30 + frontend/postcss.config.cjs | 6 + frontend/src/app/AppShell.vue | 35 + frontend/src/app/guards.ts | 23 + frontend/src/app/providers.ts | 12 + frontend/src/app/router.ts | 84 + frontend/src/components/layout/AppHeader.vue | 71 + frontend/src/components/layout/AppNav.vue | 56 + frontend/src/components/layout/AppPage.vue | 16 + .../components/patterns/QueueHealthBadge.vue | 22 + .../components/patterns/RequestErrorPanel.vue | 18 + .../components/patterns/RunStatusBadge.vue | 34 + frontend/src/components/ui/AppAlert.vue | 49 + frontend/src/components/ui/AppBadge.vue | 33 + frontend/src/components/ui/AppButton.vue | 43 + frontend/src/components/ui/AppCard.vue | 5 + frontend/src/components/ui/AppCheckbox.vue | 22 + frontend/src/components/ui/AppEmptyState.vue | 16 + frontend/src/components/ui/AppInput.vue | 21 + frontend/src/components/ui/AppModal.vue | 17 + frontend/src/components/ui/AppSelect.vue | 19 + frontend/src/components/ui/AppSkeleton.vue | 15 + frontend/src/components/ui/AppTable.vue | 31 + frontend/src/env.d.ts | 1 + frontend/src/features/admin_users/index.ts | 49 + frontend/src/features/auth/index.ts | 9 + frontend/src/features/dashboard/index.ts | 58 + frontend/src/features/publications/index.ts | 81 + frontend/src/features/runs/index.ts | 171 + frontend/src/features/scholars/index.ts | 117 + frontend/src/features/settings/index.ts | 34 + frontend/src/lib/api/client.ts | 102 + frontend/src/lib/api/csrf.ts | 10 + frontend/src/lib/api/envelope.test.ts | 43 + frontend/src/lib/api/envelope.ts | 44 + frontend/src/lib/api/errors.test.ts | 22 + frontend/src/lib/api/errors.ts | 27 + frontend/src/lib/auth/session.ts | 35 + frontend/src/lib/utils/request_id.ts | 8 + frontend/src/main.ts | 22 + frontend/src/pages/AdminUsersPage.vue | 199 ++ frontend/src/pages/DashboardPage.vue | 187 ++ frontend/src/pages/LoginPage.vue | 129 + frontend/src/pages/PublicationsPage.vue | 449 +++ frontend/src/pages/RunDetailPage.vue | 134 + frontend/src/pages/RunsPage.vue | 266 ++ frontend/src/pages/ScholarsPage.vue | 921 +++++ frontend/src/pages/SettingsPage.vue | 255 ++ frontend/src/pages/StyleGuidePage.vue | 138 + frontend/src/stores/auth.ts | 71 + frontend/src/stores/theme.ts | 51 + frontend/src/stores/ui.ts | 20 + frontend/src/styles.css | 69 + frontend/tailwind.config.js | 32 + frontend/tsconfig.json | 19 + frontend/vite.config.ts | 26 + frontend/vitest.config.ts | 16 + pyproject.toml | 1 + scripts/check_frontend_api_contract.py | 113 + scripts/smoke_compose.sh | 7 +- tests/integration/test_api_v1.py | 355 +- tests/integration/test_migrations.py | 93 +- tests/smoke/test_migration_schema.py | 2 +- tests/unit/test_scholar_parser.py | 97 +- tests/unit/test_scholar_search_safety.py | 176 + uv.lock | 11 + 97 files changed, 10764 insertions(+), 223 deletions(-) create mode 100644 alembic/versions/20260217_0005_manual_run_idempotency.py create mode 100644 alembic/versions/20260217_0006_scholar_profile_images.py create mode 100644 alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py create mode 100644 docker-compose.dev.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.cjs create mode 100644 frontend/src/app/AppShell.vue create mode 100644 frontend/src/app/guards.ts create mode 100644 frontend/src/app/providers.ts create mode 100644 frontend/src/app/router.ts create mode 100644 frontend/src/components/layout/AppHeader.vue create mode 100644 frontend/src/components/layout/AppNav.vue create mode 100644 frontend/src/components/layout/AppPage.vue create mode 100644 frontend/src/components/patterns/QueueHealthBadge.vue create mode 100644 frontend/src/components/patterns/RequestErrorPanel.vue create mode 100644 frontend/src/components/patterns/RunStatusBadge.vue create mode 100644 frontend/src/components/ui/AppAlert.vue create mode 100644 frontend/src/components/ui/AppBadge.vue create mode 100644 frontend/src/components/ui/AppButton.vue create mode 100644 frontend/src/components/ui/AppCard.vue create mode 100644 frontend/src/components/ui/AppCheckbox.vue create mode 100644 frontend/src/components/ui/AppEmptyState.vue create mode 100644 frontend/src/components/ui/AppInput.vue create mode 100644 frontend/src/components/ui/AppModal.vue create mode 100644 frontend/src/components/ui/AppSelect.vue create mode 100644 frontend/src/components/ui/AppSkeleton.vue create mode 100644 frontend/src/components/ui/AppTable.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/features/admin_users/index.ts create mode 100644 frontend/src/features/auth/index.ts create mode 100644 frontend/src/features/dashboard/index.ts create mode 100644 frontend/src/features/publications/index.ts create mode 100644 frontend/src/features/runs/index.ts create mode 100644 frontend/src/features/scholars/index.ts create mode 100644 frontend/src/features/settings/index.ts create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/csrf.ts create mode 100644 frontend/src/lib/api/envelope.test.ts create mode 100644 frontend/src/lib/api/envelope.ts create mode 100644 frontend/src/lib/api/errors.test.ts create mode 100644 frontend/src/lib/api/errors.ts create mode 100644 frontend/src/lib/auth/session.ts create mode 100644 frontend/src/lib/utils/request_id.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/AdminUsersPage.vue create mode 100644 frontend/src/pages/DashboardPage.vue create mode 100644 frontend/src/pages/LoginPage.vue create mode 100644 frontend/src/pages/PublicationsPage.vue create mode 100644 frontend/src/pages/RunDetailPage.vue create mode 100644 frontend/src/pages/RunsPage.vue create mode 100644 frontend/src/pages/ScholarsPage.vue create mode 100644 frontend/src/pages/SettingsPage.vue create mode 100644 frontend/src/pages/StyleGuidePage.vue create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/theme.ts create mode 100644 frontend/src/stores/ui.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts create mode 100755 scripts/check_frontend_api_contract.py create mode 100644 tests/unit/test_scholar_search_safety.py diff --git a/.dockerignore b/.dockerignore index d32c9e5..28eee55 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,6 @@ __pycache__ *.log .env htmlcov +frontend/node_modules +frontend/dist +planning diff --git a/.env.example b/.env.example index dc1a02a..6b3a78b 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,26 @@ POSTGRES_DB=scholar POSTGRES_USER=scholar -POSTGRES_PASSWORD=scholar -POSTGRES_PORT=5432 +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_NAME=scholarr APP_HOST=0.0.0.0 APP_PORT=8000 APP_HOST_PORT=8000 -APP_RELOAD=1 +APP_RELOAD=0 +FRONTEND_ENABLED=1 +FRONTEND_DIST_DIR=/app/frontend/dist +FRONTEND_HOST_PORT=5173 +CHOKIDAR_USEPOLLING=1 +VITE_DEV_API_PROXY_TARGET=http://app:8000 MIGRATE_ON_START=1 -SESSION_SECRET_KEY=replace-with-a-long-random-secret -SESSION_COOKIE_SECURE=0 +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 LOG_LEVEL=INFO @@ -35,6 +40,16 @@ INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_ATTEMPTS=6 SCHEDULER_QUEUE_BATCH_SIZE=10 +SCHOLAR_IMAGE_UPLOAD_DIR=/var/lib/scholarr/uploads +SCHOLAR_IMAGE_UPLOAD_MAX_BYTES=2000000 +SCHOLAR_NAME_SEARCH_ENABLED=1 +SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS=21600 +SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS=300 +SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES=512 +SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS=8.0 +SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS=2.0 +SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1 +SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800 BOOTSTRAP_ADMIN_ON_START=0 BOOTSTRAP_ADMIN_EMAIL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e6592..2aceb0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,85 @@ jobs: - name: Integration tests run: uv run pytest -m integration + frontend-quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: API contract drift check + run: python3 scripts/check_frontend_api_contract.py + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install frontend dependencies + working-directory: frontend + run: npm install + + - name: Frontend typecheck + working-directory: frontend + run: npm run typecheck + + - name: Frontend unit tests + working-directory: frontend + run: npm run test:run + + - name: Frontend build + working-directory: frontend + run: npm run build + + docker-publish: + runs-on: ubuntu-latest + needs: + - test + - frontend-quality + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Derive image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: justinzeus/scholarr + tags: | + type=raw,value=latest + type=sha,format=short,prefix=sha- + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: prod + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index f0a8f4e..ef21cff 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ htmlcov/ .uv-cache/ .env *.log +planning/ +frontend/node_modules/ +frontend/dist/ diff --git a/AGENTS.MD b/AGENTS.MD index b2aa84e..4a9cbec 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -2,8 +2,6 @@ This file is the consolidated source of truth for project scope, constraints, scrape contract, and UI implementation flow. -Raw probe artifacts in `planning/scholar_probe_tmp/notes/*.json` and `planning/scholar_probe_tmp/notes/*.md` remain historical evidence and fixture references. - ## 1. Product Objective - Build a self-hosted scholar tracking system ("scholarr") with reliable, low-cost scraping and clear, actionable UI. @@ -94,7 +92,12 @@ Admin users: 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: @@ -112,11 +115,42 @@ Runs/diagnostics/queue: 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: @@ -132,6 +166,7 @@ Critical semantics: - 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 @@ -160,19 +195,48 @@ Authenticated: 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. Per-row quick links: +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`. @@ -215,6 +279,36 @@ Authenticated: ## 11. Open Items (Known Future Work) -- Scholar discovery by real name (search Google Scholar candidates, select one, then add scholar ID). - 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/Dockerfile b/Dockerfile index bfa7e00..22dc9b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,13 @@ +FROM node:20-alpine AS frontend-builder + +WORKDIR /frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + FROM python:3.12-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -33,7 +43,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ UV_PROJECT_ENVIRONMENT=/opt/venv \ PATH="/opt/venv/bin:$PATH" \ APP_RELOAD=0 \ - UVICORN_WORKERS=1 + UVICORN_WORKERS=1 \ + FRONTEND_ENABLED=1 \ + FRONTEND_DIST_DIR=/app/frontend/dist WORKDIR /app @@ -51,4 +63,5 @@ COPY alembic ./alembic RUN uv sync --frozen COPY scripts ./scripts +COPY --from=frontend-builder /frontend/dist /app/frontend/dist ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"] diff --git a/README.md b/README.md index 934599a..d5a847b 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,131 @@ # scholarr -API-first, self-hosted scholar tracking backend in the spirit of the `*arr` ecosystem. +
-The legacy server-rendered UI has been removed. This repository now focuses on core ingestion, scheduling, and multi-user API functionality. +API-first, self-hosted scholar tracking in the spirit of the `*arr` ecosystem. -## Current Scope +[![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/justinzeus/scholarr/actions/workflows/ci.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/scholarr) +[![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) -- Multi-user accounts with admin-managed user lifecycle -- Same-origin cookie sessions with CSRF enforcement -- API auth flows (login, logout, me, password change) -- Admin user management API -- Scholar CRUD per user -- Per-user ingestion settings -- Manual runs with idempotency support -- Run history, run detail diagnostics, and continuation queue actions -- Publication listing (`new` / `all`) and mark-all-read -- Ingestion scheduler + continuation queue retries -- Structured logging with request IDs + redaction -- PostgreSQL + Alembic migrations -- Container-first development workflow with `uv` +
-## Functionality Tracking +## What This Includes -Planned and supported backend functionality is tracked in: +- Multi-user accounts with admin-managed lifecycle +- Cookie session auth with CSRF enforcement +- Scholar CRUD + name-search discovery + profile image management +- Per-user ingestion settings and scheduler +- Manual runs with idempotency behavior +- Run history and continuation queue diagnostics/actions +- Publications workflow (`new` / `all`, mark-selected-read, mark-all-read) +- Vue 3 + Vite frontend served from the same container image as the API -- `AGENTS.MD` +## Docker Image + +- Image: `justinzeus/scholarr` +- Published by GitHub Actions on every push to `main` +- Architectures: `linux/amd64`, `linux/arm64` +- Tags: + - `latest` + - `sha-` + +## Quick Deploy (Copy Compose + Fill Env) + +1. Copy `docker-compose.yml` and `.env.example` into your deployment directory. +2. Create `.env` from `.env.example`. +3. Set at minimum: + - `SESSION_SECRET_KEY` + - `POSTGRES_PASSWORD` +4. Pull and start: + +```bash +docker compose pull +docker compose up -d +``` + +5. Open: + +- App + API: `http://localhost:8000` +- Health check: `http://localhost:8000/healthz` + +The SPA and API are same-origin by default in this deployment model. + +## Required Environment Variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `SESSION_SECRET_KEY` | Yes | Session signing key. Use a long random value. | +| `POSTGRES_PASSWORD` | Yes | Database password for the bundled Postgres service. | + +Optional startup bootstrap: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `BOOTSTRAP_ADMIN_ON_START` | `0` | Auto-create admin on app start. | +| `BOOTSTRAP_ADMIN_EMAIL` | empty | Admin email for bootstrap. | +| `BOOTSTRAP_ADMIN_PASSWORD` | empty | Admin password for bootstrap. | +| `BOOTSTRAP_ADMIN_FORCE_PASSWORD` | `0` | Force-reset bootstrap admin password if exists. | + +## Ports + +| Port | Service | Description | +| --- | --- | --- | +| `8000` | `app` | scholarr API + frontend | + +## Volumes + +| Volume | Container Path | Purpose | +| --- | --- | --- | +| `postgres_data` | `/var/lib/postgresql/data` | Postgres persistence | +| `scholar_uploads` | `/var/lib/scholarr/uploads` | Scholar image upload persistence | + +## Upgrade + +```bash +docker compose pull +docker compose up -d +``` + +## Development Workflow + +Default `docker-compose.yml` is deployment-oriented (prebuilt image). + +For local development with source mounts + Vite dev server: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build +``` + +Then open: + +- API: `http://localhost:8000` +- Frontend dev server: `http://localhost:5173` + +## Test and Quality 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 check: + +```bash +python3 scripts/check_frontend_api_contract.py +``` ## API Base @@ -34,122 +135,13 @@ Planned and supported backend functionality is tracked in: - Error envelope: - `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` -## Auth & Session Model - -- Session transport: same-origin cookie session (`HttpOnly`, `SameSite=Lax`) -- CSRF: - - Required for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) via `X-CSRF-Token` - - Bootstrap token via `GET /api/v1/auth/csrf` - -## API Surface - -- Auth: - - `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` - - `PATCH /api/v1/scholars/{id}/toggle` - - `DELETE /api/v1/scholars/{id}` -- Settings: - - `GET /api/v1/settings` - - `PUT /api/v1/settings` -- Runs: - - `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-all-read` -- Ops: - - `GET /healthz` - -## Quick Start - -1. Copy environment defaults: - -```bash -cp .env.example .env -``` - -2. Optional bootstrap admin on startup: - -```bash -export BOOTSTRAP_ADMIN_ON_START=1 -export BOOTSTRAP_ADMIN_EMAIL=admin@example.com -export BOOTSTRAP_ADMIN_PASSWORD=change-me-now -``` - -3. Start stack: - -```bash -docker compose up --build -``` - -4. Health check: - -```text -http://localhost:8000/healthz -``` - -## Admin Bootstrap (Manual) - -```bash -docker compose run --rm app uv run python scripts/bootstrap_admin.py \ - --email admin@example.com \ - --password change-me-now \ - --force-password -``` - -## Test Workflow - -- Unit tests: - -```bash -docker compose run --rm app uv run pytest tests/unit -``` - -- Integration tests: - -```bash -docker compose run --rm app uv run pytest -m integration -``` - -- Smoke checks: - -```bash -./scripts/smoke_compose.sh -``` - ## Logging -Configurable env vars: +Important envs: -- `LOG_LEVEL` (default `INFO`) -- `LOG_FORMAT` (`console` or `json`, default `console`) -- `LOG_REQUESTS` (`1`/`0`, default `1`) -- `LOG_UVICORN_ACCESS` (`1`/`0`, default `0`) -- `LOG_REQUEST_SKIP_PATHS` (default `/healthz`) -- `LOG_REDACT_FIELDS` (additional redact keys) - -## Project Layout - -```text -app/ FastAPI app (API routers, auth, services, db, middleware) -alembic/ Migration environment and versions -scripts/ Entrypoint, db wait/bootstrap, smoke automation -tests/ Unit, integration, and smoke suites -planning/ Scope and implementation planning notes -``` +- `LOG_LEVEL` +- `LOG_FORMAT` (`console` or `json`) +- `LOG_REQUESTS` +- `LOG_UVICORN_ACCESS` +- `LOG_REQUEST_SKIP_PATHS` +- `LOG_REDACT_FIELDS` diff --git a/alembic/versions/20260217_0005_manual_run_idempotency.py b/alembic/versions/20260217_0005_manual_run_idempotency.py new file mode 100644 index 0000000..0375569 --- /dev/null +++ b/alembic/versions/20260217_0005_manual_run_idempotency.py @@ -0,0 +1,88 @@ +"""Add database-backed manual run idempotency key + +Revision ID: 20260217_0005 +Revises: 20260217_0004 +Create Date: 2026-02-17 16:20:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "20260217_0005" +down_revision: str | Sequence[str] | None = "20260217_0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +INDEX_NAME = "uq_crawl_runs_user_manual_idempotency_key" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("crawl_runs")} + indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} + + if "idempotency_key" not in columns: + op.add_column( + "crawl_runs", + sa.Column("idempotency_key", sa.String(length=128), nullable=True), + ) + columns.add("idempotency_key") + + if "idempotency_key" in columns: + op.execute( + """ + UPDATE crawl_runs + SET idempotency_key = NULLIF(BTRIM(error_log #>> '{meta,idempotency_key}'), '') + WHERE trigger_type = 'manual' + AND idempotency_key IS NULL + """ + ) + # Preserve one winning row per (user_id, idempotency_key) before creating uniqueness. + op.execute( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, idempotency_key + ORDER BY start_dt DESC, id DESC + ) AS rn + FROM crawl_runs + WHERE trigger_type = 'manual' + AND idempotency_key IS NOT NULL + ) + UPDATE crawl_runs AS runs + SET idempotency_key = NULL + 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", "idempotency_key"], + unique=True, + postgresql_where=sa.text( + "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("crawl_runs")} + 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") + if "idempotency_key" in columns: + op.drop_column("crawl_runs", "idempotency_key") diff --git a/alembic/versions/20260217_0006_scholar_profile_images.py b/alembic/versions/20260217_0006_scholar_profile_images.py new file mode 100644 index 0000000..08035c8 --- /dev/null +++ b/alembic/versions/20260217_0006_scholar_profile_images.py @@ -0,0 +1,59 @@ +"""Add scholar profile image metadata and overrides. + +Revision ID: 20260217_0006 +Revises: 20260217_0005 +Create Date: 2026-02-17 18:30:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "20260217_0006" +down_revision: str | Sequence[str] | None = "20260217_0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +TABLE_NAME = "scholar_profiles" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "profile_image_url" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("profile_image_url", sa.Text(), nullable=True), + ) + + if "profile_image_override_url" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("profile_image_override_url", sa.Text(), nullable=True), + ) + + if "profile_image_upload_path" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("profile_image_upload_path", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "profile_image_upload_path" in columns: + op.drop_column(TABLE_NAME, "profile_image_upload_path") + + if "profile_image_override_url" in columns: + op.drop_column(TABLE_NAME, "profile_image_override_url") + + if "profile_image_url" in columns: + op.drop_column(TABLE_NAME, "profile_image_url") diff --git a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py new file mode 100644 index 0000000..dcfd0f7 --- /dev/null +++ b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py @@ -0,0 +1,50 @@ +"""Add per-scholar initial page fingerprint snapshot columns. + +Revision ID: 20260217_0007 +Revises: 20260217_0006 +Create Date: 2026-02-17 18:55:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "20260217_0007" +down_revision: str | Sequence[str] | None = "20260217_0006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +TABLE_NAME = "scholar_profiles" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "last_initial_page_fingerprint_sha256" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("last_initial_page_fingerprint_sha256", sa.String(length=64), nullable=True), + ) + + if "last_initial_page_checked_at" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("last_initial_page_checked_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "last_initial_page_checked_at" in columns: + op.drop_column(TABLE_NAME, "last_initial_page_checked_at") + + if "last_initial_page_fingerprint_sha256" in columns: + op.drop_column(TABLE_NAME, "last_initial_page_fingerprint_sha256") diff --git a/app/api/routers/publications.py b/app/api/routers/publications.py index a783147..9933fe6 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -9,7 +9,12 @@ 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.schemas import MarkAllReadEnvelope, PublicationsListEnvelope +from app.api.schemas import ( + MarkAllReadEnvelope, + MarkSelectedReadEnvelope, + MarkSelectedReadRequest, + PublicationsListEnvelope, +) from app.db.models import User from app.db.session import get_db_session from app.services import publications as publication_service @@ -121,3 +126,43 @@ async def mark_all_publications_read( "updated_count": updated_count, }, ) + + +@router.post( + "/mark-read", + response_model=MarkSelectedReadEnvelope, +) +async def mark_selected_publications_read( + payload: MarkSelectedReadRequest, + request: Request, + 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 + } + ) + updated_count = await publication_service.mark_selected_as_read_for_user( + db_session, + user_id=current_user.id, + selections=selection_pairs, + ) + 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, + }, + ) + return success_payload( + request, + data={ + "message": "Marked selected publications as read.", + "requested_count": len(selection_pairs), + "updated_count": updated_count, + }, + ) diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index 55bffa3..e2eab8c 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -5,6 +5,7 @@ import re from typing import Any from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user @@ -380,6 +381,7 @@ async def run_manual( 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, ) except ingestion_service.RunAlreadyInProgressError as exc: await db_session.rollback() @@ -388,6 +390,45 @@ async def run_manual( code="run_in_progress", message="A run is already in progress for this account.", ) 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, + ), + ) + logger.exception( + "api.runs.manual_integrity_error", + extra={ + "event": "api.runs.manual_integrity_error", + "user_id": current_user.id, + }, + ) + raise ApiException( + status_code=500, + code="manual_run_failed", + message="Manual run failed.", + ) from exc except Exception as exc: await db_session.rollback() logger.exception( @@ -403,14 +444,6 @@ async def run_manual( message="Manual run failed.", ) from exc - if idempotency_key is not None: - await run_service.set_manual_run_idempotency_key( - db_session, - user_id=current_user.id, - run_id=run_summary.crawl_run_id, - idempotency_key=idempotency_key, - ) - return success_payload( request, data={ diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 2f109b5..bc7b8a3 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -1,33 +1,56 @@ from __future__ import annotations +import asyncio import logging +import mimetypes -from fastapi import APIRouter, Depends, Request +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.runtime_deps import get_scholar_source from app.api.schemas import ( MessageEnvelope, ScholarCreateRequest, ScholarEnvelope, + ScholarImageUrlUpdateRequest, + ScholarSearchEnvelope, ScholarsListEnvelope, ) 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.settings import settings 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, @@ -67,6 +90,7 @@ async def create_scholar( payload: ScholarCreateRequest, request: Request, db_session: AsyncSession = Depends(get_db_session), + source: ScholarSource = Depends(get_scholar_source), current_user: User = Depends(get_api_current_user), ): try: @@ -74,7 +98,8 @@ async def create_scholar( db_session, user_id=current_user.id, scholar_id=payload.scholar_id, - display_name=payload.display_name or "", + display_name="", + profile_image_url=payload.profile_image_url, ) except scholar_service.ScholarServiceError as exc: raise ApiException( @@ -90,12 +115,104 @@ async def create_scholar( "scholar_profile_id": created.id, }, ) + try: + if not created.profile_image_url or not (created.display_name or "").strip(): + created = await asyncio.wait_for( + scholar_service.hydrate_profile_metadata( + db_session, + profile=created, + source=source, + ), + timeout=5.0, + ) + except Exception: + logger.warning( + "api.scholars.create_metadata_hydration_failed", + extra={ + "event": "api.scholars.create_metadata_hydration_failed", + "user_id": current_user.id, + "scholar_profile_id": created.id, + }, + ) + return success_payload( request, data=_serialize_scholar(created), ) +@router.get( + "/search", + response_model=ScholarSearchEnvelope, +) +async def search_scholars( + request: Request, + query: str = Query(..., min_length=2, max_length=120), + limit: int = Query(10, ge=1, le=25), + source: ScholarSource = Depends(get_scholar_source), + current_user: User = Depends(get_api_current_user), +): + try: + parsed = await scholar_service.search_author_candidates( + source=source, + 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, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_search", + message=str(exc), + ) from exc + + 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, + }, + ) + 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, + }, + ) + + @router.patch( "/{scholar_profile_id}/toggle", response_model=ScholarEnvelope, @@ -154,7 +271,11 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - await scholar_service.delete_scholar(db_session, profile=profile) + await scholar_service.delete_scholar( + db_session, + profile=profile, + upload_dir=settings.scholar_image_upload_dir, + ) logger.info( "api.scholars.deleted", extra={ @@ -167,3 +288,202 @@ async def delete_scholar( request, data={"message": "Scholar deleted."}, ) + + +@router.put( + "/{scholar_profile_id}/image/url", + response_model=ScholarEnvelope, +) +async def update_scholar_image_url( + scholar_profile_id: int, + payload: ScholarImageUrlUpdateRequest, + request: Request, + 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.", + ) + + try: + updated = await scholar_service.set_profile_image_override_url( + db_session, + profile=profile, + image_url=payload.image_url, + upload_dir=settings.scholar_image_upload_dir, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_image", + 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, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.post( + "/{scholar_profile_id}/image/upload", + response_model=ScholarEnvelope, +) +async def upload_scholar_image( + scholar_profile_id: int, + request: Request, + image: UploadFile = File(...), + 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.", + ) + + try: + image_bytes = await image.read() + updated = await scholar_service.set_profile_image_upload( + db_session, + profile=profile, + content_type=image.content_type, + image_bytes=image_bytes, + upload_dir=settings.scholar_image_upload_dir, + max_upload_bytes=settings.scholar_image_upload_max_bytes, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_image", + message=str(exc), + ) from exc + finally: + await image.close() + + 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), + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.delete( + "/{scholar_profile_id}/image", + response_model=ScholarEnvelope, +) +async def clear_scholar_image_customization( + scholar_profile_id: int, + request: Request, + 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.", + ) + + updated = await scholar_service.clear_profile_image_customization( + db_session, + 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, + }, + ) + return success_payload( + request, + 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/schemas.py b/app/api/schemas.py index ab5934b..e5264db 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -112,6 +112,8 @@ 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 @@ -135,7 +137,7 @@ class ScholarsListEnvelope(BaseModel): class ScholarCreateRequest(BaseModel): scholar_id: str - display_name: str | None = None + profile_image_url: str | None = None model_config = ConfigDict(extra="forbid") @@ -147,6 +149,43 @@ class ScholarEnvelope(BaseModel): 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 @@ -454,3 +493,31 @@ class MarkAllReadEnvelope(BaseModel): 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/db/base.py b/app/db/base.py index 29adb07..6fa2049 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,4 +1,6 @@ -from sqlalchemy import MetaData +from datetime import datetime, timezone + +from sqlalchemy import MetaData, event from sqlalchemy.orm import DeclarativeBase @@ -15,4 +17,11 @@ class Base(DeclarativeBase): metadata = MetaData(naming_convention=NAMING_CONVENTION) +@event.listens_for(Base, "before_update", propagate=True) +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) + + metadata = Base.metadata diff --git a/app/db/models.py b/app/db/models.py index 428c654..68c3e72 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -119,6 +119,11 @@ class ScholarProfile(Base): ) 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) + profile_image_override_url: Mapped[str | None] = mapped_column(Text) + 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") ) @@ -141,6 +146,15 @@ class CrawlRun(Base): __tablename__ = "crawl_runs" __table_args__ = ( Index("ix_crawl_runs_user_start", "user_id", "start_dt"), + 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" + ), + ), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -163,6 +177,7 @@ class CrawlRun(Base): 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") ) diff --git a/app/main.py b/app/main.py index b6942dc..44d5cc7 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,11 @@ from __future__ import annotations from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.api.errors import register_api_exception_handlers @@ -68,3 +71,42 @@ async def healthz() -> dict[str, str]: if await check_database(): return {"status": "ok"} raise HTTPException(status_code=500, detail="database unavailable") + + +def _configure_frontend_routes(application: FastAPI) -> None: + if not settings.frontend_enabled: + return + + dist_dir = Path(settings.frontend_dist_dir) + index_file = dist_dir / "index.html" + + if not index_file.is_file(): + return + + assets_dir = dist_dir / "assets" + if assets_dir.is_dir(): + application.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets") + + @application.get("/", include_in_schema=False) + async def frontend_index() -> FileResponse: + return FileResponse(index_file) + + @application.get("/{full_path:path}", include_in_schema=False) + async def frontend_entry(full_path: str) -> FileResponse: + normalized = full_path.lstrip("/") + if normalized in {"healthz", "api"} or normalized.startswith("api/"): + raise HTTPException(status_code=404, detail="Not Found") + + candidate = (dist_dir / normalized).resolve() + try: + candidate.relative_to(dist_dir.resolve()) + except ValueError as error: + raise HTTPException(status_code=404, detail="Not Found") from error + + if candidate.is_file(): + return FileResponse(candidate) + + return FileResponse(index_file) + + +_configure_frontend_routes(app) diff --git a/app/services/continuation_queue.py b/app/services/continuation_queue.py index cb5b175..7d7d93e 100644 --- a/app/services/continuation_queue.py +++ b/app/services/continuation_queue.py @@ -181,6 +181,23 @@ async def increment_attempt_count( return item +async def reset_attempt_count( + db_session: AsyncSession, + *, + job_id: int, +) -> IngestionQueueItem | None: + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) + item = result.scalar_one_or_none() + if item is None: + return None + item.attempt_count = 0 + item.updated_at = now + return item + + async def reschedule_job( db_session: AsyncSession, *, diff --git a/app/services/ingestion.py b/app/services/ingestion.py index 61025bb..986f460 100644 --- a/app/services/ingestion.py +++ b/app/services/ingestion.py @@ -4,6 +4,7 @@ import asyncio from dataclasses import dataclass from datetime import datetime, timezone import hashlib +import json import logging import re from typing import Any @@ -45,6 +46,7 @@ RESUMABLE_PARTIAL_REASONS = { "pagination_cursor_stalled", } RESUMABLE_PARTIAL_REASON_PREFIXES = ("page_state_network_error",) +INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS = 30 logger = logging.getLogger(__name__) @@ -63,6 +65,9 @@ class RunExecutionSummary: 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]] @@ -71,6 +76,7 @@ class PagedParseResult: has_more_remaining: bool pagination_truncated_reason: str | None continuation_cstart: int | None + skipped_no_change: bool class RunAlreadyInProgressError(RuntimeError): @@ -96,6 +102,7 @@ class ScholarIngestionService: 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, ) -> RunExecutionSummary: lock_acquired = await self._try_acquire_user_lock( db_session, @@ -153,6 +160,7 @@ class ScholarIngestionService: "retry_backoff_seconds": retry_backoff_seconds, "max_pages_per_scholar": max_pages_per_scholar, "page_size": page_size, + "idempotency_key": idempotency_key, }, ) @@ -162,6 +170,7 @@ class ScholarIngestionService: status=RunStatus.RUNNING, scholar_count=len(scholars), new_pub_count=0, + idempotency_key=idempotency_key, error_log={}, ) db_session.add(run) @@ -187,6 +196,7 @@ class ScholarIngestionService: 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 @@ -194,6 +204,17 @@ class ScholarIngestionService: 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={ @@ -210,6 +231,7 @@ class ScholarIngestionService: "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, }, ) @@ -230,7 +252,55 @@ class ScholarIngestionService: "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 = ( @@ -373,6 +443,11 @@ class ScholarIngestionService: "failed_state_counts": failed_state_counts, "failed_reason_counts": failed_reason_counts, }, + "meta": { + "idempotency_key": idempotency_key, + } + if idempotency_key + else {}, } await db_session.commit() @@ -519,6 +594,7 @@ class ScholarIngestionService: 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)) @@ -534,6 +610,9 @@ class ScholarIngestionService: 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]] = [] @@ -553,11 +632,39 @@ class ScholarIngestionService: ) 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, @@ -568,6 +675,7 @@ class ScholarIngestionService: continuation_cstart=( start_cstart if parsed_page.state == ParseState.NETWORK_ERROR else None ), + skipped_no_change=False, ) publications = list(parsed_page.publications) @@ -649,6 +757,9 @@ class ScholarIngestionService: 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, @@ -657,6 +768,7 @@ class ScholarIngestionService: 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( @@ -838,6 +950,7 @@ class ScholarIngestionService: "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, @@ -914,6 +1027,37 @@ def build_publication_fingerprint(candidate: PublicationCandidate) -> str: 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 diff --git a/app/services/publications.py b/app/services/publications.py index 5856b74..3ba5f87 100644 --- a/app/services/publications.py +++ b/app/services/publications.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime -from sqlalchemy import Select, func, select, update +from sqlalchemy import Select, func, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ( @@ -295,3 +295,39 @@ async def mark_all_unread_as_read_for_user( 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/runs.py b/app/services/runs.py index e313a9d..1e65cfe 100644 --- a/app/services/runs.py +++ b/app/services/runs.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any -from sqlalchemy import and_, case, func, select +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ( @@ -152,7 +152,10 @@ async def get_manual_run_by_idempotency_key( .where( CrawlRun.user_id == user_id, CrawlRun.trigger_type == RunTriggerType.MANUAL, - CrawlRun.error_log["meta"]["idempotency_key"].astext == idempotency_key, + 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) @@ -160,33 +163,6 @@ async def get_manual_run_by_idempotency_key( return result.scalar_one_or_none() -async def set_manual_run_idempotency_key( - db_session: AsyncSession, - *, - user_id: int, - run_id: int, - idempotency_key: str, -) -> bool: - run = await get_run_for_user( - db_session, - user_id=user_id, - run_id=run_id, - ) - if run is None: - return False - if run.trigger_type != RunTriggerType.MANUAL: - return False - - payload = dict(run.error_log) if isinstance(run.error_log, dict) else {} - meta = payload.get("meta") - meta_dict = dict(meta) if isinstance(meta, dict) else {} - meta_dict["idempotency_key"] = idempotency_key - payload["meta"] = meta_dict - run.error_log = payload - await db_session.commit() - return True - - async def list_queue_items_for_user( db_session: AsyncSession, *, diff --git a/app/services/scheduler.py b/app/services/scheduler.py index 80ca1cb..40dbc6f 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -399,6 +399,39 @@ class SchedulerService: 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, @@ -451,9 +484,9 @@ class SchedulerService: ) await session.commit() logger.info( - "scheduler.queue_item_rescheduled", + "scheduler.queue_item_rescheduled_failed", extra={ - "event": "scheduler.queue_item_rescheduled", + "event": "scheduler.queue_item_rescheduled_failed", "queue_item_id": job.id, "user_id": job.user_id, "attempt_count": queue_item.attempt_count, diff --git a/app/services/scholar_parser.py b/app/services/scholar_parser.py index 4cb9ed3..de2daa5 100644 --- a/app/services/scholar_parser.py +++ b/app/services/scholar_parser.py @@ -6,7 +6,7 @@ from enum import StrEnum from html import unescape from html.parser import HTMLParser from typing import Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urljoin, urlparse from app.services.scholar_source import FetchResult @@ -16,6 +16,8 @@ BLOCKED_KEYWORDS = [ "not a robot", "our systems have detected", "automated queries", + "recaptcha", + "captcha", ] NO_RESULTS_KEYWORDS = [ @@ -25,6 +27,14 @@ NO_RESULTS_KEYWORDS = [ "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", @@ -36,6 +46,33 @@ MARKER_KEYS = [ "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( @@ -63,11 +100,24 @@ class PublicationCandidate: 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] @@ -76,6 +126,15 @@ class ParsedProfilePage: 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()) @@ -98,6 +157,19 @@ def attr_href(attrs: list[tuple[str, str | None]]) -> str | None: 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) @@ -197,6 +269,18 @@ def parse_cluster_id_from_href(href: str | None) -> str | None: 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) @@ -265,6 +349,30 @@ def extract_profile_name(html: str) -> str | None: 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['\"][^>]*>(.*?)]+>", @@ -304,6 +412,229 @@ def count_markers(html: str) -> dict[str, int]: 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], @@ -312,15 +643,19 @@ def detect_state( visible_text: str, ) -> tuple[ParseState, str]: if fetch_result.status_code is None: - return ParseState.NETWORK_ERROR, "network_error_missing_status_code" + 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) - if "accounts.google.com" in final and ("signin" in final or "servicelogin" in final): - return ParseState.BLOCKED_OR_CAPTCHA, "blocked_accounts_redirect" - if any(keyword in lowered for keyword in BLOCKED_KEYWORDS) or "sorry/index" in final: - return ParseState.BLOCKED_OR_CAPTCHA, "blocked_keyword_detected" + 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" @@ -335,6 +670,40 @@ def detect_state( 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) @@ -361,6 +730,7 @@ def parse_profile_page(fetch_result: FetchResult) -> 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, @@ -368,3 +738,29 @@ def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: 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 index 75e260a..327d1c4 100644 --- a/app/services/scholar_source.py +++ b/app/services/scholar_source.py @@ -52,6 +52,14 @@ class ScholarSource(Protocol): ) -> FetchResult: ... + async def fetch_author_search_html( + self, + query: str, + *, + start: int, + ) -> FetchResult: + ... + class LiveScholarSource: def __init__( @@ -94,6 +102,27 @@ class LiveScholarSource: ) 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, @@ -169,3 +198,14 @@ def _build_profile_url(*, scholar_id: str, cstart: int, pagesize: int) -> str: 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 index aacf210..d5f9569 100644 --- a/app/services/scholars.py +++ b/app/services/scholars.py @@ -1,14 +1,106 @@ 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 +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 class ScholarServiceError(ValueError): @@ -27,6 +119,174 @@ def normalize_display_name(value: str) -> str | None: 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 + _AUTHOR_SEARCH_CACHE.clear() + _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 + _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None + _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 + + async def list_scholars_for_user( db_session: AsyncSession, *, @@ -46,11 +306,13 @@ async def create_scholar_for_user( 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: @@ -92,7 +354,293 @@ 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, +) -> ParsedAuthorSearchPage: + global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC + global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC + global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT + + 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() + + cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc) + if cooldown_remaining_seconds > 0: + 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, + }, + ) + return _policy_blocked_author_search_result( + reason=SEARCH_COOLDOWN_REASON, + warning_codes=[ + "author_search_cooldown_active", + f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", + ], + 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] = [] + + 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") + 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), + ) + + 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 + 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/settings.py b/app/settings.py index 79fd40c..ea292e0 100644 --- a/app/settings.py +++ b/app/settings.py @@ -79,6 +79,45 @@ class Settings: 6, ) scheduler_queue_batch_size: int = _env_int("SCHEDULER_QUEUE_BATCH_SIZE", 10) + 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( + "SCHOLAR_IMAGE_UPLOAD_DIR", + "/var/lib/scholarr/uploads", + ) + scholar_image_upload_max_bytes: int = _env_int( + "SCHOLAR_IMAGE_UPLOAD_MAX_BYTES", + 2_000_000, + ) + scholar_name_search_enabled: bool = _env_bool("SCHOLAR_NAME_SEARCH_ENABLED", True) + scholar_name_search_cache_ttl_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS", + 21_600, + ) + scholar_name_search_blocked_cache_ttl_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS", + 300, + ) + scholar_name_search_cache_max_entries: int = _env_int( + "SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES", + 512, + ) + scholar_name_search_min_interval_seconds: float = _env_float( + "SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS", + 3.0, + ) + scholar_name_search_interval_jitter_seconds: float = _env_float( + "SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS", + 1.0, + ) + scholar_name_search_cooldown_block_threshold: int = _env_int( + "SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD", + 1, + ) + scholar_name_search_cooldown_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS", + 1800, + ) settings = Settings() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..1c5e574 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,31 @@ +services: + app: + image: scholarr-dev:local + build: + context: . + target: dev + environment: + APP_RELOAD: ${APP_RELOAD:-1} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} + FRONTEND_ENABLED: ${FRONTEND_ENABLED:-0} + volumes: + - ./:/app + + frontend: + image: node:20-alpine + working_dir: /frontend + command: ["sh", "-lc", "npm install && npm run dev"] + environment: + CHOKIDAR_USEPOLLING: ${CHOKIDAR_USEPOLLING:-1} + VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://app:8000} + ports: + - "${FRONTEND_HOST_PORT:-5173}:5173" + volumes: + - ./frontend:/frontend + - frontend_node_modules:/frontend/node_modules + depends_on: + app: + condition: service_healthy + +volumes: + frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index b764f1c..764ce81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,11 @@ name: scholarr services: db: image: postgres:15-alpine + restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-scholar} POSTGRES_USER: ${POSTGRES_USER:-scholar} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scholar} - ports: - - "${POSTGRES_PORT:-5432}:5432" + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -18,19 +17,19 @@ services: retries: 20 app: - build: - context: . - target: dev + image: ${SCHOLARR_IMAGE:-justinzeus/scholarr:latest} + restart: unless-stopped environment: - DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://scholar:scholar@db:5432/scholar} - TEST_DATABASE_URL: ${TEST_DATABASE_URL:-} + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${POSTGRES_USER:-scholar}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-scholar}} MIGRATE_ON_START: ${MIGRATE_ON_START:-1} APP_NAME: ${APP_NAME:-scholarr} APP_HOST: ${APP_HOST:-0.0.0.0} APP_PORT: ${APP_PORT:-8000} - APP_RELOAD: ${APP_RELOAD:-1} - SESSION_SECRET_KEY: ${SESSION_SECRET_KEY:-dev-insecure-session-key} - SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} + APP_RELOAD: ${APP_RELOAD:-0} + SESSION_SECRET_KEY: ${SESSION_SECRET_KEY:?set SESSION_SECRET_KEY} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-1} + FRONTEND_ENABLED: ${FRONTEND_ENABLED:-1} + FRONTEND_DIST_DIR: ${FRONTEND_DIST_DIR:-/app/frontend/dist} LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-5} LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-60} LOG_LEVEL: ${LOG_LEVEL:-INFO} @@ -50,6 +49,16 @@ services: INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600} INGESTION_CONTINUATION_MAX_ATTEMPTS: ${INGESTION_CONTINUATION_MAX_ATTEMPTS:-6} SCHEDULER_QUEUE_BATCH_SIZE: ${SCHEDULER_QUEUE_BATCH_SIZE:-10} + SCHOLAR_IMAGE_UPLOAD_DIR: ${SCHOLAR_IMAGE_UPLOAD_DIR:-/var/lib/scholarr/uploads} + SCHOLAR_IMAGE_UPLOAD_MAX_BYTES: ${SCHOLAR_IMAGE_UPLOAD_MAX_BYTES:-2000000} + SCHOLAR_NAME_SEARCH_ENABLED: ${SCHOLAR_NAME_SEARCH_ENABLED:-1} + SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS: ${SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS:-21600} + SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS: ${SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS:-300} + SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES: ${SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES:-512} + SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS: ${SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS:-8.0} + SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS: ${SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS:-2.0} + SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD: ${SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD:-1} + SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS: ${SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS:-1800} BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0} BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} @@ -59,7 +68,7 @@ services: ports: - "${APP_HOST_PORT:-8000}:8000" volumes: - - ./:/app + - scholar_uploads:/var/lib/scholarr/uploads depends_on: db: condition: service_healthy @@ -71,3 +80,4 @@ services: volumes: postgres_data: + scholar_uploads: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3ff38cc --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..91a3496 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + + scholarr + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..81441d5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2985 @@ +{ + "name": "scholarr-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scholarr-frontend", + "version": "0.1.0", + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/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/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "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.30001766", + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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": { + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "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/chokidar/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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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==", + "dev": true, + "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-glob/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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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/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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "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==", + "dev": true, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2b5f7d3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "scholarr-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0 --port 4173", + "typecheck": "vue-tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8", + "vue-tsc": "^2.1.10" + } +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/AppShell.vue b/frontend/src/app/AppShell.vue new file mode 100644 index 0000000..5bee674 --- /dev/null +++ b/frontend/src/app/AppShell.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/app/guards.ts b/frontend/src/app/guards.ts new file mode 100644 index 0000000..0a63a9a --- /dev/null +++ b/frontend/src/app/guards.ts @@ -0,0 +1,23 @@ +import type { Router } from "vue-router"; + +import { useAuthStore } from "@/stores/auth"; + +export function applyRouteGuards(router: Router): void { + router.beforeEach((to) => { + const auth = useAuthStore(); + + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return { name: "login" }; + } + + if (to.meta.requiresAdmin && !auth.isAdmin) { + return { name: "dashboard" }; + } + + if (to.meta.guestOnly && auth.isAuthenticated) { + return { name: "dashboard" }; + } + + return true; + }); +} diff --git a/frontend/src/app/providers.ts b/frontend/src/app/providers.ts new file mode 100644 index 0000000..4f6db55 --- /dev/null +++ b/frontend/src/app/providers.ts @@ -0,0 +1,12 @@ +import { setCsrfTokenProvider } from "@/lib/api/client"; +import { useAuthStore } from "@/stores/auth"; +import { useThemeStore } from "@/stores/theme"; + +export async function bootstrapAppProviders(): Promise { + const theme = useThemeStore(); + theme.initialize(); + + const auth = useAuthStore(); + setCsrfTokenProvider(() => auth.csrfToken); + await auth.bootstrapSession(); +} diff --git a/frontend/src/app/router.ts b/frontend/src/app/router.ts new file mode 100644 index 0000000..1527b7a --- /dev/null +++ b/frontend/src/app/router.ts @@ -0,0 +1,84 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import { applyRouteGuards } from "@/app/guards"; +import LoginPage from "@/pages/LoginPage.vue"; +import DashboardPage from "@/pages/DashboardPage.vue"; +import ScholarsPage from "@/pages/ScholarsPage.vue"; +import PublicationsPage from "@/pages/PublicationsPage.vue"; +import RunsPage from "@/pages/RunsPage.vue"; +import RunDetailPage from "@/pages/RunDetailPage.vue"; +import SettingsPage from "@/pages/SettingsPage.vue"; +import AdminUsersPage from "@/pages/AdminUsersPage.vue"; +import StyleGuidePage from "@/pages/StyleGuidePage.vue"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: LoginPage, + meta: { guestOnly: true }, + }, + { + path: "/", + redirect: "/dashboard", + }, + { + path: "/dashboard", + name: "dashboard", + component: DashboardPage, + meta: { requiresAuth: true }, + }, + { + path: "/scholars", + name: "scholars", + component: ScholarsPage, + meta: { requiresAuth: true }, + }, + { + path: "/publications", + name: "publications", + component: PublicationsPage, + meta: { requiresAuth: true }, + }, + { + path: "/settings", + name: "settings", + component: SettingsPage, + meta: { requiresAuth: true }, + }, + { + path: "/admin/style-guide", + name: "style-guide", + component: StyleGuidePage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/admin/runs", + name: "runs", + component: RunsPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/admin/runs/:id", + name: "run-detail", + component: RunDetailPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/admin/users", + name: "admin-users", + component: AdminUsersPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/:pathMatch(.*)*", + redirect: "/dashboard", + }, + ], +}); + +applyRouteGuards(router); + +export default router; diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue new file mode 100644 index 0000000..281a257 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/components/layout/AppNav.vue b/frontend/src/components/layout/AppNav.vue new file mode 100644 index 0000000..fc871e7 --- /dev/null +++ b/frontend/src/components/layout/AppNav.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/layout/AppPage.vue b/frontend/src/components/layout/AppPage.vue new file mode 100644 index 0000000..bd02771 --- /dev/null +++ b/frontend/src/components/layout/AppPage.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/patterns/QueueHealthBadge.vue b/frontend/src/components/patterns/QueueHealthBadge.vue new file mode 100644 index 0000000..71b15e3 --- /dev/null +++ b/frontend/src/components/patterns/QueueHealthBadge.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/patterns/RequestErrorPanel.vue b/frontend/src/components/patterns/RequestErrorPanel.vue new file mode 100644 index 0000000..874d694 --- /dev/null +++ b/frontend/src/components/patterns/RequestErrorPanel.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/patterns/RunStatusBadge.vue b/frontend/src/components/patterns/RunStatusBadge.vue new file mode 100644 index 0000000..cb73162 --- /dev/null +++ b/frontend/src/components/patterns/RunStatusBadge.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/ui/AppAlert.vue b/frontend/src/components/ui/AppAlert.vue new file mode 100644 index 0000000..e119708 --- /dev/null +++ b/frontend/src/components/ui/AppAlert.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/ui/AppBadge.vue b/frontend/src/components/ui/AppBadge.vue new file mode 100644 index 0000000..53edb75 --- /dev/null +++ b/frontend/src/components/ui/AppBadge.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/ui/AppButton.vue b/frontend/src/components/ui/AppButton.vue new file mode 100644 index 0000000..1df0991 --- /dev/null +++ b/frontend/src/components/ui/AppButton.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/components/ui/AppCard.vue b/frontend/src/components/ui/AppCard.vue new file mode 100644 index 0000000..fbbed0d --- /dev/null +++ b/frontend/src/components/ui/AppCard.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/src/components/ui/AppCheckbox.vue b/frontend/src/components/ui/AppCheckbox.vue new file mode 100644 index 0000000..481918a --- /dev/null +++ b/frontend/src/components/ui/AppCheckbox.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/AppEmptyState.vue b/frontend/src/components/ui/AppEmptyState.vue new file mode 100644 index 0000000..fe87ca7 --- /dev/null +++ b/frontend/src/components/ui/AppEmptyState.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/ui/AppInput.vue b/frontend/src/components/ui/AppInput.vue new file mode 100644 index 0000000..aa02f8d --- /dev/null +++ b/frontend/src/components/ui/AppInput.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/AppModal.vue b/frontend/src/components/ui/AppModal.vue new file mode 100644 index 0000000..920a267 --- /dev/null +++ b/frontend/src/components/ui/AppModal.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/AppSelect.vue b/frontend/src/components/ui/AppSelect.vue new file mode 100644 index 0000000..b9ad991 --- /dev/null +++ b/frontend/src/components/ui/AppSelect.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/AppSkeleton.vue b/frontend/src/components/ui/AppSkeleton.vue new file mode 100644 index 0000000..876d146 --- /dev/null +++ b/frontend/src/components/ui/AppSkeleton.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/AppTable.vue b/frontend/src/components/ui/AppTable.vue new file mode 100644 index 0000000..d369eaa --- /dev/null +++ b/frontend/src/components/ui/AppTable.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/features/admin_users/index.ts b/frontend/src/features/admin_users/index.ts new file mode 100644 index 0000000..d933a47 --- /dev/null +++ b/frontend/src/features/admin_users/index.ts @@ -0,0 +1,49 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface AdminUser { + id: number; + email: string; + is_active: boolean; + is_admin: boolean; + created_at: string; + updated_at: string; +} + +interface UsersListData { + users: AdminUser[]; +} + +export interface CreateAdminUserPayload { + email: string; + password: string; + is_admin: boolean; +} + +export async function listAdminUsers(): Promise { + const response = await apiRequest("/admin/users", { method: "GET" }); + return response.data.users; +} + +export async function createAdminUser(payload: CreateAdminUserPayload): Promise { + const response = await apiRequest("/admin/users", { + method: "POST", + body: payload, + }); + return response.data; +} + +export async function setAdminUserActive(userId: number, isActive: boolean): Promise { + const response = await apiRequest(`/admin/users/${userId}/active`, { + method: "PATCH", + body: { is_active: isActive }, + }); + return response.data; +} + +export async function resetAdminUserPassword(userId: number, newPassword: string): Promise<{ message: string }> { + const response = await apiRequest<{ message: string }>(`/admin/users/${userId}/reset-password`, { + method: "POST", + body: { new_password: newPassword }, + }); + return response.data; +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts new file mode 100644 index 0000000..80d73fd --- /dev/null +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,9 @@ +export { fetchCsrfBootstrap, type CsrfBootstrapData } from "@/lib/api/csrf"; +export { + fetchMe, + loginSession, + logoutSession, + type AuthSessionData, + type MessageData, + type SessionUser, +} from "@/lib/auth/session"; diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts new file mode 100644 index 0000000..dfba916 --- /dev/null +++ b/frontend/src/features/dashboard/index.ts @@ -0,0 +1,58 @@ +import { + listPublications, + type PublicationItem, + type PublicationMode, +} from "@/features/publications"; +import { listQueueItems, listRuns, type RunListItem } from "@/features/runs"; + +export interface QueueHealth { + queued: number; + retrying: number; + dropped: number; +} + +export interface DashboardSnapshot { + newCount: number; + totalCount: number; + mode: PublicationMode; + latestRun: RunListItem | null; + recentRuns: RunListItem[]; + recentPublications: PublicationItem[]; + queue: QueueHealth; +} + +function countQueueStatuses(statuses: string[]): QueueHealth { + return statuses.reduce( + (acc, status) => { + if (status === "queued") { + acc.queued += 1; + } else if (status === "retrying") { + acc.retrying += 1; + } else if (status === "dropped") { + acc.dropped += 1; + } + return acc; + }, + { queued: 0, retrying: 0, dropped: 0 }, + ); +} + +export async function fetchDashboardSnapshot(): Promise { + const [publications, runs, queueItems] = await Promise.all([ + listPublications({ mode: "new", limit: 20 }), + listRuns({ limit: 5 }), + listQueueItems(200), + ]); + + const queueHealth = countQueueStatuses(queueItems.map((item) => item.status)); + + return { + newCount: publications.new_count, + totalCount: publications.total_count, + mode: publications.mode, + latestRun: runs[0] ?? null, + recentRuns: runs, + recentPublications: publications.publications, + queue: queueHealth, + }; +} diff --git a/frontend/src/features/publications/index.ts b/frontend/src/features/publications/index.ts new file mode 100644 index 0000000..dff2b55 --- /dev/null +++ b/frontend/src/features/publications/index.ts @@ -0,0 +1,81 @@ +import { apiRequest } from "@/lib/api/client"; + +export type PublicationMode = "all" | "new"; + +export interface PublicationItem { + publication_id: number; + scholar_profile_id: number; + scholar_label: string; + title: string; + year: number | null; + citation_count: number; + venue_text: string | null; + pub_url: string | null; + is_read: boolean; + first_seen_at: string; + is_new_in_latest_run: boolean; +} + +export interface PublicationsResult { + mode: PublicationMode; + selected_scholar_profile_id: number | null; + new_count: number; + total_count: number; + publications: PublicationItem[]; +} + +export interface PublicationsQuery { + mode?: PublicationMode; + scholarProfileId?: number; + limit?: number; +} + +export interface PublicationSelection { + scholar_profile_id: number; + publication_id: number; +} + +export async function listPublications(query: PublicationsQuery = {}): Promise { + const params = new URLSearchParams(); + + if (query.mode) { + params.set("mode", query.mode); + } + if (query.scholarProfileId) { + params.set("scholar_profile_id", String(query.scholarProfileId)); + } + if (query.limit) { + params.set("limit", String(query.limit)); + } + + const suffix = params.toString(); + const response = await apiRequest( + `/publications${suffix ? `?${suffix}` : ""}`, + { method: "GET" }, + ); + return response.data; +} + +export async function markAllRead(): Promise<{ message: string; updated_count: number }> { + const response = await apiRequest<{ message: string; updated_count: number }>( + "/publications/mark-all-read", + { method: "POST" }, + ); + return response.data; +} + +export async function markSelectedRead(selections: PublicationSelection[]): Promise<{ + message: string; + requested_count: number; + updated_count: number; +}> { + const response = await apiRequest<{ + message: string; + requested_count: number; + updated_count: number; + }>("/publications/mark-read", { + method: "POST", + body: { selections }, + }); + return response.data; +} diff --git a/frontend/src/features/runs/index.ts b/frontend/src/features/runs/index.ts new file mode 100644 index 0000000..c8c47e8 --- /dev/null +++ b/frontend/src/features/runs/index.ts @@ -0,0 +1,171 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface RunListItem { + id: number; + trigger_type: string; + status: string; + start_dt: string; + end_dt: string | null; + scholar_count: number; + new_publication_count: number; + failed_count: number; + partial_count: number; +} + +export interface RunSummary { + succeeded_count: number; + failed_count: number; + partial_count: number; + failed_state_counts: Record; + failed_reason_counts: Record; +} + +export interface RunScholarResult { + scholar_profile_id: number; + scholar_id: string; + state: string; + state_reason: string | null; + outcome: string; + attempt_count: number; + publication_count: number; + start_cstart: number; + continuation_cstart: number | null; + continuation_enqueued: boolean; + continuation_cleared: boolean; + warnings: string[]; + error: string | null; + debug: Record | null; +} + +export interface RunDetail { + run: RunListItem; + summary: RunSummary; + scholar_results: RunScholarResult[]; +} + +export interface QueueItem { + id: number; + scholar_profile_id: number; + scholar_label: string; + status: string; + reason: string; + dropped_reason: string | null; + attempt_count: number; + resume_cstart: number; + next_attempt_dt: string | null; + updated_at: string; + last_error: string | null; + last_run_id: number | null; +} + +interface RunsListData { + runs: RunListItem[]; +} + +interface QueueListData { + queue_items: QueueItem[]; +} + +export interface RunsListQuery { + failedOnly?: boolean; + limit?: number; +} + +export async function listRuns(query: RunsListQuery = {}): Promise { + const params = new URLSearchParams(); + if (query.failedOnly) { + params.set("failed_only", "true"); + } + if (query.limit) { + params.set("limit", String(query.limit)); + } + + const suffix = params.toString(); + const response = await apiRequest(`/runs${suffix ? `?${suffix}` : ""}`, { + method: "GET", + }); + return response.data.runs; +} + +export async function getRunDetail(runId: number): Promise { + const response = await apiRequest(`/runs/${runId}`, { method: "GET" }); + return response.data; +} + +function generateIdempotencyKey(): string { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) { + return randomUuid; + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export async function triggerManualRun(): Promise<{ + run_id: number; + status: string; + scholar_count: number; + succeeded_count: number; + failed_count: number; + partial_count: number; + new_publication_count: number; + reused_existing_run: boolean; + idempotency_key: string | null; +}> { + const headers: Record = { + "Idempotency-Key": generateIdempotencyKey(), + }; + + const response = await apiRequest<{ + run_id: number; + status: string; + scholar_count: number; + succeeded_count: number; + failed_count: number; + partial_count: number; + new_publication_count: number; + reused_existing_run: boolean; + idempotency_key: string | null; + }>("/runs/manual", { + method: "POST", + headers, + }); + return response.data; +} + +export async function listQueueItems(limit = 200): Promise { + const response = await apiRequest(`/runs/queue/items?limit=${limit}`, { + method: "GET", + }); + return response.data.queue_items; +} + +export async function retryQueueItem(queueItemId: number): Promise { + const response = await apiRequest(`/runs/queue/${queueItemId}/retry`, { + method: "POST", + }); + return response.data; +} + +export async function dropQueueItem(queueItemId: number): Promise { + const response = await apiRequest(`/runs/queue/${queueItemId}/drop`, { + method: "POST", + }); + return response.data; +} + +export async function clearQueueItem(queueItemId: number): Promise<{ + queue_item_id: number; + previous_status: string; + status: string; + message: string; +}> { + const response = await apiRequest<{ + queue_item_id: number; + previous_status: string; + status: string; + message: string; + }>(`/runs/queue/${queueItemId}`, { + method: "DELETE", + }); + return response.data; +} diff --git a/frontend/src/features/scholars/index.ts b/frontend/src/features/scholars/index.ts new file mode 100644 index 0000000..006082d --- /dev/null +++ b/frontend/src/features/scholars/index.ts @@ -0,0 +1,117 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface ScholarProfile { + id: number; + scholar_id: string; + display_name: string | null; + profile_image_url: string | null; + profile_image_source: "upload" | "override" | "scraped" | "none"; + is_enabled: boolean; + baseline_completed: boolean; + last_run_dt: string | null; + last_run_status: string | null; +} + +export interface ScholarCreatePayload { + scholar_id: string; + profile_image_url?: string; +} + +export interface ScholarSearchCandidate { + scholar_id: string; + display_name: string; + affiliation: string | null; + email_domain: string | null; + cited_by_count: number | null; + interests: string[]; + profile_url: string; + profile_image_url: string | null; +} + +export interface ScholarSearchResult { + query: string; + state: "ok" | "no_results" | "blocked_or_captcha" | "layout_changed" | "network_error"; + state_reason: string; + action_hint: string | null; + candidates: ScholarSearchCandidate[]; + warnings: string[]; +} + +interface ScholarsListData { + scholars: ScholarProfile[]; +} + +interface ScholarSearchData extends ScholarSearchResult {} + +export async function listScholars(): Promise { + const response = await apiRequest("/scholars", { method: "GET" }); + return response.data.scholars; +} + +export async function createScholar(payload: ScholarCreatePayload): Promise { + const response = await apiRequest("/scholars", { + method: "POST", + body: payload, + }); + return response.data; +} + +export async function toggleScholar(scholarProfileId: number): Promise { + const response = await apiRequest(`/scholars/${scholarProfileId}/toggle`, { + method: "PATCH", + }); + return response.data; +} + +export async function deleteScholar(scholarProfileId: number): Promise { + await apiRequest<{ message: string }>(`/scholars/${scholarProfileId}`, { + method: "DELETE", + }); +} + +export async function searchScholarsByName( + query: string, + limit = 10, +): Promise { + const searchParams = new URLSearchParams({ + query, + limit: String(limit), + }); + const response = await apiRequest(`/scholars/search?${searchParams.toString()}`, { + method: "GET", + }); + return response.data; +} + +export async function setScholarImageUrl( + scholarProfileId: number, + imageUrl: string, +): Promise { + const response = await apiRequest(`/scholars/${scholarProfileId}/image/url`, { + method: "PUT", + body: { image_url: imageUrl }, + }); + return response.data; +} + +export async function uploadScholarImage( + scholarProfileId: number, + file: File, +): Promise { + const form = new FormData(); + form.append("image", file); + const response = await apiRequest(`/scholars/${scholarProfileId}/image/upload`, { + method: "POST", + body: form, + }); + return response.data; +} + +export async function clearScholarImage( + scholarProfileId: number, +): Promise { + const response = await apiRequest(`/scholars/${scholarProfileId}/image`, { + method: "DELETE", + }); + return response.data; +} diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts new file mode 100644 index 0000000..d09d55e --- /dev/null +++ b/frontend/src/features/settings/index.ts @@ -0,0 +1,34 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface UserSettings { + auto_run_enabled: boolean; + run_interval_minutes: number; + request_delay_seconds: number; +} + +export interface ChangePasswordPayload { + current_password: string; + new_password: string; + confirm_password: string; +} + +export async function fetchSettings(): Promise { + const response = await apiRequest("/settings", { method: "GET" }); + return response.data; +} + +export async function updateSettings(payload: UserSettings): Promise { + const response = await apiRequest("/settings", { + method: "PUT", + body: payload, + }); + return response.data; +} + +export async function changePassword(payload: ChangePasswordPayload): Promise<{ message: string }> { + const response = await apiRequest<{ message: string }>("/auth/change-password", { + method: "POST", + body: payload, + }); + return response.data; +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..dd72adc --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,102 @@ +import { + isErrorEnvelope, + isSuccessEnvelope, + readRequestId, + type ApiSuccessEnvelope, +} from "@/lib/api/envelope"; +import { ApiRequestError } from "@/lib/api/errors"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface ApiRequestOptions { + method?: HttpMethod; + body?: unknown | FormData; + headers?: Record; +} + +const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const API_BASE = "/api/v1"; + +let csrfTokenProvider: (() => string | null) | null = null; + +export function setCsrfTokenProvider(provider: () => string | null): void { + csrfTokenProvider = provider; +} + +export async function apiRequest(path: string, options: ApiRequestOptions = {}): Promise> { + const method = options.method ?? "GET"; + const headers = new Headers(options.headers ?? {}); + headers.set("Accept", "application/json"); + + const hasBody = options.body !== undefined; + const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData; + let requestBody: BodyInit | undefined; + + if (hasBody && !isFormData && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + if (hasBody) { + requestBody = isFormData ? (options.body as FormData) : JSON.stringify(options.body); + } + + if (UNSAFE_METHODS.has(method) && csrfTokenProvider) { + const csrfToken = csrfTokenProvider(); + if (csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + } + + const response = await fetch(`${API_BASE}${path}`, { + method, + headers, + credentials: "include", + body: requestBody, + }); + + const raw = await parseResponseBody(response); + const requestId = readRequestId(raw) ?? response.headers.get("X-Request-ID"); + + if (!response.ok) { + if (isErrorEnvelope(raw)) { + throw new ApiRequestError({ + status: response.status, + code: raw.error.code || "error", + message: raw.error.message || "Request failed.", + details: raw.error.details, + requestId, + }); + } + + throw new ApiRequestError({ + status: response.status, + code: "http_error", + message: `Request failed with status ${response.status}.`, + requestId, + }); + } + + if (!isSuccessEnvelope(raw)) { + throw new ApiRequestError({ + status: response.status, + code: "invalid_envelope", + message: "Server returned an unexpected response format.", + requestId, + details: raw, + }); + } + + return raw; +} + +async function parseResponseBody(response: Response): Promise { + const contentType = response.headers.get("Content-Type") || ""; + if (!contentType.includes("application/json")) { + return null; + } + try { + return await response.json(); + } catch (_err) { + return null; + } +} diff --git a/frontend/src/lib/api/csrf.ts b/frontend/src/lib/api/csrf.ts new file mode 100644 index 0000000..38d11ca --- /dev/null +++ b/frontend/src/lib/api/csrf.ts @@ -0,0 +1,10 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface CsrfBootstrapData { + csrf_token: string; + authenticated: boolean; +} + +export async function fetchCsrfBootstrap() { + return apiRequest("/auth/csrf", { method: "GET" }); +} diff --git a/frontend/src/lib/api/envelope.test.ts b/frontend/src/lib/api/envelope.test.ts new file mode 100644 index 0000000..e1bfd14 --- /dev/null +++ b/frontend/src/lib/api/envelope.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + isErrorEnvelope, + isSuccessEnvelope, + readRequestId, + type ApiSuccessEnvelope, +} from "@/lib/api/envelope"; + +describe("api envelope helpers", () => { + it("recognizes a success envelope", () => { + const payload: ApiSuccessEnvelope<{ ok: boolean }> = { + data: { ok: true }, + meta: { request_id: "req_123" }, + }; + + expect(isSuccessEnvelope(payload)).toBe(true); + expect(isErrorEnvelope(payload)).toBe(false); + expect(readRequestId(payload)).toBe("req_123"); + }); + + it("recognizes an error envelope", () => { + const payload = { + error: { + code: "invalid", + message: "Invalid request", + details: null, + }, + meta: { request_id: "req_456" }, + }; + + expect(isErrorEnvelope(payload)).toBe(true); + expect(isSuccessEnvelope(payload)).toBe(false); + expect(readRequestId(payload)).toBe("req_456"); + }); + + it("returns null when request id is missing or invalid", () => { + expect(readRequestId(null)).toBeNull(); + expect(readRequestId({})).toBeNull(); + expect(readRequestId({ meta: {} })).toBeNull(); + expect(readRequestId({ meta: { request_id: "" } })).toBeNull(); + }); +}); diff --git a/frontend/src/lib/api/envelope.ts b/frontend/src/lib/api/envelope.ts new file mode 100644 index 0000000..f74bbfe --- /dev/null +++ b/frontend/src/lib/api/envelope.ts @@ -0,0 +1,44 @@ +import type { ApiErrorPayload } from "@/lib/api/errors"; + +export interface ApiMeta { + request_id: string | null; +} + +export interface ApiSuccessEnvelope { + data: T; + meta: ApiMeta; +} + +export interface ApiErrorEnvelope { + error: ApiErrorPayload; + meta: ApiMeta; +} + +export function isSuccessEnvelope(value: unknown): value is ApiSuccessEnvelope { + if (typeof value !== "object" || value === null) { + return false; + } + return "data" in value && "meta" in value; +} + +export function isErrorEnvelope(value: unknown): value is ApiErrorEnvelope { + if (typeof value !== "object" || value === null) { + return false; + } + return "error" in value && "meta" in value; +} + +export function readRequestId(payload: unknown): string | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + const meta = (payload as { meta?: unknown }).meta; + if (typeof meta !== "object" || meta === null) { + return null; + } + const requestId = (meta as { request_id?: unknown }).request_id; + if (typeof requestId !== "string" || !requestId.trim()) { + return null; + } + return requestId; +} diff --git a/frontend/src/lib/api/errors.test.ts b/frontend/src/lib/api/errors.test.ts new file mode 100644 index 0000000..5bbac5d --- /dev/null +++ b/frontend/src/lib/api/errors.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { ApiRequestError } from "@/lib/api/errors"; + +describe("ApiRequestError", () => { + it("preserves structured metadata", () => { + const error = new ApiRequestError({ + status: 409, + code: "run_in_progress", + message: "A run is already in progress", + details: { run_id: 42 }, + requestId: "req_789", + }); + + expect(error.name).toBe("ApiRequestError"); + expect(error.status).toBe(409); + expect(error.code).toBe("run_in_progress"); + expect(error.message).toBe("A run is already in progress"); + expect(error.details).toEqual({ run_id: 42 }); + expect(error.requestId).toBe("req_789"); + }); +}); diff --git a/frontend/src/lib/api/errors.ts b/frontend/src/lib/api/errors.ts new file mode 100644 index 0000000..779370e --- /dev/null +++ b/frontend/src/lib/api/errors.ts @@ -0,0 +1,27 @@ +export interface ApiErrorPayload { + code: string; + message: string; + details: unknown; +} + +export class ApiRequestError extends Error { + readonly status: number; + readonly code: string; + readonly details: unknown; + readonly requestId: string | null; + + constructor(params: { + status: number; + code: string; + message: string; + details?: unknown; + requestId?: string | null; + }) { + super(params.message); + this.name = "ApiRequestError"; + this.status = params.status; + this.code = params.code; + this.details = params.details ?? null; + this.requestId = params.requestId ?? null; + } +} diff --git a/frontend/src/lib/auth/session.ts b/frontend/src/lib/auth/session.ts new file mode 100644 index 0000000..050c144 --- /dev/null +++ b/frontend/src/lib/auth/session.ts @@ -0,0 +1,35 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface SessionUser { + id: number; + email: string; + is_admin: boolean; + is_active: boolean; +} + +export interface AuthSessionData { + authenticated: boolean; + csrf_token: string; + user: SessionUser; +} + +export interface MessageData { + message: string; +} + +export async function fetchMe() { + return apiRequest("/auth/me", { method: "GET" }); +} + +export async function loginSession(params: { email: string; password: string }) { + return apiRequest("/auth/login", { + method: "POST", + body: params, + }); +} + +export async function logoutSession() { + return apiRequest("/auth/logout", { + method: "POST", + }); +} diff --git a/frontend/src/lib/utils/request_id.ts b/frontend/src/lib/utils/request_id.ts new file mode 100644 index 0000000..b1293d6 --- /dev/null +++ b/frontend/src/lib/utils/request_id.ts @@ -0,0 +1,8 @@ +import { ApiRequestError } from "@/lib/api/errors"; + +export function toRequestId(value: unknown): string | null { + if (value instanceof ApiRequestError) { + return value.requestId; + } + return null; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..00fe74b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,22 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; + +import AppShell from "@/app/AppShell.vue"; +import router from "@/app/router"; +import { bootstrapAppProviders } from "@/app/providers"; +import "@/styles.css"; + +async function main(): Promise { + const app = createApp(AppShell); + const pinia = createPinia(); + + app.use(pinia); + await bootstrapAppProviders(); + + app.use(router); + await router.isReady(); + + app.mount("#app"); +} + +void main(); diff --git a/frontend/src/pages/AdminUsersPage.vue b/frontend/src/pages/AdminUsersPage.vue new file mode 100644 index 0000000..7f3b438 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.vue @@ -0,0 +1,199 @@ + + + diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue new file mode 100644 index 0000000..075f70a --- /dev/null +++ b/frontend/src/pages/DashboardPage.vue @@ -0,0 +1,187 @@ + + + diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue new file mode 100644 index 0000000..5833211 --- /dev/null +++ b/frontend/src/pages/LoginPage.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/pages/PublicationsPage.vue b/frontend/src/pages/PublicationsPage.vue new file mode 100644 index 0000000..0b2cccb --- /dev/null +++ b/frontend/src/pages/PublicationsPage.vue @@ -0,0 +1,449 @@ + + + diff --git a/frontend/src/pages/RunDetailPage.vue b/frontend/src/pages/RunDetailPage.vue new file mode 100644 index 0000000..d858ee3 --- /dev/null +++ b/frontend/src/pages/RunDetailPage.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/pages/RunsPage.vue b/frontend/src/pages/RunsPage.vue new file mode 100644 index 0000000..9e12237 --- /dev/null +++ b/frontend/src/pages/RunsPage.vue @@ -0,0 +1,266 @@ + + + diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue new file mode 100644 index 0000000..5ec0f76 --- /dev/null +++ b/frontend/src/pages/ScholarsPage.vue @@ -0,0 +1,921 @@ + + +