|
9 | 9 |
|
10 | 10 | ``` |
11 | 11 | app/ |
12 | | - main.py # create_app() factory; module-level `app` for uvicorn |
13 | | - config.py # Settings (pydantic-settings); single env source of truth |
14 | | - schemas.py # wire Pydantic models + OpenAPI examples |
15 | | - exceptions.py # domain exceptions (e.g. RequestIdCollisionError) |
| 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 | 16 | api/ |
17 | | - deps.py # FastAPI Depends: settings, engine, executor, ScrapeService |
18 | | - errors.py # 422 + 500 handlers → scrape error envelope |
19 | | - openapi.py # route OpenAPI metadata |
20 | | - routes/ # thin HTTP handlers (health, scrape) |
| 17 | + deps.py # FastAPI Depends: settings, engine, executor, ScrapeService |
| 18 | + errors.py # 422 + 500 handlers → scrape error envelope |
| 19 | + openapi.py # route OpenAPI metadata (configure_openapi at app creation) |
| 20 | + openapi_examples.py # OpenAPI examples built from Pydantic model instances |
| 21 | + routes/ # thin HTTP handlers (health, scrape) |
21 | 22 | domain/ |
22 | | - scrape_service.py # validation, threadpool orchestration, status mapping |
| 23 | + scrape_service.py # validation, threadpool orchestration, status mapping |
23 | 24 | engine/ |
24 | | - orchestrator.py # ScraperEngine.execute |
25 | | - session.py # ScrapeSession lifecycle |
26 | | - request_tier.py # HTTP/curl_cffi path |
27 | | - browser_tier.py # Chromium path |
28 | | - strategies.py # NavigationMode resolution, driver helpers |
29 | | - envelope.py # success/error builders, UTF-8 HTML normalization |
30 | | - infra/ # telemetry, progress, metadata, xhr, runtime cleanup, sentry |
31 | | - security/ # UrlGuard SSRF guardrails |
| 25 | + orchestrator.py # ScraperEngine.execute |
| 26 | + session.py # ScrapeSession lifecycle |
| 27 | + request_tier.py # HTTP/curl_cffi path |
| 28 | + browser_tier.py # Chromium path |
| 29 | + strategies.py # NavigationMode resolution, driver helpers |
| 30 | + driver_capabilities.py # DriverProtocol + call_if_available adapter |
| 31 | + envelope.py # success/error builders, UTF-8 HTML normalization |
| 32 | + schemas/ |
| 33 | + enums.py # ExecutionMode, NavigationMode, ErrorCategory, ... |
| 34 | + request.py # ScrapeRequest and validators |
| 35 | + response.py # ScrapeSuccess, ScrapeError, HealthResponse, ... |
| 36 | + infra/ # telemetry, progress, metadata, xhr, runtime cleanup, sentry |
| 37 | + security/ # UrlGuard SSRF guardrails |
| 38 | +scripts/ |
| 39 | + bench_scrape.py # TestClient wall-time bench for POST /scrape (request tier) |
32 | 40 | tests/ |
33 | | - support/http.py # TestClient + dependency_overrides helper |
| 41 | + api/ # HTTP contract + request schema tests |
| 42 | + domain/ # (reserved) |
| 43 | + engine/ # ScraperEngine unit tests |
| 44 | + infra/ # challenge, metadata, xhr unit tests |
| 45 | + security/ # UrlGuard tests |
| 46 | + support/ |
| 47 | + http.py # TestClient + dependency_overrides helper |
| 48 | + fakes.py # shared FakeDriver, FakeRequest, ... |
| 49 | + test_bench_regression.py # lightweight guard that bench script completes |
| 50 | + test_engine_isolation.py # singleton + per-request isolation regression tests |
34 | 51 | ``` |
35 | 52 |
|
36 | 53 | Layer rules: |
37 | 54 |
|
38 | 55 | | Layer | May import | Must not import | |
39 | 56 | | --- | --- | --- | |
40 | | -| `api/routes` | `domain`, `api/deps`, `schemas` | `engine` internals, Botasaurus | |
41 | | -| `domain` | `engine`, `security`, `schemas`, `infra` | FastAPI, Botasaurus | |
42 | | -| `engine` | `infra`, `security`, `schemas`, `config` | FastAPI | |
43 | | -| `infra` | Botasaurus, CDP | FastAPI, routes | |
| 57 | +| `api/routes` | `domain`, `api/deps`, `schemas.*` | `engine` internals, Botasaurus | |
| 58 | +| `domain` | `engine`, `security`, `schemas.*`, `infra` | FastAPI, Botasaurus | |
| 59 | +| `engine` | `infra`, `security`, `schemas.*`, `config` | FastAPI | |
| 60 | +| `infra` | Botasaurus, CDP (lazy at use sites) | FastAPI, routes | |
44 | 61 |
|
45 | 62 | Conventions: |
46 | 63 |
|
47 | 64 | - Use `create_app()` in tests; override deps via `app.dependency_overrides`, not module globals. |
| 65 | +- Use `TestClient(app)` as a context manager so lifespan runs (singleton engine/executor). |
48 | 66 | - Config: add env vars to `Settings` in `config.py`; call `reset_settings_cache()` in tests that patch env. |
49 | | -- Wire types stay in `schemas.py`; domain logic stays out of route handlers and Pydantic shells. |
| 67 | +- Wire types live in `app/schemas/` submodules; import directly (`from app.schemas.request import ScrapeRequest`). No long-lived re-export shim. |
| 68 | +- Domain logic stays out of route handlers and Pydantic shells. |
50 | 69 | - Typed exceptions over string-matching (`RequestIdCollisionError`, not `RuntimeError` message checks). |
51 | 70 | - `NavigationMode` end-to-end in engine code; no raw strategy strings outside enum conversion boundaries. |
| 71 | +- Optional Botasaurus driver methods go through `driver_capabilities.call_if_available` only; do not ad-hoc `getattr(driver, ...)`. |
| 72 | +- OpenAPI route examples come from `openapi_examples.py` model instances, not hand-typed dicts. |
| 73 | +- Botasaurus/CDP imports are lazy inside tier entrypoints (`run_request_tier`, `run_browser_tier`, XhrCollector methods), not at app import time. |
| 74 | + |
| 75 | +## Singleton + Settings Threading |
| 76 | + |
| 77 | +- **One** `ScraperEngine` and **one** `ThreadPoolExecutor` are created in `create_app()` lifespan and stored on `app.state`. |
| 78 | +- `get_engine` / `get_executor` / `SettingsDep` read from `request.app.state` (not per-request construction). |
| 79 | +- Lifespan shutdown calls `executor.shutdown(wait=False, cancel_futures=True)` on the real pool instance. |
| 80 | +- 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()`. |
| 81 | +- `ScraperEngine` and tier functions require an injected `Settings` parameter; no `get_settings()` fallback in the hot path. |
| 82 | + |
| 83 | +## Isolation Invariants |
| 84 | + |
| 85 | +| Resource | Lifetime | Rule | |
| 86 | +| --- | --- | --- | |
| 87 | +| `ScraperEngine` | process (app.state) | shared | |
| 88 | +| `ThreadPoolExecutor` | process (app.state) | shared, sized by `SCRAPE_MAX_WORKERS` | |
| 89 | +| `_active_request_ids` | in-process memory | shared; collision guard | |
| 90 | +| runtime dir `/tmp/scrape/<request_id>` | per request | isolated; deleted in `finally` | |
| 91 | +| browser profile | per request | isolated; no reuse | |
| 92 | +| Botasaurus Driver | per request | isolated; closed in `finally` | |
| 93 | + |
| 94 | +Multi-worker uvicorn breaks in-process collision detection unless request ids are sticky to a worker. Default to single-worker for isolation semantics. |
52 | 95 |
|
53 | 96 | ## Contract (Do Not Break) |
54 | 97 |
|
55 | 98 | - Endpoints: `GET /health`, `POST /scrape`. |
56 | 99 | - `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. |
57 | | -- Wire types live in `app/schemas.py`. Engine imports them. Routes serialize via `ScrapeService.serialize()` / `json_response()`. |
| 100 | +- Wire types live in `app/schemas/`. Engine imports them. Routes serialize via `ScrapeService.serialize()` / `json_response()`. |
58 | 101 | - OpenAPI `info.version` is `2.0.0`. Schema names: `ScrapeSuccess` (200) and `ScrapeError` (400/403/422/502/504). No `ScrapeResponse` alias. |
59 | 102 | - Success `/scrape` fields: `url`, `final_url`, `status_code`, `headers`, `html`, `metadata_error`, `xhr_responses`, `diagnostics`. |
60 | 103 | - When `html` is present, document `headers` `content-type` is `text/html; charset=utf-8` and `html` is UTF-8-normalized. |
@@ -92,14 +135,27 @@ Conventions: |
92 | 135 | - keep `/usr/bin/google-chrome` symlink to Chromium for compatibility |
93 | 136 | - If browser install logic changes, re-verify binary path and Botasaurus startup. |
94 | 137 |
|
| 138 | +## Performance |
| 139 | + |
| 140 | +- Baseline bench: `PYTHONPATH=. .venv/bin/python3 scripts/bench_scrape.py --runs 10` (TestClient, `execution_mode=request`). |
| 141 | +- Record p50 wall time when changing hot paths; avoid regressions vs prior baseline. |
| 142 | +- 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. |
| 143 | +- Browser tier skips XHR harvest before Cloudflare bypass; one consolidated `collect_page_state` pass runs after bypass. |
| 144 | + |
| 145 | +## Types |
| 146 | + |
| 147 | +- `make typecheck` runs `pyright app tests` and is part of `make check`. |
| 148 | +- Engine driver seams use `DriverProtocol` in `driver_capabilities.py`; cast vendor `Driver` at construction when needed. |
| 149 | +- Test-only pyright relaxations live in `pyproject.toml` `[[tool.pyright.executionEnvironments]]` for `tests/`. |
| 150 | + |
95 | 151 | ## Safety |
96 | 152 |
|
97 | 153 | - Keep SSRF guardrails: localhost/domain checks and blocked IP classes (loopback/private/link-local/multicast/reserved/unspecified). |
98 | 154 | - Do not weaken URL validation without explicit request plus docs/tests updates. |
99 | 155 |
|
100 | 156 | ## Done Criteria |
101 | 157 |
|
102 | | -- Run `make check` before finish. |
| 158 | +- Run `make check` before finish (lint, test, typecheck, openapi-verify). |
103 | 159 | - When Pydantic models or route response metadata change, run `make openapi` and commit the snapshot with the code change. |
104 | 160 | - When API contract, Docker behavior, or error semantics change, also run `make smoke`. |
105 | 161 | - `make smoke` must cover build, boot, `/health`, `/scrape` happy path, strategy override, retry path, isolation check, localhost guardrail. |
|
0 commit comments