Miért bukik el a Python-szkripted az első percben, még mielőtt egyetlen sort parsolt volna — és mit látsz erről a hálózaton.
Rövid válasz: A legtöbb web scraper nem a HTML-parsolásnál bukik el, hanem már az első kérésnél 403-at kap, miközben a böngészőben ugyanaz az oldal betöltődik. A szerveroldali botvédelem (pl. DataDome) három rétegben azonosít: (1) HTTP-headerek, (2) TLS-ujjlenyomat (JA3/JA4), (3) aktív JavaScript-challenge. A
requestsés ahttpxmár a TLS-rétegen lebukik; acurl_cffi impersonateátjut a passzív ellenőrzésen; az aktív challenge-hez headless böngésző vagy érvényes session-cookie kell. A valódi szűk keresztmetszet azonban gyakran nem a kód, hanem az IP-reputáció.
Amikor valaki először ír web scrapert, általában azt gondolja: a nehéz rész a HTML kibogarászása lesz. Aztán jön az első komolyabb céloldal, és a szkript már az első kérésnél 403-at kap — miközben a böngészőben ugyanaz az oldal tökéletesen betöltődik. A probléma nem a parsolás, hanem hogy a szerver még mielőtt bármilyen HTML-t küldene, eldönti, hogy gép vagy-e.
Ebben a posztban végigmegyünk a szerveroldali azonosítás három rétegén, mindegyikhez valódi, tcpdump-pal rögzített bizonyítékkal:
A tesztek egy Ubuntu szervert futottak Python 3.12-vel, httpx, requests és curl-cffi csomagokkal, a forgalmat tcpdump + tshark párossal rögzítettük. A célpont a példákban egy DataDome botvédelmet használó, nagy forgalmú e-kereskedelmi oldal (example-webshop.hu) — a tanulságok bármilyen hasonlóan védett oldalra ugyanúgy érvényesek.
Mielőtt bármibe belefognál, érdemes tisztázni, hogyan épül fel a céloldal. Két szélső eset van:
requests.get() + BeautifulSoup kombináció sokszor elegendő.<div id="root">) küld, az adat külön XHR/fetch hívásokból, jellemzően JSON-ból érkezik. A HTML-parsolás ilyenkor nem visz semmire — a Network fülön kell keresned a valódi adatforrást (DevTools → Network → Fetch/XHR, és figyeld, melyik kérés viszi a tartalmat).Tipp SPA-nál: mielőtt elkezdenéd parsolni a HTML-t, nézd meg a DevTools Network fülén, hogy az alkalmazás milyen API-hívásokat csinál. Ha a szerver kiszolgál egy JSON- vagy MessagePack-választ adó REST/GraphQL/gRPC API-t, azt közvetlenül is hívhatod — sokkal kényelmesebb, mint a DOM-ból kiszedni az adatokat. Egy jól strukturált JSON-válaszban a terméknév, ár, kép URL-je és elérhetőség is ott van, méghozzá strukturáltan, parsing nélkül. Mindig nézd meg, van-e ilyen lehetőség, mielőtt a HTML-parsolás felé fordulnál.
A valóság a kettő keveréke. Egy nagy magyar webshop kezdőlapja például SSR: ha átjutunk a botellenőrzésen, ~1,45 MB-nyi kész HTML-t kapunk, benne több tucat <script type="application/json"> „adatszigettel", amelyekből a React alkalmazás betölti magát a DOM-ba. A terméklistát tartó konténert pedig így találod meg benne:
<div data-role="offers-list">
Ha egy SPA-s listázó oldalt kérsz le sima HTTP-klienssel, pont ezek a div-ek üresek — ez a leggyorsabb praktikus jelzés arra, hogy az adat nem a HTML-ben lakik.
De mindez csak akkor érdekes, ha egyáltalán le tudod tölteni az oldalt. Nézzük, miért nem tudod.
Rövid válasz: alapértelmezetten egy leleplező User-Agent-et (pl. python-requests/2.34.2), hiányzó sec-ch-ua* és Sec-Fetch-* headereket, és böngészőre egyáltalán nem jellemző header-sorrendet — a botvédelem ezekből azonnal gépet lát.
Először nézzük meg, pontosan mit küld a szkripted a hálózaton — három népszerű Python HTTP-kliens ugyanarra a címre. Futtattunk egy pici echo szervert a 127.0.0.1:8899-es porton, ami a beérkező kérés-fejléceket változatlan formában nyomtatja ki, közben tcpdump -i lo -s0 -A 'tcp port 8899'-pal rögzítettük a nyers bájtokat.
import requests, httpx
from curl_cffi import requests as creq
URL = "http://127.0.0.1:8899/proba"
requests.get(URL) # 1. urllib3 motor
httpx.get(URL) # 2. httpx alapértelmezés
creq.get(URL, impersonate="chrome150") # 3. curl_cffi Chrome-impersonációval
A rögzített, valós kérésfejek (a tcpdump-kimenettel bájtról bájtra megegyeznek):
=== 1. requests ===
GET /proba HTTP/1.1
Host: 127.0.0.1:8899
User-Agent: python-requests/2.34.2
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
=== 2. httpx ===
GET /proba HTTP/1.1
Host: 127.0.0.1:8899
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
User-Agent: python-httpx/0.28.1
=== 3. curl_cffi, impersonate="chrome150" ===
GET /proba HTTP/1.1
Host: 127.0.0.1:8899
sec-ch-ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,
image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9
Priority: u=0, i
A különbség látványos, ha egy botvédelem szemével nézzük:
| Szignál | requests | httpx | curl\_cffi (chrome150) |
|---|---|---|---|
| User-Agent | python-requests/2.x | python-httpx/x.y | valódi Chrome UA |
| sec-ch-ua klienshintek | nincs | nincs | teljes |
| Sec-Fetch-* headerek | nincs | nincs | teljes |
| Accept-Language | nincs | nincs | van |
| Header-sorrend | urllib3-féle | eltérő | Chrome-pontos |
Fontos tanulságok:
python-requests/2.34.2 és a python-httpx/0.28.1 User-Agent önmagában azonnali kizárást jelent — de nem elég ezt átírni. Ha csak a UA-t cseréled, a többi mező elárul: hiányzó sec-ch-ua* klienshintek, hiányzó Sec-Fetch-* navigációs headerek, hiányzó Accept-Language.Set-Cookie cookie-t vissza is küld a következő kérésben, a frissen írt szkript ezt rendszerint elfelejti.Persze mindez kézzel is összerakható — a kérdés, hogy ennek van-e értelme, amikor a TLS-réteg úgyis elárul.
Rövid válasz: mert a TLS ClientHello már a HTTP-headerek előtt elárul — a Python-OpenSSL más cipher- és kiterjesztéssorrendet (JA3/JA4-ujjlenyomat) küld, mint a Chrome, GREASE és ECH nélkül —, és ezt fejléccserével nem tudod eltüntetni.
Amikor a Python kliensed HTTPS-kapcsolatot nyit, a TLS ClientHello üzenetében elküldi a támogatott titkosítókat, kiterjesztéseket és elliptic curve paramétereket (lásd ECDHE/ECDSA) — mindet abban a sorrendben, ahogy a mögöttes SSL-csomag (Python esetén OpenSSL) összerakta. Ez a halmaz csomagonként és verzióként jellegzetes: belőle számolják a JA3/JA4 ujjlenyomatot. A szerver tehát még azelőtt tudja, hogy Python-OpenSSL vagy, hogy az első HTTP bájtot is látná.
A lényeg röviden: a python-requests és a sima httpx OpenSSL-helloja teljesen más kiterjesztéssorrendet és cipherlistát küld, mint egy valódi Chrome — GREASE értékek nélkül, széles legacy-cipherlistával. A Chrome-helloban 0x9a9a GREASE, 65037 = encrypted_client_hello (ECH), 17613 = ALPS — ezek egyiken sem szerepelnek a Python-kliensben. Részletes méréseket és a tshark-parancsokat a JA3/JA4 és az ECDHE/ECDSA oldalakon találsz.
UGYANAZOKAT a HTTP-headereket küldted, de a TLS-bájtjaid elárultak. Ezért nem elég a headereket lemásolni.
Rövid válasz: a passzív TLS-ellenőrzésen igen — a curl_cffi impersonate a valódi Chrome ClientHello-ját küldi —, de az aktív JavaScript-challenge-en önmagában nem, mert azt egy statikus HTTP-kliens nem tudja lefuttatni.
A curl_cffi a trükk lényege: nem a Python SSL-stackjét használja, hanem a curl-impersonate fordított BoringSSL-jét, és a impersonate="chromeXXX" kapcsolóval pontosan azt a ClientHello-t, HTTP/2 SETTINGS-frame-et és header-sorrendet küldi, mint a valódi böngészők. A curl_cffi impersonate TLS-ujjlenyomata a vezetéken gyakorlatilag megkülönböztethetetlen a valódi Chrome-étól.
De meddig visz ez? Nézzük ezt egy élő, DataDome által védett oldalon. Először a klasszikus kísérlet — tökéletesen kimásolt Chrome-headerekkel, sima kliensekkel:
CHROME_HEADERS = {
"User-Agent": "Mozilla/5.0 ... Chrome/150.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,...",
"Accept-Language": "hu-HU,hu;q=0.9,en;q=0.8",
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", ...',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"Sec-Fetch-Site": "none", "Sec-Fetch-Mode": "navigate",
"Sec-Fetch-User": "?1", "Sec-Fetch-Dest": "document",
"Upgrade-Insecure-Requests": "1",
}
httpx.get("https://www.example-webshop.hu/", headers=CHROME_HEADERS)
Valós eredmények (2026. augusztus, saját mérés alapján):
[plain requests, csak Chrome UA] status=403 len=781 datadome=True
[httpx, tökéletes Chrome-headerek] status=403 len=781 datadome=True
[curl_cffi impersonate="chrome150"] status=403 len=780 datadome=True
[robots.txt sima requests-szel] status=200 len=550
[favicon.ico sima requests-szel] status=200 len=15086
Mért tények (example-webshop.hu, 2026. augusztus):
requests csak Chrome User-Agenttel: 403, 781 bájtos DataDome blokklap.httpx tökéletesen kimásolt Chrome-headerekkel: 403, 781 bájt.curl_cffi impersonate="chrome150": 403, 780 bájt — a passzív TLS-ellenőrzésen átjut, az aktív challenge-en nem.robots.txt és favicon.ico: 200 — a védelem csak a HTML-navigációkra céloz.curl_cffi: 200, ~1,45 MB teljes SSR-HTML.A 403-as válasz 781 bájtos blokklap, amelyben ott a DataDome jellemző aláírása:
<p id="cmsg">Please enable JS and disable any ad blocker</p>
<script>var dd={'rt':'i','cid':'AHrlqAAAAAMA...','host':'geo.captcha-delivery.com',
'cookie':'yRB5ha1z7_Ai8F...'}</script>
Mit tanulunk ebből?
requests és a header-másolgatós httpx azonnal lepleződik.curl_cffi is 403-as challenge-oldalt kap, mert a DataDome JavaScriptet akar futtatni a böngésződben (rt:'i' = JS-challenge egy iframe-ben). Ezt statikus HTTP-kliens nem tudja végrehajtani.robots.txt és a favicon.ico önállóan is 200-at ad, csak a HTML-oldalak mennek challengen.És mi történik, ha van egy valós böngészős session-cookie-d? Headless Chrome-ban meglátogattuk az oldalt (a challenge automatikusan lefutott, a datadome cookie-t kiolvassuk DevTools-on keresztül), majd ugyanarról az IP-ről visszajátszottuk a cookie-t curl_cffi chrome-impersonációval:
from curl_cffi import requests as creq
cookie = open("dd_cookie.txt").read().strip() # valódi böngészőből
r = creq.get("https://www.example-webshop.hu/",
cookies={"datadome": cookie},
impersonate="chrome131", timeout=20)
# -> status: 200 | bytes: 1452851
Eredmény: 200 OK, ~1,45 MB teljes SSR-HTML ("Webshop – Remek árak – Kezdőlap"), tele tartalommal. Ugyanez a kombó — érvényes cookie + hiteles TLS — átmegy a passzív ellenőrzésen.
De vigyázz, ez nem stabil állapot: ugyanezzel a cookie-val egy második navigáció (egy listázó aloldal) pillanatok alatt friss 403-as challenge-et kapott. A DataDome-cookie-t a böngészőben futó JavaScript folyamatosan újragenerálja és a sessionhez, az IP-hez köti — egy kimásolt, statikus cookie hamar lejár vagy új kérésnél újra challenge-et vált ki.
| Technika | Passzív fingerprint | Aktív JS-challenge | Viselkedéselemzés |
|---|---|---|---|
| requests/httpx default | elbukik | elbukik | elbukik |
| Tökéletes header-másolás | elbukik (TLS!) | elbukik | elbukik |
| curl\_cffi impersonation | átmegy | elbukik | elbukik |
| curl\_cffi + érvényes cookie | átmegy | átmenetileg átmegy | elbukik |
| Headless Chromium | részlegesen | gyakran elbukik | elbukik |
| Headless, valódi böngésző | átmegy | átmegy | átmegy |
Összegzés: a TLS-impersonáció szükséges, de nem elégséges feltétel. Aktív challenge-eknél vagy headless böngészővel dolgozol (Playwright headless=False), vagy valódi böngészőből szerzett session-cookie-t használsz — különben feladod.
Rövid válasz: mert a DataDome a kliens IP-címe és TLS-ujjlenyomata alapján, szerveroldalon dönti el, hogy megoldható challenge-et, „szürke" kemény tiltást vagy átengedést ad — így ugyanaz a kód tiszta IP-ről 200-at, rossz reputációjú IP-ről 403-at kap.
A fenti „hol a határ?" táblázat azt sugallja, hogy az aktív challenge a végállomás. A gyakorlatban viszont a kép árnyaltabb — és a különbség sokszor nem a kódon múlik, hanem azon, honnan indul a kérés.
Vegyük ismét az example-webshop.hu-t, ezúttal egy curl_cffi-alapú klienssel,
változatlan headerekkel és impersonate="chrome150"-nel. Ugyanaz a szkript egyik nap
tiszta 403-at kap, a következő futáson viszont 200-at, tele találattal — pedig egyetlen
sort sem írtunk át közben. A magyarázat, hogy a DataDome nem egy, hanem három
különböző választ ad ugyanarra a kliensre, és szerveroldalon dönti el, melyiket —
még mielőtt bármilyen JavaScript lefutna:
| Állapot | HTTP | Mit kapsz | Jelentés |
|---|---|---|---|
JS-challenge (rt:'i') | 403 | var dd={'rt':'i',...} blokk + i.js | az egyetlen megoldható változat: valódi challenge fut le egy iframe-ben |
| Szürke tiltás | 403 | csupasz, szürke oldal: Please enable JS..., var dd nélkül | nincs felkínált challenge — nincs mit „megoldani" rajta |
| Átengedés | 200 | teljes SSR-HTML | a TLS-print és az IP is rendben volt |
A „szürke tiltás" a legfélrevezetőbb a három közül: nem is challenge-oldal, hanem egy
üres, dísztelen hibalap — ugyanaz a Please enable JS and disable any ad blocker
felirat, mint a challenge-nél, de a mögötte lévő var dd={...} blokk és az i.js
hiányzik. Nincs mit futtatni, nincs mit megoldani: a DataDome ilyenkor egyszerűen
közli, hogy ezt a klienst most egyáltalán nem szolgálja ki.
És itt a kulcs: hogy challenge-oldalt vagy szürke tiltást kapsz-e, az szerveroldalon,
még a JavaScript előtt eldől — és nem a klienskódhoz, hanem az IP-cím + TLS-ujjlenyomat
reputációjához kötött. Ha az IP-d egyszer a „szürke" kategóriába csúszott, a megoldható
challenge-et meg sem kapod, hiába lenne tökéletes a megoldód. Fordítva viszont: egy
tiszta IP-ről a curl_cffi passzív rétege (hiteles TLS + Chrome-pontos header-sorrend)
sokszor magától átjut, bárminemű challenge-megoldás nélkül. Ezt élőben is megfogtuk:
a 403-at adó kérés után — a kliens egyetlen bájtját sem érintve — másik kimenő IP-ről
próbáltuk újra, és rögtön 200-at kaptunk, tele valódi termékadattal.
Ebből két gyakorlati tanulság adódik, amit a „hol a határ?" táblázat önmagában elrejt:
curl_cffi passzív
rétege elég, ha az IP-reputáció rendben van. A valódi szűk keresztmetszet nem a
JavaScript, hanem az IP — ide fektesd az energiát, ne egy böngésző nélküli JS-solver
visszafejtésébe.A gyakorlati recept ezért egyszerűbb, mint elsőre tűnik: detektáld, milyen állapotot
kaptál (a datadome cookie értéke a missing / allocation — ~ jelet tartalmaz —
/ ok skálán mozog), és amikor szürkét vagy allokációt látsz, ne a challenge-et
próbáld erőltetni, hanem válts IP-t (residential vagy mobil proxy), a régit pedig
hagyd kihűlni. A hiteles TLS-t adja a curl_cffi, a tiszta identitást a proxy — a
kettő együtt jut át.
Mielőtt legközelebb 403-at kapsz, futtasd végig:
data-role="offers-list"-szerű konténerek üresek a letöltött HTML-ben, az adat XHR/JSON-ból jön — DevTools Network fülön keresd a forráskérést. SPA-nál mindig nézd meg, van-e közvetlen API-hívás (JSON, MessagePack, GraphQL) — ha igen, azt használd a HTML-parsolás helyett.curl_cffi impersonate=-et; ellenőrizd tcpdump + tshark segítségével, hogy a ClientHello tényleg Chrome-ot mutat-e (GREASE, ECH, kiterjesztés-sorrend).A technika önmagában semmi — a használat számít. Mielőtt scrapelsz: olvasd el a robots.txt-et (szinte minden oldal közzéteszi, mit tilt), tarts mértékkel a kérésszámmal (ne DDoS-olj véletlenül), és nézd meg az oldal ÁSZF-ét — sok oldal explicit tilalmat ír elő az automatizált elérésre. Nyilvános termékadatok esetén GDPR tipikusan nem merül fel, mert nem személyes adatot kezelsz, de a szerződési feltételek (ToU) ettől függetlenül kötnek. Soha ne scrappelj bejelentkezést igénylő, személyes adatokat tartalmazó felületeket engedély nélkül.
Rövid válasz: ne adj fel és ne is ronts vissza azonnal — várj egyre hosszabb, véletlen jitterrel megtoldott szünetekkel (2, 4, 8, 16… másodperc), és ha a szerver Retry-After headert küld, azt mindig tartsd be.
A 403 nem az egyetlen kód, amivel találkozhatsz. A 429 Too Many Requests és az 5xx szerverhiba esetén az udvarias scraper nem adja fel azonnal, de nem is rohan vissza: exponenciális backoff-fal vár – egyre hosszabb szünetekkel (2, 4, 8, 16… másodperc), és jitterrel, azaz véletlen plusz késleltetéssel. A jitter nélkül ugyanis minden párhuzamos kliens pontosan ugyanabban a másodpercben csapna rá újra a szerverre, és a backoff pontosan azt a thundering-herd-helyzetet termelné újra, amitől a szerver épp próbál megszabadulni.
import random, time
from curl_cffi import requests as creq
def get_slowly(url: str, *, max_tries: int = 6,
base_delay: float = 2.0, cap: float = 60.0):
"""429/50x-re lassú retry – csak idempotens GET-et adj be neki!"""
for attempt in range(max_tries):
r = creq.get(url, impersonate="chrome150", timeout=30)
if r.status_code < 400:
return r
if r.status_code != 429 and r.status_code < 500:
r.raise_for_status() # 4xx: újrapróbálni sem éri meg
retry_after = r.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
delay = float(retry_after) # ha a szerver mondja, ő tudja pontosan
else:
delay = min(cap, base_delay * 2 ** attempt)
delay += random.uniform(0, min(cap, delay)) # full jitter
time.sleep(delay)
raise RuntimeError(f"{url}: {max_tries} próbálkozás után is hibás")
A szabályok röviden:
cap-pel lefedve — lassan engedd vissza a klienst, ne pörgesse a szervert.Retry-After header, azt mindig tartsd be, akkor is, ha rövidebb a saját számításodnál.429 és 5xx esetén próbálkozz újra; a többi 4xx (403, 404) nem múlik el várakozással.Mert a szerveroldali botvédelem még a HTML elküldése előtt azonosítja a klienst. A requests és a httpx alapértelmezett User-Agent-je, hiányzó sec-ch-ua* / Sec-Fetch-* headerei és nem böngészőszerű TLS-ujjlenyomata együtt azonnal elárulja, hogy nem valódi böngészőről van szó — ezért kapsz 403-at, miközben ugyanaz az URL a Chrome-ban betöltődik.
A JA3 és az újabb JA4 egy tömör aláírás, amelyet a szerver a TLS ClientHello üzenetéből számol: a támogatott titkosítók, kiterjesztések és paraméterek halmazából és sorrendjéből. Mivel ezt a mögöttes SSL-könyvtár (Python esetén OpenSSL) rakja össze, egy Python-kliens ujjlenyomata jól elkülönül a valódi Chrome-étól — függetlenül attól, milyen HTTP-headereket küldesz.
Részben. A curl_cffi impersonate a valódi böngésző TLS- és HTTP/2-ujjlenyomatát küldi, ezért a passzív ellenőrzésen átjut. Az aktív JavaScript-challenge-et (rt:'i') azonban egy statikus HTTP-kliens nem tudja lefuttatni, ahhoz headless böngésző vagy érvényes session-cookie kell. A gyakorlatban a legfontosabb tényező nem is a challenge, hanem az IP-reputáció.
Mert a DataDome a kliens IP-címe és TLS-ujjlenyomata alapján, szerveroldalon dönti el a választ: megoldható challenge-et, „szürke" kemény tiltást (üres hibaoldal, challenge nélkül) vagy átengedést ad. Ha az IP jó reputációjú, a curl_cffi passzív rétege magától átjut; ha a „szürke" bucketbe került, még a challenge-et sem kapod meg. A megoldás az IP-rotáció, nem a challenge erőltetése.
Nem stabil. A datadome cookie-t a böngészőben futó JavaScript folyamatosan újragenerálja, és a sessionhez meg az IP-hez köti. Egy kimásolt, statikus cookie hamar lejár, vagy egy következő navigációnál új challenge-et vált ki — ezért nem megbízható hosszú távú megoldás a cookie kézi átemelése.
A technika önmagában nem tilos, de a használat számít. Olvasd el a robots.txt-et és az oldal ÁSZF-ét (sok oldal explicit tilalmat ír elő az automatizált elérésre), tarts mértéket a kérésszámmal, és soha ne gyűjts személyes adatot vagy bejelentkezés mögötti tartalmat engedély nélkül. Nyilvános termékadatoknál a GDPR tipikusan nem merül fel, a szerződési feltételek viszont akkor is kötnek.