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
Обрыв «пул прокси пуст» уходил из цикла между 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
646 lines
37 KiB
Python
646 lines
37 KiB
Python
"""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
|