Skip to content

Commit b8564c8

Browse files
committed
perf(engine): lazy imports and dedupe browser-tier hot path
Defer Botasaurus/CDP imports to tier entrypoints, consolidate post-bypass page-state collection, and document SCRAPE_MAX_WORKERS tuning for low-RAM hosts. Bench p50 wall_ms: 106.3 -> 102.9 (no regression).
1 parent c1f3ab6 commit b8564c8

8 files changed

Lines changed: 56 additions & 38 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ Use a **separate Sentry project** from html2rss-web (`BOTASAURUS_SENTRY_DSN` →
333333

334334
| Variable | Default | Description |
335335
| :--- | :--- | :--- |
336-
| `SCRAPE_MAX_WORKERS` | `4` | Threadpool worker limit for sync browser execution. |
336+
| `SCRAPE_MAX_WORKERS` | `4` | Threadpool worker limit for sync browser execution. On low-RAM hosts (for example Docker with `--memory=768m` or a 1–2 vCPU VM), use `1` or `2` to limit concurrent Chromium boots; higher values increase queue wait and swap pressure without improving wall time. |
337337
| `SCRAPE_TIMEOUT_SECONDS` | `45` | Handler wall-clock budget in seconds (queue, browser boot, and work). |
338338
| `SCRAPE_WORK_TIMEOUT_SECONDS` | `30` | Post-boot navigate, selector wait, and scroll budget in seconds. |
339339
| `SCRAPE_RUNTIME_MIN_FREE_BYTES` | `268435456` (256 MiB) | Prune orphan runtime dirs when free space drops below this threshold. |

app/engine/browser_tier.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@
55
import errno
66
import logging
77
import time
8+
from typing import Any
89
from urllib.parse import urlparse
910

10-
from botasaurus.browser import Driver
11-
1211
from app.config import Settings
13-
from app.engine.driver_capabilities import call_if_available
12+
from app.engine.driver_capabilities import DriverProtocol, call_if_available
1413
from app.engine.envelope import build_error, build_success
1514
from app.engine.request_tier import remaining_total_seconds
1615
from app.engine.session import ScrapeSession
@@ -22,8 +21,8 @@
2221
resolve_strategies,
2322
wait_for_readiness,
2423
)
25-
from app.infra.detector import ChallengeDetector
26-
from app.infra.metadata import MetadataExtractor
24+
from app.infra.detector import ChallengeAssessment, ChallengeDetector
25+
from app.infra.metadata import MetadataExtractor, MetadataResult
2726
from app.infra.scrape_progress import ScrapeProgress
2827
from app.infra.xhr_collector import XhrCollector
2928
from app.schemas.enums import ErrorCategory, ExecutionTier, TimeoutPhase
@@ -56,6 +55,20 @@ def is_timeout_exception(exc: Exception) -> bool:
5655
return "timeout" in str(exc).lower()
5756

5857

58+
def collect_page_state(
59+
driver: DriverProtocol,
60+
target_url: str,
61+
collector: XhrCollector,
62+
*,
63+
include_xhr: bool = True,
64+
) -> tuple[str, MetadataResult, ChallengeAssessment, list[dict[str, Any]]]:
65+
xhr_responses = harvest_xhr(collector, driver) if include_xhr else []
66+
html = driver.page_html or ""
67+
meta = MetadataExtractor.fetch(driver, target_url)
68+
assessment = ChallengeDetector.detect(html, meta.status_code, driver=driver)
69+
return html, meta, assessment, xhr_responses
70+
71+
5972
def run_browser_tier(
6073
payload: ScrapeRequest,
6174
session: ScrapeSession,
@@ -64,6 +77,8 @@ def run_browser_tier(
6477
*,
6578
settings: Settings,
6679
) -> ScrapeSuccess | ScrapeError:
80+
from botasaurus.browser import Driver
81+
6782
target_url = str(payload.url)
6883
request_id = session.request_id
6984
progress.mark(
@@ -141,21 +156,22 @@ def run_browser_tier(
141156
if payload.scroll:
142157
apply_scrolling(session.driver)
143158

144-
xhr_responses = harvest_xhr(collector, session.driver)
145-
146-
html = session.driver.page_html or ""
147-
meta = MetadataExtractor.fetch(session.driver, target_url)
148-
assessment = ChallengeDetector.detect(
149-
html, meta.status_code, driver=session.driver
159+
html, meta, assessment, xhr_responses = collect_page_state(
160+
session.driver,
161+
target_url,
162+
collector,
163+
include_xhr=False,
150164
)
151165

152166
if assessment.challenge_detected or assessment.blocked_detected:
153167
call_if_available(session.driver, "bypass_cloudflare")
154-
html = session.driver.page_html or ""
155-
meta = MetadataExtractor.fetch(session.driver, target_url)
156-
assessment = ChallengeDetector.detect(
157-
html, meta.status_code, driver=session.driver
168+
html, meta, assessment, xhr_responses = collect_page_state(
169+
session.driver,
170+
target_url,
171+
collector,
172+
include_xhr=True,
158173
)
174+
else:
159175
xhr_responses = harvest_xhr(collector, session.driver)
160176

161177
if assessment.challenge_detected or assessment.blocked_detected:

app/engine/request_tier.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import time
77
from urllib.parse import urlparse
88

9-
from botasaurus.request import Request
10-
119
from app.config import Settings
1210
from app.engine.envelope import build_error, build_success
1311
from app.infra.detector import ChallengeDetector
@@ -34,6 +32,8 @@ def run_request_tier(
3432
*,
3533
settings: Settings,
3634
) -> ScrapeSuccess | ScrapeError | None:
35+
from botasaurus.request import Request
36+
3737
target_url = str(payload.url)
3838
remaining_budget = remaining_total_seconds(settings, started_monotonic)
3939
progress.mark(

app/infra/xhr_collector.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@
1313
import threading
1414
from typing import Any
1515

16-
from botasaurus_driver import cdp
17-
from botasaurus_driver.core.custom_storage_cdp import enable_network
18-
1916
logger = logging.getLogger("botasaurus_scrape_api")
2017

2118

@@ -37,6 +34,9 @@ def __init__(self, target_url: str) -> None:
3734

3835
def install(self, tab: Any) -> None:
3936
"""Enable the network domain and register handlers before navigation."""
37+
from botasaurus_driver import cdp
38+
from botasaurus_driver.core.custom_storage_cdp import enable_network
39+
4040
tab.send(enable_network())
4141
tab.after_response_received(self._on_response)
4242
tab.add_handler(cdp.network.LoadingFinished, self._on_finished)
@@ -74,7 +74,7 @@ def _on_response(self, request_id: Any, response: Any, _event: Any) -> None:
7474
"request_id": request_id,
7575
}
7676

77-
def _on_finished(self, event: cdp.network.LoadingFinished) -> None:
77+
def _on_finished(self, event: Any) -> None:
7878
# Do not call get_response_body here — CDP deadlocks (Phase 0 spike).
7979
rid = str(event.request_id)
8080
with self._lock:
@@ -116,6 +116,8 @@ def harvest(self, tab: Any) -> list[dict[str, Any]]:
116116
return self.results()
117117

118118
def _fetch_body(self, tab: Any, request_id: Any, rid: str) -> str | None:
119+
from botasaurus_driver import cdp
120+
119121
try:
120122
body, b64 = tab.send(cdp.network.get_response_body(request_id))
121123
except Exception as exc:

tests/api/test_request_schema.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def test_clamped_wait_timeout_allows_execute(self):
103103

104104
with tempfile.TemporaryDirectory() as tmp:
105105
engine = ScraperEngine(settings=get_settings(), runtime_root=Path(tmp))
106-
with patch("app.engine.browser_tier.Driver", FakeDriver):
106+
with patch("botasaurus.browser.Driver", FakeDriver):
107107
result = engine.execute(payload)
108108

109109
self.assertIsNone(result.error if isinstance(result, ScrapeError) else None)
@@ -126,7 +126,7 @@ def test_html_response_sets_utf8_content_type_and_normalizes_body(self):
126126

127127
with tempfile.TemporaryDirectory() as tmp:
128128
engine = ScraperEngine(settings=get_settings(), runtime_root=Path(tmp))
129-
with patch("app.engine.request_tier.Request", FakeRequest):
129+
with patch("botasaurus.request.Request", FakeRequest):
130130
result = engine.execute(payload)
131131

132132
self.assertIsInstance(result, ScrapeSuccess)
@@ -155,7 +155,7 @@ def test_utf8_normalize_leaves_correct_unicode_unchanged(self):
155155
payload = ScrapeRequest(url="https://example.com", execution_mode="request")
156156
with tempfile.TemporaryDirectory() as tmp:
157157
engine = ScraperEngine(settings=get_settings(), runtime_root=Path(tmp))
158-
with patch("app.engine.request_tier.Request", FakeRequest):
158+
with patch("botasaurus.request.Request", FakeRequest):
159159
result = engine.execute(payload)
160160

161161
self.assertEqual(result.html, html)
@@ -176,8 +176,8 @@ def test_request_tier_blocked_status_escalates_to_browser(self):
176176
settings=get_settings(), runtime_root=Path(tmp)
177177
)
178178
with (
179-
patch("app.engine.request_tier.Request", FakeRequest),
180-
patch("app.engine.browser_tier.Driver", ArticleDriver),
179+
patch("botasaurus.request.Request", FakeRequest),
180+
patch("botasaurus.browser.Driver", ArticleDriver),
181181
):
182182
result = engine.execute(payload)
183183

@@ -230,7 +230,7 @@ def test_cleanup_runs_on_navigation_error(self):
230230
with tempfile.TemporaryDirectory() as tmp:
231231
runtime_root = Path(tmp)
232232
engine = ScraperEngine(settings=get_settings(), runtime_root=runtime_root)
233-
with patch("app.engine.browser_tier.Driver", FakeDriver):
233+
with patch("botasaurus.browser.Driver", FakeDriver):
234234
result = engine.execute(payload)
235235

236236
self.assertEqual(result.error_category, ErrorCategory.NAVIGATION_ERROR)
@@ -254,7 +254,7 @@ def boom_mkdir(*_args, exist_ok=False, **_kwargs):
254254
raise OSError(28, "No space left on device")
255255

256256
with (
257-
patch("app.engine.browser_tier.Driver", FakeDriver),
257+
patch("botasaurus.browser.Driver", FakeDriver),
258258
patch.object(Path, "mkdir", side_effect=boom_mkdir),
259259
):
260260
result = engine.execute(payload)
@@ -279,7 +279,7 @@ def test_prune_orphan_runtime_dirs_before_new_request(self):
279279
(orphan / "profile").mkdir()
280280

281281
engine = ScraperEngine(settings=get_settings(), runtime_root=runtime_root)
282-
with patch("app.engine.browser_tier.Driver", FakeDriver):
282+
with patch("botasaurus.browser.Driver", FakeDriver):
283283
result = engine.execute(payload)
284284

285285
self.assertIsInstance(result, ScrapeSuccess)
@@ -308,7 +308,7 @@ def test_prune_orphan_runtime_dirs_before_http_request(self):
308308
(orphan / "profile").mkdir()
309309

310310
engine = ScraperEngine(settings=get_settings(), runtime_root=runtime_root)
311-
with patch("app.engine.request_tier.Request", FakeRequest):
311+
with patch("botasaurus.request.Request", FakeRequest):
312312
result = engine.execute(payload)
313313

314314
self.assertEqual(result.html, html)
@@ -333,7 +333,7 @@ def test_run_scrape_forwards_driver_kwargs(self):
333333
with tempfile.TemporaryDirectory() as tmp:
334334
runtime_root = Path(tmp)
335335
engine = ScraperEngine(settings=get_settings(), runtime_root=runtime_root)
336-
with patch("app.engine.browser_tier.Driver", CaptureDriver):
336+
with patch("botasaurus.browser.Driver", CaptureDriver):
337337
result = engine.execute(payload)
338338

339339
self.assertIsInstance(result, ScrapeSuccess)

tests/engine/test_scraper_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def get(self, *_args, **kwargs):
5050
with tempfile.TemporaryDirectory() as tmp:
5151
engine = ScraperEngine(settings=get_settings(), runtime_root=Path(tmp))
5252
with (
53-
patch("app.engine.browser_tier.Driver", _NavigateCaptureDriver),
53+
patch("botasaurus.browser.Driver", _NavigateCaptureDriver),
5454
patch(
5555
"app.engine.browser_tier.time.monotonic",
5656
side_effect=monotonic_values,

tests/test_engine_isolation.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def tracking_enter(self: ScrapeSession) -> ScrapeSession:
6969
headers={"X-Request-Id": self.INBOUND_ID_B},
7070
)
7171
)
72-
with patch("app.engine.request_tier.Request") as mock_request:
72+
with patch("botasaurus.request.Request") as mock_request:
7373
mock_request.return_value.get.return_value = type(
7474
"Resp",
7575
(),
@@ -109,7 +109,7 @@ def slow_enter(self: ScrapeSession) -> ScrapeSession:
109109
with (
110110
patch.object(ScrapeSession, "__enter__", slow_enter),
111111
test_client() as client,
112-
patch("app.engine.request_tier.Request") as mock_request,
112+
patch("botasaurus.request.Request") as mock_request,
113113
):
114114
mock_request.return_value.get.return_value = type(
115115
"Resp",
@@ -162,7 +162,7 @@ def capture_exit(self, exc_type, exc_val, exc_tb):
162162
with (
163163
patch.object(ScrapeSession, "__exit__", capture_exit),
164164
test_client() as client,
165-
patch("app.engine.request_tier.Request") as mock_request,
165+
patch("botasaurus.request.Request") as mock_request,
166166
):
167167
mock_request.return_value.get.return_value = type(
168168
"Resp",

tests/test_timeout_phase.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ def _execute(payload, *, progress, request_id, **patches):
7373
with ExitStack() as stack:
7474
if "Driver" in patches:
7575
stack.enter_context(
76-
patch("app.engine.browser_tier.Driver", patches["Driver"])
76+
patch("botasaurus.browser.Driver", patches["Driver"])
7777
)
7878
if "Request" in patches:
7979
stack.enter_context(
80-
patch("app.engine.request_tier.Request", patches["Request"])
80+
patch("botasaurus.request.Request", patches["Request"])
8181
)
8282
return engine.execute(payload, request_id=request_id, progress=progress)
8383

0 commit comments

Comments
 (0)