lljz66 commited on
Commit
ce82e17
·
verified ·
1 Parent(s): 67cce2f

3-stage fetch: httpx, then headless Chromium, then headed Chromium (Xvfb); report exact per-stage results

Browse files
Files changed (1) hide show
  1. app/services/fetcher.py +145 -65
app/services/fetcher.py CHANGED
@@ -1,11 +1,14 @@
1
  """Safe async page fetching for the asset extractor.
2
 
3
- Guards: SSRF block-list, redirect cap, size cap, timeout, content-type check.
4
-
5
- Fallback: when a site answers 403 Forbidden or a Cloudflare-style challenge
6
- page, retry once with a headless-Chromium render. This distinguishes a
7
- client-fingerprint block (render succeeds) from a network/IP block
8
- (render also fails).
 
 
 
9
  """
10
 
11
  import asyncio
@@ -31,6 +34,21 @@ class FetchError(Exception):
31
  """Raised when the page cannot be safely fetched."""
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def _blocked(raw_url: str) -> str | None:
35
  """Return a reason string if the URL host is disallowed, else None."""
36
  parsed = urlparse(raw_url)
@@ -71,37 +89,54 @@ def _looks_like_challenge(html: str) -> bool:
71
  return any(m in low for m in markers)
72
 
73
 
74
- async def _render_with_chromium(url: str) -> tuple[str, str]:
75
- """Load the URL in headless Chromium and return (final_url, html).
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- Runs in a thread so the (blocking) sync Playwright API doesn't stall
78
- the event loop. Raises FetchError if the render itself is refused.
79
  """
80
- logger.info("render fallback starting url=%r", url)
81
 
82
  def _render() -> tuple[str, str]:
83
  from playwright.sync_api import sync_playwright
84
 
85
  with sync_playwright() as p:
86
  browser = p.chromium.launch(
87
- headless=True,
88
- args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
89
  )
90
  try:
91
  context = browser.new_context(
92
  user_agent=ASSETS_USER_AGENT,
93
  locale="en-US",
94
  )
 
 
 
 
95
  page = context.new_page()
96
- resp = page.goto(url, wait_until="domcontentloaded", timeout=int(ASSETS_RENDER_TIMEOUT * 1000))
 
 
 
 
97
  if resp is not None and resp.status == 403:
98
- raise FetchError(f"render returned HTTP 403")
99
  final_url = page.url
100
- page.wait_for_timeout(1500) # let JS-text pages settle
101
  html = page.content()
102
  if len(html) > ASSETS_MAX_BYTES:
103
  html = html[:ASSETS_MAX_BYTES]
104
- logger.info("render fallback ok url=%r bytes=%d", final_url, len(html))
105
  return final_url, html
106
  finally:
107
  browser.close()
@@ -111,73 +146,118 @@ async def _render_with_chromium(url: str) -> tuple[str, str]:
111
  except FetchError:
112
  raise
113
  except Exception as exc:
114
- raise FetchError(f"render failed: {exc}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  async def fetch_html(url: str) -> tuple[str, str, str]:
118
  """Fetch a page and return (final_url, html, title).
119
 
120
- Raises FetchError on any unsafe/failed/oversized/non-HTML fetch.
 
 
121
  """
122
 
123
- # Basic SSRF / scheme guard on the requested URL.
124
  reason = _blocked(url)
125
  if reason:
126
  raise FetchError(reason)
127
 
128
- headers = _browser_headers()
129
-
130
- limits = httpx.Limits(max_connections=50, max_keepalive_connections=10)
131
 
 
132
  try:
133
- async with httpx.AsyncClient(
134
- follow_redirects=True,
135
- max_redirects=ASSETS_MAX_REDIRECTS,
136
- timeout=ASSETS_FETCH_TIMEOUT,
137
- limits=limits,
138
- headers=headers,
139
- ) as client:
140
- async with client.stream("GET", url) as resp:
141
- resp.raise_for_status()
142
-
143
- content_type = resp.headers.get("content-type", "").lower()
144
- if "html" not in content_type and "xml" not in content_type:
145
- raise FetchError(f"not an HTML page (content-type: {content_type or 'unknown'})")
146
-
147
- # Enforce size cap while streaming.
148
- chunks: list[bytes] = []
149
- size = 0
150
- async for chunk in resp.aiter_bytes():
151
- size += len(chunk)
152
- if size > ASSETS_MAX_BYTES:
153
- raise FetchError(f"page exceeds {ASSETS_MAX_BYTES} bytes")
154
- chunks.append(chunk)
155
-
156
- final_url = str(resp.url)
157
- html = b"".join(chunks).decode("utf-8", "ignore")
158
- except httpx.HTTPStatusError as exc:
159
- if exc.response.status_code == 403 and ASSETS_RENDER_FALLBACK:
160
- logger.info("fetch got 403 for url=%r -> trying render fallback", url)
161
- final_url, html = await _render_with_chromium(url)
162
  else:
163
- raise FetchError(f"HTTP error {exc.response.status_code}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  except httpx.HTTPError as exc:
165
- raise FetchError(f"request failed: {exc}") from exc
166
-
167
- # The request may have "succeeded" (200) but returned a challenge page.
168
- if _looks_like_challenge(html) and ASSETS_RENDER_FALLBACK:
169
- logger.info("fetch got challenge page for url=%r -> trying render fallback", url)
170
- rendered_url, rendered_html = await _render_with_chromium(url)
171
- if not _looks_like_challenge(rendered_html):
172
- final_url, html = rendered_url, rendered_html
173
-
174
- # Guard against redirected-to-private hosts (defence in depth).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  reason = _blocked(final_url)
176
  if reason:
177
  raise FetchError(f"redirected to {reason}")
178
 
179
- title = _extract_title(html)
180
- return final_url, html, title
 
181
 
182
 
183
  def _extract_title(html: str) -> str | None:
 
1
  """Safe async page fetching for the asset extractor.
2
 
3
+ Stage cascade:
4
+ 1. httpx GET with browser-like headers (fast path).
5
+ 2. Headless Chromium render (Playwright) bypasses header/fingerprint blocks.
6
+ 3. Headed Chromium render (Playwright under Xvfb, real browser window) —
7
+ bypasses headless detection.
8
+
9
+ Every stage is attempted in order and reports its exact result. If all stages
10
+ fail, the FetchError message contains the per-stage result so the 502 response
11
+ (and container logs) show precisely where each method was blocked.
12
  """
13
 
14
  import asyncio
 
34
  """Raised when the page cannot be safely fetched."""
35
 
36
 
37
+ class StageResult:
38
+ """Exact outcome of one fetch attempt, for diagnostics."""
39
+
40
+ def __init__(self, stage: str, ok: bool, detail: str, bytes_=None):
41
+ self.stage = stage
42
+ self.ok = ok
43
+ self.detail = detail
44
+ self.bytes = bytes_
45
+
46
+ def __str__(self) -> str:
47
+ status = "OK" if self.ok else "FAIL"
48
+ suffix = f" ({self.bytes}b)" if self.bytes is not None else ""
49
+ return f"{self.stage}: {status}{suffix} - {self.detail}"
50
+
51
+
52
  def _blocked(raw_url: str) -> str | None:
53
  """Return a reason string if the URL host is disallowed, else None."""
54
  parsed = urlparse(raw_url)
 
89
  return any(m in low for m in markers)
90
 
91
 
92
+ def _launch_args(headless: bool) -> list[str]:
93
+ args = ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
94
+ if not headless:
95
+ # Reduce automation fingerprints in the headed browser.
96
+ args += [
97
+ "--disable-blink-features=AutomationControlled",
98
+ "--window-size=1366,768",
99
+ ]
100
+ return args
101
+
102
+
103
+ async def _render_with_chromium(url: str, headless: bool, stage: str) -> tuple[str, str]:
104
+ """Load the URL in Chromium and return (final_url, html).
105
 
106
+ Runs in a thread. Raises FetchError (with per-stage detail) on refusal.
 
107
  """
 
108
 
109
  def _render() -> tuple[str, str]:
110
  from playwright.sync_api import sync_playwright
111
 
112
  with sync_playwright() as p:
113
  browser = p.chromium.launch(
114
+ headless=headless,
115
+ args=_launch_args(headless),
116
  )
117
  try:
118
  context = browser.new_context(
119
  user_agent=ASSETS_USER_AGENT,
120
  locale="en-US",
121
  )
122
+ # Mask the automation flag that anti-bot JS looks for.
123
+ context.add_init_script(
124
+ "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
125
+ )
126
  page = context.new_page()
127
+ resp = page.goto(
128
+ url,
129
+ wait_until="domcontentloaded",
130
+ timeout=int(ASSETS_RENDER_TIMEOUT * 1000),
131
+ )
132
  if resp is not None and resp.status == 403:
133
+ raise FetchError(f"{stage}: HTTP 403")
134
  final_url = page.url
135
+ page.wait_for_timeout(2500) # let JS/bot pages settle
136
  html = page.content()
137
  if len(html) > ASSETS_MAX_BYTES:
138
  html = html[:ASSETS_MAX_BYTES]
139
+ logger.info("%s ok url=%r final=%r bytes=%d", stage, url, final_url, len(html))
140
  return final_url, html
141
  finally:
142
  browser.close()
 
146
  except FetchError:
147
  raise
148
  except Exception as exc:
149
+ raise FetchError(f"{stage}: {exc}") from exc
150
+
151
+
152
+ async def _stage1_httpx(url: str) -> tuple[str, str]:
153
+ """Fast path: plain HTTP GET with browser headers. Returns (final_url, html)."""
154
+ headers = _browser_headers()
155
+ limits = httpx.Limits(max_connections=50, max_keepalive_connections=10)
156
+
157
+ async with httpx.AsyncClient(
158
+ follow_redirects=True,
159
+ max_redirects=ASSETS_MAX_REDIRECTS,
160
+ timeout=ASSETS_FETCH_TIMEOUT,
161
+ limits=limits,
162
+ headers=headers,
163
+ ) as client:
164
+ async with client.stream("GET", url) as resp:
165
+ resp.raise_for_status()
166
+
167
+ content_type = resp.headers.get("content-type", "").lower()
168
+ if "html" not in content_type and "xml" not in content_type:
169
+ raise FetchError(f"not an HTML page (content-type: {content_type or 'unknown'})")
170
+
171
+ chunks: list[bytes] = []
172
+ size = 0
173
+ async for chunk in resp.aiter_bytes():
174
+ size += len(chunk)
175
+ if size > ASSETS_MAX_BYTES:
176
+ raise FetchError(f"page exceeds {ASSETS_MAX_BYTES} bytes")
177
+ chunks.append(chunk)
178
+
179
+ return str(resp.url), b"".join(chunks).decode("utf-8", "ignore")
180
 
181
 
182
  async def fetch_html(url: str) -> tuple[str, str, str]:
183
  """Fetch a page and return (final_url, html, title).
184
 
185
+ Tries stage 1 (httpx), then stage 2 (headless Chromium), then stage 3
186
+ (headed Chromium under Xvfb). Reports the exact result of every stage in
187
+ the raised FetchError.
188
  """
189
 
 
190
  reason = _blocked(url)
191
  if reason:
192
  raise FetchError(reason)
193
 
194
+ stages: list[StageResult] = []
 
 
195
 
196
+ # ---- Stage 1: plain HTTP ----
197
  try:
198
+ final_url, html = await _stage1_httpx(url)
199
+ if _looks_like_challenge(html):
200
+ stages.append(StageResult("1-httpx", True, "challenge page returned", len(html)))
201
+ fallthrough = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  else:
203
+ stages.append(StageResult("1-httpx", True, "html fetched", len(html)))
204
+ _guard_final(final_url)
205
+ return final_url, html, _extract_title(html)
206
+ except httpx.HTTPStatusError as exc:
207
+ sc = exc.response.status_code
208
+ stages.append(StageResult("1-httpx", False, f"HTTP {sc}"))
209
+ if not (ASSETS_RENDER_FALLBACK and sc in (403, 429, 503)):
210
+ raise FetchError(_summarize(stages)) from exc
211
+ fallthrough = True
212
+ except FetchError as exc:
213
+ stages.append(StageResult("1-httpx", False, str(exc)))
214
+ if str(exc).startswith("HTTP ") or "not an HTML" in str(exc):
215
+ raise FetchError(_summarize(stages)) from exc
216
+ raise FetchError("; ".join([str(s) for s in stages]) + " | stage1 failed") from exc
217
  except httpx.HTTPError as exc:
218
+ stages.append(StageResult("1-httpx", False, f"request failed: {exc}"))
219
+ if not ASSETS_RENDER_FALLBACK:
220
+ raise FetchError(_summarize(stages)) from exc
221
+ fallthrough = True
222
+
223
+ # ---- Stage 2: headless Chromium ----
224
+ if fallthrough:
225
+ try:
226
+ final_url, html = await _render_with_chromium(url, headless=True, stage="2-headless")
227
+ if _looks_like_challenge(html):
228
+ stages.append(StageResult("2-headless", True, "challenge page returned", len(html)))
229
+ else:
230
+ stages.append(StageResult("2-headless", True, "rendered OK", len(html)))
231
+ _guard_final(final_url)
232
+ return final_url, html, _extract_title(html)
233
+ except FetchError as exc:
234
+ stages.append(StageResult("2-headless", False, str(exc)))
235
+
236
+ # ---- Stage 3: headed Chromium under Xvfb ----
237
+ try:
238
+ final_url, html = await _render_with_chromium(url, headless=False, stage="3-headed")
239
+ if _looks_like_challenge(html):
240
+ stages.append(StageResult("3-headed", True, "challenge page returned", len(html)))
241
+ else:
242
+ stages.append(StageResult("3-headed", True, "rendered OK", len(html)))
243
+ _guard_final(final_url)
244
+ return final_url, html, _extract_title(html)
245
+ except FetchError as exc:
246
+ stages.append(StageResult("3-headed", False, str(exc)))
247
+
248
+ raise FetchError(_summarize(stages))
249
+
250
+ raise FetchError(_summarize(stages))
251
+
252
+
253
+ def _guard_final(final_url: str) -> None:
254
  reason = _blocked(final_url)
255
  if reason:
256
  raise FetchError(f"redirected to {reason}")
257
 
258
+
259
+ def _summarize(stages: list[StageResult]) -> str:
260
+ return " | ".join(str(s) for s in stages)
261
 
262
 
263
  def _extract_title(html: str) -> str | None: