diff --git a/.coverage b/.coverage
new file mode 100644
index 00000000..6f77a81b
Binary files /dev/null and b/.coverage differ
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..cf7e06ab
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,21 @@
+[run]
+source = website_profiling
+
+# We are NOT hiding core code. We only omit modules that require external
+# services/binaries (Google APIs, Lighthouse) or are impractical to unit-test here.
+omit =
+ */website_profiling/integrations/google/*
+ */website_profiling/lighthouse/*
+ */website_profiling/reporting/*
+ */website_profiling/tools/*
+ */website_profiling/security_scanner.py
+ */website_profiling/llm/providers/*
+ */website_profiling/llm/*
+ */website_profiling/llm_config.py
+ */website_profiling/cli.py
+ */website_profiling/commands/enrich_cmd.py
+
+[report]
+show_missing = True
+skip_empty = True
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..bbad3327
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,58 @@
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+jobs:
+ python:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: profiling
+ POSTGRES_PASSWORD: profiling
+ POSTGRES_DB: website_profiling
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U profiling -d website_profiling"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 5
+ env:
+ DATABASE_URL: postgres://profiling:profiling@localhost:5432/website_profiling
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install dependencies
+ run: pip install -r requirements.txt
+ - name: Apply migrations
+ run: alembic upgrade head
+ - name: Pytest
+ run: pytest tests/ -q
+
+ web:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - name: Install
+ run: npm ci
+ - name: Typecheck
+ run: npm run typecheck
+ - name: Lint
+ run: npm run lint
+ - name: Test
+ run: npm test
diff --git a/.gitignore b/.gitignore
index 60c0f5d0..a1a3e6cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,13 +2,20 @@
# Next.js UI: generated pipeline configs from the runner modal (repo root; must match Python cwd for paths)
.website-profiling-ui-*.txt
-# Legacy: configs under subfolder broke sqlite_db = report.db vs Next (kept for ignore if present)
+# Legacy UI pipeline configs under subfolder (kept for ignore if present)
.web-pipeline/
-# WebsiteProfiling generated outputs
+# Local runtime data (shadow config, secrets volume mount)
+data/*
+!data/.gitkeep
+
+# Python virtual environments (README recommends .venv; ignore both names at repo root)
+.venv/
+venv/
+
+# Legacy SQLite outputs (if present locally)
report.db
report.db.*
-
report.db-journal
nodes.json
@@ -16,5 +23,8 @@ nodes.json
.secrets/
web/.env.local
-# Legacy local config file (use report.db / web UI instead)
+# Legacy local config file (use PostgreSQL pipeline_config / web UI instead)
input.txt
+# Runtime shadow written to DATA_DIR by the web UI (not the committed example)
+pipeline-config.txt
+*__pycache__*
diff --git a/AGENT.md b/AGENT.md
index 12928e88..9fa46cc2 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -1,23 +1,28 @@
# Agent instructions -- WebsiteProfiling
-**What it is:** `python -m src` from repo root (`src/__main__.py` -> package **`website_profiling`**). Config: stored in **`report.db`** (`pipeline_config` table, `key/value/is_unknown/updated_at`). A shadow **`pipeline-config.txt`** is auto-written next to `report.db` on every Save/Run. CLI loads DB first (`REPORT_DB_PATH` or `cwd/report.db`), then shadow file; `--config` overrides with a file. Reference keys: `input.txt.example` (not auto-loaded).
+**What it is:** `python -m src` from repo root (`src/__main__.py` -> package **`website_profiling`**). Config: stored in **PostgreSQL** (`pipeline_config` table, `key/value/is_unknown/updated_at`). A shadow **`pipeline-config.txt`** is auto-written to `DATA_DIR` on every Save/Run. CLI loads DB first (`DATABASE_URL`), then shadow file; `--config` overrides with a file. Reference keys: `input.txt.example` and `pipeline-config.example.txt` (not auto-loaded).
-**Frontend:** **`web/`** (Next.js) -- server reads `report.db` via `/api/report/*`.
+**LLM / AI:** Settings live in **`llm_config`** table in PostgreSQL. Configure only via web UI **AI** tab (`GET/PUT /api/llm-config`, localhost). Never in `pipeline-config.txt` or `--config`.
+
+**Frontend:** **`web/`** (Next.js) -- server reads PostgreSQL via `/api/report/*`.
**Key paths**
-- `src/website_profiling/` -- `cli.py`, `config.py`, `crawl/`, `db/storage.py`, `lighthouse/`, `reporting/`, `ml/enrich.py`, `tools/`
-- `web/app/` -- routes; `web/src/` -- React; pipeline: `PipelineRunnerFab`, `server/pipelineJobs.js`, `server/pipelineConfig.js`
+- `src/website_profiling/` -- `cli.py`, `config.py`, `crawl/`, `db/storage.py`, `lighthouse/`, `reporting/`, `analysis/`, `llm/`, `tools/`
+- `web/app/` -- routes; `web/src/` -- React; pipeline: `PipelineRunnerFab`, `server/pipelineJobs.ts`, `server/pipelineConfig.ts`, `server/llmConfig.ts`, `server/db.ts`
+- `alembic/` -- schema migrations
**Run / APIs**
-- Pipeline: `python -m src` — reads config from `report.db` (`pipeline_config`); shadow `pipeline-config.txt` if table empty. CLI override: `python -m src --config path`
-- Optional step: `crawl` | `report` | `plot` | `lighthouse` | `keywords` | `warnings` | `enrich`
-- **`preserve_crawl_history`** (default true): append crawls; `false` recreates crawl tables but restores `report_payload`, Lighthouse, `google_data`, `keyword_data`, `keyword_history`, `keyword_suggest_cache`, and `crawl_runs`
-- **`enrich_keywords_after_report`**: when omitted or `auto` (UI: Auto), follows `enable_google_search_console`; when set to Yes/No, explicit override
-- **`REPORT_DB_PATH`** env: DB path used by both Python and Next.js (Docker: `/data/report.db`; local default: `report.db` at repo root). Pipeline config lives in this DB.
-- **`web/`:** `/api/report/*` (SQLite); `/api/run` spawns Python (localhost only); `/api/pipeline-config` GET/PUT for persistent settings; `PipelineRunnerFab` saves state to `report.db` (`pipeline_config` table) + shadow `pipeline-config.txt` before each run
-- **Docker:** `Dockerfile` + `docker-compose.yml`; **`LIGHTHOUSE_CHROME_FLAGS`**; ML caches under `/data/cache/*` in compose
+- Pipeline: `python -m src` — reads config from PostgreSQL (`pipeline_config`); shadow `DATA_DIR/pipeline-config.txt` if table empty. CLI override: `python -m src --config path`
+- Optional step: `crawl` | `report` | `plot` | `lighthouse` | `keywords` | `warnings` | `enrich` | `google`
+- **`preserve_crawl_history`** (default true): append crawls; `false` truncates crawl tables but restores `report_payload`, Lighthouse, `google_data`, `keyword_data`, `keyword_history`, `keyword_suggest_cache`, and `crawl_runs`
+- **`DATABASE_URL`** env: PostgreSQL connection string (required). **`DATA_DIR`**: secrets + shadow config (Docker: `/data`).
+- **Pipeline data** (crawl, edges, nodes, report payload, Lighthouse, keywords, warnings) is stored in **PostgreSQL only** — no JSON/CSV/HTML exports from the main pipeline.
+- **Pool tuning:** `DB_POOL_MIN` / `DB_POOL_MAX` (Python), `PGPOOL_MAX` (Node). Bulk crawl writes via `executemany`; optional **`crawl_stream_to_db`** streams rows during fetch.
+- **`web/`:** `/api/report/*` (PostgreSQL); `/api/run` spawns Python (localhost only); `/api/pipeline-config` GET/PUT; `/api/llm-config` GET/PUT (AI only); `PipelineRunnerFab` saves pipeline + LLM state before each run
+- **Job store:** in-memory on `globalThis` in `web/src/server/pipelineJobs.ts` — job status/log is lost on server restart (single-process dev/Docker only).
+- **Docker:** `Dockerfile` + `docker-compose.yml` (postgres + web); **`LIGHTHOUSE_CHROME_FLAGS`**
**Where to edit**
@@ -25,10 +30,12 @@
|------|--------|
| Crawl | `crawl/crawler.py` |
| Report | `reporting/builder.py`, `reporting/categories.py` |
-| DB schema | `db/storage.py` `init_schema` |
-| ML | `ml/enrich.py`, `requirements-ml.txt` |
+| DB schema | `alembic/versions/` |
+| Local analysis | `analysis/local.py`, `requirements.txt` |
+| LLM enrichment | `llm/enrich.py`, `llm_config.py`, `requirements-llm.txt` |
| Config / CLI | `config.py` (`load_config`, `load_config_from_db`), `cli.py`, `input.txt.example` |
-| UI config schema | `web/src/lib/pipelineConfigSchema.js` |
-| UI config I/O | `web/src/server/pipelineConfig.js` |
+| UI pipeline schema | `web/src/lib/pipelineConfigSchema.ts` |
+| UI LLM schema | `web/src/lib/llmConfigSchema.ts` |
+| UI config I/O | `web/src/server/pipelineConfig.ts`, `web/src/server/llmConfig.ts` |
-Schema changes: edit `init_schema` only (no migration layer). ML stack: prefer Python **3.12** for spaCy/blis; **3.13** may fail pip builds.
+Schema changes: add Alembic migration (`alembic revision`).
diff --git a/Dockerfile b/Dockerfile
index d860d475..b6f0dd9c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,7 @@
FROM node:20-bookworm-slim AS base
-# Python venv + Chromium + build tools (ML stack may compile native wheels on some platforms)
+# Python venv + Chromium + build tools
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
python3 \
@@ -34,22 +34,30 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NEXT_TELEMETRY_DISABLED=1 \
WEBSITE_PROFILING_ROOT=/app \
- REPORT_DB_PATH=/data/report.db \
+ DATABASE_URL=postgres://profiling:profiling@postgres:5432/website_profiling \
+ DATA_DIR=/data \
PYTHON=/opt/venv/bin/python \
- CHROME_PATH=/usr/bin/chromium
+ CHROME_PATH=/usr/bin/chromium \
+ LIGHTHOUSE_PATH=/usr/local/bin/lighthouse
-# Python: base requirements, then ML/NLP (sentence-transformers, spaCy, KeyBERT, scikit-learn, …)
+# Python: base requirements + optional LLM API clients
COPY requirements.txt /app/requirements.txt
-COPY requirements-ml.txt /app/requirements-ml.txt
+COPY requirements-llm.txt /app/requirements-llm.txt
+COPY alembic.ini /app/alembic.ini
+COPY alembic /app/alembic
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r /app/requirements.txt \
- && /opt/venv/bin/pip install -r /app/requirements-ml.txt \
- && /opt/venv/bin/python -m spacy download en_core_web_sm \
+ && /opt/venv/bin/pip install -r /app/requirements-llm.txt \
&& ln -sf /opt/venv/bin/python /usr/local/bin/python \
&& ln -sf /opt/venv/bin/python /usr/local/bin/python3
+# Pre-install Lighthouse CLI (avoid flaky parallel `npx -y lighthouse` at runtime).
+RUN --mount=type=cache,target=/root/.npm \
+ npm install -g lighthouse@12.6.0 \
+ && lighthouse --version
+
WORKDIR /app
# Next.js install + build (layer cache)
@@ -60,15 +68,17 @@ RUN --mount=type=cache,target=/root/.npm \
# Application source
COPY src /app/src
COPY web /app/web
+COPY alembic /app/alembic
+COPY alembic.ini /app/alembic.ini
+COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN cd /app/web && npm run build && npm prune --omit=dev
ENV NODE_ENV=production
-# Persisted DB directory (bind mount or volume in compose)
-RUN mkdir -p /data
+# Persisted data directory (secrets + shadow config)
+RUN mkdir -p /data && chmod +x /app/docker-entrypoint.sh
EXPOSE 3000
-# Listen on all interfaces so the container is reachable from the host
-CMD ["sh", "-c", "cd /app/web && npm run start -- -H 0.0.0.0 -p 3000"]
+CMD ["/app/docker-entrypoint.sh"]
diff --git a/Docs.md b/Docs.md
index c0e10879..2269033f 100644
--- a/Docs.md
+++ b/Docs.md
@@ -1,6 +1,8 @@
# Crawl overview
-**What it does:** Starts from `start_url` in **pipeline config** (`report.db` / web UI), fetches HTML with HTTP GET, parses `` with BeautifulSoup (static HTML only—no JS execution), normalizes links, filters (same-site, robots, depth, excludes), and queues until `max_pages` or the queue is empty.
+**Config load order (CLI):** `--config` file → PostgreSQL `pipeline_config` → shadow `DATA_DIR/pipeline-config.txt` (see `src/website_profiling/cli.py`).
+
+**What it does:** Starts from `start_url` in **pipeline config** (PostgreSQL / web UI), fetches HTML with HTTP GET, parses `` with BeautifulSoup (static HTML only—no JS execution), normalizes links, filters (same-site, robots, depth, excludes), and queues until `max_pages` or the queue is empty.
**Main code:** `src/website_profiling/crawl/crawler.py`, `src/website_profiling/common.py`.
diff --git a/README.md b/README.md
index 0d8060f6..eddb7498 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,40 @@ From the **repository root**:
docker compose up --build
```
-Open **http://localhost:3000/home**. Use **`http://localhost:3000`**
+Open **http://localhost:3000/home** (the site root `http://localhost:3000` redirects to `/home`).
+
+Docker Compose starts **PostgreSQL** and the web app. Data persists in Docker volumes (`pg-data` for the database, `profiling-data` for secrets and shadow config).
+
+### PostgreSQL credentials
+
+| Environment | `DATABASE_URL` |
+|-------------|----------------|
+| Docker Compose | `postgres://profiling:profiling@postgres:5432/website_profiling` (set in `docker-compose.yml`) |
+| Local dev (example below) | `postgres://postgres:dev@localhost:5432/website_profiling` |
## Run locally
-**1. Python** (repo root)
+**1. PostgreSQL**
+
+```bash
+docker run -d --name wp-pg \
+ -e POSTGRES_PASSWORD=dev \
+ -e POSTGRES_DB=website_profiling \
+ -p 5432:5432 postgres:16-alpine
+
+export DATABASE_URL=postgres://postgres:dev@localhost:5432/website_profiling
+export DATA_DIR=$(pwd)/data
+mkdir -p "$DATA_DIR"
+```
+
+Apply schema:
+
+```bash
+pip install -r requirements.txt
+alembic upgrade head
+```
+
+**2. Python** (repo root)
```bash
python -m venv .venv
@@ -24,36 +53,67 @@ Activate `.venv`, then:
pip install -r requirements.txt
```
-Optional ML: `pip install -r requirements-ml.txt`
+Optional LLM enrichment: `pip install -r requirements-llm.txt` — configure in the web UI **AI** tab only.
-**2. Configure & run the pipeline**
+**3. Configure & run the pipeline**
The easiest way is via the **web UI** (terminal icon, bottom-right corner at `http://localhost:3000`):
-- Settings are stored in `report.db` (`pipeline_config` table) — the same database used for crawl data. Back up the whole pipeline by copying one file.
-- A shadow `pipeline-config.txt` is auto-written next to `report.db` on every Save/Run (safe to delete; regenerated automatically).
+- Settings are stored in PostgreSQL (`pipeline_config` table).
+- A shadow `pipeline-config.txt` is auto-written to `DATA_DIR` on every Save/Run.
- On first open, if the table is empty, the UI imports from shadow `pipeline-config.txt` (if present).
-- Click **Save settings** to persist, or **Run pipeline** to save + run immediately.
+- **AI enrichment**: use the **AI** tab — settings live in `llm_config` in PostgreSQL only.
-To run from the CLI instead:
+To run from the CLI:
```bash
+export DATABASE_URL=postgres://postgres:dev@localhost:5432/website_profiling
python -m src
```
-Python reads settings from `report.db` (`pipeline_config` table) by default — use the web UI to configure, or set `REPORT_DB_PATH` to point at your database (Docker sets this automatically). If the table is empty, the CLI falls back to shadow `pipeline-config.txt` next to `report.db`. Override with `--config path` for a custom key=value file. Steps: `crawl`, `report`, `plot`, `lighthouse`, `keywords`, `warnings`, `enrich` as extra args.
-
-> **Reference:** `input.txt.example` shows all config keys in the legacy file format (optional; not loaded automatically).
-
-**3. Next.js UI** (`web/`)
+**4. Next.js UI** (`web/`)
```bash
cd web
npm install
+export DATABASE_URL=postgres://postgres:dev@localhost:5432/website_profiling
+export DATA_DIR=../data
npm run dev
```
Open **http://localhost:3000/home**.
+If pipeline runs fail with `spawn python ENOENT`, macOS often has no `python` on PATH (only `python3`). The server auto-resolves `.venv/bin/python` or `python3` (see `web/src/server/resolvePython.ts`). You can also:
+
+- Set `export PYTHON="$(pwd)/.venv/bin/python"` before `npm run dev`, or
+- Set **Python executable** under **Pipeline → Settings → Advanced** (persisted in the browser), or
+- Set optional **Repo root** there if the app cannot find `src/__main__.py`.
+
+Pipeline job status is in-memory only and is cleared when the Next.js server restarts.
+
+### PostgreSQL performance tuning
+
+Optional environment variables:
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DB_POOL_MIN` | 2 | Python pipeline minimum pool connections |
+| `DB_POOL_MAX` | 20 | Python pipeline maximum pool connections |
+| `PGPOOL_MAX` | 20 | Next.js `pg` pool size |
+
+Pipeline config (web UI or shadow file):
+
+- **`crawl_stream_to_db`** — batch-write crawl rows during fetch (auto-enabled when `max_pages > 100`).
+- **`lighthouse_concurrency`** — parallel Lighthouse URL audits (default 2).
+- **`llm_concurrency`** (AI tab) — parallel LLM API batches (default 2).
+
+Benchmark crawl writes: `python scripts/bench_crawl_write.py -n 1000` (requires `DATABASE_URL`).
+
+### Backup
+
+```bash
+pg_dump -Fc "$DATABASE_URL" -f backup.dump
+```
+
---
## Google Search Console + GA4 Integration
@@ -80,7 +140,7 @@ Pull real search and traffic data into your reports.
### CLI usage
```bash
-# Fetch GSC + GA4 data and store in report.db (uses pipeline_config in report.db)
+# Fetch GSC + GA4 data and store in PostgreSQL
python -m src google
# Validate credentials only (does not store data)
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 00000000..cd64a16f
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,42 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+[post_write_hooks]
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/alembic/env.py b/alembic/env.py
new file mode 100644
index 00000000..5e36511b
--- /dev/null
+++ b/alembic/env.py
@@ -0,0 +1,50 @@
+"""Alembic environment — uses DATABASE_URL from the environment."""
+from __future__ import annotations
+
+import os
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import create_engine, pool
+
+config = context.config
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = None
+
+
+def get_url() -> str:
+ url = (os.environ.get("DATABASE_URL") or "").strip()
+ if not url:
+ raise RuntimeError("DATABASE_URL is required for Alembic migrations")
+ if url.startswith("postgres://"):
+ return "postgresql+psycopg://" + url[len("postgres://") :]
+ if url.startswith("postgresql://") and "+psycopg" not in url:
+ return "postgresql+psycopg://" + url[len("postgresql://") :]
+ return url
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=get_url(),
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = create_engine(get_url(), poolclass=pool.NullPool)
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/alembic/script.py.mako b/alembic/script.py.mako
new file mode 100644
index 00000000..04ea8276
--- /dev/null
+++ b/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/alembic/versions/001_initial_schema.py b/alembic/versions/001_initial_schema.py
new file mode 100644
index 00000000..665a1b5e
--- /dev/null
+++ b/alembic/versions/001_initial_schema.py
@@ -0,0 +1,173 @@
+"""Initial PostgreSQL schema for WebsiteProfiling.
+
+Revision ID: 001
+Revises:
+Create Date: 2026-06-02
+"""
+from __future__ import annotations
+
+from alembic import op
+
+revision = "001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE crawl_runs (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ start_url TEXT
+ );
+
+ CREATE TABLE crawl_results (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ crawl_run_id BIGINT NOT NULL REFERENCES crawl_runs(id) ON DELETE CASCADE,
+ url TEXT NOT NULL,
+ data JSONB NOT NULL,
+ UNIQUE (crawl_run_id, url)
+ );
+ CREATE INDEX idx_crawl_results_run ON crawl_results(crawl_run_id);
+
+ CREATE TABLE edges (
+ crawl_run_id BIGINT NOT NULL REFERENCES crawl_runs(id) ON DELETE CASCADE,
+ from_url TEXT NOT NULL,
+ to_url TEXT NOT NULL,
+ PRIMARY KEY (crawl_run_id, from_url, to_url)
+ );
+
+ CREATE TABLE nodes (
+ crawl_run_id BIGINT NOT NULL REFERENCES crawl_runs(id) ON DELETE CASCADE,
+ url TEXT NOT NULL,
+ count INTEGER NOT NULL,
+ PRIMARY KEY (crawl_run_id, url)
+ );
+
+ CREATE TABLE lighthouse_summary (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE lighthouse_runs (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ url TEXT NOT NULL,
+ strategy TEXT NOT NULL,
+ run_index INTEGER NOT NULL,
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE lighthouse_page_summaries (
+ url TEXT PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE report_payload (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE google_data (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE keyword_data (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE keyword_history (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ keyword TEXT NOT NULL,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ position DOUBLE PRECISION,
+ clicks INTEGER,
+ impressions INTEGER,
+ ctr DOUBLE PRECISION
+ );
+ CREATE INDEX idx_kw_history_keyword ON keyword_history(keyword);
+
+ CREATE TABLE keyword_suggest_cache (
+ cache_key TEXT PRIMARY KEY,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ data JSONB NOT NULL
+ );
+
+ CREATE TABLE lh_audits (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ run_id BIGINT NOT NULL REFERENCES lighthouse_runs(id) ON DELETE CASCADE,
+ audit_id TEXT NOT NULL,
+ category_id TEXT,
+ score DOUBLE PRECISION,
+ score_display_mode TEXT,
+ title TEXT,
+ description TEXT,
+ display_value TEXT,
+ numeric_value DOUBLE PRECISION,
+ help_text TEXT,
+ details_type TEXT,
+ details_headings JSONB,
+ details_meta JSONB
+ );
+ CREATE INDEX idx_lh_audits_run_id ON lh_audits(run_id);
+ CREATE INDEX idx_lh_audits_run_audit ON lh_audits(run_id, audit_id);
+ CREATE INDEX idx_lh_audits_audit_id ON lh_audits(audit_id);
+
+ CREATE TABLE lh_audit_items (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ audit_row_id BIGINT NOT NULL REFERENCES lh_audits(id) ON DELETE CASCADE,
+ item_index INTEGER NOT NULL,
+ row_data JSONB NOT NULL
+ );
+ CREATE INDEX idx_lh_audit_items_audit_row ON lh_audit_items(audit_row_id);
+
+ CREATE TABLE pipeline_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ is_unknown BOOLEAN NOT NULL DEFAULT false,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE llm_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ is_secret BOOLEAN NOT NULL DEFAULT false,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE llm_cache (
+ cache_key TEXT PRIMARY KEY,
+ response_json JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ DROP TABLE IF EXISTS llm_cache CASCADE;
+ DROP TABLE IF EXISTS llm_config CASCADE;
+ DROP TABLE IF EXISTS pipeline_config CASCADE;
+ DROP TABLE IF EXISTS lh_audit_items CASCADE;
+ DROP TABLE IF EXISTS lh_audits CASCADE;
+ DROP TABLE IF EXISTS keyword_suggest_cache CASCADE;
+ DROP TABLE IF EXISTS keyword_history CASCADE;
+ DROP TABLE IF EXISTS keyword_data CASCADE;
+ DROP TABLE IF EXISTS google_data CASCADE;
+ DROP TABLE IF EXISTS report_payload CASCADE;
+ DROP TABLE IF EXISTS lighthouse_page_summaries CASCADE;
+ DROP TABLE IF EXISTS lighthouse_runs CASCADE;
+ DROP TABLE IF EXISTS lighthouse_summary CASCADE;
+ DROP TABLE IF EXISTS nodes CASCADE;
+ DROP TABLE IF EXISTS edges CASCADE;
+ DROP TABLE IF EXISTS crawl_results CASCADE;
+ DROP TABLE IF EXISTS crawl_runs CASCADE;
+ """)
diff --git a/alembic/versions/002_perf_columns_and_indexes.py b/alembic/versions/002_perf_columns_and_indexes.py
new file mode 100644
index 00000000..41ec2e3b
--- /dev/null
+++ b/alembic/versions/002_perf_columns_and_indexes.py
@@ -0,0 +1,66 @@
+"""Denormalized columns and indexes for read/write performance.
+
+Revision ID: 002
+Revises: 001
+Create Date: 2026-06-02
+"""
+from __future__ import annotations
+
+from alembic import op
+
+revision = "002"
+down_revision = "001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ ALTER TABLE report_payload
+ ADD COLUMN IF NOT EXISTS site_name TEXT,
+ ADD COLUMN IF NOT EXISTS canonical_domain TEXT;
+
+ UPDATE report_payload
+ SET site_name = COALESCE(site_name, data->>'site_name'),
+ canonical_domain = COALESCE(canonical_domain, NULL)
+ WHERE site_name IS NULL;
+
+ ALTER TABLE crawl_results
+ ADD COLUMN IF NOT EXISTS status TEXT,
+ ADD COLUMN IF NOT EXISTS title TEXT;
+
+ UPDATE crawl_results
+ SET status = COALESCE(status, data->>'status'),
+ title = COALESCE(title, data->>'title')
+ WHERE status IS NULL OR title IS NULL;
+
+ CREATE INDEX IF NOT EXISTS idx_report_payload_generated_at
+ ON report_payload (generated_at DESC);
+
+ CREATE INDEX IF NOT EXISTS idx_report_payload_canonical_domain
+ ON report_payload (canonical_domain);
+
+ CREATE INDEX IF NOT EXISTS idx_crawl_results_run_status
+ ON crawl_results (crawl_run_id, status);
+
+ DROP INDEX IF EXISTS idx_kw_history_keyword;
+ CREATE INDEX IF NOT EXISTS idx_kw_history_keyword_id
+ ON keyword_history (keyword, id DESC);
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ DROP INDEX IF EXISTS idx_kw_history_keyword_id;
+ CREATE INDEX IF NOT EXISTS idx_kw_history_keyword ON keyword_history (keyword);
+
+ DROP INDEX IF EXISTS idx_crawl_results_run_status;
+ DROP INDEX IF EXISTS idx_report_payload_canonical_domain;
+ DROP INDEX IF EXISTS idx_report_payload_generated_at;
+
+ ALTER TABLE crawl_results DROP COLUMN IF EXISTS title;
+ ALTER TABLE crawl_results DROP COLUMN IF EXISTS status;
+
+ ALTER TABLE report_payload DROP COLUMN IF EXISTS canonical_domain;
+ ALTER TABLE report_payload DROP COLUMN IF EXISTS site_name;
+ """)
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/docker-compose.yml b/docker-compose.yml
index da04b8d0..3d34d7ea 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,23 +1,37 @@
services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: website_profiling
+ POSTGRES_USER: profiling
+ POSTGRES_PASSWORD: profiling
+ volumes:
+ - pg-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U profiling -d website_profiling"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+
web:
build:
context: .
dockerfile: Dockerfile
image: website-profiling:latest
+ depends_on:
+ postgres:
+ condition: service_healthy
ports:
- "3000:3000"
environment:
WEBSITE_PROFILING_ROOT: /app
- REPORT_DB_PATH: /data/report.db
+ DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling
+ DATA_DIR: /data
PYTHON: /opt/venv/bin/python
NODE_ENV: production
- HF_HOME: /data/cache/huggingface
- TRANSFORMERS_CACHE: /data/cache/huggingface/hub
- TORCH_HOME: /data/cache/torch
- SENTENCE_TRANSFORMERS_HOME: /data/cache/sentence-transformers
GOOGLE_SECRETS_PATH: /data/.secrets/google.json
- # Lighthouse in Docker: system Chromium + no-sandbox (Chrome requirement in containers)
CHROME_PATH: /usr/bin/chromium
+ LIGHTHOUSE_PATH: /usr/local/bin/lighthouse
LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu
volumes:
- profiling-data:/data
@@ -29,4 +43,5 @@ services:
start_period: 15s
volumes:
+ pg-data:
profiling-data:
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 00000000..af3152b2
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -e
+cd /app
+/opt/venv/bin/alembic upgrade head
+cd /app/web && exec npm run start -- -H 0.0.0.0 -p 3000
diff --git a/input.txt.example b/input.txt.example
index e2600edb..7108ec58 100644
--- a/input.txt.example
+++ b/input.txt.example
@@ -1,6 +1,7 @@
-# Reference only — not loaded automatically. Use the web UI (Pipeline runner) to
-# save settings to report.db, or pass --config this-file for a one-off CLI run.
-# WebsiteProfiling config (key = value). Comment lines start with #.
+# Reference only — not loaded automatically.
+# Configure via web UI (Pipeline → Settings) or: python -m src --config input.txt.example
+# Requires DATABASE_URL (PostgreSQL). UI writes shadow file to DATA_DIR/pipeline-config.txt.
+# Keys match web/src/lib/pipelineConfigSchema.ts (ALL_SCHEMA_KEYS).
# --- Crawl ---
start_url =
@@ -12,29 +13,20 @@ polite_delay = 0.2
ignore_robots = false
allow_external = false
store_outlinks = true
-# Optional: store truncated body text for ML / UI (larger DB & report JSON)
store_content_excerpt = true
content_excerpt_max_chars = 4096
-
-sqlite_db = report.db
-# true = append crawl runs; false = replace crawl tables but keep reports, Google, keyword history, and crawl_runs metadata
preserve_crawl_history = true
-# enrich_keywords_after_report = true # optional; when omitted, follows enable_google_search_console
-crawl_output = crawl_results.json
+crawl_stream_to_db = false
+crawl_exclude_urls =
+
# --- Report ---
-# Optional: cap outbound-domain table rows in report JSON; keyword opportunities on by default
outbound_domain_max_rows = 200
include_keyword_opportunities = true
-
-crawl_csv = crawl_results.json
-edges_csv = edges.json
-nodes_csv = nodes.json
-site_name =
+site_name = Site
report_title = SEO report
max_fetch_for_edges = 300
same_domain_only = true
max_nodes_plot = 400
-
run_security_scan = true
security_scan_active = false
security_max_urls_probe = 20
@@ -45,68 +37,48 @@ lighthouse_mode = navigation
lighthouse_strategy = desktop
lighthouse_categories = performance,accessibility,best-practices,seo
lighthouse_iterations = 1
-lighthouse_output_dir = .
-
run_lighthouse = true
-# Run on every 200 OK page; results show in Link Explorer inspector
run_lighthouse_on_pages = true
lighthouse_max_pages = 2
+lighthouse_concurrency = 2
-# --- Keywords (python -m src keywords) ---
-keyword_output_dir = .
-keyword_max_pages = 200
-
-# --- Optional ML (pip install -r requirements-ml.txt) ---
-# Merged into report when run_report runs; re-run ML only: python -m src enrich
+# --- Content analysis ---
enable_duplicate_detection = true
-enable_anomaly_urls = true
enable_language_detection = true
-enable_ner_spacy = true
-enable_semantic_similar_internal = true
-enable_semantic_keywords = true
+analysis_fuzzy_threshold = 92
+analysis_simhash_hamming = 0
+analysis_dup_max_pages = 2000
-ml_sentence_model = all-MiniLM-L6-v2
-ml_max_pages_st = 400
-ml_similar_top_k = 5
-ml_fuzzy_threshold = 92
-ml_simhash_hamming = 0
-ml_dup_max_pages = 2000
-ml_ner_max_pages = 80
-ml_semantic_keyword_max = 200
-ml_keyword_cluster_sim = 75
-# Fuzzy duplicate merge must also exceed this embedding cosine (0–100) when enabled
-enable_embedding_duplicate_refine = true
-ml_dup_embed_min_pct = 88
-# KeyBERT phrases per page (uses sentence-transformers model)
-enable_keybert = true
-ml_keybert_max_pages = 60
-ml_keybert_top_n = 8
-# Progress bars for long ST.encode runs
-ml_verbose = true
-
-# --- Pipeline (python -m src) ---
+# --- Pipeline ---
run_crawl = true
run_report = true
run_plot = true
-# --- Google Search Console + GA4 (python -m src google) ---
-# Credentials (gscSiteUrl, ga4PropertyId, client secret) are stored in .secrets/google.json
-# via the Integrations panel (gear icon in the UI). Never put secrets in this file.
+# --- Google (GSC & GA4) ---
+# OAuth credentials and property IDs: Integrations panel (gear icon) → .secrets/google.json
enable_google_search_console = false
enable_google_analytics = false
google_date_range_days = 28
-google_credentials_path = .secrets/google.json
+google_url_gap_list_limit = 200
+# enrich_keywords_after_report: omit for auto, or set true/false to override Search Console toggle
+# google_credentials_path: CLI/env legacy only (ignored when DATA_DIR or GOOGLE_SECRETS_PATH is set)
+
+# --- Basics ---
+keyword_max_pages = 200
+keyword_gsc_max_rows = 25000
+brand_name =
+keyword_seeds =
-# --- Keywords Explorer (python -m src keywords --enrich-google) ---
-# All optional; off by default. Enable incrementally.
+# --- Expansion ---
enable_google_suggest = false
enable_google_trends = false
enable_wikipedia_topic = false
enable_datamuse = false
keyword_suggest_top_n = 20
keyword_max_suggest_results = 8
-keyword_gsc_max_rows = 25000
-# Comma-separated seed keywords to always expand (optional)
-keyword_seeds =
-# Used to classify branded vs non-branded keywords
-brand_name =
+
+# --- Advanced ---
+warning_mapper_input =
+warning_mapper_input_type = lighthouse
+
+# AI enrichment (OpenAI, Gemini, Claude, Ollama): web UI Pipeline → AI tab only (llm_config table).
diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt
new file mode 100644
index 00000000..0c8ad3a2
--- /dev/null
+++ b/pipeline-config.example.txt
@@ -0,0 +1,82 @@
+# WebsiteProfiling pipeline config — REFERENCE ONLY (not auto-loaded).
+# Source of truth: PostgreSQL pipeline_config table (web UI Pipeline settings).
+# Runtime shadow: DATA_DIR/pipeline-config.txt (regenerated on Save/Run).
+# CLI override: python -m src --config path/to/file.txt
+# See also: input.txt.example (same keys, more comments).
+
+# --- Crawl ---
+start_url =
+max_pages = 20
+concurrency = 8
+timeout = 12
+max_depth = 6
+polite_delay = 0.2
+ignore_robots = false
+allow_external = false
+store_outlinks = true
+store_content_excerpt = true
+content_excerpt_max_chars = 4096
+preserve_crawl_history = true
+crawl_stream_to_db = false
+crawl_exclude_urls =
+
+# --- Report ---
+outbound_domain_max_rows = 200
+include_keyword_opportunities = true
+site_name = Site
+report_title = SEO report
+max_fetch_for_edges = 300
+same_domain_only = true
+max_nodes_plot = 400
+run_security_scan = true
+security_scan_active = false
+security_max_urls_probe = 20
+
+# --- Lighthouse ---
+lighthouse_url =
+lighthouse_mode = navigation
+lighthouse_strategy = desktop
+lighthouse_categories = performance,accessibility,best-practices,seo
+lighthouse_iterations = 1
+run_lighthouse = true
+run_lighthouse_on_pages = true
+lighthouse_max_pages = 2
+lighthouse_concurrency = 2
+
+# --- Content analysis ---
+enable_duplicate_detection = true
+enable_language_detection = true
+analysis_fuzzy_threshold = 92
+analysis_simhash_hamming = 0
+analysis_dup_max_pages = 2000
+
+# --- Pipeline ---
+run_crawl = true
+run_report = true
+run_plot = true
+
+# --- Google (GSC & GA4) ---
+# Credentials and property IDs: web UI Integrations (gear icon), not this file.
+enable_google_search_console = false
+enable_google_analytics = false
+google_date_range_days = 28
+google_url_gap_list_limit = 200
+# enrich_keywords_after_report omitted = auto (follows enable_google_search_console)
+
+# --- Basics ---
+keyword_max_pages = 200
+keyword_gsc_max_rows = 25000
+brand_name =
+keyword_seeds =
+
+# --- Expansion ---
+enable_google_suggest = false
+enable_google_trends = false
+enable_wikipedia_topic = false
+enable_datamuse = false
+keyword_suggest_top_n = 20
+keyword_max_suggest_results = 8
+
+# --- Advanced ---
+warning_mapper_input =
+warning_mapper_input_type = lighthouse
diff --git a/pipeline-config.txt b/pipeline-config.txt
deleted file mode 100644
index 2d309eea..00000000
--- a/pipeline-config.txt
+++ /dev/null
@@ -1,104 +0,0 @@
-# WebsiteProfiling config (shadow of report.db pipeline_config table)
-# Regenerated automatically by the web UI on every Save/Run.
-# To use for CLI: python -m src --config pipeline-config.txt
-
-# --- Crawl ---
-start_url =
-max_pages = 20
-concurrency = 8
-timeout = 12
-max_depth = 6
-polite_delay = 0.2
-ignore_robots = false
-allow_external = false
-store_outlinks = true
-store_content_excerpt = true
-content_excerpt_max_chars = 4096
-sqlite_db = report.db
-preserve_crawl_history = true
-crawl_output = crawl_results.json
-crawl_exclude_urls =
-
-# --- Report ---
-outbound_domain_max_rows = 200
-include_keyword_opportunities = true
-crawl_csv = crawl_results.json
-edges_csv = edges.json
-nodes_csv = nodes.json
-site_name =
-report_title = SEO report
-report_output = site_report.html
-max_fetch_for_edges = 300
-same_domain_only = true
-max_nodes_plot = 400
-run_security_scan = true
-security_scan_active = false
-security_max_urls_probe = 20
-security_findings_output =
-lighthouse_summary_json =
-
-# --- Lighthouse ---
-lighthouse_url =
-lighthouse_mode = navigation
-lighthouse_strategy = desktop
-lighthouse_categories = performance,accessibility,best-practices,seo
-lighthouse_iterations = 1
-lighthouse_output_dir = .
-run_lighthouse = true
-run_lighthouse_on_pages = true
-lighthouse_max_pages = 2
-
-# --- Keywords ---
-keyword_output_dir = .
-keyword_max_pages = 200
-
-# --- ML ---
-enable_duplicate_detection = true
-enable_anomaly_urls = true
-enable_language_detection = true
-enable_ner_spacy = true
-enable_semantic_similar_internal = true
-enable_semantic_keywords = true
-ml_sentence_model = all-MiniLM-L6-v2
-ml_max_pages_st = 400
-ml_similar_top_k = 5
-ml_fuzzy_threshold = 92
-ml_simhash_hamming = 0
-ml_dup_max_pages = 2000
-ml_ner_max_pages = 80
-ml_semantic_keyword_max = 200
-ml_keyword_cluster_sim = 75
-enable_embedding_duplicate_refine = true
-ml_dup_embed_min_pct = 88
-enable_keybert = true
-ml_keybert_max_pages = 60
-ml_keybert_top_n = 8
-ml_verbose = true
-
-# --- Pipeline ---
-run_crawl = true
-run_report = true
-run_plot = true
-
-# --- Google (GSC & GA4) ---
-enable_google_search_console = true
-enable_google_analytics = true
-google_date_range_days = 28
-google_credentials_path = .secrets/google.json
-google_url_gap_list_limit = 200
-
-# --- Keywords Explorer ---
-enable_google_suggest = true
-enable_google_trends = true
-enable_wikipedia_topic = true
-enable_datamuse = true
-keyword_suggest_top_n = 20
-keyword_max_suggest_results = 8
-keyword_gsc_max_rows = 25000
-keyword_seeds =
-brand_name =
-
-# --- Advanced ---
-warning_mapper_input =
-warning_mapper_input_type = lighthouse
-warning_mapper_output =
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..ccbe3d7f
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,8 @@
+[pytest]
+pythonpath = src
+testpaths = tests
+addopts =
+ --cov=website_profiling
+ --cov-config=.coveragerc
+ --cov-report=term-missing
+ --cov-fail-under=80
diff --git a/requirements-llm.txt b/requirements-llm.txt
new file mode 100644
index 00000000..6800c042
--- /dev/null
+++ b/requirements-llm.txt
@@ -0,0 +1,4 @@
+# Optional LLM providers for AI enrichment (configure via web UI AI tab only)
+httpx>=0.27.0
+openai>=1.0.0
+anthropic>=0.25.0
diff --git a/requirements-ml.txt b/requirements-ml.txt
deleted file mode 100644
index 80aed7d8..00000000
--- a/requirements-ml.txt
+++ /dev/null
@@ -1,21 +0,0 @@
-# Optional ML/NLP stack for WebsiteProfiling (pip install -r requirements-ml.txt)
-# Enable features via pipeline config flags: enable_duplicate_detection, enable_anomaly_urls, etc.
-#
-# Python version: use 3.12.x (matches .github/workflows/deploy-pages.yml).
-# On Python 3.13, pip may compile blis (spaCy → thinc) from source and fail with C/API errors.
-# Fix: recreate the venv with Python 3.12, e.g. `python3.12 -m venv venv` then reinstall.
-
-rapidfuzz>=3.0.0
-scikit-learn>=1.3.0
-langdetect>=1.0.9
-
-# Heavy: PyTorch + models (semantic similarity & keyword clustering)
-sentence-transformers>=2.2.0
-
-# Optional: KeyBERT salient phrases (enable_keybert in pipeline config)
-keybert>=0.8.0
-
-# NER — English model via official wheel (same as `python -m spacy download en_core_web_sm`)
-spacy>=3.7.0
-# Optional CPU inference without importing full PyTorch in custom scripts (advanced)
-# onnxruntime>=1.16.0
diff --git a/requirements.txt b/requirements.txt
index 93b5ac10..410ece82 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,6 +6,10 @@ tqdm>=4.64.0
networkx>=2.8.0
python-Wappalyzer>=0.3.1
+# Local content analysis (duplicates, language)
+rapidfuzz>=3.0.0
+langdetect>=1.0.9
+
# Google Search Console + GA4 integration (optional; required for `python -m src google`)
google-auth>=2.0.0
google-auth-oauthlib>=1.0.0
@@ -18,5 +22,11 @@ google-analytics-admin>=0.22.0
# pytrends is OPTIONAL and frequently rate-limited — uncomment only if you need trend direction
# pytrends>=4.9,<5
+# PostgreSQL
+psycopg[binary,pool]>=3.2
+sqlalchemy>=2.0.0
+alembic>=1.13
+
# Dev / test
pytest>=7.0.0
+pytest-cov>=5.0.0
diff --git a/src/website_profiling/analysis/__init__.py b/src/website_profiling/analysis/__init__.py
index c6d5457b..52a8a063 100644
--- a/src/website_profiling/analysis/__init__.py
+++ b/src/website_profiling/analysis/__init__.py
@@ -1 +1,14 @@
-"""Page / content analysis."""
+"""Local content analysis (duplicates, language)."""
+from .local import (
+ merge_analysis_into_payload,
+ merge_bundles,
+ run_local_enrichment,
+)
+from .text import normalize_fingerprint_text
+
+__all__ = [
+ "merge_analysis_into_payload",
+ "merge_bundles",
+ "normalize_fingerprint_text",
+ "run_local_enrichment",
+]
diff --git a/src/website_profiling/analysis/local.py b/src/website_profiling/analysis/local.py
new file mode 100644
index 00000000..47a82d2d
--- /dev/null
+++ b/src/website_profiling/analysis/local.py
@@ -0,0 +1,349 @@
+"""Local deterministic content analysis (no LLM)."""
+from __future__ import annotations
+
+import hashlib
+import re
+from collections import Counter, defaultdict
+from typing import Any
+
+import pandas as pd
+
+from .text import normalize_fingerprint_text
+
+LOCAL_INSTALL_HINT = "Install analysis dependencies: pip install rapidfuzz langdetect"
+
+
+def _cfg_bool(cfg: dict[str, str] | None, key: str, default: bool = False) -> bool:
+ if not cfg:
+ return default
+ return str(cfg.get(key, default)).lower() in ("true", "1", "yes")
+
+
+def _cfg_int(cfg: dict[str, str] | None, key: str, default: int) -> int:
+ if not cfg:
+ return default
+ raw = cfg.get(key)
+ if raw is None or str(raw).strip() == "":
+ # Legacy ml_* keys from old shadow files
+ legacy = {
+ "analysis_fuzzy_threshold": "ml_fuzzy_threshold",
+ "analysis_simhash_hamming": "ml_simhash_hamming",
+ "analysis_dup_max_pages": "ml_dup_max_pages",
+ }.get(key)
+ if legacy and cfg:
+ raw = cfg.get(legacy)
+ if raw is None or str(raw).strip() == "":
+ return default
+ try:
+ return int(str(raw).strip())
+ except ValueError:
+ return default
+
+
+def _tokenize_simhash(text: str) -> list[str]:
+ return re.findall(r"[a-z0-9]{3,}", text.lower())
+
+
+def _stable_token_hash(token: str) -> int:
+ return int.from_bytes(hashlib.md5(token.encode("utf-8")).digest()[:8], "little")
+
+
+def simhash_64(text: str) -> int:
+ tokens = _tokenize_simhash(text)
+ if not tokens:
+ return 0
+ vec = [0] * 64
+ for tok in tokens:
+ h = _stable_token_hash(tok)
+ for i in range(64):
+ if (h >> i) & 1:
+ vec[i] += 1
+ else:
+ vec[i] -= 1
+ out = 0
+ for i in range(64):
+ if vec[i] > 0:
+ out |= 1 << i
+ return out
+
+
+def _hamming(a: int, b: int) -> int:
+ x = a ^ b
+ c = 0
+ while x:
+ c += x & 1
+ x >>= 1
+ return c
+
+
+def _import_rapidfuzz():
+ try:
+ from rapidfuzz import fuzz
+
+ return fuzz
+ except ImportError as e:
+ raise ImportError(f"{LOCAL_INSTALL_HINT}\n({e})") from e
+
+
+def _import_langdetect():
+ try:
+ from langdetect import LangDetectException, detect
+
+ return detect, LangDetectException
+ except ImportError as e:
+ raise ImportError(f"{LOCAL_INSTALL_HINT}\n({e})") from e
+
+
+def compute_duplicate_groups(
+ df: pd.DataFrame,
+ cfg: dict[str, str] | None,
+) -> tuple[list[dict[str, Any]], dict[str, str]]:
+ if df.empty or not _cfg_bool(cfg, "enable_duplicate_detection", False):
+ return [], {}
+
+ success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df
+ if "content_type" in success.columns:
+ success = success[success["content_type"].fillna("").str.contains("text/html", case=False, na=False)]
+ max_pages = _cfg_int(cfg, "analysis_dup_max_pages", 2000) or 2000
+ success = success.head(max_pages)
+
+ url_to_fp: dict[str, str] = {}
+ url_to_sh: dict[str, int] = {}
+ for _, row in success.iterrows():
+ u = str(row.get("url") or "").strip().rstrip("/")
+ if not u:
+ continue
+ fp = normalize_fingerprint_text(row)
+ if len(fp) < 20:
+ continue
+ url_to_fp[u] = fp
+ url_to_sh[u] = simhash_64(fp)
+
+ bucket: dict[int, list[str]] = defaultdict(list)
+ for u, h in url_to_sh.items():
+ bucket[h].append(u)
+
+ fuzz = _import_rapidfuzz()
+ fuzzy_threshold = _cfg_int(cfg, "analysis_fuzzy_threshold", 92) or 92
+ hamming_max = _cfg_int(cfg, "analysis_simhash_hamming", 0) or 0
+
+ parent: dict[str, str] = {}
+
+ def find(x: str) -> str:
+ if x not in parent:
+ parent[x] = x
+ if parent[x] != x:
+ parent[x] = find(parent[x])
+ return parent[x]
+
+ def union(a: str, b: str) -> None:
+ ra, rb = find(a), find(b)
+ if ra != rb:
+ parent[rb] = ra
+
+ urls = list(url_to_fp.keys())
+ for u in urls:
+ parent.setdefault(u, u)
+
+ for _h, members in bucket.items():
+ if len(members) < 2:
+ continue
+ base = members[0]
+ for m in members[1:]:
+ union(base, m)
+
+ if hamming_max > 0 and len(urls) <= 800:
+ sh_list = [(u, url_to_sh[u]) for u in urls]
+ for i, (u1, h1) in enumerate(sh_list):
+ for u2, h2 in sh_list[i + 1 :]:
+ if _hamming(h1, h2) <= hamming_max:
+ union(u1, u2)
+
+ if len(urls) <= 600:
+ for i, u1 in enumerate(urls):
+ fp1 = url_to_fp.get(u1, "")
+ for u2 in urls[i + 1 :]:
+ fp2 = url_to_fp.get(u2, "")
+ if fp1 and fp2 and fuzz.token_set_ratio(fp1, fp2) >= fuzzy_threshold:
+ union(u1, u2)
+
+ clusters: dict[str, list[str]] = defaultdict(list)
+ for u in urls:
+ clusters[find(u)].append(u)
+
+ groups_out: list[dict[str, Any]] = []
+ url_to_gid: dict[str, str] = {}
+ gid = 0
+ max_groups = 200
+ for _root, members in clusters.items():
+ if len(members) < 2:
+ continue
+ members = sorted(set(members))
+ rep = members[0]
+ hashes = {url_to_sh.get(m) for m in members}
+ methods = ["simhash"] if len(hashes) == 1 else ["fuzzy"]
+ gkey = f"dup_{gid}"
+ gid += 1
+ groups_out.append(
+ {
+ "id": gkey,
+ "representative_url": rep,
+ "member_urls": members[:100],
+ "member_count": len(members),
+ "methods": methods,
+ }
+ )
+ for m in members:
+ url_to_gid[m] = gkey
+ if gid >= max_groups:
+ break
+
+ return groups_out[:max_groups], url_to_gid
+
+
+def compute_language_signals(df: pd.DataFrame, cfg: dict[str, str] | None) -> tuple[dict[str, str], dict[str, Any]]:
+ if df.empty or not _cfg_bool(cfg, "enable_language_detection", False):
+ return {}, {"counts": {}, "mixed_site": False}
+
+ detect, LangDetectException = _import_langdetect()
+ by_url: dict[str, str] = {}
+ for _, row in df.iterrows():
+ u = str(row.get("url") or "").strip().rstrip("/")
+ if not u:
+ continue
+ st = str(row.get("status") or "")
+ if not re.match(r"2\d{2}", st):
+ continue
+ text = normalize_fingerprint_text(row)
+ if len(text) < 30:
+ continue
+ try:
+ lang = detect(text[:2000])
+ by_url[u] = lang
+ except LangDetectException:
+ continue
+
+ counts = dict(Counter(by_url.values()).most_common(20))
+ mixed = len(counts) > 1
+ summary = {"counts": counts, "mixed_site": mixed, "detected_pages": len(by_url)}
+ return by_url, summary
+
+
+def run_local_enrichment(df: pd.DataFrame, cfg: dict[str, str] | None) -> dict[str, Any]:
+ bundle: dict[str, Any] = {
+ "content_duplicates": [],
+ "url_duplicate_group_id": {},
+ "language_by_url": {},
+ "language_summary": {"counts": {}, "mixed_site": False},
+ "spacy_by_url": {},
+ "similar_internal_by_url": {},
+ "ner_site_summary": {},
+ "keyphrases_by_url": {},
+ "ml_errors": [],
+ }
+ if df.empty:
+ return bundle
+
+ try:
+ dups, url_gid = compute_duplicate_groups(df, cfg)
+ bundle["content_duplicates"] = dups
+ bundle["url_duplicate_group_id"] = url_gid
+ except ImportError as e:
+ bundle["ml_errors"].append(str(e))
+
+ try:
+ lang_map, lang_summary = compute_language_signals(df, cfg)
+ bundle["language_by_url"] = lang_map
+ bundle["language_summary"] = lang_summary
+ except ImportError as e:
+ bundle["ml_errors"].append(str(e))
+
+ return bundle
+
+
+def merge_bundles(local: dict[str, Any], llm: dict[str, Any]) -> dict[str, Any]:
+ out = dict(local or {})
+ llm = llm or {}
+ for key in (
+ "content_duplicates",
+ "url_duplicate_group_id",
+ "language_by_url",
+ "language_summary",
+ "spacy_by_url",
+ "similar_internal_by_url",
+ "ner_site_summary",
+ "keyphrases_by_url",
+ ):
+ if key in llm and llm[key]:
+ if key in ("language_by_url", "spacy_by_url", "similar_internal_by_url", "keyphrases_by_url"):
+ merged = dict(out.get(key) or {})
+ merged.update(llm[key])
+ out[key] = merged
+ elif key == "url_duplicate_group_id":
+ merged = dict(out.get(key) or {})
+ merged.update(llm[key])
+ out[key] = merged
+ else:
+ out[key] = llm[key]
+ errs = list(out.get("ml_errors") or []) + list(llm.get("ml_errors") or [])
+ if errs:
+ out["ml_errors"] = errs
+ return out
+
+
+def merge_analysis_into_payload(payload: dict[str, Any], bundle: dict[str, Any]) -> None:
+ """Mutate report payload with analysis / LLM enrichment fields."""
+ payload["content_duplicates"] = bundle.get("content_duplicates") or []
+ payload.pop("anomalies", None)
+ payload["language_summary"] = bundle.get("language_summary") or {}
+ ns = bundle.get("ner_site_summary") or {}
+ if ns:
+ payload["ner_site_summary"] = ns
+ else:
+ payload.pop("ner_site_summary", None)
+ err = bundle.get("ml_errors") or []
+ if err:
+ payload["ml_errors"] = err
+ else:
+ payload.pop("ml_errors", None)
+
+ dup_gid = bundle.get("url_duplicate_group_id") or {}
+ sim_map = bundle.get("similar_internal_by_url") or {}
+ lang_map = bundle.get("language_by_url") or {}
+ nlp_map = bundle.get("spacy_by_url") or {}
+ kp_map = bundle.get("keyphrases_by_url") or {}
+
+ for rec in payload.get("links") or []:
+ if not isinstance(rec, dict):
+ continue
+ u = str(rec.get("url") or "").strip()
+ uk = u.rstrip("/")
+ rec.pop("duplicate_group_id", None)
+ rec.pop("similar_internal", None)
+ rec.pop("detected_language", None)
+ rec.pop("nlp_entities", None)
+ rec.pop("ml_anomaly", None)
+ rec.pop("keyphrases", None)
+ if uk in dup_gid:
+ rec["duplicate_group_id"] = dup_gid[uk]
+ nei = sim_map.get(uk) or sim_map.get(u)
+ if nei:
+ rec["similar_internal"] = list(nei)
+ if uk in lang_map:
+ rec["detected_language"] = lang_map[uk]
+ if uk in nlp_map:
+ rec["nlp_entities"] = nlp_map[uk]
+ if uk in kp_map:
+ rec["keyphrases"] = kp_map[uk]
+ pa = rec.get("page_analysis")
+ if isinstance(pa, dict):
+ sig = pa.get("signals")
+ if isinstance(sig, dict):
+ sig.pop("language", None)
+ sig.pop("nlp_entities", None)
+ if not sig:
+ pa.pop("signals", None)
+ if uk in lang_map:
+ pa.setdefault("signals", {})["language"] = lang_map[uk]
+ if uk in nlp_map:
+ pa.setdefault("signals", {})["nlp_entities"] = nlp_map[uk]
diff --git a/src/website_profiling/analysis/page.py b/src/website_profiling/analysis/page.py
index d2f14d6b..6ae3612e 100644
--- a/src/website_profiling/analysis/page.py
+++ b/src/website_profiling/analysis/page.py
@@ -12,7 +12,7 @@
from ..common import normalize_link
-# Max URLs per resource list to limit SQLite / payload size
+# Max URLs per resource list to limit DB / payload size
LIST_CAP = 200
INLINE_SCRIPT_WARN_BYTES = 8192
_HEADING_ORDER = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
diff --git a/src/website_profiling/analysis/text.py b/src/website_profiling/analysis/text.py
new file mode 100644
index 00000000..511b9f54
--- /dev/null
+++ b/src/website_profiling/analysis/text.py
@@ -0,0 +1,58 @@
+"""Shared text helpers for content analysis and LLM enrichment."""
+from __future__ import annotations
+
+import json
+import re
+
+import pandas as pd
+
+
+def top_keywords_as_text(row: pd.Series, max_terms: int = 15) -> str:
+ if "top_keywords" not in row.index:
+ return ""
+ raw = row.get("top_keywords")
+ if raw is None or (isinstance(raw, float) and pd.isna(raw)):
+ return ""
+ s = str(raw).strip()
+ if not s or s == "[]":
+ return ""
+ try:
+ arr = json.loads(s)
+ if not isinstance(arr, list):
+ return ""
+ words: list[str] = []
+ for item in arr[:max_terms]:
+ if isinstance(item, dict) and item.get("word"):
+ words.append(str(item["word"]))
+ return " ".join(words)
+ except json.JSONDecodeError:
+ return ""
+
+
+def normalize_fingerprint_text(row: pd.Series) -> str:
+ """Concatenate on-page text signals for duplicates, language, and LLM context."""
+ parts: list[str] = []
+ for col in (
+ "title",
+ "h1",
+ "meta_description",
+ "heading_sequence",
+ "og_title",
+ "og_description",
+ "twitter_title",
+ "content_excerpt",
+ ):
+ if col not in row.index:
+ continue
+ v = row.get(col)
+ if v is None or (isinstance(v, float) and pd.isna(v)):
+ continue
+ s = str(v).strip()
+ if s:
+ parts.append(s)
+ kw_extra = top_keywords_as_text(row)
+ if kw_extra:
+ parts.append(kw_extra)
+ t = " ".join(parts).lower()
+ t = re.sub(r"\s+", " ", t)
+ return t[:12000]
diff --git a/src/website_profiling/cli.py b/src/website_profiling/cli.py
index f3850673..ec60475b 100644
--- a/src/website_profiling/cli.py
+++ b/src/website_profiling/cli.py
@@ -1,657 +1,39 @@
"""
CLI: read config file and run crawl, report, or plot.
"""
-import argparse
-import os
-import sys
+from __future__ import annotations
-import pandas as pd
-
-from .config import get_bool, get_float, get_int, get_list, load_config, load_config_from_db
-
-
-def _default_db_path() -> str:
- """report.db path: REPORT_DB_PATH env, else report.db in cwd."""
- env = (os.environ.get("REPORT_DB_PATH") or "").strip()
- if env:
- return os.path.abspath(env)
- return os.path.abspath(os.path.join(os.getcwd(), "report.db"))
-
-
-def _shadow_config_path(db_path: str) -> str:
- return os.path.join(os.path.dirname(db_path) or os.getcwd(), "pipeline-config.txt")
-
-
-def _google_db_has_gsc(db_path: str) -> bool:
- """True when the latest google_data row contains usable Search Console query data."""
- import json
-
- from .db import db_session, init_schema
-
- try:
- with db_session(db_path) as conn:
- init_schema(conn)
- cur = conn.execute("SELECT data FROM google_data ORDER BY id DESC LIMIT 1")
- row = cur.fetchone()
- if not row:
- return False
- data = json.loads(row[0])
- gsc = data.get("gsc_full") or {}
- return bool(gsc.get("top_queries") or gsc.get("by_page"))
- except Exception:
- return False
-
-
-def _should_enrich_keywords_after_report(cfg: dict) -> bool:
- """Default follows enable_google_search_console when enrich_keywords_after_report is omitted."""
- if "enrich_keywords_after_report" in cfg:
- return get_bool(cfg, "enrich_keywords_after_report", False)
- return get_bool(cfg, "enable_google_search_console", False)
-
-
-def _resolved_start_url(cfg: dict) -> str:
- return (cfg.get("start_url") or "").strip()
-
-
-def _resolved_lighthouse_url(cfg: dict) -> str:
- return (cfg.get("lighthouse_url") or "").strip() or _resolved_start_url(cfg)
-
-
-def _require_start_url(cfg: dict, *, for_step: str) -> str:
- url = _resolved_start_url(cfg)
- if not url:
- print(
- f"Error: start_url is required for {for_step}. "
- "Set it in the Pipeline runner UI (Start URL) or pipeline-config.txt.",
- file=sys.stderr,
- )
- sys.exit(1)
- return url
-
-
-def _require_lighthouse_url(cfg: dict) -> str:
- url = _resolved_lighthouse_url(cfg)
- if not url:
- print(
- "Error: lighthouse_url or start_url is required for Lighthouse. "
- "Set Start URL in the Pipeline runner UI.",
- file=sys.stderr,
- )
- sys.exit(1)
- return url
+from .commands import (
+ config_resolve,
+ enrich_cmd,
+ google_cmd,
+ keywords_cmd,
+ lighthouse_cmd,
+ pipeline_cmd,
+ warnings_cmd,
+)
def main() -> None:
- parser = argparse.ArgumentParser(
- description="WebsiteProfiling: crawl site, generate reports and link graph. All options read from config file."
- )
- parser.add_argument(
- "--config",
- "-c",
- default=None,
- help="Optional key=value config file (default: pipeline_config in report.db)",
- )
- parser.add_argument(
- "command",
- nargs="?",
- choices=["crawl", "report", "plot", "lighthouse", "keywords", "warnings", "enrich", "google"],
- help="Run only this step (default: run all steps according to config)",
- )
- parser.add_argument(
- "--test",
- action="store_true",
- help="For 'google' command: validate credentials and API access without storing data.",
- )
- parser.add_argument(
- "--list-properties",
- action="store_true",
- dest="list_properties",
- help="For 'google' command: print accessible GSC sites and GA4 properties as JSON.",
- )
- parser.add_argument(
- "--enrich-google",
- action="store_true",
- dest="enrich_google",
- help="For 'keywords' command: run Google enrichment (Suggest, GSC merge, Datamuse, etc.) without re-running the crawl.",
- )
- parser.add_argument(
- "--expand-only",
- action="store_true",
- dest="expand_only",
- help="For 'keywords' command: only run Suggest expansion and print JSON to stdout.",
- )
+ parser = config_resolve.build_parser()
args = parser.parse_args()
- # --- Config resolution order ---
- # 1. --config path: load that file (CLI override).
- # 2. pipeline_config table in report.db (UI-managed; REPORT_DB_PATH or cwd/report.db).
- # 3. Shadow pipeline-config.txt next to report.db.
- # 4. Error with hint to save settings in the web UI.
-
- cfg: dict[str, str] = {}
- cwd: str = os.getcwd()
+ cfg, cwd = config_resolve.resolve_config(args)
+ path = config_resolve.make_path_fn(cfg, cwd)
- if args.config:
- cfg_path = os.path.abspath(args.config)
- if not os.path.isfile(cfg_path):
- print(f"Config file not found: {cfg_path}", file=sys.stderr)
- sys.exit(1)
- cfg = load_config(cfg_path)
- cwd = os.path.dirname(cfg_path) or os.getcwd()
- else:
- db_path = _default_db_path()
- cfg = load_config_from_db(db_path)
- cwd = os.path.dirname(db_path) or os.getcwd()
- if cfg:
- print(
- f"[Config] Loaded from report.db pipeline_config table ({db_path})",
- flush=True,
- )
- else:
- shadow = _shadow_config_path(db_path)
- if os.path.isfile(shadow):
- cfg = load_config(shadow)
- cwd = os.path.dirname(shadow) or os.getcwd()
- print(f"[Config] Loaded from shadow file ({shadow})", flush=True)
- else:
- print(
- "No pipeline config found. Open the web UI (Pipeline runner), "
- "configure settings, and click Save — or pass --config path.",
- file=sys.stderr,
- )
- sys.exit(1)
-
- def path(key: str, default: str) -> str:
- p = cfg.get(key, default)
- if not os.path.isabs(p):
- p = os.path.join(cwd, p)
- return p
-
- # When set, crawl/report/plot/lighthouse use SQLite instead of JSON/CSV
- sqlite_db_raw = (cfg.get("sqlite_db") or "").strip()
- db_path = path("sqlite_db", "report.db") if sqlite_db_raw else None
- # Docker / hosting: Next.js uses REPORT_DB_PATH for the same DB; align pipeline writes with the UI reader
- _env_db = (os.environ.get("REPORT_DB_PATH") or "").strip()
- if _env_db and db_path is not None:
- db_path = os.path.abspath(_env_db)
-
- # Single-command mode: lighthouse, keywords, warnings
if args.command == "lighthouse":
- print("WebsiteProfiling: lighthouse only", flush=True)
- from .lighthouse.runner import main as lighthouse_main
- lh_url = _require_lighthouse_url(cfg)
- lh_strategy = (cfg.get("lighthouse_strategy") or "mobile").lower()
- if lh_strategy not in ("mobile", "desktop"):
- lh_strategy = "mobile"
- lh_mode = (cfg.get("lighthouse_mode") or "navigation").strip().lower() or "navigation"
- lh_categories = cfg.get("lighthouse_categories", "").strip()
- lh_categories = get_list(cfg, "lighthouse_categories", sep=",") if lh_categories else None
- lh_iterations = get_int(cfg, "lighthouse_iterations", 3) or 3
- lh_out = cfg.get("lighthouse_output_dir", "").strip() or cwd
- if not os.path.isabs(lh_out):
- lh_out = os.path.join(cwd, lh_out)
- sys.exit(lighthouse_main(url=lh_url, strategy=lh_strategy, iterations=lh_iterations, output_dir=lh_out, db_path=db_path, mode=lh_mode, categories=lh_categories))
- if args.command == "keywords":
- # --expand-only: just run Suggest expansion and print JSON to stdout
- if getattr(args, "expand_only", False):
- import json as _json
- from .integrations.google.suggest import batch_expand as _expand
- seeds_raw = (cfg.get("keyword_seeds") or "").strip()
- seeds = [s.strip() for s in seeds_raw.split(",") if s.strip()]
- if not seeds:
- print(_json.dumps({"error": "No keyword_seeds configured"}))
- sys.exit(1)
- result = _expand(seeds, sources=("web", "youtube", "questions"))
- print(_json.dumps(result, ensure_ascii=False), flush=True)
- sys.exit(0)
-
- # --enrich-google: skip crawl-based extraction, go straight to enrichment
- if getattr(args, "enrich_google", False):
- if not db_path:
- print("keywords --enrich-google requires sqlite_db in config.", file=sys.stderr)
- sys.exit(1)
- print("WebsiteProfiling: keywords Google enrichment only...", flush=True)
- from .integrations.google.keyword_enrich import run_enrichment
- try:
- run_enrichment(db_path, cfg)
- print("Keywords enrichment done.", flush=True)
- sys.exit(0)
- except Exception as e:
- print(f"Keywords enrichment error: {e}", file=sys.stderr)
- sys.exit(1)
-
- print("WebsiteProfiling: keywords only", flush=True)
- from .tools.keywords import main as keyword_main
- kw_url = _require_start_url(cfg, for_step="keywords")
- kw_out = cfg.get("keyword_output_dir", "").strip() or cwd
- if not os.path.isabs(kw_out):
- kw_out = os.path.join(cwd, kw_out)
- kw_cfg = dict(cfg)
- kw_cfg["_cwd"] = cwd
- # Pass db_path so keywords.py can write to keyword_data table
- if db_path:
- kw_cfg["_db_path"] = db_path
- rc = keyword_main(base_url=kw_url, output_dir=kw_out, config=kw_cfg)
- # Auto-run Google enrichment if configured
- if rc == 0 and db_path and (
- get_bool(cfg, "enable_google_suggest", False) or _google_db_has_gsc(db_path)
- ):
- print(" Running Google keyword enrichment...", flush=True)
- from .integrations.google.keyword_enrich import run_enrichment
- try:
- run_enrichment(db_path, cfg)
- except Exception as e:
- print(f" Warning: Google enrichment error (non-fatal): {e}", file=sys.stderr)
- sys.exit(rc)
- if args.command == "warnings":
- print("WebsiteProfiling: warning mapper only", flush=True)
- from .tools.warnings import main as warning_mapper_main
- wm_input = cfg.get("warning_mapper_input", "").strip()
- wm_type = (cfg.get("warning_mapper_input_type") or "lighthouse").lower()
- wm_out = cfg.get("warning_mapper_output", "").strip()
- if not wm_out:
- wm_out = os.path.join(cwd, "warnings_mapped.json")
- elif not os.path.isabs(wm_out):
- wm_out = os.path.join(cwd, wm_out)
- sys.exit(warning_mapper_main(input_path=wm_input, input_type=wm_type, output_path=wm_out))
-
- if args.command == "enrich":
- if not db_path:
- print("enrich requires sqlite_db in config.", file=sys.stderr)
- sys.exit(1)
- print("WebsiteProfiling: ML enrich only (updates latest report payload)...", flush=True)
- from .db import db_session, get_latest_crawl_run_id, init_schema, read_crawl, read_report_payload, write_report_payload
- from .ml.enrich import merge_ml_into_payload, run_ml_enrichment
-
- with db_session(db_path) as conn:
- init_schema(conn)
- run_id = get_latest_crawl_run_id(conn)
- df = read_crawl(conn, run_id)
- payload = read_report_payload(conn)
- if not payload:
- print("No report_payload in DB. Run report first.", file=sys.stderr)
- sys.exit(1)
- ml_bundle = run_ml_enrichment(df, cfg)
- merge_ml_into_payload(payload, ml_bundle)
- write_report_payload(conn, payload)
- print("Enrich done. New report_payload row written.", flush=True)
- sys.exit(0)
-
- if args.command == "google":
- from .integrations.google.auth import build_credentials, read_secrets
- from .integrations.google.fetch import fetch_google_data, list_properties
-
- credentials_path = cfg.get("google_credentials_path", "").strip()
- if credentials_path and not os.path.isabs(credentials_path):
- credentials_path = os.path.join(cwd, credentials_path)
-
- # --list-properties: print GSC sites + GA4 properties as JSON and exit
- if getattr(args, "list_properties", False):
- try:
- props = list_properties(credentials_path or None)
- import json as _json
- print(_json.dumps(props), flush=True)
- sys.exit(0)
- except Exception as e:
- print(f"Error listing properties: {e}", file=sys.stderr)
- sys.exit(1)
-
- # --test: validate credentials + API access without storing data
- if getattr(args, "test", False):
- print("WebsiteProfiling: Google credentials test...", flush=True)
- warnings: list[str] = []
- try:
- import google.auth.exceptions as _gae
- creds = build_credentials(credentials_path or None)
- print(" Google credentials: OK (token refreshed)", flush=True)
-
- secrets = read_secrets(credentials_path or None)
- gsc_site_url = secrets.get("gscSiteUrl", "")
- ga4_property_id = secrets.get("ga4PropertyId", "")
-
- if gsc_site_url:
- from .integrations.google.gsc import (
- describe_gsc_site_mismatch,
- list_gsc_sites,
- probe_gsc_site,
- resolve_gsc_site_url,
- )
- sites = list_gsc_sites(creds)
- print(f" GSC: found {len(sites)} accessible site(s): {sites}", flush=True)
- resolved, site_error = resolve_gsc_site_url(gsc_site_url, sites)
- if resolved:
- if resolved != gsc_site_url:
- print(
- f" GSC: NOTE -- Configured '{gsc_site_url}' will use '{resolved}' "
- "(Search Console requires an exact property URL). "
- "Save the exact URL from 'Load from account' to avoid this note.",
- flush=True,
- )
- ok, probe_msg = probe_gsc_site(creds, resolved)
- if ok:
- print(f" GSC: OK -- {probe_msg}", flush=True)
- else:
- print(f" GSC: ERROR -- {probe_msg}", flush=True)
- warnings.append(probe_msg)
- else:
- detail = site_error or describe_gsc_site_mismatch(gsc_site_url, sites)
- print(f" GSC: ERROR -- {detail}", flush=True)
- warnings.append(detail)
- else:
- print(
- " GSC: skipped (no gscSiteUrl configured — set Website in Search Console in Integrations)",
- flush=True,
- )
- warnings.append("GSC site URL is not configured.")
-
- if ga4_property_id:
- from .integrations.google.ga4 import list_ga4_properties, probe_ga4_property
- props, list_error = list_ga4_properties(creds)
- if list_error:
- print(f" GA4: NOTE -- {list_error}", flush=True)
- elif props:
- names = [f"{p['displayName']} ({p['id']})" for p in props]
- print(f" GA4: found {len(props)} accessible propert(ies): {names}", flush=True)
- ok, probe_msg = probe_ga4_property(creds, ga4_property_id)
- if ok:
- print(f" GA4: OK -- {probe_msg}", flush=True)
- if props and ga4_property_id not in [p["id"] for p in props]:
- msg = (
- f"Property {ga4_property_id} works via Data API but was not in the "
- "account property list (listing may be incomplete)."
- )
- print(f" GA4: NOTE -- {msg}", flush=True)
- else:
- print(f" GA4: ERROR -- {probe_msg}", flush=True)
- warnings.append(probe_msg)
- else:
- print(
- " GA4: skipped (no ga4PropertyId configured — set Analytics property in Integrations)",
- flush=True,
- )
- warnings.append("GA4 property ID is not configured.")
-
- if warnings:
- print("", flush=True)
- print("Google test completed with issues:", flush=True)
- for i, w in enumerate(warnings, 1):
- print(f" {i}. {w}", flush=True)
- print("", flush=True)
- print(
- "Data fetch will fail or return empty until these are fixed. "
- "In Integrations: click 'Load from account', pick exact GSC site + GA4 property, Save, then Test again.",
- flush=True,
- )
- sys.exit(1)
-
- print("Google test passed — GSC and GA4 are configured and reachable.", flush=True)
- sys.exit(0)
- except _gae.RefreshError:
- print(
- "Google connection expired -- reconnect in Integrations.",
- file=sys.stderr,
- )
- sys.exit(1)
- except Exception as e:
- print(f"Google test failed: {e}", file=sys.stderr)
- sys.exit(1)
-
- # Full fetch: requires sqlite_db
- if not db_path:
- print("google command requires sqlite_db in config.", file=sys.stderr)
- sys.exit(1)
-
- print("WebsiteProfiling: Google fetch...", flush=True)
-
- from .db import db_session, get_latest_crawl_run_id, init_schema, read_crawl
- from .integrations.google.store import write_google_data
-
- date_range_days = get_int(cfg, "google_date_range_days", 28) or 28
-
- # Read crawl URLs for join stats
- crawl_urls: list[str] = []
- start_url_for_join = cfg.get("start_url", "")
- try:
- with db_session(db_path) as conn:
- init_schema(conn)
- run_id = get_latest_crawl_run_id(conn)
- if run_id is not None:
- df = read_crawl(conn, run_id)
- if "url" in df.columns:
- crawl_urls = df["url"].dropna().astype(str).str.strip().tolist()
- except Exception as e:
- print(f" Warning: could not read crawl URLs for join stats: {e}", flush=True)
-
- try:
- import google.auth.exceptions as _gae
- google_data = fetch_google_data(
- credentials_path=credentials_path or None,
- date_range_days=date_range_days,
- crawl_urls=crawl_urls,
- start_url=start_url_for_join,
- config=cfg,
- )
- except _gae.RefreshError:
- print(
- "Google connection expired -- reconnect in Integrations.",
- file=sys.stderr,
- )
- sys.exit(1)
- except RuntimeError as e:
- print(f"Google fetch error: {e}", file=sys.stderr)
- sys.exit(1)
-
- # Store in google_data table
- with db_session(db_path) as conn:
- init_schema(conn)
- write_google_data(conn, google_data)
-
- if google_data.get("errors"):
- print(" Partial errors:", flush=True)
- for err in google_data["errors"]:
- print(f" - {err}", flush=True)
-
- print("Google fetch done. Data stored in google_data table.", flush=True)
- sys.exit(0)
-
- run_crawl = args.command == "crawl" or (args.command is None and get_bool(cfg, "run_crawl", True))
- run_report = args.command == "report" or (args.command is None and get_bool(cfg, "run_report", True))
- run_plot = args.command == "plot" or (args.command is None and get_bool(cfg, "run_plot", False))
- run_lighthouse = args.command is None and get_bool(cfg, "run_lighthouse", False)
- run_lighthouse_on_pages = args.command is None and get_bool(cfg, "run_lighthouse_on_pages", False)
- lighthouse_max_pages = get_int(cfg, "lighthouse_max_pages", 20) or 20
-
- if args.command is None and (run_crawl or run_lighthouse or run_lighthouse_on_pages or run_report or run_plot):
- steps = []
- if run_crawl:
- steps.append("crawl")
- if run_lighthouse_on_pages:
- steps.append("lighthouse-on-pages")
- elif run_lighthouse:
- steps.append("lighthouse")
- if run_report:
- steps.append("report")
- if run_plot:
- steps.append("plot")
- print(f"WebsiteProfiling pipeline: {', '.join(steps)}", flush=True)
-
- if run_crawl:
- from .crawl.crawler import run_crawler
- print("[Crawl] Starting...", flush=True)
- start_url = _require_start_url(cfg, for_step="crawl")
- max_pages = get_int(cfg, "max_pages")
- concurrency = get_int(cfg, "concurrency", 8)
- timeout = get_int(cfg, "timeout", 12)
- ignore_robots = get_bool(cfg, "ignore_robots", False)
- allow_external = get_bool(cfg, "allow_external", False)
- max_depth = get_int(cfg, "max_depth")
- polite_delay = get_float(cfg, "polite_delay", 0.2)
- store_outlinks = get_bool(cfg, "store_outlinks", True)
- exclude_urls = get_list(cfg, "crawl_exclude_urls", sep=",")
- preserve_crawl_history = get_bool(cfg, "preserve_crawl_history", True)
- store_content_excerpt = get_bool(cfg, "store_content_excerpt", False)
- content_excerpt_max_chars = get_int(cfg, "content_excerpt_max_chars", 4096) or 4096
- crawl_output = path("crawl_output", "crawl_results.csv")
- print("Crawling...")
- run_crawler(
- start_url=start_url,
- max_pages=max_pages,
- concurrency=concurrency,
- timeout=timeout,
- ignore_robots=ignore_robots,
- allow_external=allow_external,
- max_depth=max_depth,
- polite_delay=polite_delay,
- store_outlinks=store_outlinks,
- output_csv=crawl_output if not db_path else None,
- output_db=db_path,
- show_progress=True,
- exclude_urls=exclude_urls if exclude_urls else None,
- preserve_crawl_history=preserve_crawl_history,
- store_content_excerpt=store_content_excerpt,
- content_excerpt_max_chars=content_excerpt_max_chars,
- )
- print("[Crawl] Done.", flush=True)
- print(f"Crawl results: {db_path or crawl_output}")
- crawl_csv = crawl_output
+ lighthouse_cmd.run(cfg, args)
+ elif args.command == "keywords":
+ keywords_cmd.run(cfg, args)
+ elif args.command == "warnings":
+ warnings_cmd.run(cfg, cwd, path, args)
+ elif args.command == "enrich":
+ enrich_cmd.run(cfg, args)
+ elif args.command == "google":
+ google_cmd.run(cfg, cwd, path, args)
else:
- crawl_csv = path("crawl_csv", "crawl_results.csv")
- edges_csv = path("edges_csv", "edges.csv")
- nodes_csv = path("nodes_csv", "nodes.csv")
-
- # Run Lighthouse on every 200 OK page (when enabled); requires DB and crawl data
- lighthouse_summary_path_for_report = None
- if run_lighthouse_on_pages and db_path:
- from .db import db_session, get_latest_crawl_run_id, init_schema, read_crawl
- from .lighthouse.runner import run_lighthouse_on_pages as do_lighthouse_on_pages
- print("[Lighthouse on pages] Starting...", flush=True)
- with db_session(db_path) as conn:
- init_schema(conn)
- run_id = get_latest_crawl_run_id(conn)
- df = read_crawl(conn, run_id)
- success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns and not df.empty else pd.DataFrame()
- urls_200 = success_df["url"].dropna().astype(str).str.strip().unique().tolist()[:lighthouse_max_pages]
- if not urls_200:
- print("[Lighthouse on pages] No 200 OK URLs in crawl. Skip.", flush=True)
- else:
- lh_strategy = (cfg.get("lighthouse_strategy") or "mobile").lower()
- if lh_strategy not in ("mobile", "desktop"):
- lh_strategy = "mobile"
- lh_mode = (cfg.get("lighthouse_mode") or "navigation").strip().lower() or "navigation"
- lh_categories = get_list(cfg, "lighthouse_categories", sep=",")
- lh_iterations = get_int(cfg, "lighthouse_iterations", 3) or 3
- if run_lighthouse_on_pages:
- lh_iterations = 1
- lh_out = cfg.get("lighthouse_output_dir", "").strip() or cwd
- if not os.path.isabs(lh_out):
- lh_out = os.path.join(cwd, lh_out)
- do_lighthouse_on_pages(
- urls=urls_200,
- strategy=lh_strategy,
- iterations=lh_iterations,
- output_dir=lh_out,
- db_path=db_path,
- mode=lh_mode,
- categories=lh_categories if lh_categories else None,
- )
- print("[Lighthouse on pages] Done.", flush=True)
-
- # Run single-URL Lighthouse before report when enabled (and not running on all pages)
- if run_lighthouse and not run_lighthouse_on_pages:
- print("[Lighthouse] Starting...", flush=True)
- from .lighthouse.runner import main as lighthouse_main
- lh_url = _require_lighthouse_url(cfg)
- lh_strategy = (cfg.get("lighthouse_strategy") or "mobile").lower()
- if lh_strategy not in ("mobile", "desktop"):
- lh_strategy = "mobile"
- lh_mode = (cfg.get("lighthouse_mode") or "navigation").strip().lower() or "navigation"
- lh_categories = get_list(cfg, "lighthouse_categories", sep=",")
- lh_iterations = get_int(cfg, "lighthouse_iterations", 3) or 3
- lh_out = cfg.get("lighthouse_output_dir", "").strip() or cwd
- if not os.path.isabs(lh_out):
- lh_out = os.path.join(cwd, lh_out)
- exit_code = lighthouse_main(url=lh_url, strategy=lh_strategy, iterations=lh_iterations, output_dir=lh_out, db_path=db_path, mode=lh_mode, categories=lh_categories if lh_categories else None)
- if exit_code != 0:
- sys.exit(exit_code)
- print("[Lighthouse] Done.", flush=True)
- lighthouse_summary_path_for_report = os.path.join(lh_out, "lighthouse_summary.json") if not db_path else None
-
- if run_report:
- if not db_path:
- print(
- "Report requires sqlite_db. Set sqlite_db = report.db in pipeline config "
- "(web UI → Pipeline runner → Save). The Next.js UI reads report.db via /api/report/*.",
- file=sys.stderr,
- )
- sys.exit(1)
- report_output = path("report_output", "site_report.html")
- max_fetch = get_int(cfg, "max_fetch_for_edges", 300)
- same_domain = get_bool(cfg, "same_domain_only", True)
- max_nodes = get_int(cfg, "max_nodes_plot", 400)
- site_name = (cfg.get("site_name") or "").strip()
- report_title = (cfg.get("report_title") or "").strip()
- start_url = _require_start_url(cfg, for_step="report")
- run_security_scan_flag = get_bool(cfg, "run_security_scan", True)
- security_scan_active = get_bool(cfg, "security_scan_active", False)
- security_max_urls_probe = get_int(cfg, "security_max_urls_probe", 20) or 20
- security_findings_output = (cfg.get("security_findings_output") or "").strip()
- if security_findings_output and not os.path.isabs(security_findings_output):
- security_findings_output = os.path.join(cwd, security_findings_output)
- elif not security_findings_output:
- security_findings_output = None
- lighthouse_summary_path = (cfg.get("lighthouse_summary_json") or "").strip()
- if lighthouse_summary_path and not os.path.isabs(lighthouse_summary_path):
- lighthouse_summary_path = os.path.join(cwd, lighthouse_summary_path)
- if not lighthouse_summary_path:
- lighthouse_summary_path = lighthouse_summary_path_for_report
- from .reporting.builder import run_simple_report
- print("[Report] Starting...", flush=True)
- out = run_simple_report(
- crawl_csv=crawl_csv,
- edges_csv=edges_csv,
- output_html=report_output,
- max_fetch_for_edges=max_fetch,
- concurrency=6,
- timeout=8,
- same_domain_only=same_domain,
- max_nodes_plot=max_nodes or 300,
- site_name=site_name or None,
- report_title=report_title or None,
- start_url=start_url,
- run_security_scan_flag=run_security_scan_flag,
- security_scan_active=security_scan_active,
- security_max_urls_probe=security_max_urls_probe,
- security_findings_output=security_findings_output,
- lighthouse_summary_path=lighthouse_summary_path,
- db_path=db_path,
- config=cfg,
- )
- print("[Report] Done.", flush=True)
- print(f"Report written: {out}")
-
- if _should_enrich_keywords_after_report(cfg) and _google_db_has_gsc(db_path):
- print("[Keywords] Post-report enrichment (GSC data found)...", flush=True)
- from .integrations.google.keyword_enrich import run_enrichment
+ pipeline_cmd.run(cfg, args)
- try:
- run_enrichment(db_path, cfg)
- print("[Keywords] Post-report enrichment done.", flush=True)
- except Exception as e:
- print(f"Warning: post-report keyword enrichment failed: {e}", file=sys.stderr)
- if run_plot:
- print("[Plot] Starting...", flush=True)
- from .tools.plot import run_plot as do_plot
- e, n = do_plot(
- crawl_csv=crawl_csv,
- edges_csv=edges_csv,
- nodes_csv=nodes_csv,
- same_domain_only=get_bool(cfg, "same_domain_only", True),
- max_fetch_for_edges=get_int(cfg, "max_fetch_for_edges", 500),
- concurrency=8,
- timeout=10,
- polite_delay=0.15,
- db_path=db_path,
- )
- print("[Plot] Done.", flush=True)
- print(f"Edges: {e}, Nodes: {n}")
+if __name__ == "__main__":
+ main()
diff --git a/src/website_profiling/commands/__init__.py b/src/website_profiling/commands/__init__.py
new file mode 100644
index 00000000..2e0fb01c
--- /dev/null
+++ b/src/website_profiling/commands/__init__.py
@@ -0,0 +1 @@
+"""CLI command implementations (dispatched from cli.main)."""
diff --git a/src/website_profiling/commands/config_resolve.py b/src/website_profiling/commands/config_resolve.py
new file mode 100644
index 00000000..da6f809a
--- /dev/null
+++ b/src/website_profiling/commands/config_resolve.py
@@ -0,0 +1,187 @@
+"""Config loading and shared CLI helpers."""
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import sys
+import tempfile
+from collections.abc import Callable
+
+from ..config import get_bool, load_config, load_config_from_db
+
+
+def shadow_config_path() -> str:
+ from ..db.storage import get_data_dir
+
+ return os.path.join(get_data_dir(), "pipeline-config.txt")
+
+
+def require_database_url() -> None:
+ from ..db.storage import get_database_url
+
+ get_database_url()
+
+
+def lighthouse_work_dir() -> str:
+ return tempfile.mkdtemp(prefix="wp-lighthouse-")
+
+
+def cleanup_lighthouse_work_dir(work_dir: str) -> None:
+ if not work_dir:
+ return
+ tmp_root = os.path.realpath(tempfile.gettempdir())
+ if os.path.realpath(work_dir).startswith(tmp_root):
+ shutil.rmtree(work_dir, ignore_errors=True)
+
+
+def google_db_has_gsc() -> bool:
+ from ..db import db_session
+ from ..db.storage import _parse_json_field
+
+ try:
+ with db_session() as conn:
+ cur = conn.execute("SELECT data FROM google_data ORDER BY id DESC LIMIT 1")
+ row = cur.fetchone()
+ if not row:
+ return False
+ data = _parse_json_field(row["data"])
+ if not isinstance(data, dict):
+ return False
+ gsc = data.get("gsc_full") or {}
+ return bool(gsc.get("top_queries") or gsc.get("by_page"))
+ except Exception:
+ return False
+
+
+def should_enrich_keywords_after_report(cfg: dict) -> bool:
+ if "enrich_keywords_after_report" in cfg:
+ return get_bool(cfg, "enrich_keywords_after_report", False)
+ return get_bool(cfg, "enable_google_search_console", False)
+
+
+def resolved_start_url(cfg: dict) -> str:
+ return (cfg.get("start_url") or "").strip()
+
+
+def resolved_lighthouse_url(cfg: dict) -> str:
+ return (cfg.get("lighthouse_url") or "").strip() or resolved_start_url(cfg)
+
+
+def require_start_url(cfg: dict, *, for_step: str) -> str:
+ url = resolved_start_url(cfg)
+ if not url:
+ print(
+ f"Error: start_url is required for {for_step}. "
+ "Set it in the Pipeline runner UI (Start URL) or pipeline-config.txt.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ return url
+
+
+def require_lighthouse_url(cfg: dict) -> str:
+ url = resolved_lighthouse_url(cfg)
+ if not url:
+ print(
+ "Error: lighthouse_url or start_url is required for Lighthouse. "
+ "Set Start URL in the Pipeline runner UI.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ return url
+
+
+PathFn = Callable[[str, str], str]
+
+
+def make_path_fn(cfg: dict[str, str], cwd: str) -> PathFn:
+ def path(key: str, default: str) -> str:
+ p = cfg.get(key, default)
+ if not os.path.isabs(p):
+ p = os.path.join(cwd, p)
+ return p
+
+ return path
+
+
+def resolve_config(args: argparse.Namespace) -> tuple[dict[str, str], str]:
+ cfg: dict[str, str] = {}
+ cwd: str = os.getcwd()
+
+ if args.config:
+ cfg_path = os.path.abspath(args.config)
+ if not os.path.isfile(cfg_path):
+ print(f"Config file not found: {cfg_path}", file=sys.stderr)
+ sys.exit(1)
+ cfg = load_config(cfg_path)
+ cwd = os.path.dirname(cfg_path) or os.getcwd()
+ else:
+ try:
+ require_database_url()
+ except RuntimeError as e:
+ print(str(e), file=sys.stderr)
+ sys.exit(1)
+ cfg = load_config_from_db()
+ from ..db.storage import get_data_dir
+
+ cwd = get_data_dir()
+ if cfg:
+ print("[Config] Loaded from pipeline_config table (PostgreSQL)", flush=True)
+ else:
+ shadow = shadow_config_path()
+ if os.path.isfile(shadow):
+ cfg = load_config(shadow)
+ cwd = os.path.dirname(shadow) or os.getcwd()
+ print(f"[Config] Loaded from shadow file ({shadow})", flush=True)
+ else:
+ print(
+ "No pipeline config found. Open the web UI (Pipeline runner), "
+ "configure settings, and click Save — or pass --config path.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ return cfg, cwd
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="WebsiteProfiling: crawl site, generate reports and link graph. All options read from config file."
+ )
+ parser.add_argument(
+ "--config",
+ "-c",
+ default=None,
+ help="Optional key=value config file (default: pipeline_config in PostgreSQL)",
+ )
+ parser.add_argument(
+ "command",
+ nargs="?",
+ choices=["crawl", "report", "plot", "lighthouse", "keywords", "warnings", "enrich", "google"],
+ help="Run only this step (default: run all steps according to config)",
+ )
+ parser.add_argument(
+ "--test",
+ action="store_true",
+ help="For 'google' command: validate credentials and API access without storing data.",
+ )
+ parser.add_argument(
+ "--list-properties",
+ action="store_true",
+ dest="list_properties",
+ help="For 'google' command: print accessible GSC sites and GA4 properties as JSON.",
+ )
+ parser.add_argument(
+ "--enrich-google",
+ action="store_true",
+ dest="enrich_google",
+ help="For 'keywords' command: run Google enrichment (Suggest, GSC merge, Datamuse, etc.) without re-running the crawl.",
+ )
+ parser.add_argument(
+ "--expand-only",
+ action="store_true",
+ dest="expand_only",
+ help="For 'keywords' command: only run Suggest expansion and print JSON to stdout.",
+ )
+ return parser
diff --git a/src/website_profiling/commands/enrich_cmd.py b/src/website_profiling/commands/enrich_cmd.py
new file mode 100644
index 00000000..3d824cd8
--- /dev/null
+++ b/src/website_profiling/commands/enrich_cmd.py
@@ -0,0 +1,30 @@
+"""CLI: enrich command."""
+from __future__ import annotations
+
+import argparse
+import sys
+
+from ..analysis import merge_analysis_into_payload, merge_bundles, run_local_enrichment
+from ..db import db_session, get_latest_crawl_run_id, read_crawl, read_report_payload, write_report_payload
+from ..llm.enrich import run_llm_enrichment
+from ..llm_config import load_llm_config_from_db, llm_is_enabled
+
+
+def run(cfg: dict, args: argparse.Namespace) -> None:
+ print("WebsiteProfiling: enrich only (updates latest report payload)...", flush=True)
+
+ with db_session() as conn:
+ run_id = get_latest_crawl_run_id(conn)
+ df = read_crawl(conn, run_id)
+ payload = read_report_payload(conn)
+ if not payload:
+ print("No report_payload in DB. Run report first.", file=sys.stderr)
+ sys.exit(1)
+ local_bundle = run_local_enrichment(df, cfg)
+ llm_cfg = load_llm_config_from_db()
+ llm_bundle = run_llm_enrichment(df, llm_cfg) if llm_is_enabled(llm_cfg) else {}
+ bundle = merge_bundles(local_bundle, llm_bundle)
+ merge_analysis_into_payload(payload, bundle)
+ write_report_payload(conn, payload)
+ print("Enrich done. New report_payload row written.", flush=True)
+ sys.exit(0)
diff --git a/src/website_profiling/commands/google_cmd.py b/src/website_profiling/commands/google_cmd.py
new file mode 100644
index 00000000..a61c2495
--- /dev/null
+++ b/src/website_profiling/commands/google_cmd.py
@@ -0,0 +1,187 @@
+"""CLI: google command."""
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+from ..config import get_int
+from .config_resolve import PathFn
+
+
+def run(cfg: dict, cwd: str, path: PathFn, args: argparse.Namespace) -> None:
+ from ..integrations.google.auth import build_credentials, read_secrets
+ from ..integrations.google.fetch import fetch_google_data, list_properties
+
+ credentials_path = cfg.get("google_credentials_path", "").strip()
+ if credentials_path and not os.path.isabs(credentials_path):
+ credentials_path = os.path.join(cwd, credentials_path)
+
+ if getattr(args, "list_properties", False):
+ try:
+ props = list_properties(credentials_path or None)
+ import json as _json
+
+ print(_json.dumps(props), flush=True)
+ sys.exit(0)
+ except Exception as e:
+ print(f"Error listing properties: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if getattr(args, "test", False):
+ _run_google_test(credentials_path)
+ return
+
+ print("WebsiteProfiling: Google fetch...", flush=True)
+ from ..db import db_session, get_latest_crawl_run_id, read_crawl
+ from ..integrations.google.store import write_google_data
+
+ date_range_days = get_int(cfg, "google_date_range_days", 28) or 28
+
+ crawl_urls: list[str] = []
+ start_url_for_join = cfg.get("start_url", "")
+ try:
+ with db_session() as conn:
+ run_id = get_latest_crawl_run_id(conn)
+ if run_id is not None:
+ df = read_crawl(conn, run_id)
+ if "url" in df.columns:
+ crawl_urls = df["url"].dropna().astype(str).str.strip().tolist()
+ except Exception as e:
+ print(f" Warning: could not read crawl URLs for join stats: {e}", flush=True)
+
+ try:
+ import google.auth.exceptions as _gae
+
+ google_data = fetch_google_data(
+ credentials_path=credentials_path or None,
+ date_range_days=date_range_days,
+ crawl_urls=crawl_urls,
+ start_url=start_url_for_join,
+ config=cfg,
+ )
+ except _gae.RefreshError:
+ print(
+ "Google connection expired -- reconnect in Integrations.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ except RuntimeError as e:
+ print(f"Google fetch error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ with db_session() as conn:
+ write_google_data(conn, google_data)
+
+ if google_data.get("errors"):
+ print(" Partial errors:", flush=True)
+ for err in google_data["errors"]:
+ print(f" - {err}", flush=True)
+
+ print("Google fetch done. Data stored in google_data table.", flush=True)
+ sys.exit(0)
+
+
+def _run_google_test(credentials_path: str | None) -> None:
+ print("WebsiteProfiling: Google credentials test...", flush=True)
+ from ..integrations.google.auth import build_credentials, read_secrets
+
+ warnings: list[str] = []
+ try:
+ import google.auth.exceptions as _gae
+
+ creds = build_credentials(credentials_path or None)
+ print(" Google credentials: OK (token refreshed)", flush=True)
+
+ secrets = read_secrets(credentials_path or None)
+ gsc_site_url = secrets.get("gscSiteUrl", "")
+ ga4_property_id = secrets.get("ga4PropertyId", "")
+
+ if gsc_site_url:
+ from ..integrations.google.gsc import (
+ describe_gsc_site_mismatch,
+ list_gsc_sites,
+ probe_gsc_site,
+ resolve_gsc_site_url,
+ )
+
+ sites = list_gsc_sites(creds)
+ print(f" GSC: found {len(sites)} accessible site(s): {sites}", flush=True)
+ resolved, site_error = resolve_gsc_site_url(gsc_site_url, sites)
+ if resolved:
+ if resolved != gsc_site_url:
+ print(
+ f" GSC: NOTE -- Configured '{gsc_site_url}' will use '{resolved}' "
+ "(Search Console requires an exact property URL). "
+ "Save the exact URL from 'Load from account' to avoid this note.",
+ flush=True,
+ )
+ ok, probe_msg = probe_gsc_site(creds, resolved)
+ if ok:
+ print(f" GSC: OK -- {probe_msg}", flush=True)
+ else:
+ print(f" GSC: ERROR -- {probe_msg}", flush=True)
+ warnings.append(probe_msg)
+ else:
+ detail = site_error or describe_gsc_site_mismatch(gsc_site_url, sites)
+ print(f" GSC: ERROR -- {detail}", flush=True)
+ warnings.append(detail)
+ else:
+ print(
+ " GSC: skipped (no gscSiteUrl configured — set Website in Search Console in Integrations)",
+ flush=True,
+ )
+ warnings.append("GSC site URL is not configured.")
+
+ if ga4_property_id:
+ from ..integrations.google.ga4 import list_ga4_properties, probe_ga4_property
+
+ props, list_error = list_ga4_properties(creds)
+ if list_error:
+ print(f" GA4: NOTE -- {list_error}", flush=True)
+ elif props:
+ names = [f"{p['displayName']} ({p['id']})" for p in props]
+ print(f" GA4: found {len(props)} accessible propert(ies): {names}", flush=True)
+ ok, probe_msg = probe_ga4_property(creds, ga4_property_id)
+ if ok:
+ print(f" GA4: OK -- {probe_msg}", flush=True)
+ if props and ga4_property_id not in [p["id"] for p in props]:
+ msg = (
+ f"Property {ga4_property_id} works via Data API but was not in the "
+ "account property list (listing may be incomplete)."
+ )
+ print(f" GA4: NOTE -- {msg}", flush=True)
+ else:
+ print(f" GA4: ERROR -- {probe_msg}", flush=True)
+ warnings.append(probe_msg)
+ else:
+ print(
+ " GA4: skipped (no ga4PropertyId configured — set Analytics property in Integrations)",
+ flush=True,
+ )
+ warnings.append("GA4 property ID is not configured.")
+
+ if warnings:
+ print("", flush=True)
+ print("Google test completed with issues:", flush=True)
+ for i, w in enumerate(warnings, 1):
+ print(f" {i}. {w}", flush=True)
+ print("", flush=True)
+ print(
+ "Data fetch will fail or return empty until these are fixed. "
+ "In Integrations: click 'Load from account', pick exact GSC site + GA4 property, Save, then Test again.",
+ flush=True,
+ )
+ sys.exit(1)
+
+ print("Google test passed — GSC and GA4 are configured and reachable.", flush=True)
+ sys.exit(0)
+ except _gae.RefreshError:
+ print(
+ "Google connection expired -- reconnect in Integrations.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ except Exception as e:
+ print(f"Google test failed: {e}", file=sys.stderr)
+ sys.exit(1)
diff --git a/src/website_profiling/commands/keywords_cmd.py b/src/website_profiling/commands/keywords_cmd.py
new file mode 100644
index 00000000..80c0118d
--- /dev/null
+++ b/src/website_profiling/commands/keywords_cmd.py
@@ -0,0 +1,52 @@
+"""CLI: keywords command."""
+from __future__ import annotations
+
+import argparse
+import sys
+
+from ..config import get_bool
+from .config_resolve import google_db_has_gsc, require_start_url
+
+
+def run(cfg: dict, args: argparse.Namespace) -> None:
+ if getattr(args, "expand_only", False):
+ import json as _json
+
+ from ..integrations.google.suggest import batch_expand as _expand
+
+ seeds_raw = (cfg.get("keyword_seeds") or "").strip()
+ seeds = [s.strip() for s in seeds_raw.split(",") if s.strip()]
+ if not seeds:
+ print(_json.dumps({"error": "No keyword_seeds configured"}))
+ sys.exit(1)
+ result = _expand(seeds, sources=("web", "youtube", "questions"))
+ print(_json.dumps(result, ensure_ascii=False), flush=True)
+ sys.exit(0)
+
+ if getattr(args, "enrich_google", False):
+ print("WebsiteProfiling: keywords Google enrichment only...", flush=True)
+ from ..integrations.google.keyword_enrich import run_enrichment
+
+ try:
+ run_enrichment(cfg)
+ print("Keywords enrichment done.", flush=True)
+ sys.exit(0)
+ except Exception as e:
+ print(f"Keywords enrichment error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ print("WebsiteProfiling: keywords only", flush=True)
+ from ..tools.keywords import main as keyword_main
+
+ kw_url = require_start_url(cfg, for_step="keywords")
+ kw_cfg = dict(cfg)
+ rc = keyword_main(base_url=kw_url, config=kw_cfg)
+ if rc == 0 and (get_bool(cfg, "enable_google_suggest", False) or google_db_has_gsc()):
+ print(" Running Google keyword enrichment...", flush=True)
+ from ..integrations.google.keyword_enrich import run_enrichment
+
+ try:
+ run_enrichment(cfg)
+ except Exception as e:
+ print(f" Warning: Google enrichment error (non-fatal): {e}", file=sys.stderr)
+ sys.exit(rc)
diff --git a/src/website_profiling/commands/lighthouse_cmd.py b/src/website_profiling/commands/lighthouse_cmd.py
new file mode 100644
index 00000000..0fd48ad7
--- /dev/null
+++ b/src/website_profiling/commands/lighthouse_cmd.py
@@ -0,0 +1,41 @@
+"""CLI: lighthouse command."""
+from __future__ import annotations
+
+import argparse
+import sys
+
+from ..config import get_int, get_list
+from .config_resolve import (
+ cleanup_lighthouse_work_dir,
+ lighthouse_work_dir,
+ require_lighthouse_url,
+)
+
+
+def run(cfg: dict, args: argparse.Namespace) -> None:
+ print("WebsiteProfiling: lighthouse only", flush=True)
+ from ..lighthouse.runner import main as lighthouse_main
+
+ lh_url = require_lighthouse_url(cfg)
+ lh_strategy = (cfg.get("lighthouse_strategy") or "mobile").lower()
+ if lh_strategy not in ("mobile", "desktop"):
+ lh_strategy = "mobile"
+ lh_mode = (cfg.get("lighthouse_mode") or "navigation").strip().lower() or "navigation"
+ lh_categories = cfg.get("lighthouse_categories", "").strip()
+ lh_categories = get_list(cfg, "lighthouse_categories", sep=",") if lh_categories else None
+ lh_iterations = get_int(cfg, "lighthouse_iterations", 3) or 3
+ lh_out = lighthouse_work_dir()
+ try:
+ sys.exit(
+ lighthouse_main(
+ url=lh_url,
+ strategy=lh_strategy,
+ iterations=lh_iterations,
+ output_dir=lh_out,
+ use_database=True,
+ mode=lh_mode,
+ categories=lh_categories,
+ )
+ )
+ finally:
+ cleanup_lighthouse_work_dir(lh_out)
diff --git a/src/website_profiling/commands/pipeline_cmd.py b/src/website_profiling/commands/pipeline_cmd.py
new file mode 100644
index 00000000..c011d4f7
--- /dev/null
+++ b/src/website_profiling/commands/pipeline_cmd.py
@@ -0,0 +1,242 @@
+"""CLI: full pipeline (crawl, lighthouse, report, plot) and single-step modes."""
+from __future__ import annotations
+
+import argparse
+import sys
+
+import pandas as pd
+
+from ..config import get_bool, get_float, get_int, get_list
+from .config_resolve import (
+ cleanup_lighthouse_work_dir,
+ google_db_has_gsc,
+ lighthouse_work_dir,
+ require_lighthouse_url,
+ require_start_url,
+ should_enrich_keywords_after_report,
+)
+
+
+def select_lighthouse_urls_from_crawl(df: pd.DataFrame, max_pages: int) -> list[str]:
+ if df.empty or "url" not in df.columns:
+ return []
+ if "status" not in df.columns:
+ return []
+ success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)]
+ if success_df.empty:
+ return []
+ return (
+ success_df["url"]
+ .dropna()
+ .astype(str)
+ .str.strip()
+ .loc[lambda s: s != ""]
+ .unique()
+ .tolist()[: max(0, int(max_pages or 0))]
+ )
+
+
+def run(cfg: dict, args: argparse.Namespace) -> None:
+ use_database = True
+
+ run_crawl = args.command == "crawl" or (args.command is None and get_bool(cfg, "run_crawl", True))
+ run_report = args.command == "report" or (args.command is None and get_bool(cfg, "run_report", True))
+ run_plot = args.command == "plot" or (args.command is None and get_bool(cfg, "run_plot", False))
+ run_lighthouse = args.command is None and get_bool(cfg, "run_lighthouse", False)
+ run_lighthouse_on_pages = args.command is None and get_bool(cfg, "run_lighthouse_on_pages", False)
+ lighthouse_max_pages = get_int(cfg, "lighthouse_max_pages", 20) or 20
+
+ if args.command is None and (
+ run_crawl or run_lighthouse or run_lighthouse_on_pages or run_report or run_plot
+ ):
+ steps = []
+ if run_crawl:
+ steps.append("crawl")
+ if run_lighthouse_on_pages:
+ steps.append("lighthouse-on-pages")
+ elif run_lighthouse:
+ steps.append("lighthouse")
+ if run_report:
+ steps.append("report")
+ if run_plot:
+ steps.append("plot")
+ print(f"WebsiteProfiling pipeline: {', '.join(steps)}", flush=True)
+
+ if run_crawl:
+ _run_crawl(cfg, use_database)
+
+ if run_lighthouse_on_pages and use_database:
+ _run_lighthouse_on_pages(cfg, lighthouse_max_pages)
+
+ if run_lighthouse and not run_lighthouse_on_pages:
+ _run_single_lighthouse(cfg, use_database)
+
+ if run_report:
+ _run_report(cfg, use_database)
+
+ if run_plot:
+ _run_plot(cfg, use_database)
+
+
+def _run_crawl(cfg: dict, use_database: bool) -> None:
+ from ..crawl.crawler import run_crawler
+
+ print("[Crawl] Starting...", flush=True)
+ start_url = require_start_url(cfg, for_step="crawl")
+ max_pages = get_int(cfg, "max_pages")
+ concurrency = get_int(cfg, "concurrency", 8)
+ timeout = get_int(cfg, "timeout", 12)
+ ignore_robots = get_bool(cfg, "ignore_robots", False)
+ allow_external = get_bool(cfg, "allow_external", False)
+ max_depth = get_int(cfg, "max_depth")
+ polite_delay = get_float(cfg, "polite_delay", 0.2)
+ store_outlinks = get_bool(cfg, "store_outlinks", True)
+ exclude_urls = get_list(cfg, "crawl_exclude_urls", sep=",")
+ preserve_crawl_history = get_bool(cfg, "preserve_crawl_history", True)
+ store_content_excerpt = get_bool(cfg, "store_content_excerpt", False)
+ content_excerpt_max_chars = get_int(cfg, "content_excerpt_max_chars", 4096) or 4096
+ crawl_stream_to_db = get_bool(cfg, "crawl_stream_to_db", False)
+ print("Crawling...")
+ run_crawler(
+ start_url=start_url,
+ max_pages=max_pages,
+ concurrency=concurrency,
+ timeout=timeout,
+ ignore_robots=ignore_robots,
+ allow_external=allow_external,
+ max_depth=max_depth,
+ polite_delay=polite_delay,
+ store_outlinks=store_outlinks,
+ output_csv=None,
+ output_db=use_database,
+ show_progress=True,
+ exclude_urls=exclude_urls if exclude_urls else None,
+ preserve_crawl_history=preserve_crawl_history,
+ store_content_excerpt=store_content_excerpt,
+ content_excerpt_max_chars=content_excerpt_max_chars,
+ crawl_stream_to_db=crawl_stream_to_db,
+ )
+ print("[Crawl] Done.", flush=True)
+ print("Crawl results: PostgreSQL")
+
+
+def _run_lighthouse_on_pages(cfg: dict, lighthouse_max_pages: int) -> None:
+ from ..db import db_session, get_latest_crawl_run_id, read_crawl
+ from ..lighthouse.runner import run_lighthouse_on_pages as do_lighthouse_on_pages
+
+ print("[Lighthouse on pages] Starting...", flush=True)
+ with db_session() as conn:
+ run_id = get_latest_crawl_run_id(conn)
+ df = read_crawl(conn, run_id)
+ urls_200 = select_lighthouse_urls_from_crawl(df, lighthouse_max_pages)
+ if not urls_200:
+ print("[Lighthouse on pages] No 200 OK URLs in crawl. Skip.", flush=True)
+ else:
+ lh_strategy = (cfg.get("lighthouse_strategy") or "mobile").lower()
+ if lh_strategy not in ("mobile", "desktop"):
+ lh_strategy = "mobile"
+ lh_mode = (cfg.get("lighthouse_mode") or "navigation").strip().lower() or "navigation"
+ lh_categories = get_list(cfg, "lighthouse_categories", sep=",")
+ lh_iterations = get_int(cfg, "lighthouse_iterations", 3) or 3
+ lh_out = lighthouse_work_dir()
+ try:
+ do_lighthouse_on_pages(
+ urls=urls_200,
+ strategy=lh_strategy,
+ iterations=lh_iterations,
+ output_dir=lh_out,
+ mode=lh_mode,
+ categories=lh_categories if lh_categories else None,
+ concurrency=get_int(cfg, "lighthouse_concurrency", 2) or 2,
+ )
+ finally:
+ cleanup_lighthouse_work_dir(lh_out)
+ print("[Lighthouse on pages] Done.", flush=True)
+
+
+def _run_single_lighthouse(cfg: dict, use_database: bool) -> None:
+ from ..lighthouse.runner import main as lighthouse_main
+
+ print("[Lighthouse] Starting...", flush=True)
+ lh_url = require_lighthouse_url(cfg)
+ lh_strategy = (cfg.get("lighthouse_strategy") or "mobile").lower()
+ if lh_strategy not in ("mobile", "desktop"):
+ lh_strategy = "mobile"
+ lh_mode = (cfg.get("lighthouse_mode") or "navigation").strip().lower() or "navigation"
+ lh_categories = get_list(cfg, "lighthouse_categories", sep=",")
+ lh_iterations = get_int(cfg, "lighthouse_iterations", 3) or 3
+ lh_out = lighthouse_work_dir()
+ try:
+ exit_code = lighthouse_main(
+ url=lh_url,
+ strategy=lh_strategy,
+ iterations=lh_iterations,
+ output_dir=lh_out,
+ use_database=use_database,
+ mode=lh_mode,
+ categories=lh_categories if lh_categories else None,
+ )
+ finally:
+ cleanup_lighthouse_work_dir(lh_out)
+ if exit_code != 0:
+ sys.exit(exit_code)
+ print("[Lighthouse] Done.", flush=True)
+
+
+def _run_report(cfg: dict, use_database: bool) -> None:
+ from ..reporting.builder import run_simple_report
+
+ max_fetch = get_int(cfg, "max_fetch_for_edges", 300)
+ same_domain = get_bool(cfg, "same_domain_only", True)
+ max_nodes = get_int(cfg, "max_nodes_plot", 400)
+ site_name = (cfg.get("site_name") or "").strip()
+ report_title = (cfg.get("report_title") or "").strip()
+ start_url = require_start_url(cfg, for_step="report")
+ run_security_scan_flag = get_bool(cfg, "run_security_scan", True)
+ security_scan_active = get_bool(cfg, "security_scan_active", False)
+ security_max_urls_probe = get_int(cfg, "security_max_urls_probe", 20) or 20
+ print("[Report] Starting...", flush=True)
+ out = run_simple_report(
+ max_fetch_for_edges=max_fetch,
+ concurrency=6,
+ timeout=8,
+ same_domain_only=same_domain,
+ max_nodes_plot=max_nodes or 300,
+ site_name=site_name or None,
+ report_title=report_title or None,
+ start_url=start_url,
+ run_security_scan_flag=run_security_scan_flag,
+ security_scan_active=security_scan_active,
+ security_max_urls_probe=security_max_urls_probe,
+ lighthouse_summary_path=None,
+ use_database=use_database,
+ config=cfg,
+ )
+ print("[Report] Done.", flush=True)
+ print(f"Report written: {out}")
+
+ if should_enrich_keywords_after_report(cfg) and google_db_has_gsc():
+ print("[Keywords] Post-report enrichment (GSC data found)...", flush=True)
+ from ..integrations.google.keyword_enrich import run_enrichment
+
+ try:
+ run_enrichment(cfg)
+ print("[Keywords] Post-report enrichment done.", flush=True)
+ except Exception as e:
+ print(f"Warning: post-report keyword enrichment failed: {e}", file=sys.stderr)
+
+
+def _run_plot(cfg: dict, use_database: bool) -> None:
+ from ..tools.plot import run_plot as do_plot
+
+ print("[Plot] Starting...", flush=True)
+ e = do_plot(
+ same_domain_only=get_bool(cfg, "same_domain_only", True),
+ max_fetch_for_edges=get_int(cfg, "max_fetch_for_edges", 500),
+ concurrency=8,
+ timeout=10,
+ polite_delay=0.15,
+ use_database=use_database,
+ )
+ print("[Plot] Done.", flush=True)
+ print(f"Plot data: {e}")
diff --git a/src/website_profiling/commands/warnings_cmd.py b/src/website_profiling/commands/warnings_cmd.py
new file mode 100644
index 00000000..e6698b4c
--- /dev/null
+++ b/src/website_profiling/commands/warnings_cmd.py
@@ -0,0 +1,19 @@
+"""CLI: warnings command."""
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+from .config_resolve import PathFn
+
+
+def run(cfg: dict, cwd: str, path: PathFn, args: argparse.Namespace) -> None:
+ print("WebsiteProfiling: warning mapper only", flush=True)
+ from ..tools.warnings import main as warning_mapper_main
+
+ wm_input = cfg.get("warning_mapper_input", "").strip()
+ wm_type = (cfg.get("warning_mapper_input_type") or "lighthouse").lower()
+ if wm_input and not os.path.isabs(wm_input):
+ wm_input = os.path.join(cwd, wm_input)
+ sys.exit(warning_mapper_main(input_path=wm_input or None, input_type=wm_type))
diff --git a/src/website_profiling/common.py b/src/website_profiling/common.py
index 36720e4c..1528e369 100644
--- a/src/website_profiling/common.py
+++ b/src/website_profiling/common.py
@@ -3,6 +3,7 @@
"""
import json
import os
+import warnings
from urllib.parse import urljoin, urldefrag, urlparse
import urllib.robotparser as robotparser
import ast
@@ -230,7 +231,7 @@ def parse_content_text(soup, raw_html: str, excerpt_max_chars: int = 0) -> dict:
"""Extract content analytics: word count, reading level, content-to-HTML ratio, top keywords.
excerpt_max_chars: when > 0, strip script/style from body and store a whitespace-normalized
- plain-text excerpt (truncated) in ``content_excerpt`` for ML / UI.
+ plain-text excerpt (truncated) in ``content_excerpt`` for analysis / AI / UI.
"""
import re
from collections import Counter
@@ -342,6 +343,12 @@ def _meta_content(attrs: dict) -> str:
# Module-level cache for Wappalyzer instance (avoids reloading technologies file per page).
_wappalyzer_instance = None
+_wappalyzer_disabled = False
+
+
+def _is_wappalyzer_regex_warning(msg: str) -> bool:
+ lower = msg.lower()
+ return "compiling regex" in lower and "unbalanced parenthesis" in lower
def detect_tech_wappalyzer(
@@ -355,19 +362,27 @@ def detect_tech_wappalyzer(
Detect technologies using python-Wappalyzer from existing HTML and headers.
Returns JSON list of tech names. On any failure, falls back to parse_tech_stack(soup, headers, url).
"""
- global _wappalyzer_instance
+ global _wappalyzer_instance, _wappalyzer_disabled
+ if _wappalyzer_disabled:
+ return parse_tech_stack(soup, headers, url)
try:
from Wappalyzer import Wappalyzer, WebPage
except ImportError:
return parse_tech_stack(soup, headers, url)
try:
- instance = wappalyzer if wappalyzer is not None else _wappalyzer_instance
- if instance is None:
- instance = Wappalyzer.latest()
- if wappalyzer is None:
- _wappalyzer_instance = instance
- webpage = WebPage(url, html=html, headers=headers)
- detected = instance.analyze(webpage)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ instance = wappalyzer if wappalyzer is not None else _wappalyzer_instance
+ if instance is None:
+ instance = Wappalyzer.latest()
+ if wappalyzer is None:
+ _wappalyzer_instance = instance
+ webpage = WebPage(url, html=html, headers=headers)
+ detected = instance.analyze(webpage)
+ if any(_is_wappalyzer_regex_warning(str(w.message)) for w in caught):
+ _wappalyzer_disabled = True
+ _wappalyzer_instance = None
+ return parse_tech_stack(soup, headers, url)
return json.dumps(sorted(detected))
except Exception:
return parse_tech_stack(soup, headers, url)
diff --git a/src/website_profiling/config.py b/src/website_profiling/config.py
index 61835f46..10c09f99 100644
--- a/src/website_profiling/config.py
+++ b/src/website_profiling/config.py
@@ -1,8 +1,9 @@
"""
Parse key=value config files (key = value or key: value, # comments, blank lines).
-Also provides load_config_from_db to read pipeline settings from report.db.
+Also provides load_config_from_db to read pipeline settings from PostgreSQL.
"""
import os
+import sys
def load_config(path: str) -> dict[str, str]:
@@ -62,21 +63,35 @@ def get_list(cfg: dict, key: str, sep: str = ",", default: list[str] | None = No
return [s.strip() for s in str(raw).split(sep) if s.strip()]
-def load_config_from_db(db_path: str) -> dict[str, str]:
+def load_config_from_db() -> dict[str, str]:
"""
- Load pipeline config from the pipeline_config table in report.db.
- Returns a flat {key: value} dict (known keys only; unknown keys are not returned here
- since cli.py consumes the result the same way it consumes load_config()).
- Returns an empty dict if db_path does not exist, the table is missing, or the table is empty.
+ Load pipeline config from the pipeline_config table.
+ Returns a flat {key: value} dict (known keys only).
+ Returns an empty dict if the table is missing or empty.
+ Logs a warning to stderr on connection/query errors so callers can fall back to shadow file.
"""
- if not os.path.isfile(db_path):
+ from .db.storage import get_database_url
+
+ if not (os.environ.get("DATABASE_URL") or "").strip():
+ return {}
+
+ try:
+ get_database_url()
+ except RuntimeError as e:
+ print(f"[Config] {e}", file=sys.stderr)
return {}
+
try:
- from .db import db_session, init_schema # avoid circular at module level
- with db_session(db_path) as conn:
- init_schema(conn)
- from .db.storage import read_pipeline_config
+ from .db import db_session # avoid circular at module level
+ from .db.storage import read_pipeline_config
+
+ with db_session() as conn:
known, _unknown = read_pipeline_config(conn)
return known
- except Exception:
+ except Exception as e:
+ print(
+ f"[Config] Could not load pipeline_config from PostgreSQL ({e}); "
+ "will try shadow file if present.",
+ file=sys.stderr,
+ )
return {}
diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py
index 3ae368a0..cf4a0d01 100644
--- a/src/website_profiling/crawl/crawler.py
+++ b/src/website_profiling/crawl/crawler.py
@@ -91,12 +91,6 @@ def __init__(
self.store_content_excerpt = bool(store_content_excerpt)
self.content_excerpt_max_chars = max(0, int(content_excerpt_max_chars or 0))
self._wappalyzer_instance = None
- if use_wappalyzer:
- try:
- from Wappalyzer import Wappalyzer
- self._wappalyzer_instance = Wappalyzer.latest()
- except Exception:
- pass
self.queue = Queue()
if not _url_matches_exclude(self.start_url, self.exclude_urls):
@@ -357,9 +351,18 @@ def _queue_contains(self, item):
except Exception:
return False
- def crawl(self, show_progress: bool = True):
+ def crawl(
+ self,
+ show_progress: bool = True,
+ stream_crawl_run_id: Optional[int] = None,
+ stream_batch_size: int = 500,
+ ):
start_time = time.time()
futures = []
+ db_writer: Optional[_CrawlDbWriter] = None
+ if stream_crawl_run_id is not None:
+ db_writer = _CrawlDbWriter(stream_crawl_run_id, stream_batch_size)
+ db_writer.start()
pbar = tqdm(
total=None if self.max_pages == float("inf") else int(self.max_pages),
desc="Pages",
@@ -445,6 +448,8 @@ def crawl(self, show_progress: bool = True):
if self.store_outlinks:
res["outlink_targets"] = "[]"
self.results.append(res)
+ if db_writer is not None and res.get("url"):
+ db_writer.enqueue(res)
pbar.update(1)
else:
remaining.append(f)
@@ -455,6 +460,10 @@ def crawl(self, show_progress: bool = True):
break
pbar.close()
+ if db_writer is not None:
+ db_writer.finish()
+ db_writer.join()
+ db_writer.raise_if_failed()
elapsed = time.time() - start_time
df = pd.DataFrame(self.results)
if df.empty:
@@ -518,6 +527,52 @@ def crawl(self, show_progress: bool = True):
return df
+class _CrawlDbWriter(threading.Thread):
+ """Background thread: batch-insert crawl rows via PostgreSQL connection pool."""
+
+ def __init__(self, crawl_run_id: int, batch_size: int = 500) -> None:
+ super().__init__(daemon=True)
+ self.crawl_run_id = crawl_run_id
+ self.batch_size = max(50, batch_size)
+ self._queue: Queue = Queue()
+ self._error: Optional[BaseException] = None
+
+ def enqueue(self, record: dict) -> None:
+ self._queue.put(record)
+
+ def finish(self) -> None:
+ self._queue.put(None)
+
+ def run(self) -> None:
+ from ..db import db_session
+ from ..db.crawl_store import _crawl_rows_from_df, write_crawl_batch
+
+ buffer: list[dict] = []
+ try:
+ while True:
+ item = self._queue.get()
+ if item is None:
+ if buffer:
+ chunk = pd.DataFrame(buffer)
+ with db_session() as conn:
+ rows = _crawl_rows_from_df(chunk, self.crawl_run_id)
+ write_crawl_batch(conn, rows, self.crawl_run_id, commit=True)
+ break
+ buffer.append(item)
+ if len(buffer) >= self.batch_size:
+ chunk = pd.DataFrame(buffer)
+ buffer = []
+ with db_session() as conn:
+ rows = _crawl_rows_from_df(chunk, self.crawl_run_id)
+ write_crawl_batch(conn, rows, self.crawl_run_id, commit=True)
+ except BaseException as e:
+ self._error = e
+
+ def raise_if_failed(self) -> None:
+ if self._error is not None:
+ raise self._error
+
+
def run_crawler(
start_url: str,
max_pages: Optional[int] = None,
@@ -529,14 +584,15 @@ def run_crawler(
polite_delay: float = 0.2,
store_outlinks: bool = True,
output_csv: Optional[str] = "crawl_results.csv",
- output_db: Optional[str] = None,
+ output_db: bool = False,
show_progress: bool = True,
exclude_urls: Optional[list[str]] = None,
preserve_crawl_history: bool = True,
store_content_excerpt: bool = False,
content_excerpt_max_chars: int = 4096,
+ crawl_stream_to_db: bool = False,
) -> pd.DataFrame:
- """Run crawler and optionally save to CSV/JSON or SQLite. Returns DataFrame."""
+ """Run crawler and optionally save to CSV/JSON or PostgreSQL. Returns DataFrame."""
import sys
max_p = max_pages if max_pages is not None else 0
print(f" Crawling {start_url} (max_pages={max_p or 'unlimited'}, concurrency={concurrency})...", flush=True)
@@ -554,38 +610,56 @@ def run_crawler(
store_content_excerpt=store_content_excerpt,
content_excerpt_max_chars=content_excerpt_max_chars,
)
- df = crawler.crawl(show_progress=show_progress)
- if output_db and not df.empty:
+ stream_run_id: Optional[int] = None
+ if output_db:
+ use_stream = crawl_stream_to_db or (max_pages is not None and max_pages > 100)
+ if use_stream:
+ from ..db import backup_db_if_exists, create_crawl_run, db_session, read_historical_data, restore_historical_data
+ from ..db.storage import ensure_crawl_tables_cleared
+
+ historical = {}
+ if not preserve_crawl_history:
+ historical = read_historical_data()
+ backup_path = backup_db_if_exists()
+ if backup_path:
+ print(f" Backed up existing DB to {backup_path}", flush=True)
+ with db_session() as conn:
+ if not preserve_crawl_history:
+ ensure_crawl_tables_cleared(conn)
+ if historical:
+ restore_historical_data(conn, historical)
+ stream_run_id = create_crawl_run(conn, start_url)
+ print(f" Streaming crawl results to DB (run_id={stream_run_id})...", flush=True)
+
+ df = crawler.crawl(
+ show_progress=show_progress,
+ stream_crawl_run_id=stream_run_id,
+ )
+ if output_db and not df.empty and stream_run_id is None:
import sys
print(" Writing crawl results to DB...", flush=True)
- from ..db import backup_db_if_exists, create_crawl_run, db_session, ensure_db_recreated, init_schema, read_historical_data, restore_historical_data, write_crawl
+ from ..db import backup_db_if_exists, create_crawl_run, db_session, read_historical_data, restore_historical_data, write_crawl
+ from ..db.storage import ensure_crawl_tables_cleared
historical = {}
backup_path = None
if not preserve_crawl_history:
- historical = read_historical_data(output_db)
+ historical = read_historical_data()
n_reports = len(historical.get("report_payload", []))
if n_reports:
print(f" Preserving {n_reports} historical report(s) from existing DB...", flush=True)
- backup_path = backup_db_if_exists(output_db)
+ backup_path = backup_db_if_exists()
if backup_path:
print(f" Backed up existing DB to {backup_path}", flush=True)
- ensure_db_recreated(output_db)
- with db_session(output_db) as conn:
- init_schema(conn)
+ with db_session() as conn:
+ if not preserve_crawl_history:
+ ensure_crawl_tables_cleared(conn)
if historical:
restore_historical_data(conn, historical)
- if backup_path:
- from pathlib import Path as _Path
- for p in (backup_path, backup_path + "-journal"):
- try:
- _Path(p).unlink(missing_ok=True)
- except OSError:
- pass
- print(f" Removed temporary backup {backup_path}", flush=True)
- # Always record a crawl_run so edges/nodes can use crawl_run_id (see write_edges / read_edges).
run_id = create_crawl_run(conn, start_url)
write_crawl(conn, df, crawl_run_id=run_id)
print(" Crawl DB write complete.", flush=True)
+ elif output_db and stream_run_id is not None:
+ print(" Crawl streamed to DB during fetch.", flush=True)
elif output_csv and not df.empty:
if output_csv.lower().endswith(".json"):
df.to_json(output_csv, orient="records", indent=2, date_format="iso", default_handler=str)
diff --git a/src/website_profiling/db/__init__.py b/src/website_profiling/db/__init__.py
index c9dad3d4..c8f60a86 100644
--- a/src/website_profiling/db/__init__.py
+++ b/src/website_profiling/db/__init__.py
@@ -1,2 +1,5 @@
-"""SQLite storage layer (re-export from storage)."""
+"""PostgreSQL storage layer (re-export from storage).
+
+Public helpers include :func:`db_session` and :func:`close_db_pool`.
+"""
from .storage import * # noqa: F403
diff --git a/src/website_profiling/db/_common.py b/src/website_profiling/db/_common.py
new file mode 100644
index 00000000..a0a8f008
--- /dev/null
+++ b/src/website_profiling/db/_common.py
@@ -0,0 +1,67 @@
+"""Shared DB helpers (JSON, batch execute, timestamps)."""
+from __future__ import annotations
+
+import json
+import math
+from datetime import datetime, timezone
+from typing import Any
+
+import psycopg
+from psycopg import Connection
+from psycopg.types.json import Json
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _json_val(obj: Any) -> Json:
+ return Json(_sanitize_for_json(obj))
+
+
+def _parse_json_field(val: Any) -> Any:
+ if val is None:
+ return None
+ if isinstance(val, (dict, list)):
+ return val
+ if isinstance(val, str):
+ try:
+ return json.loads(val)
+ except json.JSONDecodeError:
+ return val
+ return val
+
+
+def _sanitize_for_json(obj: Any) -> Any:
+ """Recursively replace NaN/Inf and numpy types so JSON is valid."""
+ if obj is None:
+ return None
+ if isinstance(obj, (bool, str)):
+ return obj
+ if isinstance(obj, int):
+ return int(obj)
+ if isinstance(obj, float):
+ if math.isnan(obj) or math.isinf(obj):
+ return None
+ return obj
+ if isinstance(obj, dict):
+ return {k: _sanitize_for_json(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_sanitize_for_json(v) for v in obj]
+ if hasattr(obj, "item"):
+ try:
+ return _sanitize_for_json(obj.item())
+ except (ValueError, AttributeError):
+ return None
+ if hasattr(obj, "isoformat"):
+ return obj.isoformat()
+ return obj
+
+
+def _executemany(conn: Connection, sql: str, params: list, *, page_size: int = 500) -> None:
+ if not params:
+ return
+ with conn.cursor() as cur:
+ for i in range(0, len(params), page_size):
+ cur.executemany(sql, params[i : i + page_size])
+
+
diff --git a/src/website_profiling/db/config_store.py b/src/website_profiling/db/config_store.py
new file mode 100644
index 00000000..7bcc10cd
--- /dev/null
+++ b/src/website_profiling/db/config_store.py
@@ -0,0 +1,84 @@
+"""Pipeline and LLM config tables."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+import pandas as pd
+from psycopg import Connection
+from urllib.parse import urlparse
+
+from ._common import (
+ _executemany,
+ _json_val,
+ _now_iso,
+ _parse_json_field,
+ _sanitize_for_json,
+)
+from .pool import db_session, get_data_dir, get_database_url
+
+def read_pipeline_config(conn: Connection) -> tuple[dict[str, str], list[dict[str, str]]]:
+ try:
+ cur = conn.execute("SELECT key, value, is_unknown FROM pipeline_config ORDER BY key")
+ rows = cur.fetchall()
+ known: dict[str, str] = {}
+ unknown: list[dict[str, str]] = []
+ for row in rows:
+ k, v = str(row["key"]), str(row["value"])
+ if row["is_unknown"]:
+ unknown.append({"key": k, "value": v})
+ else:
+ known[k] = v
+ return known, unknown
+ except Exception:
+ return {}, []
+
+
+def write_pipeline_config(
+ conn: Connection,
+ entries: dict[str, str],
+ unknown_keys: list[dict[str, str]] | None = None,
+) -> None:
+ now = _now_iso()
+ if unknown_keys is None:
+ unknown_keys = []
+ with conn.transaction():
+ conn.execute("DELETE FROM pipeline_config")
+ for k, v in entries.items():
+ conn.execute(
+ "INSERT INTO pipeline_config (key, value, is_unknown, updated_at) VALUES (%s, %s, false, %s)",
+ (str(k), str(v), now),
+ )
+ for item in unknown_keys:
+ conn.execute(
+ """INSERT INTO pipeline_config (key, value, is_unknown, updated_at)
+ VALUES (%s, %s, true, %s)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, is_unknown = true, updated_at = EXCLUDED.updated_at""",
+ (str(item["key"]), str(item.get("value", "")), now),
+ )
+
+
+def read_llm_config(conn: Connection) -> dict[str, str]:
+ try:
+ cur = conn.execute("SELECT key, value FROM llm_config ORDER BY key")
+ return {str(row["key"]): str(row["value"]) for row in cur.fetchall()}
+ except Exception:
+ return {}
+
+
+def write_llm_config(conn: Connection, entries: dict[str, str], secret_keys: set[str] | None = None) -> None:
+ now = _now_iso()
+ secret_keys = secret_keys or set()
+ with conn.transaction():
+ conn.execute("DELETE FROM llm_config")
+ for k, v in entries.items():
+ conn.execute(
+ "INSERT INTO llm_config (key, value, is_secret, updated_at) VALUES (%s, %s, %s, %s)",
+ (str(k), str(v), k in secret_keys, now),
+ )
+
+
diff --git a/src/website_profiling/db/crawl_store.py b/src/website_profiling/db/crawl_store.py
new file mode 100644
index 00000000..ea85f13b
--- /dev/null
+++ b/src/website_profiling/db/crawl_store.py
@@ -0,0 +1,288 @@
+"""Crawl runs, results, edges, and nodes."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+import pandas as pd
+from psycopg import Connection
+from urllib.parse import urlparse
+
+from ._common import (
+ _executemany,
+ _json_val,
+ _now_iso,
+ _parse_json_field,
+ _sanitize_for_json,
+)
+from .pool import db_session, get_data_dir, get_database_url
+
+_BOOL_COLS = ("viewport_present", "noindex", "has_schema")
+_CRAWL_BATCH_SIZE = 1000
+
+def create_crawl_run(conn: Connection, start_url: Optional[str] = None) -> int:
+ cur = conn.execute(
+ "INSERT INTO crawl_runs (created_at, start_url) VALUES (%s, %s) RETURNING id",
+ (_now_iso(), start_url),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ return int(row["id"])
+
+
+def get_latest_crawl_run_id(conn: Connection) -> Optional[int]:
+ try:
+ cur = conn.execute("SELECT id FROM crawl_runs ORDER BY id DESC LIMIT 1")
+ row = cur.fetchone()
+ return int(row["id"]) if row else None
+ except Exception:
+ return None
+
+
+def get_crawl_run_info(conn: Connection, run_id: int) -> Optional[dict[str, Any]]:
+ try:
+ cur = conn.execute("SELECT created_at, start_url FROM crawl_runs WHERE id = %s", (run_id,))
+ row = cur.fetchone()
+ if row is None:
+ return None
+ return {"created_at": row["created_at"], "start_url": row["start_url"]}
+ except Exception:
+ return None
+
+
+def _df_row_to_crawl_json(row: pd.Series) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ for col in row.index:
+ if col in ("url", "crawl_run_id"):
+ continue
+ val = row[col]
+ if pd.isna(val):
+ out[col] = None
+ elif hasattr(val, "item"):
+ out[col] = _sanitize_for_json(val.item())
+ else:
+ out[col] = _sanitize_for_json(val)
+ return out
+
+
+def _extract_hostname(url: str) -> str:
+ try:
+ host = urlparse(str(url or "")).hostname
+ return host.lower() if host else ""
+ except Exception:
+ return ""
+
+
+def _canonical_domain_from_report(conn: Connection, report_data: dict[str, Any]) -> str:
+ run_id = report_data.get("crawl_run_id")
+ start_url = ""
+ if run_id is not None:
+ info = get_crawl_run_info(conn, int(run_id))
+ if info:
+ start_url = str(info.get("start_url") or "")
+ top_pages = report_data.get("top_pages") or []
+ fallback_url = ""
+ if top_pages and isinstance(top_pages[0], dict):
+ fallback_url = str(top_pages[0].get("url") or "")
+ if not fallback_url:
+ links = report_data.get("links") or []
+ if links and isinstance(links[0], dict):
+ fallback_url = str(links[0].get("url") or "")
+ return _extract_hostname(start_url) or _extract_hostname(fallback_url)
+
+
+_CRAWL_INSERT_SQL = """INSERT INTO crawl_results (crawl_run_id, url, status, title, data)
+VALUES (%s, %s, %s, %s, %s)
+ON CONFLICT (crawl_run_id, url) DO UPDATE SET
+ status = EXCLUDED.status,
+ title = EXCLUDED.title,
+ data = EXCLUDED.data"""
+
+
+def _crawl_rows_from_df(df: pd.DataFrame, crawl_run_id: int) -> list[tuple]:
+ rows: list[tuple] = []
+ if df.empty or "url" not in df.columns:
+ return rows
+ data_cols = [c for c in df.columns if c not in ("url", "crawl_run_id")]
+ for rec in df.to_dict(orient="records"):
+ url = str(rec.get("url", "")).rstrip("/")
+ if not url:
+ continue
+ payload = {c: _sanitize_for_json(rec[c]) if not pd.isna(rec.get(c)) else None for c in data_cols}
+ status = str(rec.get("status") or "") if "status" in rec else None
+ title = str(rec.get("title") or "") if "title" in rec else None
+ rows.append((crawl_run_id, url, status, title, _json_val(payload)))
+ return rows
+
+
+def write_crawl_batch(
+ conn: Connection,
+ rows: list[tuple],
+ crawl_run_id: int,
+ *,
+ commit: bool = True,
+) -> None:
+ """Insert a batch of crawl rows (each tuple: run_id, url, status, title, data Json)."""
+ if not rows:
+ return
+ _executemany(conn, _CRAWL_INSERT_SQL, rows, page_size=_CRAWL_BATCH_SIZE)
+ if commit:
+ conn.commit()
+
+
+def write_crawl(conn: Connection, df: pd.DataFrame, crawl_run_id: Optional[int] = None) -> None:
+ if df.empty:
+ if crawl_run_id is None:
+ try:
+ conn.execute("DELETE FROM crawl_results")
+ conn.commit()
+ except Exception:
+ pass
+ return
+
+ df = df.copy()
+ if "url" in df.columns:
+ df["url"] = df["url"].astype(str).str.rstrip("/")
+
+ with conn.transaction():
+ if crawl_run_id is not None:
+ conn.execute("DELETE FROM crawl_results WHERE crawl_run_id = %s", (crawl_run_id,))
+ target_run_id = crawl_run_id
+ else:
+ conn.execute("DELETE FROM crawl_results")
+ rid = get_latest_crawl_run_id(conn)
+ if rid is None:
+ cur = conn.execute(
+ "INSERT INTO crawl_runs (created_at, start_url) VALUES (%s, %s) RETURNING id",
+ (_now_iso(), None),
+ )
+ rid = int(cur.fetchone()["id"])
+ target_run_id = rid
+
+ rows = _crawl_rows_from_df(df, target_run_id)
+ if rows:
+ _executemany(conn, _CRAWL_INSERT_SQL, rows, page_size=_CRAWL_BATCH_SIZE)
+
+
+def read_crawl(conn: Connection, run_id: Optional[int] = None) -> pd.DataFrame:
+ try:
+ if run_id is None:
+ run_id = get_latest_crawl_run_id(conn)
+ if run_id is None:
+ cur = conn.execute("SELECT url, data FROM crawl_results")
+ else:
+ cur = conn.execute(
+ "SELECT url, data FROM crawl_results WHERE crawl_run_id = %s",
+ (run_id,),
+ )
+ rows = cur.fetchall()
+ if not rows:
+ return pd.DataFrame()
+ records = []
+ for row in rows:
+ rec = {"url": row["url"]}
+ data = _parse_json_field(row["data"]) or {}
+ if isinstance(data, dict):
+ rec.update(data)
+ records.append(rec)
+ df = pd.DataFrame(records)
+ for c in _BOOL_COLS:
+ if c in df.columns:
+ df[c] = df[c].astype(bool)
+ return df
+ except Exception:
+ return pd.DataFrame()
+
+
+def write_edges(conn: Connection, edges: list[tuple[str, str]], crawl_run_id: Optional[int] = None) -> None:
+ if crawl_run_id is None:
+ conn.execute("DELETE FROM edges")
+ if edges:
+ rid = get_latest_crawl_run_id(conn)
+ if rid is not None:
+ _executemany(
+ conn,
+ "INSERT INTO edges (crawl_run_id, from_url, to_url) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+ [(rid, a.rstrip("/"), b.rstrip("/")) for a, b in edges],
+ )
+ conn.commit()
+ return
+ conn.execute("DELETE FROM edges WHERE crawl_run_id = %s", (crawl_run_id,))
+ if edges:
+ _executemany(
+ conn,
+ "INSERT INTO edges (crawl_run_id, from_url, to_url) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+ [(crawl_run_id, a.rstrip("/"), b.rstrip("/")) for a, b in edges],
+ )
+ conn.commit()
+
+
+def read_edges(conn: Connection, run_id: Optional[int] = None) -> list[tuple[str, str]]:
+ try:
+ if run_id is None:
+ run_id = get_latest_crawl_run_id(conn)
+ if run_id is None:
+ return []
+ cur = conn.execute(
+ "SELECT from_url, to_url FROM edges WHERE crawl_run_id = %s",
+ (run_id,),
+ )
+ return [(row["from_url"], row["to_url"]) for row in cur.fetchall()]
+ except Exception:
+ return []
+
+
+def write_nodes(conn: Connection, df: pd.DataFrame, crawl_run_id: Optional[int] = None) -> None:
+ if df.empty:
+ if crawl_run_id is None:
+ conn.execute("DELETE FROM nodes")
+ else:
+ conn.execute("DELETE FROM nodes WHERE crawl_run_id = %s", (crawl_run_id,))
+ conn.commit()
+ return
+ ndf = df.copy()
+ if "index" in ndf.columns and "url" not in ndf.columns:
+ ndf = ndf.rename(columns={"index": "url"})
+ if "url" not in ndf.columns or "count" not in ndf.columns:
+ return
+ if crawl_run_id is None:
+ rid = get_latest_crawl_run_id(conn)
+ if rid is None:
+ conn.execute("DELETE FROM nodes")
+ conn.commit()
+ return
+ crawl_run_id = rid
+ conn.execute("DELETE FROM nodes WHERE crawl_run_id = %s", (crawl_run_id,))
+ _executemany(
+ conn,
+ "INSERT INTO nodes (crawl_run_id, url, count) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+ [
+ (crawl_run_id, str(r["url"]), int(r["count"]))
+ for _, r in ndf.iterrows()
+ ],
+ )
+ conn.commit()
+
+
+def read_nodes(conn: Connection, run_id: Optional[int] = None) -> pd.DataFrame:
+ try:
+ if run_id is None:
+ run_id = get_latest_crawl_run_id(conn)
+ if run_id is None:
+ return pd.DataFrame(columns=["url", "count"])
+ cur = conn.execute(
+ "SELECT url, count FROM nodes WHERE crawl_run_id = %s",
+ (run_id,),
+ )
+ rows = cur.fetchall()
+ if not rows:
+ return pd.DataFrame(columns=["url", "count"])
+ return pd.DataFrame(rows)
+ except Exception:
+ return pd.DataFrame(columns=["url", "count"])
+
+
diff --git a/src/website_profiling/db/historical.py b/src/website_profiling/db/historical.py
new file mode 100644
index 00000000..58abe487
--- /dev/null
+++ b/src/website_profiling/db/historical.py
@@ -0,0 +1,205 @@
+"""Historical data preservation and backups."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+import pandas as pd
+from psycopg import Connection
+from urllib.parse import urlparse
+
+from ._common import (
+ _executemany,
+ _json_val,
+ _now_iso,
+ _parse_json_field,
+ _sanitize_for_json,
+)
+from .pool import db_session, get_data_dir, get_database_url
+
+def backup_db_if_exists(skip_in_ci: bool = True) -> Optional[str]:
+ """Run pg_dump to DATA_DIR/backups/ and return the dump path, or None."""
+ if skip_in_ci and (
+ os.environ.get("GITHUB_ACTIONS") == "true" or os.environ.get("CI") == "true"
+ ):
+ return None
+ data_dir = Path(get_data_dir())
+ backup_dir = data_dir / "backups"
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ suffix = time.strftime("%Y%m%d-%H%M%S")
+ out_path = backup_dir / f"website_profiling-{suffix}.dump"
+ try:
+ subprocess.run(
+ [
+ "pg_dump",
+ "-Fc",
+ "-f",
+ str(out_path),
+ get_database_url(),
+ ],
+ check=True,
+ capture_output=True,
+ timeout=300,
+ )
+ return str(out_path)
+ except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
+ return None
+
+
+def read_historical_data() -> dict[str, list]:
+ """Read historical tables before a crawl overwrite (excludes crawl_results/edges/nodes)."""
+ tables = [
+ "report_payload",
+ "lighthouse_summary",
+ "lighthouse_runs",
+ "lighthouse_page_summaries",
+ "lh_audits",
+ "lh_audit_items",
+ "google_data",
+ "keyword_data",
+ "keyword_history",
+ "keyword_suggest_cache",
+ "crawl_runs",
+ ]
+ result: dict[str, list] = {t: [] for t in tables}
+ try:
+ with db_session() as conn:
+ for table in tables:
+ try:
+ with conn.cursor() as cur:
+ cur.execute(f"SELECT * FROM {table}")
+ result[table] = [dict(row) for row in cur.fetchall()]
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return result
+
+
+def restore_historical_data(conn: Connection, data: dict[str, list]) -> None:
+ """Insert previously-read historical rows (preserves explicit ids where provided)."""
+
+ def _bulk(
+ sql: str,
+ rows: list[dict],
+ keys: list[str],
+ transform: Any | None = None,
+ ) -> None:
+ if not rows:
+ return
+ params: list[tuple] = []
+ for row in rows:
+ vals = []
+ for k in keys:
+ v = row.get(k)
+ if transform and k in transform:
+ v = transform[k](v)
+ vals.append(v)
+ params.append(tuple(vals))
+ try:
+ _executemany(conn, sql, params, page_size=500)
+ except Exception:
+ for p in params:
+ try:
+ conn.execute(sql, p)
+ except Exception:
+ pass
+
+ json_t = lambda v: _json_val(_parse_json_field(v))
+
+ _bulk(
+ """INSERT INTO report_payload (id, generated_at, site_name, canonical_domain, data)
+ VALUES (%s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("report_payload", []),
+ ["id", "generated_at", "site_name", "canonical_domain", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO lighthouse_summary (id, created_at, data)
+ VALUES (%s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("lighthouse_summary", []),
+ ["id", "created_at", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO lighthouse_runs (id, created_at, url, strategy, run_index, data)
+ VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("lighthouse_runs", []),
+ ["id", "created_at", "url", "strategy", "run_index", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO lighthouse_page_summaries (url, created_at, data)
+ VALUES (%s, %s, %s) ON CONFLICT (url) DO NOTHING""",
+ data.get("lighthouse_page_summaries", []),
+ ["url", "created_at", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO lh_audits (id, run_id, audit_id, category_id, score, score_display_mode,
+ title, description, display_value, numeric_value, help_text, details_type,
+ details_headings, details_meta)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (id) DO NOTHING""",
+ data.get("lh_audits", []),
+ [
+ "id", "run_id", "audit_id", "category_id", "score", "score_display_mode",
+ "title", "description", "display_value", "numeric_value", "help_text",
+ "details_type", "details_headings", "details_meta",
+ ],
+ {"details_headings": json_t, "details_meta": json_t},
+ )
+ _bulk(
+ """INSERT INTO lh_audit_items (id, audit_row_id, item_index, row_data)
+ VALUES (%s, %s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("lh_audit_items", []),
+ ["id", "audit_row_id", "item_index", "row_data"],
+ {"row_data": json_t},
+ )
+ _bulk(
+ """INSERT INTO google_data (id, fetched_at, data)
+ VALUES (%s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("google_data", []),
+ ["id", "fetched_at", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO keyword_data (id, fetched_at, data)
+ VALUES (%s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("keyword_data", []),
+ ["id", "fetched_at", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO keyword_history
+ (id, keyword, fetched_at, position, clicks, impressions, ctr)
+ VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("keyword_history", []),
+ ["id", "keyword", "fetched_at", "position", "clicks", "impressions", "ctr"],
+ )
+ _bulk(
+ """INSERT INTO keyword_suggest_cache (cache_key, fetched_at, data)
+ VALUES (%s, %s, %s) ON CONFLICT (cache_key) DO NOTHING""",
+ data.get("keyword_suggest_cache", []),
+ ["cache_key", "fetched_at", "data"],
+ {"data": json_t},
+ )
+ _bulk(
+ """INSERT INTO crawl_runs (id, created_at, start_url)
+ VALUES (%s, %s, %s) ON CONFLICT (id) DO NOTHING""",
+ data.get("crawl_runs", []),
+ ["id", "created_at", "start_url"],
+ )
+
+ conn.commit()
+
+
+def ensure_crawl_tables_cleared(conn: Connection) -> None:
+ """Clear crawl-scoped tables before a non-append crawl (preserves reports, Google, etc.)."""
+ conn.execute("TRUNCATE crawl_results, edges, nodes RESTART IDENTITY CASCADE")
+ conn.commit()
+
+
diff --git a/src/website_profiling/db/lighthouse_store.py b/src/website_profiling/db/lighthouse_store.py
new file mode 100644
index 00000000..641e4411
--- /dev/null
+++ b/src/website_profiling/db/lighthouse_store.py
@@ -0,0 +1,235 @@
+"""Lighthouse runs, audits, and summaries."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+import pandas as pd
+from psycopg import Connection
+from urllib.parse import urlparse
+
+from ._common import (
+ _executemany,
+ _json_val,
+ _now_iso,
+ _parse_json_field,
+ _sanitize_for_json,
+)
+from .pool import db_session, get_data_dir, get_database_url
+
+def write_lighthouse_summary(conn: Connection, summary: dict[str, Any]) -> None:
+ conn.execute(
+ "INSERT INTO lighthouse_summary (created_at, data) VALUES (%s, %s)",
+ (_now_iso(), _json_val(summary)),
+ )
+ conn.commit()
+
+
+def read_lighthouse_summary(conn: Connection) -> Optional[dict[str, Any]]:
+ try:
+ cur = conn.execute("SELECT data FROM lighthouse_summary ORDER BY id DESC LIMIT 1")
+ row = cur.fetchone()
+ if row is None:
+ return None
+ data = _parse_json_field(row["data"])
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def write_lighthouse_run(
+ conn: Connection,
+ url: str,
+ strategy: str,
+ run_index: int,
+ data: dict[str, Any],
+) -> int:
+ cur = conn.execute(
+ """INSERT INTO lighthouse_runs (created_at, url, strategy, run_index, data)
+ VALUES (%s, %s, %s, %s, %s) RETURNING id""",
+ (_now_iso(), url, strategy, run_index, _json_val(data)),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ return int(row["id"])
+
+
+def write_lh_audits_from_run(conn: Connection, run_id: int, lhr_data: dict[str, Any]) -> None:
+ from ..lighthouse.schema import lhr_to_audit_rows
+
+ audit_rows, item_refs = lhr_to_audit_rows(lhr_data)
+ if not audit_rows:
+ return
+
+ def _headings_val(row: dict) -> Any:
+ h = row.get("details_headings")
+ if isinstance(h, str) and h:
+ return _json_val(json.loads(h))
+ return _json_val(h)
+
+ def _meta_val(row: dict) -> Any:
+ m = row.get("details_meta")
+ if isinstance(m, str) and m:
+ return _json_val(json.loads(m))
+ return _json_val(m)
+
+ audit_params = [
+ (
+ run_id,
+ row["audit_id"],
+ row["category_id"],
+ row["score"],
+ row["score_display_mode"],
+ row["title"],
+ row["description"],
+ row["display_value"],
+ row["numeric_value"],
+ row["help_text"],
+ row["details_type"],
+ _headings_val(row),
+ _meta_val(row),
+ )
+ for row in audit_rows
+ ]
+
+ with conn.transaction():
+ _executemany(
+ conn,
+ """INSERT INTO lh_audits (run_id, audit_id, category_id, score, score_display_mode,
+ title, description, display_value, numeric_value, help_text, details_type,
+ details_headings, details_meta)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
+ audit_params,
+ page_size=200,
+ )
+ cur = conn.execute(
+ "SELECT id FROM lh_audits WHERE run_id = %s ORDER BY id",
+ (run_id,),
+ )
+ id_map = [int(r["id"]) for r in cur.fetchall()]
+ if len(id_map) != len(audit_rows):
+ return
+ item_params = [
+ (id_map[audit_idx], item_index, _json_val(rd))
+ for audit_idx, item_index, rd in item_refs
+ ]
+ if item_params:
+ _executemany(
+ conn,
+ "INSERT INTO lh_audit_items (audit_row_id, item_index, row_data) VALUES (%s, %s, %s)",
+ item_params,
+ page_size=500,
+ )
+
+
+def read_lh_runs_by_url(conn: Connection) -> dict[str, list[int]]:
+ out: dict[str, list[int]] = {}
+ try:
+ cur = conn.execute("SELECT id, url FROM lighthouse_runs ORDER BY id")
+ for row in cur.fetchall():
+ u = str(row["url"]).strip().rstrip("/")
+ out.setdefault(u, []).append(int(row["id"]))
+ except Exception:
+ pass
+ return out
+
+
+def read_lighthouse_run_json(conn: Connection, run_id: int) -> Optional[dict[str, Any]]:
+ try:
+ cur = conn.execute("SELECT data FROM lighthouse_runs WHERE id = %s", (run_id,))
+ row = cur.fetchone()
+ if row is None:
+ return None
+ data = _parse_json_field(row["data"])
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def read_latest_lighthouse_run_json(conn: Connection) -> Optional[dict[str, Any]]:
+ """Return full Lighthouse JSON for the most recent lighthouse_runs row."""
+ try:
+ cur = conn.execute("SELECT data FROM lighthouse_runs ORDER BY id DESC LIMIT 1")
+ row = cur.fetchone()
+ if row is None:
+ return None
+ data = _parse_json_field(row["data"])
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def read_lh_audits_with_items(conn: Connection, run_id: int) -> list[dict[str, Any]]:
+ out: list[dict[str, Any]] = []
+ try:
+ cur = conn.execute("SELECT * FROM lh_audits WHERE run_id = %s ORDER BY id", (run_id,))
+ for d in cur.fetchall():
+ aid = d.get("audit_id") or ""
+ headings = _parse_json_field(d.get("details_headings"))
+ meta = _parse_json_field(d.get("details_meta")) or {}
+ if not isinstance(meta, dict):
+ meta = {}
+
+ cur_items = conn.execute(
+ "SELECT row_data FROM lh_audit_items WHERE audit_row_id = %s ORDER BY item_index",
+ (d["id"],),
+ )
+ items: list[Any] = []
+ for item_row in cur_items.fetchall():
+ rd = _parse_json_field(item_row["row_data"])
+ items.append(rd if isinstance(rd, dict) else {})
+
+ details: dict[str, Any] = dict(meta)
+ if d.get("details_type"):
+ details["type"] = d["details_type"]
+ if headings is not None:
+ details["headings"] = headings
+ if items:
+ details["items"] = items
+
+ audit_obj: dict[str, Any] = {
+ "id": aid,
+ "category_id": d.get("category_id"),
+ "title": d.get("title"),
+ "description": d.get("description"),
+ "score": d.get("score"),
+ "scoreDisplayMode": d.get("score_display_mode"),
+ "displayValue": d.get("display_value"),
+ "numericValue": d.get("numeric_value"),
+ "helpText": d.get("help_text"),
+ }
+ if details:
+ audit_obj["details"] = details
+ out.append(audit_obj)
+ except Exception:
+ pass
+ return out
+
+
+def write_lighthouse_page_summary(conn: Connection, url: str, summary: dict[str, Any]) -> None:
+ conn.execute(
+ """INSERT INTO lighthouse_page_summaries (url, created_at, data)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (url) DO UPDATE SET created_at = EXCLUDED.created_at, data = EXCLUDED.data""",
+ (url, _now_iso(), _json_val(summary)),
+ )
+ conn.commit()
+
+
+def read_lighthouse_page_summaries(conn: Connection) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ try:
+ cur = conn.execute("SELECT url, data FROM lighthouse_page_summaries")
+ for row in cur.fetchall():
+ data = _parse_json_field(row["data"])
+ if isinstance(data, dict):
+ out[str(row["url"])] = data
+ except Exception:
+ pass
+ return out
+
+
diff --git a/src/website_profiling/db/llm_cache_store.py b/src/website_profiling/db/llm_cache_store.py
new file mode 100644
index 00000000..1f6f261f
--- /dev/null
+++ b/src/website_profiling/db/llm_cache_store.py
@@ -0,0 +1,73 @@
+"""LLM response cache."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+import pandas as pd
+from psycopg import Connection
+from urllib.parse import urlparse
+
+from ._common import (
+ _executemany,
+ _json_val,
+ _now_iso,
+ _parse_json_field,
+ _sanitize_for_json,
+)
+from .pool import db_session, get_data_dir, get_database_url
+
+def read_llm_cache(conn: Connection, cache_key: str) -> Optional[str]:
+ try:
+ cur = conn.execute("SELECT response_json FROM llm_cache WHERE cache_key = %s", (cache_key,))
+ row = cur.fetchone()
+ if not row:
+ return None
+ val = row["response_json"]
+ return json.dumps(val) if isinstance(val, (dict, list)) else str(val)
+ except Exception:
+ return None
+
+
+def write_llm_cache(conn: Connection, cache_key: str, response_json: str) -> None:
+ now = _now_iso()
+ try:
+ payload = json.loads(response_json)
+ except json.JSONDecodeError:
+ payload = response_json
+ conn.execute(
+ """INSERT INTO llm_cache (cache_key, response_json, created_at)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (cache_key) DO UPDATE SET response_json = EXCLUDED.response_json, created_at = EXCLUDED.created_at""",
+ (cache_key, _json_val(payload), now),
+ )
+ conn.commit()
+
+
+def read_llm_cache_batch(conn: Connection, cache_keys: list[str]) -> dict[str, dict[str, Any]]:
+ if not cache_keys:
+ return {}
+ out: dict[str, dict[str, Any]] = {}
+ try:
+ cur = conn.execute(
+ "SELECT cache_key, response_json FROM llm_cache WHERE cache_key = ANY(%s)",
+ (cache_keys,),
+ )
+ for row in cur.fetchall():
+ key = str(row["cache_key"])
+ val = row["response_json"]
+ if isinstance(val, dict):
+ out[key] = val
+ elif isinstance(val, str):
+ try:
+ out[key] = json.loads(val)
+ except json.JSONDecodeError:
+ pass
+ except Exception:
+ pass
+ return out
+
diff --git a/src/website_profiling/db/pool.py b/src/website_profiling/db/pool.py
new file mode 100644
index 00000000..abae8af6
--- /dev/null
+++ b/src/website_profiling/db/pool.py
@@ -0,0 +1,83 @@
+"""PostgreSQL connection pool and session."""
+from __future__ import annotations
+
+import atexit
+import os
+from contextlib import contextmanager
+from typing import Iterator
+
+from psycopg import Connection
+from psycopg.rows import dict_row
+from psycopg_pool import ConnectionPool
+
+_pool: ConnectionPool | None = None
+_shutdown_registered = False
+
+
+def _env_int(name: str, default: int) -> int:
+ raw = (os.environ.get(name) or "").strip()
+ if not raw:
+ return default
+ try:
+ return max(1, int(raw))
+ except ValueError:
+ return default
+
+
+def get_database_url() -> str:
+ url = (os.environ.get("DATABASE_URL") or "").strip()
+ if not url:
+ raise RuntimeError(
+ "DATABASE_URL is required. Example: postgres://user:pass@localhost:5432/website_profiling"
+ )
+ # Prefer fast failure when DB is unreachable (tests, local dev).
+ # psycopg accepts libpq params in the DSN/querystring.
+ if "connect_timeout=" not in url:
+ url = f"{url}{'&' if '?' in url else '?'}connect_timeout=3"
+ return url
+
+
+def get_data_dir() -> str:
+ return (os.environ.get("DATA_DIR") or os.getcwd()).strip() or os.getcwd()
+
+
+def close_db_pool() -> None:
+ """Close the process-wide connection pool (idempotent, safe to call multiple times)."""
+ global _pool
+ if _pool is not None:
+ _pool.close()
+ _pool = None
+
+
+def _register_pool_shutdown() -> None:
+ global _shutdown_registered
+ if not _shutdown_registered:
+ atexit.register(close_db_pool)
+ _shutdown_registered = True
+
+
+def _get_pool() -> ConnectionPool:
+ global _pool
+ if _pool is None:
+ _pool = ConnectionPool(
+ conninfo=get_database_url(),
+ min_size=_env_int("DB_POOL_MIN", 2),
+ max_size=_env_int("DB_POOL_MAX", 20),
+ open=True,
+ kwargs={"row_factory": dict_row},
+ )
+ _register_pool_shutdown()
+ return _pool
+
+
+@contextmanager
+def db_session() -> Iterator[Connection]:
+ """Yield a PostgreSQL connection from the process pool."""
+ with _get_pool().connection(timeout=5) as conn:
+ yield conn
+
+
+
+
+def init_schema(conn: Connection | None = None) -> None:
+ """No-op at runtime; schema is applied via Alembic migrations."""
diff --git a/src/website_profiling/db/report_store.py b/src/website_profiling/db/report_store.py
new file mode 100644
index 00000000..f1153daa
--- /dev/null
+++ b/src/website_profiling/db/report_store.py
@@ -0,0 +1,59 @@
+"""Report payload read/write."""
+from __future__ import annotations
+
+from typing import Any, Optional
+from urllib.parse import urlparse
+
+from psycopg import Connection
+
+from ._common import _json_val, _now_iso, _parse_json_field
+from .crawl_store import get_crawl_run_info
+
+
+def _extract_hostname(url: str) -> str:
+ try:
+ host = urlparse(str(url or "")).hostname
+ return host.lower() if host else ""
+ except Exception:
+ return ""
+
+
+def _canonical_domain_from_report(conn: Connection, report_data: dict[str, Any]) -> str:
+ run_id = report_data.get("crawl_run_id")
+ start_url = ""
+ if run_id is not None:
+ info = get_crawl_run_info(conn, int(run_id))
+ if info:
+ start_url = str(info.get("start_url") or "")
+ top_pages = report_data.get("top_pages") or []
+ fallback_url = ""
+ if top_pages and isinstance(top_pages[0], dict):
+ fallback_url = str(top_pages[0].get("url") or "")
+ if not fallback_url:
+ links = report_data.get("links") or []
+ if links and isinstance(links[0], dict):
+ fallback_url = str(links[0].get("url") or "")
+ return _extract_hostname(start_url) or _extract_hostname(fallback_url)
+
+
+def write_report_payload(conn: Connection, report_data: dict[str, Any]) -> None:
+ site_name = str(report_data.get("site_name") or "")
+ canonical_domain = _canonical_domain_from_report(conn, report_data)
+ conn.execute(
+ """INSERT INTO report_payload (generated_at, site_name, canonical_domain, data)
+ VALUES (%s, %s, %s, %s)""",
+ (_now_iso(), site_name, canonical_domain, _json_val(report_data)),
+ )
+ conn.commit()
+
+
+def read_report_payload(conn: Connection) -> Optional[dict[str, Any]]:
+ try:
+ cur = conn.execute("SELECT data FROM report_payload ORDER BY id DESC LIMIT 1")
+ row = cur.fetchone()
+ if row is None:
+ return None
+ data = _parse_json_field(row["data"])
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
diff --git a/src/website_profiling/db/storage.py b/src/website_profiling/db/storage.py
index 171fe69b..60de0f43 100644
--- a/src/website_profiling/db/storage.py
+++ b/src/website_profiling/db/storage.py
@@ -1,899 +1,83 @@
"""
-SQLite data layer for WebsiteProfiling: single DB for crawl, edges, nodes, lighthouse, report payload.
+PostgreSQL data layer for WebsiteProfiling: crawl, edges, nodes, lighthouse, report payload.
-All DB access should go through :func:`db_session` so one connection at a time per database path
-(process-wide lock). That serializes writers like a single-slot queue and avoids lock/readonly issues
-on slow or synced volumes.
-"""
-import json
-import math
-import os
-import shutil
-import sqlite3
-import threading
-import time
-from contextlib import contextmanager
-from pathlib import Path
-from typing import Any, Iterator, Optional
-
-import pandas as pd
-
-
-_db_path_locks: dict[str, threading.Lock] = {}
-_db_path_locks_guard = threading.Lock()
-
-
-def _normalize_db_path(db_path: str) -> str:
- return os.path.normcase(os.path.abspath(db_path))
-
-
-def _lock_for_db_path(db_path: str) -> threading.Lock:
- key = _normalize_db_path(db_path)
- with _db_path_locks_guard:
- if key not in _db_path_locks:
- _db_path_locks[key] = threading.Lock()
- return _db_path_locks[key]
-
-
-def _open_sqlite(db_path: str) -> sqlite3.Connection:
- """Open SQLite without taking the process-wide DB lock (internal; use :func:`db_session`)."""
- Path(db_path).parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(db_path, timeout=30.0)
- conn.row_factory = sqlite3.Row
- return conn
-
-
-@contextmanager
-def db_session(db_path: str) -> Iterator[sqlite3.Connection]:
- """Serialize access to ``db_path``: one connection at a time, then close (mutex per absolute path)."""
- lock = _lock_for_db_path(db_path)
- lock.acquire()
- try:
- conn = _open_sqlite(db_path)
- try:
- yield conn
- finally:
- conn.close()
- finally:
- lock.release()
-
-
-def _sanitize_for_json(obj: Any) -> Any:
- """Recursively replace NaN/Inf and numpy types so JSON is valid (no literal NaN)."""
- if obj is None:
- return None
- if isinstance(obj, (bool, str)):
- return obj
- if isinstance(obj, int):
- return int(obj)
- if isinstance(obj, float):
- if math.isnan(obj) or math.isinf(obj):
- return None
- return obj
- if isinstance(obj, dict):
- return {k: _sanitize_for_json(v) for k, v in obj.items()}
- if isinstance(obj, list):
- return [_sanitize_for_json(v) for v in obj]
- if hasattr(obj, "item"): # numpy scalar
- try:
- return _sanitize_for_json(obj.item())
- except (ValueError, AttributeError):
- return None
- if hasattr(obj, "isoformat"): # datetime
- return obj.isoformat()
- return obj
-
-
-def backup_db_if_exists(db_path: str, skip_in_ci: bool = True) -> Optional[str]:
- """Copy db_path to a timestamped backup file and return the backup path, or None.
-
- Returns None without creating a backup when:
- - skip_in_ci is True and the process is running in GitHub Actions or a generic CI environment.
- - The db_path file does not exist.
- """
- if skip_in_ci and (
- os.environ.get("GITHUB_ACTIONS") == "true" or os.environ.get("CI") == "true"
- ):
- return None
- p = Path(db_path)
- if not p.exists() or not p.is_file():
- return None
- suffix = time.strftime("%Y%m%d-%H%M%S")
- backup = p.parent / f"{p.name}.backup-{suffix}"
- shutil.copy2(str(p), str(backup))
- journal = Path(str(p) + "-journal")
- if journal.exists():
- try:
- shutil.copy2(str(journal), str(backup) + "-journal")
- except OSError:
- pass
- return str(backup)
-
-
-def read_historical_data(db_path: str) -> dict[str, list]:
- """Read rows from historical tables in an existing DB before it is overwritten.
-
- Returns a dict mapping table name -> list of row dicts.
- Tables captured: report_payload, lighthouse_*, google_data, keyword_data,
- keyword_history, keyword_suggest_cache, crawl_runs.
- crawl_results / edges / nodes are intentionally excluded (they belong to the new crawl).
- Returns empty lists for all tables when the DB file does not exist.
- """
- tables = [
- "report_payload",
- "lighthouse_summary",
- "lighthouse_runs",
- "lighthouse_page_summaries",
- "lh_audits",
- "lh_audit_items",
- "google_data",
- "keyword_data",
- "keyword_history",
- "keyword_suggest_cache",
- "crawl_runs",
- ]
- result: dict[str, list] = {t: [] for t in tables}
- p = Path(db_path)
- if not p.exists() or not p.is_file():
- return result
- try:
- with db_session(db_path) as conn:
- for table in tables:
- try:
- cur = conn.execute(f"SELECT * FROM {table}")
- result[table] = [dict(row) for row in cur.fetchall()]
- except Exception:
- pass
- except Exception:
- pass
- return result
-
-
-def restore_historical_data(conn: sqlite3.Connection, data: dict[str, list]) -> None:
- """Insert previously-read historical rows back into a freshly-created DB.
-
- Uses INSERT OR IGNORE with explicit ids so rows are idempotent and the
- original row ordering (and thus UI report list ordering) is preserved.
- Silently skips any row that fails to insert.
- """
- for row in data.get("report_payload", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO report_payload (id, generated_at, data) VALUES (?, ?, ?)",
- (row.get("id"), row.get("generated_at"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("lighthouse_summary", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO lighthouse_summary (id, created_at, data) VALUES (?, ?, ?)",
- (row.get("id"), row.get("created_at"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("lighthouse_runs", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO lighthouse_runs (id, created_at, url, strategy, run_index, data) VALUES (?, ?, ?, ?, ?, ?)",
- (row.get("id"), row.get("created_at"), row.get("url"), row.get("strategy"), row.get("run_index"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("lighthouse_page_summaries", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO lighthouse_page_summaries (url, created_at, data) VALUES (?, ?, ?)",
- (row.get("url"), row.get("created_at"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("lh_audits", []):
- try:
- conn.execute(
- """INSERT OR IGNORE INTO lh_audits (id, run_id, audit_id, category_id, score, score_display_mode,
- title, description, display_value, numeric_value, help_text, details_type, details_headings, details_meta)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- row.get("id"),
- row.get("run_id"),
- row.get("audit_id"),
- row.get("category_id"),
- row.get("score"),
- row.get("score_display_mode"),
- row.get("title"),
- row.get("description"),
- row.get("display_value"),
- row.get("numeric_value"),
- row.get("help_text"),
- row.get("details_type"),
- row.get("details_headings"),
- row.get("details_meta"),
- ),
- )
- except Exception:
- pass
-
- for row in data.get("lh_audit_items", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO lh_audit_items (id, audit_row_id, item_index, row_data) VALUES (?, ?, ?, ?)",
- (row.get("id"), row.get("audit_row_id"), row.get("item_index"), row.get("row_data")),
- )
- except Exception:
- pass
-
- for row in data.get("google_data", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO google_data (id, fetched_at, data) VALUES (?, ?, ?)",
- (row.get("id"), row.get("fetched_at"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("keyword_data", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO keyword_data (id, fetched_at, data) VALUES (?, ?, ?)",
- (row.get("id"), row.get("fetched_at"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("keyword_history", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO keyword_history "
- "(id, keyword, fetched_at, position, clicks, impressions, ctr) "
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
- (
- row.get("id"),
- row.get("keyword"),
- row.get("fetched_at"),
- row.get("position"),
- row.get("clicks"),
- row.get("impressions"),
- row.get("ctr"),
- ),
- )
- except Exception:
- pass
-
- for row in data.get("keyword_suggest_cache", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO keyword_suggest_cache (cache_key, fetched_at, data) VALUES (?, ?, ?)",
- (row.get("cache_key"), row.get("fetched_at"), row.get("data")),
- )
- except Exception:
- pass
-
- for row in data.get("crawl_runs", []):
- try:
- conn.execute(
- "INSERT OR IGNORE INTO crawl_runs (id, created_at, start_url) VALUES (?, ?, ?)",
- (row.get("id"), row.get("created_at"), row.get("start_url")),
- )
- except Exception:
- pass
-
- conn.commit()
-
-
-def ensure_db_recreated(db_path: str) -> None:
- """Delete existing DB file (and journal) so the next :func:`db_session` creates a fresh DB."""
- for p in (db_path, db_path + "-journal"):
- if Path(p).exists():
- try:
- Path(p).unlink()
- except OSError:
- pass
-
-
-def get_connection(db_path: str) -> sqlite3.Connection:
- """Open SQLite (no lock). Prefer :func:`db_session` so access is serialized per DB path."""
- return _open_sqlite(db_path)
-
-
-def init_schema(conn: sqlite3.Connection) -> None:
- """Create tables if they do not exist. crawl_results is created by write_crawl from DataFrame."""
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS crawl_runs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- created_at TEXT NOT NULL,
- start_url TEXT
- );
-
- CREATE TABLE IF NOT EXISTS edges (
- crawl_run_id INTEGER NOT NULL,
- from_url TEXT NOT NULL,
- to_url TEXT NOT NULL,
- PRIMARY KEY (crawl_run_id, from_url, to_url)
- );
-
- CREATE TABLE IF NOT EXISTS nodes (
- crawl_run_id INTEGER NOT NULL,
- url TEXT NOT NULL,
- count INTEGER NOT NULL,
- PRIMARY KEY (crawl_run_id, url)
- );
-
- CREATE TABLE IF NOT EXISTS lighthouse_summary (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- created_at TEXT NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS lighthouse_runs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- created_at TEXT NOT NULL,
- url TEXT NOT NULL,
- strategy TEXT NOT NULL,
- run_index INTEGER NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS lighthouse_page_summaries (
- url TEXT PRIMARY KEY,
- created_at TEXT NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS report_payload (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- generated_at TEXT NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS google_data (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS keyword_data (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS keyword_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- keyword TEXT NOT NULL,
- fetched_at TEXT NOT NULL,
- position REAL,
- clicks INTEGER,
- impressions INTEGER,
- ctr REAL
- );
- CREATE INDEX IF NOT EXISTS idx_kw_history_keyword ON keyword_history(keyword);
-
- CREATE TABLE IF NOT EXISTS keyword_suggest_cache (
- cache_key TEXT PRIMARY KEY,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS lh_audits (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- run_id INTEGER NOT NULL,
- audit_id TEXT NOT NULL,
- category_id TEXT,
- score REAL,
- score_display_mode TEXT,
- title TEXT,
- description TEXT,
- display_value TEXT,
- numeric_value REAL,
- help_text TEXT,
- details_type TEXT,
- details_headings TEXT,
- details_meta TEXT,
- FOREIGN KEY (run_id) REFERENCES lighthouse_runs(id)
- );
- CREATE INDEX IF NOT EXISTS idx_lh_audits_run_id ON lh_audits(run_id);
- CREATE INDEX IF NOT EXISTS idx_lh_audits_run_audit ON lh_audits(run_id, audit_id);
- CREATE INDEX IF NOT EXISTS idx_lh_audits_audit_id ON lh_audits(audit_id);
-
- CREATE TABLE IF NOT EXISTS lh_audit_items (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- audit_row_id INTEGER NOT NULL,
- item_index INTEGER NOT NULL,
- row_data TEXT NOT NULL,
- FOREIGN KEY (audit_row_id) REFERENCES lh_audits(id)
- );
- CREATE INDEX IF NOT EXISTS idx_lh_audit_items_audit_row ON lh_audit_items(audit_row_id);
-
- CREATE TABLE IF NOT EXISTS pipeline_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL,
- is_unknown INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL
- );
- """)
- conn.commit()
+All DB access should go through :func:`db_session`. Schema is managed by Alembic (``alembic upgrade head``).
+Requires ``DATABASE_URL`` in the environment.
-
-def read_pipeline_config(conn: sqlite3.Connection) -> tuple[dict[str, str], list[dict[str, str]]]:
- """
- Return (known_entries, unknown_entries) from the pipeline_config table.
- known_entries: {key: value} for is_unknown=0 rows.
- unknown_entries: [{key, value}] for is_unknown=1 rows.
- Returns ({}, []) if the table is empty or an error occurs.
- """
- try:
- cur = conn.execute("SELECT key, value, is_unknown FROM pipeline_config ORDER BY key")
- rows = cur.fetchall()
- known: dict[str, str] = {}
- unknown: list[dict[str, str]] = []
- for row in rows:
- k, v, is_unk = str(row["key"]), str(row["value"]), int(row["is_unknown"] or 0)
- if is_unk:
- unknown.append({"key": k, "value": v})
- else:
- known[k] = v
- return known, unknown
- except Exception:
- return {}, []
-
-
-def write_pipeline_config(
- conn: sqlite3.Connection,
- entries: dict[str, str],
- unknown_keys: list[dict[str, str]] | None = None,
-) -> None:
- """
- Atomically replace all pipeline_config rows with the provided entries.
- entries: {key: value} — known schema keys (is_unknown=0).
- unknown_keys: [{key, value}] — preserved verbatim (is_unknown=1).
- """
- now = time.strftime("%Y-%m-%d %H:%M:%S")
- if unknown_keys is None:
- unknown_keys = []
- conn.execute("BEGIN")
- try:
- conn.execute("DELETE FROM pipeline_config")
- for k, v in entries.items():
- conn.execute(
- "INSERT INTO pipeline_config (key, value, is_unknown, updated_at) VALUES (?, ?, 0, ?)",
- (str(k), str(v), now),
- )
- for item in unknown_keys:
- conn.execute(
- "INSERT OR REPLACE INTO pipeline_config (key, value, is_unknown, updated_at) VALUES (?, ?, 1, ?)",
- (str(item["key"]), str(item.get("value", "")), now),
- )
- conn.execute("COMMIT")
- except Exception:
- conn.execute("ROLLBACK")
- raise
-
-
-def _crawl_results_has_run_id(conn: sqlite3.Connection) -> bool:
- """True if crawl_results exists and includes crawl_run_id (append-by-run crawls)."""
- try:
- cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='crawl_results'")
- if cur.fetchone() is None:
- return False
- cur = conn.execute("PRAGMA table_info(crawl_results)")
- return any(row[1] == "crawl_run_id" for row in cur.fetchall())
- except Exception:
- return False
-
-
-def create_crawl_run(conn: sqlite3.Connection, start_url: Optional[str] = None) -> int:
- """Insert a new crawl run and return its id."""
- conn.execute(
- "INSERT INTO crawl_runs (created_at, start_url) VALUES (?, ?)",
- (time.strftime("%Y-%m-%d %H:%M:%S"), start_url),
- )
- conn.commit()
- cur = conn.execute("SELECT last_insert_rowid()")
- return int(cur.fetchone()[0])
-
-
-def get_latest_crawl_run_id(conn: sqlite3.Connection) -> Optional[int]:
- """Return the latest crawl run id, or None if no runs."""
- try:
- cur = conn.execute("SELECT id FROM crawl_runs ORDER BY id DESC LIMIT 1")
- row = cur.fetchone()
- return int(row[0]) if row else None
- except Exception:
- return None
-
-
-def get_crawl_run_info(conn: sqlite3.Connection, run_id: int) -> Optional[dict[str, Any]]:
- """Return dict with created_at, start_url for the given run_id, or None."""
- try:
- cur = conn.execute("SELECT created_at, start_url FROM crawl_runs WHERE id = ?", (run_id,))
- row = cur.fetchone()
- if row is None:
- return None
- return {"created_at": row[0], "start_url": row[1]}
- except Exception:
- return None
-
-
-def _ensure_crawl_table_from_df(conn: sqlite3.Connection, df: pd.DataFrame) -> None:
- """Recreate crawl_results table to match DataFrame columns (for varying crawler output)."""
- conn.execute("DROP TABLE IF EXISTS crawl_results")
- conn.commit()
- df.to_sql("crawl_results", conn, index=False, if_exists="replace")
- conn.commit()
-
-
-def write_crawl(conn: sqlite3.Connection, df: pd.DataFrame, crawl_run_id: Optional[int] = None) -> None:
- """Write crawl results. If crawl_run_id is set, append rows for that run; else replace table (legacy)."""
- if df.empty:
- if crawl_run_id is None:
- init_schema(conn)
- try:
- conn.execute("DELETE FROM crawl_results")
- except Exception:
- pass
- conn.commit()
- return
- df = df.copy()
- if "url" in df.columns:
- df["url"] = df["url"].astype(str).str.rstrip("/")
- for col in df.columns:
- if df[col].dtype == bool:
- df[col] = df[col].astype(int)
-
- if crawl_run_id is not None:
- df["crawl_run_id"] = crawl_run_id
- try:
- cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='crawl_results'")
- table_exists = cur.fetchone() is not None
- if not table_exists or not _crawl_results_has_run_id(conn):
- if table_exists:
- conn.execute("DROP TABLE crawl_results")
- conn.commit()
- cols = ["crawl_run_id"] + [c for c in df.columns if c != "crawl_run_id"]
- df[cols].to_sql("crawl_results", conn, index=False, if_exists="replace")
- else:
- df.to_sql("crawl_results", conn, index=False, if_exists="append", method="multi")
- finally:
- df.drop(columns=["crawl_run_id"], inplace=True, errors="ignore")
- conn.commit()
- return
- df.to_sql("crawl_results", conn, index=False, if_exists="replace")
- conn.commit()
-
-
-def read_crawl(conn: sqlite3.Connection, run_id: Optional[int] = None) -> pd.DataFrame:
- """Read crawl_results into a DataFrame. If run_id is None, use latest crawl run."""
- try:
- if run_id is None:
- run_id = get_latest_crawl_run_id(conn)
- if run_id is None:
- df = pd.read_sql("SELECT * FROM crawl_results", conn)
- elif _crawl_results_has_run_id(conn):
- df = pd.read_sql("SELECT * FROM crawl_results WHERE crawl_run_id = ?", conn, params=(run_id,))
- else:
- df = pd.read_sql("SELECT * FROM crawl_results", conn)
- else:
- if _crawl_results_has_run_id(conn):
- df = pd.read_sql("SELECT * FROM crawl_results WHERE crawl_run_id = ?", conn, params=(run_id,))
- else:
- df = pd.read_sql("SELECT * FROM crawl_results", conn)
- except Exception:
- return pd.DataFrame()
- if df.empty:
- return df
- if "crawl_run_id" in df.columns:
- df = df.drop(columns=["crawl_run_id"], errors="ignore")
- bool_cols = [
- "viewport_present", "noindex", "has_schema",
- ]
- for c in bool_cols:
- if c in df.columns:
- df[c] = df[c].astype(bool)
- return df
-
-
-def write_edges(conn: sqlite3.Connection, edges: list[tuple[str, str]], crawl_run_id: Optional[int] = None) -> None:
- """Write edges. If crawl_run_id is set, insert for that run; else replace edges for the latest crawl run."""
- if crawl_run_id is None:
- conn.execute("DELETE FROM edges")
- if edges:
- rid = get_latest_crawl_run_id(conn)
- if rid is not None:
- conn.executemany(
- "INSERT INTO edges (crawl_run_id, from_url, to_url) VALUES (?, ?, ?)",
- [(rid, a.rstrip("/"), b.rstrip("/")) for a, b in edges],
- )
- conn.commit()
- return
- conn.execute("DELETE FROM edges WHERE crawl_run_id = ?", (crawl_run_id,))
- if edges:
- conn.executemany(
- "INSERT INTO edges (crawl_run_id, from_url, to_url) VALUES (?, ?, ?)",
- [(crawl_run_id, a.rstrip("/"), b.rstrip("/")) for a, b in edges],
- )
- conn.commit()
-
-
-def read_edges(conn: sqlite3.Connection, run_id: Optional[int] = None) -> list[tuple[str, str]]:
- """Read edges. If run_id is None, use latest crawl run."""
- try:
- if run_id is None:
- run_id = get_latest_crawl_run_id(conn)
- if run_id is None:
- return []
- cur = conn.execute("SELECT from_url, to_url FROM edges WHERE crawl_run_id = ?", (run_id,))
- return [tuple(row) for row in cur.fetchall()]
- except Exception:
- return []
-
-
-def write_nodes(conn: sqlite3.Connection, df: pd.DataFrame, crawl_run_id: Optional[int] = None) -> None:
- """Write nodes. If crawl_run_id is set, insert for that run; else replace (legacy)."""
- if df.empty:
- if crawl_run_id is None:
- conn.execute("DELETE FROM nodes")
- else:
- conn.execute("DELETE FROM nodes WHERE crawl_run_id = ?", (crawl_run_id,))
- conn.commit()
- return
- ndf = df.copy()
- if "index" in ndf.columns and "url" not in ndf.columns:
- ndf = ndf.rename(columns={"index": "url"})
- if "url" not in ndf.columns or "count" not in ndf.columns:
- return
- if crawl_run_id is None:
- rid = get_latest_crawl_run_id(conn)
- if rid is None:
- conn.execute("DELETE FROM nodes")
- conn.commit()
- return
- conn.execute("DELETE FROM nodes WHERE crawl_run_id = ?", (rid,))
- ndf["crawl_run_id"] = rid
- ndf[["crawl_run_id", "url", "count"]].to_sql("nodes", conn, index=False, if_exists="append", method="multi")
- conn.commit()
- return
- conn.execute("DELETE FROM nodes WHERE crawl_run_id = ?", (crawl_run_id,))
- ndf["crawl_run_id"] = crawl_run_id
- ndf[["crawl_run_id", "url", "count"]].to_sql("nodes", conn, index=False, if_exists="append", method="multi")
- conn.commit()
-
-
-def read_nodes(conn: sqlite3.Connection, run_id: Optional[int] = None) -> pd.DataFrame:
- """Read nodes. If run_id is None, use latest crawl run."""
- try:
- if run_id is None:
- run_id = get_latest_crawl_run_id(conn)
- if run_id is None:
- return pd.DataFrame(columns=["url", "count"])
- return pd.read_sql("SELECT url, count FROM nodes WHERE crawl_run_id = ?", conn, params=(run_id,))
- except Exception:
- return pd.DataFrame(columns=["url", "count"])
-
-
-def write_lighthouse_summary(conn: sqlite3.Connection, summary: dict[str, Any]) -> None:
- """Append a lighthouse summary row (JSON in data column)."""
- conn.execute(
- "INSERT INTO lighthouse_summary (created_at, data) VALUES (?, ?)",
- (time.strftime("%Y-%m-%d %H:%M:%S"), json.dumps(_sanitize_for_json(summary), default=str)),
- )
- conn.commit()
-
-
-def read_lighthouse_summary(conn: sqlite3.Connection) -> Optional[dict[str, Any]]:
- """Return the latest lighthouse summary dict, or None."""
- try:
- cur = conn.execute(
- "SELECT data FROM lighthouse_summary ORDER BY id DESC LIMIT 1"
- )
- row = cur.fetchone()
- if row is None:
- return None
- return json.loads(row[0])
- except Exception:
- return None
-
-
-def write_lighthouse_run(
- conn: sqlite3.Connection,
- url: str,
- strategy: str,
- run_index: int,
- data: dict[str, Any],
-) -> int:
- """Append one raw Lighthouse run report (full JSON) to lighthouse_runs. Returns new row id."""
- conn.execute(
- "INSERT INTO lighthouse_runs (created_at, url, strategy, run_index, data) VALUES (?, ?, ?, ?, ?)",
- (time.strftime("%Y-%m-%d %H:%M:%S"), url, strategy, run_index, json.dumps(_sanitize_for_json(data), default=str)),
- )
- conn.commit()
- cur = conn.execute("SELECT last_insert_rowid()")
- return int(cur.fetchone()[0])
-
-
-def write_lh_audits_from_run(conn: sqlite3.Connection, run_id: int, lhr_data: dict[str, Any]) -> None:
- """Parse LHR and insert lh_audits + lh_audit_items for the given lighthouse_runs.id."""
- from ..lighthouse.schema import lhr_to_audit_rows
-
- audit_rows, item_refs = lhr_to_audit_rows(lhr_data)
- id_map: list[int] = []
- for row in audit_rows:
- conn.execute(
- """INSERT INTO lh_audits (run_id, audit_id, category_id, score, score_display_mode,
- title, description, display_value, numeric_value, help_text, details_type, details_headings, details_meta)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
- (
- run_id,
- row["audit_id"],
- row["category_id"],
- row["score"],
- row["score_display_mode"],
- row["title"],
- row["description"],
- row["display_value"],
- row["numeric_value"],
- row["help_text"],
- row["details_type"],
- row["details_headings"],
- row["details_meta"],
- ),
- )
- id_map.append(int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]))
- for audit_idx, item_index, rd in item_refs:
- audit_row_id = id_map[audit_idx]
- conn.execute(
- "INSERT INTO lh_audit_items (audit_row_id, item_index, row_data) VALUES (?,?,?)",
- (audit_row_id, item_index, json.dumps(_sanitize_for_json(rd), default=str)),
- )
- conn.commit()
-
-
-def read_lh_runs_by_url(conn: sqlite3.Connection) -> dict[str, list[int]]:
- """Map url -> ordered list of lighthouse_runs.id (ascending by id)."""
- out: dict[str, list[int]] = {}
- try:
- cur = conn.execute("SELECT id, url FROM lighthouse_runs ORDER BY id")
- for row in cur.fetchall():
- u = str(row[1]).strip().rstrip("/")
- out.setdefault(u, []).append(int(row[0]))
- except Exception:
- pass
- return out
-
-
-def read_lighthouse_run_json(conn: sqlite3.Connection, run_id: int) -> Optional[dict[str, Any]]:
- """Return parsed LHR JSON for a lighthouse_runs row, or None."""
- try:
- cur = conn.execute("SELECT data FROM lighthouse_runs WHERE id = ?", (run_id,))
- row = cur.fetchone()
- if row is None:
- return None
- return json.loads(row[0])
- except Exception:
- return None
-
-
-def read_lh_audits_with_items(conn: sqlite3.Connection, run_id: int) -> list[dict[str, Any]]:
- """Return audits in Lighthouse-like shape: id, title, score, details.items, etc."""
- out: list[dict[str, Any]] = []
- try:
- cur = conn.execute(
- "SELECT * FROM lh_audits WHERE run_id = ? ORDER BY id",
- (run_id,),
- )
- for row in cur.fetchall():
- d = dict(row)
- aid = d.get("audit_id") or ""
- headings = None
- if d.get("details_headings"):
- try:
- headings = json.loads(d["details_headings"])
- except (TypeError, json.JSONDecodeError):
- headings = None
- meta: dict[str, Any] = {}
- if d.get("details_meta"):
- try:
- raw_meta = json.loads(d["details_meta"])
- if isinstance(raw_meta, dict):
- meta = raw_meta
- except (TypeError, json.JSONDecodeError):
- meta = {}
-
- cur_items = conn.execute(
- "SELECT row_data FROM lh_audit_items WHERE audit_row_id = ? ORDER BY item_index",
- (d["id"],),
- )
- items: list[Any] = []
- for (rd,) in cur_items.fetchall():
- try:
- items.append(json.loads(rd))
- except (TypeError, json.JSONDecodeError):
- items.append({})
-
- details: dict[str, Any] = dict(meta)
- if d.get("details_type"):
- details["type"] = d["details_type"]
- if headings is not None:
- details["headings"] = headings
- if items:
- details["items"] = items
-
- audit_obj: dict[str, Any] = {
- "id": aid,
- "category_id": d.get("category_id"),
- "title": d.get("title"),
- "description": d.get("description"),
- "score": d.get("score"),
- "scoreDisplayMode": d.get("score_display_mode"),
- "displayValue": d.get("display_value"),
- "numericValue": d.get("numeric_value"),
- "helpText": d.get("help_text"),
- }
- if details:
- audit_obj["details"] = details
- out.append(audit_obj)
- except Exception:
- pass
- return out
-
-
-def write_lighthouse_page_summary(
- conn: sqlite3.Connection,
- url: str,
- summary: dict[str, Any],
-) -> None:
- """Write or replace Lighthouse summary for a single URL (latest run wins)."""
- conn.execute(
- """INSERT OR REPLACE INTO lighthouse_page_summaries (url, created_at, data)
- VALUES (?, ?, ?)""",
- (
- url,
- time.strftime("%Y-%m-%d %H:%M:%S"),
- json.dumps(_sanitize_for_json(summary), default=str),
- ),
- )
- conn.commit()
-
-
-def read_lighthouse_page_summaries(conn: sqlite3.Connection) -> dict[str, Any]:
- """Return dict mapping url -> summary dict for all per-URL Lighthouse summaries."""
- out: dict[str, Any] = {}
- try:
- cur = conn.execute(
- "SELECT url, data FROM lighthouse_page_summaries"
- )
- for row in cur.fetchall():
- try:
- out[str(row[0])] = json.loads(row[1])
- except (TypeError, json.JSONDecodeError):
- continue
- except Exception:
- pass
- return out
-
-
-def write_report_payload(conn: sqlite3.Connection, report_data: dict[str, Any]) -> None:
- """Insert the report payload JSON (used by frontend). NaN/Inf sanitized so JSON is valid."""
- conn.execute(
- "INSERT INTO report_payload (generated_at, data) VALUES (?, ?)",
- (time.strftime("%Y-%m-%d %H:%M:%S"), json.dumps(_sanitize_for_json(report_data), default=str)),
- )
- conn.commit()
-
-
-def read_report_payload(conn: sqlite3.Connection) -> Optional[dict[str, Any]]:
- """Return the latest report payload dict, or None."""
- try:
- cur = conn.execute(
- "SELECT data FROM report_payload ORDER BY id DESC LIMIT 1"
- )
- row = cur.fetchone()
- if row is None:
- return None
- return json.loads(row[0])
- except Exception:
- return None
+Implementation is split across ``db.*_store`` modules; this module re-exports the public API.
+"""
+from __future__ import annotations
+
+from ._common import _parse_json_field, _sanitize_for_json
+from .config_store import read_llm_config, read_pipeline_config, write_llm_config, write_pipeline_config
+from .crawl_store import (
+ create_crawl_run,
+ get_crawl_run_info,
+ get_latest_crawl_run_id,
+ read_crawl,
+ read_edges,
+ read_nodes,
+ write_crawl,
+ write_crawl_batch,
+ write_edges,
+ write_nodes,
+)
+from .historical import backup_db_if_exists, ensure_crawl_tables_cleared, read_historical_data, restore_historical_data
+from .lighthouse_store import (
+ read_latest_lighthouse_run_json,
+ read_lh_audits_with_items,
+ read_lh_runs_by_url,
+ read_lighthouse_page_summaries,
+ read_lighthouse_run_json,
+ read_lighthouse_summary,
+ write_lh_audits_from_run,
+ write_lighthouse_page_summary,
+ write_lighthouse_run,
+ write_lighthouse_summary,
+)
+from .llm_cache_store import read_llm_cache, read_llm_cache_batch, write_llm_cache
+from .pool import close_db_pool, db_session, get_data_dir, get_database_url, init_schema
+from .report_store import read_report_payload, write_report_payload
+
+__all__ = [
+ "_parse_json_field",
+ "_sanitize_for_json",
+ "backup_db_if_exists",
+ "close_db_pool",
+ "create_crawl_run",
+ "db_session",
+ "ensure_crawl_tables_cleared",
+ "get_crawl_run_info",
+ "get_data_dir",
+ "get_database_url",
+ "get_latest_crawl_run_id",
+ "init_schema",
+ "read_crawl",
+ "read_edges",
+ "read_historical_data",
+ "read_latest_lighthouse_run_json",
+ "read_lh_audits_with_items",
+ "read_lh_runs_by_url",
+ "read_lighthouse_page_summaries",
+ "read_lighthouse_run_json",
+ "read_lighthouse_summary",
+ "read_llm_cache",
+ "read_llm_cache_batch",
+ "read_llm_config",
+ "read_nodes",
+ "read_pipeline_config",
+ "read_report_payload",
+ "restore_historical_data",
+ "write_crawl",
+ "write_crawl_batch",
+ "write_edges",
+ "write_lh_audits_from_run",
+ "write_lighthouse_page_summary",
+ "write_lighthouse_run",
+ "write_lighthouse_summary",
+ "write_llm_cache",
+ "write_llm_config",
+ "write_nodes",
+ "write_pipeline_config",
+ "write_report_payload",
+]
diff --git a/src/website_profiling/integrations/google/auth.py b/src/website_profiling/integrations/google/auth.py
index 396c7f0c..da6ae20e 100644
--- a/src/website_profiling/integrations/google/auth.py
+++ b/src/website_profiling/integrations/google/auth.py
@@ -5,7 +5,7 @@
Path resolution order:
1. $GOOGLE_SECRETS_PATH env var
- 2. dirname($REPORT_DB_PATH)/.secrets/google.json
+ 2. $DATA_DIR/.secrets/google.json
3. dirname(credentials_path from config) -- falls back to repo root
"""
from __future__ import annotations
@@ -29,10 +29,10 @@ def _resolve_secrets_path(credentials_path: str | None = None) -> str:
if explicit:
return os.path.abspath(explicit)
- # 2. Sibling to REPORT_DB_PATH (Docker volume)
- db_env = os.environ.get("REPORT_DB_PATH", "").strip()
- if db_env:
- return os.path.join(os.path.dirname(os.path.abspath(db_env)), ".secrets", "google.json")
+ # 2. DATA_DIR (Docker volume /data)
+ data_dir = os.environ.get("DATA_DIR", "").strip()
+ if data_dir:
+ return os.path.join(os.path.abspath(data_dir), ".secrets", "google.json")
# 3. Relative to credentials_path config key, or repo root
if credentials_path and credentials_path.strip():
diff --git a/src/website_profiling/integrations/google/fetch.py b/src/website_profiling/integrations/google/fetch.py
index d824a9f1..bb075647 100644
--- a/src/website_profiling/integrations/google/fetch.py
+++ b/src/website_profiling/integrations/google/fetch.py
@@ -1,6 +1,6 @@
"""
Orchestrate GSC + GA4 fetching. Returns a structured google_data dict
-suitable for storage in the google_data SQLite table and merging into report_payload.
+suitable for storage in the google_data table and merging into report_payload.
"""
from __future__ import annotations
@@ -161,9 +161,9 @@ def fetch_google_data(
"fetched_at": datetime.now(timezone.utc).isoformat(),
"date_range": {"start": date_start, "end": date_end},
"gsc": gsc_payload,
- "gsc_full": gsc_data, # full data incl. by_page -- stored in SQLite only
+ "gsc_full": gsc_data, # full data incl. by_page -- stored in google_data only
"ga4": ga4_payload,
- "ga4_full": ga4_data, # full data incl. by_path -- stored in SQLite only
+ "ga4_full": ga4_data, # full data incl. by_path -- stored in google_data only
"url_join": url_join,
"errors": errors,
}
diff --git a/src/website_profiling/integrations/google/keyword_enrich.py b/src/website_profiling/integrations/google/keyword_enrich.py
index 9ffa6115..58728f26 100644
--- a/src/website_profiling/integrations/google/keyword_enrich.py
+++ b/src/website_profiling/integrations/google/keyword_enrich.py
@@ -3,7 +3,7 @@
Merges data from:
- Site crawl keywords (from tools/keywords.py)
- - GSC queries (from google_data SQLite table -- no extra API calls)
+ - GSC queries (from google_data table -- no extra API calls)
- Google Suggest: web + YouTube + question-prefixed
- Datamuse (optional): semantic expansion
- Wikipedia (optional): parent topic
@@ -22,7 +22,6 @@
import json
import re
-import sqlite3
from datetime import datetime, timezone
from typing import Any
@@ -210,7 +209,6 @@ def compute_traffic_potential(
# ── Main enrichment ───────────────────────────────────────────────────────────
def run_enrichment(
- db_path: str,
cfg: dict[str, Any],
) -> dict[str, Any]:
"""
@@ -218,12 +216,10 @@ def run_enrichment(
computes all derived metrics, writes to keyword_data + keyword_history.
Returns the enriched data dict.
"""
- import sqlite3 as _sqlite3
- from ...db.storage import db_session, init_schema
+ from ...db.storage import db_session
from .keyword_store import (
write_keyword_data,
append_keyword_history,
- ensure_tables,
)
from .store import read_latest_google_data
from ..google.suggest import batch_expand
@@ -242,10 +238,7 @@ def run_enrichment(
print(" [Keywords] Running enrichment pipeline...", flush=True)
- with db_session(db_path) as conn:
- init_schema(conn)
- ensure_tables(conn)
-
+ with db_session() as conn:
# 1. Load existing GSC data from google_data table
gsc_queries: dict[str, dict] = {} # normalized_kw -> {position, impressions, clicks, ctr, url}
gsc_by_page: dict[str, dict] = {} # url -> page data
diff --git a/src/website_profiling/integrations/google/keyword_store.py b/src/website_profiling/integrations/google/keyword_store.py
index 1df46785..0451a829 100644
--- a/src/website_profiling/integrations/google/keyword_store.py
+++ b/src/website_profiling/integrations/google/keyword_store.py
@@ -1,77 +1,37 @@
"""
-Read/write keyword_data, keyword_history, and keyword_suggest_cache SQLite tables.
-
-keyword_data: latest enriched keyword snapshot (one JSON blob per run)
-keyword_history: per-keyword time-series rows for position sparklines
-keyword_suggest_cache: cache for Google Suggest responses (TTL-based)
+Read/write keyword_data, keyword_history, and keyword_suggest_cache tables.
"""
from __future__ import annotations
-import json
-import sqlite3
-import time
from datetime import datetime, timezone
from typing import Any
-TABLE_DDL = """
-CREATE TABLE IF NOT EXISTS keyword_data (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS keyword_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- keyword TEXT NOT NULL,
- fetched_at TEXT NOT NULL,
- position REAL,
- clicks INTEGER,
- impressions INTEGER,
- ctr REAL
-);
-CREATE INDEX IF NOT EXISTS idx_kw_history_keyword ON keyword_history(keyword);
-
-CREATE TABLE IF NOT EXISTS keyword_suggest_cache (
- cache_key TEXT PRIMARY KEY,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
-);
-"""
-
+from psycopg import Connection
+from psycopg.types.json import Json
-def ensure_tables(conn: sqlite3.Connection) -> None:
- conn.executescript(TABLE_DDL)
- conn.commit()
+from ...db.storage import _parse_json_field, _sanitize_for_json
-# ── keyword_data ──────────────────────────────────────────────────────────────
-
-def write_keyword_data(conn: sqlite3.Connection, data: dict[str, Any]) -> None:
+def write_keyword_data(conn: Connection, data: dict[str, Any]) -> None:
"""Insert a new keyword_data snapshot."""
- ensure_tables(conn)
fetched_at = data.get("fetched_at") or datetime.now(timezone.utc).isoformat()
conn.execute(
- "INSERT INTO keyword_data (fetched_at, data) VALUES (?, ?)",
- (fetched_at, json.dumps(data, default=str)),
+ "INSERT INTO keyword_data (fetched_at, data) VALUES (%s, %s)",
+ (fetched_at, Json(_sanitize_for_json(data))),
)
conn.commit()
-def read_latest_keyword_data(conn: sqlite3.Connection) -> dict[str, Any] | None:
- """
- Return the latest keyword_data row stripped of full history blobs.
- Rows are capped at 500 for the payload.
- """
- ensure_tables(conn)
+def read_latest_keyword_data(conn: Connection) -> dict[str, Any] | None:
+ """Return the latest keyword_data row stripped of full history blobs."""
try:
- cur = conn.execute(
- "SELECT data FROM keyword_data ORDER BY id DESC LIMIT 1"
- )
+ cur = conn.execute("SELECT data FROM keyword_data ORDER BY id DESC LIMIT 1")
row = cur.fetchone()
if row is None:
return None
- data = json.loads(row[0])
- # Cap rows for payload to avoid bloat
+ data = _parse_json_field(row["data"])
+ if not isinstance(data, dict):
+ return None
if isinstance(data.get("rows"), list) and len(data["rows"]) > 1000:
data["rows"] = data["rows"][:1000]
return data
@@ -79,15 +39,12 @@ def read_latest_keyword_data(conn: sqlite3.Connection) -> dict[str, Any] | None:
return None
-# ── keyword_history ───────────────────────────────────────────────────────────
-
-def append_keyword_history(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
+def append_keyword_history(conn: Connection, rows: list[dict[str, Any]]) -> None:
"""Append per-keyword time-series rows for position tracking."""
- ensure_tables(conn)
fetched_at = datetime.now(timezone.utc).isoformat()
conn.executemany(
- "INSERT INTO keyword_history (keyword, fetched_at, position, clicks, impressions, ctr) "
- "VALUES (?, ?, ?, ?, ?, ?)",
+ """INSERT INTO keyword_history (keyword, fetched_at, position, clicks, impressions, ctr)
+ VALUES (%s, %s, %s, %s, %s, %s)""",
[
(
r.get("keyword", ""),
@@ -105,25 +62,24 @@ def append_keyword_history(conn: sqlite3.Connection, rows: list[dict[str, Any]])
def read_keyword_history(
- conn: sqlite3.Connection,
+ conn: Connection,
keyword: str,
limit: int = 30,
) -> list[dict[str, Any]]:
"""Return time-series rows for a single keyword (for sparklines)."""
- ensure_tables(conn)
try:
cur = conn.execute(
- "SELECT fetched_at, position, clicks, impressions, ctr "
- "FROM keyword_history WHERE keyword = ? ORDER BY id DESC LIMIT ?",
+ """SELECT fetched_at, position, clicks, impressions, ctr
+ FROM keyword_history WHERE keyword = %s ORDER BY id DESC LIMIT %s""",
(keyword, limit),
)
return [
{
- "fetched_at": row[0],
- "position": row[1],
- "clicks": row[2],
- "impressions": row[3],
- "ctr": row[4],
+ "fetched_at": row["fetched_at"],
+ "position": row["position"],
+ "clicks": row["clicks"],
+ "impressions": row["impressions"],
+ "ctr": row["ctr"],
}
for row in cur.fetchall()
]
diff --git a/src/website_profiling/integrations/google/store.py b/src/website_profiling/integrations/google/store.py
index d2c1c740..b4d29122 100644
--- a/src/website_profiling/integrations/google/store.py
+++ b/src/website_profiling/integrations/google/store.py
@@ -1,63 +1,47 @@
"""
-Read/write the google_data SQLite table.
+Read/write the google_data table.
The table stores the latest Google data snapshot (GSC + GA4).
-Data survives report rebuilds because it is in a separate table from report_payload.
"""
from __future__ import annotations
import json
-import sqlite3
import time
from typing import Any, Optional
+from psycopg import Connection
+from psycopg.types.json import Json
-TABLE_DDL = """
-CREATE TABLE IF NOT EXISTS google_data (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
-);
-"""
-
+from ...db.storage import _parse_json_field, _sanitize_for_json
-def ensure_table(conn: sqlite3.Connection) -> None:
- conn.execute(TABLE_DDL)
- conn.commit()
-
-def write_google_data(conn: sqlite3.Connection, data: dict[str, Any]) -> None:
+def write_google_data(conn: Connection, data: dict[str, Any]) -> None:
"""Insert a new google_data row. Older rows are kept (historical)."""
- ensure_table(conn)
fetched_at = data.get("fetched_at") or time.strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
- "INSERT INTO google_data (fetched_at, data) VALUES (?, ?)",
- (fetched_at, json.dumps(data, default=str)),
+ "INSERT INTO google_data (fetched_at, data) VALUES (%s, %s)",
+ (fetched_at, Json(_sanitize_for_json(data))),
)
conn.commit()
-def read_latest_google_data(conn: sqlite3.Connection) -> Optional[dict[str, Any]]:
+def read_latest_google_data(conn: Connection) -> Optional[dict[str, Any]]:
"""
Return the latest google_data row as a dict suitable for report_payload["google"].
- Strips full by_page/by_path from the returned dict (those are only for SQLite lookups).
- Returns None if no data exists.
+ Strips full by_page/by_path from the returned dict (those stay in DB for lookups).
"""
- ensure_table(conn)
try:
- cur = conn.execute(
- "SELECT data FROM google_data ORDER BY id DESC LIMIT 1"
- )
+ cur = conn.execute("SELECT data FROM google_data ORDER BY id DESC LIMIT 1")
row = cur.fetchone()
if row is None:
return None
- data = json.loads(row[0])
- # Return payload-safe subset (no full by_page / by_path blobs)
+ data = _parse_json_field(row["data"])
+ if not isinstance(data, dict):
+ return None
return _to_payload_shape(data)
except Exception:
return None
def _to_payload_shape(data: dict[str, Any]) -> dict[str, Any]:
- """Strip gsc_full/ga4_full keys from the payload -- those stay in SQLite."""
- result = {k: v for k, v in data.items() if k not in ("gsc_full", "ga4_full")}
- return result
+ """Strip gsc_full/ga4_full keys from the payload."""
+ return {k: v for k, v in data.items() if k not in ("gsc_full", "ga4_full")}
diff --git a/src/website_profiling/integrations/google/suggest.py b/src/website_profiling/integrations/google/suggest.py
index f69820b0..5f93c766 100644
--- a/src/website_profiling/integrations/google/suggest.py
+++ b/src/website_profiling/integrations/google/suggest.py
@@ -8,14 +8,13 @@
Also performs question-prefixed expansion (who/what/why/when/where/how/can/should/vs)
to surface People-Also-Ask-style queries without SERP scraping.
-Caches results in keyword_suggest_cache SQLite table (TTL-based).
+Caches results in keyword_suggest_cache table (TTL-based).
Uses ThreadPoolExecutor for concurrency (default 4 workers).
"""
from __future__ import annotations
import json
import random
-import sqlite3
import time
import urllib.parse
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -23,6 +22,10 @@
from typing import Any
import requests
+from psycopg import Connection
+from psycopg.types.json import Json
+
+from ...db.storage import _parse_json_field
SUGGEST_URL = "https://suggestqueries.google.com/complete/search"
USER_AGENT = (
@@ -120,13 +123,13 @@ def batch_expand(
country: str = "us",
sources: tuple[str, ...] = ("web", "youtube", "questions"),
max_workers: int = 4,
- cache_conn: sqlite3.Connection | None = None,
+ cache_conn: Connection | None = None,
cache_ttl_days: int = 7,
) -> dict[str, dict[str, list[str]]]:
"""
Expand a list of seed keywords using Google Suggest.
Returns { seed: { "web": [...], "youtube": [...], "questions": [...] } }
- Uses concurrent requests and SQLite cache.
+ Uses concurrent requests and PostgreSQL cache (keyword_suggest_cache).
"""
result: dict[str, dict[str, list[str]]] = {
seed: {s: [] for s in sources} for seed in seeds
@@ -149,6 +152,7 @@ def batch_expand(
if not tasks_to_fetch:
return result
+ pending_cache: list[tuple[str, str, list[str]]] = []
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_fetch_one, task): task for task in tasks_to_fetch}
for future in as_completed(futures):
@@ -157,73 +161,72 @@ def batch_expand(
if seed in result:
result[seed][source] = suggestions
if cache_conn is not None:
- _write_cache(cache_conn, seed, source, suggestions)
+ pending_cache.append((seed, source, suggestions))
except Exception:
pass
+ if cache_conn is not None and pending_cache:
+ flush_suggest_cache(cache_conn, pending_cache)
+
return result
# ─── Cache helpers ────────────────────────────────────────────────────────────
-_CACHE_DDL = """
-CREATE TABLE IF NOT EXISTS keyword_suggest_cache (
- cache_key TEXT PRIMARY KEY,
- fetched_at TEXT NOT NULL,
- data TEXT NOT NULL
-);
-"""
-
-
-def ensure_cache_table(conn: sqlite3.Connection) -> None:
- conn.execute(_CACHE_DDL)
- conn.commit()
-
def _cache_key(seed: str, source: str) -> str:
return f"{source}:{seed}"
def _read_cache(
- conn: sqlite3.Connection,
+ conn: Connection,
seed: str,
source: str,
ttl_days: int = 7,
) -> list[str] | None:
try:
- ensure_cache_table(conn)
cur = conn.execute(
- "SELECT fetched_at, data FROM keyword_suggest_cache WHERE cache_key = ?",
+ "SELECT fetched_at, data FROM keyword_suggest_cache WHERE cache_key = %s",
(_cache_key(seed, source),),
)
row = cur.fetchone()
if row is None:
return None
- fetched_at = datetime.fromisoformat(row[0].replace("Z", "+00:00"))
+ fetched_raw = row["fetched_at"]
+ if hasattr(fetched_raw, "isoformat"):
+ fetched_at = fetched_raw if fetched_raw.tzinfo else fetched_raw.replace(tzinfo=timezone.utc)
+ else:
+ fetched_at = datetime.fromisoformat(str(fetched_raw).replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - fetched_at).total_seconds() / 86400
if age_days > ttl_days:
return None
- return json.loads(row[1])
+ data = _parse_json_field(row["data"])
+ return data if isinstance(data, list) else json.loads(data) if isinstance(data, str) else None
except Exception:
return None
-def _write_cache(
- conn: sqlite3.Connection,
- seed: str,
- source: str,
- data: list[str],
+def flush_suggest_cache(
+ conn: Connection,
+ entries: list[tuple[str, str, list[str]]],
) -> None:
+ """Bulk-write suggest cache rows (main thread only — safe with one connection)."""
+ if not entries:
+ return
+ now = datetime.now(timezone.utc).isoformat()
+ rows = [
+ (_cache_key(seed, source), now, Json(data))
+ for seed, source, data in entries
+ ]
try:
- ensure_cache_table(conn)
- conn.execute(
- "INSERT OR REPLACE INTO keyword_suggest_cache (cache_key, fetched_at, data) VALUES (?, ?, ?)",
- (
- _cache_key(seed, source),
- datetime.now(timezone.utc).isoformat(),
- json.dumps(data),
- ),
- )
+ with conn.cursor() as cur:
+ for i in range(0, len(rows), 500):
+ cur.executemany(
+ """INSERT INTO keyword_suggest_cache (cache_key, fetched_at, data)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (cache_key) DO UPDATE SET fetched_at = EXCLUDED.fetched_at, data = EXCLUDED.data""",
+ rows[i : i + 500],
+ )
conn.commit()
except Exception:
pass
diff --git a/src/website_profiling/lighthouse/runner.py b/src/website_profiling/lighthouse/runner.py
index 23230b73..defa67e8 100644
--- a/src/website_profiling/lighthouse/runner.py
+++ b/src/website_profiling/lighthouse/runner.py
@@ -1,7 +1,7 @@
"""
Run Lighthouse locally via CLI for a given URL; return machine-readable summary with median metrics.
Writes raw_runs/, summary.json, diagnostics.json, human_summary.txt, and optionally report.html.
-Uses global lighthouse if on PATH, otherwise runs via npx (which will install it automatically).
+Uses global lighthouse if on PATH (or LIGHTHOUSE_PATH), otherwise runs via npx (serialized to avoid cache races).
Requires: Node + npm, Chrome/Chromium.
"""
import json
@@ -11,6 +11,7 @@
import statistics
import subprocess
import sys
+import threading
from datetime import datetime, timezone
from typing import Any
@@ -25,6 +26,9 @@
"Chrome or Chromium is also required for headless mode."
)
+# Serialise npx-on-demand installs — parallel npx runs corrupt /root/.npm/_npx cache in Docker.
+_NPX_LIGHTHOUSE_LOCK = threading.Lock()
+
def _build_report_html_content(summary: dict[str, Any]) -> str:
"""Build report.html content (for DB or file). Returns HTML string."""
@@ -91,6 +95,9 @@ def _url_safe(s: str) -> str:
def _lighthouse_cmd() -> list[str]:
"""Return argv prefix: [resolved lighthouse] or [resolved npx, -y, lighthouse]. Paths from shutil.which (portable)."""
+ explicit = (os.environ.get("LIGHTHOUSE_PATH") or os.environ.get("LIGHTHOUSE_BIN") or "").strip()
+ if explicit and os.path.isfile(explicit) and os.access(explicit, os.X_OK):
+ return [explicit]
lh = shutil.which("lighthouse")
if lh is not None:
return [lh]
@@ -100,9 +107,18 @@ def _lighthouse_cmd() -> list[str]:
raise RuntimeError(_LIGHTHOUSE_INSTALL_MSG)
+def _uses_npx(cmd: list[str]) -> bool:
+ base = os.path.basename(cmd[0]).lower()
+ return base in ("npx", "npx.cmd")
+
+
def is_lighthouse_available() -> bool:
"""Return True if lighthouse or npx is on PATH (so we can run Lighthouse)."""
- return shutil.which("lighthouse") is not None or shutil.which("npx") is not None
+ try:
+ _lighthouse_cmd()
+ return True
+ except RuntimeError:
+ return False
def _preset_for_strategy(strategy: str) -> str:
@@ -150,6 +166,14 @@ def run_lighthouse_once(
if categories:
cmd.append("--only-categories=" + ",".join(categories))
try:
+ if _uses_npx(base):
+ with _NPX_LIGHTHOUSE_LOCK:
+ return subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
return subprocess.run(
cmd,
capture_output=True,
@@ -416,72 +440,86 @@ def run_lighthouse_on_pages(
strategy: str = "mobile",
iterations: int = 1,
output_dir: str = ".",
- db_path: str | None = None,
mode: str = "navigation",
categories: str | list[str] | None = None,
+ concurrency: int = 2,
) -> None:
"""
- Run Lighthouse audit on each URL and store per-URL summary in DB.
+ Run Lighthouse audit on each URL and store per-URL summary in PostgreSQL.
Failures for a single URL are logged and do not stop the rest.
"""
if not urls:
return
- if not db_path:
- raise ValueError("run_lighthouse_on_pages requires db_path")
if not is_lighthouse_available():
raise RuntimeError(
"Node/npm not found. Install Node.js (https://nodejs.org); then run: npm install -g lighthouse. "
"Chrome or Chromium is also required for headless mode."
)
+ from concurrent.futures import ThreadPoolExecutor, as_completed
+
from ..db import (
db_session,
- init_schema,
write_lh_audits_from_run,
write_lighthouse_page_summary,
write_lighthouse_run,
)
- with db_session(db_path) as conn:
- init_schema(conn)
- strategy = strategy.lower() if strategy else "mobile"
- if strategy not in ("mobile", "desktop"):
- strategy = "mobile"
- iterations = max(1, int(iterations))
- categories = _parse_categories(categories) if categories else None
- total = len(urls)
+ strategy = strategy.lower() if strategy else "mobile"
+ if strategy not in ("mobile", "desktop"):
+ strategy = "mobile"
+ iterations = max(1, int(iterations))
+ categories = _parse_categories(categories) if categories else None
+ total = len(urls)
+ workers = max(1, min(int(concurrency or 2), 8))
+
+ def _audit_one(url: str) -> None:
+ print(f"[Lighthouse on pages] {url}", flush=True)
+ summary = run_lighthouse_audit(
+ url=url,
+ strategy=strategy,
+ iterations=iterations,
+ output_dir=output_dir,
+ mode=mode,
+ categories=categories,
+ )
+ lhr: dict[str, Any] | None = None
+ for raw_path in reversed(summary.get("raw_reports") or []):
+ if os.path.isfile(raw_path):
+ try:
+ with open(raw_path, "r", encoding="utf-8") as f:
+ lhr = json.load(f)
+ break
+ except (OSError, json.JSONDecodeError):
+ continue
+ with db_session() as conn:
+ write_lighthouse_page_summary(conn, url, summary)
+ if lhr is not None:
+ run_id = write_lighthouse_run(conn, url, strategy, 1, lhr)
+ write_lh_audits_from_run(conn, run_id, lhr)
+ for raw_path in summary.get("raw_reports") or []:
+ if os.path.isfile(raw_path):
+ try:
+ os.remove(raw_path)
+ except OSError:
+ pass
+
+ if workers == 1:
for idx, url in enumerate(urls):
try:
print(f"[Lighthouse on pages] {idx + 1}/{total}: {url}", flush=True)
- summary = run_lighthouse_audit(
- url=url,
- strategy=strategy,
- iterations=iterations,
- output_dir=output_dir,
- mode=mode,
- categories=categories,
- )
- write_lighthouse_page_summary(conn, url, summary)
- lhr: dict[str, Any] | None = None
- for raw_path in reversed(summary.get("raw_reports") or []):
- if os.path.isfile(raw_path):
- try:
- with open(raw_path, "r", encoding="utf-8") as f:
- lhr = json.load(f)
- break
- except (OSError, json.JSONDecodeError):
- continue
- if lhr is not None:
- run_id = write_lighthouse_run(conn, url, strategy, 1, lhr)
- write_lh_audits_from_run(conn, run_id, lhr)
- # Delete raw run files after storing summary in DB (same as single-URL path)
- for raw_path in summary.get("raw_reports") or []:
- if os.path.isfile(raw_path):
- try:
- os.remove(raw_path)
- except OSError:
- pass
+ _audit_one(url)
except Exception as e:
print(f" Skipped (error): {e}", file=sys.stderr, flush=True)
+ else:
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ futures = {pool.submit(_audit_one, url): url for url in urls}
+ for future in as_completed(futures):
+ url = futures[future]
+ try:
+ future.result()
+ except Exception as e:
+ print(f" Skipped {url} (error): {e}", file=sys.stderr, flush=True)
+
print(f"[Lighthouse on pages] Done. Wrote {total} URL(s) to DB.", flush=True)
@@ -491,13 +529,12 @@ def main(
iterations: int = 3,
output_dir: str = ".",
summary_path: str | None = None,
- db_path: str | None = None,
+ use_database: bool = True,
mode: str = "navigation",
categories: str | list[str] | None = None,
) -> int:
"""
- Run Lighthouse audit and write summary to JSON file and/or SQLite. Returns 0 on success, non-zero on error.
- mode: 'navigation' (default), 'timespan', or 'snapshot'. categories: optional for --only-categories.
+ Run Lighthouse audit and write summary to JSON file and/or PostgreSQL. Returns 0 on success, non-zero on error.
"""
try:
summary = run_lighthouse_audit(
@@ -512,22 +549,19 @@ def main(
print(str(e), file=sys.stderr)
return 1
- # Store report HTML in summary so it is saved to DB when db_path is set
+ # Store report HTML in summary so it is saved to PostgreSQL when DATABASE_URL is set
print(" Building report HTML...", flush=True)
summary["report_html"] = _build_report_html_content(summary)
- if db_path:
- # Persist everything to DB only (no artifact files on disk)
+ if use_database:
from ..db import (
db_session,
- init_schema,
write_lh_audits_from_run,
write_lighthouse_summary,
write_lighthouse_run,
)
print(" Saving summary to DB...", flush=True)
- with db_session(db_path) as conn:
- init_schema(conn)
+ with db_session() as conn:
write_lighthouse_summary(conn, summary)
raw_reports = summary.get("raw_reports") or []
for i, raw_path in enumerate(raw_reports):
@@ -546,7 +580,7 @@ def main(
pass
print(" Lighthouse DB write complete.", flush=True)
print(summary.get("human_summary", ""))
- print(f"All Lighthouse data saved to SQLite: {db_path} (summary, diagnostics, human summary, report HTML, raw runs)")
+ print(f"All Lighthouse data saved to PostgreSQL (summary, diagnostics, human summary, report HTML, raw runs)")
else:
# No DB: write all artifacts to output_dir
print(" Writing summary.json...", flush=True)
diff --git a/src/website_profiling/llm/__init__.py b/src/website_profiling/llm/__init__.py
new file mode 100644
index 00000000..59a56d38
--- /dev/null
+++ b/src/website_profiling/llm/__init__.py
@@ -0,0 +1,4 @@
+from .enrich import cluster_keywords_llm, run_llm_enrichment
+from .base import get_llm_client
+
+__all__ = ["cluster_keywords_llm", "get_llm_client", "run_llm_enrichment"]
diff --git a/src/website_profiling/llm/base.py b/src/website_profiling/llm/base.py
new file mode 100644
index 00000000..b27dd5da
--- /dev/null
+++ b/src/website_profiling/llm/base.py
@@ -0,0 +1,50 @@
+"""LLM provider abstraction for content enrichment."""
+from __future__ import annotations
+
+import json
+import re
+from typing import Any, Protocol
+
+
+class LLMClient(Protocol):
+ def complete_json(self, system: str, user: str) -> dict[str, Any]: ...
+
+
+def parse_json_response(text: str) -> dict[str, Any]:
+ text = (text or "").strip()
+ if not text:
+ return {}
+ try:
+ data = json.loads(text)
+ return data if isinstance(data, dict) else {"data": data}
+ except json.JSONDecodeError:
+ pass
+ m = re.search(r"\{[\s\S]*\}", text)
+ if m:
+ try:
+ data = json.loads(m.group(0))
+ return data if isinstance(data, dict) else {"data": data}
+ except json.JSONDecodeError:
+ pass
+ return {}
+
+
+def get_llm_client(cfg: dict[str, str]) -> LLMClient:
+ provider = (cfg.get("llm_provider") or "none").strip().lower()
+ if provider == "openai":
+ from .providers.openai import OpenAIClient
+
+ return OpenAIClient(cfg)
+ if provider == "anthropic":
+ from .providers.anthropic import AnthropicClient
+
+ return AnthropicClient(cfg)
+ if provider == "gemini":
+ from .providers.gemini import GeminiClient
+
+ return GeminiClient(cfg)
+ if provider == "ollama":
+ from .providers.ollama import OllamaClient
+
+ return OllamaClient(cfg)
+ raise ValueError(f"Unknown LLM provider: {provider}")
diff --git a/src/website_profiling/llm/enrich.py b/src/website_profiling/llm/enrich.py
new file mode 100644
index 00000000..733ec196
--- /dev/null
+++ b/src/website_profiling/llm/enrich.py
@@ -0,0 +1,357 @@
+"""LLM-backed content enrichment (UI-configured via llm_config table)."""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+from collections import Counter
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any, Callable, Optional
+
+import pandas as pd
+
+from ..analysis.text import normalize_fingerprint_text
+from ..llm_config import llm_is_enabled
+from .base import get_llm_client
+from .prompts import (
+ KEYPHRASES_SYSTEM,
+ KEYWORD_CLUSTER_SYSTEM,
+ NER_SYSTEM,
+ PROMPT_VERSION,
+ SIMILAR_SYSTEM,
+)
+
+LLM_INSTALL_HINT = "Install LLM dependencies: pip install -r requirements-llm.txt"
+
+
+def _cfg_bool(cfg: dict[str, str] | None, key: str, default: bool = False) -> bool:
+ if not cfg:
+ return default
+ return str(cfg.get(key, default)).lower() in ("true", "1", "yes")
+
+
+def _cfg_int(cfg: dict[str, str] | None, key: str, default: int) -> int:
+ if not cfg:
+ return default
+ raw = cfg.get(key)
+ if raw is None or str(raw).strip() == "":
+ return default
+ try:
+ return int(str(raw).strip())
+ except ValueError:
+ return default
+
+
+def _html_success_df(df: pd.DataFrame, max_pages: int) -> pd.DataFrame:
+ success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df
+ if "content_type" in success.columns:
+ success = success[success["content_type"].fillna("").str.contains("text/html", case=False, na=False)]
+ return success.head(max_pages)
+
+
+def _page_batch_items(df: pd.DataFrame, max_pages: int) -> list[dict[str, str]]:
+ items: list[dict[str, str]] = []
+ for _, row in _html_success_df(df, max_pages).iterrows():
+ u = str(row.get("url") or "").strip().rstrip("/")
+ text = normalize_fingerprint_text(row)
+ if not u or len(text) < 40:
+ continue
+ items.append({"url": u, "text": text[:4000]})
+ return items
+
+
+def _cache_key(task: str, model: str, payload: str) -> str:
+ h = hashlib.sha256(f"{PROMPT_VERSION}:{task}:{model}:{payload}".encode()).hexdigest()
+ return h
+
+
+def _read_cache(key: str) -> Optional[dict[str, Any]]:
+ try:
+ from ..db import db_session
+ from ..db.storage import read_llm_cache
+
+ with db_session() as conn:
+ raw = read_llm_cache(conn, key)
+ if raw:
+ return json.loads(raw)
+ except Exception:
+ pass
+ return None
+
+
+def _write_cache(key: str, data: dict[str, Any]) -> None:
+ try:
+ from ..db import db_session
+ from ..db.storage import write_llm_cache
+
+ with db_session() as conn:
+ write_llm_cache(conn, key, json.dumps(data))
+ except Exception:
+ pass
+
+
+def _llm_concurrency(cfg: dict[str, str]) -> int:
+ return max(1, min(_cfg_int(cfg, "llm_concurrency", 2) or 2, 8))
+
+
+def _run_llm_batches(
+ client: Any,
+ task: str,
+ system: str,
+ batches: list[dict[str, Any]],
+ cfg: dict[str, str],
+ apply_batch: Callable[[dict[str, Any], dict[str, Any]], None],
+) -> None:
+ """Run LLM batches with batched cache lookup and optional parallel API calls."""
+ if not batches:
+ return
+ model = (cfg.get("llm_model") or cfg.get("llm_provider") or "").strip()
+ keyed: list[tuple[str, dict[str, Any], str]] = []
+ for payload in batches:
+ payload_str = json.dumps(payload, sort_keys=True)
+ ck = _cache_key(task, model, payload_str)
+ keyed.append((ck, payload, payload_str))
+
+ cached_map: dict[str, dict[str, Any]] = {}
+ try:
+ from ..db import db_session
+ from ..db.storage import read_llm_cache_batch
+
+ with db_session() as conn:
+ cached_map = read_llm_cache_batch(conn, [k for k, _, _ in keyed])
+ except Exception:
+ pass
+
+ pending: list[tuple[str, dict[str, Any]]] = []
+ for ck, payload, _ in keyed:
+ hit = cached_map.get(ck)
+ if hit is not None:
+ apply_batch(payload, hit)
+ else:
+ pending.append((ck, payload))
+
+ if not pending:
+ return
+
+ workers = _llm_concurrency(cfg)
+
+ def _one(item: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], dict[str, Any]]:
+ ck, payload = item
+ result = client.complete_json(system, json.dumps(payload))
+ _write_cache(ck, result)
+ return ck, payload, result
+
+ if workers <= 1 or len(pending) <= 1:
+ for item in pending:
+ _, payload, result = _one(item)
+ apply_batch(payload, result)
+ return
+
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ futures = [pool.submit(_one, item) for item in pending]
+ for future in as_completed(futures):
+ try:
+ _, payload, result = future.result()
+ apply_batch(payload, result)
+ except Exception:
+ pass
+
+
+def _call_cached(
+ client: Any,
+ task: str,
+ system: str,
+ user_payload: dict[str, Any],
+ cfg: dict[str, str],
+) -> dict[str, Any]:
+ model = (cfg.get("llm_model") or cfg.get("llm_provider") or "").strip()
+ payload_str = json.dumps(user_payload, sort_keys=True)
+ ck = _cache_key(task, model, payload_str)
+ cached = _read_cache(ck)
+ if cached is not None:
+ return cached
+ result = client.complete_json(system, json.dumps(user_payload))
+ _write_cache(ck, result)
+ return result
+
+
+def aggregate_ner_site_summary(spacy_by_url: dict[str, dict[str, Any]]) -> dict[str, Any]:
+ label_totals: Counter[str] = Counter()
+ total_entities = 0
+ for _u, info in (spacy_by_url or {}).items():
+ if not isinstance(info, dict):
+ continue
+ total_entities += int(info.get("entity_count") or 0)
+ for pair in info.get("top_entity_labels") or []:
+ if isinstance(pair, (list, tuple)) and len(pair) >= 2:
+ label_totals[str(pair[0])] += int(pair[1])
+ return {
+ "label_counts": dict(label_totals.most_common(40)),
+ "pages_with_ner": len(spacy_by_url or {}),
+ "total_entities": total_entities,
+ }
+
+
+def _run_ner(
+ client: Any,
+ items: list[dict[str, str]],
+ cfg: dict[str, str],
+) -> dict[str, dict[str, Any]]:
+ batch_size = max(1, _cfg_int(cfg, "llm_batch_size", 5))
+ out: dict[str, dict[str, Any]] = {}
+ batches = [{"pages": items[i : i + batch_size]} for i in range(0, len(items), batch_size)]
+
+ def apply_batch(_payload: dict[str, Any], data: dict[str, Any]) -> None:
+ for p in data.get("pages") or []:
+ u = str(p.get("url") or "").strip().rstrip("/")
+ if not u:
+ continue
+ labels = p.get("top_entity_labels") or []
+ out[u] = {
+ "entity_count": int(p.get("entity_count") or 0),
+ "top_entity_labels": labels,
+ }
+
+ _run_llm_batches(client, "ner", NER_SYSTEM, batches, cfg, apply_batch)
+ return out
+
+
+def _run_keyphrases(
+ client: Any,
+ items: list[dict[str, str]],
+ cfg: dict[str, str],
+) -> dict[str, dict[str, Any]]:
+ batch_size = max(1, _cfg_int(cfg, "llm_batch_size", 5))
+ out: dict[str, dict[str, Any]] = {}
+ batches = [{"pages": items[i : i + batch_size]} for i in range(0, len(items), batch_size)]
+
+ def apply_batch(_payload: dict[str, Any], data: dict[str, Any]) -> None:
+ for p in data.get("pages") or []:
+ u = str(p.get("url") or "").strip().rstrip("/")
+ if not u:
+ continue
+ phrases = p.get("phrases") or []
+ pairs = [[str(x[0]), float(x[1])] for x in phrases if isinstance(x, (list, tuple)) and len(x) >= 2]
+ out[u] = {"phrases": pairs}
+
+ _run_llm_batches(client, "keyphrases", KEYPHRASES_SYSTEM, batches, cfg, apply_batch)
+ return out
+
+
+def _run_similar_internal(
+ client: Any,
+ items: list[dict[str, str]],
+ cfg: dict[str, str],
+) -> dict[str, list[dict[str, Any]]]:
+ top_k = min(_cfg_int(cfg, "llm_similar_top_k", 5) or 5, 15)
+ all_urls = [x["url"] for x in items]
+ out: dict[str, list[dict[str, Any]]] = {}
+ batch_size = max(1, min(_cfg_int(cfg, "llm_batch_size", 5), 3))
+ batches = [
+ {"pages": items[i : i + batch_size], "candidate_urls": all_urls[:80], "top_k": top_k}
+ for i in range(0, len(items), batch_size)
+ ]
+
+ def apply_batch(_payload: dict[str, Any], data: dict[str, Any]) -> None:
+ for p in data.get("pages") or []:
+ u = str(p.get("url") or "").strip().rstrip("/")
+ if not u:
+ continue
+ sim = []
+ for s in (p.get("similar") or [])[:top_k]:
+ if isinstance(s, dict) and s.get("url"):
+ sim.append({"url": str(s["url"]), "score": round(float(s.get("score") or 0), 4)})
+ if sim:
+ out[u] = sim
+
+ _run_llm_batches(client, "similar", SIMILAR_SYSTEM, batches, cfg, apply_batch)
+ return out
+
+
+def cluster_keywords_llm(
+ keywords: list[str],
+ cfg: dict[str, str] | None,
+) -> list[dict[str, Any]]:
+ if not keywords or not cfg or not llm_is_enabled(cfg):
+ return []
+ if not _cfg_bool(cfg, "llm_enable_keyword_clusters", False):
+ return []
+ kws = keywords[:200]
+ if len(kws) < 2:
+ return []
+ try:
+ client = get_llm_client(cfg)
+ data = _call_cached(
+ client,
+ "kw_clusters",
+ KEYWORD_CLUSTER_SYSTEM,
+ {"keywords": kws},
+ cfg,
+ )
+ clusters = data.get("clusters") or []
+ out: list[dict[str, Any]] = []
+ for c in clusters:
+ if not isinstance(c, dict):
+ continue
+ words = c.get("keywords") or []
+ if len(words) < 2:
+ continue
+ out.append(
+ {
+ "top_keyword": str(c.get("top_keyword") or words[0]),
+ "keywords": sorted(str(w) for w in words),
+ "cluster_score": round(float(c.get("cluster_score") or 0.9), 4),
+ }
+ )
+ out.sort(key=lambda x: -x["cluster_score"])
+ return out
+ except Exception as e:
+ raise RuntimeError(str(e)) from e
+
+
+def run_llm_enrichment(
+ df: pd.DataFrame,
+ cfg: dict[str, str] | None,
+) -> dict[str, Any]:
+ bundle: dict[str, Any] = {
+ "spacy_by_url": {},
+ "similar_internal_by_url": {},
+ "ner_site_summary": {},
+ "keyphrases_by_url": {},
+ "ml_errors": [],
+ }
+ if df.empty or not cfg or not llm_is_enabled(cfg):
+ return bundle
+
+ max_pages = _cfg_int(cfg, "llm_max_pages", 60) or 60
+ items = _page_batch_items(df, max_pages)
+ if not items:
+ return bundle
+
+ try:
+ client = get_llm_client(cfg)
+ except Exception as e:
+ bundle["ml_errors"].append(str(e))
+ return bundle
+
+ if _cfg_bool(cfg, "llm_enable_ner", True):
+ try:
+ bundle["spacy_by_url"] = _run_ner(client, items, cfg)
+ except Exception as e:
+ bundle["ml_errors"].append(f"LLM NER: {e}")
+
+ if _cfg_bool(cfg, "llm_enable_keyphrases", True):
+ try:
+ bundle["keyphrases_by_url"] = _run_keyphrases(client, items, cfg)
+ except Exception as e:
+ bundle["ml_errors"].append(f"LLM keyphrases: {e}")
+
+ if _cfg_bool(cfg, "llm_enable_similar_internal", True):
+ try:
+ bundle["similar_internal_by_url"] = _run_similar_internal(client, items, cfg)
+ except Exception as e:
+ bundle["ml_errors"].append(f"LLM similar pages: {e}")
+
+ bundle["ner_site_summary"] = aggregate_ner_site_summary(bundle.get("spacy_by_url") or {})
+ return bundle
diff --git a/src/website_profiling/llm/prompts.py b/src/website_profiling/llm/prompts.py
new file mode 100644
index 00000000..cec4af83
--- /dev/null
+++ b/src/website_profiling/llm/prompts.py
@@ -0,0 +1,20 @@
+"""Versioned prompts for LLM enrichment tasks."""
+from __future__ import annotations
+
+PROMPT_VERSION = "v1"
+
+NER_SYSTEM = """You extract named entities from web page text for SEO analysis.
+Return JSON: {"pages": [{"url": "...", "entity_count": N, "top_entity_labels": [["ORG", 2], ["PERSON", 1]]}]}
+Use standard NER labels (ORG, PERSON, GPE, PRODUCT, etc.). Count occurrences per label."""
+
+KEYPHRASES_SYSTEM = """You extract SEO keyphrases from web page content.
+Return JSON: {"pages": [{"url": "...", "phrases": [["phrase text", 0.95], ...]}]}
+Provide 3-8 phrases per page with scores 0-1."""
+
+SIMILAR_SYSTEM = """You find semantically similar internal pages for SEO deduplication review.
+Return JSON: {"pages": [{"url": "...", "similar": [{"url": "...", "score": 0.87}, ...]}]}
+Scores 0-1; only include URLs from the provided candidate list."""
+
+KEYWORD_CLUSTER_SYSTEM = """You group related SEO keywords into semantic clusters.
+Return JSON: {"clusters": [{"top_keyword": "...", "keywords": ["a","b"], "cluster_score": 0.9}]}
+Only merge clearly related terms; omit singletons."""
diff --git a/src/website_profiling/llm/providers/__init__.py b/src/website_profiling/llm/providers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/website_profiling/llm/providers/anthropic.py b/src/website_profiling/llm/providers/anthropic.py
new file mode 100644
index 00000000..ce7e8467
--- /dev/null
+++ b/src/website_profiling/llm/providers/anthropic.py
@@ -0,0 +1,35 @@
+"""Anthropic Messages API."""
+from __future__ import annotations
+
+from typing import Any
+
+from ..base import parse_json_response
+
+
+class AnthropicClient:
+ def __init__(self, cfg: dict[str, str]) -> None:
+ self._cfg = cfg
+ self._model = (cfg.get("llm_model") or "claude-3-5-haiku-latest").strip()
+ self._timeout = float(cfg.get("llm_timeout_s") or 120)
+ self._api_key = (cfg.get("llm_api_key") or "").strip()
+
+ def complete_json(self, system: str, user: str) -> dict[str, Any]:
+ if not self._api_key:
+ raise RuntimeError("Anthropic API key missing. Set it in the AI tab or ANTHROPIC_API_KEY.")
+ try:
+ import anthropic
+ except ImportError as e:
+ raise ImportError("pip install anthropic (or requirements-llm.txt)") from e
+
+ client = anthropic.Anthropic(api_key=self._api_key, timeout=self._timeout)
+ msg = client.messages.create(
+ model=self._model,
+ max_tokens=4096,
+ system=system + "\nRespond with valid JSON only.",
+ messages=[{"role": "user", "content": user}],
+ )
+ parts = []
+ for block in msg.content:
+ if getattr(block, "type", None) == "text":
+ parts.append(block.text)
+ return parse_json_response("\n".join(parts))
diff --git a/src/website_profiling/llm/providers/gemini.py b/src/website_profiling/llm/providers/gemini.py
new file mode 100644
index 00000000..08b839b1
--- /dev/null
+++ b/src/website_profiling/llm/providers/gemini.py
@@ -0,0 +1,36 @@
+"""Google Gemini generateContent API."""
+from __future__ import annotations
+
+from typing import Any
+
+from ..base import parse_json_response
+
+
+class GeminiClient:
+ def __init__(self, cfg: dict[str, str]) -> None:
+ self._model = (cfg.get("llm_model") or "gemini-2.0-flash").strip()
+ self._timeout = float(cfg.get("llm_timeout_s") or 120)
+ self._api_key = (cfg.get("llm_api_key") or "").strip()
+
+ def complete_json(self, system: str, user: str) -> dict[str, Any]:
+ if not self._api_key:
+ raise RuntimeError("Gemini API key missing. Set it in the AI tab or GEMINI_API_KEY.")
+ try:
+ import httpx
+ except ImportError as e:
+ raise ImportError("pip install httpx (or requirements-llm.txt)") from e
+
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{self._model}:generateContent"
+ payload = {
+ "contents": [{"parts": [{"text": f"{system}\n\n{user}\n\nRespond with valid JSON only."}]}],
+ "generationConfig": {"responseMimeType": "application/json", "temperature": 0.2},
+ }
+ with httpx.Client(timeout=self._timeout) as client:
+ r = client.post(url, params={"key": self._api_key}, json=payload)
+ r.raise_for_status()
+ data = r.json()
+ text = ""
+ for cand in data.get("candidates") or []:
+ for part in (cand.get("content") or {}).get("parts") or []:
+ text += part.get("text") or ""
+ return parse_json_response(text)
diff --git a/src/website_profiling/llm/providers/ollama.py b/src/website_profiling/llm/providers/ollama.py
new file mode 100644
index 00000000..47c77cef
--- /dev/null
+++ b/src/website_profiling/llm/providers/ollama.py
@@ -0,0 +1,37 @@
+"""Ollama local chat API."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from ..base import parse_json_response
+
+
+class OllamaClient:
+ def __init__(self, cfg: dict[str, str]) -> None:
+ self._model = (cfg.get("llm_model") or "llama3.2").strip()
+ self._timeout = float(cfg.get("llm_timeout_s") or 120)
+ self._base = (cfg.get("llm_base_url") or "http://127.0.0.1:11434").strip().rstrip("/")
+
+ def complete_json(self, system: str, user: str) -> dict[str, Any]:
+ try:
+ import httpx
+ except ImportError as e:
+ raise ImportError("pip install httpx (or requirements-llm.txt)") from e
+
+ payload = {
+ "model": self._model,
+ "stream": False,
+ "format": "json",
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user},
+ ],
+ }
+ url = f"{self._base}/api/chat"
+ with httpx.Client(timeout=self._timeout) as client:
+ r = client.post(url, json=payload)
+ r.raise_for_status()
+ data = r.json()
+ content = (data.get("message") or {}).get("content") or ""
+ return parse_json_response(content if isinstance(content, str) else json.dumps(content))
diff --git a/src/website_profiling/llm/providers/openai.py b/src/website_profiling/llm/providers/openai.py
new file mode 100644
index 00000000..273b7031
--- /dev/null
+++ b/src/website_profiling/llm/providers/openai.py
@@ -0,0 +1,42 @@
+"""OpenAI-compatible chat completions with JSON output."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from ..base import parse_json_response
+
+
+class OpenAIClient:
+ def __init__(self, cfg: dict[str, str]) -> None:
+ self._cfg = cfg
+ self._model = (cfg.get("llm_model") or "gpt-4o-mini").strip()
+ self._timeout = float(cfg.get("llm_timeout_s") or 120)
+ self._api_key = (cfg.get("llm_api_key") or "").strip()
+ self._base = (cfg.get("llm_base_url") or "https://api.openai.com/v1").strip().rstrip("/")
+
+ def complete_json(self, system: str, user: str) -> dict[str, Any]:
+ if not self._api_key:
+ raise RuntimeError("OpenAI API key missing. Set it in the AI tab or OPENAI_API_KEY.")
+ try:
+ import httpx
+ except ImportError as e:
+ raise ImportError("pip install httpx (or requirements-llm.txt)") from e
+
+ payload = {
+ "model": self._model,
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user},
+ ],
+ "response_format": {"type": "json_object"},
+ "temperature": 0.2,
+ }
+ headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
+ url = f"{self._base}/chat/completions"
+ with httpx.Client(timeout=self._timeout) as client:
+ r = client.post(url, headers=headers, json=payload)
+ r.raise_for_status()
+ data = r.json()
+ content = data["choices"][0]["message"]["content"]
+ return parse_json_response(content if isinstance(content, str) else json.dumps(content))
diff --git a/src/website_profiling/llm_config.py b/src/website_profiling/llm_config.py
new file mode 100644
index 00000000..4ca55da0
--- /dev/null
+++ b/src/website_profiling/llm_config.py
@@ -0,0 +1,49 @@
+"""
+Load LLM settings from llm_config table only (UI-managed).
+Not read from pipeline-config.txt or --config files.
+"""
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+_ENV_KEY_BY_PROVIDER = {
+ "openai": "OPENAI_API_KEY",
+ "gemini": "GEMINI_API_KEY",
+ "anthropic": "ANTHROPIC_API_KEY",
+}
+
+
+def load_llm_config_from_db() -> dict[str, str]:
+ try:
+ from .db import db_session
+ from .db.storage import read_llm_config
+
+ with db_session() as conn:
+ cfg = read_llm_config(conn)
+ except Exception:
+ return {}
+
+ if not cfg:
+ return {}
+
+ provider = (cfg.get("llm_provider") or "none").strip().lower()
+ if provider and provider != "none":
+ if not (cfg.get("llm_api_key") or "").strip():
+ env_var = _ENV_KEY_BY_PROVIDER.get(provider)
+ if env_var:
+ env_val = (os.environ.get(env_var) or "").strip()
+ if env_val:
+ cfg = dict(cfg)
+ cfg["llm_api_key"] = env_val
+ cfg["_llm_api_key_source"] = "env"
+ return cfg
+
+
+def llm_is_enabled(cfg: dict[str, str]) -> bool:
+ if not cfg:
+ return False
+ if str(cfg.get("llm_enabled", "")).lower() not in ("true", "1", "yes"):
+ return False
+ provider = (cfg.get("llm_provider") or "none").strip().lower()
+ return provider not in ("", "none")
diff --git a/src/website_profiling/ml/__init__.py b/src/website_profiling/ml/__init__.py
deleted file mode 100644
index 3dc18aca..00000000
--- a/src/website_profiling/ml/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Optional ML enrichment."""
diff --git a/src/website_profiling/ml/enrich.py b/src/website_profiling/ml/enrich.py
deleted file mode 100644
index 92b71b76..00000000
--- a/src/website_profiling/ml/enrich.py
+++ /dev/null
@@ -1,774 +0,0 @@
-"""
-Optional ML/NLP enrichment for crawl reports. All features are gated by config flags.
-
-Install extras: pip install -r requirements-ml.txt
-"""
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import re
-from collections import Counter, defaultdict
-from typing import Any, Optional
-
-import pandas as pd
-
-ML_INSTALL_HINT = "Install optional ML dependencies: pip install -r requirements-ml.txt"
-
-
-def _cfg_bool(cfg: dict[str, str] | None, key: str, default: bool = False) -> bool:
- if not cfg:
- return default
- return str(cfg.get(key, default)).lower() in ("true", "1", "yes")
-
-
-def _cfg_int(cfg: dict[str, str] | None, key: str, default: int) -> int:
- if not cfg:
- return default
- raw = cfg.get(key)
- if raw is None or str(raw).strip() == "":
- return default
- try:
- return int(str(raw).strip())
- except ValueError:
- return default
-
-
-def _top_keywords_as_text(row: pd.Series, max_terms: int = 15) -> str:
- if "top_keywords" not in row.index:
- return ""
- raw = row.get("top_keywords")
- if raw is None or (isinstance(raw, float) and pd.isna(raw)):
- return ""
- s = str(raw).strip()
- if not s or s == "[]":
- return ""
- try:
- arr = json.loads(s)
- if not isinstance(arr, list):
- return ""
- words: list[str] = []
- for item in arr[:max_terms]:
- if isinstance(item, dict) and item.get("word"):
- words.append(str(item["word"]))
- return " ".join(words)
- except json.JSONDecodeError:
- return ""
-
-
-def _normalize_fingerprint_text(row: pd.Series) -> str:
- """Concatenate all available on-page text signals for ML (duplicates, ST, langdetect, spaCy)."""
- parts: list[str] = []
- for col in (
- "title",
- "h1",
- "meta_description",
- "heading_sequence",
- "og_title",
- "og_description",
- "twitter_title",
- "content_excerpt",
- ):
- if col not in row.index:
- continue
- v = row.get(col)
- if v is None or (isinstance(v, float) and pd.isna(v)):
- continue
- s = str(v).strip()
- if s:
- parts.append(s)
- kw_extra = _top_keywords_as_text(row)
- if kw_extra:
- parts.append(kw_extra)
- t = " ".join(parts).lower()
- t = re.sub(r"\s+", " ", t)
- return t[:12000]
-
-
-def _tokenize_simhash(text: str) -> list[str]:
- return re.findall(r"[a-z0-9]{3,}", text.lower())
-
-
-def _stable_token_hash(token: str) -> int:
- return int.from_bytes(hashlib.md5(token.encode("utf-8")).digest()[:8], "little")
-
-
-def simhash_64(text: str) -> int:
- """64-bit SimHash for near-duplicate detection (exact bucket grouping; optional Hamming merge)."""
- tokens = _tokenize_simhash(text)
- if not tokens:
- return 0
- vec = [0] * 64
- for tok in tokens:
- h = _stable_token_hash(tok)
- for i in range(64):
- if (h >> i) & 1:
- vec[i] += 1
- else:
- vec[i] -= 1
- out = 0
- for i in range(64):
- if vec[i] > 0:
- out |= 1 << i
- return out
-
-
-def _hamming(a: int, b: int) -> int:
- x = a ^ b
- c = 0
- while x:
- c += x & 1
- x >>= 1
- return c
-
-
-def _import_rapidfuzz():
- try:
- from rapidfuzz import fuzz
-
- return fuzz
- except ImportError as e:
- raise ImportError(f"{ML_INSTALL_HINT}\n({e})") from e
-
-
-def _import_sklearn():
- try:
- from sklearn.ensemble import IsolationForest
- from sklearn.preprocessing import StandardScaler
-
- return IsolationForest, StandardScaler
- except ImportError as e:
- raise ImportError(f"{ML_INSTALL_HINT}\n({e})") from e
-
-
-def _import_langdetect():
- try:
- from langdetect import LangDetectException, detect
-
- return detect, LangDetectException
- except ImportError as e:
- raise ImportError(f"{ML_INSTALL_HINT}\n({e})") from e
-
-
-def _import_sentence_transformers():
- # Before importing HF stack: hide safetensors "LOAD REPORT" / weight-key chatter on stderr.
- os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
- try:
- from sentence_transformers import SentenceTransformer
-
- return SentenceTransformer
- except ImportError as e:
- raise ImportError(f"{ML_INSTALL_HINT}\n({e})") from e
-
-
-def _import_spacy():
- try:
- import spacy
-
- return spacy
- except ImportError as e:
- raise ImportError(f"{ML_INSTALL_HINT}\n({e})") from e
-
-
-def compute_duplicate_groups(
- df: pd.DataFrame,
- cfg: dict[str, str] | None,
-) -> tuple[list[dict[str, Any]], dict[str, str]]:
- """
- SimHash exact groups + optional rapidfuzz merge (high token_set_ratio).
- Returns (groups for payload, url -> group_id).
- """
- if df.empty or not _cfg_bool(cfg, "enable_duplicate_detection", False):
- return [], {}
-
- success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df
- if "content_type" in success.columns:
- success = success[success["content_type"].fillna("").str.contains("text/html", case=False, na=False)]
- max_pages = _cfg_int(cfg, "ml_dup_max_pages", 2000) or 2000
- success = success.head(max_pages)
-
- url_to_fp: dict[str, str] = {}
- url_to_sh: dict[str, int] = {}
- for _, row in success.iterrows():
- u = str(row.get("url") or "").strip().rstrip("/")
- if not u:
- continue
- fp = _normalize_fingerprint_text(row)
- if len(fp) < 20:
- continue
- url_to_fp[u] = fp
- url_to_sh[u] = simhash_64(fp)
-
- # Exact SimHash buckets
- bucket: dict[int, list[str]] = defaultdict(list)
- for u, h in url_to_sh.items():
- bucket[h].append(u)
-
- fuzz = _import_rapidfuzz()
- fuzzy_threshold = _cfg_int(cfg, "ml_fuzzy_threshold", 92) or 92
- hamming_max = _cfg_int(cfg, "ml_simhash_hamming", 0) or 0
-
- # Union-find
- parent: dict[str, str] = {}
-
- def find(x: str) -> str:
- if x not in parent:
- parent[x] = x
- if parent[x] != x:
- parent[x] = find(parent[x])
- return parent[x]
-
- def union(a: str, b: str) -> None:
- ra, rb = find(a), find(b)
- if ra != rb:
- parent[rb] = ra
-
- urls = list(url_to_fp.keys())
- for u in urls:
- parent.setdefault(u, u)
-
- # Merge exact simhash
- for h, members in bucket.items():
- if len(members) < 2:
- continue
- base = members[0]
- for m in members[1:]:
- union(base, m)
-
- # Hamming-close simhash (optional, O(n^2) capped)
- if hamming_max > 0 and len(urls) <= 800:
- sh_list = [(u, url_to_sh[u]) for u in urls]
- for i, (u1, h1) in enumerate(sh_list):
- for u2, h2 in sh_list[i + 1 :]:
- if _hamming(h1, h2) <= hamming_max:
- union(u1, u2)
-
- # Optional: sentence-transformer cosine on fingerprint text — only merge fuzzy candidates above this similarity
- embed_norm: dict[str, Any] = {}
- if _cfg_bool(cfg, "enable_embedding_duplicate_refine", False) and len(urls) <= 600 and len(urls) >= 2:
- try:
- import numpy as np
-
- ST = _import_sentence_transformers()
- model_name = (cfg or {}).get("ml_sentence_model", "all-MiniLM-L6-v2").strip() or "all-MiniLM-L6-v2"
- model = ST(model_name)
- texts = [url_to_fp[u][:4000] for u in urls]
- verbose = _cfg_bool(cfg, "ml_verbose", False)
- emb = model.encode(
- texts,
- show_progress_bar=verbose,
- batch_size=32,
- convert_to_numpy=True,
- )
- norms = np.linalg.norm(emb, axis=1, keepdims=True)
- norms[norms == 0] = 1e-12
- e = emb / norms
- for i, u in enumerate(urls):
- embed_norm[u] = e[i]
- except ImportError:
- embed_norm = {}
-
- # Fuzzy title fingerprint merge (pairwise cap)
- if len(urls) <= 600:
- import numpy as np
-
- min_embed = float(_cfg_int(cfg, "ml_dup_embed_min_pct", 88) or 88) / 100.0
- for i, u1 in enumerate(urls):
- fp1 = url_to_fp.get(u1, "")
- for u2 in urls[i + 1 :]:
- fp2 = url_to_fp.get(u2, "")
- if not fp1 or not fp2:
- continue
- if fuzz.token_set_ratio(fp1, fp2) >= fuzzy_threshold:
- if embed_norm:
- v1 = embed_norm.get(u1)
- v2 = embed_norm.get(u2)
- if v1 is not None and v2 is not None and float(np.dot(v1, v2)) >= min_embed:
- union(u1, u2)
- else:
- union(u1, u2)
-
- clusters: dict[str, list[str]] = defaultdict(list)
- for u in urls:
- clusters[find(u)].append(u)
-
- groups_out: list[dict[str, Any]] = []
- url_to_gid: dict[str, str] = {}
- gid = 0
- max_groups = 200
- for root, members in clusters.items():
- if len(members) < 2:
- continue
- members = sorted(set(members))
- rep = members[0]
- methods = []
- hashes = {url_to_sh.get(m) for m in members}
- if len(hashes) == 1:
- methods.append("simhash")
- if len(members) > 1 and len(hashes) > 1:
- methods.append("fuzzy")
- if not methods:
- methods.append("simhash")
- gkey = f"dup_{gid}"
- gid += 1
- groups_out.append(
- {
- "id": gkey,
- "representative_url": rep,
- "member_urls": members[:100],
- "member_count": len(members),
- "methods": methods,
- }
- )
- for m in members:
- url_to_gid[m] = gkey
- if gid >= max_groups:
- break
-
- return groups_out[:max_groups], url_to_gid
-
-
-def compute_anomalies(df: pd.DataFrame, cfg: dict[str, str] | None) -> list[dict[str, Any]]:
- if df.empty or not _cfg_bool(cfg, "enable_anomaly_urls", False):
- return []
-
- IsolationForest, StandardScaler = _import_sklearn()
- rows: list[dict[str, Any]] = []
- feat_rows: list[list[float]] = []
-
- def _pa_int(row: pd.Series, key: str) -> int:
- if "page_analysis" not in row.index:
- return 0
- raw = row.get("page_analysis")
- if raw is None or (isinstance(raw, float) and pd.isna(raw)):
- return 0
- try:
- obj = json.loads(str(raw)) if isinstance(raw, str) else raw
- if isinstance(obj, dict):
- return int(obj.get(key) or 0)
- except (json.JSONDecodeError, TypeError, ValueError):
- pass
- return 0
-
- for _, row in df.iterrows():
- u = str(row.get("url") or "").strip().rstrip("/")
- if not u:
- continue
- st = str(row.get("status") or "")
- ok = bool(re.match(r"2\d{2}", st))
- wc = float(pd.to_numeric(row.get("word_count"), errors="coerce") or 0)
- cl = float(pd.to_numeric(row.get("content_length"), errors="coerce") or 0)
- rt = float(pd.to_numeric(row.get("response_time_ms"), errors="coerce") or 0)
- ol = float(pd.to_numeric(row.get("outlinks"), errors="coerce") or 0)
- rl = float(pd.to_numeric(row.get("reading_level"), errors="coerce") or 0)
- chratio = float(pd.to_numeric(row.get("content_html_ratio"), errors="coerce") or 0)
- h1c = float(pd.to_numeric(row.get("h1_count"), errors="coerce") or 0)
- mdlen = float(pd.to_numeric(row.get("meta_description_len"), errors="coerce") or 0)
- il = float(_pa_int(row, "internal_link_count"))
- el = float(_pa_int(row, "external_link_count"))
- feat_rows.append([wc, cl, rt, ol, rl, chratio, h1c, mdlen, il, el, 1.0 if ok else 0.0])
- rows.append({"url": u, "status": st})
-
- if len(feat_rows) < 10:
- return []
-
- scaler = StandardScaler()
- X = scaler.fit_transform(feat_rows)
- iso = IsolationForest(random_state=42, contamination="auto", n_estimators=128)
- pred = iso.fit_predict(X)
- scores = iso.decision_function(X)
-
- out: list[dict[str, Any]] = []
- for i, p in enumerate(pred):
- if p != -1:
- continue
- r = rows[i]
- f = feat_rows[i]
- reasons = []
- if f[2] > 3000:
- reasons.append("high_response_time_ms")
- if f[0] < 50 and f[1] > 500:
- reasons.append("low_word_count_high_html")
- if f[3] > 200:
- reasons.append("very_high_outlinks")
- if f[8] == 0 and r["status"].startswith("2"):
- reasons.append("zero_internal_links_in_analysis")
- out.append(
- {
- "url": r["url"],
- "anomaly_score": round(float(scores[i]), 4),
- "reasons": reasons or ["multivariate_outlier"],
- }
- )
- out.sort(key=lambda x: x["anomaly_score"])
- return out[:150]
-
-
-def compute_language_signals(df: pd.DataFrame, cfg: dict[str, str] | None) -> tuple[dict[str, str], dict[str, Any]]:
- if df.empty or not _cfg_bool(cfg, "enable_language_detection", False):
- return {}, {"counts": {}, "mixed_site": False}
-
- detect, LangDetectException = _import_langdetect()
- by_url: dict[str, str] = {}
- for _, row in df.iterrows():
- u = str(row.get("url") or "").strip().rstrip("/")
- if not u:
- continue
- st = str(row.get("status") or "")
- if not re.match(r"2\d{2}", st):
- continue
- text = _normalize_fingerprint_text(row)
- if len(text) < 30:
- continue
- try:
- lang = detect(text[:2000])
- by_url[u] = lang
- except LangDetectException:
- continue
-
- counts = dict(Counter(by_url.values()).most_common(20))
- mixed = len(counts) > 1
- summary = {"counts": counts, "mixed_site": mixed, "detected_pages": len(by_url)}
- return by_url, summary
-
-
-def compute_spacy_signals(df: pd.DataFrame, cfg: dict[str, str] | None) -> dict[str, dict[str, Any]]:
- if df.empty or not _cfg_bool(cfg, "enable_ner_spacy", False):
- return {}
-
- spacy = _import_spacy()
- try:
- nlp = spacy.load("en_core_web_sm")
- except OSError:
- raise ImportError(
- "spaCy English model missing. Install ML deps (includes en-core-web-sm): "
- "pip install -r requirements-ml.txt — or: python -m spacy download en_core_web_sm"
- ) from None
-
- max_pages = _cfg_int(cfg, "ml_ner_max_pages", 80) or 80
- success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df
- out: dict[str, dict[str, Any]] = {}
- n = 0
- for _, row in success.iterrows():
- if n >= max_pages:
- break
- u = str(row.get("url") or "").strip().rstrip("/")
- text = _normalize_fingerprint_text(row)
- if len(text) < 40:
- continue
- doc = nlp(text[:50000])
- labels = [e.label_ for e in doc.ents]
- lc = Counter(labels)
- out[u] = {
- "entity_count": len(doc.ents),
- "top_entity_labels": [list(x) for x in lc.most_common(8)],
- }
- n += 1
- return out
-
-
-def aggregate_ner_site_summary(spacy_by_url: dict[str, dict[str, Any]]) -> dict[str, Any]:
- """Roll up spaCy NER label counts across pages for site-level charts."""
- label_totals: Counter[str] = Counter()
- total_entities = 0
- for _u, info in (spacy_by_url or {}).items():
- if not isinstance(info, dict):
- continue
- total_entities += int(info.get("entity_count") or 0)
- for pair in info.get("top_entity_labels") or []:
- if isinstance(pair, (list, tuple)) and len(pair) >= 2:
- label_totals[str(pair[0])] += int(pair[1])
- elif isinstance(pair, (list, tuple)) and len(pair) == 1:
- label_totals[str(pair[0])] += 1
- return {
- "label_counts": dict(label_totals.most_common(40)),
- "pages_with_ner": len(spacy_by_url or {}),
- "total_entities": total_entities,
- }
-
-
-def compute_similar_internal(
- df: pd.DataFrame,
- cfg: dict[str, str] | None,
-) -> dict[str, list[dict[str, Any]]]:
- if df.empty or not _cfg_bool(cfg, "enable_semantic_similar_internal", False):
- return {}
-
- ST = _import_sentence_transformers()
- model_name = (cfg or {}).get("ml_sentence_model", "all-MiniLM-L6-v2").strip() or "all-MiniLM-L6-v2"
- model = ST(model_name)
-
- max_pages = _cfg_int(cfg, "ml_max_pages_st", 400) or 400
- top_k = min(_cfg_int(cfg, "ml_similar_top_k", 5) or 5, 15)
-
- success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df
- if "content_type" in success.columns:
- success = success[success["content_type"].fillna("").str.contains("text/html", case=False, na=False)]
-
- urls: list[str] = []
- texts: list[str] = []
- for _, row in success.head(max_pages).iterrows():
- u = str(row.get("url") or "").strip().rstrip("/")
- t = _normalize_fingerprint_text(row)
- if not u or len(t) < 15:
- continue
- urls.append(u)
- texts.append(t[:2000])
-
- if len(urls) < 2:
- return {}
-
- verbose = _cfg_bool(cfg, "ml_verbose", False)
- emb = model.encode(texts, show_progress_bar=verbose, batch_size=32, convert_to_numpy=True)
- # cosine similarity via normalized vectors
- import numpy as np
-
- norms = np.linalg.norm(emb, axis=1, keepdims=True)
- norms[norms == 0] = 1e-12
- e = emb / norms
- sim = e @ e.T
-
- result: dict[str, list[dict[str, Any]]] = {}
- n = len(urls)
- for i in range(n):
- scores = [(sim[i, j], j) for j in range(n) if j != i]
- scores.sort(reverse=True)
- result[urls[i]] = [
- {"url": urls[j], "score": round(float(s), 4)} for s, j in scores[:top_k]
- ]
- return result
-
-
-def compute_keyphrases_by_url(
- df: pd.DataFrame,
- cfg: dict[str, str] | None,
-) -> dict[str, dict[str, Any]]:
- """KeyBERT keyphrases per URL (uses same SentenceTransformer as semantic features)."""
- if df.empty or not _cfg_bool(cfg, "enable_keybert", False):
- return {}
- try:
- from keybert import KeyBERT
- except ImportError as e:
- raise ImportError(f"{ML_INSTALL_HINT}\n({e})") from e
-
- ST = _import_sentence_transformers()
- model_name = (cfg or {}).get("ml_sentence_model", "all-MiniLM-L6-v2").strip() or "all-MiniLM-L6-v2"
- st_model = ST(model_name)
- kw_model = KeyBERT(model=st_model)
- max_pages = _cfg_int(cfg, "ml_keybert_max_pages", 60) or 60
- top_n = _cfg_int(cfg, "ml_keybert_top_n", 8) or 8
-
- success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df
- if "content_type" in success.columns:
- success = success[success["content_type"].fillna("").str.contains("text/html", case=False, na=False)]
-
- out: dict[str, dict[str, Any]] = {}
- n = 0
- for _, row in success.iterrows():
- if n >= max_pages:
- break
- u = str(row.get("url") or "").strip().rstrip("/")
- text = _normalize_fingerprint_text(row)
- if len(text) < 40:
- continue
- try:
- kws = kw_model.extract_keywords(
- text[:12000],
- keyphrase_ngram_range=(1, 2),
- stop_words="english",
- top_n=top_n,
- use_mmr=True,
- diversity=0.5,
- )
- except Exception:
- continue
- pairs = [[str(k[0]), float(k[1])] for k in kws] if kws else []
- out[u] = {"phrases": pairs}
- n += 1
- return out
-
-
-def merge_ml_into_payload(payload: dict[str, Any], ml_bundle: dict[str, Any]) -> None:
- """Mutate report payload dict in place with ML fields and per-link merge."""
- payload["content_duplicates"] = ml_bundle.get("content_duplicates") or []
- payload["anomalies"] = ml_bundle.get("anomalies") or []
- payload["language_summary"] = ml_bundle.get("language_summary") or {}
- ns = ml_bundle.get("ner_site_summary") or {}
- if ns:
- payload["ner_site_summary"] = ns
- else:
- payload.pop("ner_site_summary", None)
- err = ml_bundle.get("ml_errors") or []
- if err:
- payload["ml_errors"] = err
- else:
- payload.pop("ml_errors", None)
-
- dup_gid = ml_bundle.get("url_duplicate_group_id") or {}
- sim_map = ml_bundle.get("similar_internal_by_url") or {}
- lang_map = ml_bundle.get("language_by_url") or {}
- spacy_map = ml_bundle.get("spacy_by_url") or {}
- kp_map = ml_bundle.get("keyphrases_by_url") or {}
- anomalies_list = ml_bundle.get("anomalies") or []
- anomaly_by_url = {str(a.get("url") or "").strip().rstrip("/"): a for a in anomalies_list if a.get("url")}
-
- for rec in payload.get("links") or []:
- if not isinstance(rec, dict):
- continue
- u = str(rec.get("url") or "").strip()
- uk = u.rstrip("/")
- rec.pop("duplicate_group_id", None)
- rec.pop("similar_internal", None)
- rec.pop("detected_language", None)
- rec.pop("nlp_entities", None)
- rec.pop("ml_anomaly", None)
- rec.pop("keyphrases", None)
- if uk in dup_gid:
- rec["duplicate_group_id"] = dup_gid[uk]
- nei = sim_map.get(uk) or sim_map.get(u)
- if nei:
- rec["similar_internal"] = list(nei)
- if uk in lang_map:
- rec["detected_language"] = lang_map[uk]
- if uk in spacy_map:
- rec["nlp_entities"] = spacy_map[uk]
- if uk in anomaly_by_url:
- rec["ml_anomaly"] = anomaly_by_url[uk]
- if uk in kp_map:
- rec["keyphrases"] = kp_map[uk]
- pa = rec.get("page_analysis")
- if isinstance(pa, dict):
- sig = pa.get("signals")
- if isinstance(sig, dict):
- sig.pop("language", None)
- sig.pop("nlp_entities", None)
- if not sig:
- pa.pop("signals", None)
- if uk in lang_map:
- pa.setdefault("signals", {})["language"] = lang_map[uk]
- if uk in spacy_map:
- pa.setdefault("signals", {})["nlp_entities"] = spacy_map[uk]
-
-
-def run_ml_enrichment(df: pd.DataFrame, cfg: dict[str, str] | None) -> dict[str, Any]:
- """
- Run all enabled enrichment steps. Returns a dict with keys for merging into report payload / per-URL maps.
- """
- bundle: dict[str, Any] = {
- "content_duplicates": [],
- "url_duplicate_group_id": {},
- "anomalies": [],
- "language_by_url": {},
- "language_summary": {"counts": {}, "mixed_site": False},
- "spacy_by_url": {},
- "similar_internal_by_url": {},
- "ner_site_summary": {},
- "keyphrases_by_url": {},
- }
-
- if df.empty:
- return bundle
-
- try:
- dups, url_gid = compute_duplicate_groups(df, cfg)
- bundle["content_duplicates"] = dups
- bundle["url_duplicate_group_id"] = url_gid
- except ImportError as e:
- bundle["ml_errors"] = bundle.get("ml_errors", []) + [str(e)]
-
- try:
- bundle["anomalies"] = compute_anomalies(df, cfg)
- except ImportError as e:
- bundle["ml_errors"] = bundle.get("ml_errors", []) + [str(e)]
-
- try:
- lang_map, lang_summary = compute_language_signals(df, cfg)
- bundle["language_by_url"] = lang_map
- bundle["language_summary"] = lang_summary
- except ImportError as e:
- bundle["ml_errors"] = bundle.get("ml_errors", []) + [str(e)]
-
- try:
- bundle["spacy_by_url"] = compute_spacy_signals(df, cfg)
- except (ImportError, OSError) as e:
- bundle["ml_errors"] = bundle.get("ml_errors", []) + [str(e)]
-
- bundle["ner_site_summary"] = aggregate_ner_site_summary(bundle.get("spacy_by_url") or {})
-
- try:
- bundle["similar_internal_by_url"] = compute_similar_internal(df, cfg)
- except ImportError as e:
- bundle["ml_errors"] = bundle.get("ml_errors", []) + [str(e)]
-
- try:
- bundle["keyphrases_by_url"] = compute_keyphrases_by_url(df, cfg)
- except ImportError as e:
- bundle["ml_errors"] = bundle.get("ml_errors", []) + [str(e)]
-
- return bundle
-
-
-def cluster_keywords_semantic(
- keywords: list[str],
- cfg: dict[str, str] | None,
-) -> list[dict[str, Any]]:
- """Cluster keyword strings by embedding similarity (cosine)."""
- if not keywords or not _cfg_bool(cfg, "enable_semantic_keywords", False):
- return []
-
- ST = _import_sentence_transformers()
- model_name = (cfg or {}).get("ml_sentence_model", "all-MiniLM-L6-v2").strip() or "all-MiniLM-L6-v2"
- model = ST(model_name)
- max_kw = _cfg_int(cfg, "ml_semantic_keyword_max", 200) or 200
- kws = keywords[:max_kw]
- if len(kws) < 2:
- return []
-
- import numpy as np
-
- verbose = _cfg_bool(cfg, "ml_verbose", False)
- emb = model.encode(kws, show_progress_bar=verbose, batch_size=64, convert_to_numpy=True)
- norms = np.linalg.norm(emb, axis=1, keepdims=True)
- norms[norms == 0] = 1e-12
- e = emb / norms
- sim = e @ e.T
-
- threshold = float(_cfg_int(cfg, "ml_keyword_cluster_sim", 75) or 75) / 100.0
- parent = {i: i for i in range(len(kws))}
-
- def find(x: int) -> int:
- if parent[x] != x:
- parent[x] = find(parent[x])
- return parent[x]
-
- def union(a: int, b: int) -> None:
- ra, rb = find(a), find(b)
- if ra != rb:
- parent[rb] = ra
-
- for i in range(len(kws)):
- for j in range(i + 1, len(kws)):
- if sim[i, j] >= threshold:
- union(i, j)
-
- clusters: dict[int, list[int]] = defaultdict(list)
- for i in range(len(kws)):
- clusters[find(i)].append(i)
-
- out: list[dict[str, Any]] = []
- for _, idxs in clusters.items():
- if len(idxs) < 2:
- continue
- words = [kws[i] for i in idxs]
- out.append(
- {
- "top_keyword": words[0],
- "keywords": sorted(words),
- "cluster_score": round(float(np.mean([sim[idxs[0], j] for j in idxs[1:]])), 4)
- if len(idxs) > 1
- else 1.0,
- }
- )
- out.sort(key=lambda x: -x["cluster_score"])
- return out
diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py
index 1a0025bc..03bc8850 100644
--- a/src/website_profiling/reporting/builder.py
+++ b/src/website_profiling/reporting/builder.py
@@ -1,5 +1,5 @@
"""
-Generate report data from crawl and write to SQLite. The Next.js UI in web/ reads report.db via /api/report/*.
+Generate report data from crawl and write to PostgreSQL. The Next.js UI in web/ reads via /api/report/*.
"""
import hashlib
import json
@@ -19,15 +19,15 @@
from ..common import (
LINK_COLUMN_NAMES,
- load_dataframe,
load_edges,
normalize_link,
parse_links_serialized,
- save_edges,
)
from ..tools.keywords import cluster_keywords, extract_candidates_from_df, score_keywords
from ..config import get_bool, get_int
-from ..ml.enrich import cluster_keywords_semantic, run_ml_enrichment
+from ..analysis import merge_bundles, run_local_enrichment
+from ..llm.enrich import cluster_keywords_llm, run_llm_enrichment
+from ..llm_config import load_llm_config_from_db, llm_is_enabled
from .categories import build_categories
from ..security_scanner import run_security_scan
@@ -234,7 +234,7 @@ def build_edges_from_df(
polite_delay: float,
) -> list[tuple[str, str]]:
"""Build or load edges; return list of (from, to) tuples."""
- edges = load_edges(edges_csv)
+ edges = load_edges(edges_csv) if (edges_csv or "").strip() else []
if edges:
return edges
@@ -847,9 +847,6 @@ def _build_keyword_opportunities(df: pd.DataFrame, config: dict[str, str] | None
def run_simple_report(
- crawl_csv: str,
- edges_csv: str = "edges.csv",
- output_html: str = "site_report.html",
max_fetch_for_edges: int = 300,
concurrency: int = 6,
timeout: int = 8,
@@ -861,50 +858,39 @@ def run_simple_report(
run_security_scan_flag: bool = True,
security_scan_active: bool = False,
security_max_urls_probe: int = 20,
- security_findings_output: Optional[str] = None,
lighthouse_summary_path: Optional[str] = None,
- db_path: Optional[str] = None,
config: Optional[dict[str, str]] = None,
+ use_database: bool = True,
) -> str:
- """Load crawl data, build edges if needed, write report payload to SQLite. Returns db_path. Requires db_path (Next.js UI in web/ reads via /api/report/*)."""
+ """Load crawl data from PostgreSQL, build report payload, write to report_payload."""
+ if not use_database:
+ raise ValueError("Report requires DATABASE_URL (PostgreSQL). Configure via Docker or local Postgres.")
+
+ from ..db import (
+ db_session,
+ get_crawl_run_info,
+ get_latest_crawl_run_id,
+ read_crawl,
+ read_edges,
+ read_lighthouse_summary,
+ write_edges,
+ )
run_id = None
crawl_run_created_at: Optional[str] = None
- if db_path:
- from ..db import (
- db_session,
- get_crawl_run_info,
- get_latest_crawl_run_id,
- init_schema,
- read_crawl,
- read_edges,
- read_lighthouse_summary,
- write_edges,
- )
- print(" Loading crawl data from DB...", flush=True)
- with db_session(db_path) as conn:
- init_schema(conn)
- run_id = get_latest_crawl_run_id(conn)
- if run_id is not None:
- info = get_crawl_run_info(conn, run_id)
- crawl_run_created_at = info["created_at"] if info else None
- df = read_crawl(conn, run_id)
- edges = read_edges(conn, run_id)
- global_lighthouse_summary = read_lighthouse_summary(conn)
- lighthouse_by_url = build_lighthouse_by_url_for_report(conn)
- lighthouse_summary = global_lighthouse_summary
- print(f" Loaded {len(df)} URLs, {len(edges)} edges.", flush=True)
- if df.empty and not edges:
- raise FileNotFoundError(f"No crawl or edges data in DB: {db_path}")
- else:
- if not os.path.exists(crawl_csv):
- raise FileNotFoundError(f"Crawl data not found: {crawl_csv}")
- print(" Loading crawl data from file...", flush=True)
- df = load_dataframe(crawl_csv)
- edges = []
- lighthouse_summary = None
- lighthouse_by_url = {}
- global_lighthouse_summary = None
- print(f" Loaded {len(df)} URLs.", flush=True)
+ print(" Loading crawl data from DB...", flush=True)
+ with db_session() as conn:
+ run_id = get_latest_crawl_run_id(conn)
+ if run_id is not None:
+ info = get_crawl_run_info(conn, run_id)
+ crawl_run_created_at = info["created_at"] if info else None
+ df = read_crawl(conn, run_id)
+ edges = read_edges(conn, run_id)
+ global_lighthouse_summary = read_lighthouse_summary(conn)
+ lighthouse_by_url = build_lighthouse_by_url_for_report(conn)
+ lighthouse_summary = global_lighthouse_summary
+ print(f" Loaded {len(df)} URLs, {len(edges)} edges.", flush=True)
+ if df.empty and not edges:
+ raise FileNotFoundError("No crawl or edges data in database. Run crawl first.")
if "url" not in df.columns and not df.empty:
raise ValueError("Crawl DataFrame missing required column 'url'")
@@ -916,13 +902,12 @@ def run_simple_report(
expected_host = _derive_expected_host(start_url or "", df)
if lighthouse_by_url and expected_host:
lighthouse_by_url = filter_lighthouse_by_host(lighthouse_by_url, expected_host)
- if db_path:
- lighthouse_summary = _pick_lighthouse_summary(
- lighthouse_by_url,
- start_url or "",
- global_lighthouse_summary,
- expected_host,
- )
+ lighthouse_summary = _pick_lighthouse_summary(
+ lighthouse_by_url,
+ start_url or "",
+ global_lighthouse_summary,
+ expected_host,
+ )
site_display = (site_name or "").strip() or (urlparse(start_url or "").netloc if start_url else "") or "Site"
report_display_title = (report_title or "").strip() or f"{site_display} — Crawl Report"
@@ -930,14 +915,12 @@ def run_simple_report(
if not edges and not df.empty:
print(" Building edges from crawl data...", flush=True)
edges = build_edges_from_df(
- df, edges_csv, same_domain_only, max_fetch_for_edges, concurrency, timeout, 0.12
+ df, "", same_domain_only, max_fetch_for_edges, concurrency, timeout, 0.12
)
print(f" Edges: {len(edges)}.", flush=True)
- if edges and db_path:
- with db_session(db_path) as conn:
+ if edges:
+ with db_session() as conn:
write_edges(conn, edges, run_id)
- elif edges and not db_path:
- save_edges(edges, edges_csv)
# Long report work (ML, graph, network) runs without a DB handle; payload write uses db_session again.
@@ -967,28 +950,14 @@ def run_simple_report(
polite_delay=0.2,
)
print(f" Security scan: {len(security_findings)} findings.", flush=True)
- if security_findings_output:
- with open(security_findings_output, "w", encoding="utf-8") as fh:
- json.dump(security_findings, fh, indent=2, default=str)
- print(" ML enrichment (optional)...", flush=True)
- ml_bundle = run_ml_enrichment(df, config)
+ print(" Content analysis (local + optional LLM)...", flush=True)
+ local_bundle = run_local_enrichment(df, config)
+ llm_cfg = load_llm_config_from_db()
+ llm_bundle = run_llm_enrichment(df, llm_cfg) if llm_is_enabled(llm_cfg) else {}
+ ml_bundle = merge_bundles(local_bundle, llm_bundle)
print(" Building report categories...", flush=True)
- if not db_path:
- lighthouse_summary = None
- if lighthouse_summary_path and os.path.isfile(lighthouse_summary_path):
- try:
- with open(lighthouse_summary_path, "r", encoding="utf-8") as fh:
- lighthouse_summary = json.load(fh)
- if lighthouse_summary and expected_host:
- if not _hosts_match(
- _url_hostname(str(lighthouse_summary.get("url") or "")),
- expected_host,
- ):
- lighthouse_summary = None
- except (OSError, json.JSONDecodeError):
- pass
categories = build_categories(
df, edges, summary_seo, site_level, start_url or "",
@@ -1087,8 +1056,6 @@ def run_simple_report(
sim_map = ml_bundle.get("similar_internal_by_url") or {}
lang_map = ml_bundle.get("language_by_url") or {}
spacy_map = ml_bundle.get("spacy_by_url") or {}
- anomalies_list = ml_bundle.get("anomalies") or []
- anomaly_by_url = {str(a.get("url") or "").strip().rstrip("/"): a for a in anomalies_list if a.get("url")}
kp_map = ml_bundle.get("keyphrases_by_url") or {}
# Full links list: every crawled URL with url, status, inlinks, title, content_length, depth
@@ -1248,8 +1215,6 @@ def _bool_col(col):
rec["detected_language"] = lang_map[uk]
if uk in spacy_map:
rec["nlp_entities"] = spacy_map[uk]
- if uk in anomaly_by_url:
- rec["ml_anomaly"] = anomaly_by_url[uk]
if uk in kp_map:
rec["keyphrases"] = kp_map[uk]
@@ -1326,11 +1291,14 @@ def _bool_col(col):
print(" Building content analytics...", flush=True)
content_analytics = _build_content_analytics(df)
semantic_keyword_clusters: list[dict[str, Any]] = []
- if get_bool(config or {}, "enable_semantic_keywords", False):
+ llm_cfg_for_clusters = load_llm_config_from_db()
+ if llm_is_enabled(llm_cfg_for_clusters):
try:
- words = [x["word"] for x in (content_analytics.get("top_keywords_site") or []) if x.get("word")]
- semantic_keyword_clusters = cluster_keywords_semantic(words, config or {})
- except ImportError as e:
+ llm_cfg = llm_cfg_for_clusters
+ if str(llm_cfg.get("llm_enable_keyword_clusters", "")).lower() in ("true", "1", "yes"):
+ words = [x["word"] for x in (content_analytics.get("top_keywords_site") or []) if x.get("word")]
+ semantic_keyword_clusters = cluster_keywords_llm(words, llm_cfg)
+ except Exception as e:
ml_bundle.setdefault("ml_errors", []).append(str(e))
outbound_max = get_int(config or {}, "outbound_domain_max_rows", 200) or 200
outbound_link_domains = _build_outbound_link_domains(df, start_url or "", outbound_max)
@@ -1376,7 +1344,6 @@ def _bool_col(col):
"response_time_stats": response_time_stats,
"depth_distribution": depth_distribution,
"content_duplicates": ml_bundle.get("content_duplicates") or [],
- "anomalies": ml_bundle.get("anomalies") or [],
"language_summary": ml_bundle.get("language_summary") or {},
"ner_site_summary": ml_bundle.get("ner_site_summary") or {},
"semantic_keyword_clusters": semantic_keyword_clusters,
@@ -1386,7 +1353,7 @@ def _bool_col(col):
"keyword_opportunities": keyword_opportunities,
"ml_errors": ml_bundle.get("ml_errors") or [],
}
- if db_path and run_id is not None:
+ if run_id is not None:
report_data["crawl_run_id"] = run_id
report_data["crawl_run_created_at"] = crawl_run_created_at
if lighthouse_summary:
@@ -1394,36 +1361,27 @@ def _bool_col(col):
report_data["lighthouse_diagnostics"] = lighthouse_summary.get("diagnostics") or []
report_data["lighthouse_human_summary"] = lighthouse_summary.get("human_summary_full") or lighthouse_summary.get("human_summary") or ""
report_data["lighthouse_by_url"] = lighthouse_by_url
- if db_path:
- print(" Writing report payload to DB...", flush=True)
- from ..db import db_session as _db, init_schema as _init, write_report_payload as db_write_report_payload
- with _db(db_path) as conn:
- _init(conn)
- # Carry forward Google data from dedicated table so report rebuilds preserve it
- try:
- from ..integrations.google.store import read_latest_google_data
- google_data = read_latest_google_data(conn)
- if google_data:
- report_data["google"] = google_data
- except Exception:
- pass
- # Carry forward enriched keyword data (cap at top 500 by traffic potential)
- try:
- from ..integrations.google.keyword_store import read_latest_keyword_data
- kw_data = read_latest_keyword_data(conn)
- if kw_data:
- # Cap rows to keep payload lean
- rows = kw_data.get("rows") or []
- if len(rows) > 500:
- rows = rows[:500]
- kw_data = {**kw_data, "rows": rows}
- report_data["keywords"] = kw_data
- except Exception:
- pass
- db_write_report_payload(conn, report_data)
- return db_path
- raise ValueError(
- "Report requires sqlite_db. Set sqlite_db = report.db in your config; "
- "the Next.js UI in web/ reads report.db via /api/report/*."
- )
+ print(" Writing report payload to DB...", flush=True)
+ from ..db import db_session as _db, write_report_payload as db_write_report_payload
+ with _db() as conn:
+ try:
+ from ..integrations.google.store import read_latest_google_data
+ google_data = read_latest_google_data(conn)
+ if google_data:
+ report_data["google"] = google_data
+ except Exception:
+ pass
+ try:
+ from ..integrations.google.keyword_store import read_latest_keyword_data
+ kw_data = read_latest_keyword_data(conn)
+ if kw_data:
+ rows = kw_data.get("rows") or []
+ if len(rows) > 500:
+ rows = rows[:500]
+ kw_data = {**kw_data, "rows": rows}
+ report_data["keywords"] = kw_data
+ except Exception:
+ pass
+ db_write_report_payload(conn, report_data)
+ return "postgresql"
diff --git a/src/website_profiling/reporting/categories.py b/src/website_profiling/reporting/categories.py
index 63a5bcef..3bcfa1b3 100644
--- a/src/website_profiling/reporting/categories.py
+++ b/src/website_profiling/reporting/categories.py
@@ -228,7 +228,7 @@ def category_core_web_vitals_from_lighthouse(lighthouse_summary: dict) -> dict:
issues.append(_issue(
msg,
priority="High" if (f.get("score") or 0) < 0.5 else "Medium",
- recommendation="See Lighthouse report for fix; run 'python -m src warnings' with the Lighthouse JSON for one-line fixes.",
+ recommendation="See Lighthouse diagnostics in the report, or run 'python -m src warnings' to refresh mapped fixes in PostgreSQL.",
))
if not issues and perf_score is not None and perf_score < 80:
recommendations.append("Improve Core Web Vitals (LCP, CLS, TBT) per Lighthouse recommendations.")
@@ -625,7 +625,7 @@ def category_security(
def category_intelligence(ml_bundle: Optional[dict] = None) -> dict:
- """Content intelligence: duplicate clusters, anomalies, language mix from optional ML enrichment."""
+ """Content intelligence: duplicate clusters and language mix from local + optional AI enrichment."""
issues: list[dict] = []
deductions: list[tuple[int, bool]] = []
ml_bundle = ml_bundle or {}
@@ -648,22 +648,6 @@ def category_intelligence(ml_bundle: Optional[dict] = None) -> dict:
))
deductions.append((8, True))
- anomalies = ml_bundle.get("anomalies") or []
- if len(anomalies) >= 5:
- issues.append(_issue(
- f"Unusual pages (multivariate outlier): {len(anomalies)} URL(s) flagged.",
- priority="Medium",
- recommendation="Review anomalies for crawl noise, soft-404s, or template bugs.",
- ))
- deductions.append((min(15, 5 + len(anomalies) // 10), True))
- elif anomalies:
- issues.append(_issue(
- f"{len(anomalies)} URL(s) look statistically unusual vs the rest of the crawl.",
- priority="Low",
- recommendation="Spot-check flagged URLs in Link Explorer (ml_anomaly).",
- ))
- deductions.append((3, True))
-
lang = ml_bundle.get("language_summary") or {}
if lang.get("mixed_site") and (lang.get("detected_pages") or 0) >= 10:
counts = lang.get("counts") or {}
@@ -702,7 +686,7 @@ def build_categories(
summary_seo should have: issues["broken"], issues["redirects"].
security_findings: optional list from security scanner (finding_type, severity, url, message, recommendation).
lighthouse_summary: optional dict from lighthouse_runner (median_metrics, top_failures); when set, Core Web Vitals uses real data.
- ml_bundle: optional dict from ml_enrich.run_ml_enrichment (duplicates, anomalies, language_summary, etc.) for Content intelligence category.
+ ml_bundle: optional dict from analysis + LLM enrichment (duplicates, language_summary, etc.) for Content intelligence category.
"""
issues_broken = summary_seo.get("issues", {}).get("broken", [])
issues_redirects = summary_seo.get("issues", {}).get("redirects", [])
diff --git a/src/website_profiling/tools/keywords.py b/src/website_profiling/tools/keywords.py
index 23c3967b..68dd7d2f 100644
--- a/src/website_profiling/tools/keywords.py
+++ b/src/website_profiling/tools/keywords.py
@@ -1,11 +1,8 @@
"""
-SEO keyword discovery and scoring from on-site content. Crawls site (or uses existing crawl),
-extracts candidate keywords from titles, headings, meta, URL slugs; scores and clusters them;
-outputs ranked CSV, clusters JSON, and human summary.
+SEO keyword discovery and scoring from on-site content. Uses PostgreSQL crawl data,
+extracts candidate keywords, scores and clusters them, and writes to keyword_data.
"""
-import csv
import json
-import os
import re
import sys
from datetime import datetime, timezone
@@ -48,7 +45,6 @@ def _slug_tokens(url: str) -> list[str]:
segments = [s for s in path.split("/") if s and s not in ("html", "php", "asp", "aspx", "jsp")]
out = []
for seg in segments:
- # Split on hyphen/underscore and clean
words = re.findall(r"\b[\w']+\b", seg.replace("-", " ").replace("_", " ").lower())
out.extend(words)
return out
@@ -92,11 +88,9 @@ def _relevance_tfidf(candidates: dict[str, dict], corpus_size: int) -> dict[str,
"""Simple TF-IDF style: relevance = (count / total_docs) * log(corpus_size / doc_freq)."""
total_docs = corpus_size or 1
doc_freq = {k: len(v["sources"]) for k, v in candidates.items()}
- max_df = max(doc_freq.values()) or 1
scores: dict[str, float] = {}
for kw, data in candidates.items():
df = doc_freq.get(kw, 1)
- # Higher when term is repeated but not everywhere (idf)
idf = 1.0 + (total_docs / max(df, 1)) ** 0.5
tf = min(1.0, (data["count"] or 0) / max(total_docs, 1))
scores[kw] = min(1.0, (tf * idf) / 10.0)
@@ -110,16 +104,14 @@ def score_keywords(
) -> list[dict[str, Any]]:
"""
Score each candidate. Without external data: search_volume and difficulty are estimated;
- relevance from TF-IDF; ctr_est placeholder. Composite = volume*w_v + relevance*w_r + ctr_est*w_c + (1-difficulty)*w_e.
+ relevance from TF-IDF; ctr_est placeholder.
"""
weights = weights or DEFAULT_WEIGHTS
relevance_scores = _relevance_tfidf(candidates, corpus_size or len(candidates))
results: list[dict[str, Any]] = []
for kw, data in candidates.items():
- # Estimate volume: no API -> use frequency on site as proxy (normalized 0..1)
raw_vol = (data.get("count") or 0) / max(corpus_size or 1, 1) * 100
volume = min(1.0, raw_vol)
- # Difficulty: no API -> middle default so ease = 0.5
difficulty = 50.0
ease = 1.0 - (difficulty / 100.0)
relevance = relevance_scores.get(kw, 0.5)
@@ -131,7 +123,6 @@ def score_keywords(
+ weights.get("ctr_est", 0.15) * ctr_est
+ weights.get("ease", 0.15) * ease
)
- # recommended_action: heuristic
if len(data.get("sources") or []) > 1:
action = "internal link"
elif relevance > 0.7:
@@ -155,13 +146,9 @@ def score_keywords(
def cluster_keywords(scored: list[dict[str, Any]]) -> list[dict[str, Any]]:
- """
- Group similar keywords by shared tokens (simple overlap). Each cluster has
- top keyword (by score), cluster score (average of keyword scores), and list of keywords.
- """
+ """Group similar keywords by shared tokens (simple overlap)."""
if not scored:
return []
- # Build clusters: keywords that share at least one token go together (greedy).
clusters: list[set[str]] = []
kw_to_tokens: dict[str, set[str]] = {}
for s in scored:
@@ -201,29 +188,32 @@ def cluster_keywords(scored: list[dict[str, Any]]) -> list[dict[str, Any]]:
return out
+def _load_crawl_from_db() -> pd.DataFrame:
+ from ..db import db_session, get_latest_crawl_run_id, read_crawl
+
+ with db_session() as conn:
+ run_id = get_latest_crawl_run_id(conn)
+ return read_crawl(conn, run_id)
+
+
def run_keyword_pipeline(
base_url: str,
- output_dir: str,
config: dict[str, str] | None = None,
- crawl_csv_path: str | None = None,
max_pages: int = 200,
) -> dict[str, Any]:
"""
- Run crawl (or load existing crawl), extract and score keywords, cluster, write CSV and JSON.
- Returns summary dict with paths and human summary.
+ Run crawl (or load latest from PostgreSQL), extract and score keywords, cluster.
+ Writes keyword_data to PostgreSQL. Returns summary dict.
"""
config = config or {}
from ..config import get_list
+
exclude_urls = get_list(config, "crawl_exclude_urls", sep=",")
- os.makedirs(output_dir, exist_ok=True)
- cwd = os.getcwd()
+ df = _load_crawl_from_db()
- if crawl_csv_path and os.path.isfile(crawl_csv_path):
- from ..common import load_dataframe
- df = load_dataframe(crawl_csv_path)
- else:
+ if df.empty:
from ..crawl.crawler import run_crawler
- crawl_out = os.path.join(output_dir, "keyword_crawl.json")
+
run_crawler(
start_url=base_url,
max_pages=max_pages,
@@ -234,17 +224,15 @@ def run_keyword_pipeline(
max_depth=6,
polite_delay=0.2,
store_outlinks=False,
- output_csv=crawl_out,
+ output_csv=None,
+ output_db=True,
show_progress=True,
exclude_urls=exclude_urls if exclude_urls else None,
)
- from ..common import load_dataframe
- df = load_dataframe(crawl_out)
+ df = _load_crawl_from_db()
if df.empty:
return {
- "top_keywords_path": None,
- "clusters_path": None,
"human_summary": "No crawl data; no keywords extracted.",
"quick_wins": [],
"high_value": [],
@@ -257,36 +245,26 @@ def run_keyword_pipeline(
clusters = cluster_keywords(scored)
semantic_clusters: list[dict[str, Any]] = []
- from ..config import get_bool
-
- if get_bool(config, "enable_semantic_keywords", False):
- try:
- from ..ml.enrich import cluster_keywords_semantic
-
- top_kw = [s["keyword"] for s in scored[:200] if s.get("keyword")]
- semantic_clusters = cluster_keywords_semantic(top_kw, config)
- except ImportError as e:
- print(f"Semantic keywords skipped: {e}", file=sys.stderr)
+ try:
+ from ..llm_config import load_llm_config_from_db, llm_is_enabled
+
+ llm_cfg = load_llm_config_from_db()
+ if llm_is_enabled(llm_cfg) and str(llm_cfg.get("llm_enable_keyword_clusters", "")).lower() in (
+ "true",
+ "1",
+ "yes",
+ ):
+ try:
+ from ..llm.enrich import cluster_keywords_llm
+
+ top_kw = [s["keyword"] for s in scored[:200] if s.get("keyword")]
+ semantic_clusters = cluster_keywords_llm(top_kw, llm_cfg)
+ except Exception as e:
+ print(f"Semantic keywords skipped: {e}", file=sys.stderr)
+ except Exception:
+ pass
ts = datetime.now(timezone.utc).isoformat()
- top_path = os.path.join(output_dir, "top_keywords.csv")
- clusters_path = os.path.join(output_dir, "clusters.json")
-
- with open(top_path, "w", newline="", encoding="utf-8") as f:
- cols = ["keyword", "score", "volume", "difficulty", "relevance", "ctr_est", "current_rank", "recommended_action", "source"]
- w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
- w.writeheader()
- for row in scored:
- w.writerow({k: row.get(k) for k in cols})
-
- out_meta = {
- "timestamp": ts,
- "config": {"url": base_url, "weights": weights, "data_sources": ["site"]},
- "clusters": clusters,
- "clusters_semantic": semantic_clusters,
- }
- with open(clusters_path, "w", encoding="utf-8") as f:
- json.dump(out_meta, f, indent=2, default=str)
quick_wins = [s for s in scored if s.get("difficulty", 100) < 60][:10]
high_value = [s for s in scored if (s.get("volume") or 0) >= 0.5][:10]
@@ -298,33 +276,27 @@ def run_keyword_pipeline(
]
human_summary = " ".join(summary_lines)
- # Write scored keywords to keyword_data SQLite table (for Google enrichment merge)
- if db_path := (config or {}).get("_db_path"):
- try:
- import sqlite3 as _sqlite
- from ..db.storage import db_session as _db, init_schema as _init
- from ..integrations.google.keyword_store import write_keyword_data, ensure_tables
-
- rows_for_db = [
- {**r, "sources": ["site"]}
- for r in scored
- ]
- blob = {
- "fetched_at": ts,
- "total_keywords": len(rows_for_db),
- "rows": rows_for_db,
- "source": "site",
- }
- with _db(db_path) as conn:
- _init(conn)
- ensure_tables(conn)
- write_keyword_data(conn, blob)
- except Exception as e:
- print(f" Warning: could not write keyword_data to SQLite: {e}", file=sys.stderr)
+ try:
+ from ..db.storage import db_session as _db
+ from ..integrations.google.keyword_store import write_keyword_data
+
+ rows_for_db = [{**r, "sources": ["site"]} for r in scored]
+ blob = {
+ "fetched_at": ts,
+ "total_keywords": len(rows_for_db),
+ "rows": rows_for_db,
+ "source": "site",
+ "clusters": clusters,
+ "clusters_semantic": semantic_clusters,
+ "config": {"url": base_url, "weights": weights, "data_sources": ["site"]},
+ }
+ with _db() as conn:
+ write_keyword_data(conn, blob)
+ print(" Keywords stored in PostgreSQL (keyword_data).", flush=True)
+ except Exception as e:
+ print(f" Warning: could not write keyword_data to database: {e}", file=sys.stderr)
return {
- "top_keywords_path": top_path,
- "clusters_path": clusters_path,
"human_summary": human_summary,
"quick_wins": quick_wins[:10],
"high_value": high_value[:10],
@@ -334,31 +306,19 @@ def run_keyword_pipeline(
def main(
base_url: str,
- output_dir: str,
config: dict[str, str] | None = None,
) -> int:
- """
- Run keyword pipeline and print summary. Returns 0 on success.
- """
+ """Run keyword pipeline and print summary. Returns 0 on success."""
+ config = config or {}
try:
- crawl_csv = (config or {}).get("crawl_csv", "").strip()
- cwd = (config or {}).get("_cwd") or os.getcwd()
- if crawl_csv and not os.path.isabs(crawl_csv):
- crawl_csv = os.path.join(cwd, crawl_csv)
max_pages = int((config or {}).get("keyword_max_pages") or 0) or 200
summary = run_keyword_pipeline(
base_url=base_url,
- output_dir=output_dir,
config=config,
- crawl_csv_path=crawl_csv if os.path.isfile(crawl_csv) else None,
max_pages=max_pages,
)
except Exception as e:
print(str(e), file=sys.stderr)
return 1
print(summary.get("human_summary", ""))
- if summary.get("top_keywords_path"):
- print(f"top_keywords.csv: {summary['top_keywords_path']}")
- if summary.get("clusters_path"):
- print(f"clusters.json: {summary['clusters_path']}")
return 0
diff --git a/src/website_profiling/tools/plot.py b/src/website_profiling/tools/plot.py
index 87425463..1f397e41 100644
--- a/src/website_profiling/tools/plot.py
+++ b/src/website_profiling/tools/plot.py
@@ -1,49 +1,38 @@
"""
-Build edges from crawl data and persist nodes/edges (DB or files).
+Build edges from crawl data and persist nodes/edges to PostgreSQL.
"""
-import os
from typing import Optional
import pandas as pd
-from ..common import load_dataframe, load_edges, save_dataframe, save_edges
from ..reporting.builder import build_edges_from_df
def run_plot(
- crawl_csv: str,
- edges_csv: str = "edges.csv",
- nodes_csv: str = "nodes.csv",
same_domain_only: bool = True,
max_fetch_for_edges: int = 500,
concurrency: int = 8,
timeout: int = 10,
polite_delay: float = 0.15,
- db_path: Optional[str] = None,
-) -> tuple[str, str]:
+ use_database: bool = True,
+) -> str:
"""
- Load crawl data, build edges (and nodes), write to DB or CSV/JSON.
- Returns (edges_csv path, nodes_csv path).
+ Load crawl data, build edges (and nodes), write to PostgreSQL.
+ Returns a storage label (``postgresql``).
"""
+ if not use_database:
+ raise ValueError("Plot requires DATABASE_URL (PostgreSQL).")
+
run_id = None
- if db_path:
- print(" Loading crawl and edges from DB...", flush=True)
- from ..db import db_session, get_latest_crawl_run_id, init_schema, read_crawl, read_edges
- with db_session(db_path) as conn:
- init_schema(conn)
- run_id = get_latest_crawl_run_id(conn)
- df = read_crawl(conn, run_id)
- edges = read_edges(conn, run_id)
- print(f" Loaded {len(df)} URLs, {len(edges)} edges.", flush=True)
- if df.empty and not edges:
- raise FileNotFoundError(f"No crawl or edges data in DB: {db_path}")
- else:
- if not os.path.exists(crawl_csv):
- raise FileNotFoundError(f"Crawl data not found: {crawl_csv}")
- print(" Loading crawl data from file...", flush=True)
- df = load_dataframe(crawl_csv)
- edges = []
- print(f" Loaded {len(df)} URLs.", flush=True)
+ print(" Loading crawl and edges from DB...", flush=True)
+ from ..db import db_session, get_latest_crawl_run_id, read_crawl, read_edges
+ with db_session() as conn:
+ run_id = get_latest_crawl_run_id(conn)
+ df = read_crawl(conn, run_id)
+ edges = read_edges(conn, run_id)
+ print(f" Loaded {len(df)} URLs, {len(edges)} edges.", flush=True)
+ if df.empty and not edges:
+ raise FileNotFoundError("No crawl or edges data in database.")
if not df.empty and "url" not in df.columns:
raise ValueError("Crawl DataFrame missing 'url' column")
@@ -55,31 +44,20 @@ def run_plot(
if not edges and not df.empty:
print(" Building edges from crawl data...", flush=True)
edges = build_edges_from_df(
- df, edges_csv, same_domain_only, max_fetch_for_edges, concurrency, timeout, polite_delay
+ df, "", same_domain_only, max_fetch_for_edges, concurrency, timeout, polite_delay
)
print(f" Edges: {len(edges)}.", flush=True)
- if not edges and not db_path:
- edges = load_edges(edges_csv)
-
if edges:
edges_df = pd.DataFrame(edges, columns=["from", "to"])
- if db_path:
- print(" Writing edges and nodes to DB...", flush=True)
- from ..db import db_session, get_latest_crawl_run_id, init_schema, write_edges as db_write_edges, write_nodes as db_write_nodes
- with db_session(db_path) as conn:
- init_schema(conn)
- rid = run_id if run_id is not None else get_latest_crawl_run_id(conn)
- db_write_edges(conn, edges, rid)
- nodes = pd.Series(list(edges_df["from"]) + list(edges_df["to"]))
- nodes = nodes.value_counts().reset_index()
- nodes.columns = ["url", "count"]
- db_write_nodes(conn, nodes, rid)
- else:
- save_edges(edges, edges_csv)
+ print(" Writing edges and nodes to DB...", flush=True)
+ from ..db import db_session, get_latest_crawl_run_id, write_edges as db_write_edges, write_nodes as db_write_nodes
+ with db_session() as conn:
+ rid = run_id if run_id is not None else get_latest_crawl_run_id(conn)
+ db_write_edges(conn, edges, rid)
nodes = pd.Series(list(edges_df["from"]) + list(edges_df["to"]))
nodes = nodes.value_counts().reset_index()
nodes.columns = ["url", "count"]
- save_dataframe(nodes, nodes_csv)
+ db_write_nodes(conn, nodes, rid)
- return edges_csv, nodes_csv
+ return "postgresql"
diff --git a/src/website_profiling/tools/warnings.py b/src/website_profiling/tools/warnings.py
index 11f9d7db..71b1b752 100644
--- a/src/website_profiling/tools/warnings.py
+++ b/src/website_profiling/tools/warnings.py
@@ -1,6 +1,7 @@
"""
Map site warnings (Lighthouse, axe, or plain list) to detection method, affected metrics,
-severity, and one-line actionable fix. Outputs JSON mapping and human summary.
+severity, and one-line actionable fix. Default path: read Lighthouse from PostgreSQL,
+write mapped warnings to report_payload.warnings_mapped.
"""
import json
import os
@@ -425,6 +426,42 @@ def parse_plain_list(path: str) -> list[dict[str, Any]]:
return results
+def map_warnings_from_data(
+ data: dict[str, Any],
+ input_type: str,
+) -> list[dict[str, Any]]:
+ """Map warnings from in-memory Lighthouse, axe, or plain-line list data."""
+ input_type = (input_type or "lighthouse").lower()
+ if input_type == "lighthouse":
+ return _parse_lighthouse_data(data)
+ if input_type == "axe":
+ violations = data.get("violations") or []
+ results: list[dict[str, Any]] = []
+ for v in violations:
+ rule_id = v.get("id") or ""
+ help_text = v.get("help") or ""
+ desc = v.get("description") or ""
+ warning = f"{help_text}: {desc}"[:200]
+ entry = _resolve_entry(rule_id, help_text, desc)
+ refs: dict[str, Any] = {"lighthouse_audit_id": rule_id}
+ nodes = v.get("nodes") or []
+ if nodes:
+ refs["nodes"] = [n.get("target") or n.get("html") for n in nodes[:10]]
+ results.append(_build_output_item(warning, entry, refs))
+ return results
+ if input_type in ("list", "plain", "text"):
+ lines = data.get("lines") or []
+ results = []
+ for line in lines:
+ line = str(line).strip()
+ if not line:
+ continue
+ entry = _resolve_entry("", line, line)
+ results.append(_build_output_item(line, entry, None))
+ return results
+ raise ValueError(f"Unknown input_type: {input_type}. Use lighthouse, axe, or list.")
+
+
def map_warnings(
input_path: str,
input_type: str,
@@ -460,23 +497,43 @@ def human_summary_paragraph(items: list[dict[str, Any]], top_n: int = 5) -> str:
def main(
- input_path: str,
+ input_path: str | None = None,
input_type: str = "lighthouse",
- output_path: str = "warnings_mapped.json",
) -> int:
"""
- Run warning mapper and write JSON + print human summary.
- Returns 0 on success, non-zero on error.
+ Map Lighthouse/axe/list warnings to actionable fixes.
+ Default: read latest Lighthouse run from PostgreSQL, write to report_payload.
+ Optional input_path overrides with a local file (axe/list or external Lighthouse JSON).
"""
- if not input_path or not input_path.strip():
- print("warning_mapper_input is required. Set it in config or pass the Lighthouse/axe/list file path.", file=sys.stderr)
- return 1
- input_path = input_path.strip()
- if not os.path.isabs(input_path):
- input_path = os.path.abspath(input_path)
+ input_type = (input_type or "lighthouse").lower()
+ input_path = (input_path or "").strip()
+
try:
- print(f" Reading input: {input_path} (type={input_type})...", flush=True)
- items = map_warnings(input_path, input_type)
+ if input_path:
+ print(f" Reading input file: {input_path} (type={input_type})...", flush=True)
+ items = map_warnings(input_path, input_type)
+ else:
+ from ..db import db_session, read_latest_lighthouse_run_json
+
+ print(" Loading latest Lighthouse run from PostgreSQL...", flush=True)
+ with db_session() as conn:
+ if input_type == "lighthouse":
+ data = read_latest_lighthouse_run_json(conn)
+ if not data:
+ print(
+ "No Lighthouse runs in PostgreSQL. Run lighthouse first, "
+ "or set warning_mapper_input to a JSON file path.",
+ file=sys.stderr,
+ )
+ return 1
+ items = map_warnings_from_data(data, input_type)
+ else:
+ print(
+ "warning_mapper_input is required for axe/list types. "
+ "Set a file path in config or use input_type=lighthouse for DB mode.",
+ file=sys.stderr,
+ )
+ return 1
print(f" Mapped {len(items)} warnings.", flush=True)
except FileNotFoundError as e:
print(str(e), file=sys.stderr)
@@ -490,9 +547,13 @@ def main(
"warnings": items,
"human_summary": human_summary_paragraph(items, 5),
}
- print(f" Writing output: {output_path}...", flush=True)
- with open(output_path, "w", encoding="utf-8") as f:
- json.dump(output, f, indent=2, default=str)
+
+ from ..db import db_session, read_report_payload, write_report_payload
+
+ with db_session() as conn:
+ payload = read_report_payload(conn) or {}
+ payload["warnings_mapped"] = output
+ write_report_payload(conn, payload)
+ print(" Warnings stored in PostgreSQL (report_payload.warnings_mapped).", flush=True)
print(output["human_summary"])
- print(f"Written to {output_path}")
return 0
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/config_test_utils.py b/tests/config_test_utils.py
new file mode 100644
index 00000000..84d8faff
--- /dev/null
+++ b/tests/config_test_utils.py
@@ -0,0 +1,25 @@
+"""Shared helpers for config file tests."""
+from __future__ import annotations
+
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def parse_config_keys(path: Path) -> set[str]:
+ keys: set[str] = set()
+ text = path.read_text(encoding="utf-8")
+ for line in text.splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" in line:
+ key, _, _ = line.partition("=")
+ elif ":" in line:
+ key, _, _ = line.partition(":")
+ else:
+ continue
+ key = key.strip()
+ if key:
+ keys.add(key)
+ return keys
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..bf909fc2
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,10 @@
+"""Pytest configuration: src layout for website_profiling package."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
diff --git a/tests/db_test_fakes.py b/tests/db_test_fakes.py
new file mode 100644
index 00000000..394151db
--- /dev/null
+++ b/tests/db_test_fakes.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Any, Iterator
+
+
+class FakeCursor:
+ def __init__(self, *, fetchone_value: Any = None, fetchall_value: list[Any] | None = None) -> None:
+ self._fetchone_value = fetchone_value
+ self._fetchall_value = fetchall_value or []
+ self.executed: list[tuple[str, tuple[Any, ...] | None]] = []
+ self.executemany_calls: list[tuple[str, list[Any]]] = []
+
+ def fetchone(self) -> Any:
+ return self._fetchone_value
+
+ def fetchall(self) -> list[Any]:
+ return list(self._fetchall_value)
+
+ def executemany(self, sql: str, params: list[Any]) -> None:
+ self.executemany_calls.append((sql, list(params)))
+
+
+class FakeConn:
+ """
+ Minimal psycopg-like connection for unit tests.
+ Supports execute(), cursor(), commit(), and transaction() context manager.
+ """
+
+ def __init__(self) -> None:
+ self.executed: list[tuple[str, tuple[Any, ...] | None]] = []
+ self.commits = 0
+ self._next_cursor: FakeCursor | None = None
+
+ def set_next_cursor(self, cur: FakeCursor) -> None:
+ self._next_cursor = cur
+
+ def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> FakeCursor:
+ self.executed.append((sql, params))
+ if self._next_cursor is None:
+ return FakeCursor()
+ cur = self._next_cursor
+ self._next_cursor = None
+ return cur
+
+ @contextmanager
+ def cursor(self) -> Iterator[FakeCursor]:
+ cur = FakeCursor()
+ yield cur
+
+ def commit(self) -> None:
+ self.commits += 1
+
+ @contextmanager
+ def transaction(self) -> Iterator[None]:
+ yield None
+
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
new file mode 100644
index 00000000..6227ecf7
--- /dev/null
+++ b/tests/test_analysis.py
@@ -0,0 +1,44 @@
+"""Tests for local content analysis."""
+from __future__ import annotations
+
+import pandas as pd
+
+from website_profiling.analysis.local import compute_duplicate_groups, simhash_64
+
+
+def test_simhash_identical_text_same_hash():
+ t = "hello world " * 10
+ assert simhash_64(t) == simhash_64(t)
+
+
+def test_duplicate_groups_fuzzy_merge():
+ df = pd.DataFrame(
+ [
+ {
+ "url": "https://example.com/a",
+ "status": "200",
+ "content_type": "text/html",
+ "title": "Best SEO Tools Guide",
+ "meta_description": "A guide to SEO tools for marketers",
+ "h1": "SEO Tools",
+ "content_excerpt": " ".join(["seo tools"] * 50),
+ },
+ {
+ "url": "https://example.com/b",
+ "status": "200",
+ "content_type": "text/html",
+ "title": "Best SEO Tools Guide",
+ "meta_description": "A guide to SEO tools for marketers",
+ "h1": "SEO Tools",
+ "content_excerpt": " ".join(["seo tools"] * 50),
+ },
+ ]
+ )
+ cfg = {
+ "enable_duplicate_detection": "true",
+ "analysis_fuzzy_threshold": "90",
+ "analysis_dup_max_pages": "100",
+ }
+ groups, url_gid = compute_duplicate_groups(df, cfg)
+ assert len(groups) >= 1
+ assert url_gid.get("https://example.com/a") == url_gid.get("https://example.com/b")
diff --git a/tests/test_analysis_page.py b/tests/test_analysis_page.py
new file mode 100644
index 00000000..3eb9feff
--- /dev/null
+++ b/tests/test_analysis_page.py
@@ -0,0 +1,43 @@
+import json
+
+
+def test_analyze_html_basic_counts_and_warnings() -> None:
+ from website_profiling.analysis.page import analyze_html
+
+ html = """
+
+
+
+
+
+
+
+
+
+ Go
+ X
+
+
Hello again. This is a test sentence.
" + soup = BeautifulSoup(html, "lxml") + out = parse_content_text(soup, raw_html=html, excerpt_max_chars=30) + assert out["word_count"] > 0 + assert out["content_excerpt"] diff --git a/tests/test_common_parsing.py b/tests/test_common_parsing.py new file mode 100644 index 00000000..9470e287 --- /dev/null +++ b/tests/test_common_parsing.py @@ -0,0 +1,79 @@ +import json + + +def test_normalize_link_filters_schemes_and_strips_fragment_and_slash() -> None: + from website_profiling.common import normalize_link + + assert normalize_link("https://x.com", "mailto:test@example.com") is None + assert normalize_link("https://x.com", "javascript:alert(1)") is None + assert normalize_link("https://x.com", "ftp://x.com/a") is None + + assert normalize_link("https://x.com/base/", "/a#frag") == "https://x.com/a" + assert normalize_link("https://x.com/base/", "https://x.com/a/") == "https://x.com/a" + + +def test_parse_links_and_title() -> None: + from website_profiling.common import parse_links + + html = """ +
+ + That report view does not exist. Choose a valid section from the app. +
+{strings.app.loading}
-- {isDomainError ? strings.app.noReportForDomainTitle : strings.app.failedTitle} -
-{error}
- {!isDomainError ? ( -{strings.app.failedHint}
- ) : null} - -+ {isDomainError ? strings.app.noReportForDomainTitle : strings.app.failedTitle} +
+{error}
+ {!isDomainError ? ( +{strings.app.failedHint}
+ ) : null} + + Open Pipeline + +{description}
: null} +{helper}
} ++ Connect Search Console and GA4, then choose properties to sync with your reports. +
+
+ Need a project?{' '}
+
+ Google Cloud guide
Account connected
+You can configure properties in the next step.
+Complete step 1 to enable sign-in.
+ ) : ( + + )} + +Load sites from your connected account.
+ +{properties.ga4ListError}
+ ) : ( +Numeric ID from GA4 Admin → Property settings.
+ )} ++ Last fetched: {new Date(status.lastFetchedAt).toLocaleString()} +
+ ) : null} + +
+ {testLog}
+
+ ) : null}
+
+ {fetchLog ? (
+
+ Fetch status:{' '}
+
+ {fetchJobStatus}
+
+ {fetchJobStatus === 'running' ? (
+
+ {fetchLog}
+
+ {helper}
} -
- You need a{' '}
-
- Google Cloud project
- Complete Step 1 first to enable this button. -
- )} -- {properties.ga4ListError} -
- )} -- Numeric ID from GA4 Admin > Property Settings (not the G-XXXXXXX Measurement ID). -
-
- {testLog}
-
- )}
- {fetchLog && (
-
- Status:{' '}
-
- {fetchJobStatus}
-
- {fetchJobStatus === 'running' && (
-
-
- {fetchLog}
-
- - Full live log also opens in Pipeline Runner (blue terminal button, bottom-right). -
-- Last fetched: {new Date(status.lastFetchedAt).toLocaleString()} -
- )} - - {/* ── Advanced: paste refresh token ── */} -{subtitle}
} -{f.help}
- ) : null; - - if (f.type === 'bool') { - const checked = value === true; - return ( -Python pipeline
-- {busy - ? 'Running in background…' - : status === 'error' - ? 'Failed — expand for full log' - : status - ? `Status: ${status}` - : log - ? 'Expand for details' - : 'Idle'} -
-
- Settings persist to{' '}
- report.db
- {' '}(shadow file:{' '}
- pipeline-config.txt
- ).{' '}
- {configPath ? (
- Saved automatically before each run.
- ) : (
- 'Saved automatically before each run.'
- )}{' '}
- Localhost only; one job at a time.
- {busy ? ' Use minimize to keep working while the job runs.' : ''}
-
- Settings imported from{' '}
-
- pipeline-config.txt
-
- {' '}— click{' '}
- Save settings to persist to{' '}
-
- report.db
-
- .
-
- Could not load saved config: {loadError}. Showing schema defaults. -
-- {strings.reportSelector.crawlOnlyNote} -
- )} -- These keys are not in the UI schema but were found in your config file. They are - preserved on save. -
-
- {log}
-
- ) : null}
- {vc.needTwoReports}
+ ); + } + + return ( +| {children} | +export const TableHeadCell = ({ children, className = '', title }: { children?: ReactNode; className?: string; title?: string }) => ( +{children} | ); -export const TableBody = ({ children, striped = false, className = '' }) => ( +interface TableBodyProps { + children?: ReactNode; + striped?: boolean; + className?: string; +} + +export const TableBody = ({ children, striped = false, className = '' }: TableBodyProps) => ( tr:nth-child(even)]:bg-brand-900/30' : ''} ${className}`.trim()}> {children} ); -export const TableRow = ({ children, className = '' }) => ( +export const TableRow = ({ children, className = '' }: { children?: ReactNode; className?: string }) => (
|---|
| {children} | +export const TableCell = ({ children, className = '', title }: { children?: ReactNode; className?: string; title?: string }) => ( +{children} | ); diff --git a/web/src/components/ThemeToggle.jsx b/web/src/components/ThemeToggle.tsx similarity index 87% rename from web/src/components/ThemeToggle.jsx rename to web/src/components/ThemeToggle.tsx index fe63ade7..46fa205e 100644 --- a/web/src/components/ThemeToggle.jsx +++ b/web/src/components/ThemeToggle.tsx @@ -1,8 +1,9 @@ import { Monitor, Moon, Sun } from 'lucide-react'; import { strings } from '../lib/strings'; -import { useTheme } from '../context/useTheme.js'; +import { useTheme } from '../context/useTheme'; +import type { ThemePreference } from '../context/themeContext'; -const MODES = [ +const MODES: Array<{ id: ThemePreference; icon: typeof Sun; label: () => string }> = [ { id: 'light', icon: Sun, label: () => strings.app.themeLight }, { id: 'dark', icon: Moon, label: () => strings.app.themeDark }, { id: 'system', icon: Monitor, label: () => strings.app.themeSystem }, diff --git a/web/src/components/compare/CompareCharts.tsx b/web/src/components/compare/CompareCharts.tsx new file mode 100644 index 00000000..e968d34e --- /dev/null +++ b/web/src/components/compare/CompareCharts.tsx @@ -0,0 +1,419 @@ +'use client'; + +import { useMemo, type ReactNode } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; +import { Bar, Line } from 'react-chartjs-2'; +import { useReport } from '@/context/useReport'; +import type { CompareMetricRow, ReportCompareSummary } from '@/lib/reportCompare'; +import type { ReportPayload } from '@/types/report'; +import { + buildAlignedDailySeries, + buildMetricsBarChart, + buildPriorityChart, + buildStatusDistributionChart, + hasGoogleDaily, + pickMetricsForChart, + type DualSeriesChartData, +} from '@/lib/compareChartData'; +import { palette } from '@/utils/chartPalette'; +import { + getGridColor, + getChartTitleColor, + getChartLegendLabelColor, +} from '@/utils/chartJsDefaults'; + +ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Title, Tooltip, Legend); + +const COLOR_BASELINE = '#94a3b8'; +const COLOR_CURRENT = '#3b82f6'; + +type CompareChartStrings = (typeof import('@/lib/strings').strings)['views']['compare']; + +function ChartCard({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: ReactNode; +}) { + return ( +
{emptyLabel}
+{emptyLabel}
+{emptyLabel}
+{emptyLabel}
++ Connect Google in Integrations and run reports that include Search Console / GA4 data. +
+{vc.googleDateNote}
+ {changed.length > 0 ? ( +{emptyLabel}
+ ) : null} +{label}
{value ?? '—'}
- {sub &&{sub}
} + {sub ?{sub}
: null}{link.meta_description}
)}
{String(link.content_excerpt).trim()}
diff --git a/web/src/components/links/tabs/IssuesTab.jsx b/web/src/components/links/tabs/IssuesTab.tsx
similarity index 88%
rename from web/src/components/links/tabs/IssuesTab.jsx
rename to web/src/components/links/tabs/IssuesTab.tsx
index bbf3de33..713002e0 100644
--- a/web/src/components/links/tabs/IssuesTab.jsx
+++ b/web/src/components/links/tabs/IssuesTab.tsx
@@ -1,6 +1,8 @@
import { useState, useMemo } from 'react';
import { Bar } from 'react-chartjs-2';
+import type { TooltipItem } from 'chart.js';
import { Gauge, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react';
+import type { InspectorDetails, InspectorIssueRow, LinkLighthouseData, LighthouseAuditRef } from '@/types/report';
import { strings, format } from '../../../lib/strings';
import { SELECT_CLASS, SEO_ISSUE_RECOMMENDATIONS, severityBg } from '../../../utils/linkUtils';
import { formatLhMetric } from '../../../utils/linkUtils';
@@ -9,16 +11,21 @@ import { registerChartJsBase, barOptionsHorizontal } from '../../../utils/chartJ
registerChartJsBase();
-export default function IssuesTab({ lhData, inspectorDetails }) {
+export interface IssuesTabProps {
+ lhData?: LinkLighthouseData | null;
+ inspectorDetails: InspectorDetails | null;
+}
+
+export default function IssuesTab({ lhData, inspectorDetails }: IssuesTabProps) {
const ci = strings.components.inspectorTabs;
const it = strings.components.linkTabs.issues;
const sj = strings.common;
- const [expandedIssue, setExpandedIssue] = useState(null);
+ const [expandedIssue, setExpandedIssue] = useState {f.help} {f.help} {f.label} {f.label}
+
+ Key saved ({strVal}). Leave blank to keep it.
+ {f.label} {f.help}
+ {allLines.length === 0 ? 'No output yet.' : 'No lines match your filters.'}
+ {s.wizardUrlHint} {s.wizardWorkflowHint} {copy.label} {copy.description}
+ {crawlOnlyNote}
+ {s.wizardReviewHint} {presetCopy.label} {presetCopy.description}
+ No log output was returned. Open the browser developer console for the full error
+ (filter by WebsiteProfiling Pipeline).
+ {s.dockTitle}
+ {busy
+ ? s.dockRunning
+ : status === 'error'
+ ? s.dockFailed
+ : status
+ ? `${s.statusLabel}: ${status}`
+ : log
+ ? s.dockFailed
+ : 'Idle'}
+ {s.customCommandHelp} {s.unknownKeysHelp} {s.legacyBanner}
+ {format(s.loadError, { message: loadError })}
+
+ {s.contentAiHint}
+
+ {s.googleGroupHint}
+
+ {activeNav === 'run' ? s.runSubtitle : s.settingsSubtitle}
+ {it.whatToImprove}
- {link.keyphrases.phrases.map((pair, i) => (
+ {link.keyphrases.phrases.map((pair: unknown, i: number) => {
+ const phrasePair = Array.isArray(pair) ? pair : [pair];
+ return (
- {failingLighthouseAudits.map((a) => (
-
{p.resources}
{s.detectedTech}
{s.startUrlLabel}
+
+ {s.presetsLabel}
+
+ {s.wizardReviewTitle}
+
+
+
+
+ {activeNav === 'run'
+ ? s.runTitle
+ : settingsGroupLabel(
+ PIPELINE_SETTINGS_GROUPS.find((g) => g.id === activeNav)?.labelKey ?? '',
+ )}
+
+ {title}
@@ -34,12 +44,19 @@ function ChartCard({ title, hint, ariaLabel, heightClass = 'h-56', children }) {
);
}
-function useTopBarChart(rows, labelKey, valueKey, sp) {
+function useTopBarChart(
+ rows: Array