"""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" -- dedicated residential proxy pool, see 173_scrape_proxies_add_domclick_affinity.sql) + 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 via mark_done (NOT mark_failed) once max_consecutive_blocks is hit -- a block-abort is an expected operational outcome (QRATOR reputation burn), not a task failure. Mirrors Avito's AvitoBlockedError handling. No IP-rotation/cooldown recovery step exists here (DomClick uses one dedicated residential proxy, not a rotating pool) -- an aborted run simply retries the remaining backlog next window. - 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 a warning 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). """ from __future__ import annotations import asyncio import logging import random import time from dataclasses import dataclass, field from scraper_kit.browser_fetcher import BrowserFetcher 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 logger = logging.getLogger(__name__) __all__ = [ "DomClickDetailBackfillResult", "run_domclick_detail_backfill", ] @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_done (incl. partial/block-abort) / mark_failed (exception only). """ 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. None is a valid (degraded) state, not an error. cookies = domclick_session_svc.load_session(db) if cookies is None: logger.warning( "domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies " "in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to " "organic SERP-origin navigation only, PR #2430). Refresh test-account " "session via POST /scrape/domclick/upload-cookies.", 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 do_sleep = False # Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see # module docstring). source="domclick" -- infra identifier, dedicated # residential proxy (scrape_proxies.provider_affinity='domclick', # 173_scrape_proxies_add_domclick_affinity.sql). async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) 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 logger.warning( "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d " "(consecutive=%d): %s", run_id, idx + 1, len(snapshot), consecutive_blocks, e, ) if consecutive_blocks >= max_consecutive_blocks: logger.error( "domclick_detail_backfill: run_id=%d ABORT -- %d consecutive " "blocks, QRATOR reputation likely burned for the session/proxy. " "enriched=%d attempted=%d", run_id, consecutive_blocks, counters.enriched, counters.attempted, ) 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_done(db, run_id, current_counters) logger.info( "domclick_detail_backfill: run_id=%d DONE -- 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