gendesign/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
bot-backend fd95c962bb
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m53s
fix(tradein/domclick): backfill ходил в сайдкар мимо прокси-пула (#3197 ч.1)
BrowserFetcher(source="domclick", reuse_context=True) конструировался без
proxy_provider/use_pool/environment -- тела POST /fetch не несли "proxy",
сайдкар брал свой env-прокси, и прогон шёл мимо пула целиком: ни выбора узла
по affinity, ни scrape_proxy_source_bans, ни ротации при блоке. Тот же дефект
уже чинили на avito_detail_backfill/house_imv_backfill (#2698) -- этот call
site оставался последним непочиненным. environment обязателен: без него
отказ «пул пуст» на этом пути мёртв (#2616 шаг 1). reuse_context=True
сохранён без изменений.

Заодно поправлен устаревший комментарий над конструктором: ссылался на
scrape_proxies.provider_affinity='domclick' и миграцию 173 -- на проде
такого больше нет (миграция 253 сняла резервацию узла, #2800), все четыре
включённых узла (id 1/9/10/11) имеют provider_affinity='any'.

test_3118_domclick_warm_context.py обновлён под новую сигнатуру вызова
(assert_called_once_with -> точечная проверка нужных kwargs).
2026-08-29 15:26:14 +03:00

490 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Scheduled backfill: detail-enrichment (Layer B) for domklik listings (issue #2000).
Nightly window 15:00-18:00 UTC (migration 175, source=domclick_detail_backfill).
Offset from avito_detail_backfill (09-12 UTC) and yandex_detail_backfill (12-15 UTC)
to avoid parallel egress across scraper sources sharing the tradein-browser pool.
Problem: DomClick SERP layer (Layer A, scraper_kit.providers.domclick.serp) discovers
listings via the BFF JSON API, but detail-enrichment (Layer B — repair_state,
living/kitchen area, year_built, owners_count, price history) requires a per-card
browser-fetch that is QRATOR-guarded (issue #2000). Two prior PRs solved the block at
the single-fetch level:
- PR #2430 -- organic same-site SERP-origin navigation before the card fetch.
- PR #2433 -- cookie-injection MVP (authenticated test-account session bypasses the
QRATOR reputation-block even on an already-suspicious proxy-IP).
Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detail-fetch`
(app/api/v1/admin.py). This module ports that same session -> cookies -> fetch_detail
wiring into the production scheduled orchestrator (previously only reachable manually).
Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher
per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула;
выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800),
на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via
domclick_session.load_session(db) and threaded into every fetch_detail() call.
NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere):
listings.source value for DomClick rows is 'domklik' (with a "k") -- that is the
DATA/business identifier used across listings.source, DomClickScraper.name/.source,
schemas, PDF exporter labels. 'domclick' (with a "c") is the newer INFRA/transport
identifier -- BrowserFetcher(source="domclick"), scrape_proxies.provider_affinity,
the SSRF host-guard, cookie-session tables. Both strings are correct in their own
context; this split is intentional and pre-existing, not a bug to unify here.
No curl fallback: unlike Avito's dual-mode (curl_cffi backconnect OR browser),
DomClick only has a BrowserFetcher path -- scraper_kit.providers.domclick.detail.
fetch_detail() raises DomClickBlockedError if the browser fetch itself fails. Exactly
one BrowserFetcher is constructed per run.
Exception triad differs from Avito:
- DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) --
increments consecutive_blocks, abort once max_consecutive_blocks is hit.
Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished):
блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
No IP-rotation/cooldown recovery step exists here -- an aborted run simply
retries the remaining backlog next window.
УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated
residential proxy, not a rotating pool». Это перестало быть правдой на миграции
253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть
отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто
непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
длительности до блока 111-332 с, получает свой бюджет).
ДИАГНОЗ (#3196, снимает ограничение #2764): scrape_runs.ban_kind теперь
передаётся. Один и тот же DomClickBlockedError по-прежнему поднимается и на
распознанном маркере, и на сбое браузерного fetch, но несёт HTTP-статус ответа
(`.status`, см. providers/domclick/detail.py::fetch_detail) — разводить типы
исключений ради этого не понадобилось. 403/429 → 'platform' (страница
`403 | Домклик` на 26 624 байта, снятая вживую 28.08), 5xx → 'infra', статуса нет →
'unknown', честно «не установлено». Раньше все 14 прогонов за 14 дней
получали 'unknown' просто потому, что диагноз не доезжал до финализатора.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
block) -- counted as failed++, logged, does NOT touch consecutive_blocks and
does NOT abort the run (neutral to the block-breaker, mirrors how Avito's
AvitoListingGoneError is neutral to its breaker for a different reason).
- There is no "listing gone / 404" exception type for DomClick yet -- not
invented here.
Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run.
If None (no valid session uploaded / expired) -- the run still proceeds (cookie-
injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin
navigation from PR #2430 still applies) but an ERROR is logged once at run start so
operators notice the test-account session needs refreshing via
`POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation,
see app/services/domclick_session.py module docstring).
#2674: раньше это был WARNING, который в скрапер-контейнере событием не становится
(LoggingIntegration event_level=ERROR) — куки протухли 2026-08-03 и об этом никто не
узнал. Теперь два сигнала вместо одного: ERROR по факту (_alert_domclick_cookies) и
ERROR ЗАРАНЕЕ, пока куки ещё живы (_warn_before_domclick_cookies_expire) — по образцу
#2658 для Циана, ручное обновление кук требует запаса времени.
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from collections import Counter
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.shutdown import shutdown_requested
from app.services import domclick_session as domclick_session_svc
from app.services import scrape_runs as runs_mod
from app.services.scrape_runs import BAN_KIND_PLATFORM, BAN_KIND_UNKNOWN
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
logger = logging.getLogger(__name__)
__all__ = [
"DomClickDetailBackfillResult",
"run_domclick_detail_backfill",
]
def _alert_domclick_cookies(db: Session, run_id: int) -> None:
"""Громкий сигнал «обогащение идёт без кук» — logger.error, не warning (#2674).
В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR)
(scheduler_main.py), поэтому прежний WARNING событием не становился: куки протухли
на проде 2026-08-03, и единственным следом была строка в docker-логе, которая
теряется при редеплое. Прогон при этом НЕ прерываем — cookie-инъекция это
механизм обхода QRATOR, а не жёсткое требование (см. докстринг модуля), — но
состояние требует ручного действия человека, значит должно быть событием.
Причину различаем так же, как #2658 у Циана: «кук нет вовсе» и «протухли N дней
назад» лечатся одинаково, но диагностируются по-разному.
"""
expires_at = domclick_session_svc.session_expires_at(db)
now = datetime.now(tz=UTC)
if expires_at is None:
detail = "кук DomClick нет в БД"
elif expires_at <= now:
detail = (
f"куки DomClick протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
)
else:
detail = "куки DomClick помечены невалидными (last_invalid_at)"
logger.error(
"domclick_detail_backfill: run_id=%d%s; обогащение идёт БЕЗ cookie-инъекции "
"(QRATOR-обход деградировал до organic SERP-origin навигации, PR #2430). "
"Перезалейте сессию test-аккаунта: POST /scrape/domclick/upload-cookies",
run_id,
detail,
)
def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
"""Предупредить ЗАРАНЕЕ, пока куки ещё рабочие (#2674, образец — #2658 для Циана).
Сигнал по факту протухания приходит, когда обогащение уже встало; обновление кук
ручное, человеку нужен запас. valid_only=True — срок ИМЕННО той записи, которую
взял load_session (при нескольких аккаунтах свежайшая-любая может быть чужой).
"""
expires_at = domclick_session_svc.session_expires_at(db, valid_only=True)
if expires_at is None:
return
left = expires_at - datetime.now(tz=UTC)
if left <= timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS):
logger.error(
"domclick_detail_backfill: run_id=%d — куки DomClick протухнут %s "
"(осталось %.1f дн.); обновите заранее, иначе обогащение деградирует молча",
run_id,
expires_at.date().isoformat(),
left.total_seconds() / 86400,
)
# 401 у ДомКлика — отказ площадки, а не «наша сессия протухла» (замер прод 2026-08-29):
# страница при 401 РОВНО 26 624 байта, байт в байт совпадает со страницей `403 | Домклик`
# (снята 28.08.2026). Приходит одинаково и с валидной сохранённой сессией (16 куков
# domclick_session), и полностью анонимно — значит дело не в сессии, а в вердикте WAF.
#
# ВАЖНО, #3212: ранняя формулировка «без PoW/QRATOR/капчи, это отказ, а не челлендж»
# была НЕВЕРНА и здесь снята. Челлендж есть: QRATOR отдаёт 401 с крошечной заглушкой,
# браузер считает proof-of-work и дёргает /__qrator/validate — принятая валидация даёт
# 200 и пропуск в куках, ОТВЕРГНУТАЯ даёт 403 и вот эту самую страницу на 26 624 байта.
# То есть 26 624 байта — не «статика вместо челленджа», а РЕЗУЛЬТАТ проваленного
# челленджа. Ошибка вышла из метода: HTML читали на 4.5-й секунде, когда заглушка уже
# заменена, и ни разу не смотрели в сетевой лог.
# Правка ДОМЕННАЯ, не в общей ban_kind_from_status (scraper_kit.browser_fetcher): у других
# поставщиков 401 обычно значит «наша сессия протухла» (наша сторона) — общая метка
# 'platform' там зря запустила бы ротацию IP (#2611).
_DOMCLICK_REFUSAL_STATUSES = frozenset({401})
def _ban_kind_of_block(exc: DomClickBlockedError) -> str:
"""Диагноз одного блока по HTTP-статусу ответа площадки (#3196, #3178).
Форма повторяет avito_detail_backfill (ban_kind_of_exception → Counter →
mark_backfill_finished(ban_kinds=...)), но опора другая: у Авито причину несёт
ТИП исключения (AvitoSidecarUnavailableError vs AvitoBlockedError), у Домклика
тип один на оба случая, поэтому смотрим статус. 401/403/429 → 'platform' (401 —
доменное исключение из общего правила, см. _DOMCLICK_REFUSAL_STATUSES), 5xx →
'infra' (ban_kind_from_status), статуса нет → 'unknown' — не назначаем причину,
которую не установили (#2764).
"""
status = getattr(exc, "status", None)
if status in _DOMCLICK_REFUSAL_STATUSES:
return BAN_KIND_PLATFORM
return ban_kind_from_status(status) or BAN_KIND_UNKNOWN
@dataclass
class DomClickDetailBackfillResult:
"""Counters for one backfill run."""
attempted: int = 0
enriched: int = 0
blocked: int = 0
failed: int = 0
duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int]:
return {
"attempted": self.attempted,
"enriched": self.enriched,
"blocked": self.blocked,
"failed": self.failed,
"duration_sec": int(self.duration_sec),
}
async def run_domclick_detail_backfill(
db: Session,
*,
run_id: int,
params: dict,
) -> DomClickDetailBackfillResult:
"""Backfill detail_enriched_at for domklik listings via cookie-injected browser fetch.
Params (from default_params jsonb in scrape_schedules):
batch_size: int -- snapshot size (SELECT LIMIT), default 200.
budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 12.0s.
max_consecutive_blocks: int -- abort threshold, default 3.
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
mark_failed напрямую — только при исключении.
"""
batch_size = int(params.get("batch_size", 200))
budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 12.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 3))
counters = DomClickDetailBackfillResult()
current_counters: dict[str, int] = counters.to_dict()
start = time.monotonic()
logger.info("domclick_detail_backfill: run_id=%d starting", run_id)
try:
# Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every
# fetch_detail() call below. Прогон продолжается и без кук (см. докстринг), но
# это состояние требует ЧЕЛОВЕКА: обновление сессии — ручная операция.
cookies = domclick_session_svc.load_session(db)
if cookies is None:
_alert_domclick_cookies(db, run_id)
else:
_warn_before_domclick_cookies_expire(db, run_id)
runs_mod.update_heartbeat(db, run_id, current_counters)
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# NAMING TRAP (see module docstring): source='domklik' here (data identifier) is
# NOT the same string as BrowserFetcher(source="domclick") below (infra
# identifier) -- both are correct, do not "fix" one to match the other.
snapshot = (
db.execute(
text(
"""
SELECT id, source_url
FROM listings
WHERE source = 'domklik'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
),
{"batch_size": batch_size},
)
.mappings()
.all()
)
if not snapshot:
logger.info(
"domclick_detail_backfill: run_id=%d -- no pending listings "
"(detail_enriched_at IS NULL = 0), done",
run_id,
)
runs_mod.mark_done(db, run_id, current_counters)
return counters
logger.info(
"domclick_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
"delay=%.1fs max_blocks=%d cookies=%s)",
run_id,
len(snapshot),
budget_sec,
request_delay_sec,
max_consecutive_blocks,
"yes" if cookies is not None else "no",
)
consecutive_blocks = 0
# #3212: сброс переиспользуемого context'а разрешён РОВНО ОДИН раз за прогон.
# Причина ниже, у самого вызова request_context_reset.
context_reset_used = False
aborted_by_blocks = False
do_sleep = False
# #3196: перепись диагнозов ВСЕХ блоков прогона (kind -> сколько раз);
# кратности важны — их разбирает _dominant_ban_kind в scrape_runs.py.
block_ban_kinds: Counter[str] = Counter()
# Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see
# module docstring). source="domclick" -- infra identifier; node comes from the
# SHARED pool (migration 253 / #2800 dropped the dedicated-node reservation --
# as of 29.08 all four enabled nodes, id 1/9/10/11, have
# provider_affinity='any', there is no 'domclick'-affinity row anymore).
# proxy_provider/use_pool/environment (#3197): mandatory wiring, mirrors
# avito_detail_backfill.py -- without them BrowserFetcher never puts "proxy" in
# the /fetch body, the sidecar falls back to its own env-proxy, and the run goes
# around the pool entirely (no affinity selection, no scrape_proxy_source_bans,
# no rotation on block). environment is required too: without it the "pool
# empty" refusal on this path is dead code and the fetcher silently falls back
# to the env-proxy instead (#2616 step 1). Same defect already fixed in
# avito_detail_backfill (#2698) and house_imv_backfill (#2698) -- this call site
# was the one still missing it.
# reuse_context=True (#3118): sidecar's browser.new_page() creates a fresh
# isolated context on EVERY /fetch, so the once-per-run cookie injection above
# never sees the live qrator_jsid2 the site rotates via Set-Cookie (~2.5h TTL)
# -- confirmed live: 26 sequential sidecar fetches = 100% blocked, same cards
# in a warm browser context = 5/5 in ~2s each. reuse_context keeps ONE sidecar
# context alive for the whole run instead, so the cookie jar evolves on its
# own. Reset happens once per detected block (see reset_context_next below),
# not on every subsequent fetch.
_cfg = RealScraperConfig()
async with BrowserFetcher(
source="domclick",
endpoint=settings.browser_http_endpoint,
proxy_provider=RealProxyProvider(),
use_pool=_cfg.use_proxy_pool_browser,
environment=_cfg.environment,
reuse_context=True,
) as bf:
for idx, row in enumerate(snapshot):
# Budget guard
elapsed = time.monotonic() - start
if elapsed > budget_sec:
logger.info(
"domclick_detail_backfill: run_id=%d -- budget %.0fs exhausted "
"(elapsed=%.1fs), stopping at #%d/%d",
run_id,
budget_sec,
elapsed,
idx,
len(snapshot),
)
break
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
if shutdown_requested():
logger.info(
"domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at #%d/%d",
run_id,
idx,
len(snapshot),
)
break
# Jittered delay (±30%) -- organic pacing, matches the request_delay_sec
# spirit of PR #2430's same-site navigation (less robotic cadence).
if do_sleep:
await asyncio.sleep(request_delay_sec * random.uniform(0.7, 1.4))
do_sleep = True
source_url: str = row["source_url"]
listing_id: int = row["id"]
counters.attempted += 1
try:
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
consecutive_blocks = 0
except DomClickParseError as e:
# Schema drift, not a block -- neutral to the block-breaker (does
# NOT touch consecutive_blocks, does NOT abort the run).
counters.failed += 1
logger.warning(
"domclick_detail_backfill: run_id=%d listing %s PARSE-ERROR "
"(schema drift, not a block): %s",
run_id,
source_url,
e,
)
except DomClickBlockedError as e:
consecutive_blocks += 1
counters.blocked += 1
ban_kind = _ban_kind_of_block(e)
block_ban_kinds[ban_kind] += 1
# #3118 просил сброс на КАЖДЫЙ блок — и этим сам себя блокировал.
# Пропуск QRATOR (куки qrator_jsid2 + qrator_jsr) живёт в context'е;
# сброс его выбрасывает, а повторная валидация с того же IP сразу
# после принятой получает 403. То есть сброс на каждый блок делает
# из одной осечки необратимый каскад: блок → сброс → гарантированный
# блок → сброс… Ровно это и дало приёмке #3118 «1 успех из 10».
# #3212: одна попытка начать с чистого листа за прогон — на случай
# действительно протухшей сессии (TTL пропуска ~2.5ч) — и всё.
if not context_reset_used:
context_reset_used = True
bf.request_context_reset()
logger.warning(
"domclick_detail_backfill: run_id=%d BLOCKED #%d/%d "
"(consecutive=%d, http=%s, kind=%s): %s",
run_id,
idx + 1,
len(snapshot),
consecutive_blocks,
getattr(e, "status", None),
ban_kind,
e,
)
if consecutive_blocks >= max_consecutive_blocks:
# #3196: причину больше не выдумываем и не молчим — печатаем
# перепись диагнозов по HTTP-статусам этого прогона.
logger.error(
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
"blocks, диагнозы: %s. enriched=%d attempted=%d",
run_id,
consecutive_blocks,
dict(block_ban_kinds) or "нет",
counters.enriched,
counters.attempted,
)
aborted_by_blocks = True
break
except Exception as e:
counters.failed += 1
logger.warning(
"domclick_detail_backfill: run_id=%d listing %s failed: %s",
run_id,
source_url,
e,
)
try:
db.rollback()
except Exception:
pass
if counters.attempted % 25 == 0:
current_counters = counters.to_dict()
runs_mod.update_heartbeat(db, run_id, current_counters)
counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict()
runs_mod.mark_backfill_finished(
db,
run_id,
current_counters,
source="domclick_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
ban_kinds=block_ban_kinds,
)
logger.info(
"domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"blocked=%d failed=%d duration=%.1fs",
run_id,
counters.attempted,
counters.enriched,
counters.blocked,
counters.failed,
counters.duration_sec,
)
return counters
except Exception as exc:
counters.duration_sec = time.monotonic() - start
logger.exception(
"domclick_detail_backfill: run_id=%d FAILED after %.1fs",
run_id,
counters.duration_sec,
)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
raise