gendesign/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
bot-backend e2e7e17f95
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
fix(domclick): каждая попытка добора получает исход (#3332)
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.

Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.

Closes #3332
2026-09-02 17:35:48 +05:00

646 lines
37 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
import httpx
from scraper_kit.browser_fetcher import (
BrowserFetcher,
SidecarBanPageError,
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 scraper_kit.proxy_errors import NoProxyAvailableError
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 _iter_causes(exc: BaseException) -> list[BaseException]:
"""Цепочка причин исключения, без зацикливания."""
seen: set[int] = set()
out: list[BaseException] = []
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
out.append(cur)
seen.add(id(cur))
cur = cur.__cause__ or cur.__context__
return out
def _caused_by_empty_pool(exc: BaseException) -> bool:
"""Прячется ли за этим «блоком» пустой пул прокси (#3283).
`NoProxyAvailableError` документирован ровно как «НАША инфраструктура, не
внешний блок», и поднимается ДО HTTP-запроса: к площадке мы не ходили вовсе.
Сюда он попадает под видом блокировки, потому что `fetch_detail` заворачивает
в `DomClickBlockedError` любое исключение фетча (`except Exception`).
"""
return any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(exc))
def _is_transport_failure(exc: BaseException) -> bool:
"""Сбой нашей стороны, а не отказ площадки (#3283).
Различать по HTTP-статусу нельзя: настоящий QRATOR-челлендж приходит вообще
без статуса либо под 200, то есть неотличим от таймаута навигации. Зато
различима ПРИРОДА исключения, и разделение уже проведено в `fetch_detail`:
* ветка `except SidecarBanPageError` — сайдкар опознал страницу-отказ по
маркерам тела, это генуинный бан (там же `report_ban`);
* `parse_detail_html`, поднявший `DomClickBlockedError` — маркеры в HTML,
тоже генуинный;
* ветка `except Exception` — таймаут / 5xx сайдкара / транспорт, обёрнутый
`raise ... from exc`. Исходное исключение остаётся в `__cause__`.
ПОРЯДОК ПРОВЕРОК ЗДЕСЬ НЕСЛУЧАЕН. `SidecarBanPageError` объявлен как
`class SidecarBanPageError(httpx.HTTPStatusError)`, то есть ПО ТИПУ он
httpx-ошибка — и проверка на httpx, стоящая первой, объявляла генуинный
отказ площадки транспортным сбоем. Поймано на проде: прогон 5406 записал
`http=401, kind=platform` под заголовком «не блок площадки». Поэтому бан
сайдкара отсекается ДО общей httpx-ветки, а не после.
"""
causes = _iter_causes(exc)
if any(isinstance(c, SidecarBanPageError) for c in causes):
return False
return any(isinstance(c, httpx.HTTPError) for c in causes)
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))
# #3283: отдельный, намеренно более высокий порог для сбоев нашей стороны
# (таймаут навигации, 5xx сайдкара). Тройка на них — хайртриггер: прогон 5399
# умер на трёх подряд, не увидев ни одного отказа площадки.
max_consecutive_soft = int(params.get("max_consecutive_soft_failures", 10))
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
# #3283: сбои НЕ блочной природы считаются отдельно и с большим порогом.
consecutive_soft = 0
no_proxy_stop = False
# #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
else:
# #3332, та же дыра в тождестве, что и у пустого пула: карточку
# забрали, а строки уже нет (удалена/деактивирована между
# снимком и UPDATE) — попытка была, исхода не было. Тихо
# терять её нельзя: расхождение читается как потерянный блок.
counters.failed += 1
logger.warning(
"domclick_detail_backfill: run_id=%d listing %s — карточка "
"разобрана, но UPDATE не нашёл строку id=%d",
run_id,
source_url,
listing_id,
)
consecutive_blocks = 0
consecutive_soft = 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:
ban_kind = _ban_kind_of_block(e)
# #3283 (1): пустой пул — не блок. Запрос к площадке НЕ уходил,
# и следующая карточка упрётся ровно в то же самое: продолжать
# цикл бессмысленно, а начислять блок — прямая ложь про причину.
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
if _caused_by_empty_pool(e):
# #3332: попытка уже посчитана в attempted (строка выше), а этот
# выход из цикла шёл мимо ВСЕХ исходов — тождество
# attempted = enriched + failed + blocked ломалось ровно на 1
# (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
# Исход честно failed, а не blocked: к площадке не ходили, это
# отказ нашей стороны — тот же разряд, что у транспортных сбоев
# ниже. Причина не теряется: в записи прогона стоит
# no_proxy_stop=1 и mark_failed с текстом про пул.
counters.failed += 1
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
counters.enriched,
counters.attempted,
)
no_proxy_stop = True
break
# #3283 (2): порог считаем по ПРИЧИНЕ, а не по числу исключений.
# Обрыв нужен, чтобы не долбить отказывающую площадку, — значит
# считать надо её отказы. Таймаут навигации и 5xx сайдкара это
# наша сторона; они идут в failed, как уже идёт DomClickParseError
# (он честно помечен «schema drift, not a block»).
if _is_transport_failure(e):
counters.failed += 1
consecutive_soft += 1
block_ban_kinds[ban_kind] += 1
logger.warning(
"domclick_detail_backfill: run_id=%d СБОЙ #%d/%d "
"(подряд=%d/%d, http=%s, kind=%s, не блок площадки): %s",
run_id,
idx + 1,
len(snapshot),
consecutive_soft,
max_consecutive_soft,
getattr(e, "status", None),
ban_kind,
e,
)
# Сторож на случай, если площадка отказывает молча (сайдкар не
# отдал статус → kind='unknown'): без него такой отказ гнал бы
# весь батч впустую. Порог выше блочного намеренно — цена
# ошибки здесь несимметрична, см. #3272.
if consecutive_soft >= max_consecutive_soft:
logger.error(
"domclick_detail_backfill: run_id=%d ABORT -- %d сбоев "
"подряд без единого отказа площадки, диагнозы: %s. "
"enriched=%d attempted=%d",
run_id,
consecutive_soft,
dict(block_ban_kinds) or "нет",
counters.enriched,
counters.attempted,
)
aborted_by_blocks = True
break
# Пауза не нужна: цикл сам спит в начале следующей итерации.
continue
consecutive_blocks += 1
counters.blocked += 1
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()
if no_proxy_stop:
# #3283: остановка из-за пустого пула — НЕ блок, поэтому и не
# aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись будет
# утверждать про площадку то, чего не было. Это отказ нашей стороны.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db,
run_id,
"пул прокси пуст — к площадке не ходили (#3283)",
current_counters,
)
else:
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