"""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.warning( "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.warning( "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, ) current_counters = counters.to_dict() if _caused_by_empty_pool(exc): # #3384: пул был пуст ещё ДО первой карточки — lease берётся в # BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из # самого `async with` (строка 403) мимо стоп-механики цикла, которая и # ставит no_proxy_stop. Без ключа такой прогон (attempted=0, к площадке не # ходили) неотличим от любого другого падения: разбор простоя идёт SQL'ём # по counters.no_proxy_stop (#3283/#3367), а не грепом текста ошибки. current_counters["no_proxy_stop"] = 1 runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters) raise