|
5 | 5 | - Docker-first and Docker-only unless user asks otherwise. |
6 | 6 | - Keep repo focused: stable Botasaurus scrape API wrapper, not generic framework. |
7 | 7 |
|
| 8 | +## Project Layout |
| 9 | + |
| 10 | +``` |
| 11 | +app/ |
| 12 | + main.py # create_app() factory; module-level `app` for uvicorn |
| 13 | + constants.py # SERVICE_NAME and other shared literals |
| 14 | + config.py # Settings + nested SentrySettings; single env source of truth |
| 15 | + exceptions.py # domain exceptions (e.g. RequestIdCollisionError) |
| 16 | + logging_config.py # setup_logging + get_logger(); single owner of logger name |
| 17 | + api/ |
| 18 | + deps.py # FastAPI Depends: settings, engine, executor, ScrapeService |
| 19 | + errors.py # 422 + 500 handlers → scrape error envelope |
| 20 | + openapi.py # route OpenAPI metadata (configure_openapi at app creation) |
| 21 | + openapi_examples.py # OpenAPI examples built from Pydantic model instances |
| 22 | + routes/ # thin HTTP handlers (health, scrape) |
| 23 | + domain/ |
| 24 | + scrape_service.py # request-id resolution, URL guardrails, threadpool execution, status mapping |
| 25 | + engine/ |
| 26 | + orchestrator.py # ScraperEngine.execute |
| 27 | + session.py # ScrapeSession lifecycle |
| 28 | + budget.py # wall-clock budget math shared across tiers (elapsed_ms, step budgets) |
| 29 | + request_tier.py # HTTP/curl_cffi path |
| 30 | + browser_tier.py # Chromium path |
| 31 | + strategies.py # NavigationMode resolution, driver helpers |
| 32 | + driver_capabilities.py # DriverProtocol + call_if_available adapter |
| 33 | + envelope.py # success/error builders, UTF-8 HTML normalization |
| 34 | + schemas/ |
| 35 | + enums.py # ExecutionMode, NavigationMode, ErrorCategory, ... |
| 36 | + request.py # ScrapeRequest and validators |
| 37 | + response.py # ScrapeSuccess, ScrapeError, HealthResponse, ... |
| 38 | + infra/ # telemetry, progress, metadata, xhr, runtime cleanup, sentry |
| 39 | + security/ # UrlGuard SSRF guardrails |
| 40 | +scripts/ |
| 41 | + bench_scrape.py # TestClient wall-time bench for POST /scrape (request tier) |
| 42 | +tests/ |
| 43 | + api/ # HTTP contract, request schema, 504 timeout envelope tests |
| 44 | + domain/ # ScrapeService unit tests (timeout error mapping) |
| 45 | + engine/ # ScraperEngine units, isolation regressions, timeout progress |
| 46 | + infra/ # challenge, metadata, xhr, progress, sentry, telemetry, request-id, cleanup |
| 47 | + security/ # UrlGuard tests |
| 48 | + support/ |
| 49 | + http.py # test_client() context manager + dependency_overrides helper |
| 50 | + fakes.py # shared FakeDriver, FakeRequest, fake_request_cls, ... |
| 51 | + factories.py # scrape_request(), example_url() |
| 52 | + test_bench_regression.py # lightweight guard that bench script completes (root: guards scripts/) |
| 53 | +``` |
| 54 | + |
| 55 | +Layer rules: |
| 56 | + |
| 57 | +| Layer | May import | Must not import | |
| 58 | +| --- | --- | --- | |
| 59 | +| `api/routes` | `domain`, `api/deps`, `schemas.*` | `engine` internals, Botasaurus | |
| 60 | +| `domain` | `engine`, `security`, `schemas.*`, `infra` | FastAPI, Botasaurus | |
| 61 | +| `engine` | `infra`, `security`, `schemas.*`, `config` | FastAPI | |
| 62 | +| `infra` | Botasaurus, CDP (lazy at use sites) | FastAPI, routes | |
| 63 | + |
| 64 | +Conventions: |
| 65 | + |
| 66 | +- Use `tests/support/http.test_client()` for HTTP tests; it runs lifespan and manages `dependency_overrides`. No ad-hoc `TestClient(create_app())` in test modules. |
| 67 | +- Loggers come from `app.logging_config.get_logger()`; do not call `logging.getLogger` with a literal name. |
| 68 | +- Wall-clock/timeout math (elapsed, remaining, step budgets) lives in `app/engine/budget.py`; tiers must not re-derive it. |
| 69 | +- Config: add env vars to `Settings` in `config.py`; call `reset_settings_cache()` in tests that patch env. |
| 70 | +- Wire types live in `app/schemas/` submodules; import directly (`from app.schemas.request import ScrapeRequest`). No long-lived re-export shim. |
| 71 | +- Domain logic stays out of route handlers and Pydantic shells. |
| 72 | +- Typed exceptions over string-matching (`RequestIdCollisionError`, not `RuntimeError` message checks). |
| 73 | +- `NavigationMode` end-to-end in engine code; no raw strategy strings outside enum conversion boundaries. |
| 74 | +- Optional Botasaurus driver methods go through `driver_capabilities.call_if_available` / `resolve_callable` only; do not ad-hoc `getattr(driver, ...)`. |
| 75 | +- OpenAPI route examples come from `openapi_examples.py` model instances, not hand-typed dicts. |
| 76 | +- Route modules expose `create_router()` factories included from `create_app()` **after** `configure_openapi(settings)`, so timeout-dependent response metadata is not frozen on first import. |
| 77 | +- Botasaurus/CDP imports are lazy inside tier entrypoints (`run_request_tier`, `run_browser_tier`, XhrCollector methods), not at app import time. |
| 78 | + |
| 79 | +## Singleton + Settings Threading |
| 80 | + |
| 81 | +- **One** `ScraperEngine` and **one** `ThreadPoolExecutor` are created in `create_app()` lifespan and stored on `app.state`. |
| 82 | +- `get_engine` / `get_executor` / `SettingsDep` read from `request.app.state` (not per-request construction). |
| 83 | +- Lifespan shutdown calls `executor.shutdown(wait=False, cancel_futures=True)` on the real pool instance. |
| 84 | +- Do **not** freeze settings at import time in schemas or OpenAPI modules. `configure_openapi(settings)` runs during `create_app()`; `clamp_wait_timeout_seconds` reads live `get_settings()`. |
| 85 | +- `ScraperEngine` and tier functions require an injected `Settings` parameter; no `get_settings()` fallback in the hot path. |
| 86 | + |
| 87 | +## Isolation Invariants |
| 88 | + |
| 89 | +| Resource | Lifetime | Rule | |
| 90 | +| --- | --- | --- | |
| 91 | +| `ScraperEngine` | process (app.state) | shared | |
| 92 | +| `ThreadPoolExecutor` | process (app.state) | shared, sized by `SCRAPE_MAX_WORKERS` | |
| 93 | +| `_active_request_ids` | in-process memory | shared; collision guard | |
| 94 | +| runtime dir `/tmp/scrape/<request_id>` | per request | isolated; deleted in `finally` | |
| 95 | +| browser profile | per request | isolated; no reuse | |
| 96 | +| Botasaurus Driver | per request | isolated; closed in `finally` | |
| 97 | + |
| 98 | +Multi-worker uvicorn breaks in-process collision detection unless request ids are sticky to a worker. Default to single-worker for isolation semantics. |
| 99 | + |
8 | 100 | ## Contract (Do Not Break) |
9 | 101 |
|
10 | 102 | - Endpoints: `GET /health`, `POST /scrape`. |
11 | 103 | - `openapi.yaml` is generated from `app.openapi()` via `make openapi`. Do not hand-edit. `make openapi-verify` is part of `make check`. Spectral (`make spectral`) lints the snapshot; do not add a post-processor that mutates the dump. |
12 | | -- Wire types live in `app/schemas.py`. Engine imports them. Routes `model_dump()` once into `JSONResponse`. |
| 104 | +- Wire types live in `app/schemas/`. Engine imports them. Routes call `ScrapeService.process()` and serialize via `json_response()`. |
13 | 105 | - OpenAPI `info.version` is `2.0.0`. Schema names: `ScrapeSuccess` (200) and `ScrapeError` (400/403/422/502/504). No `ScrapeResponse` alias. |
14 | 106 | - Success `/scrape` fields: `url`, `final_url`, `status_code`, `headers`, `html`, `metadata_error`, `xhr_responses`, `diagnostics`. |
15 | 107 | - When `html` is present, document `headers` `content-type` is `text/html; charset=utf-8` and `html` is UTF-8-normalized. |
|
36 | 128 | - close browser driver |
37 | 129 | - delete request runtime dir |
38 | 130 | - remove in-memory active request id |
39 | | -- Keep request-id collision/invariant guard (`_active_request_ids`) intact. |
| 131 | +- Before each scrape, prune orphaned runtime dirs not tied to an active request id; ENOSPC on profile creation retries after another prune pass. Optional `SCRAPE_RUNTIME_MIN_FREE_BYTES` (default 256MiB) logs when the runtime filesystem is low. |
| 132 | +- Keep request-id collision/invariant guard (`_active_request_ids`) intact; raises `RequestIdCollisionError`. |
40 | 133 | - `driver.requests.get` metadata is best-effort; metadata failure must not fail HTML success. |
41 | 134 | - Keep strategy engine behavior: |
42 | 135 | - `auto` mode attempt order: `google_get` -> `google_get_bypass` -> `get` |
|
46 | 139 | - keep `/usr/bin/google-chrome` symlink to Chromium for compatibility |
47 | 140 | - If browser install logic changes, re-verify binary path and Botasaurus startup. |
48 | 141 |
|
| 142 | +## Performance |
| 143 | + |
| 144 | +- Baseline bench: `PYTHONPATH=. .venv/bin/python3 scripts/bench_scrape.py --runs 10` (TestClient, `execution_mode=request`). |
| 145 | +- Record p50 wall time when changing hot paths; avoid regressions vs prior baseline. |
| 146 | +- On low-RAM hosts (Docker `--memory=768m`, 1–2 vCPU), tune `SCRAPE_MAX_WORKERS` to `1` or `2`; higher values increase queue wait and swap without improving wall time. |
| 147 | +- Browser tier skips XHR harvest before Cloudflare bypass; one consolidated `collect_page_state` pass runs after bypass. |
| 148 | + |
| 149 | +## Types |
| 150 | + |
| 151 | +- `make typecheck` runs **`pyright` strict** on `app tests` and is part of `make check`. |
| 152 | +- Vendor seams: local stubs in `typings/` (`stubPath` in `pyproject.toml`); CDP shapes in `app/infra/cdp_types.py`. |
| 153 | +- Engine driver seams use `DriverProtocol` / `CdpTabProtocol` in `driver_capabilities.py`; cast vendor `Driver` at construction when needed. |
| 154 | +- Tests construct `ScrapeRequest` via `tests/support/factories.py` (`scrape_request`, `example_url`); fakes implement protocol shapes in `tests/support/fakes.py`. |
| 155 | +- Residual policy and file-level test directives: `docs/typing-residuals.md`. |
| 156 | + |
49 | 157 | ## Safety |
50 | 158 |
|
51 | 159 | - Keep SSRF guardrails: localhost/domain checks and blocked IP classes (loopback/private/link-local/multicast/reserved/unspecified). |
52 | 160 | - Do not weaken URL validation without explicit request plus docs/tests updates. |
53 | 161 |
|
54 | 162 | ## Done Criteria |
55 | 163 |
|
56 | | -- Run `make check` before finish. |
| 164 | +- Run `make check` before finish (lint, test, typecheck, openapi-verify). |
57 | 165 | - When Pydantic models or route response metadata change, run `make openapi` and commit the snapshot with the code change. |
58 | 166 | - When API contract, Docker behavior, or error semantics change, also run `make smoke`. |
59 | 167 | - `make smoke` must cover build, boot, `/health`, `/scrape` happy path, strategy override, retry path, isolation check, localhost guardrail. |
|
0 commit comments