diff --git a/.coverage b/.coverage
index fea11124..fb5527cf 100644
Binary files a/.coverage and b/.coverage differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index da82f0fa..82a03f55 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,12 @@ jobs:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t website-profiling:ci .
+ - name: Browser crawl tests in image
+ run: |
+ docker run --rm \
+ -e DATABASE_URL=postgres://profiling:profiling@localhost:5432/website_profiling \
+ website-profiling:ci \
+ /opt/venv/bin/pytest tests/test_crawl_fetchers.py tests/test_crawler_browser_e2e.py -m browser -q -o addopts=
web:
runs-on: ubuntu-latest
diff --git a/AGENT.md b/AGENT.md
index e0677ed5..7c0b7a61 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -12,7 +12,9 @@
- `web/app/` -- routes; `web/src/` -- React; pipeline: `PipelineRunnerFab`, `server/pipelineJobs.ts`, `server/pipelineConfig.ts`, `server/llmConfig.ts`, `server/db.ts`
- `alembic/` -- schema migrations
-**Local dev:** `./local-run` (Postgres in Docker `wp-pg`, Next.js on host). See `scripts/local-run.sh`. **Local tests (CI parity):** `./local-test` — see `scripts/local-test.sh`.
+**Local dev:** `./local-run` (Postgres in Docker `wp-pg`, Next.js on host). See `scripts/local-run.sh`. **Local tests (CI parity):** `./local-test` (100% in-scope coverage gate); `./local-test browser` for `@pytest.mark.browser` integration tests — see `scripts/local-test.sh`. Mocked browser unit tests: `tests/test_browser_fetcher_unit.py`.
+
+**JavaScript crawl (optional):** Config keys `crawl_render_mode` (`static` | `javascript` | `auto`) and `crawl_js_*` in pipeline config / `pipelineConfigSchema.ts`. JS/auto crawls can capture browser console errors and uncaught exceptions (`crawl_js_capture_console`, stored under `page_analysis.browser`). **Auto mode** uses static-first fetch, pre-parse SPA heuristics (`needs_js_render`), then post-parse low-outlink fallback (`needs_js_render_after_parse`) in `crawler.py`. **Preflight:** `GET /api/crawl/browser-status` (localhost) spawns Python `browser_status()`; Run audit settings/run validation calls it when render mode is `javascript` or `auto`. Browser deps: `requirements-browser.txt` (installed by `./local-run setup` and `./local-test`). Runtime needs Chromium on `PATH` or `CHROME_PATH` (Docker sets `CHROME_PATH=/usr/bin/chromium`). Integration tests: `@pytest.mark.browser` — excluded by default in `pytest.ini`; Docker CI runs `tests/test_crawl_fetchers.py` and `tests/test_crawler_browser_e2e.py -m browser`; locally `./local-test browser`.
**Run / APIs**
@@ -22,7 +24,7 @@
- **`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
+- **`web/`:** `/api/report/*` (PostgreSQL); `/api/run` spawns Python (localhost only); `/api/crawl/browser-status` GET (localhost, Playwright/Chromium preflight); `/api/pipeline-config` GET/PUT; `/api/llm-config` GET/PUT (AI only); `/api/properties/{id}/google/links/import` POST (GSC Links CSV); `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`**
@@ -30,7 +32,7 @@
| Task | Where |
|------|--------|
-| Crawl | `crawl/crawler.py` |
+| Crawl | `crawl/crawler.py`, `crawl/fetchers/` |
| Report | `reporting/builder.py`, `reporting/categories.py` |
| DB schema | `alembic/versions/` |
| Local analysis | `analysis/local.py`, `requirements.txt` |
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ac51803..4c858768 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,6 +19,8 @@ Thank you for helping improve this project. All contributions are welcome under
Details: [README.md](README.md), [AGENT.md](AGENT.md).
+JavaScript/auto crawl needs Playwright (`requirements-browser.txt`, installed by `./local-run setup`) and Chromium on `PATH` or `CHROME_PATH`. Unit tests mock the browser fetcher; integration tests use `@pytest.mark.browser` and run in the Docker CI job (`tests/test_crawl_fetchers.py`, `tests/test_crawler_browser_e2e.py`). Locally: `./local-test browser` (skips gracefully if Chromium is missing).
+
## Running tests
Match CI before opening a pull request:
@@ -26,11 +28,12 @@ Match CI before opening a pull request:
```bash
./local-test # full check (recommended)
./local-test python # backend only
+./local-test browser # JS crawl integration tests (skips if Chromium unavailable)
./local-test web # frontend only
./local-test quick # faster; DB must already be running
```
-CI runs Python tests (PostgreSQL + Alembic), web typecheck/lint/vitest, CLI smoke, and a Docker build (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
+CI runs Python tests (PostgreSQL + Alembic, 80% coverage gate), web typecheck/lint/vitest, CLI smoke, and a Docker build that also runs browser-marked pytest inside the image (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
## How to contribute
diff --git a/Dockerfile b/Dockerfile
index b6f0dd9c..3557010a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -43,6 +43,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
# Python: base requirements + optional LLM API clients
COPY requirements.txt /app/requirements.txt
COPY requirements-llm.txt /app/requirements-llm.txt
+COPY requirements-browser.txt /app/requirements-browser.txt
COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic
RUN --mount=type=cache,target=/root/.cache/pip \
@@ -50,6 +51,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r /app/requirements.txt \
&& /opt/venv/bin/pip install -r /app/requirements-llm.txt \
+ && /opt/venv/bin/pip install -r /app/requirements-browser.txt \
&& ln -sf /opt/venv/bin/python /usr/local/bin/python \
&& ln -sf /opt/venv/bin/python /usr/local/bin/python3
@@ -66,7 +68,9 @@ RUN --mount=type=cache,target=/root/.npm \
cd /app/web && npm ci
# Application source
+COPY pytest.ini /app/pytest.ini
COPY src /app/src
+COPY tests /app/tests
COPY web /app/web
COPY alembic /app/alembic
COPY alembic.ini /app/alembic.ini
diff --git a/README.md b/README.md
index dd7a6268..d1da21fa 100644
--- a/README.md
+++ b/README.md
@@ -32,10 +32,11 @@ Open [http://localhost:3000/home](http://localhost:3000/home).
```bash
./local-test # before push: full CI parity (DB + pytest + web)
-./local-test python # backend only: pytest + CLI smoke
-./local-test web # frontend only: typecheck, lint, vitest
-./local-test quick # fast loop: skip Docker start; needs DB already up
-./local-test all --no-cov # full run without pytest coverage gate
+./local-test python # backend: pytest (80% coverage) + browser pytest + CLI smoke
+./local-test browser # JS crawl integration tests (skips if Chromium unavailable)
+./local-test web # frontend: typecheck, lint, vitest
+./local-test quick # fast loop; needs DB already up (no coverage gate)
+./local-test all --no-cov # full run without pytest coverage gate
```
## Contributing
@@ -53,6 +54,8 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and
Google Search Console / Analytics: connect via **Integrations** (gear icon) in the app.
+**JavaScript crawl (optional):** In Audit settings, set **Crawl rendering** to `javascript` (always headless Chromium) or `auto` (static first, browser when SPA heuristics match). Install locally: `pip install -r requirements-browser.txt` and Chromium on `PATH` or `CHROME_PATH` (included in Docker). The UI preflights via `GET /api/crawl/browser-status` before runs when JS/auto is selected.
+
Production: `docker-compose.prod.yml` (set `POSTGRES_PASSWORD`, `AUTH_SECRET`).
## License
diff --git a/alembic/versions/008_crawl_render_mode.py b/alembic/versions/008_crawl_render_mode.py
new file mode 100644
index 00000000..5c9e0f4a
--- /dev/null
+++ b/alembic/versions/008_crawl_render_mode.py
@@ -0,0 +1,24 @@
+"""Add render_mode to crawl_runs for audit provenance."""
+
+from alembic import op
+
+revision = "008_crawl_render_mode"
+down_revision = "007_keyword_property_id"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ ALTER TABLE crawl_runs ADD COLUMN IF NOT EXISTS render_mode TEXT DEFAULT 'static';
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ """
+ ALTER TABLE crawl_runs DROP COLUMN IF EXISTS render_mode;
+ """
+ )
diff --git a/alembic/versions/009_crawl_results_fetch_method.py b/alembic/versions/009_crawl_results_fetch_method.py
new file mode 100644
index 00000000..1ce0b19d
--- /dev/null
+++ b/alembic/versions/009_crawl_results_fetch_method.py
@@ -0,0 +1,30 @@
+"""Add fetch_method column to crawl_results for SQL-level filtering."""
+
+from alembic import op
+
+revision = "009_crawl_results_fetch_method"
+down_revision = "008_crawl_render_mode"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ ALTER TABLE crawl_results ADD COLUMN IF NOT EXISTS fetch_method TEXT DEFAULT 'static';
+ CREATE INDEX IF NOT EXISTS idx_crawl_results_run_fetch_method
+ ON crawl_results (crawl_run_id, fetch_method);
+ UPDATE crawl_results
+ SET fetch_method = COALESCE(data->>'fetch_method', 'static')
+ WHERE fetch_method IS NULL OR fetch_method = 'static';
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ """
+ DROP INDEX IF EXISTS idx_crawl_results_run_fetch_method;
+ ALTER TABLE crawl_results DROP COLUMN IF EXISTS fetch_method;
+ """
+ )
diff --git a/alembic/versions/010_gsc_links_data.py b/alembic/versions/010_gsc_links_data.py
new file mode 100644
index 00000000..8471aa8c
--- /dev/null
+++ b/alembic/versions/010_gsc_links_data.py
@@ -0,0 +1,33 @@
+"""Property-scoped GSC Links CSV import snapshots."""
+
+from alembic import op
+
+revision = "010_gsc_links_data"
+down_revision = "009_crawl_results_fetch_method"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS gsc_links_data (
+ id BIGSERIAL PRIMARY KEY,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
+ data JSONB NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_gsc_links_data_property_fetched
+ ON gsc_links_data (property_id, fetched_at DESC);
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ """
+ DROP INDEX IF EXISTS idx_gsc_links_data_property_fetched;
+ DROP TABLE IF EXISTS gsc_links_data CASCADE;
+ """
+ )
diff --git a/docs/COMPANY_STANDARDS.md b/docs/COMPANY_STANDARDS.md
index a220027a..6ca07285 100644
--- a/docs/COMPANY_STANDARDS.md
+++ b/docs/COMPANY_STANDARDS.md
@@ -16,8 +16,11 @@ Audit category scores (0–100) are **internal audit scores**, not Google rankin
## Crawl limitations
-- Crawl uses **HTTP GET + static HTML parsing** only (no JavaScript execution). See [Docs.md](../Docs.md).
-- Client-rendered links and SPAs may be under-represented; reports must show crawl scope (pages crawled vs limit, robots blocks).
+- Default crawl uses **HTTP GET + static HTML parsing** (no JavaScript execution). `crawl_render_mode = static` (default).
+- Optional **JavaScript rendering** (`crawl_render_mode = javascript`) loads every page in headless Chromium before parsing — slower (~10–20×) and heavier on memory, but required for many React, Vue, Next.js, Angular, Svelte, and Shopify themes.
+- **Auto rendering** (`crawl_render_mode = auto`) fetches static HTML first, then uses browser fallback when SPA shell heuristics or low outlink counts suggest client-rendered content. Per-page `fetch_method` (`static` vs `rendered`) is stored on crawl rows for provenance.
+- Client-rendered links and SPAs may be under-represented in static-only mode; reports show crawl scope (pages crawled vs limit, robots blocks, render mode, browser diagnostic counts when applicable).
+- JS and auto modes require Playwright + Chromium; the Run audit UI checks availability via `GET /api/crawl/browser-status` before starting a job.
- Only crawl sites you are **authorized** to test. Respect `robots.txt` unless an admin explicitly overrides for owned properties.
## Security scanning
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index 142440ea..663fb7a1 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -18,6 +18,7 @@ UI terms agencies recognize, mapped to internal keys and data sources.
| Technologies | `tech-stack` | Wappalyzer-style detection | BuiltWith |
| Crawl summary | `charts` | Crawl aggregates | SF overview |
| Internal links | `network` | Link graph | Ahrefs Internal Links |
+| Backlinks | `backlinks`, `gsc_links`, `gsc_links_data` | GSC Links CSV import (Google sample) | GSC Links report |
| Page previews | `gallery` | Crawl excerpts | Visual QA |
| Search Console | `search-performance`, `google_data` (scoped by `property_id`) | GSC API per property | Google Search Console |
| Analytics (GA4) | `traffic`, `google_data` (scoped by `property_id`) | GA4 API per property | Google Analytics |
@@ -32,7 +33,10 @@ UI terms agencies recognize, mapped to internal keys and data sources.
| Inlinks | `inlinks` | Crawl graph |
| Outlinks | `outlinks` | Crawl graph |
| Status code | `status` | HTTP |
+| Crawl rendering | `crawl_render_mode` on run; `fetch_method` per URL | `static`, `javascript`, or `auto` crawl config; `static` vs `rendered` per page |
| Impressions | `gsc_impressions` | Search Console |
+| Referring domains | `top_linking_sites` | GSC Links CSV import |
+| External links to site | `sample_links`, `latest_links` | GSC Links CSV import |
| Clicks | `gsc_clicks` | Search Console |
| CTR | `gsc_ctr` | Search Console |
| Average position | `gsc_position` | Search Console |
diff --git a/input.txt.example b/input.txt.example
index e5259a3f..c829d07f 100644
--- a/input.txt.example
+++ b/input.txt.example
@@ -18,6 +18,17 @@ content_excerpt_max_chars = 4096
preserve_crawl_history = true
crawl_stream_to_db = false
crawl_exclude_urls =
+# crawl_render_mode: static | javascript | auto (auto = static first, browser when SPA heuristics match)
+crawl_render_mode = static
+crawl_js_concurrency = 3
+crawl_js_timeout = 30
+crawl_js_wait_until = domcontentloaded
+crawl_js_extra_wait_ms = 1500
+crawl_js_block_resources = true
+crawl_js_capture_console = true
+crawl_js_console_levels = error,warning
+crawl_js_capture_failed_requests = false
+crawl_js_console_max_per_page = 20
# --- Report ---
outbound_domain_max_rows = 200
diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt
index 9ce7acf2..b7e5b7f2 100644
--- a/pipeline-config.example.txt
+++ b/pipeline-config.example.txt
@@ -19,6 +19,17 @@ content_excerpt_max_chars = 4096
preserve_crawl_history = true
crawl_stream_to_db = false
crawl_exclude_urls =
+# crawl_render_mode: static | javascript | auto
+crawl_render_mode = static
+crawl_js_concurrency = 3
+crawl_js_timeout = 30
+crawl_js_wait_until = domcontentloaded
+crawl_js_extra_wait_ms = 1500
+crawl_js_block_resources = true
+crawl_js_capture_console = true
+crawl_js_console_levels = error,warning
+crawl_js_capture_failed_requests = false
+crawl_js_console_max_per_page = 20
# --- Report ---
outbound_domain_max_rows = 200
diff --git a/pytest.ini b/pytest.ini
index ccbe3d7f..3514d814 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,8 +1,11 @@
[pytest]
pythonpath = src
testpaths = tests
+markers =
+ browser: integration tests requiring Chromium/Playwright (deselect with '-m "not browser"')
addopts =
--cov=website_profiling
--cov-config=.coveragerc
--cov-report=term-missing
- --cov-fail-under=80
+ --cov-fail-under=100
+ -m "not browser"
diff --git a/requirements-browser.txt b/requirements-browser.txt
new file mode 100644
index 00000000..4415efe0
--- /dev/null
+++ b/requirements-browser.txt
@@ -0,0 +1,2 @@
+# Optional: JavaScript rendering crawl (headless Chromium via Playwright)
+playwright>=1.49.0
diff --git a/scripts/local-run.sh b/scripts/local-run.sh
index 6b1fb55c..b32b7410 100755
--- a/scripts/local-run.sh
+++ b/scripts/local-run.sh
@@ -23,6 +23,7 @@ export DATABASE_URL="${DATABASE_URL:-postgres://${PG_USER}:${PG_PASSWORD}@127.0.
export DATA_DIR="${DATA_DIR:-$ROOT/data}"
export PYTHON="${PYTHON:-$ROOT/.venv/bin/python}"
export WEBSITE_PROFILING_ROOT="$ROOT"
+export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$ROOT/src"
VENV="$ROOT/.venv"
WEB="$ROOT/web"
@@ -82,6 +83,7 @@ cmd_venv() {
fi
log "Installing Python dependencies"
"$VENV/bin/pip" install -q -r "$ROOT/requirements.txt"
+ "$VENV/bin/pip" install -q -r "$ROOT/requirements-browser.txt"
}
cmd_migrate() {
@@ -99,10 +101,25 @@ cmd_web_deps() {
fi
}
+cmd_browser_deps() {
+ [[ -x "$VENV/bin/python" ]] || cmd_venv
+ log "Ensuring Playwright + Chromium for JS crawl"
+ if ! "$VENV/bin/python" -c "
+from website_profiling.crawl.fetchers import ensure_browser_deps
+import json, sys
+status = ensure_browser_deps()
+print(json.dumps(status))
+sys.exit(0 if status.get('ok') else 1)
+"; then
+ warn "Browser deps unavailable — JS/auto crawl disabled until Playwright + Chromium install successfully"
+ fi
+}
+
cmd_setup() {
mkdir -p "$DATA_DIR"
cmd_db
cmd_venv
+ cmd_browser_deps
cmd_migrate
cmd_web_deps
log "Setup complete."
@@ -114,6 +131,7 @@ cmd_start() {
mkdir -p "$DATA_DIR"
cmd_db
[[ -x "$VENV/bin/alembic" ]] || cmd_venv
+ cmd_browser_deps
log "Ensuring migrations are up to date"
"$VENV/bin/alembic" upgrade head
cmd_web_deps
@@ -122,7 +140,7 @@ cmd_start() {
log "DATA_DIR=$DATA_DIR"
log "PYTHON=$PYTHON"
cd "$WEB"
- export DATABASE_URL DATA_DIR PYTHON WEBSITE_PROFILING_ROOT
+ export DATABASE_URL DATA_DIR PYTHON WEBSITE_PROFILING_ROOT PYTHONPATH
exec npm run dev
}
@@ -155,7 +173,7 @@ Environment overrides (optional):
After start, open: http://localhost:3000/home
Run audits via sidebar "Run audit" (bottom-right FAB).
-Run CI-style tests: ./local-test (see ./local-test help).
+Run CI-style tests: ./local-test (see ./local-test help). JS crawl integration: ./local-test browser.
EOF
}
diff --git a/scripts/local-test.sh b/scripts/local-test.sh
index 324b539a..8d0172d4 100755
--- a/scripts/local-test.sh
+++ b/scripts/local-test.sh
@@ -21,6 +21,7 @@ PG_DB="${WP_PG_DB:-website_profiling}"
export DATABASE_URL="${DATABASE_URL:-postgres://${PG_USER}:${PG_PASSWORD}@127.0.0.1:${PG_PORT}/${PG_DB}}"
export DATA_DIR="${DATA_DIR:-$ROOT/data}"
export WEBSITE_PROFILING_ROOT="$ROOT"
+export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$ROOT/src"
VENV="$ROOT/.venv"
WEB="$ROOT/web"
@@ -83,6 +84,7 @@ cmd_venv() {
if [[ ! -x "$VENV/bin/pytest" ]]; then
log "Installing Python dependencies"
"$VENV/bin/pip" install -q -r "$ROOT/requirements.txt"
+ "$VENV/bin/pip" install -q -r "$ROOT/requirements-browser.txt"
fi
}
@@ -105,21 +107,37 @@ run_pytest() {
log "Pytest (tests/ -q --no-cov)"
"$VENV/bin/pytest" tests/ -q --no-cov
else
- log "Pytest (tests/ -q, 80% coverage gate — same as CI)"
+ log "Pytest (tests/ -q, 100% in-scope coverage gate — same as CI)"
"$VENV/bin/pytest" tests/ -q
fi
}
+run_browser_pytest() {
+ if "$VENV/bin/python" -c "from website_profiling.crawl.fetchers import browser_status; import sys; sys.exit(0 if browser_status().get('ok') else 1)" 2>/dev/null; then
+ log "Browser pytest (tests/test_crawl_fetchers.py tests/test_crawler_browser_e2e.py -m browser)"
+ "$VENV/bin/pytest" tests/test_crawl_fetchers.py tests/test_crawler_browser_e2e.py -m browser -q --no-cov
+ else
+ warn "Chromium unavailable — skipping browser integration tests"
+ fi
+}
+
cmd_python() {
cmd_db
cmd_venv
cmd_migrate
run_pytest
+ run_browser_pytest
log "CLI smoke (python -m src --help)"
"$VENV/bin/python" -m src --help >/dev/null
ok "Python checks passed"
}
+cmd_browser() {
+ cmd_venv
+ run_browser_pytest
+ ok "Browser pytest finished"
+}
+
cmd_web() {
cmd_web_deps
log "Web typecheck"
@@ -162,7 +180,8 @@ Local test runner — mirrors CI (python + web jobs)
./local-test Same as: all
./local-test all Postgres + migrations + pytest + CLI + web checks
- ./local-test python DB + pytest + python -m src --help
+ ./local-test python DB + pytest + browser pytest + python -m src --help
+ ./local-test browser Browser integration pytest only (skips if no Chromium)
./local-test web typecheck, lint, vitest (no Docker)
./local-test quick pytest + web without starting Docker (DB must be ready)
@@ -191,6 +210,7 @@ main() {
case "$raw_cmd" in
all|"") cmd_all ;;
python) cmd_python ;;
+ browser) cmd_browser ;;
web) cmd_web ;;
quick)
PYTEST_NO_COV=1
diff --git a/src/website_profiling/analysis/local.py b/src/website_profiling/analysis/local.py
index 47a82d2d..c55adf13 100644
--- a/src/website_profiling/analysis/local.py
+++ b/src/website_profiling/analysis/local.py
@@ -130,8 +130,6 @@ def compute_duplicate_groups(
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]
diff --git a/src/website_profiling/cli.py b/src/website_profiling/cli.py
index 1438756c..f492f16d 100644
--- a/src/website_profiling/cli.py
+++ b/src/website_profiling/cli.py
@@ -7,6 +7,7 @@
config_resolve,
enrich_cmd,
google_cmd,
+ gsc_links_cmd,
keywords_cmd,
lighthouse_cmd,
page_coach_cmd,
@@ -33,6 +34,8 @@ def main() -> None:
enrich_cmd.run(cfg, args)
elif args.command == "google":
google_cmd.run(cfg, cwd, path, args)
+ elif args.command == "gsc-links-import":
+ gsc_links_cmd.run(cfg, args)
elif args.command == "page-live":
page_live_cmd.run(cfg, cwd, args)
elif args.command == "page-coach":
diff --git a/src/website_profiling/commands/config_resolve.py b/src/website_profiling/commands/config_resolve.py
index 6064a4a5..90aab5be 100644
--- a/src/website_profiling/commands/config_resolve.py
+++ b/src/website_profiling/commands/config_resolve.py
@@ -181,13 +181,21 @@ def resolve_config(args: argparse.Namespace) -> tuple[dict[str, str], str]:
cwd = get_data_dir()
if cfg:
- print("[Config] Loaded from pipeline_config table (PostgreSQL)", flush=True)
+ print(
+ "[Config] Loaded from pipeline_config table (PostgreSQL)",
+ file=sys.stderr,
+ 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)
+ print(
+ f"[Config] Loaded from shadow file ({shadow})",
+ file=sys.stderr,
+ flush=True,
+ )
else:
print(
"No audit settings found. Open Run audit in the web app, "
@@ -221,6 +229,7 @@ def build_parser() -> argparse.ArgumentParser:
"warnings",
"enrich",
"google",
+ "gsc-links-import",
"page-live",
"page-coach",
],
@@ -266,6 +275,29 @@ def build_parser() -> argparse.ArgumentParser:
dest="property_id",
help="WebsiteProfiling property id for per-site Google credentials.",
)
+ parser.add_argument(
+ "--csv-stdin",
+ action="store_true",
+ dest="csv_stdin",
+ help="For gsc-links-import: read CSV from stdin.",
+ )
+ parser.add_argument(
+ "--csv-file",
+ default=None,
+ dest="csv_file",
+ help="For gsc-links-import: path to CSV file.",
+ )
+ parser.add_argument(
+ "--file-name",
+ default=None,
+ dest="file_name",
+ help="For gsc-links-import: original upload file name.",
+ )
+ parser.add_argument(
+ "--status",
+ action="store_true",
+ help="For gsc-links-import: print import status JSON and exit.",
+ )
parser.add_argument(
"--enrich-google",
action="store_true",
diff --git a/src/website_profiling/commands/gsc_links_cmd.py b/src/website_profiling/commands/gsc_links_cmd.py
new file mode 100644
index 00000000..ce83ba24
--- /dev/null
+++ b/src/website_profiling/commands/gsc_links_cmd.py
@@ -0,0 +1,66 @@
+"""CLI: gsc-links-import command."""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+
+def run(cfg: dict, args: argparse.Namespace) -> None:
+ from ..db import db_session, get_latest_crawl_run_id, read_crawl
+ from ..integrations.google.gsc_links_store import import_gsc_links_csv, read_gsc_links_status
+ from .config_resolve import resolve_property_id_from_cfg
+
+ property_id = getattr(args, "property_id", None)
+ if not property_id:
+ property_id = resolve_property_id_from_cfg(cfg)
+ if not property_id:
+ print("Error: --property-id is required.", file=sys.stderr)
+ sys.exit(1)
+
+ if getattr(args, "status", False):
+ with db_session() as conn:
+ status = read_gsc_links_status(conn, int(property_id))
+ print(json.dumps(status), flush=True)
+ sys.exit(0)
+
+ csv_text = ""
+ if getattr(args, "csv_stdin", False):
+ csv_text = sys.stdin.read()
+ elif getattr(args, "csv_file", None):
+ with open(args.csv_file, encoding="utf-8-sig") as f:
+ csv_text = f.read()
+ else:
+ print("Error: provide --csv-stdin or --csv-file.", file=sys.stderr)
+ sys.exit(1)
+
+ file_name = getattr(args, "file_name", None) or ""
+
+ crawl_urls: list[str] = []
+ 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:
+ pass
+
+ try:
+ with db_session() as conn:
+ result = import_gsc_links_csv(
+ conn,
+ int(property_id),
+ csv_text,
+ crawl_urls=crawl_urls,
+ file_name=file_name,
+ )
+ print(json.dumps(result), flush=True)
+ sys.exit(0)
+ except ValueError as e:
+ print(json.dumps({"ok": False, "error": str(e)}), flush=True)
+ sys.exit(1)
+ except Exception as e:
+ print(json.dumps({"ok": False, "error": str(e)}), flush=True)
+ sys.exit(1)
diff --git a/src/website_profiling/commands/pipeline_cmd.py b/src/website_profiling/commands/pipeline_cmd.py
index 8e7592e2..9906015d 100644
--- a/src/website_profiling/commands/pipeline_cmd.py
+++ b/src/website_profiling/commands/pipeline_cmd.py
@@ -17,6 +17,19 @@
should_enrich_keywords_after_report,
)
+_ALLOWED_RENDER_MODES = frozenset({"static", "javascript", "auto"})
+
+
+def _normalize_render_mode(cfg: dict) -> str:
+ mode = (cfg.get("crawl_render_mode") or "static").strip().lower()
+ if mode not in _ALLOWED_RENDER_MODES:
+ print(
+ f"Warning: invalid crawl_render_mode {mode!r}; using static.",
+ file=sys.stderr,
+ )
+ return "static"
+ return mode
+
def select_lighthouse_urls_from_crawl(df: pd.DataFrame, max_pages: int) -> list[str]:
if df.empty or "url" not in df.columns:
@@ -98,6 +111,18 @@ def _run_crawl(cfg: dict, use_database: bool) -> None:
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)
property_id = active_property_id_from_cfg(cfg)
+ render_mode = _normalize_render_mode(cfg)
+ js_concurrency = get_int(cfg, "crawl_js_concurrency", 3) or 3
+ js_timeout = get_int(cfg, "crawl_js_timeout", 30) or 30
+ js_wait_until = (cfg.get("crawl_js_wait_until") or "domcontentloaded").strip()
+ js_extra_wait_ms = get_int(cfg, "crawl_js_extra_wait_ms", 1500)
+ if js_extra_wait_ms is None:
+ js_extra_wait_ms = 1500
+ js_block_resources = get_bool(cfg, "crawl_js_block_resources", True)
+ capture_console = get_bool(cfg, "crawl_js_capture_console", True)
+ js_console_levels = (cfg.get("crawl_js_console_levels") or "error,warning").strip()
+ capture_failed_requests = get_bool(cfg, "crawl_js_capture_failed_requests", False)
+ console_max_per_page = get_int(cfg, "crawl_js_console_max_per_page", 20) or 20
print("Crawling...")
run_crawler(
start_url=start_url,
@@ -118,6 +143,16 @@ def _run_crawl(cfg: dict, use_database: bool) -> None:
content_excerpt_max_chars=content_excerpt_max_chars,
crawl_stream_to_db=crawl_stream_to_db,
property_id=property_id,
+ render_mode=render_mode,
+ js_concurrency=js_concurrency,
+ js_timeout=js_timeout,
+ js_wait_until=js_wait_until,
+ js_extra_wait_ms=js_extra_wait_ms,
+ js_block_resources=js_block_resources,
+ capture_console=capture_console,
+ js_console_levels=js_console_levels,
+ capture_failed_requests=capture_failed_requests,
+ console_max_per_page=console_max_per_page,
)
print("[Crawl] Done.", flush=True)
print("Crawl results: PostgreSQL")
@@ -233,6 +268,20 @@ def _run_plot(cfg: dict, use_database: bool) -> None:
from ..tools.plot import run_plot as do_plot
print("[Plot] Starting...", flush=True)
+ render_mode = (cfg.get("crawl_render_mode") or "").strip().lower() or None
+ if render_mode is not None and render_mode not in _ALLOWED_RENDER_MODES:
+ print(
+ f"Warning: invalid crawl_render_mode {render_mode!r}; using crawl run default.",
+ file=sys.stderr,
+ )
+ render_mode = None
+ js_concurrency = get_int(cfg, "crawl_js_concurrency", 3) or 3
+ js_timeout = get_int(cfg, "crawl_js_timeout", 30) or 30
+ js_wait_until = (cfg.get("crawl_js_wait_until") or "domcontentloaded").strip()
+ js_extra_wait_ms = get_int(cfg, "crawl_js_extra_wait_ms", 1500)
+ if js_extra_wait_ms is None:
+ js_extra_wait_ms = 1500
+ js_block_resources = get_bool(cfg, "crawl_js_block_resources", 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),
@@ -240,6 +289,12 @@ def _run_plot(cfg: dict, use_database: bool) -> None:
timeout=10,
polite_delay=0.15,
use_database=use_database,
+ render_mode=render_mode,
+ js_timeout=js_timeout,
+ js_concurrency=js_concurrency,
+ js_wait_until=js_wait_until,
+ js_extra_wait_ms=js_extra_wait_ms,
+ js_block_resources=js_block_resources,
)
print("[Plot] Done.", flush=True)
print(f"Plot data: {e}")
diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py
index 83ed3e17..2adc2f7d 100644
--- a/src/website_profiling/crawl/crawler.py
+++ b/src/website_profiling/crawl/crawler.py
@@ -40,20 +40,15 @@ def _url_matches_exclude(url: str, exclude_urls: list[str]) -> bool:
parse_tech_stack,
)
from ..analysis.page import analyze_html
+from .fetchers import build_fetcher
+from .fetchers.base import FetchResult
+from .fetchers.browser_diagnostics import merge_browser_into_page_analysis
+from .fetchers.hybrid import HybridFetcher
+from .fetchers.spa_heuristics import needs_js_render_after_parse
+from .sitemap import discover_sitemap_urls
DEFAULT_USER_AGENT = "WebsiteProfilingCrawler/1.0"
-# Headers we store for caching and security
-HEADER_KEYS = (
- "Cache-Control",
- "ETag",
- "X-Robots-Tag",
- "Strict-Transport-Security",
- "X-Content-Type-Options",
- "X-Frame-Options",
- "Content-Security-Policy",
-)
-
class Crawler:
def __init__(
@@ -72,13 +67,30 @@ def __init__(
use_wappalyzer: bool = True,
store_content_excerpt: bool = False,
content_excerpt_max_chars: int = 4096,
+ render_mode: str = "static",
+ js_concurrency: int = 3,
+ js_timeout: int = 30,
+ js_wait_until: str = "domcontentloaded",
+ js_extra_wait_ms: int = 1500,
+ js_block_resources: bool = True,
+ capture_console: bool = True,
+ js_console_levels: str = "error,warning",
+ capture_failed_requests: bool = False,
+ console_max_per_page: int = 20,
):
self.start_url = start_url.rstrip("/")
self.start_netloc = urlparse(self.start_url).netloc
+ self.render_mode = (render_mode or "static").strip().lower()
+ self.js_concurrency = max(1, int(js_concurrency))
+ effective_concurrency = (
+ self.js_concurrency
+ if self.render_mode == "javascript"
+ else max(1, int(concurrency))
+ )
self.max_pages = (
max_pages if (max_pages is not None and max_pages > 0) else float("inf")
)
- self.concurrency = max(1, int(concurrency))
+ self.concurrency = effective_concurrency
self.timeout = timeout
self.ignore_robots = ignore_robots
self.allow_external = allow_external
@@ -102,6 +114,25 @@ def __init__(
self.session = requests.Session()
self.session.headers.update({"User-Agent": self.user_agent})
self.rp = None if self.ignore_robots else load_robots(self.start_url)
+ self.fetcher = build_fetcher(
+ render_mode="javascript" if self.render_mode == "javascript" else ("auto" if self.render_mode == "auto" else "static"),
+ timeout=timeout,
+ user_agent=self.user_agent,
+ session=self.session,
+ js_concurrency=self.js_concurrency,
+ js_timeout=js_timeout,
+ js_wait_until=js_wait_until,
+ js_extra_wait_ms=js_extra_wait_ms,
+ js_block_resources=js_block_resources,
+ capture_console=capture_console,
+ js_console_levels=js_console_levels,
+ capture_failed_requests=capture_failed_requests,
+ console_max_per_page=console_max_per_page,
+ )
+ self._hybrid_fetcher = (
+ self.fetcher if isinstance(self.fetcher, HybridFetcher) else None
+ )
+ self._seed_sitemap_urls(timeout)
def same_domain(self, url):
return urlparse(url).netloc == self.start_netloc
@@ -114,36 +145,27 @@ def allowed_by_robots(self, url):
except Exception:
return True
- def fetch(self, url):
+ def _seed_sitemap_urls(self, timeout: int) -> None:
try:
- t0 = time.perf_counter()
- resp = self.session.get(
- url, timeout=self.timeout, allow_redirects=True
- )
- response_time_ms = int((time.perf_counter() - t0) * 1000)
- ct = resp.headers.get("Content-Type", "")
- is_html = resp.status_code == 200 and (
- "text/html" in ct or "application/xhtml+xml" in ct
- )
- text = resp.text if is_html else None
- content_length = len(resp.content) if resp.content is not None else 0
- final_url = resp.url or url
- redirect_chain_length = len(resp.history)
- headers_dict = {
- k: (resp.headers.get(k) or "") for k in HEADER_KEYS
- }
- return (
- resp.status_code,
- ct,
- text,
- response_time_ms,
- content_length,
- final_url,
- headers_dict,
- redirect_chain_length,
+ seeds = discover_sitemap_urls(
+ self.start_url,
+ timeout=timeout,
+ session=self.session,
)
except Exception:
- return None, None, None, None, None, None, {}, 0
+ return
+ for url in seeds:
+ if _url_matches_exclude(url, self.exclude_urls):
+ continue
+ if not self.allow_external and not self.same_domain(url):
+ continue
+ if url == self.start_url or url in self.depths:
+ continue
+ self.queue.put(url)
+ self.depths[url] = 0
+
+ def fetch(self, url) -> FetchResult:
+ return self.fetcher.fetch(url)
def _empty_seo(self, url: str, headers_dict: Optional[dict] = None, redirect_chain_length: int = 0) -> dict:
"""Default SEO/performance fields when no HTML or error."""
@@ -197,6 +219,127 @@ def _empty_seo(self, url: str, headers_dict: Optional[dict] = None, redirect_cha
"page_analysis": "{}",
}
+ def _parse_page_content(
+ self,
+ url: str,
+ text: str,
+ final_url: str,
+ headers_dict: dict,
+ redirect_chain_length: int,
+ ) -> dict:
+ """Extract title, links, and SEO/content fields from HTML."""
+ ext = self._empty_seo(url, headers_dict, redirect_chain_length)
+ title, links = parse_links(url, text)
+ meta_description, meta_description_len, h1_text, h1_count, canonical_url = (
+ parse_seo(url, text)
+ )
+ seo_ext = parse_seo_extended(text, final_url or url)
+ ext["viewport_present"] = seo_ext.get("viewport_present", False)
+ ext["viewport_content"] = seo_ext.get("viewport_content", "")
+ ext["noindex"] = seo_ext.get("noindex", False)
+ if (headers_dict.get("X-Robots-Tag") or "").lower().find("noindex") >= 0:
+ ext["noindex"] = True
+ ext["has_schema"] = seo_ext.get("has_schema", False)
+ ext["heading_sequence"] = ",".join(seo_ext.get("heading_sequence") or [])
+ ext["images_without_alt"] = seo_ext.get("images_without_alt", 0)
+ ext["images_total"] = seo_ext.get("images_total", 0)
+ ext["img_without_lazy"] = seo_ext.get("img_without_lazy", 0)
+ ext["img_without_dimensions"] = seo_ext.get("img_without_dimensions", 0)
+ ext["aria_count"] = seo_ext.get("aria_count", 0)
+ ext["mixed_content_count"] = seo_ext.get("mixed_content_count", 0)
+ res_res = parse_resources(text, final_url or url)
+ ext["script_count"] = res_res.get("script_count", 0)
+ ext["link_stylesheet_count"] = res_res.get("link_stylesheet_count", 0)
+ from bs4 import BeautifulSoup as _BS
+
+ _soup = _BS(text, "lxml")
+ excerpt_max = self.content_excerpt_max_chars if self.store_content_excerpt else 0
+ ct_data = parse_content_text(_soup, text, excerpt_max_chars=excerpt_max)
+ ext["word_count"] = ct_data.get("word_count", 0)
+ ext["reading_level"] = ct_data.get("reading_level", 0.0)
+ ext["content_html_ratio"] = ct_data.get("content_html_ratio", 0.0)
+ ext["top_keywords"] = ct_data.get("top_keywords", "[]")
+ ext["content_excerpt"] = ct_data.get("content_excerpt") or ""
+ social = parse_social_meta(_soup)
+ ext["og_title"] = social.get("og_title", "")
+ ext["og_description"] = social.get("og_description", "")
+ ext["og_image"] = social.get("og_image", "")
+ ext["og_type"] = social.get("og_type", "")
+ ext["twitter_card"] = social.get("twitter_card", "")
+ ext["twitter_title"] = social.get("twitter_title", "")
+ ext["twitter_image"] = social.get("twitter_image", "")
+ if self.use_wappalyzer:
+ ext["tech_stack"] = detect_tech_wappalyzer(
+ final_url or url, text, headers_dict, _soup, self._wappalyzer_instance
+ )
+ else:
+ ext["tech_stack"] = parse_tech_stack(_soup, headers_dict, final_url or url)
+ ext["page_analysis"] = json.dumps(
+ analyze_html(text, final_url or url, final_url or url, canonical_url)
+ )
+ return {
+ "title": title,
+ "links": links,
+ "meta_description": meta_description,
+ "meta_description_len": meta_description_len,
+ "h1_text": h1_text,
+ "h1_count": h1_count,
+ "canonical_url": canonical_url,
+ "ext": ext,
+ }
+
+ def _maybe_refetch_after_parse(
+ self,
+ url: str,
+ result: FetchResult,
+ *,
+ link_count: int,
+ same_domain_link_count: int,
+ ) -> FetchResult:
+ """Post-parse auto-mode fallback when static HTML has too few links."""
+ if self.render_mode != "auto" or self._hybrid_fetcher is None:
+ return result
+ if result.fetch_method != "static":
+ return result
+ if not needs_js_render_after_parse(
+ result,
+ link_count=link_count,
+ same_domain_link_count=same_domain_link_count,
+ ):
+ return result
+ rendered = self._hybrid_fetcher.refetch_rendered(url)
+ if rendered.status == 200 and rendered.text:
+ return rendered
+ return result
+
+ @staticmethod
+ def _sync_from_fetch_result(
+ result: FetchResult,
+ url: str,
+ *,
+ text: Optional[str],
+ fetch_method: str,
+ final_url: str,
+ content_length: int,
+ response_time_ms: Optional[int],
+ headers_dict: dict,
+ redirect_chain_length: int,
+ status: Optional[int],
+ ct: Optional[str],
+ ) -> dict:
+ """Copy all FetchResult fields after a post-parse browser refetch."""
+ return {
+ "text": result.text,
+ "fetch_method": result.fetch_method,
+ "final_url": result.final_url or url,
+ "content_length": result.content_length or content_length,
+ "response_time_ms": result.response_time_ms,
+ "headers_dict": result.headers_dict or headers_dict,
+ "redirect_chain_length": result.redirect_chain_length,
+ "status": result.status,
+ "ct": result.content_type,
+ }
+
def worker(self, url):
if not self.allowed_by_robots(url):
out = {
@@ -205,6 +348,7 @@ def worker(self, url):
"content_type": "",
"title": "",
"outlinks": 0,
+ "fetch_method": "static",
**self._empty_seo(url),
}
if self.store_outlinks:
@@ -212,14 +356,15 @@ def worker(self, url):
return out
result = self.fetch(url)
- status = result[0]
- ct = result[1]
- text = result[2]
- response_time_ms = result[3] if len(result) > 3 else None
- content_length = result[4] if len(result) > 4 else 0
- final_url = result[5] if len(result) > 5 else url
- headers_dict = result[6] if len(result) > 6 else {}
- redirect_chain_length = result[7] if len(result) > 7 else 0
+ status = result.status
+ ct = result.content_type
+ text = result.text
+ response_time_ms = result.response_time_ms
+ content_length = result.content_length or 0
+ final_url = result.final_url or url
+ headers_dict = result.headers_dict or {}
+ redirect_chain_length = result.redirect_chain_length
+ fetch_method = result.fetch_method
if status is None:
out = {
@@ -228,10 +373,15 @@ def worker(self, url):
"content_type": "",
"title": "",
"outlinks": 0,
+ "fetch_method": fetch_method,
**self._empty_seo(url, headers_dict, redirect_chain_length),
}
if self.store_outlinks:
out["outlink_targets"] = "[]"
+ if result.browser_diagnostics:
+ out["page_analysis"] = merge_browser_into_page_analysis(
+ None, result.browser_diagnostics
+ )
return out
title = ""
@@ -245,54 +395,54 @@ def worker(self, url):
ext = self._empty_seo(url, headers_dict, redirect_chain_length)
if text:
- title, links = parse_links(url, text)
- outlinks_count = len(links)
- meta_description, meta_description_len, h1_text, h1_count, canonical_url = (
- parse_seo(url, text)
+ parsed = self._parse_page_content(
+ url, text, final_url or url, headers_dict, redirect_chain_length
)
- seo_ext = parse_seo_extended(text, final_url or url)
- ext["viewport_present"] = seo_ext.get("viewport_present", False)
- ext["viewport_content"] = seo_ext.get("viewport_content", "")
- ext["noindex"] = seo_ext.get("noindex", False)
- if (headers_dict.get("X-Robots-Tag") or "").lower().find("noindex") >= 0:
- ext["noindex"] = True
- ext["has_schema"] = seo_ext.get("has_schema", False)
- ext["heading_sequence"] = ",".join(seo_ext.get("heading_sequence") or [])
- ext["images_without_alt"] = seo_ext.get("images_without_alt", 0)
- ext["images_total"] = seo_ext.get("images_total", 0)
- ext["img_without_lazy"] = seo_ext.get("img_without_lazy", 0)
- ext["img_without_dimensions"] = seo_ext.get("img_without_dimensions", 0)
- ext["aria_count"] = seo_ext.get("aria_count", 0)
- ext["mixed_content_count"] = seo_ext.get("mixed_content_count", 0)
- res_res = parse_resources(text, final_url or url)
- ext["script_count"] = res_res.get("script_count", 0)
- ext["link_stylesheet_count"] = res_res.get("link_stylesheet_count", 0)
- from bs4 import BeautifulSoup as _BS
- _soup = _BS(text, "lxml")
- excerpt_max = self.content_excerpt_max_chars if self.store_content_excerpt else 0
- ct_data = parse_content_text(_soup, text, excerpt_max_chars=excerpt_max)
- ext["word_count"] = ct_data.get("word_count", 0)
- ext["reading_level"] = ct_data.get("reading_level", 0.0)
- ext["content_html_ratio"] = ct_data.get("content_html_ratio", 0.0)
- ext["top_keywords"] = ct_data.get("top_keywords", "[]")
- ext["content_excerpt"] = ct_data.get("content_excerpt") or ""
- social = parse_social_meta(_soup)
- ext["og_title"] = social.get("og_title", "")
- ext["og_description"] = social.get("og_description", "")
- ext["og_image"] = social.get("og_image", "")
- ext["og_type"] = social.get("og_type", "")
- ext["twitter_card"] = social.get("twitter_card", "")
- ext["twitter_title"] = social.get("twitter_title", "")
- ext["twitter_image"] = social.get("twitter_image", "")
- if self.use_wappalyzer:
- ext["tech_stack"] = detect_tech_wappalyzer(
- final_url or url, text, headers_dict, _soup, self._wappalyzer_instance
- )
- else:
- ext["tech_stack"] = parse_tech_stack(_soup, headers_dict, final_url or url)
- ext["page_analysis"] = json.dumps(
- analyze_html(text, final_url or url, final_url or url, canonical_url)
+ links = parsed["links"]
+ same_domain_link_count = sum(1 for link in links if self.same_domain(link))
+ result = self._maybe_refetch_after_parse(
+ url,
+ result,
+ link_count=len(links),
+ same_domain_link_count=same_domain_link_count,
)
+ if result.text and result.text != text:
+ synced = self._sync_from_fetch_result(
+ result,
+ url,
+ text=text,
+ fetch_method=fetch_method,
+ final_url=final_url,
+ content_length=content_length,
+ response_time_ms=response_time_ms,
+ headers_dict=headers_dict,
+ redirect_chain_length=redirect_chain_length,
+ status=status,
+ ct=ct,
+ )
+ text = synced["text"]
+ fetch_method = synced["fetch_method"]
+ final_url = synced["final_url"]
+ content_length = synced["content_length"]
+ response_time_ms = synced["response_time_ms"]
+ headers_dict = synced["headers_dict"]
+ redirect_chain_length = synced["redirect_chain_length"]
+ status = synced["status"]
+ ct = synced["ct"]
+ parsed = self._parse_page_content(
+ url, text, final_url, headers_dict, redirect_chain_length
+ )
+ links = parsed["links"]
+
+ title = parsed["title"]
+ outlinks_count = len(links)
+ meta_description = parsed["meta_description"]
+ meta_description_len = parsed["meta_description_len"]
+ h1_text = parsed["h1_text"]
+ h1_count = parsed["h1_count"]
+ canonical_url = parsed["canonical_url"]
+ ext = parsed["ext"]
+
for link in links:
if _url_matches_exclude(link, self.exclude_urls):
continue
@@ -333,12 +483,18 @@ def worker(self, url):
if self.polite_delay:
time.sleep(self.polite_delay)
+ if result.browser_diagnostics:
+ ext["page_analysis"] = merge_browser_into_page_analysis(
+ ext.get("page_analysis"), result.browser_diagnostics
+ )
+
res = {
"url": url,
"status": status,
"content_type": ct or "",
"title": title,
"outlinks": outlinks_count,
+ "fetch_method": fetch_method,
**ext,
}
if self.store_outlinks:
@@ -368,98 +524,100 @@ def crawl(
desc="Pages",
disable=not show_progress,
)
- with ThreadPoolExecutor(max_workers=self.concurrency) as ex:
- while (len(self.results) < self.max_pages) and (
- not self.queue.empty() or futures
- ):
- while (
- not self.queue.empty()
- and len(futures) < self.concurrency
- and len(self.results) + len(futures) < self.max_pages
+ try:
+ with ThreadPoolExecutor(max_workers=self.concurrency) as ex:
+ while (len(self.results) < self.max_pages) and (
+ not self.queue.empty() or futures
):
- url = self.queue.get()
- if _url_matches_exclude(url, self.exclude_urls):
- continue
- with self.lock:
- if url in self.visited:
+ while (
+ not self.queue.empty()
+ and len(futures) < self.concurrency
+ and len(self.results) + len(futures) < self.max_pages
+ ):
+ url = self.queue.get()
+ if _url_matches_exclude(url, self.exclude_urls):
continue
- self.visited.add(url)
- futures.append(ex.submit(self.worker, url))
-
- remaining = []
- for f in futures:
- if f.done():
- try:
- res = f.result()
- except Exception:
- res = {
- "url": None,
- "status": "error",
- "content_type": "",
- "title": "",
- "outlinks": 0,
- "response_time_ms": "",
- "content_length": 0,
- "final_url": "",
- "meta_description": "",
- "meta_description_len": 0,
- "h1": "",
- "h1_count": 0,
- "canonical_url": "",
- "viewport_present": False,
- "viewport_content": "",
- "noindex": False,
- "has_schema": False,
- "heading_sequence": "",
- "images_without_alt": 0,
- "images_total": 0,
- "img_without_lazy": 0,
- "img_without_dimensions": 0,
- "aria_count": 0,
- "mixed_content_count": 0,
- "redirect_chain_length": 0,
- "cache_control": "",
- "etag": "",
- "x_robots_tag": "",
- "strict_transport_security": "",
- "x_content_type_options": "",
- "x_frame_options": "",
- "content_security_policy": "",
- "script_count": 0,
- "link_stylesheet_count": 0,
- "total_js_bytes": 0,
- "total_css_bytes": 0,
- "word_count": 0,
- "reading_level": 0.0,
- "content_html_ratio": 0.0,
- "top_keywords": "[]",
- "content_excerpt": "",
- "og_title": "",
- "og_description": "",
- "og_image": "",
- "og_type": "",
- "twitter_card": "",
- "twitter_title": "",
- "twitter_image": "",
- "tech_stack": "[]",
- "depth": None,
- "page_analysis": "{}",
- }
- 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)
- futures = remaining
- time.sleep(0.01)
-
- if self.queue.empty() and not futures:
- break
-
- pbar.close()
+ with self.lock:
+ if url in self.visited:
+ continue
+ self.visited.add(url)
+ futures.append(ex.submit(self.worker, url))
+
+ remaining = []
+ for f in futures:
+ if f.done():
+ try:
+ res = f.result()
+ except Exception:
+ res = {
+ "url": None,
+ "status": "error",
+ "content_type": "",
+ "title": "",
+ "outlinks": 0,
+ "response_time_ms": "",
+ "content_length": 0,
+ "final_url": "",
+ "meta_description": "",
+ "meta_description_len": 0,
+ "h1": "",
+ "h1_count": 0,
+ "canonical_url": "",
+ "viewport_present": False,
+ "viewport_content": "",
+ "noindex": False,
+ "has_schema": False,
+ "heading_sequence": "",
+ "images_without_alt": 0,
+ "images_total": 0,
+ "img_without_lazy": 0,
+ "img_without_dimensions": 0,
+ "aria_count": 0,
+ "mixed_content_count": 0,
+ "redirect_chain_length": 0,
+ "cache_control": "",
+ "etag": "",
+ "x_robots_tag": "",
+ "strict_transport_security": "",
+ "x_content_type_options": "",
+ "x_frame_options": "",
+ "content_security_policy": "",
+ "script_count": 0,
+ "link_stylesheet_count": 0,
+ "total_js_bytes": 0,
+ "total_css_bytes": 0,
+ "word_count": 0,
+ "reading_level": 0.0,
+ "content_html_ratio": 0.0,
+ "top_keywords": "[]",
+ "content_excerpt": "",
+ "og_title": "",
+ "og_description": "",
+ "og_image": "",
+ "og_type": "",
+ "twitter_card": "",
+ "twitter_title": "",
+ "twitter_image": "",
+ "tech_stack": "[]",
+ "depth": None,
+ "page_analysis": "{}",
+ }
+ 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)
+ futures = remaining
+ time.sleep(0.01)
+
+ if self.queue.empty() and not futures:
+ break
+ finally:
+ self.fetcher.close()
+ pbar.close()
if db_writer is not None:
db_writer.finish()
db_writer.join()
@@ -519,6 +677,7 @@ def crawl(
"tech_stack",
"depth",
"page_analysis",
+ "fetch_method",
]
if self.store_outlinks:
cols.append("outlink_targets")
@@ -592,11 +751,27 @@ def run_crawler(
content_excerpt_max_chars: int = 4096,
crawl_stream_to_db: bool = False,
property_id: Optional[int] = None,
+ render_mode: str = "static",
+ js_concurrency: int = 3,
+ js_timeout: int = 30,
+ js_wait_until: str = "domcontentloaded",
+ js_extra_wait_ms: int = 1500,
+ js_block_resources: bool = True,
+ capture_console: bool = True,
+ js_console_levels: str = "error,warning",
+ capture_failed_requests: bool = False,
+ console_max_per_page: int = 20,
) -> pd.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)
+ mode_label = (render_mode or "static").strip().lower()
+ conc_label = js_concurrency if mode_label == "javascript" else concurrency
+ print(
+ f" Crawling {start_url} (max_pages={max_p or 'unlimited'}, "
+ f"render_mode={mode_label}, concurrency={conc_label})...",
+ flush=True,
+ )
crawler = Crawler(
start_url=start_url,
max_pages=max_pages,
@@ -610,6 +785,16 @@ def run_crawler(
exclude_urls=exclude_urls,
store_content_excerpt=store_content_excerpt,
content_excerpt_max_chars=content_excerpt_max_chars,
+ render_mode=render_mode,
+ js_concurrency=js_concurrency,
+ js_timeout=js_timeout,
+ js_wait_until=js_wait_until,
+ js_extra_wait_ms=js_extra_wait_ms,
+ js_block_resources=js_block_resources,
+ capture_console=capture_console,
+ js_console_levels=js_console_levels,
+ capture_failed_requests=capture_failed_requests,
+ console_max_per_page=console_max_per_page,
)
stream_run_id: Optional[int] = None
if output_db:
@@ -629,7 +814,9 @@ def run_crawler(
ensure_crawl_tables_cleared(conn)
if historical:
restore_historical_data(conn, historical)
- stream_run_id = create_crawl_run(conn, start_url, property_id=property_id)
+ stream_run_id = create_crawl_run(
+ conn, start_url, property_id=property_id, render_mode=render_mode
+ )
print(f" Streaming crawl results to DB (run_id={stream_run_id})...", flush=True)
df = crawler.crawl(
@@ -656,7 +843,9 @@ def run_crawler(
ensure_crawl_tables_cleared(conn)
if historical:
restore_historical_data(conn, historical)
- run_id = create_crawl_run(conn, start_url, property_id=property_id)
+ run_id = create_crawl_run(
+ conn, start_url, property_id=property_id, render_mode=render_mode
+ )
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:
diff --git a/src/website_profiling/crawl/fetchers/__init__.py b/src/website_profiling/crawl/fetchers/__init__.py
new file mode 100644
index 00000000..f66f493b
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/__init__.py
@@ -0,0 +1,14 @@
+"""HTTP and browser fetchers for the website crawler."""
+
+from .base import FetchResult, HEADER_KEYS
+from .browser_deps import browser_status, ensure_browser_deps
+from .factory import build_fetcher, validate_browser_available
+
+__all__ = [
+ "FetchResult",
+ "HEADER_KEYS",
+ "browser_status",
+ "build_fetcher",
+ "ensure_browser_deps",
+ "validate_browser_available",
+]
diff --git a/src/website_profiling/crawl/fetchers/base.py b/src/website_profiling/crawl/fetchers/base.py
new file mode 100644
index 00000000..6681de32
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/base.py
@@ -0,0 +1,49 @@
+"""Shared fetch result types for static and browser crawlers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Literal, Optional, Protocol
+
+HEADER_KEYS = (
+ "Cache-Control",
+ "ETag",
+ "X-Robots-Tag",
+ "Strict-Transport-Security",
+ "X-Content-Type-Options",
+ "X-Frame-Options",
+ "Content-Security-Policy",
+)
+
+
+@dataclass
+class FetchResult:
+ status: Optional[int]
+ content_type: Optional[str]
+ text: Optional[str]
+ response_time_ms: Optional[int]
+ content_length: Optional[int]
+ final_url: Optional[str]
+ headers_dict: dict[str, str]
+ redirect_chain_length: int
+ fetch_method: Literal["static", "rendered"] = "static"
+ browser_diagnostics: Optional[dict[str, Any]] = None
+
+ def as_tuple(self) -> tuple:
+ """Legacy tuple shape used by Crawler.worker."""
+ return (
+ self.status,
+ self.content_type,
+ self.text,
+ self.response_time_ms,
+ self.content_length,
+ self.final_url,
+ self.headers_dict,
+ self.redirect_chain_length,
+ )
+
+
+class PageFetcher(Protocol):
+ def fetch(self, url: str) -> FetchResult: ...
+
+ def close(self) -> None: ...
diff --git a/src/website_profiling/crawl/fetchers/browser.py b/src/website_profiling/crawl/fetchers/browser.py
new file mode 100644
index 00000000..c91a764f
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/browser.py
@@ -0,0 +1,403 @@
+"""Headless browser fetcher: async Playwright on a dedicated event-loop thread."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import threading
+import time
+from concurrent.futures import Future
+from dataclasses import dataclass
+from typing import Any, Callable, Optional
+
+from .base import HEADER_KEYS, FetchResult
+from .browser_diagnostics import finalize_browser_diagnostics, truncate_diag_text
+
+_BROWSER_INSTALL_MSG = (
+ "JavaScript crawl requires Playwright and Chromium. Install: "
+ "pip install -r requirements-browser.txt. "
+ "Chrome or Chromium must be available (set CHROME_PATH if needed)."
+)
+
+_DEFAULT_CHROME_ARGS = [
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ "--headless=new",
+]
+
+_BLOCKED_RESOURCE_TYPES = frozenset({"image", "media", "font"})
+
+
+@dataclass
+class _FetchJob:
+ url: str
+ future: Future[FetchResult]
+
+
+class _PageDiagnosticsCollector:
+ def __init__(
+ self,
+ *,
+ capture_console: bool,
+ console_levels: frozenset[str],
+ capture_failed_requests: bool,
+ max_per_page: int,
+ ) -> None:
+ self.capture_console = capture_console
+ self.console_levels = console_levels
+ self.capture_failed_requests = capture_failed_requests
+ self.max_per_page = max(1, int(max_per_page))
+ self.console: list[dict[str, Any]] = []
+ self.page_errors: list[dict[str, Any]] = []
+ self.failed_requests: list[dict[str, Any]] = []
+ self._handlers: list[tuple[str, Callable[..., Any]]] = []
+
+ def attach(self, page: Any) -> None:
+ if self.capture_console:
+
+ def on_console(msg: Any) -> None:
+ level = str(getattr(msg, "type", "") or "").lower()
+ if level not in self.console_levels or len(self.console) >= self.max_per_page:
+ return
+ entry: dict[str, Any] = {
+ "level": level,
+ "text": truncate_diag_text(getattr(msg, "text", "")),
+ }
+ loc = getattr(msg, "location", None)
+ if isinstance(loc, dict):
+ if loc.get("url"):
+ entry["source_url"] = str(loc["url"])
+ if loc.get("lineNumber") is not None:
+ entry["line"] = int(loc["lineNumber"])
+ self.console.append(entry)
+
+ page.on("console", on_console)
+ self._handlers.append(("console", on_console))
+
+ def on_page_error(err: Any) -> None:
+ if len(self.page_errors) >= self.max_per_page:
+ return
+ self.page_errors.append(
+ {
+ "message": truncate_diag_text(str(err)),
+ "stack": truncate_diag_text(getattr(err, "stack", "") or ""),
+ }
+ )
+
+ page.on("pageerror", on_page_error)
+ self._handlers.append(("pageerror", on_page_error))
+
+ if self.capture_failed_requests:
+
+ def on_request_failed(request: Any) -> None:
+ if len(self.failed_requests) >= self.max_per_page:
+ return
+ failure = getattr(request, "failure", None)
+ if isinstance(failure, str):
+ fail_text = failure
+ elif failure is not None:
+ fail_text = getattr(failure, "error_text", None) or str(failure)
+ else:
+ fail_text = ""
+ self.failed_requests.append(
+ {
+ "url": str(getattr(request, "url", "") or ""),
+ "method": str(getattr(request, "method", "") or ""),
+ "failure": truncate_diag_text(fail_text),
+ }
+ )
+
+ page.on("requestfailed", on_request_failed)
+ self._handlers.append(("requestfailed", on_request_failed))
+
+ def detach(self, page: Any) -> None:
+ for event, handler in self._handlers:
+ try:
+ page.remove_listener(event, handler)
+ except Exception:
+ pass
+ self._handlers.clear()
+
+ def build(self) -> dict[str, Any]:
+ return finalize_browser_diagnostics(self.console, self.page_errors, self.failed_requests)
+
+
+class BrowserFetcher:
+ """Sync API bridging crawler threads to async Playwright page pool."""
+
+ def __init__(
+ self,
+ *,
+ timeout: int = 30,
+ user_agent: str = "WebsiteProfilingCrawler/1.0",
+ js_concurrency: int = 3,
+ wait_until: str = "domcontentloaded",
+ extra_wait_ms: int = 1500,
+ block_resources: bool = True,
+ capture_console: bool = True,
+ console_levels: frozenset[str] | None = None,
+ capture_failed_requests: bool = False,
+ console_max_per_page: int = 20,
+ ) -> None:
+ self.timeout = max(1, int(timeout))
+ self.user_agent = user_agent
+ self.js_concurrency = max(1, int(js_concurrency))
+ self.wait_until = wait_until if wait_until in ("domcontentloaded", "load", "commit") else "domcontentloaded"
+ self.extra_wait_ms = max(0, int(extra_wait_ms))
+ self.block_resources = bool(block_resources)
+ self.capture_console = bool(capture_console)
+ self.console_levels = console_levels or frozenset({"error", "warning"})
+ self.capture_failed_requests = bool(capture_failed_requests)
+ self.console_max_per_page = max(1, int(console_max_per_page))
+
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._thread: Optional[threading.Thread] = None
+ self._ready = threading.Event()
+ self._startup_error: Optional[BaseException] = None
+ self._closed = False
+ self._jobs: asyncio.Queue[_FetchJob | None] | None = None
+
+ self._thread = threading.Thread(target=self._run_loop_thread, name="browser-fetcher", daemon=True)
+ self._thread.start()
+ if not self._ready.wait(timeout=60):
+ raise RuntimeError("Browser fetcher failed to start within 60 seconds")
+ if self._startup_error is not None:
+ raise RuntimeError(_BROWSER_INSTALL_MSG) from self._startup_error
+
+ def _run_loop_thread(self) -> None:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ self._loop = loop
+ try:
+ loop.run_until_complete(self._async_main())
+ except BaseException as e:
+ self._startup_error = e
+ self._ready.set()
+ finally:
+ try:
+ pending = asyncio.all_tasks(loop)
+ for task in pending:
+ task.cancel()
+ if pending:
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
+ except Exception:
+ pass
+ loop.close()
+
+ async def _async_main(self) -> None:
+ from playwright.async_api import async_playwright
+
+ self._jobs = asyncio.Queue()
+ playwright = await async_playwright().start()
+ chrome_path = (os.environ.get("CHROME_PATH") or "").strip() or None
+ launch_kwargs: dict[str, Any] = {
+ "headless": True,
+ "args": list(_DEFAULT_CHROME_ARGS),
+ }
+ if chrome_path:
+ launch_kwargs["executable_path"] = chrome_path
+
+ browser = await playwright.chromium.launch(**launch_kwargs)
+ context = await browser.new_context(user_agent=self.user_agent)
+ semaphore = asyncio.Semaphore(self.js_concurrency)
+ pages: list[Any] = []
+ for _ in range(self.js_concurrency):
+ page = await context.new_page()
+ if self.block_resources:
+
+ async def _route_handler(route: Any, request: Any) -> None:
+ if request.resource_type in _BLOCKED_RESOURCE_TYPES:
+ await route.abort()
+ else:
+ await route.continue_()
+
+ await page.route("**/*", _route_handler)
+ pages.append(page)
+ page_queue: asyncio.Queue[Any] = asyncio.Queue()
+ for page in pages:
+ await page_queue.put(page)
+
+ async def worker() -> None:
+ assert self._jobs is not None
+ while True:
+ job = await self._jobs.get()
+ if job is None:
+ self._jobs.task_done()
+ break
+ page = await page_queue.get()
+ try:
+ async with semaphore:
+ result = await self._fetch_page(page, job.url)
+ if not job.future.done():
+ job.future.set_result(result)
+ except Exception:
+ if not job.future.done():
+ job.future.set_result(
+ FetchResult(
+ status=None,
+ content_type=None,
+ text=None,
+ response_time_ms=None,
+ content_length=None,
+ final_url=job.url,
+ headers_dict={},
+ redirect_chain_length=0,
+ fetch_method="rendered",
+ )
+ )
+ finally:
+ await page_queue.put(page)
+ self._jobs.task_done()
+
+ workers = [asyncio.create_task(worker()) for _ in range(self.js_concurrency)]
+ self._ready.set()
+ await asyncio.gather(*workers)
+
+ for page in pages:
+ try:
+ await page.close()
+ except Exception:
+ pass
+ try:
+ await context.close()
+ except Exception:
+ pass
+ try:
+ await browser.close()
+ except Exception:
+ pass
+ try:
+ await playwright.stop()
+ except Exception:
+ pass
+
+ def _diagnostics_enabled(self) -> bool:
+ return self.capture_console or self.capture_failed_requests
+
+ async def _fetch_page(self, page: Any, url: str) -> FetchResult:
+ t0 = time.perf_counter()
+ response = None
+ collector: Optional[_PageDiagnosticsCollector] = None
+ if self._diagnostics_enabled():
+ collector = _PageDiagnosticsCollector(
+ capture_console=self.capture_console,
+ console_levels=self.console_levels,
+ capture_failed_requests=self.capture_failed_requests,
+ max_per_page=self.console_max_per_page,
+ )
+ collector.attach(page)
+ try:
+ try:
+ response = await page.goto(
+ url,
+ wait_until=self.wait_until,
+ timeout=self.timeout * 1000,
+ )
+ except Exception:
+ response = None
+
+ if self.extra_wait_ms and response is not None:
+ await asyncio.sleep(self.extra_wait_ms / 1000.0)
+ finally:
+ if collector is not None:
+ collector.detach(page)
+
+ response_time_ms = int((time.perf_counter() - t0) * 1000)
+ final_url = page.url or url
+ redirect_chain_length = 1 if final_url.rstrip("/") != url.rstrip("/") else 0
+ browser_diagnostics = collector.build() if collector is not None else None
+
+ if response is None:
+ return FetchResult(
+ status=None,
+ content_type=None,
+ text=None,
+ response_time_ms=response_time_ms,
+ content_length=0,
+ final_url=final_url,
+ headers_dict={},
+ redirect_chain_length=redirect_chain_length,
+ fetch_method="rendered",
+ browser_diagnostics=browser_diagnostics,
+ )
+
+ status = response.status
+ headers = response.headers or {}
+ lower_headers = {str(k).lower(): v for k, v in headers.items()}
+ ct = lower_headers.get("content-type", "")
+ headers_dict = {
+ k: (headers.get(k) or lower_headers.get(k.lower(), "")) for k in HEADER_KEYS
+ }
+
+ is_html = status == 200 and ("text/html" in ct or "application/xhtml+xml" in ct)
+ text: Optional[str] = None
+ content_length = 0
+ if is_html:
+ try:
+ text = await page.content()
+ content_length = len(text.encode("utf-8")) if text else 0
+ except Exception:
+ text = None
+
+ return FetchResult(
+ status=status,
+ content_type=ct,
+ text=text,
+ response_time_ms=response_time_ms,
+ content_length=content_length,
+ final_url=final_url,
+ headers_dict=headers_dict,
+ redirect_chain_length=redirect_chain_length,
+ fetch_method="rendered",
+ browser_diagnostics=browser_diagnostics,
+ )
+
+ def fetch(self, url: str) -> FetchResult:
+ if self._closed:
+ return FetchResult(
+ status=None,
+ content_type=None,
+ text=None,
+ response_time_ms=None,
+ content_length=None,
+ final_url=url,
+ headers_dict={},
+ redirect_chain_length=0,
+ fetch_method="rendered",
+ )
+ assert self._loop is not None and self._jobs is not None
+ fut: Future[FetchResult] = Future()
+ job = _FetchJob(url=url, future=fut)
+
+ def _submit() -> None:
+ assert self._jobs is not None
+ self._jobs.put_nowait(job)
+
+ self._loop.call_soon_threadsafe(_submit)
+ total_timeout = self.timeout + (self.extra_wait_ms / 1000.0) + 15
+ try:
+ return fut.result(timeout=total_timeout)
+ except Exception:
+ return FetchResult(
+ status=None,
+ content_type=None,
+ text=None,
+ response_time_ms=None,
+ content_length=None,
+ final_url=url,
+ headers_dict={},
+ redirect_chain_length=0,
+ fetch_method="rendered",
+ )
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ if self._loop is None or self._jobs is None:
+ return
+ for _ in range(self.js_concurrency):
+ self._loop.call_soon_threadsafe(self._jobs.put_nowait, None)
+ if self._thread is not None and self._thread.is_alive():
+ self._thread.join(timeout=30)
diff --git a/src/website_profiling/crawl/fetchers/browser_deps.py b/src/website_profiling/crawl/fetchers/browser_deps.py
new file mode 100644
index 00000000..055a5ea0
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/browser_deps.py
@@ -0,0 +1,109 @@
+"""Install and verify Playwright + Chromium for JavaScript crawls."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from .browser import _BROWSER_INSTALL_MSG
+
+_CHROME_NAMES = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
+
+
+def _repo_root() -> Path:
+ root = (os.environ.get("WEBSITE_PROFILING_ROOT") or "").strip()
+ if root:
+ return Path(root)
+ return Path(__file__).resolve().parents[4]
+
+
+def _auto_install_enabled() -> bool:
+ flag = os.environ.get("WP_SKIP_BROWSER_AUTO_INSTALL", "").strip().lower()
+ return flag not in ("1", "true", "yes")
+
+
+def _playwright_importable() -> bool:
+ try:
+ import playwright # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def _system_chromium_available() -> bool:
+ chrome_path = (os.environ.get("CHROME_PATH") or "").strip()
+ if chrome_path and os.path.isfile(chrome_path):
+ return True
+ return any(shutil.which(name) for name in _CHROME_NAMES)
+
+
+def _playwright_chromium_available() -> bool:
+ if not _playwright_importable():
+ return False
+ try:
+ from playwright.sync_api import sync_playwright
+
+ with sync_playwright() as playwright:
+ executable = (playwright.chromium.executable_path or "").strip()
+ return bool(executable and os.path.isfile(executable))
+ except Exception:
+ return False
+
+
+def chromium_available() -> bool:
+ return _system_chromium_available() or _playwright_chromium_available()
+
+
+def browser_status() -> dict[str, str | bool]:
+ """Non-raising check for JS crawl prerequisites."""
+ if not _playwright_importable():
+ return {"ok": False, "message": _BROWSER_INSTALL_MSG}
+ if chromium_available():
+ return {"ok": True}
+ return {"ok": False, "message": _BROWSER_INSTALL_MSG}
+
+
+def _pip_install_browser_requirements() -> None:
+ req = _repo_root() / "requirements-browser.txt"
+ if not req.is_file():
+ raise RuntimeError(f"Missing {req.name}; cannot auto-install Playwright.")
+ subprocess.run(
+ [sys.executable, "-m", "pip", "install", "-q", "-r", str(req)],
+ check=True,
+ cwd=_repo_root(),
+ )
+
+
+def _playwright_install_chromium() -> None:
+ subprocess.run(
+ [sys.executable, "-m", "playwright", "install", "chromium"],
+ check=True,
+ cwd=_repo_root(),
+ )
+
+
+def ensure_browser_deps(*, install: bool | None = None) -> dict[str, str | bool]:
+ """Install Playwright and Chromium when missing, then return browser_status()."""
+ status = browser_status()
+ if status["ok"]:
+ return status
+
+ should_install = _auto_install_enabled() if install is None else install
+ if not should_install:
+ return status
+
+ try:
+ if not _playwright_importable():
+ _pip_install_browser_requirements()
+ if not chromium_available():
+ _playwright_install_chromium()
+ except (OSError, subprocess.CalledProcessError) as exc:
+ return {
+ "ok": False,
+ "message": f"{_BROWSER_INSTALL_MSG} Auto-install failed: {exc}",
+ }
+
+ return browser_status()
diff --git a/src/website_profiling/crawl/fetchers/browser_diagnostics.py b/src/website_profiling/crawl/fetchers/browser_diagnostics.py
new file mode 100644
index 00000000..e0d8671e
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/browser_diagnostics.py
@@ -0,0 +1,149 @@
+"""Browser runtime diagnostics: console messages, page errors, failed requests."""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Optional
+
+_TEXT_MAX = 500
+
+
+def parse_console_levels(raw: str) -> frozenset[str]:
+ parts = [p.strip().lower() for p in (raw or "error,warning").split(",") if p.strip()]
+ return frozenset(parts) if parts else frozenset({"error", "warning"})
+
+
+def truncate_diag_text(value: Any, max_len: int = _TEXT_MAX) -> str:
+ s = str(value or "")
+ if len(s) <= max_len:
+ return s
+ return s[: max_len - 3] + "..."
+
+
+def finalize_browser_diagnostics(
+ console: list[dict[str, Any]],
+ page_errors: list[dict[str, Any]],
+ failed_requests: list[dict[str, Any]],
+) -> dict[str, Any]:
+ console_error_count = sum(1 for c in console if c.get("level") == "error")
+ console_warning_count = sum(1 for c in console if c.get("level") == "warning")
+ return {
+ "console": console,
+ "page_errors": page_errors,
+ "failed_requests": failed_requests,
+ "summary": {
+ "console_error_count": console_error_count,
+ "console_warning_count": console_warning_count,
+ "page_error_count": len(page_errors),
+ "failed_request_count": len(failed_requests),
+ },
+ }
+
+
+def merge_browser_into_page_analysis(
+ page_analysis_json: Optional[str],
+ browser_diagnostics: Optional[dict[str, Any]],
+) -> str:
+ if not browser_diagnostics:
+ return page_analysis_json or "{}"
+ pa: dict[str, Any] = {}
+ if page_analysis_json:
+ try:
+ parsed = json.loads(page_analysis_json)
+ if isinstance(parsed, dict):
+ pa = parsed
+ except json.JSONDecodeError:
+ pa = {}
+ pa["browser"] = browser_diagnostics
+ return json.dumps(pa)
+
+
+def browser_summary_from_page_analysis(pa: dict[str, Any]) -> dict[str, int]:
+ browser = pa.get("browser") if isinstance(pa.get("browser"), dict) else {}
+ summary = browser.get("summary") if isinstance(browser.get("summary"), dict) else {}
+ return {
+ "console_error_count": int(summary.get("console_error_count") or 0),
+ "console_warning_count": int(summary.get("console_warning_count") or 0),
+ "page_error_count": int(summary.get("page_error_count") or 0),
+ "failed_request_count": int(summary.get("failed_request_count") or 0),
+ }
+
+
+def _parse_page_analysis_cell(raw: object) -> dict[str, Any]:
+ if raw is None:
+ return {}
+ try:
+ import pandas as pd
+
+ if isinstance(raw, float) and pd.isna(raw):
+ return {}
+ except Exception:
+ pass
+ s = str(raw).strip()
+ if not s or s == "{}":
+ return {}
+ try:
+ o = json.loads(s)
+ return o if isinstance(o, dict) else {}
+ except json.JSONDecodeError:
+ return {}
+
+
+def aggregate_browser_diagnostics_df(df) -> dict[str, Any]:
+ """Site-level browser diagnostic counts from crawl DataFrame page_analysis cells."""
+ pages_with_console_errors = 0
+ pages_with_page_errors = 0
+ total_console_errors = 0
+ total_page_errors = 0
+ message_counts: dict[str, dict[str, Any]] = {}
+
+ if df is None or getattr(df, "empty", True) or "page_analysis" not in df.columns:
+ return {}
+
+ for _, row in df.iterrows():
+ pa = _parse_page_analysis_cell(row.get("page_analysis"))
+ if not pa:
+ continue
+ counts = browser_summary_from_page_analysis(pa)
+ url = str(row.get("url") or "").strip()
+ ce = counts["console_error_count"]
+ pe = counts["page_error_count"]
+ if ce > 0:
+ pages_with_console_errors += 1
+ total_console_errors += ce
+ if pe > 0:
+ pages_with_page_errors += 1
+ total_page_errors += pe
+ browser = pa.get("browser") if isinstance(pa.get("browser"), dict) else {}
+ for msg in browser.get("console") or []:
+ if not isinstance(msg, dict) or msg.get("level") != "error":
+ continue
+ text = str(msg.get("text") or "").strip()
+ if not text:
+ continue
+ bucket = message_counts.setdefault(text, {"text": text, "count": 0, "sample_urls": []})
+ bucket["count"] += 1
+ if url and url not in bucket["sample_urls"] and len(bucket["sample_urls"]) < 3:
+ bucket["sample_urls"].append(url)
+
+ if (
+ pages_with_console_errors == 0
+ and pages_with_page_errors == 0
+ and total_console_errors == 0
+ and total_page_errors == 0
+ ):
+ return {}
+
+ top_console_messages = sorted(
+ message_counts.values(),
+ key=lambda x: int(x.get("count") or 0),
+ reverse=True,
+ )[:5]
+
+ return {
+ "pages_with_console_errors": pages_with_console_errors,
+ "pages_with_page_errors": pages_with_page_errors,
+ "total_console_errors": total_console_errors,
+ "total_page_errors": total_page_errors,
+ "top_console_messages": top_console_messages,
+ }
diff --git a/src/website_profiling/crawl/fetchers/factory.py b/src/website_profiling/crawl/fetchers/factory.py
new file mode 100644
index 00000000..d4086dd2
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/factory.py
@@ -0,0 +1,98 @@
+"""Build page fetchers from crawl configuration."""
+
+from __future__ import annotations
+
+from typing import Callable, Literal, Optional
+
+import requests
+
+from .base import PageFetcher
+from .browser import BrowserFetcher, _BROWSER_INSTALL_MSG
+from .browser_deps import browser_status, ensure_browser_deps
+from .browser_diagnostics import parse_console_levels
+from .hybrid import HybridFetcher
+from .static import StaticFetcher
+
+RenderMode = Literal["static", "javascript", "auto"]
+
+
+def validate_browser_available() -> None:
+ """Raise RuntimeError if JS crawl prerequisites are missing."""
+ status = ensure_browser_deps()
+ if not status["ok"]:
+ raise RuntimeError(str(status.get("message") or _BROWSER_INSTALL_MSG))
+
+
+def _browser_factory(
+ *,
+ js_timeout: int,
+ user_agent: str,
+ js_concurrency: int,
+ js_wait_until: str,
+ js_extra_wait_ms: int,
+ js_block_resources: bool,
+ capture_console: bool = True,
+ console_levels: frozenset[str] | None = None,
+ capture_failed_requests: bool = False,
+ console_max_per_page: int = 20,
+) -> Callable[[], PageFetcher]:
+ def _make() -> PageFetcher:
+ return BrowserFetcher(
+ timeout=js_timeout,
+ user_agent=user_agent,
+ js_concurrency=js_concurrency,
+ wait_until=js_wait_until,
+ extra_wait_ms=js_extra_wait_ms,
+ block_resources=js_block_resources,
+ capture_console=capture_console,
+ console_levels=console_levels,
+ capture_failed_requests=capture_failed_requests,
+ console_max_per_page=console_max_per_page,
+ )
+
+ return _make
+
+
+def build_fetcher(
+ *,
+ render_mode: RenderMode = "static",
+ timeout: int = 12,
+ user_agent: str = "WebsiteProfilingCrawler/1.0",
+ session: Optional[requests.Session] = None,
+ js_concurrency: int = 3,
+ js_timeout: int = 30,
+ js_wait_until: str = "domcontentloaded",
+ js_extra_wait_ms: int = 1500,
+ js_block_resources: bool = True,
+ capture_console: bool = True,
+ js_console_levels: str = "error,warning",
+ capture_failed_requests: bool = False,
+ console_max_per_page: int = 20,
+) -> PageFetcher:
+ mode = (render_mode or "static").strip().lower()
+ levels = parse_console_levels(js_console_levels)
+ browser_kwargs = dict(
+ js_timeout=js_timeout,
+ user_agent=user_agent,
+ js_concurrency=js_concurrency,
+ js_wait_until=js_wait_until,
+ js_extra_wait_ms=js_extra_wait_ms,
+ js_block_resources=js_block_resources,
+ capture_console=capture_console,
+ console_levels=levels,
+ capture_failed_requests=capture_failed_requests,
+ console_max_per_page=console_max_per_page,
+ )
+ if mode == "javascript":
+ validate_browser_available()
+ return _browser_factory(**browser_kwargs)()
+ static = StaticFetcher(timeout=timeout, user_agent=user_agent, session=session)
+ if mode == "static":
+ return static
+ if mode == "auto":
+ validate_browser_available()
+ return HybridFetcher(
+ static,
+ _browser_factory(**browser_kwargs),
+ )
+ return static
diff --git a/src/website_profiling/crawl/fetchers/hybrid.py b/src/website_profiling/crawl/fetchers/hybrid.py
new file mode 100644
index 00000000..df4d6df2
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/hybrid.py
@@ -0,0 +1,50 @@
+"""Static-first fetcher with optional JavaScript fallback for SPA shells."""
+
+from __future__ import annotations
+
+from typing import Callable, Optional
+
+from .base import FetchResult, PageFetcher
+from .spa_heuristics import needs_js_render
+
+
+class HybridFetcher:
+ """Try static HTTP first; re-fetch with browser when SPA heuristics match."""
+
+ def __init__(
+ self,
+ static: PageFetcher,
+ browser_factory: Callable[[], PageFetcher],
+ ) -> None:
+ self._static = static
+ self._browser_factory = browser_factory
+ self._browser_instance: Optional[PageFetcher] = None
+
+ def _get_browser(self) -> PageFetcher:
+ if self._browser_instance is None:
+ self._browser_instance = self._browser_factory()
+ return self._browser_instance
+
+ def fetch(self, url: str) -> FetchResult:
+ static_result = self._static.fetch(url)
+ if not needs_js_render(static_result):
+ return static_result
+ rendered = self._get_browser().fetch(url)
+ if rendered.status is None and static_result.status is not None:
+ return static_result
+ return rendered
+
+ def refetch_rendered(self, url: str) -> FetchResult:
+ """Re-fetch with browser (post-parse auto-mode fallback)."""
+ rendered = self._get_browser().fetch(url)
+ if rendered.status is None:
+ static_result = self._static.fetch(url)
+ if static_result.status is not None:
+ return static_result
+ return rendered
+
+ def close(self) -> None:
+ self._static.close()
+ if self._browser_instance is not None:
+ self._browser_instance.close()
+ self._browser_instance = None
diff --git a/src/website_profiling/crawl/fetchers/spa_heuristics.py b/src/website_profiling/crawl/fetchers/spa_heuristics.py
new file mode 100644
index 00000000..c4ad055f
--- /dev/null
+++ b/src/website_profiling/crawl/fetchers/spa_heuristics.py
@@ -0,0 +1,95 @@
+"""Detect SPA shells that benefit from JavaScript re-fetch."""
+
+from __future__ import annotations
+
+from .base import FetchResult
+
+_SPA_MARKERS = (
+ "__NEXT_DATA__",
+ 'id="root"',
+ "id='root'",
+ 'id="app"',
+ "id='app'",
+ "data-reactroot",
+ "cdn.shopify.com",
+ "__NUXT__",
+ "window.__INITIAL_STATE__",
+ "_next/static",
+ "__REACT_DEVTOOLS",
+ "react.production.min",
+ "__vue",
+ "vue.min.js",
+ "ng-version",
+ "ng-app",
+ "svelte",
+)
+
+
+def _has_spa_markers(html: str) -> bool:
+ lower = html.lower()
+ return any(marker.lower() in lower for marker in _SPA_MARKERS)
+
+
+def _html_word_count(html: str) -> int:
+ from bs4 import BeautifulSoup
+
+ try:
+ text = BeautifulSoup(html, "lxml").get_text(separator=" ", strip=True)
+ return len(text.split())
+ except Exception:
+ return 0
+
+
+def needs_js_render(result: FetchResult) -> bool:
+ """True when static HTML looks like a client-rendered shell."""
+ if result.fetch_method == "rendered":
+ return False
+ if result.status != 200 or not result.text:
+ return False
+ html = result.text
+ html_len = len(html)
+ if html_len == 0:
+ return False
+
+ lower = html.lower()
+ if _has_spa_markers(html):
+ return True
+
+ script_count = lower.count("
+
+
+