Skip to content

Commit 4be0dd1

Browse files
authored
fix: clamp wait_timeout, UTF-8 HTML, and gate CI on Ruff plus Hadolint (#35)
* fix: clamp wait_timeout and return scrape envelope on schema 422 Gem clients send wait=28 against a 20s service cap; clamp inbound wait so scrape still runs, and keep remaining schema 422s in the scrape envelope the gem already parses. * fix: utf-8 html headers and request-tier 403 escalate Botasaurus HTML must be labeled UTF-8 so clients do not treat octet-stream as abort; 401/403/429 on the request tier already escalate to browser, and tests now lock that El País/NOS path. * test: lock utf-8 identity and clamp wait on POST /scrape Already-correct Caffè/CJK HTML must survive normalize; README timeout and content-type now match the 20s UTF-8 runtime. * chore(lint): add Ruff with Makefile and CI Host-side lint/format as the fast gate so Docker smoke is not the only quality check. * style: apply Ruff autofix so check and format pass Bring the existing modules in line with the new linter so CI lint is green on this branch.
1 parent df9714f commit 4be0dd1

14 files changed

Lines changed: 476 additions & 76 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ permissions:
1010
contents: read
1111

1212
jobs:
13+
ruff:
14+
name: Ruff
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Check out repository
18+
uses: actions/checkout@v6
19+
20+
- name: Run Ruff
21+
# ruff-action has v4.1.0 / v4.0.0 tags, not a floating v4 major.
22+
uses: astral-sh/ruff-action@v4.1.0
23+
with:
24+
version: "0.16.3"
25+
args: check --output-format=github
26+
27+
- name: Check Ruff format
28+
uses: astral-sh/ruff-action@v4.1.0
29+
with:
30+
version: "0.16.3"
31+
args: format --check
32+
1333
hadolint:
1434
name: Hadolint
1535
runs-on: ubuntu-latest
@@ -20,6 +40,17 @@ jobs:
2040
- name: Lint Dockerfile
2141
run: docker run --rm -i hadolint/hadolint < Dockerfile
2242

43+
lint:
44+
name: Lint
45+
if: ${{ !cancelled() }}
46+
needs: [ruff, hadolint]
47+
runs-on: ubuntu-latest
48+
steps:
49+
- name: Require Ruff and Hadolint
50+
run: |
51+
test "${{ needs.ruff.result }}" = "success"
52+
test "${{ needs.hadolint.result }}" = "success"
53+
2354
unit-tests:
2455
name: Run unit tests
2556
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.ruff_cache/
2+
.venv/
23
__pycache__/
34
*.pyc

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99

1010
- Endpoints: `GET /health`, `POST /scrape`.
1111
- Stable legacy `/scrape` fields: `url`, `final_url`, `status_code`, `headers`, `html`, `error`, `metadata_error`.
12+
- When `html` is present, document `headers` `content-type` is `text/html; charset=utf-8` and `html` is UTF-8-normalized.
1213
- Additive diagnostics fields (current contract): `request_id`, `attempts`, `strategy_used`, `render_ms`, `blocked_detected`, `challenge_detected`, `error_category`, `execution_tier`, `detected_challenge`, `xhr_responses`.
13-
- Request options (current contract): `execution_mode`, `navigation_mode`, `max_retries`, `wait_for_selector`, `wait_timeout_seconds`, `scroll`, `scroll_to_bottom`, `block_images`, `block_trackers`, `headers`, `cookies`.
14+
- Request options (current contract): `execution_mode`, `navigation_mode`, `max_retries`, `wait_for_selector`, `wait_timeout_seconds`, `scroll`, `scroll_to_bottom`, `block_images`, `block_images_and_css`, `block_trackers`, `wait_for_complete_page_load`, `headers`, `cookies`, `user_agent`, `window_size`, `lang`, `headless`, `proxy`.
15+
- `wait_timeout_seconds` outside `[1, SCRAPE_TIMEOUT_SECONDS]` (default 20) is clamped into that range so `/scrape` still runs; remaining schema 422 bodies use the scrape envelope (`url`, `error`, `error_category`, `request_id`), not FastAPI `detail`.
1416
- Error codes:
1517
- `400` validation/resolution failure
1618
- `403` SSRF guardrail block

Makefile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: test build serve health scrape-example smoke lint check ready
1+
.PHONY: test build serve health scrape-example smoke lint lintfix check ready
22

33
.DEFAULT_GOAL := check
44

@@ -7,9 +7,15 @@ PORT ?= 4010
77
BASE_URL ?= http://localhost:$(PORT)
88

99
lint:
10+
ruff check .
11+
ruff format --check .
1012
docker run --rm -i hadolint/hadolint < Dockerfile
1113

12-
check: lint
14+
lintfix:
15+
ruff check --fix .
16+
ruff format .
17+
18+
check: lint test
1319

1420
ready: check
1521

README.md

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Docker-only FastAPI service that uses [Botasaurus](https://github.com/omkarcloud
88
- `GET /health`
99
- `POST /scrape`
1010
- Intended usage: run and test through Docker only.
11-
- Runtime boundary: async FastAPI handler delegates sync browser work to a bounded threadpool (`SCRAPE_MAX_WORKERS`, default `4`), with a per-request timeout (`SCRAPE_TIMEOUT_SECONDS`, default `25`).
11+
- Runtime boundary: async FastAPI handler delegates sync browser work to a bounded threadpool (`SCRAPE_MAX_WORKERS`, default `4`), with a per-request timeout (`SCRAPE_TIMEOUT_SECONDS`, default `20`).
1212
- On-demand isolation-first runtime: every scrape request runs with an ephemeral browser profile and request-scoped runtime dir, then gets fully cleaned up.
1313

1414
## Prerequisites
@@ -139,22 +139,19 @@ Request options (contract):
139139
- `organic_get`: only `organic_get`
140140
- `max_retries`: `0..3`, default `2` (attempts = `1 + max_retries`, with `auto` capped by 3 strategy steps).
141141
- `wait_for_selector`: if set, response waits for selector before capture (routes to browser tier).
142-
- `wait_timeout_seconds`: selector wait timeout (default `15`, capped by service timeout).
142+
- `wait_timeout_seconds`: selector wait timeout (default `15`). Values outside `[1, SCRAPE_TIMEOUT_SECONDS]` (default `20`) are clamped into that range so scrape still runs.
143143
- `scroll` / `scroll_to_bottom`: if true, scrolls the page to trigger lazy-loaded feeds (routes to browser tier).
144144
- `block_images`: pass image blocking to driver. Default `true`.
145+
- `block_images_and_css`: pass image+css blocking to driver. Default `false`.
145146
- `block_trackers`: block tracking/ad networks and web fonts to speed up rendering. Default `true`.
147+
- `wait_for_complete_page_load`: pass page-load wait behavior to driver. Default `true`.
148+
- `user_agent`: explicit user agent string passed to driver.
146149
- `headers`: custom HTTP request headers forwarded to request client or browser session.
147150
- `cookies`: key-value cookies map forwarded to request client or browser session.
148-
149-
Currently accepted passthrough options (implemented, not part of stable request-options contract):
150-
151-
- `block_images_and_css`: pass image+css blocking to driver.
152-
- `wait_for_complete_page_load`: pass page-load wait behavior to driver.
153-
- `user_agent`: explicit user agent string passed to driver.
154151
- `window_size`: two-item integer list `[width, height]` passed to driver.
155152
- `lang`: browser language passed to driver (for example `en-US`).
156153
- `headless`: pass headless browser mode to driver. Default `false`.
157-
- `proxy`: proxy URL passed to driver.
154+
- `proxy`: proxy URL passed to driver. Invalid or blocked proxy URLs are rejected by SSRF guardrails.
158155

159156
Success response shape (legacy fields preserved, additive diagnostics included):
160157

@@ -164,7 +161,7 @@ Success response shape (legacy fields preserved, additive diagnostics included):
164161
"final_url": "https://example.com/",
165162
"status_code": 200,
166163
"headers": {
167-
"content-type": "text/html"
164+
"content-type": "text/html; charset=utf-8"
168165
},
169166
"html": "<!doctype html>...",
170167
"error": null,
@@ -184,8 +181,8 @@ Success response shape (legacy fields preserved, additive diagnostics included):
184181

185182
Field behavior:
186183

187-
- `html`: rendered page HTML.
188-
- `headers`, `status_code`, `final_url`: best-effort metadata and may be `null`.
184+
- `html`: rendered page HTML, UTF-8-normalized.
185+
- `headers`, `status_code`, `final_url`: best-effort metadata and may be `null`. When `html` is present, document `headers` `content-type` is `text/html; charset=utf-8`.
189186
- `error`: populated when scrape fails or challenge is detected on final attempt.
190187
- `metadata_error`: populated when metadata extraction fails but HTML scrape succeeds.
191188
- `request_id`: unique per request for tracing.
@@ -207,7 +204,7 @@ Status codes:
207204
- `200`: scrape completed without `error`.
208205
- `400`: URL rejected by validation (for example unresolved host).
209206
- `403`: URL blocked by SSRF guardrails.
210-
- `422`: request schema validation failed.
207+
- `422`: request schema validation failed. Body is the scrape error envelope (`url`, `error`, `error_category`, `request_id`), not FastAPI `{"detail":[...]}`.
211208
- `502`: scrape execution failure/challenge block.
212209
- `504`: scrape timed out.
213210

app/detector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ def detect(
7575
status_code in {401, 403, 429} if status_code is not None else False
7676
)
7777

78-
error_category = "challenge_block" if (challenge_detected or blocked_detected) else None
78+
error_category = (
79+
"challenge_block" if (challenge_detected or blocked_detected) else None
80+
)
7981

8082
return ChallengeAssessment(
8183
blocked_detected=blocked_detected,

app/engine.py

Lines changed: 83 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from botasaurus.browser import Driver
1515
from botasaurus.request import Request
16-
from pydantic import BaseModel, Field, HttpUrl, field_validator
16+
from pydantic import BaseModel, Field, HttpUrl, ValidationInfo, field_validator
1717

1818
from app.detector import ChallengeDetector
1919
from app.metadata import MetadataExtractor
@@ -26,7 +26,9 @@
2626
_RUNTIME_ROOT = Path("/tmp/scrape")
2727

2828
ExecutionMode = Literal["auto", "request", "browser"]
29-
NavigationMode = Literal["auto", "get", "google_get", "google_get_bypass", "organic_get"]
29+
NavigationMode = Literal[
30+
"auto", "get", "google_get", "google_get_bypass", "organic_get"
31+
]
3032
ErrorCategory = Literal[
3133
"timeout", "challenge_block", "navigation_error", "metadata_error"
3234
]
@@ -73,6 +75,28 @@ class ScrapeRequest(BaseModel):
7375
headless: bool = False
7476
proxy: str | None = None
7577

78+
@field_validator("wait_timeout_seconds", mode="before")
79+
@classmethod
80+
def clamp_wait_timeout_seconds(cls, value: Any, info: ValidationInfo) -> Any:
81+
if value is None:
82+
return value
83+
try:
84+
numeric = int(value)
85+
except (TypeError, ValueError): # fmt: skip
86+
return value
87+
88+
clamped = max(1, min(numeric, DEFAULT_SCRAPE_TIMEOUT_SECONDS))
89+
if clamped != numeric:
90+
url = info.data.get("url")
91+
logger.info(
92+
"request_field_clamped host=%s field=%s from=%s to=%s",
93+
urlparse(str(url)).hostname if url else None,
94+
"wait_timeout_seconds",
95+
numeric,
96+
clamped,
97+
)
98+
return clamped
99+
76100
@field_validator("window_size")
77101
@classmethod
78102
def validate_window_size(cls, value: list[int] | None) -> list[int] | None:
@@ -93,6 +117,36 @@ def effective_user_agent(self) -> str | None:
93117
return None
94118

95119

120+
HTML_DOCUMENT_CONTENT_TYPE = "text/html; charset=utf-8"
121+
122+
123+
def utf8_normalize_html(html: str) -> str:
124+
if not html:
125+
return html
126+
if not isinstance(html, str):
127+
html = str(html)
128+
try:
129+
html = html.encode("latin-1").decode("utf-8")
130+
except (UnicodeEncodeError, UnicodeDecodeError): # fmt: skip
131+
pass
132+
return html.encode("utf-8", errors="replace").decode("utf-8")
133+
134+
135+
def html_document_headers(
136+
html: str, headers: dict[str, str] | None
137+
) -> tuple[str, dict[str, str] | None]:
138+
if not html:
139+
return html, headers
140+
normalized = utf8_normalize_html(html)
141+
out: dict[str, str] = {}
142+
for key, value in (headers or {}).items():
143+
if str(key).lower() == "content-type":
144+
continue
145+
out[str(key)] = str(value)
146+
out["content-type"] = HTML_DOCUMENT_CONTENT_TYPE
147+
return normalized, out
148+
149+
96150
class ScrapeResponse(BaseModel):
97151
url: str
98152
final_url: str | None = None
@@ -134,6 +188,7 @@ def create_success(
134188
detected_challenge: str | None = None,
135189
xhr_responses: list[dict[str, Any]] | None = None,
136190
) -> dict[str, Any]:
191+
html, headers = html_document_headers(html, headers)
137192
return cls(
138193
url=url,
139194
final_url=final_url or url,
@@ -176,6 +231,7 @@ def create_error(
176231
metadata_error: str | None = None,
177232
xhr_responses: list[dict[str, Any]] | None = None,
178233
) -> dict[str, Any]:
234+
html, headers = html_document_headers(html, headers)
179235
return cls(
180236
url=url,
181237
final_url=final_url,
@@ -284,9 +340,7 @@ def unregister_request_id(self, request_id: str) -> None:
284340
self._active_request_ids.discard(request_id)
285341

286342
@classmethod
287-
def resolve_strategies(
288-
cls, mode: NavigationMode, max_retries: int
289-
) -> list[str]:
343+
def resolve_strategies(cls, mode: NavigationMode, max_retries: int) -> list[str]:
290344
max_attempts = 1 + max_retries
291345
if mode == "auto":
292346
ordered = ["google_get", "google_get_bypass", "get"]
@@ -298,11 +352,13 @@ def navigate(
298352
cls, driver: Driver, target_url: str, strategy: str, timeout_seconds: int
299353
) -> None:
300354
if strategy == "organic_get":
301-
method = getattr(driver, "organic_get", getattr(driver, "google_get", getattr(driver, "get")))
355+
method = getattr(
356+
driver, "organic_get", getattr(driver, "google_get", driver.get)
357+
)
302358
elif strategy.startswith("google_get"):
303-
method = getattr(driver, "google_get", getattr(driver, "get"))
359+
method = getattr(driver, "google_get", driver.get)
304360
else:
305-
method = getattr(driver, "get")
361+
method = driver.get
306362

307363
kwargs: dict[str, Any] = {}
308364
if strategy == "google_get_bypass":
@@ -441,16 +497,13 @@ def run_request_tier(
441497
remaining_budget = max(
442498
1,
443499
int(
444-
DEFAULT_SCRAPE_TIMEOUT_SECONDS
445-
- (time.monotonic() - started_monotonic)
500+
DEFAULT_SCRAPE_TIMEOUT_SECONDS - (time.monotonic() - started_monotonic)
446501
),
447502
)
448503

449504
req_headers = dict(payload.headers) if payload.headers else {}
450505
proxies = (
451-
{"http": payload.proxy, "https": payload.proxy}
452-
if payload.proxy
453-
else None
506+
{"http": payload.proxy, "https": payload.proxy} if payload.proxy else None
454507
)
455508

456509
req = Request()
@@ -497,8 +550,12 @@ def run_request_tier(
497550
)
498551
return None
499552

500-
error_msg = "Challenge block detected" if assessment.blocked_detected else None
501-
error_cat: ErrorCategory | None = "challenge_block" if assessment.blocked_detected else None
553+
error_msg = (
554+
"Challenge block detected" if assessment.blocked_detected else None
555+
)
556+
error_cat: ErrorCategory | None = (
557+
"challenge_block" if assessment.blocked_detected else None
558+
)
502559

503560
return ScrapeResponse.create_success(
504561
target_url,
@@ -554,9 +611,7 @@ def run_browser_tier(
554611
lang=payload.lang,
555612
remove_default_browser_check_argument=True,
556613
)
557-
self._configure_driver(
558-
session.driver, payload, target_url, collector=collector
559-
)
614+
self._configure_driver(session.driver, payload, target_url, collector=collector)
560615

561616
for attempt_index, strategy in enumerate(strategies, start=1):
562617
attempts = attempt_index
@@ -572,9 +627,7 @@ def run_browser_tier(
572627
self.wait_for_readiness(
573628
session.driver,
574629
selector=payload.wait_for_selector,
575-
timeout_seconds=min(
576-
payload.wait_timeout_seconds, remaining_budget
577-
),
630+
timeout_seconds=min(payload.wait_timeout_seconds, remaining_budget),
578631
)
579632

580633
if payload.should_scroll:
@@ -598,11 +651,11 @@ def run_browser_tier(
598651
assessment = ChallengeDetector.detect(
599652
html, meta.status_code, driver=session.driver
600653
)
601-
xhr_responses = self._harvest_xhr(
602-
collector, session.driver
603-
)
654+
xhr_responses = self._harvest_xhr(collector, session.driver)
604655
except Exception as exc:
605-
logger.debug("bypass_cloudflare_attempt_failed error=%s", str(exc))
656+
logger.debug(
657+
"bypass_cloudflare_attempt_failed error=%s", str(exc)
658+
)
606659

607660
if assessment.challenge_detected or assessment.blocked_detected:
608661
logger.warning(
@@ -715,14 +768,11 @@ def execute(
715768
)
716769

717770
with ScrapeSession(self, request_id) as session:
718-
should_try_request_tier = (
719-
payload.execution_mode == "request"
720-
or (
721-
payload.execution_mode == "auto"
722-
and payload.navigation_mode == "auto"
723-
and not payload.wait_for_selector
724-
and not payload.should_scroll
725-
)
771+
should_try_request_tier = payload.execution_mode == "request" or (
772+
payload.execution_mode == "auto"
773+
and payload.navigation_mode == "auto"
774+
and not payload.wait_for_selector
775+
and not payload.should_scroll
726776
)
727777

728778
if should_try_request_tier:

0 commit comments

Comments
 (0)