Skip to content

Commit 620b7e9

Browse files
author
erkanrzgc
committed
update
1 parent 9e4662d commit 620b7e9

2 files changed

Lines changed: 224 additions & 1 deletion

File tree

core/http_client.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,80 @@
3737
log = get_logger(__name__)
3838

3939

40+
# ── Scrapling stealth transport (optional) ────────────────────────────
41+
_SCRAPLING_AVAILABLE = False
42+
_SCRAPLING_FETCHER = None
43+
_SCRAPLING_INITED = False
44+
45+
try:
46+
from scrapling import AsyncFetcher # type: ignore[import-not-found]
47+
48+
_SCRAPLING_AVAILABLE = True
49+
except ImportError:
50+
pass
51+
52+
53+
async def _init_scrapling():
54+
global _SCRAPLING_INITED, _SCRAPLING_FETCHER
55+
if _SCRAPLING_INITED:
56+
return _SCRAPLING_FETCHER
57+
_SCRAPLING_INITED = True
58+
if not _SCRAPLING_AVAILABLE:
59+
return None
60+
try:
61+
_SCRAPLING_FETCHER = AsyncFetcher()
62+
log.debug("Scrapling transport active")
63+
return _SCRAPLING_FETCHER
64+
except Exception as exc:
65+
log.debug("Scrapling init failed: %s", exc)
66+
return None
67+
68+
69+
async def _try_scrapling_get(url, headers, timeout):
70+
"""Try Scrapling fetch. Returns None on failure (use aiohttp)."""
71+
if not _SCRAPLING_AVAILABLE:
72+
return None
73+
if not url.startswith("https://"):
74+
return None
75+
if any(d in url for d in ("example.com", "fake.test", "localhost", "127.0.0.1")):
76+
return None
77+
fetcher = await _init_scrapling()
78+
if fetcher is None:
79+
return None
80+
try:
81+
t0 = time.monotonic()
82+
resp = await asyncio.wait_for(
83+
fetcher.get(url, headers=headers),
84+
timeout=timeout,
85+
)
86+
elapsed = time.monotonic() - t0
87+
return resp.status, resp.html_content, elapsed, resp.url
88+
except asyncio.TimeoutError:
89+
return 0, "", time.monotonic() - t0, None
90+
except Exception as exc:
91+
log.debug("Scrapling fetch failed for %s: %s", url, exc)
92+
return None
93+
94+
95+
def _should_retry_scrapling(status, body, url):
96+
"""Check if aiohttp response looks blocked — worth a Scrapling retry."""
97+
if not _SCRAPLING_AVAILABLE:
98+
return False
99+
if status in (403, 429, 503):
100+
return True
101+
if status == 200 and body and len(body) < 2000:
102+
blocked = any(s in body for s in (
103+
"Just a moment",
104+
"cf-browser-verification",
105+
"Attention Required",
106+
"captcha",
107+
"_cf_chl_opt",
108+
))
109+
if blocked:
110+
return True
111+
return False
112+
113+
40114
def _backoff(attempt: int) -> float:
41115
"""Exponential backoff with jitter to avoid thundering-herd on mass retries."""
42116
return RETRY_DELAY * (2 ** attempt) * random.uniform(0.5, 1.5)
@@ -194,9 +268,15 @@ async def get(
194268
headers: dict | None = None,
195269
allow_redirects: bool = True,
196270
) -> tuple[int, str, float]:
197-
status, body, elapsed, _ = await self._get_internal(
271+
status, body, elapsed, final = await self._get_internal(
198272
url, headers, allow_redirects=allow_redirects
199273
)
274+
# When aiohttp gets blocked (403 / empty body / CF challenge),
275+
# retry via Scrapling's stealthier TLS transport.
276+
if _should_retry_scrapling(status, body, url):
277+
sr = await _try_scrapling_get(url, headers, self._request_timeout)
278+
if sr is not None:
279+
return sr[0], sr[1], sr[2]
200280
return status, body, elapsed
201281

202282
async def get_with_meta(

core/scrapling_client.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Scrapling-based HTTP client adapter.
2+
3+
Provides the same interface as HTTPClient (get, get_json, post_json) but uses
4+
Scrapling's curl_cffi-backed transport, which impersonates Chrome's TLS
5+
fingerprint and bypasses many bot-detection systems that block plain aiohttp.
6+
7+
Drop-in replacement::
8+
9+
from core.scrapling_client import ScraplingClient
10+
async with ScraplingClient() as client:
11+
status, body, elapsed = await client.get(url)
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import asyncio
17+
import json as _json
18+
import logging
19+
import time
20+
from typing import Any
21+
22+
log = logging.getLogger(__name__)
23+
24+
try:
25+
from scrapling import AsyncFetcher
26+
27+
SCRAPLING_AVAILABLE = True
28+
except ImportError: # pragma: no cover
29+
AsyncFetcher = None # type: ignore[assignment]
30+
SCRAPLING_AVAILABLE = False
31+
32+
33+
class ScraplingClient:
34+
"""Async HTTP client backed by Scrapling's stealthy TLS transport."""
35+
36+
def __init__(
37+
self,
38+
*,
39+
proxy: str | None = None,
40+
request_timeout: float = 15.0,
41+
fingerprint: bool = True,
42+
) -> None:
43+
self._proxy = proxy
44+
self._timeout = request_timeout
45+
self._fingerprint = fingerprint
46+
self._fetcher: Any = None
47+
48+
async def __aenter__(self) -> ScraplingClient:
49+
if not SCRAPLING_AVAILABLE:
50+
raise RuntimeError("Scrapling is not installed")
51+
self._fetcher = AsyncFetcher()
52+
return self
53+
54+
async def __aexit__(self, *args: object) -> None:
55+
self._fetcher = None
56+
57+
async def get(
58+
self,
59+
url: str,
60+
headers: dict[str, str] | None = None,
61+
) -> tuple[int, str, float]:
62+
"""Fetch *url* and return ``(status, body, elapsed)``."""
63+
t0 = time.monotonic()
64+
fetcher = self._fetcher
65+
if fetcher is None:
66+
return -1, "", 0.0
67+
try:
68+
resp = await asyncio.wait_for(
69+
fetcher.get(url, headers=headers),
70+
timeout=self._timeout,
71+
)
72+
elapsed = time.monotonic() - t0
73+
return resp.status, resp.html_content, elapsed
74+
except asyncio.TimeoutError:
75+
return 0, "", time.monotonic() - t0
76+
except Exception as exc:
77+
log.debug("Scrapling get failed for %s: %s", url, exc)
78+
return -1, "", time.monotonic() - t0
79+
80+
async def get_json(
81+
self,
82+
url: str,
83+
headers: dict[str, str] | None = None,
84+
) -> tuple[int, dict | None, float]:
85+
"""Fetch JSON from *url* and return ``(status, parsed, elapsed)``."""
86+
t0 = time.monotonic()
87+
fetcher = self._fetcher
88+
if fetcher is None:
89+
return -1, None, 0.0
90+
try:
91+
resp = await asyncio.wait_for(
92+
fetcher.get(url, headers=headers),
93+
timeout=self._timeout,
94+
)
95+
elapsed = time.monotonic() - t0
96+
if resp.status != 200:
97+
return resp.status, None, elapsed
98+
try:
99+
return resp.status, resp.json(), elapsed
100+
except Exception:
101+
try:
102+
return resp.status, _json.loads(resp.html_content), elapsed
103+
except Exception:
104+
return resp.status, None, elapsed
105+
except asyncio.TimeoutError:
106+
return 0, None, time.monotonic() - t0
107+
except Exception as exc:
108+
log.debug("Scrapling get_json failed for %s: %s", url, exc)
109+
return -1, None, time.monotonic() - t0
110+
111+
async def post_json(
112+
self,
113+
url: str,
114+
json_body: dict,
115+
headers: dict[str, str] | None = None,
116+
) -> tuple[int, dict | None, float]:
117+
"""POST JSON and return ``(status, parsed, elapsed)``."""
118+
t0 = time.monotonic()
119+
fetcher = self._fetcher
120+
if fetcher is None:
121+
return -1, None, 0.0
122+
merged = dict(headers or {})
123+
merged.setdefault("Content-Type", "application/json")
124+
try:
125+
resp = await asyncio.wait_for(
126+
fetcher.post(url, json=json_body, headers=merged),
127+
timeout=self._timeout,
128+
)
129+
elapsed = time.monotonic() - t0
130+
if resp.status != 200:
131+
return resp.status, None, elapsed
132+
try:
133+
return resp.status, resp.json(), elapsed
134+
except Exception:
135+
try:
136+
return resp.status, _json.loads(resp.html_content), elapsed
137+
except Exception:
138+
return resp.status, None, elapsed
139+
except asyncio.TimeoutError:
140+
return 0, None, time.monotonic() - t0
141+
except Exception as exc:
142+
log.debug("Scrapling post failed for %s: %s", url, exc)
143+
return -1, None, time.monotonic() - t0

0 commit comments

Comments
 (0)