gendesign/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
bot-backend 227e82dc01
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein): avito detail-enrichment nightly backfill (#1551)
- New task app/tasks/avito_detail_backfill.py with run_avito_detail_backfill()
  * Single snapshot SELECT at start (guarantees termination)
  * Same proxy/AsyncSession path as scrape_pipeline.py step 5
  * Budget guard (budget_sec), consecutive block abort (mark_done not mark_failed)
  * rotate_ip() on every AvitoBlockedError; rollback on generic Exception (#1368)
  * start = time.monotonic() initialized before try so except can reference it
- Scheduler wiring: trigger_avito_detail_backfill_run() + elif in scheduler_loop()
- Migration 112: scrape_schedules INSERT window 09-12 UTC, batch_size=800,
  budget_sec=3600, request_delay_sec=6, max_consecutive_blocks=5
- 7 unit tests (no pytest-mock, unittest.mock only): all 7 passing,
  full CI suite 1809 passed
2026-06-16 13:25:29 +03:00

270 lines
9.2 KiB
Python

"""Scheduled backfill: detail-enrichment for legacy avito listings (#1551).
Nightly window 09:00-12:00 UTC (migration 112, source=avito_detail_backfill).
Offset from avito_city_sweep (~03-04 UTC) to avoid sharing proxy IP simultaneously.
Problem: ~15243/15917 avito listings have detail_enriched_at IS NULL.
City sweep ENRICH_DETAIL only enriches top-N proximate listings per anchor.
Legacy listings (older than 2h or outside radius) are never enriched.
Solution: single snapshot SELECT at start (guarantees termination), same proxy
session path as scrape_pipeline.py step 5. Block handling mirrors step 5:
rotate IP on every block, abort after max_consecutive_blocks (mark_done not
mark_failed -- block is temporary, retry next night via NULL detail_enriched_at).
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from urllib.parse import urlparse
from curl_cffi.requests import AsyncSession
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod
from app.services.scrape_pipeline import _CHROME_HEADERS, _avito_proxies
from app.services.scrapers.avito import AvitoScraper
from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment
from app.services.scrapers.avito_exceptions import (
AvitoBlockedError,
AvitoRateLimitedError,
)
from app.services.scrapers.browser_fetcher import BrowserFetcher
logger = logging.getLogger(__name__)
__all__ = [
"AvitoDetailBackfillResult",
"run_avito_detail_backfill",
]
@dataclass
class AvitoDetailBackfillResult:
"""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_avito_detail_backfill(
db: Session,
*,
run_id: int,
params: dict,
) -> AvitoDetailBackfillResult:
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
Params (from default_params jsonb in scrape_schedules):
batch_size: int -- snapshot size (SELECT LIMIT), default 800.
budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 6.0s.
max_consecutive_blocks: int -- abort threshold, default 5.
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", 800))
budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 6.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
counters = AvitoDetailBackfillResult()
current_counters: dict[str, int] = counters.to_dict()
browser_mode = settings.scraper_fetch_mode == "browser"
session: AsyncSession | None = None
browser_fetcher: BrowserFetcher | None = None
own_session = False
own_browser = False
scraper = AvitoScraper()
start = time.monotonic()
try:
# Setup session (mirrors run_avito_pipeline lines 161-183)
if browser_mode:
browser_fetcher = BrowserFetcher()
await browser_fetcher.__aenter__()
own_browser = True
scraper._browser = browser_fetcher
else:
own_session = True
session = AsyncSession(
impersonate="chrome120",
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(),
)
scraper._cffi = session
runs_mod.update_heartbeat(db, run_id, current_counters)
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
snapshot = (
db.execute(
text(
"""
SELECT id, source_url
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
),
{"batch_size": batch_size},
)
.mappings()
.all()
)
if not snapshot:
logger.info(
"avito_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(
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
"delay=%.1fs max_blocks=%d browser=%s)",
run_id,
len(snapshot),
budget_sec,
request_delay_sec,
max_consecutive_blocks,
browser_mode,
)
consecutive_blocks = 0
do_sleep = False
for idx, row in enumerate(snapshot):
# Budget guard
elapsed = time.monotonic() - start
if elapsed > budget_sec:
logger.info(
"avito_detail_backfill: run_id=%d -- budget %.0fs exhausted "
"(elapsed=%.1fs), stopping at #%d/%d",
run_id,
budget_sec,
elapsed,
idx,
len(snapshot),
)
break
# Delay before each request except the first
if do_sleep:
await asyncio.sleep(request_delay_sec)
do_sleep = True
source_url: str = row["source_url"]
counters.attempted += 1
# Normalise URL -> path (mirrors scrape_pipeline.py line 311)
item_url = urlparse(source_url).path if source_url.startswith("http") else source_url
try:
enrichment = await fetch_detail(
item_url,
cffi_session=session,
browser_fetcher=browser_fetcher,
)
if save_detail_enrichment(db, enrichment):
counters.enriched += 1
consecutive_blocks = 0
except (AvitoBlockedError, AvitoRateLimitedError) as e:
consecutive_blocks += 1
counters.blocked += 1
do_sleep = False
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
run_id,
idx + 1,
len(snapshot),
consecutive_blocks,
e,
)
await scraper._rotate_ip()
if consecutive_blocks >= max_consecutive_blocks:
logger.error(
"avito_detail_backfill: run_id=%d ABORT -- %d consecutive blocks, "
"IP rate-limited. enriched=%d attempted=%d",
run_id,
consecutive_blocks,
counters.enriched,
counters.attempted,
)
break
except Exception as e:
counters.failed += 1
logger.warning(
"avito_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(
"avito_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(
"avito_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
finally:
if own_session and session is not None:
await session.close()
if own_browser and browser_fetcher is not None:
await browser_fetcher.__aexit__(None, None, None)