Боевая лекция: сценарии веб-парсинга

Для кого этот документ. Это самодостаточный учебный материал, который можно передать другому агенту (или человеку) целиком. Он учит не отдельным приёмам, а инженерному мышлению: «увидел защиту сайта → выбрал адекватный приём → знаешь, чем заменить, если защиту усилят».

Кейс-основа. Разбор построен на реальном работающем сервисе dubai_prop — конвейере, который ежедневно парсит объявления о недвижимости в Дубае с трёх площадок (Property Finder, Bayut, Dubizzle) и официальную статистику сделок Земельного департамента (DLD), а потом ищет недооценённые/distress-лоты. Этот сервис ценен тем, что в нём собраны почти все боевые сценарии парсинга, и каждый выбран под конкретную защиту, а не случайно.


0. Главная мысль лекции

Парсинг — это не «написать regex по HTML». Парсинг — это диалог с защитой сайта:

  1. Сайт решает, как отдать данные (готовым JSON / через JS / через приватный API / под WAF).
  2. Сайт решает, кого пускать (детект ботов по отпечаткам).
  3. Ты выбираешь минимально достаточный приём: чем проще — тем дешевле и надёжнее.

Золотое правило: сначала ищи лёгкий путь (готовый JSON, открытый API), и только если его нет — поднимай тяжёлую артиллерию (браузер, стелс, платный прокси).


1. Карта решений: «какая защита → какой приём»

Это дерево решений — главный конспект. Остальная лекция просто раскрывает каждую ветку.

Открыл страницу. Где данные?
│
├─ Данные уже в 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.


2. Сценарий 1 — Скрытый JSON прямо в HTML (__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. Часто сайт уже отдал тебе все данные структурой, и работа сводится к навигации по словарю.


3. Сценарий 2 — Извлечение 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 с жадностью/лень даст битый кусок на первой же вложенности. Скобочный матчинг с учётом строковых литералов — классический надёжный приём, который стоит держать в инструментарии.


4. Сценарий 3 — Headless-браузер вместо HTTP-запроса

Когда: данных в исходном 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 получен
# дальше все запросы идут в этом же контексте — токен уже «запечён»

Урок. Много защит устроены как «получи пропуск на входе, дальше ходи свободно». Не ломись сразу на целевую страницу — сначала пройди рукопожатие на простой.


6. Сценарий 5 — Платный API-парсинга / прокси-сервис

Когда: воевать с антиботом самому дорого или ненадёжно (например, нужен резидентный 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.


7. Сценарий 6 ⭐ — Реверс приватного API + перехват и «переигрывание» токенов

Это самый продвинутый и самый ценный сценарий. Вместо парсинга 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. Приём «открыть страницу браузером → перехватить токены → переиграть в прямой запрос» обходит даже подписанные/временные токены, потому что их выдаёт сам сайт.


8. Сценарий 7 — Обход антибот-детекта (cat & mouse)

Это теоретическая база: по каким признакам сайты ловят автоматизацию и почему одни инструменты проходят, а другие — нет. В кейсе этому посвящён целый ресёрч (research/headless_stealth.md), где тестировались 3 разных антибота:

Площадка Антибот Чем ловит
Bayut Humbucker JS-challenge + WebGL/canvas-отпечаток
Dubizzle Imperva ___utmvc-cookie + следы Selenium/chromedriver
Property Finder AWS WAF вероятностный JS-challenge по IP

Главные векторы детекта (это надо знать наизусть):

Почему 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

Урок. Выбор стелс-инструмента — это не «какой моднее», а «какой именно вектор детекта у цели». И всегда держи запасной способ: защиту могут усилить в любой день.


9. Сценарий 8 — Сквозные приёмы (нужны почти всегда)

Эти вещи не зависят от защиты — они есть в каждом нормальном парсере.

Пагинация и обход пространства 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)

10. Чек-лист парсера (бери и применяй)

Перед тем как писать код:

При написании:

Гигиена и этика:


11. Глоссарий


Приложение. Файлы кейса (для самостоятельного разбора)

Уровень Файл Приём
🟢 База 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. Каждый файл — это один-два приёма из лекции в боевом исполнении.