Для кого этот документ. Это самодостаточный учебный материал, который можно передать другому агенту (или человеку) целиком. Он учит не отдельным приёмам, а инженерному мышлению: «увидел защиту сайта → выбрал адекватный приём → знаешь, чем заменить, если защиту усилят».
Кейс-основа. Разбор построен на реальном работающем сервисе
dubai_prop— конвейере, который ежедневно парсит объявления о недвижимости в Дубае с трёх площадок (Property Finder, Bayut, Dubizzle) и официальную статистику сделок Земельного департамента (DLD), а потом ищет недооценённые/distress-лоты. Этот сервис ценен тем, что в нём собраны почти все боевые сценарии парсинга, и каждый выбран под конкретную защиту, а не случайно.
Парсинг — это не «написать regex по HTML». Парсинг — это диалог с защитой сайта:
Золотое правило: сначала ищи лёгкий путь (готовый JSON, открытый API), и только если его нет — поднимай тяжёлую артиллерию (браузер, стелс, платный прокси).
Это дерево решений — главный конспект. Остальная лекция просто раскрывает каждую ветку.
Открыл страницу. Где данные?
│
├─ Данные уже в HTML готовым JSON?
│ ├─ В теге <script id="__NEXT_DATA__"> …………………… Сценарий 1 (просто json.loads)
│ └─ Вшиты в код, без тега ("hits":[…]) ……………… Сценарий 2 (скобочный матчинг)
│
├─ Данных в HTML нет, их догружает JS?
│ └─ Нужен реальный браузер …………………………………… Сценарий 3 (Playwright / Camoufox)
│ └─ WAF выдаёт cookie только после визита? … Сценарий 4 (cookie-baking)
│
├─ Сайт ходит в свой бэкенд-API за JSON?
│ └─ Перехвати заголовки и бей в API напрямую …… Сценарий 6 (реверс API + replay токенов) ⭐
│
└─ Антибот ловит автоматизацию по отпечатку?
├─ Заплатить сервису, пусть решает за тебя ……… Сценарий 5 (scrape.do / прокси-API)
└─ Обходить отпечаток самому ……………………………… Сценарий 7 (стелс: Camoufox / curl_cffi)
И сквозные приёмы, которые нужны почти всегда: пагинация, ожидание гидрации, дедуп, снапшоты, текст-майнинг — Сценарий 8.
__NEXT_DATA__)Когда: сайт построен на Next.js (очень
распространено: маркетплейсы, листинги, e-commerce). Сервер уже
сериализовал все данные страницы в один JSON и положил в
<script id="__NEXT_DATA__" type="application/json">.
Идея: ты не парсишь верстку вообще.
Берёшь один блок, json.loads, идёшь по дереву объекта. Это
самый чистый и устойчивый парсинг — верстка может меняться, а структура
данных стабильна.
# pull_pf.py — Property Finder
raw = await page.evaluate("() => document.getElementById('__NEXT_DATA__')?.textContent")
d = json.loads(raw)
listings = (d.get("props", {})
.get("pageProps", {})
.get("searchResult", {})
.get("listings", []))# pull_dubizzle.py — то же, но JSON достаётся из сырого HTML регуляркой
m = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.+?)</script>',
html, re.DOTALL)
d = json.loads(m.group(1))Урок. Прежде чем писать селекторы — открой DevTools
→ Elements/Network и поищи __NEXT_DATA__,
window.__INITIAL_STATE__, __NUXT__,
application/ld+json. Часто сайт уже отдал тебе все данные
структурой, и работа сводится к навигации по словарю.
Когда: данные есть в HTML, но не
изолированы в отдельном теге — массив объектов просто вшит в
код (у Bayut это Algolia-массив "hits":[…]). Регулярка тут
ломается: внутри строк бывают [, ],
экранированные кавычки, вложенные массивы.
Идея: ручной парсер-сканер со счётчиком
глубины скобок, который умеет различать «внутри строки» и
экранирование. Находим открывающую [, идём по символам,
считаем баланс, вырезаем ровно массив.
# pull_bayut.py — extract_hits()
i = html.index('"hits":[')
j = i + len('"hits":[')
depth, in_str, esc = 1, False, False
while j < len(html) and depth > 0:
c = html[j]
if esc: esc = False
elif c == "\\": esc = True
elif c == '"': in_str = not in_str
elif not in_str:
if c == "[": depth += 1
elif c == "]": depth -= 1
j += 1
inner = html[i + len('"hits":['): j - 1]
hits = json.loads("[" + inner + "]")Урок. Когда JSON не отделён тегом —
re.search с жадностью/лень даст битый кусок на первой же
вложенности. Скобочный матчинг с учётом строковых литералов —
классический надёжный приём, который стоит держать в инструментарии.
Когда: данных в исходном HTML нет — их рисует
JavaScript после загрузки; или стоит WAF, который проверяет исполнение
JS. Обычный requests/curl вернёт пустой
каркас.
Идея: запустить настоящий браузер, который выполнит JS, и забрать уже отрендеренный DOM/HTML.
Два варианта в кейсе:
(а) Vanilla Playwright + ручные «стелс»-патчи
(pull_pf.py):
browser = await pw.chromium.launch(
headless=not headed,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"])
ctx = await browser.new_context(
user_agent=UA, # подменяем User-Agent
viewport={"width": 1440, "height": 900},
locale="en-US")
await ctx.add_init_script( # прячем флаг автоматизации
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined})")(б) Camoufox — браузер-невидимка
(cf.py), drop-in замена для всего:
from camoufox.sync_api import Camoufox
_browser = Camoufox(headless=True, humanize=True, locale="en-US",
os=("macos", "linux")) # форк Firefox с маскировкой отпечатка
page = ctx.new_page()
resp = page.goto(url, wait_until="domcontentloaded")
html = page.content()Тонкость продакшена (из кейса): браузер запускается
ОДИН раз на процесс и переиспользуется между запросами — буст браузера
~3 сек, при сотнях страниц это экономит минуты. См. cf.py:
модульные синглтоны _browser/_ctx +
atexit.
Урок. Браузер — дорого (память ~400 МБ, секунды на страницу). Поднимай его только когда без JS никак. И всегда переиспользуй инстанс.
Когда: WAF (напр. AWS WAF у Property Finder) выдаёт токен-cookie только после визита на лёгкую страницу (главную), а на страницах поиска уже проверяет его наличие.
Идея: сначала зайти на homepage, подождать,
получить cookie (aws-waf-token), и лишь
потом в том же контексте идти по тяжёлым страницам.
# pull_pf.py — main()
await page.goto("https://www.propertyfinder.ae/en", wait_until="domcontentloaded")
await page.wait_for_timeout(8000) # даём WAF выдать токен
cookies = await ctx.cookies()
waf = [c["name"] for c in cookies if "waf" in c["name"].lower()]
print(f"WAF tokens: {waf}") # убедились, что cookie получен
# дальше все запросы идут в этом же контексте — токен уже «запечён»Урок. Много защит устроены как «получи пропуск на входе, дальше ходи свободно». Не ломись сразу на целевую страницу — сначала пройди рукопожатие на простой.
Когда: воевать с антиботом самому дорого или ненадёжно (например, нужен резидентный IP именно из нужной страны). Прагматичный путь — заплатить сервису (scrape.do, ScraperAPI, Zyte и т.п.), который держит пул прокси и рендерит за тебя.
Идея: ты шлёшь один GET на их API с параметрами, они
возвращают готовый HTML. Параметры из sd.py — это, по сути,
словарь любого такого сервиса:
# sd.py
params = {
"token": TOKEN,
"url": target_url,
"render": "true", # выполнить JS на их стороне
"super": "true", # премиум резидентный прокси
"geoCode": "ae", # IP именно из ОАЭ (гео-таргетинг)
"customWait": "4000", # подождать гидрацию
"waitSelector": "...", # дождаться конкретного элемента
"sessionId": "...", # липкая сессия — тот же IP между запросами
}
# устойчивость: ретраи с экспоненциальной задержкой
for attempt in range(retries + 1):
r = requests.get(API, params=params, timeout=timeout)
if r.status_code == 200: return r.status_code, r.text
time.sleep(2 ** attempt) # 1с, 2с, 4с …Урок. Ретраи 2**attempt (exponential
backoff), гео-роутинг и sticky-сессии — это базовая гигиена устойчивого
парсинга, даже когда прокси свои. Параметр geoCode важен:
цены/выдача часто зависят от страны IP.
Это самый продвинутый и самый ценный сценарий. Вместо парсинга HTML — найди, в какой бэкенд-API ходит сам сайт, перехвати его заголовки авторизации и постучись в API напрямую. На выходе — чистый JSON без всякой верстки.
Кейс (pull_dld.py): у Земельного
департамента Дубая есть внутренний gateway
gateway.dubailand.gov.ae, который требует заголовки
consumer-id + token. Эти заголовки сайт сам
себе выдаёт при загрузке страницы eServices. Алгоритм:
# pull_dld.py
# 1. Вешаем слушатель на сетевые запросы браузера
captured = {}
page.on("request",
lambda r: captured.update(dict(r.headers))
if "gateway.dubailand.gov.ae" in r.url else None)
# 2. Открываем реальную страницу — сайт сам сходит в свой API,
# и мы перехватываем его заголовки авторизации
page.goto("https://dubailand.gov.ae/en/eservices/real-estate-transaction/")
page.wait_for_timeout(10000)
# 3. Переигрываем перехваченные заголовки — бьём в JSON-API НАПРЯМУЮ
url = ("https://gateway.dubailand.gov.ae/communitywise/transaction/sales/details/"
f"?fromDate={from_date}&toDate={to_date}&data=Aggregated")
r = ctx.request.get(url, headers=captured) # получаем чистый JSON со сделками
data = json.loads(r.text())Урок. Вкладка Network в DevTools — главный инструмент парсера. Перед тем как парсить верстку, посмотри XHR/Fetch-запросы: часто за красивым сайтом стоит открытый (или полу-открытый) JSON-API. Парсить надо его — это на порядок стабильнее и быстрее, чем HTML. Приём «открыть страницу браузером → перехватить токены → переиграть в прямой запрос» обходит даже подписанные/временные токены, потому что их выдаёт сам сайт.
Это теоретическая база: по каким признакам сайты ловят
автоматизацию и почему одни инструменты проходят, а другие —
нет. В кейсе этому посвящён целый ресёрч
(research/headless_stealth.md), где тестировались 3 разных
антибота:
| Площадка | Антибот | Чем ловит |
|---|---|---|
| Bayut | Humbucker | JS-challenge + WebGL/canvas-отпечаток |
| Dubizzle | Imperva | ___utmvc-cookie + следы Selenium/chromedriver |
| Property Finder | AWS WAF | вероятностный JS-challenge по IP |
Главные векторы детекта (это надо знать наизусть):
Runtime.enable оставляют
след, который антибот видит.--headless=new fingerprint. У
headless-Chrome характерный отпечаток GPU/WebGL/canvas — отличается от
обычного. Поэтому headless часто палится там, где headful (с экраном)
проходит.curl_cffi имитирует
рукопожатие реального браузера — и проходит WAF, которые смотрят только
на TLS/заголовки (но бессилен против JS-challenge).navigator.webdriver, chrome.runtime, наличие
подставленных функций.Почему Camoufox побеждает всех трёх (вывод кейса):
это форк Firefox, где (1) браузер «честно» является Firefox и не
рекламирует HeadlessChrome, и (2) управляющий
Playwright-код вынесен из JS-контекста страницы
(sandboxed agent) — поэтому JS-пробы антибота видят нативные значения.
Результат тестов: Camoufox headless = 3/3 PASS,
остальные библиотеки падали хотя бы на одном сайте.
Лестница фолбэков (зрелое инженерное решение — всегда имей план Б):
Bayut: Camoufox → nodriver (headed, когда ноут активен)
Dubizzle: Camoufox → фолбэка нет (Imperva ест всё остальное)
PropertyFinder: curl_cffi (~1 сек, дёшево) → если WAF включат, Camoufox
Урок. Выбор стелс-инструмента — это не «какой моднее», а «какой именно вектор детекта у цели». И всегда держи запасной способ: защиту могут усилить в любой день.
Эти вещи не зависят от защиты — они есть в каждом нормальном парсере.
Пагинация и обход пространства URL. Все парсеры идут
циклом район × страница. Слаги районов сверены с sitemap
сайта (dubai-buy-0.xml). Конец страниц ловится по сигналам,
а не угадывается:
tx_list, total_pages = parse_tx(html) # totalPageCount подсказывает, сколько страниц есть
if not tx_list: break # пустой ответ = достигли конца
if status == 404: return [] # 404 = слаг не существуетОжидание ленивой гидрации. Вместо «поспать N секунд» правильнее ждать появления маркера данных — это убирает 90% флейков:
# плохо: page.wait_for_timeout(4000)
# хорошо:
page.wait_for_function("() => document.documentElement.outerHTML.includes('\"hits\":[')")Дедуп и инкрементальные снапшоты. Каждый день сохраняется parquet, дубли убираются по id, история копится в DuckDB. Это обязательно, если нужно ловить изменения (падение цены): для diff нужны исторические снимки.
df = pd.DataFrame(all_rows).drop_duplicates(subset=["id"])
df.to_parquet(DATA / f"{today}.parquet", index=False)Текст-майнинг по распарсенному контенту. Поверх данных — regex по описанию объявления, ищем сигналы:
DISTRESS_KEYWORDS = re.compile(
r"(distress|urgent|must\s+sell|below\s+market|price\s+drop|"
r"срочн|торг|ниже\s*рынка|переезжаю)", re.IGNORECASE)Перед тем как писать код:
При написании:
Гигиена и этика:
__NEXT_DATA__ — JSON-дамп состояния
страницы у сайтов на Next.js.| Уровень | Файл | Приём |
|---|---|---|
| 🟢 База | parsers/pull_pf.py |
__NEXT_DATA__ + браузер + cookie-baking |
| 🟢 База | parsers/pull_bayut.py |
скобочный матчинг JSON |
| 🟢 База | parsers/pull_dubizzle.py |
__NEXT_DATA__ из сырого HTML + обход дерева |
| 🟡 Средне | parsers/sd.py |
платный прокси-API, ретраи, гео, сессии |
| 🟡 Средне | parsers/cf.py |
Camoufox как универсальная замена, синглтон-браузер |
| 🔴 Продвинуто | parsers/pull_dld.py |
реверс приватного API + перехват и replay токенов |
| 📖 Теория | research/headless_stealth.md |
детект ботов, сравнение 8 библиотек, лестница фолбэков |
Лучший способ усвоить — открыть эти файлы по порядку (от 🟢 к 🔴) и пройтись по сценариям 1→8. Каждый файл — это один-два приёма из лекции в боевом исполнении.