gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
bot-backend 9519c89d92
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
fix(scraper-kit/yandex): выпавшая страница leaf'а делает бакет неполным; capped_buckets переживает дрейн
Ревью #3373, два minor:
1. `_leaf` считал полноту только по потолку страниц — упавшая страница
   (`payload=None` → `[]`) молча уходила в чекпоинт как собранная. Теперь
   паритет с cian по-настоящему: `complete = not capped and dropped_pages == 0`.
2. `counters.capped_buckets` присваивался ПОСЛЕ await — cancel/shutdown
   (RuntimeError из `_on_bucket`) уносил управление мимо строки. Перенесено в
   `finally`, счётчик виден в финальном payload дрейна.

Нит: `ceil(total / 20)` → `_GATE_PAGE_SIZE`.

Тесты по значению: бакет с 1 выпавшей страницей из 3 — не в done-леджере
(+ контроль: 3 успешных страницы по-прежнему complete); дрейн после обрезанного
бакета → `capped_buckets == 1` в финальных counters. Фейк `_DrainAtFirstBucket`
(#3355) теперь объявляет `capped_buckets` явно: его catch-all `__getattr__`
отдавал корутину на любое имя и ронял сериализацию counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:16:14 +05:00

4702 lines
251 KiB
Python
Raw 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.

"""pipeline.py — Avito sweep orchestrator (#2135 strangler, единственный в проде с #2397 Part E1).
Изначально был strangler-COPY боевого `app.services.scrape_pipeline` (#2135);
legacy-оригинал удалён (#2397 Part E1) — этот модуль теперь единственная
orchestration-реализация. Развязка от `app.*` через инжекцию (см.
`scraper_kit.contracts`):
- `app.core.config.settings` → аргумент `config: ScraperConfig`
- `app.services.matching` → аргумент `matcher: HouseMatcher`
- `app.services.house_imv_backfill` → аргумент `enrichment: EnrichmentJobs`
- `app.core.shutdown.shutdown_requested` → аргумент `shutdown_requested: Callable`
- `app.services.scrape_runs` → локальный `scraper_kit.orchestration.runs`
- провайдеры/база → `scraper_kit.providers.*` / `scraper_kit.base`
Скопирована КРИТИЧНАЯ логика оркестрации (#2135):
- `run_avito_pipeline` — одиночный anchor (SEARCH→SAVE→GROUP→HOUSES→DETAIL)
- `run_avito_city_sweep` — city-sweep + ban/rotation + partial-intake + IMV (F1)
- `run_avito_newbuilding_sweep`— citywide novostroyka-обход + save (F2)
- `run_yandex_city_sweep` — combos (room×price) + save + address-enrich (F2)
- `run_cian_city_sweep` — anchor SERP → detail(+price-hist) → newbuilding (F2)
- `run_cian_full_load` — region-wide bisection + resume/checkpoint (F2)
- `run_yandex_full_load` — region-wide bisection + price-history (F2)
- `run_avito_full_load` — citywide bisection (exhaustive/incremental) (F2)
- `run_domclick_city_sweep` — BFF citywide sweep + честный статус (F2)
Ban-rotation state-machine сведена в единый хелпер `_try_rotate_within_budget`
(в оригинале инлайн-дублировалась в house/detail/house-detail/enrich фазах).
Yandex sweep'ы дёргают продуктовую логику (price-history + address-parse) через
инжектируемый `EnrichmentJobs` (record_yandex_price_history / extract_address_from_title
/ address_has_house_number) вместо прямых импортов app.services.yandex_*.
"""
from __future__ import annotations
import asyncio
import logging
import random
from collections.abc import Callable
from contextlib import AsyncExitStack
from dataclasses import dataclass, field, fields
from datetime import date, timedelta
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from curl_cffi.requests import AsyncSession
from sqlalchemy import text
from scraper_kit.avito_exceptions import (
AvitoBlockedError,
AvitoRateLimitedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.base import ScrapedLot, save_listings
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration import runs
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
# работать и когда тесты подменяют весь модуль runs двойником (#2686).
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM, BAN_KIND_UNKNOWN
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.avito.houses import (
fetch_house_catalog,
save_house_catalog_enrichment,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.providers.cian.detail import fetch_detail as cian_fetch_detail
from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save_detail_enrichment
from scraper_kit.providers.cian.newbuilding import (
fetch_newbuilding,
save_newbuilding_enrichment,
)
from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
from scraper_kit.providers.yandex.serp import (
DEFAULT_PRICE_RANGES,
ROOM_PATH,
YandexRealtyScraper,
)
from scraper_kit.proxy_errors import NoProxyAvailableError
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from scraper_kit.contracts import (
EnrichmentJobs,
HouseMatcher,
ProxyProvider,
ScraperConfig,
)
logger = logging.getLogger(__name__)
# Регион по умолчанию для listings.region_code (в старом save_listings хардкод 66).
# Вынесен параметром run_*-функций — kit не знает про конкретный регион.
DEFAULT_REGION_CODE: int = 66
def _max_rotations(config: ScraperConfig, source: str) -> int:
"""Лимит IP-ротаций по провайдеру (avito/cian/yandex)."""
if source == "cian":
return config.cian_proxy_max_rotations
if source == "yandex":
return config.yandex_proxy_max_rotations
return config.avito_proxy_max_rotations
async def _rotate_proxy_ip(
config: ScraperConfig,
*,
reason: str,
rotations_done: int,
source: str = "avito",
) -> bool:
"""changeip-ротация mobileproxy снята (#2616 шаг 2) — все три changeip-ссылки
(avito/cian/yandex `*_PROXY_ROTATE_URL`) указывали на закрытые аккаунты
(407/connection refused, проверено вживую #2613) и удалены из ScraperConfig.
IP-ротация здесь физически невозможна без changeip-ссылки → всегда False.
Аналог AvitoScraper._rotate_ip (см. providers/avito/serp.py) — используется в
enrichment-фазах где нет доступа к экземпляру scraper'а. Единственный живой
прокси (SCRAPER_PROXY_URL, ASocks) не имеет per-request changeip — его ротация
— POST /api/v1/admin/proxies/{id}/rotate (app.services.proxy_rotation, #2611),
отдельный от sweep-оркестрации путь.
Оставлена как stub (не удалена целиком): `_try_rotate_within_budget` продолжает
считать бюджет ротаций точно так же, как раньше при мёртвом changeip (та же
False-ветка) — вызывающие сохраняют abort-поведение без изменений, без
сетевого похода в никуда.
"""
logger.debug(
"pipeline: IP rotation unavailable (changeip retired #2616) — "
"source=%s reason=%s rotation=#%d",
source,
reason,
rotations_done + 1,
)
return False
async def _try_rotate_within_budget(
config: ScraperConfig,
*,
reason: str,
rotations_done: int,
source: str = "avito",
) -> tuple[bool, int]:
"""Единый шаг ban-rotation state-machine (свод инлайн-дублей #2135).
Вызывается при достижении порога ПОСЛЕДОВАТЕЛЬНЫХ блоков. Если бюджет ротаций
ещё не исчерпан (`rotations_done < max_rotations(source)`) — дёргает changeip и
увеличивает счётчик ротаций. Возвращает `(rotated, rotations_done')`:
- rotated=True → IP сменён; вызывающий сбрасывает consecutive и продолжает.
- rotated=False → бюджет исчерпан ЛИБО changeip не удался; вызывающий делает
abort/propagate (листинги уже сохранены).
Поведение идентично прежним инлайн-веткам: инкремент rotations_done происходит
только при наличии бюджета (когда реально пытаемся ротировать).
"""
if rotations_done < _max_rotations(config, source):
rotated = await _rotate_proxy_ip(
config, reason=reason, rotations_done=rotations_done, source=source
)
return rotated, rotations_done + 1
return False, rotations_done
# Per-anchor watchdog timeout (секунды). Если обработка одного anchor'а занимает
# дольше этого значения (зависший HTTP-запрос, прокси hang), asyncio.wait_for
# прерывает её с TimeoutError, sweep продолжается со следующим anchor'ом.
# Диапазон 180-300 с; 240 — разумный середина (4 мин > любой нормальный anchor).
ANCHOR_TIMEOUT_SEC: int = 240
# Константы для расчёта watchdog-таймаута Avito city sweep.
# Avito detail-fetch идёт через браузерный сервис с page.goto timeout 60s, и
# anti-bot страницы нередко доходят до этого лимита. Фиксированный ANCHOR_TIMEOUT_SEC=240
# guillotine'ит anchor ещё в detail-фазе → SERP-счётчики теряются, run показывает
# lots_fetched=0 при реально сохранённых листингах. Таймаут масштабируется:
# _avito_anchor_timeout = ANCHOR_TIMEOUT_SEC
# + detail_top_n × _AVITO_PER_DETAIL_S
# + (_AVITO_HOUSES_BUDGET_S if enrich_houses else 0)
# Пример: detail_top_n=20 + houses → 240 + 1000 + 180 = 1420s/anchor (worst-case).
_AVITO_PER_DETAIL_S: float = 50.0 # секунд на один detail-fetch (incl. occasional 60s timeout)
_AVITO_HOUSES_BUDGET_S: float = 180.0 # бюджет на house-enrich фазу
# Fail-fast: если detail phase накапливает подряд N timeout/ошибок, прерываем её
# (аналог consecutive_blocks для AvitoBlockedError в step 5).
_AVITO_DETAIL_CONSECUTIVE_TIMEOUT_ABORT: int = 5
# #1820: порог ПОСЛЕДОВАТЕЛЬНЫХ блоков (AvitoBlockedError/AvitoRateLimitedError) внутри
# enrichment-фаз run_avito_pipeline. Один заблокированный фетч (house/detail) больше НЕ
# роняет весь прогон — блок ловится пер-item (как обычная ошибка), и лишь N подряд
# прерывают enrichment-фазу. Listings из SEARCH+SAVE сохраняются всегда.
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT: int = 3
def ban_kind_of_exception(exc: BaseException) -> str:
"""Диагноз для scrape_runs.ban_kind по ТИПУ исключения, не по тексту (#2686).
AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша
инфраструктура (браузерный сайдкар не отдал страницу) → 'infra'.
AvitoBlockedError (firewall/403/captcha/content-block) и честный 429 площадки —
единственные типы, которыми площадка себя ПОКАЗАЛА → 'platform'.
Всё остальное → 'unknown' (#2764). Раньше здесь стоял `else PLATFORM`, то есть
любой RuntimeError/timeout/сетевой сбой объявлялся блокировкой площадкой — тот
же дефект, что и дефолт mark_banned, только уровнем ниже: диагноз назначался,
а не устанавливался.
Один узел на все avito-сайты mark_banned: разводить исход в каждом из трёх
было бы тремя копиями одного условия.
"""
if isinstance(exc, AvitoSidecarUnavailableError | NoProxyAvailableError):
# NoProxyAvailableError (#2687): пул прокси пуст в проде. Причина здесь не
# «не установлена» (тогда был бы 'unknown', #2764), а названа самим типом —
# см. proxy_errors.py: «это НАША инфраструктура (нет живого прокси), не
# внешний блок». Тот же диагноз, что и у отказа сайдкара.
return BAN_KIND_INFRA
if isinstance(exc, AvitoBlockedError | AvitoRateLimitedError):
return BAN_KIND_PLATFORM
return BAN_KIND_UNKNOWN
# #2160: константы для расчёта watchdog-таймаута Cian city sweep. При
# USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене
# прокси (page.goto timeout 60s + overhead) = 13-45s/страница, а якорь cian = 4 room-buckets
# × pages_per_anchor SERP-фетчей + detail_top_n detail-фетчей + houses-enrich. Фиксированный
# ANCHOR_TIMEOUT_SEC=240 гильотинит якорь ещё в SERP-фазе → save_listings не успевает,
# lots_fetched=0, после N подряд → mark_banned. Таймаут масштабируется:
# _cian_anchor_timeout = max(
# ANCHOR_TIMEOUT_SEC,
# _CIAN_ROOM_BUCKETS × pages_per_anchor × (request_delay_sec + _CIAN_PER_SERP_FETCH_S)
# + detail_top_n × _CIAN_PER_DETAIL_S
# + (_CIAN_HOUSES_BUDGET_S if enrich_houses else 0))
# Пример (дефолты): 4×3×(5+70) + 10×50 + 180 = 900 + 500 + 180 = 1580s/anchor (worst-case,
# это watchdog от зависания, не ожидаемая длительность).
_CIAN_ROOM_BUCKETS: int = 4 # fetch_around_multi_room итерирует ((1,),(2,),(3,),(4,)),
# см. providers/cian/serp.py:215
_CIAN_PER_SERP_FETCH_S: float = 70.0 # browser fetch через пул: page.goto 60s + camoufox relaunch
_CIAN_PER_DETAIL_S: float = 50.0 # секунд на один detail-fetch (как avito)
_CIAN_HOUSES_BUDGET_S: float = 180.0 # бюджет на house-enrich фазу (как avito)
def _cian_anchor_timeout_s(
pages_per_anchor: int,
request_delay_sec: float,
detail_top_n: int,
enrich_houses: bool,
) -> float:
"""Масштабируемый watchdog-таймаут одного Cian anchor'а (секунды).
Чистая функция для тестируемости формулы (#2160). Никогда не опускается ниже
ANCHOR_TIMEOUT_SEC (max-ветка): при малых параметрах фиксированный минимум остаётся.
"""
return max(
float(ANCHOR_TIMEOUT_SEC),
_CIAN_ROOM_BUCKETS * pages_per_anchor * (request_delay_sec + _CIAN_PER_SERP_FETCH_S)
+ detail_top_n * _CIAN_PER_DETAIL_S
+ (_CIAN_HOUSES_BUDGET_S if enrich_houses else 0.0),
)
# Default anchors ЕКБ — 5 точек покрытия города
EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8400, 60.6050, "Центр"),
(56.7950, 60.5300, "ЮЗ"),
(56.8970, 60.6100, "Уралмаш"),
(56.7700, 60.5500, "Академический"),
(56.8650, 60.6200, "Пионерский"),
]
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
# на большой город), эти города компактнее — одного anchor'а достаточно.
# Domclick (BFF, city_id-based, НЕ anchors-based) сюда не входит — отдельный B2 rollout.
#
# Wave 1 (5 городов, ниже) — enabled на проде. Wave 2 (40 городов, оставшаяся часть
# Свердловской обл. КРОМЕ Бисерти — пгт, не город, Циан её не резолвит вообще, см.
# CITY_LOCATIONS-коммент ниже; data/sql/262_scrape_schedules_seed_oblast_city_sweeps_
# wave2.sql) — координаты проверены оператором (прод-геокодер + независимая сверка
# медианой координат сделок Росреестра по городу), все schedule-строки enabled=false
# (оператор включает волнами вручную). CITY_LOCATIONS заведён для всех 40 (см. ниже) —
# но НЕ у каждого города подтверждены все 3 provider-идентификатора: run_avito_city_
# sweep/run_yandex_city_sweep явно падают (ValueError), если для известного city_slug
# соответствующий идентификатор не подтверждён (None), вместо молчаливого ЕКБ-дефолта.
# 262_ заводит schedule-строку ТОЛЬКО там, где идентификатор источника подтверждён —
# поэтому этот ValueError в проде не должен срабатывать при штатной эксплуатации.
CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
"nizhniy_tagil": [(57.910, 59.980, "Н.Тагил центр")],
"kamensk_uralskiy": [(56.414, 61.918, "Каменск центр")],
"pervouralsk": [(56.908, 59.943, "Первоуральск центр")],
"verkhnyaya_pyshma": [(56.976, 60.578, "В.Пышма центр")],
"serov": [(59.604, 60.578, "Серов центр")],
# ── wave 2 (40 городов, dormant — см. коммент выше; Бисерть исключена целиком) ──
"novouralsk": [(57.2470, 60.0958, "Новоуральск центр")],
"revda": [(56.8001, 59.9087, "Ревда центр")],
"polevskoy": [(56.4958, 60.2370, "Полевской центр")],
"asbest": [(57.0141, 61.4659, "Асбест центр")],
"bogdanovich": [(56.7683, 62.0521, "Богданович центр")],
"irbit": [(57.6810, 63.0585, "Ирбит центр")],
"krasnoufimsk": [(56.6181, 57.7713, "Красноуфимск центр")],
"berezovskiy": [(56.9098, 60.8120, "Берёзовский центр")],
"zarechny": [(56.8023, 61.3202, "Заречный центр")],
"kachkanar": [(58.6995, 59.4811, "Качканар центр")],
"krasnoturinsk": [(59.7646, 60.1936, "Краснотурьинск центр")],
"severouralsk": [(60.1502, 59.9605, "Североуральск центр")],
"ivdel": [(60.6973, 60.4173, "Ивдель центр")],
"tavda": [(58.0414, 65.2720, "Тавда центр")],
"turinsk": [(58.0525, 63.6909, "Туринск центр")],
"sysert": [(56.5054, 60.8097, "Сысерть центр")],
"sredneuralsk": [(56.9916, 60.4689, "Среднеуральск центр")],
"degtyarsk": [(56.6983, 60.0868, "Дегтярск центр")],
"verkhnyaya_salda": [(58.0494, 60.5473, "В.Салда центр")],
"nizhnyaya_salda": [(58.0714, 60.7147, "Н.Салда центр")],
"nevyansk": [(57.4924, 60.2105, "Невьянск центр")],
"artemovskiy": [(57.3386, 61.8941, "Артёмовский центр")],
"kamyshlov": [(56.8462, 62.7122, "Камышлов центр")],
"alapaevsk": [(57.8508, 61.6950, "Алапаевск центр")],
"sukhoy_log": [(56.9047, 62.0350, "Сухой Лог центр")],
"kushva": [(58.2817, 59.7625, "Кушва центр")],
"krasnouralsk": [(58.3571, 60.0374, "Красноуральск центр")],
"karpinsk": [(59.7668, 60.0009, "Карпинск центр")],
"nizhnyaya_tura": [(58.6208, 59.8473, "Н.Тура центр")],
"verkhniy_tagil": [(57.3745, 59.9321, "В.Тагил центр")],
"nizhnie_sergi": [(56.6619, 59.3000, "Н.Серги центр")],
"lesnoy": [(58.6364, 59.7897, "Лесной центр")],
"rezh": [(57.3706, 61.4043, "Реж центр")],
"aramil": [(56.6963, 60.8331, "Арамиль центр")],
"volchansk": [(59.9347, 60.0780, "Волчанск центр")],
"verkhnyaya_tura": [(58.3605, 59.8182, "В.Тура центр")],
"mikhaylovsk": [(56.4445, 59.1242, "Михайловск центр")],
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
"talitsa": [(57.0148, 63.7302, "Талица центр")],
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
}
def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] | None:
"""Anchors для oblast-города по slug (CITY_ANCHORS). Неизвестный slug/None → None.
None — сигнал caller'у (scheduler.py _job_*_city_sweep) падать обратно на EKB_ANCHORS
(run_avito_city_sweep/run_cian_city_sweep) либо на единственный центральный anchor ЕКБ
(run_yandex_city_sweep combos-mode) — см. `anchors if anchors is not None else
EKB_ANCHORS` в соответствующих run_*_city_sweep.
"""
if city_slug is None:
return None
return CITY_ANCHORS.get(city_slug)
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
"""(lat, lon) референсной точки города-цели — для гео-guard'а `save_listings`.
Соседний-город-в-развёртке (замер на проде): oblast city-sweep стамповал СВОЙ
город-цель на 100% найденного, включая лоты, физически лежащие в куда более
крупном соседнем городе, случайно захваченные loose SERP-city-фильтром
провайдера (напр. Верхняя Пышма ~15км от ЕКБ; у yandex дело не в `radius_m`
запроса — gate-API скоупит выдачу city-scoped `rgid` и игнорирует lat/lon/
radius_m целиком, см. `providers/yandex/serp.py:fetch_around` — 97%
"verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
единственный) anchor CITY_ANCHORS[city_slug] — та же точка, что реально ходит в
scraper (fetch_around/fetch_around_multi_room), поэтому guard сверяется с
РЕАЛЬНЫМ центром запроса, а не с отдельно захардкоженными координатами.
None (EKB/неизвестный slug) — гео-guard НЕ применяется у вызывающей стороны (см.
run_*_city_sweep): в регионе нет города КРУПНЕЕ ЕКБ, чей SERP мог бы её
"поглотить" — симметричный риск для ЕКБ-развёртки отсутствует.
"""
anchors = get_city_anchors(city_slug)
if not anchors:
return None
lat, lon, _name = anchors[0]
return (lat, lon)
@dataclass(frozen=True)
class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
avito_slug — city-slug в пути avito URL (ОТЛИЧАЕТСЯ от нашего slug у некоторых
городов: kamensk-uralskiy через дефис, verhnyaya_pyshma без kh).
Optional (#262 wave 2): у части городов НЕ подтверждён (403/429 —
пул исчерпан на проверке, либо omonym-коллизия). None здесь —
ЖЁСТКИЙ инвариант "нет достоверного значения", НЕ "бери ЕКБ"
run_avito_city_sweep явно падает (ValueError), если city_slug
известен, а avito_slug для него None (см. её тело).
cian_region_id — числовой region= в ekb.cian.ru/cat.php (ГОРОД, не область).
Подтверждён у ВСЕХ городов wave 1+2 — required, не Optional.
yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город). Optional (#262 wave
2): mikhaylovsk отсутствует в гео-базе Яндекса вообще (единственный
"Михайловск" там — ставропольский, чужой rgid подставлять нельзя).
Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep
явно падает (ValueError), если city_slug известен, а yandex_rgid
для него None.
"""
avito_slug: str | None
cian_region_id: int
yandex_rgid: int | None
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
# в карте держать НЕ обязательно (None-путь = ЕКБ). Города Свердловской обл. (region 66):
#
# Wave 2 (#262, 40 городов ниже wave-1 пятёрки) — идентификаторы добыты и валидированы
# отдельно по каждому provider (метод + валидация 5/5 на wave-1 городах — см. шапку
# data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql). Бисерть ИСКЛЮЧЕНА
# целиком (не город, пгт — у Циана её нет, любой запрос отдаёт Сысерть id=176028).
# avito_slug=None у 17 городов (не подтверждён — пул исчерпан на 429, ПЕРЕПРОВЕРИТЬ
# позже, не опровергнут); yandex_rgid=None только у mikhaylovsk (город отсутствует в
# гео-базе Яндекса — это НЕ "не проверили", это подтверждённое отсутствие данных).
CITY_LOCATIONS: dict[str, CityLocation] = {
"nizhniy_tagil": CityLocation("nizhniy_tagil", 4886, 328286),
"kamensk_uralskiy": CityLocation("kamensk-uralskiy", 4781, 328468),
"pervouralsk": CityLocation("pervouralsk", 4925, 328379),
"verkhnyaya_pyshma": CityLocation("verhnyaya_pyshma", 4697, 166366),
"serov": CityLocation("serov", 4982, 328238),
# ── wave 2: avito+cian+yandex все подтверждены (23 города) ──────────────────
"novouralsk": CityLocation("novouralsk", 4900, 327473),
"asbest": CityLocation("asbest", 4659, 328344),
"bogdanovich": CityLocation("bogdanovich", 4684, 327697),
"irbit": CityLocation("irbit", 4773, 327182),
"krasnoufimsk": CityLocation("krasnoufimsk", 4826, 326938),
"krasnoturinsk": CityLocation("krasnoturinsk", 4824, 328232),
"severouralsk": CityLocation("severouralsk", 4979, 328576),
"ivdel": CityLocation("ivdel", 4769, 328471),
"tavda": CityLocation("tavda", 5007, 328612),
"turinsk": CityLocation("turinsk", 176151, 327028),
"sysert": CityLocation("sysert", 176028, 327787),
"verkhnyaya_salda": CityLocation("verhnyaya_salda", 4698, 328268),
"nizhnyaya_salda": CityLocation("nizhnyaya_salda", 4887, 328267),
"nevyansk": CityLocation("nevyansk", 4879, 327237),
"alapaevsk": CityLocation("alapaevsk", 4642, 328292),
"krasnouralsk": CityLocation("krasnouralsk", 4825, 328611),
"verkhniy_tagil": CityLocation("verhniy_tagil", 4695, 328510),
"rezh": CityLocation("rezh", 4957, 328306),
"aramil": CityLocation("aramil", 174206, 327631),
"volchansk": CityLocation("volchansk", 4710, 328231),
"verkhnyaya_tura": CityLocation("verhnyaya_tura", 4699, 328489),
"talitsa": CityLocation("talitsa", 176053, 328369),
"novaya_lyalya": CityLocation("novaya_lyalya", 175466, 328244),
# ── wave 2: avito НЕ подтверждён (429/403 — пул исчерпан, не опровергнут; либо
# omonym без avito-специфичной дизамбигуации) — только cian+yandex (16 городов) ──
"revda": CityLocation(None, 4956, 328540),
"polevskoy": CityLocation(None, 4937, 328436),
"berezovskiy": CityLocation(None, 4679, 559131),
"zarechny": CityLocation(None, 4754, 328386),
"kachkanar": CityLocation(None, 4792, 327958),
"sredneuralsk": CityLocation(None, 5000, 327474),
"degtyarsk": CityLocation(None, 4730, 328381),
"artemovskiy": CityLocation(None, 4657, 156418),
"kamyshlov": CityLocation(None, 4784, 328411),
"sukhoy_log": CityLocation(None, 5004, 328395),
"kushva": CityLocation(None, 4836, 328492),
"karpinsk": CityLocation(None, 4790, 328477),
"nizhnyaya_tura": CityLocation(None, 4888, 328487),
"nizhnie_sergi": CityLocation(None, 175439, 328114),
"lesnoy": CityLocation(None, 4844, 327968),
"verkhoturye": CityLocation(None, 174499, 328251),
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
"mikhaylovsk": CityLocation(None, 175332, None),
}
def get_city_location(city_slug: str | None) -> CityLocation | None:
"""CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт)."""
if city_slug is None:
return None
return CITY_LOCATIONS.get(city_slug)
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
# Развёртка ЗНАЕТ город из своего контекста (city_slug), но раньше нигде его не
# записывала — листинг терял привязку к городу и адрес без города в тексте
# ("ул. Победы, 30" — так отдают и Avito, и Cian, см. providers/cian/serp.py
# `_format_address` skip_types={"location",...}) при геокодинге считался «город не
# назван» и коллизировал с одноимённой ЕКБ-улицей. Ключи СОВПАДАЮТ с CITY_LOCATIONS/
# CITY_ANCHORS; значения — те же формы, что уже есть в geocoder.SVERDLOVSK_OBLAST_CITIES
# (lower + word-boundary матчинг там регистронезависим, поэтому регистр здесь не
# критичен, но человекочитаемый — для админки/логов/дальнейшего QA).
CITY_DISPLAY_NAMES: dict[str, str] = {
"nizhniy_tagil": "Нижний Тагил",
"kamensk_uralskiy": "Каменск-Уральский",
"pervouralsk": "Первоуральск",
"verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов",
# ── wave 2 (40 городов, #262) — заведены для ВСЕХ, независимо от того, какие
# provider-идентификаторы подтверждены в CITY_LOCATIONS: без записи здесь
# resolve_city_name() тихо вернула бы "Екатеринбург" для листингов города,
# у которого хотя бы один sweep-источник реально enabled.
"novouralsk": "Новоуральск",
"revda": "Ревда",
"polevskoy": "Полевской",
"asbest": "Асбест",
"bogdanovich": "Богданович",
"irbit": "Ирбит",
"krasnoufimsk": "Красноуфимск",
"berezovskiy": "Берёзовский",
"zarechny": "Заречный",
"kachkanar": "Качканар",
"krasnoturinsk": "Краснотурьинск",
"severouralsk": "Североуральск",
"ivdel": "Ивдель",
"tavda": "Тавда",
"turinsk": "Туринск",
"sysert": "Сысерть",
"sredneuralsk": "Среднеуральск",
"degtyarsk": "Дегтярск",
"verkhnyaya_salda": "Верхняя Салда",
"nizhnyaya_salda": "Нижняя Салда",
"nevyansk": "Невьянск",
"artemovskiy": "Артёмовский",
"kamyshlov": "Камышлов",
"alapaevsk": "Алапаевск",
"sukhoy_log": "Сухой Лог",
"kushva": "Кушва",
"krasnouralsk": "Красноуральск",
"karpinsk": "Карпинск",
"nizhnyaya_tura": "Нижняя Тура",
"verkhniy_tagil": "Верхний Тагил",
"nizhnie_sergi": "Нижние Серги",
"lesnoy": "Лесной",
"rezh": "Реж",
"aramil": "Арамиль",
"volchansk": "Волчанск",
"verkhnyaya_tura": "Верхняя Тура",
"mikhaylovsk": "Михайловск",
"verkhoturye": "Верхотурье",
"talitsa": "Талица",
"novaya_lyalya": "Новая Ляля",
}
EKATERINBURG_CITY_NAME = "Екатеринбург"
def resolve_city_name(city_slug: str | None) -> str:
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с
get_city_location/get_city_anchors (тот же None-путь = ЕКБ-дефолт): EKB-варианты
city-sweep функций (run_avito_city_sweep и т.д., вызванные БЕЗ city_slug) реально
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург"
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет».
Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и
get_city_location/get_city_anchors для неизвестных slug'ов.
"""
if city_slug is None:
return EKATERINBURG_CITY_NAME
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
# которого save_listings НЕ доверяет city этого batch'а — см. save_listings docstring
# (scraper_kit.base) и замер на проде в PR. НЕ путать с radius_m поисковых запросов
# scraper'а — avito/cian реально используют radius_m (1500м вокруг того же anchor'а)
# как фильтр ЧТО скачано; yandex — НЕТ: gate-API скоупит выдачу city-scoped rgid и
# lat/lon/radius_m игнорирует целиком (providers/yandex/serp.py:fetch_around, param
# radius_m=25000 там — мёртвый default, не влияет на выдачу). guard-радиус ниже — про
# другое: какому city-batch'у из уже скачанного верить.
#
# Дефолт 15км — с запасом покрывает застройку города + ближние пригороды и остаётся
# НАМНОГО меньше дистанции до ЕКБ у 4 из 5 oblast-городов (Первоуральск ~41км,
# Каменск-Уральский ~93км, Нижний Тагил ~125км, Серов ~307км от центра ЕКБ) — guard
# у них никогда ложно не режет настоящие лоты, но всё ещё ловит редкие выбросы
# (напр. геокод-артефакты, см. PR: 3 avito-лота "Серов" физически в ЕКБ).
#
# Верхняя Пышма — особый случай: САМ anchor'а города лишь ~15.3км от центра ЕКБ
# (агломерации почти смыкаются) — дефолтный 15км-порог никогда бы не сработал (лот
# из ЕКБ остался бы формально "в радиусе" В.Пышмы). Уменьшенный порог 8км (~половина
# дистанции до ЕКБ, safety margin ~7.3км) всё ещё покрывает застройку самой
# В.Пышмы (компактный город, ~5км в поперечнике) и реально режет EKB-заброс (на
# проде — 97% "verkhnyaya_pyshma"-лотов из yandex-развёртки).
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0,
}
def get_city_stamp_radius_km(city_slug: str | None) -> float:
"""Гео-guard радиус (км) для city_slug — см. `_CITY_STAMP_RADIUS_KM` выше.
None/неизвестный slug → `_DEFAULT_CITY_STAMP_RADIUS_KM`. Вызывающая сторона
передаёт результат в `save_listings(..., city_radius_km=...)` ТОЛЬКО вместе с
`get_city_anchor_point(city_slug)` (при anchor=None guard всё равно выключен —
см. save_listings docstring).
"""
if city_slug is None:
return _DEFAULT_CITY_STAMP_RADIUS_KM
return _CITY_STAMP_RADIUS_KM.get(city_slug, _DEFAULT_CITY_STAMP_RADIUS_KM)
_CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
def _avito_proxies(config: ScraperConfig) -> dict[str, str] | None:
"""#623/#806: mobile-proxy egress для shared-сессии sweep'а.
КРИТИЧНО: sweep создаёт собственную shared AsyncSession и присваивает её
`scraper._cffi`, перезатирая проксированную сессию из `AvitoScraper.__aenter__`.
Через эту же сессию идёт и SERP, и detail-энричмент (fetch_detail). Без прокси
detail-страницы летят с datacenter-IP → HTTP 429 → весь run mark_banned.
Пусто (env не задан) → прямое подключение (dev/no-op).
Читает config.scraper_proxy_url (= SCRAPER_PROXY_URL, #2616 шаг 2: legacy
AVITO_PROXY_URL fallback снят).
"""
url = config.scraper_proxy_url
return {"http": url, "https": url} if url else None
# ── Result dataclasses ──────────────────────────────────────────
@dataclass
class PipelineCounters:
"""Counters одного pipeline run для логов/админки."""
lots_fetched: int = 0
lots_inserted: int = 0
lots_updated: int = 0
unique_houses: int = 0
houses_enriched: int = 0
houses_failed: int = 0
detail_attempted: int = 0
detail_enriched: int = 0
detail_failed: int = 0
errors: list[str] = field(default_factory=list)
@dataclass
class PipelineResult:
"""Полный результат full Avito pipeline."""
anchor_lat: float
anchor_lon: float
radius_m: int
counters: PipelineCounters
enrich_houses: bool
enrich_detail_top_n: int
touched_house_ids: set[int] = field(default_factory=set)
# ── Main orchestrator ───────────────────────────────────────────
async def run_avito_pipeline(
db: Session,
lat: float,
lon: float,
radius_m: int = 1500,
*,
config: ScraperConfig,
matcher: HouseMatcher,
enrich_houses: bool = True,
enrich_detail_top_n: int = 10,
pages: int = 1,
request_delay_sec: float | None = None,
shared_session: AsyncSession | None = None,
shared_browser: BrowserFetcher | None = None,
proxy_provider: ProxyProvider | None = None,
region_code: int = DEFAULT_REGION_CODE,
) -> PipelineResult:
"""Full Avito search → houses → detail enrichment pipeline.
Steps:
1. SEARCH: AvitoScraper().fetch_around(lat, lon, radius_m, pages=pages)
2. SAVE: save_listings(db, lots) → (inserted, updated)
3. GROUP: dedupe house_url из новых lots → unique house path set
4. ENRICH_HOUSES: fetch_house_catalog + save per house (with sleep between)
5. ENRICH_DETAIL: top-N listings → fetch_detail + save (abort on 3 blocks)
shared_session — если передан, используется без закрытия (lifecycle у вызывающего).
shared_browser — если передан в browser-mode, используется без закрытия.
Блокировки (AvitoBlockedError / AvitoRateLimitedError) в SEARCH-фазе (шаг 1)
по-прежнему propagate наружу (без листингов прогон бессмыслен). В enrichment-фазах
(houses шаг 4 + detail шаг 5 + house-detail шаг 5b) блок ловится ПЕР-ITEM как обычная
ошибка (#1820): один заблокированный фетч НЕ роняет весь прогон. Лишь
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT блоков ПОДРЯД прерывают enrichment — но
PipelineResult всё равно возвращается с уже собранными listings/обогащениями.
"""
counters = PipelineCounters()
lots: list[ScrapedLot] = []
detail_delay = request_delay_sec if request_delay_sec is not None else 7.0
# #1820: общий счётчик ПОСЛЕДОВАТЕЛЬНЫХ блоков на все enrichment-фазы (4/5/5b).
# Сбрасывается на любом успешном fetch; при достижении порога — ротация IP (#1790)
# или abort если бюджет ротаций исчерпан.
enrich_consecutive_blocks = 0
enrich_rotations_done = 0 # #1790: сколько раз уже сменили IP в этом run
enrichment_aborted = False
browser_mode = config.scraper_fetch_mode == "browser"
session: AsyncSession | None = None
browser_fetcher: BrowserFetcher | None = None
own_session = False
own_browser = False
# proxy_provider прокинут в конструктор для консистентности с cian/yandex (#2616);
# НЕ load-bearing здесь — browser_mode ниже всегда переопределяет scraper._browser
# напрямую (shared_browser либо свежепостроенный BrowserFetcher с proxy_provider=
# proxy_provider), минуя AvitoScraper.__aenter__ целиком (own_browser/shared_session
# pattern, см. docstring выше). Оставлено для defense-in-depth на случай будущего
# рефакторинга, который начнёт полагаться на __aenter__.
scraper = AvitoScraper(config, proxy_provider=proxy_provider)
if browser_mode:
browser_fetcher = shared_browser
if browser_fetcher is None:
browser_fetcher = BrowserFetcher(
source="avito",
endpoint=config.browser_http_endpoint,
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
# #2616 шаг 1: прямое конструирование обходит build_browser_fetcher() —
# без environment guard NoProxyAvailableError мёртв для avito.
environment=getattr(config, "environment", "dev"),
)
await browser_fetcher.__aenter__()
own_browser = True
scraper._browser = browser_fetcher
else:
own_session = shared_session is None
session = shared_session or AsyncSession(
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(config),
)
scraper._cffi = session
try:
# ── Step 1: search ──────────────────────────────────────
try:
lots = await scraper.fetch_around(
lat,
lon,
radius_m,
pages=pages,
delay_override_sec=request_delay_sec,
)
counters.lots_fetched = len(lots)
logger.info(
"pipeline:search anchor=(%.4f,%.4f) r=%dm fetched=%d",
lat,
lon,
radius_m,
len(lots),
)
except (AvitoBlockedError, AvitoRateLimitedError):
logger.error("pipeline:search BLOCKED by Avito at anchor=(%.4f,%.4f)", lat, lon)
raise
except Exception as e:
logger.exception("pipeline:search failed")
counters.errors.append(f"search: {e}")
return PipelineResult(lat, lon, radius_m, counters, enrich_houses, enrich_detail_top_n)
# ── Step 2: save listings ───────────────────────────────
if lots:
try:
# run_id НЕ передаётся намеренно (#2701): у этой функции нет строки в
# scrape_runs — она одиночный anchor-путь, продуктовых вызовов не имеет
# (единственные вызывающие — тесты; развёртки идут через
# run_avito_city_sweep / run_avito_newbuilding_sweep / run_avito_full_load,
# которые run_id заводят и передают). Привязывать снимок не к чему;
# снимок с run_id несуществующего прогона был бы хуже пустого поля.
# Появится вызывающий с прогоном — добавить параметр run_id, как у sweep'ов.
counters.lots_inserted, counters.lots_updated = save_listings(
db, lots, matcher=matcher, region_code=region_code
)
except Exception as e:
logger.exception("pipeline:save_listings failed")
counters.errors.append(f"save_listings: {e}")
# ── Step 3: group by house ──────────────────────────────
unique_house_paths: set[str] = set()
for lot in lots:
if lot.house_url:
try:
parsed = urlparse(lot.house_url)
path = parsed.path if parsed.path else lot.house_url
unique_house_paths.add(path)
except Exception:
continue
counters.unique_houses = len(unique_house_paths)
logger.info("pipeline:group_by_house unique=%d", counters.unique_houses)
# ── Step 4: enrich houses с shared session + sleep ──────
touched_house_ids: set[int] = set()
if enrich_houses and unique_house_paths and not enrichment_aborted:
house_paths_list = list(unique_house_paths)
for idx, house_path in enumerate(house_paths_list):
try:
enrichment = await fetch_house_catalog(
house_path, cffi_session=session, browser_fetcher=browser_fetcher
)
house_counters = save_house_catalog_enrichment(db, enrichment, matcher=matcher)
counters.houses_enriched += 1
enrich_consecutive_blocks = 0
hid = house_counters.get("house_id")
if hid:
touched_house_ids.add(int(hid))
except (AvitoBlockedError, AvitoRateLimitedError) as e:
# #1820: блок ловим пер-item как обычную ошибку — НЕ роняем прогон.
enrich_consecutive_blocks += 1
counters.houses_failed += 1
counters.errors.append(f"BLOCKED_HOUSE {house_path}: {e}")
logger.warning(
"pipeline:house BLOCKED at %s (consecutive=%d)",
house_path,
enrich_consecutive_blocks,
)
if enrich_consecutive_blocks >= _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT:
# #1790: Перед abort'ом пробуем сменить IP (changeip).
# После ротации сбрасываем счётчик и продолжаем — НЕ abort.
# Если ротации исчерпаны — abort enrichment (listings уже сохранены).
rotated, enrich_rotations_done = await _try_rotate_within_budget(
config,
reason=f"house consecutive={enrich_consecutive_blocks}",
rotations_done=enrich_rotations_done,
)
if rotated:
enrich_consecutive_blocks = 0
logger.info(
"pipeline:enrich ROTATE — IP changed, "
"reset consecutive blocks (rotation #%d/%d)",
enrich_rotations_done,
config.avito_proxy_max_rotations,
)
continue
logger.error(
"pipeline:enrich ABORT — %d consecutive blocks (IP rate-limited), "
"listings preserved",
enrich_consecutive_blocks,
)
counters.errors.append(
f"AVITO_BLOCKED — enrichment aborted after "
f"{enrich_consecutive_blocks} consecutive blocks (house phase)"
)
enrichment_aborted = True
break
except Exception as e:
logger.warning("pipeline:house_enrich failed for %s: %s", house_path, e)
counters.houses_failed += 1
counters.errors.append(f"house {house_path}: {e}")
# #1368: save_house_catalog_enrichment не делает внутренний rollback,
# поэтому упавший промежуточный statement оставляет общий Session в
# InFailedSqlTransaction. Без сброса — каскад «current transaction is
# aborted» на все оставшиеся дома/listings этого якоря.
try:
db.rollback()
except Exception:
pass
if idx < len(house_paths_list) - 1:
await asyncio.sleep(detail_delay)
# ── Step 5: enrich detail для top-N priority listings ───
if enrich_detail_top_n > 0 and not enrichment_aborted:
priority_rows = (
db.execute(
text("""
SELECT source_url
FROM listings
WHERE source = 'avito'
AND source_url IS NOT NULL
AND (
(
detail_enriched_at IS NULL
AND price_rub > 0
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
:radius
)
)
OR (
detail_enriched_at IS NULL
AND scraped_at > NOW() - INTERVAL '2 hours'
)
)
ORDER BY scraped_at DESC NULLS LAST
LIMIT :limit
"""),
{
"lat": lat,
"lon": lon,
"radius": radius_m * 2,
"limit": enrich_detail_top_n,
},
)
.mappings()
.all()
)
detail_house_paths: set[str] = set()
for idx, row in enumerate(priority_rows):
source_url: str = row["source_url"]
counters.detail_attempted += 1
try:
item_url = (
urlparse(source_url).path if source_url.startswith("http") else source_url
)
enrichment_detail = await fetch_detail(
item_url, cffi_session=session, browser_fetcher=browser_fetcher
)
if save_detail_enrichment(db, enrichment_detail):
counters.detail_enriched += 1
# #871: house-link есть в detail SSR (в SERP-карточке — JS-only).
# Копим catalog-пути для house-enrich ниже (Step 5b).
if enrichment_detail.house_catalog_url:
hp = urlparse(enrichment_detail.house_catalog_url).path
if hp:
detail_house_paths.add(hp)
enrich_consecutive_blocks = 0
except (AvitoBlockedError, AvitoRateLimitedError) as e:
# #1820: блок ловим пер-item, НЕ роняем прогон. Счётчик общий с house-фазой.
enrich_consecutive_blocks += 1
counters.detail_failed += 1
counters.errors.append(f"BLOCKED_DETAIL #{idx}: {e}")
logger.warning(
"pipeline:detail BLOCKED #%d/%d (consecutive=%d): %s",
idx + 1,
len(priority_rows),
enrich_consecutive_blocks,
e,
)
if enrich_consecutive_blocks >= _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT:
# #1790: Перед abort'ом пробуем сменить IP (changeip).
# После ротации сбрасываем счётчик — продолжаем detail-обход.
# Если ротации исчерпаны — abort detail-фазы (listings уже сохранены).
rotated, enrich_rotations_done = await _try_rotate_within_budget(
config,
reason=f"detail consecutive={enrich_consecutive_blocks}",
rotations_done=enrich_rotations_done,
)
if rotated:
enrich_consecutive_blocks = 0
logger.info(
"pipeline:enrich ROTATE — IP changed, "
"reset consecutive blocks (rotation #%d/%d)",
enrich_rotations_done,
config.avito_proxy_max_rotations,
)
continue
logger.error(
"pipeline:detail ABORT — %d consecutive blocks (IP rate-limited), "
"listings preserved",
enrich_consecutive_blocks,
)
counters.errors.append(
f"AVITO_BLOCKED — enrichment aborted after "
f"{idx + 1}/{len(priority_rows)} details (consecutive blocks)"
)
# #1820: НЕ raise — прерываем только detail-фазу (и Step 5b),
# PipelineResult возвращается с уже сохранёнными listings/houses.
enrichment_aborted = True
break
except Exception as e:
counters.detail_failed += 1
counters.errors.append(f"detail {source_url}: {e}")
logger.warning("pipeline:detail_enrich failed for %s: %s", source_url, e)
# #1386: НЕ сбрасываем consecutive_blocks здесь. Транзиентная
# сетевая/parse-ошибка (5xx, timeout, connection reset) — правдоподобный
# побочный эффект активного IP-блока (Avito не отдаёт 403/429 единообразно).
# Сброс маскировал бы продолжающийся блок (block, block, timeout→reset, ...)
# и не давал бы сработать early-abort на 3 consecutive blocks.
# Сброс на успехе (см. выше) остаётся.
# #1368: save_detail_enrichment без внутреннего rollback мог оставить
# общий Session в aborted-state — сбрасываем, иначе каскад на оставшиеся
# detail-строки и Step 5b.
try:
db.rollback()
except Exception:
pass
if idx < len(priority_rows) - 1:
jitter = random.uniform(0.8, 1.2)
await asyncio.sleep(detail_delay * jitter)
# ── Step 5b: enrich houses, найденные через detail-страницы (#871) ──
# SERP-карточки больше не несут house-link (JS-render) → дома берём из
# detail SSR. Дедуп против обработанных в Step 4 (unique_house_paths).
new_house_paths = detail_house_paths - unique_house_paths
if enrich_houses and new_house_paths and not enrichment_aborted:
counters.unique_houses += len(new_house_paths)
nh_list = list(new_house_paths)
for h_idx, house_path in enumerate(nh_list):
try:
enrichment = await fetch_house_catalog(
house_path, cffi_session=session, browser_fetcher=browser_fetcher
)
house_counters = save_house_catalog_enrichment(
db, enrichment, matcher=matcher
)
counters.houses_enriched += 1
enrich_consecutive_blocks = 0
hid = house_counters.get("house_id")
if hid:
touched_house_ids.add(int(hid))
except (AvitoBlockedError, AvitoRateLimitedError) as e:
# #1820: блок пер-item, общий счётчик; не роняем прогон.
enrich_consecutive_blocks += 1
counters.houses_failed += 1
counters.errors.append(f"BLOCKED_HOUSE(detail) {house_path}: {e}")
logger.warning(
"pipeline:house(detail) BLOCKED at %s (consecutive=%d)",
house_path,
enrich_consecutive_blocks,
)
if enrich_consecutive_blocks >= _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT:
# #1790: попытка ротации перед abort'ом (общий бюджет run).
rotated, enrich_rotations_done = await _try_rotate_within_budget(
config,
reason=f"house-detail consecutive={enrich_consecutive_blocks}",
rotations_done=enrich_rotations_done,
)
if rotated:
enrich_consecutive_blocks = 0
logger.info(
"pipeline:enrich ROTATE — IP changed, "
"reset consecutive blocks (rotation #%d/%d)",
enrich_rotations_done,
config.avito_proxy_max_rotations,
)
continue
logger.error(
"pipeline:enrich ABORT — %d consecutive blocks "
"(IP rate-limited), listings preserved",
enrich_consecutive_blocks,
)
counters.errors.append(
f"AVITO_BLOCKED — enrichment aborted after "
f"{enrich_consecutive_blocks} consecutive blocks "
f"(house-detail phase)"
)
enrichment_aborted = True
break
except Exception as e:
logger.warning(
"pipeline:house(detail) enrich failed for %s: %s", house_path, e
)
counters.houses_failed += 1
counters.errors.append(f"house(detail) {house_path}: {e}")
# #1368: см. Step 4 — сбрасываем aborted-state общего Session,
# иначе каскад на оставшиеся дома detail-фазы.
try:
db.rollback()
except Exception:
pass
if h_idx < len(nh_list) - 1:
await asyncio.sleep(detail_delay)
logger.info(
"pipeline:done anchor=(%.4f,%.4f) lots=%d (ins=%d/upd=%d) "
"houses=%d/%d detail=%d/%d touched_house_ids=%d errors=%d",
lat,
lon,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.houses_enriched,
counters.unique_houses,
counters.detail_enriched,
counters.detail_attempted,
len(touched_house_ids),
len(counters.errors),
)
return PipelineResult(
lat,
lon,
radius_m,
counters,
enrich_houses,
enrich_detail_top_n,
touched_house_ids=touched_house_ids,
)
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)
@dataclass
class CitySweepCounters:
"""Aggregate counters для full city sweep run."""
anchors_total: int = 0
anchors_done: int = 0
lots_fetched: int = 0
lots_inserted: int = 0
lots_updated: int = 0
unique_houses: int = 0
houses_enriched: int = 0
houses_failed: int = 0
detail_attempted: int = 0
detail_enriched: int = 0
detail_failed: int = 0
imv_attempted: int = 0
imv_enriched: int = 0
imv_failed: int = 0
errors_count: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
async def run_avito_city_sweep(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
enrichment: EnrichmentJobs,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
anchors: list[tuple[float, float, str]] | None = None,
city_slug: str | None = None,
radius_m: int = 1500,
pages_per_anchor: int = 3,
enrich_houses: bool = True,
detail_top_n: int = 10,
request_delay_sec: float = 7.0,
enrich_imv: bool = True,
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
) -> CitySweepCounters:
"""Full city sweep: iterate anchors × pages → save → enrich houses + detail → IMV.
- Single shared AsyncSession на весь sweep (один TLS fingerprint)
- AvitoBlockedError/AvitoRateLimitedError → ABORT sweep + mark_banned (status='banned')
- Прочие errors per-anchor логируются, не валят весь sweep
- После всех anchor'ов: если enrich_imv=True — IMV-оценка тронутых домов
(cooperative cancel + per-house graceful error handling)
SERP-счётчики (lots_fetched/inserted/updated) записываются в sweep-level counters
НЕМЕДЛЕННО после save_listings — до начала detail/houses фазы. Даже если anchor
превышает watchdog-таймаут в detail-фазе, SERP-результаты не теряются.
Инжекция (#2135): config/matcher/enrichment/shutdown_requested приходят снаружи
вместо прямых импортов app.* (см. scraper_kit.contracts). process_houses_imv_batch
зовётся через enrichment (EnrichmentJobs Protocol).
"""
# city_slug (#2487): слаг города-цели (напр. "nizhniy_tagil") прокидывается в
# AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города,
# а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ.
# None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже).
_anchors = anchors if anchors is not None else EKB_ANCHORS
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
# Прод-факт: у avito_city_sweep типичный итог `anchors_done: 1` из
# `anchors_total: 5` — прогон срывается блокировкой детализации на первом же
# якоре (25 банов за 60 дней). Без чекпоинта следующий прогон снова начинает
# с первого якоря, упирается в ту же стену, и якоря 2-5 не собираются никогда.
#
# Ключ чекпоинта — ИМЯ якоря, а не индекс: список якорей зависит от
# `city_slug`, и позиция в нём не устойчива между прогонами разных городов.
_skip_anchors: set[str] = set()
if resume_run_id is not None:
_prev = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev is not None and _prev.counters:
_pc: dict = _prev.counters if isinstance(_prev.counters, dict) else {}
_skip_anchors = set(_pc.get("done_buckets", []))
logger.info(
"city-sweep run_id=%d: resuming from run %s%d якорей уже пройдено",
run_id,
resume_run_id,
len(_skip_anchors),
)
_done_anchors: set[str] = set(_skip_anchors)
def _ckpt(**extra: Any) -> dict[str, Any]:
"""Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319).
До этого точку писала ровно одна строка — end-of-anchor heartbeat в конце
итерации цикла. Финализаторы её НЕ стирали: все четыре писателя в runs.py
мержат jsonb (`counters || :counters`), уже записанный ключ переживал и
mark_done, и mark_banned, и mark_failed. Закрывается другая дыра — выходы,
случившиеся РАНЬШЕ первой такой записи: cancel/SIGTERM-дрейн на границе
первого якоря и ранний done #1950 («SERP собран, detail заблокирован») на
якоре №1. Именно им и кончается типичный прод-прогон, у которого
`anchors_done: 1` из 5.
Замер «0 из 67 прогонов за 60 дней несут done_buckets» сам по себе этого НЕ
доказывает: строка записи появилась только 26.08.2026 (#3074), а такт avito
— 7 суток, так что выборка почти целиком из эры, где механизма не было.
"""
return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra}
_loc = get_city_location(city_slug)
# #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города
# он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия).
# Известный city_slug БЕЗ подтверждённого avito_slug — explicit fail, а НЕ молчаливый
# fallback на ЕКБ: `_city_seg()` в AvitoScraper делает `target_city_slug or
# "ekaterinburg"`, поэтому city_slug=None ниже незаметно собрал бы Екатеринбург под
# меткой города-цели. scrape_schedules-сиды (262_) заводят avito_city_sweep_<city>
# ТОЛЬКО там, где avito_slug подтверждён — этот раздел кода не должен исполняться в
# штатной эксплуатации; сработавший ValueError сигнализирует рассинхрон данных.
if _loc is not None and _loc.avito_slug is None:
raise ValueError(
f"run_avito_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но "
"avito_slug не подтверждён (None) — отказ от молчаливого fallback на "
"ekaterinburg. Подтверди avito_slug для этого города, прежде чем заводить "
"avito_city_sweep_<city> schedule."
)
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
counters = CitySweepCounters(anchors_total=len(_anchors))
all_touched_house_ids: set[int] = set()
# Масштабируемый watchdog-таймаут для Avito anchor'а. В отличие от Yandex/Cian,
# Avito detail-fetch идёт через browser service с page.goto timeout 60s. При
# detail_top_n=20 detail-фаза может занять до N×60s — фиксированный ANCHOR_TIMEOUT_SEC
# обрезает её раньше SERP, теряя счётчики. Масштабирование по detail_top_n + houses
# гарантирует, что SERP+save всегда успевает до watchdog.
_avito_anchor_timeout = (
float(ANCHOR_TIMEOUT_SEC)
+ detail_top_n * _AVITO_PER_DETAIL_S
+ (_AVITO_HOUSES_BUDGET_S if enrich_houses else 0.0)
)
logger.info(
"city-sweep run_id=%d: avito anchor_timeout=%.0fs "
"(base=%d + detail_top_n=%d×%.0fs + houses=%.0fs)",
run_id,
_avito_anchor_timeout,
ANCHOR_TIMEOUT_SEC,
detail_top_n,
_AVITO_PER_DETAIL_S,
_AVITO_HOUSES_BUDGET_S if enrich_houses else 0.0,
)
browser_mode = config.scraper_fetch_mode == "browser"
# #1790: общий бюджет IP-ротаций на весь sweep (разделяется по всем anchor'ам).
sweep_rotations_done = 0
async with AsyncExitStack() as stack:
session: AsyncSession | None = None
shared_bf: BrowserFetcher | None = None
if browser_mode:
shared_bf = await stack.enter_async_context(
BrowserFetcher(
source="avito",
endpoint=config.browser_http_endpoint,
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
# #2616 шаг 1: см. run_avito_pipeline — environment обязателен
# при прямом конструировании (guard иначе мёртв).
environment=getattr(config, "environment", "dev"),
)
)
else:
session = await stack.enter_async_context(
AsyncSession(
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(config),
)
)
try:
for idx, (lat, lon, name) in enumerate(_anchors, start=1):
if name in _skip_anchors:
# #3074: якорь собран предыдущим оборванным прогоном — ни одного
# HTTP-запроса. Счётчик двигаем, чтобы `anchors_done` продолжал
# означать «докуда дошли по списку», а не «сколько собрал этот run».
counters.anchors_done = idx
logger.info(
"city-sweep run_id=%d: anchor #%d/%d (%s) пропущен — есть в чекпоинте",
run_id,
idx,
len(_anchors),
name,
)
continue
if runs.is_cancelled(db, run_id):
logger.info(
"city-sweep run_id=%d: cancelled at anchor #%d/%d (%s)",
run_id,
idx,
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, _ckpt())
return counters
elif shutdown_requested():
# Кооперативный SIGTERM-drain (#1182 Phase 3a): останавливаемся
# на границе anchor'а и финализируем run как mark_done(partial) —
# это НЕ user-cancel, поэтому НЕ mark_cancelled. Дальнейшие anchor'ы
# и IMV-фаза не выполняются.
logger.info(
"city-sweep run_id=%d: SIGTERM-drain — stopping at anchor #%d/%d (%s)",
run_id,
idx,
len(_anchors),
name,
)
# #3319: 'done' с counters.interrupted=1 — НЕ полный обход
# (та же метка, что у rosreestr_dkp-дрейна). Без неё оборванный
# деплоем прогон неотличим от честно обошедшего все якоря:
# статус тот же, счётчики частичные, а резюм его не берёт.
# Читатели статуса не трогаем — 'done' остаётся 'done'.
runs.update_heartbeat(db, run_id, _ckpt(interrupted=1))
runs.mark_done(db, run_id, _ckpt(interrupted=1))
return counters
logger.info(
"city-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
)
# #3074: сбрасывается на КАЖДОЙ итерации — иначе один упавший якорь
# заразил бы все последующие, и чекпоинт не пополнялся бы вовсе.
_anchor_ok = True
# Capture loop variables in default args (B023): prevents stale binding
# if the coroutine is scheduled after the loop variable changes.
_a_lat, _a_lon, _a_name = lat, lon, name
async def _avito_anchor_phases(
_lat: float = _a_lat,
_lon: float = _a_lon,
_name: str = _a_name,
) -> None:
"""Все фазы одного Avito anchor'а (SERP+save → houses → detail).
Обновляет sweep-level counters и all_touched_house_ids через nonlocal
НЕМЕДЛЕННО после SERP+save (до detail-фазы). Это гарантирует, что
TimeoutError из wait_for теряет только detail-счётчики, но не SERP.
"""
nonlocal all_touched_house_ids, sweep_rotations_done
# ── Phase 1+2: SERP + save ─────────────────────────────────
# #2487: target_city_slug → _parse_html оставляет карточки
# города-цели (oblast slug). #12: _avito_slug (может отличаться
# от city_slug) идёт в путь URL — не хардкод /ekaterinburg/. None → ЕКБ.
# proxy_provider прокинут для консистентности (#2616) — не load-bearing,
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
# переопределяет scraper._browser напрямую, минуя __aenter__.
scraper = AvitoScraper(
config, target_city_slug=_avito_slug, proxy_provider=proxy_provider
)
if browser_mode:
scraper._browser = shared_bf
# Shared-browser режим: _cffi=None → curl_cffi-fallback на
# firewall пропускается (by design). Логируем для observability.
logger.debug("avito pipeline-mode: shared browser only, no cffi fallback")
else:
scraper._cffi = session
try:
anchor_lots: list[ScrapedLot] = await scraper.fetch_around(
_lat,
_lon,
radius_m,
pages=pages_per_anchor,
delay_override_sec=request_delay_sec,
)
except (AvitoBlockedError, AvitoRateLimitedError):
logger.error(
"city-sweep run_id=%d: SERP BLOCKED at anchor (%s, %.4f, %.4f)",
run_id,
_name,
_lat,
_lon,
)
raise
counters.lots_fetched += len(anchor_lots)
if anchor_lots:
try:
ins, upd = save_listings(
db,
anchor_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += ins
counters.lots_updated += upd
except Exception as save_exc:
logger.exception(
"city-sweep run_id=%d: save_listings failed at anchor %s: %s",
run_id,
_name,
save_exc,
)
counters.errors_count += 1
logger.info(
"city-sweep run_id=%d anchor %s: SERP fetched=%d ins=%d upd=%d",
run_id,
_name,
len(anchor_lots),
counters.lots_inserted,
counters.lots_updated,
)
# SERP-счётчики зафиксированы в sweep-level counters.
# Дальнейший TimeoutError из wait_for их не сотрёт.
# ── Phase 3: group by house ────────────────────────────────
unique_house_paths: set[str] = set()
for lot in anchor_lots:
if lot.house_url:
try:
parsed = urlparse(lot.house_url)
path = parsed.path if parsed.path else lot.house_url
unique_house_paths.add(path)
except Exception:
continue
if unique_house_paths:
counters.unique_houses += len(unique_house_paths)
# ── Phase 4: enrich houses ─────────────────────────────────
touched_house_ids: set[int] = set()
sweep_consecutive_house_blocks = 0 # #1790: per-anchor счётчик
if enrich_houses and unique_house_paths:
house_paths_list = list(unique_house_paths)
h_idx = 0
while h_idx < len(house_paths_list):
house_path = house_paths_list[h_idx]
try:
enrichment_hc = await fetch_house_catalog(
house_path,
cffi_session=session,
browser_fetcher=shared_bf,
)
hc = save_house_catalog_enrichment(
db, enrichment_hc, matcher=matcher
)
counters.houses_enriched += 1
sweep_consecutive_house_blocks = 0
hid = hc.get("house_id")
if hid:
touched_house_ids.add(int(hid))
if h_idx < len(house_paths_list) - 1:
await asyncio.sleep(request_delay_sec)
h_idx += 1
except (AvitoBlockedError, AvitoRateLimitedError) as be:
# #1790: пробуем сменить IP перед re-raise.
sweep_consecutive_house_blocks += 1
counters.houses_failed += 1
counters.errors_count += 1
logger.warning(
"city-sweep run_id=%d: house BLOCKED at %s "
"(consecutive=%d) — %s",
run_id,
house_path,
sweep_consecutive_house_blocks,
be,
)
if (
sweep_consecutive_house_blocks
>= _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
and sweep_rotations_done < config.avito_proxy_max_rotations
):
rotated, sweep_rotations_done = await _try_rotate_within_budget(
config,
reason=(
f"city-sweep house "
f"consecutive={sweep_consecutive_house_blocks}"
),
rotations_done=sweep_rotations_done,
)
if rotated:
sweep_consecutive_house_blocks = 0
logger.info(
"city-sweep run_id=%d: house ROTATE — "
"IP changed, reset consecutive "
"(rotation #%d/%d)",
run_id,
sweep_rotations_done,
config.avito_proxy_max_rotations,
)
# Не инкрементируем h_idx — retry текущего дома
continue
elif sweep_consecutive_house_blocks >= 3:
logger.error(
"city-sweep run_id=%d: house ABORT — "
"%d consecutive blocks, propagating",
run_id,
sweep_consecutive_house_blocks,
)
raise
h_idx += 1
except Exception as he:
logger.warning(
"city-sweep run_id=%d: house_enrich failed %s: %s",
run_id,
house_path,
he,
)
counters.houses_failed += 1
counters.errors_count += 1
try:
db.rollback()
except Exception:
pass
h_idx += 1
# ── Phase 5: enrich detail (top-N) ────────────────────────
if detail_top_n > 0 and anchor_lots:
priority_rows = (
db.execute(
text("""
SELECT source_url
FROM listings
WHERE source = 'avito'
AND source_url IS NOT NULL
AND (
(
detail_enriched_at IS NULL
AND price_rub > 0
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
:radius
)
)
OR (
detail_enriched_at IS NULL
AND scraped_at > NOW() - INTERVAL '2 hours'
)
)
ORDER BY scraped_at DESC NULLS LAST
LIMIT :limit
"""),
{
"lat": _lat,
"lon": _lon,
"radius": radius_m * 2,
"limit": detail_top_n,
},
)
.mappings()
.all()
)
consecutive_blocks = 0
consecutive_timeouts = 0
detail_house_paths: set[str] = set()
for d_idx, row in enumerate(priority_rows):
source_url: str = row["source_url"]
counters.detail_attempted += 1
try:
item_url = (
urlparse(source_url).path
if source_url.startswith("http")
else source_url
)
enrichment_detail = await fetch_detail(
item_url,
cffi_session=session,
browser_fetcher=shared_bf,
)
if save_detail_enrichment(db, enrichment_detail):
counters.detail_enriched += 1
if enrichment_detail.house_catalog_url:
hp = urlparse(enrichment_detail.house_catalog_url).path
if hp:
detail_house_paths.add(hp)
consecutive_blocks = 0
consecutive_timeouts = 0
except (AvitoBlockedError, AvitoRateLimitedError) as be:
consecutive_blocks += 1
counters.detail_failed += 1
counters.errors_count += 1
logger.warning(
"city-sweep run_id=%d: detail BLOCKED #%d/%d "
"(consecutive=%d): %s",
run_id,
d_idx + 1,
len(priority_rows),
consecutive_blocks,
be,
)
if consecutive_blocks >= 3:
# #1790: попытка ротации перед abort'ом.
rotated, sweep_rotations_done = await _try_rotate_within_budget(
config,
reason=(
f"city-sweep detail consecutive={consecutive_blocks}"
),
rotations_done=sweep_rotations_done,
)
if rotated:
consecutive_blocks = 0
logger.info(
"city-sweep run_id=%d: detail ROTATE — "
"IP changed, reset consecutive "
"(rotation #%d/%d)",
run_id,
sweep_rotations_done,
config.avito_proxy_max_rotations,
)
continue
logger.error(
"city-sweep run_id=%d: detail ABORT — "
"%d consecutive blocks (IP rate-limited)",
run_id,
consecutive_blocks,
)
raise
except Exception as de:
counters.detail_failed += 1
counters.errors_count += 1
consecutive_timeouts += 1
logger.warning(
"city-sweep run_id=%d: detail failed #%d/%d "
"(consecutive_timeouts=%d): %s",
run_id,
d_idx + 1,
len(priority_rows),
consecutive_timeouts,
de,
)
# (C) Fail-fast: браузер явно деградирует — прерываем
# detail-фазу, не тратим время на оставшиеся запросы.
if consecutive_timeouts >= _AVITO_DETAIL_CONSECUTIVE_TIMEOUT_ABORT:
logger.error(
"city-sweep run_id=%d: detail ABORT — "
"%d consecutive timeouts/errors, browser degraded",
run_id,
consecutive_timeouts,
)
break
try:
db.rollback()
except Exception:
pass
if d_idx < len(priority_rows) - 1:
jitter = random.uniform(0.8, 1.2)
await asyncio.sleep(request_delay_sec * jitter)
# ── Phase 5b: houses найденные через detail-страницы ──
new_house_paths = detail_house_paths - unique_house_paths
if enrich_houses and new_house_paths:
counters.unique_houses += len(new_house_paths)
nh_list = list(new_house_paths)
nh_idx = 0
sweep_consecutive_detail_house_blocks = 0 # #1790
while nh_idx < len(nh_list):
house_path = nh_list[nh_idx]
try:
enrichment_hc = await fetch_house_catalog(
house_path,
cffi_session=session,
browser_fetcher=shared_bf,
)
hc = save_house_catalog_enrichment(
db, enrichment_hc, matcher=matcher
)
counters.houses_enriched += 1
sweep_consecutive_detail_house_blocks = 0
hid = hc.get("house_id")
if hid:
touched_house_ids.add(int(hid))
if nh_idx < len(nh_list) - 1:
await asyncio.sleep(request_delay_sec)
nh_idx += 1
except (AvitoBlockedError, AvitoRateLimitedError) as beh:
# #1790: ротация перед re-raise.
sweep_consecutive_detail_house_blocks += 1
counters.houses_failed += 1
counters.errors_count += 1
logger.warning(
"city-sweep run_id=%d: house(detail) BLOCKED %s"
" (consecutive=%d) — %s",
run_id,
house_path,
sweep_consecutive_detail_house_blocks,
beh,
)
if (
sweep_consecutive_detail_house_blocks >= 3
and sweep_rotations_done < config.avito_proxy_max_rotations
):
(
rotated,
sweep_rotations_done,
) = await _try_rotate_within_budget(
config,
reason=(
"city-sweep house(detail) "
f"consecutive="
f"{sweep_consecutive_detail_house_blocks}"
),
rotations_done=sweep_rotations_done,
)
if rotated:
sweep_consecutive_detail_house_blocks = 0
logger.info(
"city-sweep run_id=%d: house(detail) "
"ROTATE — IP changed (rotation #%d/%d)",
run_id,
sweep_rotations_done,
config.avito_proxy_max_rotations,
)
continue
elif sweep_consecutive_detail_house_blocks >= 3:
logger.error(
"city-sweep run_id=%d: house(detail) ABORT"
"%d consecutive blocks, propagating",
run_id,
sweep_consecutive_detail_house_blocks,
)
raise
nh_idx += 1
except Exception as he:
logger.warning(
"city-sweep run_id=%d: house(detail) failed %s: %s",
run_id,
house_path,
he,
)
counters.houses_failed += 1
counters.errors_count += 1
try:
db.rollback()
except Exception:
pass
nh_idx += 1
all_touched_house_ids.update(touched_house_ids)
logger.info(
"city-sweep run_id=%d anchor %s done: "
"lots=%d houses=%d/%d detail=%d/%d touched=%d errors=%d",
run_id,
_name,
len(anchor_lots),
counters.houses_enriched,
counters.unique_houses,
counters.detail_enriched,
counters.detail_attempted,
len(touched_house_ids),
counters.errors_count,
)
try:
await asyncio.wait_for(_avito_anchor_phases(), timeout=_avito_anchor_timeout)
except TimeoutError:
logger.warning(
"city-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) "
"timed out after %.0fs — SERP counters preserved, "
"detail phase incomplete",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
_avito_anchor_timeout,
)
counters.errors_count += 1
# #3319: тот же инвариант, что у generic-except ниже. Якорь,
# умерший по таймауту, ПРОЙДЕН НЕ БЫЛ: SERP мог успеть, а
# detail/houses — нет, и какая именно часть осталась несобранной,
# здесь неизвестно. Считать его пройденным значит, что резюм
# пропустит его навсегда — молча, при штатно завершившемся прогоне.
_anchor_ok = False
except (AvitoBlockedError, AvitoRateLimitedError) as e:
logger.error(
"city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s",
run_id,
idx,
len(_anchors),
name,
e,
)
counters.errors_count += 1
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, _ckpt())
# #1950: если SERP уже собрал лоты и заблокировало только detail/houses,
# ставим 'done' (не 'banned') — partial intake сохранён.
# За флагом avito_serp_ok_not_banned (default True).
_serp_ok = (counters.lots_inserted + counters.lots_updated) > 0
if config.avito_serp_ok_not_banned and _serp_ok:
_note = (
f"detail enrichment aborted ({e}) но SERP intake завершён: "
f"{counters.lots_inserted} ins {counters.lots_updated} upd"
)
logger.warning(
"city-sweep run_id=%d: SERP OK (ins=%d upd=%d), "
"detail blocked — DONE not banned. %s",
run_id,
counters.lots_inserted,
counters.lots_updated,
_note,
)
runs.mark_done(
db,
run_id,
_ckpt(enrichment_abort_note=_note), # type: ignore[arg-type]
)
else:
runs.mark_banned(
db,
run_id,
str(e),
_ckpt(),
ban_kind=ban_kind_of_exception(e),
)
return counters
except Exception:
logger.exception("city-sweep run_id=%d: anchor %s failed", run_id, name)
counters.errors_count += 1
_anchor_ok = False
counters.anchors_done = idx
# #3074: в чекпоинт попадает ТОЛЬКО якорь, пройденный до конца.
# Ветка блокировки выше делает `return` и сюда не доходит, а вот
# generic-except доходит — якорь упал, но цикл продолжается. Записать
# его как пройденный значило бы, что следующий прогон его пропустит и
# объявления оттуда не соберутся НИКОГДА, причём молча: прогон
# завершится штатно. Тот же инвариант, что у combo в yandex-свипе.
if _anchor_ok:
_done_anchors.add(name)
runs.update_heartbeat(db, run_id, _ckpt())
# ── IMV-фаза: финальный обход тронутых домов ──────────
if enrich_imv and all_touched_house_ids:
if runs.is_cancelled(db, run_id):
logger.info(
"city-sweep run_id=%d: cancelled before IMV phase (%d houses skipped)",
run_id,
len(all_touched_house_ids),
)
runs.mark_done(db, run_id, _ckpt())
return counters
elif shutdown_requested():
# SIGTERM-drain до IMV-фазы: финализируем без дорогой IMV-оценки.
logger.info(
"city-sweep run_id=%d: SIGTERM-drain — skipping IMV phase "
"(%d houses skipped)",
run_id,
len(all_touched_house_ids),
)
# #3319: тоже дрейн, но якоря пройдены ВСЕ — резюмить нечего
# (пропустил бы весь список и собрал ноль), поэтому метка
# диагностическая, а не резюм-флаг `interrupted`.
runs.mark_done(db, run_id, _ckpt(imv_phase_drained=1))
return counters
logger.info(
"city-sweep run_id=%d: IMV phase — %d touched houses",
run_id,
len(all_touched_house_ids),
)
try:
# Периодический heartbeat внутри длинной IMV-фазы (#1363):
# без него десятки/сотни домов × request_delay_sec проходят
# без update_heartbeat, и reap_zombies помечает живой run
# 'zombie' → последующий mark_done становится no-op (дубль-sweep).
def _imv_heartbeat() -> None:
runs.update_heartbeat(db, run_id, _ckpt())
imv_result = await enrichment.process_houses_imv_batch(
db,
all_touched_house_ids,
request_delay_sec=request_delay_sec,
heartbeat=_imv_heartbeat,
)
counters.imv_attempted += imv_result.checked
counters.imv_enriched += imv_result.saved
counters.imv_failed += imv_result.errors
counters.errors_count += imv_result.errors
runs.update_heartbeat(db, run_id, _ckpt())
logger.info(
"city-sweep run_id=%d: IMV phase done — attempted=%d enriched=%d failed=%d",
run_id,
imv_result.checked,
imv_result.saved,
imv_result.errors,
)
except Exception as exc:
logger.error(
"city-sweep run_id=%d: IMV phase crashed — %r (sweep still done)",
run_id,
exc,
)
counters.errors_count += 1
# #1521: IMV-фаза могла оставить Session в aborted-state
# (необёрнутый eligibility SELECT / _mark_status на отравленной
# сессии). update_heartbeat и mark_done — единственные финализаторы
# БЕЗ defensive rollback, поэтому сбрасываем txn-state сами. Иначе
# PendingRollbackError пролетает мимо внутреннего try во внешний
# except (mark_failed) → успешный sweep ложно помечается 'failed'.
try:
db.rollback()
except Exception:
pass
runs.update_heartbeat(db, run_id, _ckpt())
runs.mark_done(db, run_id, _ckpt())
logger.info(
"city-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) "
"houses=%d/%d detail=%d/%d imv=%d/%d errors=%d",
run_id,
counters.anchors_done,
counters.anchors_total,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.houses_enriched,
counters.unique_houses,
counters.detail_enriched,
counters.detail_attempted,
counters.imv_enriched,
counters.imv_attempted,
counters.errors_count,
)
return counters
except Exception as exc:
logger.exception("city-sweep run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), _ckpt())
raise
# ── Avito newbuilding (novostroyka) citywide sweep ────────────────
@dataclass
class NewbuildingSweepCounters:
"""Aggregate counters для Avito novostroyka citywide sweep run."""
lots_fetched: int = 0
lots_inserted: int = 0
lots_updated: int = 0
errors_count: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
async def run_avito_newbuilding_sweep(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
pages: int = 20,
request_delay_sec: float = 7.0,
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
) -> NewbuildingSweepCounters:
"""Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save.
В отличие от run_avito_city_sweep (anchor-based fetch_around + houses/detail/IMV),
это простой citywide paginated-обход через AvitoScraper.fetch_newbuildings:
dedicated novostroyka-SERP отдаёт 100% new-build карточки (listing_segment=
"novostroyki", newbuilding_id/newbuilding_url заполнены парсером). Сохраняем через
стандартный save_listings, ведём SERP-счётчики через runs.
- Single shared AsyncSession/BrowserFetcher на весь sweep (один TLS fingerprint)
- AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned')
- is_cancelled-чек до старта SERP-фазы
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
прямых импортов app.* (см. scraper_kit.contracts).
── Checkpoint/resume (#3074): единица обхода — СТРАНИЦА выдачи ────────────
Последний длинный свип без чекпоинтов: прогон убивался деплоем посреди
обхода (страница 300+ при pages в разы больше дефолта), и всё собранное
терялось целиком, потому что save_listings раньше был ОДИН на весь sweep —
ждать в конце было нечего. Здесь (как и у combo в yandex-свипе) save
происходит инкрементально в on_page-callback: сразу после того, как
страница пройдена до конца, её лоты сохраняются и её номер уходит в
чекпоинт `done_buckets` (список номеров страниц). Страницы — единственная и
строго последовательная единица обхода (break-on-empty), поэтому resume —
это просто `start_page = max(done_buckets) + 1`, без skip-набора: страница,
оборванная исключением, callback не получает и в чекпоинт не попадает —
иначе следующий прогон пропустил бы её навсегда, причём молча.
"""
counters = NewbuildingSweepCounters()
_done_pages: set[int] = set()
if resume_run_id is not None:
_prev = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev is not None and _prev.counters:
_pc: dict = _prev.counters if isinstance(_prev.counters, dict) else {}
_done_pages = {int(p) for p in _pc.get("done_buckets", [])}
logger.info(
"nb-sweep run_id=%d: resuming from run %s%d страниц уже собрано",
run_id,
resume_run_id,
len(_done_pages),
)
# #3074: продолжаем с ПЕРВОЙ несобранной страницы, а не с max+1. Дыра в
# чекпоинте возможна (страница собрана, но её save упал — она не отмечена,
# а следующие отмечены), и max+1 перепрыгнул бы её навсегда: молча, потому
# что прогон завершится штатно. Уже собранные страницы после дыры при этом
# перечитаются — это дешевле потери, а повторная запись идемпотентна
# (save_listings схлопывает по dedup_hash).
_start_page = 1
while _start_page in _done_pages:
_start_page += 1
def _on_page(page: int, new_lots: list[ScrapedLot]) -> None:
"""Страница собрана и СОХРАНЕНА — только тогда чекпоинт (мерж, не замена)."""
counters.lots_fetched += len(new_lots)
_saved_ok = True
if new_lots:
try:
# #2594: citywide novostroyka-обход — только ЕКБ (см. docstring).
ins, upd = save_listings(
db,
new_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=EKATERINBURG_CITY_NAME,
)
counters.lots_inserted += ins
counters.lots_updated += upd
except Exception as save_exc:
logger.exception(
"nb-sweep run_id=%d page=%d: save_listings failed: %s",
run_id,
page,
save_exc,
)
counters.errors_count += 1
_saved_ok = False
try:
db.rollback()
except Exception:
pass
# #3074: страница попадает в чекпоинт ТОЛЬКО если её лоты сохранены.
# Отказ save_listings перехвачен выше и прогон продолжается — но отметить
# страницу пройденной значило бы, что следующий прогон её пропустит, а
# объявления оттуда не соберутся НИКОГДА, причём молча: прогон завершится
# штатно. Тот же инвариант, что у якоря в avito city sweep и у бакета в
# cian full-load (_mark_bucket): в чекпоинт — только полностью собранная
# единица. Heartbeat обновляем в любом случае, иначе reap_zombies посчитает
# живой прогон мёртвым.
if _saved_ok:
_done_pages.add(page)
runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_pages)}
)
browser_mode = config.scraper_fetch_mode == "browser"
async with AsyncExitStack() as stack:
session: AsyncSession | None = None
shared_bf: BrowserFetcher | None = None
if browser_mode:
shared_bf = await stack.enter_async_context(
BrowserFetcher(
source="avito",
endpoint=config.browser_http_endpoint,
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
# #2616 шаг 1: см. run_avito_pipeline — environment обязателен
# при прямом конструировании (guard иначе мёртв).
environment=getattr(config, "environment", "dev"),
)
)
else:
session = await stack.enter_async_context(
AsyncSession(
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(config),
)
)
try:
if runs.is_cancelled(db, run_id):
logger.info("nb-sweep run_id=%d: cancelled before SERP phase", run_id)
runs.mark_done(db, run_id, counters.to_dict())
return counters
elif shutdown_requested():
logger.info(
"nb-sweep run_id=%d: SIGTERM-drain — stopping before SERP phase", run_id
)
# #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)).
# Без неё оборванный деплоем обход неотличим от полного: статус 'done',
# счётчики частичные — и резюм (`_drained_done` в scheduler) его не берёт.
# done_buckets тут не пишем: heartbeat мержит jsonb, уже записанные
# страницы переживают финализатор.
_drain = {**counters.to_dict(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
# proxy_provider прокинут для консистентности (#2616) — не load-bearing,
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
# переопределяет scraper._browser напрямую, минуя __aenter__.
scraper = AvitoScraper(config, proxy_provider=proxy_provider)
if browser_mode:
scraper._browser = shared_bf
# Shared-browser режим: _cffi=None → curl_cffi-fallback на firewall
# пропускается (by design). Логируем для observability.
logger.debug("avito pipeline-mode: shared browser only, no cffi fallback")
else:
scraper._cffi = session
try:
# #3074: save + чекпоинт идут инкрементально внутри _on_page —
# aggregate `lots` ниже используется только для лог-сообщения.
await scraper.fetch_newbuildings(
pages=pages,
start_page=_start_page,
on_page=_on_page,
delay_override_sec=request_delay_sec,
)
except (AvitoBlockedError, AvitoRateLimitedError) as e:
logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e)
runs.mark_banned(
db, run_id, str(e), counters.to_dict(), ban_kind=ban_kind_of_exception(e)
)
return counters
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, counters.to_dict())
logger.info(
"nb-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) errors=%d",
run_id,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.errors_count,
)
return counters
except Exception as exc:
logger.exception("nb-sweep run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
# ── Yandex city sweep ───────────────────────────────────────────
# Константы для расчёта watchdog-таймаута combos-режима Yandex sweep.
# В combos-режиме единственный "anchor" делает num_combos × max_pages fetch'ей;
# каждый fetch занимает request_delay_sec + сетевой overhead. ADDRESS_ENRICH_BUDGET_S
# добавляет буфер на address-enrich фазу (per-listing HTTP + sleep × N листингов).
_YANDEX_COMBOS_PER_FETCH_S: float = 12.0 # network + parse margin (секунды на страницу)
_YANDEX_ADDRESS_ENRICH_BUDGET_S: float = 300.0 # бюджет на address-enrich фазу
@dataclass
class YandexCitySweepCounters:
"""Aggregate counters для Yandex.Недвижимость city sweep run.
Фазы: SERP (fetch_around_multi_room) → save_listings инкрементально → address-enrich.
address_* — результат T10-обогащения (detail <title> → полный адрес с номером дома).
combos_skipped — сколько (room×price) combo пропущено из-за таймаутов gate-API.
"""
anchors_total: int = 0
anchors_done: int = 0
lots_fetched: int = 0
lots_inserted: int = 0
lots_updated: int = 0
price_history_rows: int = 0
combos_skipped: int = 0
address_attempted: int = 0
address_enriched: int = 0
address_failed: int = 0
errors_count: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
# Defensive abort threshold: у YandexRealtyScraper нет block/rate-exception классов
# (fetch_around глотает ошибки и возвращает []). Чтобы не молотить Yandex впустую при
# системном блоке/бане IP, прерываем sweep после N подряд anchor'ов, упавших с
# исключением. NB: пустой результат (0 lots) — это НЕ ошибка, счётчик не растёт.
YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES = 3
async def run_yandex_city_sweep(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
enrichment: EnrichmentJobs,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
anchors: list[tuple[float, float, str]] | None = None,
city_slug: str | None = None,
radius_m: int = 25000,
pages_per_anchor: int = 2,
request_delay_sec: float | None = None,
enrich_address: bool = True,
rooms_list: list[str] | None = None,
price_ranges: list[tuple[int | None, int | None]] | None = None,
segments: list[str] | None = None,
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
) -> YandexCitySweepCounters:
"""Yandex.Недвижимость city sweep: rooms × price combos от центра ЕКБ → save → address-enrich.
Вместо 5 географических anchor'ов итерирует ВСЕ combinations (room × price_range)
из одной центральной точки ЕКБ (56.8400, 60.6050). Город на самом деле скоупит
`city_rgid` (см. ниже) — lat/lon/radius_m у yandex gate-API игнорируются целиком
(providers/yandex/serp.py:fetch_around), `radius_m=25000` default здесь мёртв;
price/room segmentation обходит Yandex SERP ~575-card cap без geo-шрапнели.
Фазы (единственный центральный якорь):
1. SERP: YandexRealtyScraper.fetch_around_multi_room(...) — combos mode (on_combo save).
2. ADDRESS-ENRICH (если enrich_address=True): для yandex-листингов без номера дома
запрашиваем detail-страницу и извлекаем полный адрес из <title>.
Инжекция (#2135 F2): config/matcher/shutdown_requested + продуктовые хелперы
(record_yandex_price_history / extract_address_from_title / address_has_house_number)
через enrichment (EnrichmentJobs) вместо прямых импортов app.services.yandex_*.
Возвращает YandexCitySweepCounters. mark_done вызывается ВСЕГДА (кроме cancel/ban/fatal).
"""
# EKB center — единственный anchor для citywide combos sweep.
# anchors-параметр принимается для backward-compat тестов.
if anchors is not None:
_anchors: list[tuple[float, float, str]] = anchors
else:
_anchors = [(56.8400, 60.6050, "Центр (combos)")]
# city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
_loc = get_city_location(city_slug)
# #262 wave 2: yandex_rgid у CityLocation Optional — mikhaylovsk (Нижнесергинский
# р-н) отсутствует в гео-базе Яндекс.Недвижимости вообще (единственный там —
# ставропольский Михайловск, rgid 586221, подставлять чужой rgid нельзя). Известный
# city_slug БЕЗ подтверждённого yandex_rgid — explicit fail, а НЕ молчаливый fallback
# на ЕКБ rgid (YandexRealtyScraper(city_rgid=None) конструктор молча берёт ЕКБ).
# scrape_schedules-сиды (262_) НЕ заводят yandex_city_sweep_<city> для городов без
# rgid — этот раздел не должен исполняться в штатной эксплуатации.
if _loc is not None and _loc.yandex_rgid is None:
raise ValueError(
f"run_yandex_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но "
"yandex_rgid не подтверждён (None) — отказ от молчаливого fallback на ЕКБ "
"rgid. Город отсутствует в гео-базе Яндекса — не заводи yandex_city_sweep_"
"<city> schedule для него вовсе."
)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
_rooms_list = rooms_list or list(ROOM_PATH.keys())
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
# newFlat-сегменты: оба прохода (vtorichka + novostroyki) по одним combos.
_segments = segments or ["NO", "YES"]
counters = YandexCitySweepCounters(anchors_total=len(_anchors))
inter_anchor_delay = request_delay_sec if request_delay_sec is not None else 7.0
enrich_delay = request_delay_sec if request_delay_sec is not None else 3.0
_resolved_delay = request_delay_sec if request_delay_sec is not None else 9.0
consecutive_failures = 0
yandex_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
# #2625: run-level счётчики gate-API "структура извлечена" (сумма по всем
# anchor'ам — у каждого свой YandexRealtyScraper). Если ВСЕ попытки прогона
# провалились — капча/тарпит/смена API, а не честная пустая выдача (см.
# проверку перед финальным mark_done ниже).
yandex_gate_attempts = 0
yandex_gate_failures = 0
# Вычисляем watchdog-таймаут для combos-режима (центр, anchors=None).
_num_combos = len(_rooms_list) * len(_price_ranges)
if anchors is None and _num_combos > 0:
_sweep_timeout = max(
ANCHOR_TIMEOUT_SEC,
len(_segments)
* _num_combos
* pages_per_anchor
* (_resolved_delay + _YANDEX_COMBOS_PER_FETCH_S)
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
)
else:
# Explicit anchors (backward-compat тесты или ручной override) — legacy timeout.
_sweep_timeout = ANCHOR_TIMEOUT_SEC
# Прокси для address-enrich curl_cffi сессии (зеркало yandex_address_backfill).
_proxy_url = config.scraper_proxy_url
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
# ── Checkpoint/resume (#3074): combo — естественная единица обхода ────────
# Ключ чекпоинта — combo_label ("сегмент/комнатность:lo:hi"), якоря в нём нет,
# поэтому подхват включается ТОЛЬКО при единственном якоре (штатный прод-режим:
# и ЕКБ-центр, и city-свипы — ровно один anchor). Multi-anchor — легаси/ручной
# режим, у него один combo_label повторяется на каждом якоре и пропуск был бы
# пропуском ЧУЖИХ якорей.
skip_combos: set[str] = set()
if resume_run_id is not None and len(_anchors) == 1:
_prev_row = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev_row is not None and _prev_row.counters:
_prev_counters: dict = (
_prev_row.counters if isinstance(_prev_row.counters, dict) else {}
)
skip_combos = set(_prev_counters.get("done_buckets", []))
logger.info(
"yandex-sweep run_id=%d: resuming from run %s%d combos already done",
run_id,
resume_run_id,
len(skip_combos),
)
elif resume_run_id is not None:
logger.warning(
"yandex-sweep run_id=%d: resume от run %s ОТКЛОНЁН — %d якорей (>1), "
"combo-чекпоинт применим только к единственному якорю",
run_id,
resume_run_id,
len(_anchors),
)
done_combos: set[str] = set(skip_combos)
try:
for idx, (lat, lon, name) in enumerate(_anchors, start=1):
# ── Cooperative cancel ───────────────────────────────────────────
if runs.is_cancelled(db, run_id):
logger.info(
"yandex-sweep run_id=%d: cancelled at anchor #%d/%d (%s)",
run_id,
idx,
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
return counters
elif shutdown_requested():
# SIGTERM-drain: чистый стоп на границе anchor'а → mark_done(partial).
logger.info(
"yandex-sweep run_id=%d: SIGTERM-drain — stopping at anchor #%d/%d (%s)",
run_id,
idx,
len(_anchors),
name,
)
# #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)) —
# см. там же. done_buckets пишет combo-heartbeat, jsonb-мерж их сохраняет.
_drain = {**counters.to_dict(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
logger.info(
"yandex-sweep run_id=%d center-combos #%d/%d (%s, %.4f, %.4f) "
"radius_m=%d rooms=%d price_ranges=%d",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
radius_m,
len(_rooms_list),
len(_price_ranges),
)
# ── Per-anchor phases под watchdog-таймаутом ────────────────────
anchor_lots: list[ScrapedLot] = []
_anchor_timed_out = False
_lat, _lon, _name = lat, lon, name
_combo_lots_accumulator: list[ScrapedLot] = anchor_lots
async def _yandex_anchor_phases(
_a_lat: float = _lat,
_a_lon: float = _lon,
_a_name: str = _name,
_al: list[ScrapedLot] = _combo_lots_accumulator,
) -> None:
"""Все фазы одного anchor'а (SERP + address-enrich)."""
nonlocal consecutive_failures, yandex_rotations_done
nonlocal yandex_gate_attempts, yandex_gate_failures
# ── Phase 1+2: SERP + инкрементальный save per-combo ──────
def _on_combo(
combo_label: str,
new_lots: list[ScrapedLot],
_accumulator: list[ScrapedLot] = _al,
) -> None:
"""Callback: после каждого ПРОЙДЕННОГО combo (и пустого — #3074).
В чекпоинт done_combos (и, соответственно, в heartbeat
done_buckets, откуда resume берёт skip_combos) попадает
ТОЛЬКО combo, чьи лоты реально сохранены (#3170). Отказ
save_listings перехвачен ниже и прогон продолжается — но
отметить combo пройденным ДО успешного save значило бы, что
следующий прогон пропустит его НАВСЕГДА, причём молча: прогон
завершится штатно. Тот же инвариант, что у страницы в
run_avito_newbuilding_sweep (_saved_ok), якоря в
run_avito_city_sweep (_anchor_ok) и бакета в run_cian_full_load
(_mark_bucket). Heartbeat пишем в любом случае (мерж jsonb:
другие heartbeat'ы без ключа его не затирают) — иначе
reap_zombies посчитает живой прогон мёртвым.
"""
_saved_ok = False
if not new_lots:
_saved_ok = True # пустая выдача — тоже валидно пройденный combo
else:
_accumulator.extend(new_lots)
counters.lots_fetched += len(new_lots)
try:
ins, upd = save_listings(
db,
new_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += ins
counters.lots_updated += upd
_saved_ok = True
except Exception as save_exc:
counters.errors_count += 1
logger.warning(
"yandex-sweep run_id=%d combo %s: save_listings failed: %s",
run_id,
combo_label,
save_exc,
)
try:
db.rollback()
except Exception:
pass
if _saved_ok:
done_combos.add(combo_label)
runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(done_combos)}
)
logger.debug(
"yandex-sweep run_id=%d combo %s: saved %d lots "
"(ins=%d upd=%d total_fetched=%d)",
run_id,
combo_label,
len(new_lots),
counters.lots_inserted,
counters.lots_updated,
counters.lots_fetched,
)
async with YandexRealtyScraper(
config,
proxy_provider=proxy_provider,
city_rgid=(_loc.yandex_rgid if _loc else None),
) as scraper:
await scraper.fetch_around_multi_room(
_a_lat,
_a_lon,
radius_m,
max_pages=pages_per_anchor,
rooms_list=_rooms_list,
price_ranges=_price_ranges,
segments=_segments,
on_combo=_on_combo,
skip_combos=skip_combos or None,
)
# #2625: аккумулируем run-level gate-API attempts/failures.
yandex_gate_attempts += scraper.gate_fetch_attempts
yandex_gate_failures += scraper.gate_fetch_failures
# _al накоплен on_combo; save уже вызван per-combo.
# Price-history из gate price.previous/trend (graceful — провал
# истории НЕ должен ронять сейв листингов).
if _al:
try:
counters.price_history_rows += enrichment.record_yandex_price_history(
db, _al
)
except Exception as ph_exc:
logger.warning(
"yandex-sweep run_id=%d: price-history failed: %s",
run_id,
ph_exc,
)
try:
db.rollback()
except Exception:
pass
consecutive_failures = 0
logger.info(
"yandex-sweep run_id=%d center-combos %s: fetched=%d ins=%d upd=%d",
run_id,
_a_name,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
)
# ── Phase 3: address-enrich ────────────────────────────────
if not (enrich_address and _al):
return
source_ids = [lot.source_id for lot in _al if lot.source_id]
if not source_ids:
return
id_rows = (
db.execute(
text("""
SELECT id, source_id, address, source_url
FROM listings
WHERE source = 'yandex'
AND source_id = ANY(CAST(:ids AS text[]))
AND address IS NOT NULL
AND NOT (address ~ ',\\s*\\d+')
AND source_url IS NOT NULL
"""),
{"ids": source_ids},
)
.mappings()
.all()
)
items = [dict(r) for r in id_rows]
counters.address_attempted += len(items)
if items:
# #1848: rotation tracking для Yandex address-enrich (stuck-proxy)
_yandex_enrich_consec_fail = 0
_yandex_enrich_abort = 3 # abort address-enrich после N подряд
async with AsyncSession(
impersonate=DEFAULT_IMPERSONATE,
timeout=30.0,
proxies=_proxies,
headers={"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8"},
) as enrich_session:
eidx = 0
while eidx < len(items):
item = items[eidx]
lid: int = item["id"]
old_addr: str = item["address"]
url: str = item["source_url"]
try:
resp = await enrich_session.get(url, allow_redirects=True)
if resp.status_code not in {200}:
logger.warning(
"yandex-sweep run_id=%d address-enrich:"
" HTTP %d listing_id=%d",
run_id,
resp.status_code,
lid,
)
counters.address_failed += 1
# 503/429 = прокси заблокирован → считаем как failure
if resp.status_code in {429, 503}:
_yandex_enrich_consec_fail += 1
else:
_yandex_enrich_consec_fail = 0
else:
new_addr = enrichment.extract_address_from_title(resp.text)
if (
new_addr is None
or new_addr == old_addr
or not enrichment.address_has_house_number(new_addr)
):
pass
else:
try:
db.execute(
text("""
UPDATE listings
SET address = :addr,
geocode_tried_at = NULL
WHERE id = CAST(:id AS bigint)
"""),
{"addr": new_addr, "id": lid},
)
db.commit()
counters.address_enriched += 1
logger.info(
"yandex-sweep run_id=%d: "
"address updated listing_id=%d "
"%r -> %r",
run_id,
lid,
old_addr,
new_addr,
)
except Exception as upd_exc:
counters.address_failed += 1
counters.errors_count += 1
logger.warning(
"yandex-sweep run_id=%d: "
"address DB update failed "
"listing_id=%d: %s",
run_id,
lid,
upd_exc,
)
try:
db.rollback()
except Exception:
pass
_yandex_enrich_consec_fail = 0
except Exception as fetch_exc:
counters.address_failed += 1
_yandex_enrich_consec_fail += 1
logger.warning(
"yandex-sweep run_id=%d: address-enrich "
"fetch failed listing_id=%d "
"(consecutive=%d): %s",
run_id,
lid,
_yandex_enrich_consec_fail,
fetch_exc,
)
# Ротация Yandex-прокси при stuck/ban (#1848)
if _yandex_enrich_consec_fail >= _yandex_enrich_abort:
rotated, yandex_rotations_done = await _try_rotate_within_budget(
config,
reason=(
f"yandex enrich consecutive={_yandex_enrich_consec_fail}"
),
rotations_done=yandex_rotations_done,
source="yandex",
)
if rotated:
_yandex_enrich_consec_fail = 0
logger.info(
"yandex-sweep run_id=%d: enrich ROTATE — "
"IP changed, reset consecutive "
"(rotation #%d/%d)",
run_id,
yandex_rotations_done,
config.yandex_proxy_max_rotations,
)
# retry текущего item после ротации
continue
logger.error(
"yandex-sweep run_id=%d: address-enrich ABORT — "
"%d consecutive failures (proxy likely stuck/banned)",
run_id,
_yandex_enrich_consec_fail,
)
break
if eidx < len(items) - 1:
await asyncio.sleep(enrich_delay)
eidx += 1
logger.info(
"yandex-sweep run_id=%d anchor %s: address enrich=%d/%d failed=%d",
run_id,
_a_name,
counters.address_enriched,
counters.address_attempted,
counters.address_failed,
)
try:
await asyncio.wait_for(_yandex_anchor_phases(), timeout=_sweep_timeout)
except TimeoutError:
logger.warning(
"yandex-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) "
"timed out after %.0fs — skipping",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
_sweep_timeout,
)
counters.errors_count += 1
consecutive_failures += 1
_anchor_timed_out = True
if consecutive_failures >= YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES:
logger.error(
"yandex-sweep run_id=%d ABORT at anchor #%d/%d (%s) — "
"%d consecutive failures (last: timeout): "
"IP likely blocked or proxy hung",
run_id,
idx,
len(_anchors),
name,
consecutive_failures,
)
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_banned(
db,
run_id,
f"yandex sweep aborted: {consecutive_failures} consecutive "
f"anchor failures (last: anchor timeout {_sweep_timeout:.0f}s)",
counters.to_dict(),
)
return counters
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
if idx < len(_anchors):
await asyncio.sleep(inter_anchor_delay)
continue
except Exception as e:
# YandexRealtyScraper не propagate'ит block/rate exceptions — ловим broad.
logger.exception("yandex-sweep run_id=%d: anchor %s SERP failed", run_id, name)
counters.errors_count += 1
consecutive_failures += 1
if consecutive_failures >= YANDEX_SWEEP_MAX_CONSECUTIVE_FAILURES:
logger.error(
"yandex-sweep run_id=%d ABORT at anchor #%d/%d (%s) — "
"%d consecutive failures (IP likely blocked): %s",
run_id,
idx,
len(_anchors),
name,
consecutive_failures,
e,
)
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_banned(
db,
run_id,
f"yandex sweep aborted: {consecutive_failures} consecutive "
f"anchor failures (last: {e})",
counters.to_dict(),
)
return counters
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
# Пауза перед следующим anchor'ом даже при ошибке.
if idx < len(_anchors):
await asyncio.sleep(inter_anchor_delay)
continue
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
# Доп. пауза между anchor'ами (поверх per-page sleep внутри scraper'а).
if idx < len(_anchors):
await asyncio.sleep(inter_anchor_delay)
# #2625: если ВСЕ gate-API попытки прогона не смогли извлечь структуру
# (payload/pager) — капча/тарпит/смена API Yandex (детект-маркер: tarpit
# status=0 / JSON-ошибка / gate-error-payload / отсутствие
# response.search.offers на каждом запросе), а не честная пустая выдача
# (та даёт валидный payload с entities=[] и НЕ инкрементирует
# gate_fetch_failures). Один заблокированный anchor среди успешных НЕ
# триггерит banned (yandex_gate_failures < attempts) — анти-флап. banned
# делает статус доступным для внешнего триггера ротации IP (#2611) — сама
# ротация здесь не вызывается (см. issue #2625 скоуп-гард).
if yandex_gate_attempts > 0 and yandex_gate_failures == yandex_gate_attempts:
logger.error(
"yandex-sweep run_id=%d: all %d gate-API attempts failed to extract "
"structure (captcha/tarpit/API-change suspected) — marking banned (#2625)",
run_id,
yandex_gate_attempts,
)
runs.mark_banned(
db,
run_id,
f"yandex sweep: all {yandex_gate_attempts} gate-API attempts failed "
"to extract structure (captcha/tarpit suspected, #2625)",
counters.to_dict(),
)
return counters
runs.mark_done(db, run_id, counters.to_dict())
logger.info(
"yandex-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) "
"address=%d/%d failed=%d errors=%d",
run_id,
counters.anchors_done,
counters.anchors_total,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.address_enriched,
counters.address_attempted,
counters.address_failed,
counters.errors_count,
)
return counters
except Exception as exc:
logger.exception("yandex-sweep run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
# ── Cian city sweep (#860) ──────────────────────────────────────────────────
@dataclass
class CianCitySweepCounters:
"""Aggregate counters для Cian city sweep run.
Поля симметричны CitySweepCounters, но без imv_* (у Cian нет IMV-фазы).
detail_* — обогащение через cian_detail (вкл. price-history).
houses_* — обогащение newbuilding/ЖК через cian_newbuilding.
"""
anchors_total: int = 0
anchors_done: int = 0
lots_fetched: int = 0
lots_dropped_secondary: int = 0 # вторичка, отброшенная при newbuilding_only=True
lots_inserted: int = 0
lots_updated: int = 0
detail_attempted: int = 0
detail_enriched: int = 0
detail_failed: int = 0
houses_attempted: int = 0
houses_enriched: int = 0
houses_failed: int = 0
errors_count: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
# Defensive abort threshold: если N подряд anchor'ов упали с исключением
# (не просто вернули пустой результат) — прерываем sweep.
CIAN_SWEEP_MAX_CONSECUTIVE_FAILURES = 3
async def run_cian_city_sweep(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
anchors: list[tuple[float, float, str]] | None = None,
city_slug: str | None = None,
radius_m: int = 1500,
pages_per_anchor: int = 3,
request_delay_sec: float = 5.0,
detail_top_n: int = 10,
enrich_houses: bool = True,
newbuilding_only: bool = True,
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
) -> CianCitySweepCounters:
"""Cian newbuilding city sweep: SERP → detail(+price-history) → newbuilding/houses.
Симметрично run_avito_city_sweep. Использует EKB_ANCHORS (общий список).
newbuilding_only (default True): в SERP-фазе сохраняются только лоты с
listing_segment == "novostroyki" — вторичку отбрасываем, ею авторитетно владеет
run_cian_full_load (exhaustive региональный сбор).
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
прямых импортов app.* (см. scraper_kit.contracts).
Cooperative cancel: runs.is_cancelled(db, run_id) перед каждым anchor.
mark_done вызывается ВСЕГДА (finally outer).
"""
_anchors = anchors if anchors is not None else EKB_ANCHORS
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
# Замер за 60 дней: 65 прогонов, среднее 35 минут, максимум 72, две отмены
# деплоем. Пятиминутного дренажа (#3029) на такие прогоны не хватает, и
# убитый на 35-й минуте сбор начинается заново с первого якоря.
#
# Ключ — ИМЯ якоря, а не индекс: состав списка зависит от `city_slug`
# (областные свипы идут по своим наборам), позиция между городами не
# устойчива. По той же причине гарда по числу якорей не нужна — в отличие от
# combo-чекпоинта яндекса, где ключ якоря не содержал.
_skip_anchors: set[str] = set()
if resume_run_id is not None:
_prev = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev is not None and _prev.counters:
_pc: dict = _prev.counters if isinstance(_prev.counters, dict) else {}
_skip_anchors = set(_pc.get("done_buckets", []))
logger.info(
"cian-sweep run_id=%d: resuming from run %s%d якорей уже пройдено",
run_id,
resume_run_id,
len(_skip_anchors),
)
_done_anchors: set[str] = set(_skip_anchors)
# city_slug (#12): region_id города-цели → CianScraper.city_region_id скоупит SERP
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
counters = CianCitySweepCounters(anchors_total=len(_anchors))
consecutive_failures = 0
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
# #2625: run-level счётчики Redux-state extraction (сумма по всем anchor'ам —
# у каждого anchor'а свой CianScraper). Если ВСЕ вызовы extract_state за прогон
# провалились — это капча/смена вёрстки, а не честная пустая выдача (см. проверку
# перед финальным mark_done ниже).
cian_state_attempts = 0
cian_state_failures = 0
# #2160: масштабируемый watchdog-таймаут для cian anchor'а. При proxy-pool browser каждый
# SERP-фетч 13-45s (camoufox relaunch), фиксированный ANCHOR_TIMEOUT_SEC=240 гильотинит
# якорь в SERP-фазе. См. _cian_anchor_timeout_s + константы выше.
_cian_anchor_timeout = _cian_anchor_timeout_s(
pages_per_anchor, request_delay_sec, detail_top_n, enrich_houses
)
logger.info(
"cian-sweep run_id=%d: anchor_timeout=%.0fs "
"(base=%d, serp=%d×%d×(%.0f+%.0f)s + detail_top_n=%d×%.0fs + houses=%.0fs)",
run_id,
_cian_anchor_timeout,
ANCHOR_TIMEOUT_SEC,
_CIAN_ROOM_BUCKETS,
pages_per_anchor,
request_delay_sec,
_CIAN_PER_SERP_FETCH_S,
detail_top_n,
_CIAN_PER_DETAIL_S,
_CIAN_HOUSES_BUDGET_S if enrich_houses else 0.0,
)
try:
for idx, (lat, lon, name) in enumerate(_anchors, start=1):
if name in _skip_anchors:
# #3074: якорь собран предыдущим оборванным прогоном — ни одного
# HTTP-запроса. Счётчик двигаем, чтобы `anchors_done` продолжал
# означать «докуда дошли по списку», а не «сколько собрал этот run».
counters.anchors_done = idx
logger.info(
"cian-sweep run_id=%d: anchor #%d/%d (%s) пропущен — есть в чекпоинте",
run_id,
idx,
len(_anchors),
name,
)
continue
# Cooperative cancel перед каждым anchor
if runs.is_cancelled(db, run_id):
logger.info(
"cian-sweep run_id=%d: cancelled at anchor #%d/%d (%s)",
run_id,
idx,
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
return counters
elif shutdown_requested():
# SIGTERM-drain: чистый стоп на границе anchor'а → mark_done(partial).
logger.info(
"cian-sweep run_id=%d: SIGTERM-drain — stopping at anchor #%d/%d (%s)",
run_id,
idx,
len(_anchors),
name,
)
# #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)) —
# см. там же. done_buckets пишет end-of-anchor heartbeat, jsonb-мерж хранит.
_drain = {**counters.to_dict(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
logger.info(
"cian-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
)
# ── Per-anchor phases под watchdog-таймаутом ────────────────────
anchor_lots: list[ScrapedLot] = []
_c_lat, _c_lon, _c_name = lat, lon, name
async def _cian_anchor_phases(
_a_lat: float = _c_lat,
_a_lon: float = _c_lon,
_a_name: str = _c_name,
) -> None:
"""Все фазы одного cian anchor'а (SERP + detail + houses)."""
nonlocal anchor_lots, consecutive_failures, cian_rotations_done
nonlocal cian_state_attempts, cian_state_failures
# ── Phase 1+2: SERP + save ─────────────────────────────────
async with CianScraper(
config,
proxy_provider=proxy_provider,
city_region_id=(_loc.cian_region_id if _loc else None),
) as scraper:
anchor_lots = await scraper.fetch_around_multi_room(
_a_lat,
_a_lon,
radius_m,
pages=pages_per_anchor,
)
# #2625: аккумулируем run-level extraction attempts/failures.
cian_state_attempts += scraper.state_extraction_attempts
cian_state_failures += scraper.state_extraction_failures
counters.lots_fetched += len(anchor_lots)
# ── Фильтр вторички (newbuilding_only) ─────────────────────
if newbuilding_only:
_before = len(anchor_lots)
anchor_lots = [
lot for lot in anchor_lots if lot.listing_segment == "novostroyki"
]
counters.lots_dropped_secondary += _before - len(anchor_lots)
if anchor_lots:
inserted, updated = save_listings(
db,
anchor_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += inserted
counters.lots_updated += updated
consecutive_failures = 0
logger.info(
"cian-sweep run_id=%d anchor %s: SERP fetched=%d nb_kept=%d "
"dropped_secondary=%d ins=%d upd=%d",
run_id,
_a_name,
counters.lots_fetched,
len(anchor_lots),
counters.lots_dropped_secondary,
counters.lots_inserted,
counters.lots_updated,
)
# ── Phase 3: detail enrichment (top-N) ────────────────────
if detail_top_n > 0:
priority_rows = (
db.execute(
text("""
SELECT id, source_url
FROM listings
WHERE source = 'cian'
AND source_url IS NOT NULL
AND detail_enriched_at IS NULL
AND scraped_at > NOW() - INTERVAL '2 hours'
ORDER BY scraped_at DESC NULLS LAST
LIMIT :lim
"""),
{"lim": detail_top_n},
)
.mappings()
.all()
)
# #1848: Cian bans/stuck-proxy tracking + rotation (зеркало avito)
_cian_detail_consec_failures = 0
_cian_detail_abort = 3 # abort detail-фазы после N подряд
didx = 0
while didx < len(priority_rows):
row = priority_rows[didx]
listing_id: int = row["id"]
source_url: str = row["source_url"]
counters.detail_attempted += 1
try:
cian_enrichment = await cian_fetch_detail(
source_url, config=config, proxy_provider=proxy_provider
)
if cian_enrichment is not None:
cian_save_detail_enrichment(
db, listing_id, cian_enrichment, matcher=matcher
)
counters.detail_enriched += 1
else:
counters.detail_failed += 1
logger.warning(
"cian-sweep run_id=%d: detail returned None for "
"listing_id=%d url=%s",
run_id,
listing_id,
source_url,
)
_cian_detail_consec_failures = 0
except Exception as exc:
counters.detail_failed += 1
counters.errors_count += 1
_cian_detail_consec_failures += 1
logger.warning(
"cian-sweep run_id=%d: detail failed listing_id=%d "
"(consecutive=%d): %s",
run_id,
listing_id,
_cian_detail_consec_failures,
exc,
)
if _cian_detail_consec_failures >= _cian_detail_abort:
# Попытка ротации Cian-прокси перед abort'ом (#1848)
rotated, cian_rotations_done = await _try_rotate_within_budget(
config,
reason=(
f"cian detail consecutive={_cian_detail_consec_failures}"
),
rotations_done=cian_rotations_done,
source="cian",
)
if rotated:
_cian_detail_consec_failures = 0
logger.info(
"cian-sweep run_id=%d: detail ROTATE — "
"IP changed, reset consecutive "
"(rotation #%d/%d)",
run_id,
cian_rotations_done,
config.cian_proxy_max_rotations,
)
# retry текущего detail (не инкрементируем didx)
continue
logger.error(
"cian-sweep run_id=%d: detail ABORT — "
"%d consecutive failures (proxy likely banned/stuck)",
run_id,
_cian_detail_consec_failures,
)
break
if didx < len(priority_rows) - 1:
jitter = random.uniform(0.8, 1.2)
await asyncio.sleep(request_delay_sec * jitter)
didx += 1
logger.info(
"cian-sweep run_id=%d anchor %s: detail=%d/%d failed=%d",
run_id,
_a_name,
counters.detail_enriched,
counters.detail_attempted,
counters.detail_failed,
)
# ── Phase 4: newbuilding/houses enrichment ─────────────────
if not (enrich_houses and anchor_lots):
return
cian_nb_ext_ids = {
lot.house_ext_id
for lot in anchor_lots
if lot.house_source == "cian_newbuilding" and lot.house_ext_id
}
if not cian_nb_ext_ids:
return
nb_id_list = list(cian_nb_ext_ids)
try:
house_rows = (
db.execute(
text("""
SELECT h.id, h.cian_zhk_url
FROM houses h
JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'cian_newbuilding'
AND hs.ext_id = ANY(CAST(:ids AS text[]))
AND h.cian_zhk_url IS NOT NULL
"""),
{"ids": nb_id_list},
)
.mappings()
.all()
)
except Exception as exc:
logger.exception(
"cian-sweep run_id=%d anchor %s: houses DB query failed — "
"skipping houses phase (%d ids): %s",
run_id,
_a_name,
len(nb_id_list),
exc,
)
counters.houses_failed += len(nb_id_list)
try:
db.rollback()
except Exception:
pass
return
for hidx, hrow in enumerate(house_rows):
house_id_val: int = hrow["id"]
zhk_url: str = hrow["cian_zhk_url"]
counters.houses_attempted += 1
try:
nb_enrichment = await fetch_newbuilding(zhk_url, config=config)
if nb_enrichment is not None:
save_newbuilding_enrichment(db, house_id_val, nb_enrichment)
counters.houses_enriched += 1
else:
counters.houses_failed += 1
logger.warning(
"cian-sweep run_id=%d: newbuilding returned None "
"house_id=%d url=%s",
run_id,
house_id_val,
zhk_url,
)
except Exception as exc:
counters.houses_failed += 1
counters.errors_count += 1
logger.warning(
"cian-sweep run_id=%d: houses failed house_id=%d: %s",
run_id,
house_id_val,
exc,
)
try:
db.rollback()
except Exception:
pass
if hidx < len(house_rows) - 1:
await asyncio.sleep(request_delay_sec)
logger.info(
"cian-sweep run_id=%d anchor %s: houses=%d/%d failed=%d",
run_id,
_a_name,
counters.houses_enriched,
counters.houses_attempted,
counters.houses_failed,
)
try:
await asyncio.wait_for(_cian_anchor_phases(), timeout=_cian_anchor_timeout)
except TimeoutError:
logger.warning(
"cian-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) "
"timed out after %ds — skipping",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
_cian_anchor_timeout,
)
counters.errors_count += 1
consecutive_failures += 1
if consecutive_failures >= CIAN_SWEEP_MAX_CONSECUTIVE_FAILURES:
logger.error(
"cian-sweep run_id=%d ABORT at anchor #%d/%d (%s) — "
"%d consecutive failures (last: timeout): "
"IP likely blocked or proxy hung",
run_id,
idx,
len(_anchors),
name,
consecutive_failures,
)
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_banned(
db,
run_id,
f"cian sweep aborted: {consecutive_failures} consecutive "
f"anchor failures (last: anchor timeout {_cian_anchor_timeout:.0f}s)",
counters.to_dict(),
)
return counters
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
continue
except Exception as e:
logger.exception("cian-sweep run_id=%d: anchor %s SERP failed", run_id, name)
counters.errors_count += 1
consecutive_failures += 1
if consecutive_failures >= CIAN_SWEEP_MAX_CONSECUTIVE_FAILURES:
logger.error(
"cian-sweep run_id=%d ABORT at anchor #%d/%d (%s) — "
"%d consecutive SERP failures (IP likely blocked): %s",
run_id,
idx,
len(_anchors),
name,
consecutive_failures,
e,
)
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_banned(
db,
run_id,
f"cian sweep aborted: {consecutive_failures} consecutive "
f"anchor SERP failures (last: {e})",
counters.to_dict(),
)
return counters
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
continue
counters.anchors_done = idx
# #3074: сюда попадают ТОЛЬКО якоря, пройденные до конца. Все ветки
# отказа выше либо `return`, либо `continue` — в чекпоинт они не
# заходят. Разница с avito-свипом, где успех и неудача сходились в
# одной строке и потребовался отдельный флаг: здесь граница уже
# проведена самим потоком управления, и её достаточно не нарушать.
#
# Записать упавший якорь пройденным значило бы, что следующий прогон
# пропустит его навсегда — молча, потому что прогон завершится
# штатно, просто часть города не соберётся.
_done_anchors.add(name)
runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)}
)
# Пауза между anchor'ами (поверх per-request sleep внутри scraper'а)
if idx < len(_anchors):
await asyncio.sleep(request_delay_sec)
# #2625: если ВСЕ SERP state-extraction попытки прогона провалились — это
# капча/смена вёрстки Cian (детект-маркер: extract_state вернул None на
# каждом запросе), а не честная пустая выдача (та даёт валидный state с
# offers=[] и НЕ инкрементирует state_extraction_failures). Один заблокированный
# anchor среди успешных НЕ триггерит banned (cian_state_failures < attempts) —
# анти-флап. banned делает статус доступным для внешнего триггера ротации
# IP (#2611) — сама ротация здесь не вызывается (см. issue #2625 скоуп-гард).
if cian_state_attempts > 0 and cian_state_failures == cian_state_attempts:
logger.error(
"cian-sweep run_id=%d: all %d SERP state-extraction attempts failed "
"(captcha/layout-change suspected) — marking banned (#2625)",
run_id,
cian_state_attempts,
)
runs.mark_banned(
db,
run_id,
f"cian sweep: all {cian_state_attempts} SERP state-extraction "
"attempts failed (captcha/layout-change suspected, #2625)",
counters.to_dict(),
)
return counters
runs.mark_done(db, run_id, counters.to_dict())
logger.info(
"cian-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) "
"detail=%d/%d houses=%d/%d errors=%d",
run_id,
counters.anchors_done,
counters.anchors_total,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.detail_enriched,
counters.detail_attempted,
counters.houses_enriched,
counters.houses_attempted,
counters.errors_count,
)
return counters
except Exception as exc:
logger.exception("cian-sweep run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
# ── Cian exhaustive full load (region-wide, без anchor'ов) ─────────────────
@dataclass
class CianFullLoadCounters:
"""Aggregate counters для run_cian_full_load.
Без anchor-полей — единственный региональный проход.
detail_* — опциональное обогащение detail-страниц (если enrich_detail=True).
"""
unique_fetched: int = 0
saved_inserted: int = 0
saved_updated: int = 0
detail_attempted: int = 0
detail_enriched: int = 0
detail_failed: int = 0
errors_count: int = 0
# Бакеты, отданные SERP-слоем как НЕполные (страница выпала / исключение
# проглочено / hard-cap): лоты сохранены, но в done_buckets бакет не попал.
# Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом
# логов, которые теряются при редеплое.
partial_buckets: int = 0
# Новостройки, отброшенные фильтром secondary_only ПОСЛЕ скачивания и разбора
# (#1781). Ровно тот же довод, что у partial_buckets выше: счётчик логировался
# (`dropped_nb=` в cian/serp.py), но не сохранялся, и ответить «сколько инвентаря
# выбрасывает полный обход» задним числом было нечем — логи за нужную дату уже
# ротировались. Заодно это цена вопроса при обсуждении secondary_only=False:
# страницы с этими лотами УЖЕ скачаны, лимит страниц и антибан-бюджет за них
# уже заплачены, выбрасывается только результат разбора.
dropped_novostroyki: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
async def run_cian_full_load(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
price_cap_per_bucket: int = 1400,
detail_top_n: int = 0,
request_delay_sec: float = 4.0,
enrich_detail: bool = False,
concurrency: int = 5,
resume_run_id: int | None = None,
region_code: int = DEFAULT_REGION_CODE,
secondary_only: bool = True,
) -> CianFullLoadCounters:
"""Exhaustive региональный сбор Cian ЕКБ вторички (БЕЗ anchor'ов).
Один проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное
деление) охватывает весь ЕКБ. Инкрементальный save: on_bucket коммитит каждый
leaf-бакет в БД сразу после сбора.
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
прямых импортов app.* (см. scraper_kit.contracts).
Cooperative cancel: runs.is_cancelled проверяется per-bucket.
"""
counters = CianFullLoadCounters()
# ── Checkpoint/resume: читаем done_buckets из прошлого run ───────────────
skip_set: set[str] = set()
if resume_run_id is not None:
_prev_row = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev_row is not None and _prev_row.counters:
_prev_counters: dict = (
_prev_row.counters if isinstance(_prev_row.counters, dict) else {}
)
skip_set = set(_prev_counters.get("done_buckets", []))
logger.info(
"cian-full-load run_id=%d: resuming from run %d%d buckets already done",
run_id,
resume_run_id,
len(skip_set),
)
done: set[str] = set(skip_set) # накапливаем завершённые бакеты этого прогона
def _mark_bucket(bucket_key: str, complete: bool) -> None:
"""В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем.
Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета,
поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск
частичного бакета на следующем прогоне означал бы, что его непрочитанные
страницы не перечитает уже никто, а счётчики покажут успех.
"""
if complete:
done.add(bucket_key)
return
counters.partial_buckets += 1
logger.warning(
"cian-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, "
"следующий прогон перечитает его целиком (partial_buckets=%d)",
run_id,
bucket_key,
counters.partial_buckets,
)
def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
"""Инкрементальный save после каждого leaf-бакета.
complete=False → лоты сохраняем, бакет в чекпоинт не пишем
(см. _mark_bucket). Дефолт True — для вызывающих без пагинации.
"""
nonlocal done
if runs.is_cancelled(db, run_id):
logger.info("cian-full-load run_id=%d: cancel detected in on_bucket", run_id)
raise RuntimeError("cancelled")
elif shutdown_requested():
# SIGTERM-drain: тот же sentinel-механизм, что и user-cancel, но отдельный
# маркер "shutdown" → handler финализирует mark_done(partial), не mark_failed.
logger.info(
"cian-full-load run_id=%d: SIGTERM-drain — stopping at bucket %s",
run_id,
bucket_key,
)
raise RuntimeError("shutdown")
if not lots:
_mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
return
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
skip_seen_today=config.scraper_skip_seen_today,
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
city=EKATERINBURG_CITY_NAME,
)
# save_listings вызывает db.commit() внутри — данные в БД сразу
counters.saved_inserted += inserted
counters.saved_updated += updated
counters.unique_fetched += len(lots)
_mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"cian-full-load run_id=%d: bucket %s saved ins=%d upd=%d total_unique=%d",
run_id,
bucket_key,
inserted,
updated,
counters.unique_fetched,
)
def _on_progress(unique_count: int) -> None:
"""Heartbeat per room-bucket (на случай пустых бакетов без on_bucket вызовов)."""
runs.update_heartbeat(db, run_id, counters.to_dict())
# #1949: фоновый heartbeat — обновляет heartbeat_at каждые 60s независимо от on_bucket.
async def _background_heartbeat() -> None:
"""Периодический heartbeat каждые 60s, не завязанный на прогресс бакетов."""
while True:
await asyncio.sleep(60.0)
try:
runs.update_heartbeat(db, run_id, counters.to_dict())
except Exception:
pass
_hb_task: asyncio.Task[None] | None = None
try:
_hb_task = asyncio.create_task(_background_heartbeat())
async with CianScraper(config, proxy_provider=proxy_provider) as scraper:
scraper.request_delay_sec = request_delay_sec
# #1949: per-fetch hard-cancel для browser-fetch'ей внутри bucket-gather.
_fetch_timeout = config.cian_full_load_per_fetch_timeout_s
if _fetch_timeout > 0 and scraper._browser is not None:
_orig_fetch = scraper._browser.fetch
async def _timed_fetch(
url: str,
_f: Any = _orig_fetch,
_t: float = _fetch_timeout,
_rid: int = run_id,
) -> str:
try:
return await asyncio.wait_for(_f(url), timeout=_t)
except TimeoutError:
logger.warning(
"cian-full-load run_id=%d: browser-fetch timed out "
"after %.0fs — url=%s",
_rid,
_t,
url,
)
raise
scraper._browser.fetch = _timed_fetch # type: ignore[method-assign]
logger.info(
"cian-full-load run_id=%d: per-fetch timeout %.0fs enabled",
run_id,
_fetch_timeout,
)
await scraper.fetch_all_secondary(
price_cap_per_bucket=price_cap_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=_on_bucket,
on_progress=_on_progress,
skip_buckets=skip_set if skip_set else None,
)
counters.dropped_novostroyki = getattr(scraper, "last_dropped_nb", 0)
logger.info(
"cian-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d dropped_novostroyki=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
counters.dropped_novostroyki,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
# ── Опциональный detail-энричмент ─────────────────────────────────────
if enrich_detail and detail_top_n > 0:
priority_rows = (
db.execute(
text("""
SELECT id, source_url
FROM listings
WHERE source = 'cian'
AND source_url IS NOT NULL
AND detail_enriched_at IS NULL
AND scraped_at > NOW() - INTERVAL '4 hours'
ORDER BY scraped_at DESC NULLS LAST
LIMIT :lim
"""),
{"lim": detail_top_n},
)
.mappings()
.all()
)
for didx, row in enumerate(priority_rows):
if runs.is_cancelled(db, run_id):
logger.info("cian-full-load run_id=%d: cancelled during detail enrich", run_id)
break
elif shutdown_requested():
logger.info(
"cian-full-load run_id=%d: SIGTERM-drain — stopping detail at #%d/%d",
run_id,
didx + 1,
len(priority_rows),
)
# #3355: не break. SERP-фаза тут ЦЕЛАЯ (done_buckets полные),
# обрезано только обогащение — но break уводил в обычный
# mark_done ниже, и оборванный прогон выглядел полным. Тот же
# sentinel, что в _on_bucket → handler `RuntimeError("shutdown")`
# ставит interrupted=1 и сохраняет done_buckets.
raise RuntimeError("shutdown")
listing_id: int = row["id"]
source_url: str = row["source_url"]
counters.detail_attempted += 1
try:
cian_enrichment = await cian_fetch_detail(
source_url, config=config, proxy_provider=proxy_provider
)
if cian_enrichment is not None:
cian_save_detail_enrichment(
db, listing_id, cian_enrichment, matcher=matcher
)
counters.detail_enriched += 1
else:
counters.detail_failed += 1
except Exception as exc:
counters.detail_failed += 1
counters.errors_count += 1
logger.warning(
"cian-full-load run_id=%d: detail failed listing_id=%d: %s",
run_id,
listing_id,
exc,
)
if didx < len(priority_rows) - 1:
await asyncio.sleep(request_delay_sec * random.uniform(0.8, 1.2))
logger.info(
"cian-full-load run_id=%d: detail done — enriched=%d/%d failed=%d",
run_id,
counters.detail_enriched,
counters.detail_attempted,
counters.detail_failed,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
# #2625: как в run_cian_city_sweep — все extract_state попытки провалились
# (за весь региональный проход, один scraper на весь run_cian_full_load) →
# капча/смена вёрстки, не честная пустая выдача → banned (внешний триггер
# ротации #2611 может сработать на этом статусе; сама ротация не вызывается).
if scraper.state_extraction_attempts > 0 and (
scraper.state_extraction_failures == scraper.state_extraction_attempts
):
logger.error(
"cian-full-load run_id=%d: all %d SERP state-extraction attempts "
"failed (captcha/layout-change suspected) — marking banned (#2625)",
run_id,
scraper.state_extraction_attempts,
)
runs.mark_banned(
db,
run_id,
f"cian full-load: all {scraper.state_extraction_attempts} SERP "
"state-extraction attempts failed (captcha/layout-change suspected, #2625)",
{**counters.to_dict(), "done_buckets": sorted(done)},
)
return counters
runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"cian-full-load run_id=%d done: unique=%d ins=%d upd=%d detail=%d/%d errors=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
counters.detail_enriched,
counters.detail_attempted,
counters.errors_count,
)
return counters
except NoProxyAvailableError as exc:
# #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан
# сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который
# его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930).
logger.error("cian-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
db,
run_id,
f"cian full load aborted: {exc}",
{**counters.to_dict(), "done_buckets": sorted(done)},
ban_kind=ban_kind_of_exception(exc),
)
return counters
except RuntimeError as exc:
# on_bucket кидает RuntimeError("cancelled") при cooperative cancel,
# RuntimeError("shutdown") при кооперативном SIGTERM-drain (#1182 Phase 3a).
if str(exc) == "cancelled":
logger.info(
"cian-full-load run_id=%d: cancelled — partial results unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
return counters
if str(exc) == "shutdown":
logger.info(
"cian-full-load run_id=%d: SIGTERM-drain — partial results unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
# #3355: та же метка дрейна, что у city-свипов (#3330/#3333). Без неё
# оборванный дрейном обход неотличим от полного: статус тот же 'done',
# а `_drained_done` в scheduler._resume_decision смотрит именно на неё —
# и done_buckets этого прогона не подхватывал никто.
runs.mark_done(
db,
run_id,
{**counters.to_dict(), "done_buckets": sorted(done), "interrupted": 1},
)
return counters
logger.exception("cian-full-load run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
except Exception as exc:
logger.exception("cian-full-load run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
finally:
# #1949: отменяем фоновый heartbeat при любом завершении (return / raise / cancel).
if _hb_task is not None:
_hb_task.cancel()
try:
await _hb_task
except asyncio.CancelledError:
pass
# ── Yandex exhaustive full load (region-wide, без anchor'ов) ──────────────────
@dataclass
class YandexFullLoadCounters:
"""Aggregate counters для run_yandex_full_load.
Без detail_* полей — Yandex full load не делает detail-обогащение.
"""
unique_fetched: int = 0
saved_inserted: int = 0
saved_updated: int = 0
price_history_rows: int = 0
errors_count: int = 0
# Бакеты, собранные ЧАСТИЧНО (probe провалился → degraded-пагинация): в
# чекпоинт не пишутся, следующий прогон перечитает их целиком.
partial_buckets: int = 0
# Подмножество partial_buckets: бакет неполон ПО ПОСТРОЕНИЮ (#3368) — total
# требует больше страниц, чем потолок, а бисекции делить его уже нечем.
# Отдельный счётчик, потому что лечение другое: не «повторить прогон», а
# снизить min_bracket / поднять потолок страниц.
capped_buckets: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
async def run_yandex_full_load(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
enrichment: EnrichmentJobs,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
price_cap_per_bucket: int = 500,
request_delay_sec: float = 2.0,
concurrency: int = 4,
resume_run_id: int | None = None,
region_code: int = DEFAULT_REGION_CODE,
) -> YandexFullLoadCounters:
"""Exhaustive региональный сбор Yandex ЕКБ вторички (БЕЗ anchor'ов).
Один проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное
деление) охватывает весь ЕКБ. Инкрементальный save: on_bucket коммитит каждый
leaf-бакет в БД сразу после сбора.
Инжекция (#2135 F2): config/matcher/enrichment/shutdown_requested приходят снаружи
вместо прямых импортов app.* (см. scraper_kit.contracts). record_yandex_price_history
зовётся через enrichment (EnrichmentJobs Protocol).
Cooperative cancel: runs.is_cancelled проверяется per-bucket.
"""
counters = YandexFullLoadCounters()
skip_set: set[str] = set()
if resume_run_id is not None:
_prev_row = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev_row is not None and _prev_row.counters:
_prev_counters: dict = (
_prev_row.counters if isinstance(_prev_row.counters, dict) else {}
)
skip_set = set(_prev_counters.get("done_buckets", []))
logger.info(
"yandex-full-load run_id=%d: resuming from run %d%d buckets already done",
run_id,
resume_run_id,
len(skip_set),
)
done: set[str] = set(skip_set)
def _mark_bucket(bucket_key: str, complete: bool) -> None:
"""В done-леджер пишем ТОЛЬКО полностью собранный бакет."""
if complete:
done.add(bucket_key)
return
counters.partial_buckets += 1
logger.warning(
"yandex-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, "
"следующий прогон перечитает его целиком (partial_buckets=%d)",
run_id,
bucket_key,
counters.partial_buckets,
)
def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
"""Инкрементальный save после каждого leaf-бакета.
complete=False (degraded-ветка бисекции) → в чекпоинт бакет не пишем,
следующий прогон перечитает полосу целиком. Дефолт True — для вызывающих
без пагинации. Тот же контракт, что у cian (см. run_cian_full_load).
"""
nonlocal done
if runs.is_cancelled(db, run_id):
logger.info("yandex-full-load run_id=%d: cancel detected in on_bucket", run_id)
raise RuntimeError("cancelled")
elif shutdown_requested():
logger.info(
"yandex-full-load run_id=%d: SIGTERM-drain — stopping at bucket %s",
run_id,
bucket_key,
)
raise RuntimeError("shutdown")
if not lots:
_mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
return
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
skip_seen_today=config.scraper_skip_seen_today,
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
city=EKATERINBURG_CITY_NAME,
)
# save_listings вызывает db.commit() внутри — данные в БД сразу
counters.saved_inserted += inserted
counters.saved_updated += updated
counters.unique_fetched += len(lots)
# Price-history из gate price.previous/trend (graceful — провал истории
# НЕ должен ронять сейв листингов/бакета).
try:
counters.price_history_rows += enrichment.record_yandex_price_history(db, lots)
except Exception as ph_exc:
logger.warning(
"yandex-full-load run_id=%d: price-history failed bucket=%s: %s",
run_id,
bucket_key,
ph_exc,
)
try:
db.rollback()
except Exception:
pass
_mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"yandex-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d",
run_id,
bucket_key,
inserted,
updated,
counters.unique_fetched,
)
def _on_progress(unique_count: int) -> None:
"""Heartbeat per room-bucket (на случай пустых бакетов без on_bucket вызовов)."""
runs.update_heartbeat(db, run_id, counters.to_dict())
try:
async with YandexRealtyScraper(config, proxy_provider=proxy_provider) as scraper:
scraper.request_delay_sec = request_delay_sec
try:
await scraper.fetch_all_secondary(
price_cap_per_bucket=price_cap_per_bucket,
concurrency=concurrency,
on_bucket=_on_bucket,
on_progress=_on_progress,
skip_buckets=skip_set if skip_set else None,
)
finally:
# #3368: leaf'ы, обрезанные потолком страниц (в чекпоинт не попали).
# В finally, а не после await: cancel/shutdown прилетают сюда
# RuntimeError'ом из _on_bucket, и присваивание после вызова
# пропускалось ровно в тех прогонах, чей итог и надо объяснить.
counters.capped_buckets = getattr(scraper, "capped_buckets", 0)
logger.info(
"yandex-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
# #2625: как в run_yandex_city_sweep — все gate-API попытки не извлекли
# структуру (за весь региональный проход, один scraper на весь
# run_yandex_full_load) → капча/тарпит/смена API, не честная пустая выдача
# → banned (внешний триггер ротации #2611 может сработать на этом статусе;
# сама ротация не вызывается).
if scraper.gate_fetch_attempts > 0 and (
scraper.gate_fetch_failures == scraper.gate_fetch_attempts
):
logger.error(
"yandex-full-load run_id=%d: all %d gate-API attempts failed to "
"extract structure (captcha/tarpit/API-change suspected) — "
"marking banned (#2625)",
run_id,
scraper.gate_fetch_attempts,
)
runs.mark_banned(
db,
run_id,
f"yandex full-load: all {scraper.gate_fetch_attempts} gate-API "
"attempts failed to extract structure (captcha/tarpit suspected, #2625)",
{**counters.to_dict(), "done_buckets": sorted(done)},
)
return counters
runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"yandex-full-load run_id=%d done: unique=%d ins=%d upd=%d errors=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
counters.errors_count,
)
return counters
except NoProxyAvailableError as exc:
# #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан
# сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который
# его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930).
logger.error("yandex-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
db,
run_id,
f"yandex full load aborted: {exc}",
{**counters.to_dict(), "done_buckets": sorted(done)},
ban_kind=ban_kind_of_exception(exc),
)
return counters
except RuntimeError as exc:
# on_bucket кидает RuntimeError("cancelled") при cooperative cancel,
# RuntimeError("shutdown") при кооперативном SIGTERM-drain (#1182 Phase 3a).
if str(exc) == "cancelled":
logger.info(
"yandex-full-load run_id=%d: cancelled — partial results unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
return counters
if str(exc) == "shutdown":
logger.info(
"yandex-full-load run_id=%d: SIGTERM-drain — partial results "
"unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
# #3355: та же метка дрейна, что у city-свипов (#3330/#3333). Без неё
# оборванный дрейном обход неотличим от полного: статус тот же 'done',
# а `_drained_done` в scheduler._resume_decision смотрит именно на неё —
# и done_buckets этого прогона не подхватывал никто.
runs.mark_done(
db,
run_id,
{**counters.to_dict(), "done_buckets": sorted(done), "interrupted": 1},
)
return counters
logger.exception("yandex-full-load run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
except Exception as exc:
logger.exception("yandex-full-load run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
# ── Avito exhaustive full load (citywide, без anchor'ов) ──────────────────────
@dataclass
class AvitoFullLoadCounters:
"""Aggregate counters для run_avito_full_load.
Зеркало YandexFullLoadCounters — citywide room×price bisection без anchor/detail.
"""
unique_fetched: int = 0
saved_inserted: int = 0
saved_updated: int = 0
errors_count: int = 0
# Бакеты, отданные SERP-слоем как НЕполные — см. CianFullLoadCounters.
partial_buckets: int = 0
# #3033: новостройки, отброшенные post-parse фильтром secondary_only. При родном
# фильтре вторички в URL ожидается ≈0; рост = путь перестал фильтровать (редирект
# на общую выдачу) — видно по счётчику, а не по тишине. Зеркало cian (#1781).
dropped_novostroyki: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
async def run_avito_full_load(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
price_cap_per_bucket: int = 1400,
request_delay_sec: float = 7.0,
concurrency: int = 5,
secondary_only: bool = True,
resume_run_id: int | None = None,
incremental_days: int | None = None,
region_code: int = DEFAULT_REGION_CODE,
) -> AvitoFullLoadCounters:
"""Exhaustive ИЛИ инкрементальный региональный сбор Avito ЕКБ вторички (БЕЗ anchor'ов).
Один проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное
деление ценового диапазона через `pmin`/`pmax`) охватывает весь ЕКБ.
incremental_days: если задан — shallow инкрементальный обход (since-cutoff).
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
прямых импортов app.* (см. scraper_kit.contracts).
proxy_provider (#2616): в отличие от run_avito_city_sweep/run_avito_newbuilding_sweep
(shared-browser, ручной override scraper._browser), здесь `AvitoScraper` реально
проходит через свой `__aenter__` (`async with AvitoScraper(...)`) — proxy_provider
здесь LOAD-BEARING: без него build_browser_fetcher(config, "avito") строит
BrowserFetcher без пула (env-fallback), ровно как run_cian_full_load передаёт
proxy_provider в CianScraper.
Cooperative cancel: runs.is_cancelled проверяется per-bucket.
AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned').
"""
counters = AvitoFullLoadCounters()
# ── Инкрементальный режим: since-cutoff для shallow обхода ────────────────
since: date | None = None
if incremental_days is not None:
since = date.today() - timedelta(days=incremental_days)
logger.info(
"avito-full-load run_id=%d: INCREMENTAL mode — since=%s (last %d days)",
run_id,
since.isoformat(),
incremental_days,
)
# ── Checkpoint/resume: читаем done_buckets из прошлого run ───────────────
skip_set: set[str] = set()
if resume_run_id is not None:
_prev_row = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev_row is not None and _prev_row.counters:
_prev_counters: dict = (
_prev_row.counters if isinstance(_prev_row.counters, dict) else {}
)
skip_set = set(_prev_counters.get("done_buckets", []))
logger.info(
"avito-full-load run_id=%d: resuming from run %d%d buckets already done",
run_id,
resume_run_id,
len(skip_set),
)
done: set[str] = set(skip_set)
def _mark_bucket(bucket_key: str, complete: bool) -> None:
"""В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем.
Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета,
поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск
частичного бакета на следующем прогоне означал бы, что его непрочитанные
страницы не перечитает уже никто, а счётчики покажут успех.
"""
if complete:
done.add(bucket_key)
return
counters.partial_buckets += 1
logger.warning(
"avito-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, "
"следующий прогон перечитает его целиком (partial_buckets=%d)",
run_id,
bucket_key,
counters.partial_buckets,
)
def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
"""Инкрементальный save после каждого leaf-бакета.
complete=False → лоты сохраняем, бакет в чекпоинт не пишем
(см. _mark_bucket). Дефолт True — для вызывающих без пагинации.
"""
nonlocal done
if runs.is_cancelled(db, run_id):
logger.info("avito-full-load run_id=%d: cancel detected in on_bucket", run_id)
raise RuntimeError("cancelled")
elif shutdown_requested():
logger.info(
"avito-full-load run_id=%d: SIGTERM-drain — stopping at bucket %s",
run_id,
bucket_key,
)
raise RuntimeError("shutdown")
if not lots:
_mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
return
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
skip_seen_today=config.scraper_skip_seen_today,
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
city=EKATERINBURG_CITY_NAME,
)
# save_listings вызывает db.commit() внутри — данные в БД сразу
counters.saved_inserted += inserted
counters.saved_updated += updated
counters.unique_fetched += len(lots)
_mark_bucket(bucket_key, complete)
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"avito-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d",
run_id,
bucket_key,
inserted,
updated,
counters.unique_fetched,
)
def _on_progress(unique_count: int) -> None:
"""Heartbeat per room-bucket (на случай пустых бакетов без on_bucket вызовов)."""
runs.update_heartbeat(db, run_id, counters.to_dict())
try:
async with AvitoScraper(config, proxy_provider=proxy_provider) as scraper:
scraper.request_delay_sec = request_delay_sec
await scraper.fetch_all_secondary(
price_cap_per_bucket=price_cap_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=_on_bucket,
on_progress=_on_progress,
skip_buckets=skip_set if skip_set else None,
since=since,
)
counters.dropped_novostroyki = getattr(scraper, "last_dropped_nb", 0)
logger.info(
"avito-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d "
"dropped_novostroyki=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
counters.dropped_novostroyki,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info(
"avito-full-load run_id=%d done: unique=%d ins=%d upd=%d errors=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
counters.errors_count,
)
return counters
except (AvitoBlockedError, AvitoRateLimitedError) as exc:
# IP-бан/rate-limit: partial results уже сохранены через on_bucket.
logger.error("avito-full-load run_id=%d: blocked/rate-limited — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
db,
run_id,
f"avito full load aborted: {exc}",
# done_buckets — чекпоинт пройденных бакетов; он сохраняется ОДИНАКОВО
# при обоих ban_kind (#2686 треб. 2): следующий прогон не начинает с нуля
# ни при нашем отказе сайдкара, ни при блокировке площадкой.
{**counters.to_dict(), "done_buckets": sorted(done)},
ban_kind=ban_kind_of_exception(exc),
)
return counters
except NoProxyAvailableError as exc:
# #2687: пул опустел mid-run. Ветка стоит ДО generic-RuntimeError намеренно —
# NoProxyAvailableError его подкласс, и без неё отказ уходил в mark_failed,
# который (в отличие от mark_banned) НЕ передавал done_buckets. То есть чекпоинт
# терялся ровно на НАШЕМ отказе — том исходе, для которого #2686 требовал его
# сохранять наравне с блокировкой площадкой. Диагноз ban_kind='infra' ветка
# даёт по-прежнему; сам чекпоинт с #930-мержем counters переживает и mark_failed.
logger.error("avito-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
db,
run_id,
f"avito full load aborted: {exc}",
{**counters.to_dict(), "done_buckets": sorted(done)},
ban_kind=ban_kind_of_exception(exc),
)
return counters
except RuntimeError as exc:
# on_bucket кидает RuntimeError("cancelled") при cooperative cancel,
# RuntimeError("shutdown") при кооперативном SIGTERM-drain (#1182 Phase 3a).
if str(exc) == "cancelled":
logger.info(
"avito-full-load run_id=%d: cancelled — partial results unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
return counters
if str(exc) == "shutdown":
logger.info(
"avito-full-load run_id=%d: SIGTERM-drain — partial results "
"unique=%d ins=%d upd=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
)
# #3355: та же метка дрейна, что у city-свипов (#3330/#3333). Без неё
# оборванный дрейном обход неотличим от полного: статус тот же 'done',
# а `_drained_done` в scheduler._resume_decision смотрит именно на неё —
# и done_buckets этого прогона не подхватывал никто.
runs.mark_done(
db,
run_id,
{**counters.to_dict(), "done_buckets": sorted(done), "interrupted": 1},
)
return counters
logger.exception("avito-full-load run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
except Exception as exc:
logger.exception("avito-full-load run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
# ── DomClick citywide sweep (без anchor'ов) ────────────────────────────────
@dataclass
class DomClickCitySweepCounters:
"""Aggregate counters для DomClick citywide sweep run.
DomClick BFF — citywide (city_id), не geo-bbox: anchor-loop отсутствует.
Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а.
"""
lots_fetched: int = 0
lots_inserted: int = 0
lots_updated: int = 0
pages_fetched: int = 0
errors_count: int = 0
blocked: int = 0 # 1 если QRATOR-блок был во время sweep
geo_filtered: int = 0 # число офферов отфильтрованных geo-guard
# #2670: измеренный охват прогона (сколько комнатных бакетов пройдено из скольких).
# 0/0 = скрейпер не успел создаться — тогда судить об охвате нечем.
buckets_completed: int = 0
buckets_total: int = 0
# #2854: с какой корзины ROOM_BUCKETS начался обход. Без него прогон
# неатрибутируем: «двушек не собрали» и «до двушек не дошли» выглядят одинаково.
bucket_start_index: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
# Дефолтные параметры sweep'а (EKB city_id=4).
DOMCLICK_DEFAULT_CITY_ID: int = 4
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets
# Число BFF-бакетов (st/1/2/3/4/5+) — используется для watchdog. Берётся из самого
# ROOM_BUCKETS: два литерала, обязанных совпадать, однажды уже разъезжались (#2674).
_DOMCLICK_NUM_BUCKETS: int = len(ROOM_BUCKETS)
# Оценка времени одного fetch'а (network + parse) для watchdog.
_DOMCLICK_PER_FETCH_S: float = 12.0
# Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits).
_DOMCLICK_SWEEP_BUDGET_S: float = 300.0
async def run_domclick_city_sweep(
db: Session,
*,
run_id: int,
config: ScraperConfig,
matcher: HouseMatcher,
shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None,
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
rooms: list[int] | None = None,
pages: int = 100,
request_delay_sec: float | None = None,
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
cookies: dict[str, str] | None = None,
) -> DomClickCitySweepCounters:
"""DomClick citywide sweep через BFF JSON API.
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → прокси-пул.
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
прямых импортов app.* (см. scraper_kit.contracts).
proxy_provider (#2657): домклик был единственным sweep-источником без пула — ходил
через статический SCRAPER_PROXY_URL сайдкара, а тот адрес с 06.08 отдаёт
NS_ERROR_PROXY_BAD_GATEWAY именно на домкликовский BFF. Теперь узел берётся из пула,
как у avito/cian/yandex, и плохой узел ротируется/банится штатными механизмами.
ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned
(независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты,
прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed.
Иначе mark_done.
cookies (#3264): снимок Sber ID сессии (dict cookie_name→value), инжектируется
снаружи strangler-путём (#2133) — kit сам не читает app.services.domclick_session /
БД. Без него sweep ходит на bff-search-web.domclick.ru БЕЗ кук и обязан решать
QRATOR PoW с нуля на каждый запрос (прод 30.08, прогон 5330 — 9/9 запросов
зависли на challenge). None (дефолт, сессии в БД нет/протухла) — прежнее
поведение, без инъекции.
Возвращает DomClickCitySweepCounters.
"""
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters()
# Watchdog: 6 buckets × pages × per_fetch + budget.
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
_sweep_timeout = max(
ANCHOR_TIMEOUT_SEC,
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
)
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
_scraper_ref: list[DomClickScraper] = []
try:
# ── Checkpoint/resume (#3118): корзина ROOM_BUCKETS — единица обхода ──
# Вместе со сдвигом #2854 подхват превращает случайную ротацию в
# систематический обход: банимый на 1-й корзине источник закрывает все
# шесть за несколько прогонов вместо повторов случайных.
# Читаем ДО ранних выходов: дрейн-ветка ниже обязана положить чекпоинт в
# свой payload сама (#3355), см. комментарий там.
skip_buckets: set[str] = set()
if resume_run_id is not None:
_prev_row = db.execute(
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
{"rid": resume_run_id},
).fetchone()
if _prev_row is not None and _prev_row.counters:
_prev_counters: dict = (
_prev_row.counters if isinstance(_prev_row.counters, dict) else {}
)
skip_buckets = set(_prev_counters.get("done_buckets", []))
logger.info(
"domclick-sweep run_id=%d: resuming from run %s%d корзин уже собрано",
run_id,
resume_run_id,
len(skip_buckets),
)
# ── Cooperative cancel перед SERP-фазой ──────────────────────────────
if runs.is_cancelled(db, run_id):
logger.info("domclick-sweep run_id=%d: cancelled before SERP", run_id)
runs.update_heartbeat(db, run_id, counters.to_dict())
return counters
elif shutdown_requested():
# SIGTERM-drain до SERP-фазы: финализируем run как mark_done(partial),
# минуя honest-status (это чистый drain, не QRATOR-блок).
logger.info("domclick-sweep run_id=%d: SIGTERM-drain — stopping before SERP", run_id)
# #3355: метка дрейна (см. city-свипы #3330/#3333) И явный перенос
# чекпоинта. Никакого jsonb-мержа тут нет: domclick пишет counters
# через app-level scrape_runs (update_heartbeat/mark_done делают
# `counters = CAST(:counters AS jsonb)` — ПОЛНАЯ перезапись), а
# scheduler при claim done_buckets не наследует. Без явного
# done_buckets дрейн-прогон закрылся бы с пустым чекпоинтом и резюм
# пересобирал бы все шесть корзин заново. Тот же приём, что в
# ban-ветке ниже (except NoProxyAvailableError).
_drain = {
**counters.to_dict(),
"done_buckets": sorted(skip_buckets),
"interrupted": 1,
}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
logger.info(
"domclick-sweep run_id=%d: BFF citywide sweep city_id=%d "
"buckets=%d pages_cap=%d (watchdog %ds)",
run_id,
city_id,
len(ROOM_BUCKETS),
pages,
_sweep_timeout,
)
lots: list[ScrapedLot] = []
async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots
async with DomClickScraper(
config, proxy_provider=proxy_provider, cookies=cookies
) as _scraper:
_scraper_ref.append(_scraper)
if request_delay_sec is not None:
_scraper.request_delay_sec = _resolved_delay
# #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону.
# Блок прилетает внутри ПЕРВОЙ корзины (buckets_completed=0 во всех
# прод-прогонах), а обход всегда стартовал со студий — поэтому в
# listings у Домклика 575 студий, 408 однушек и ровно 1 двушка при
# 7 352 двушках у Циана. Сдвиг не лечит блок, он раздаёт собранное по
# всем корзинам.
#
# Источник сдвига — run_id, а не дата и не случайность: детерминирован
# (тест воспроизводим), не зависит от часов и не требует нового
# состояния в БД. Строгого round-robin он не даёт — run_id общий на все
# источники и растёт неравномерно, — но за 30 суток каждая корзина
# получает порядка пяти стартов, чего достаточно для критерия приёмки
# «объявления с rooms >= 2 появились».
lots = await _scraper.fetch_city(
city_id=city_id,
rooms=rooms,
pages=pages,
start_bucket_index=run_id % len(ROOM_BUCKETS),
skip_buckets=skip_buckets or None,
)
counters.lots_fetched += len(lots)
if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
# угадывается.
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_dc_city,
)
counters.lots_inserted += inserted
counters.lots_updated += updated
try:
await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout)
except TimeoutError:
logger.warning(
"domclick-sweep run_id=%d: SERP phase timed out after %ds — partial results",
run_id,
_sweep_timeout,
)
counters.errors_count += 1
except NoProxyAvailableError as exc:
# #3118: пул опустел ДО первого HTTP-запроса — исключение поднимается из
# BrowserFetcher.__aenter__ (_acquire_lease) внутри async with DomClickScraper(...),
# то есть ДО _scraper_ref.append(_scraper). Без этой ветки NoProxyAvailableError
# (подкласс Exception) проваливался в generic except Exception ниже: errors_count
# растёт, но _scraper_ref остаётся пустым (blocked=0, buckets_completed=0/0) — и
# честный статус ниже видел «0 лотов + errors>0» и звал mark_failed("fetch errors —
# 0 listings") с ban_kind=NULL. Прод-след (run 5023, 27.08): умер за 51 мс,
# errors_count=1, ban_kind=NULL — неотличимо от честного отказа сбора площадкой,
# хотя площадка вообще не была затронута. Тот же диагноз и тот же по духу
# обработчик, что у run_avito_full_load/run_cian_full_load/run_yandex_full_load
# (см. except NoProxyAvailableError там же) — ban_kind_of_exception() относит тип
# исключения к BAN_KIND_INFRA («наша инфраструктура», не площадка). done_buckets
# сохраняем как унаследованные skip_buckets — этот прогон новых не завершил, но
# терять уже собранный чекпоинт (#2687-класс дефекта) resume не должен.
logger.error("domclick-sweep run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1
runs.mark_banned(
db,
run_id,
f"domclick sweep aborted: {exc}",
{**counters.to_dict(), "done_buckets": sorted(skip_buckets)},
ban_kind=ban_kind_of_exception(exc),
)
return counters
except Exception:
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
counters.errors_count += 1
# Перенести счётчики scraper'а в counters (если scraper был создан).
if _scraper_ref:
_s = _scraper_ref[0]
counters.blocked = 1 if _s.blocked else 0
counters.geo_filtered = _s.geo_filtered
# Не-block fetch-ошибки скрейпера учитываем в errors_count.
counters.errors_count += _s.fetch_errors
# #2670: охват читаем даже когда фаза была снята по таймауту — ссылка на
# скрейпер живая, а его счётчик показывает, докуда прогон дошёл.
counters.buckets_completed = _s.buckets_completed
counters.buckets_total = _s.buckets_total
# #3118: чекпоинт = унаследованное завершённое в этом прогоне.
# Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не
# затирают, и оборванный болезнью финализации прогон его не теряет.
_done_now = sorted(skip_buckets | set(_s.completed_buckets))
runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": _done_now})
counters.bucket_start_index = _s.bucket_start_index
# pages_fetched: worst-case число страниц (buckets × pages cap).
counters.pages_fetched = _num_fetches
runs.update_heartbeat(db, run_id, counters.to_dict())
# ── ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657) ───────────────────────────
# Распознанный QRATOR-блок НИКОГДА не даёт done. Домклик тут структурно
# отличается от cian/yandex (#2625/#2642): там независимые anchor'ы, и
# провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет,
# sweep линейный по ROOM_BUCKETS, и первый же блок делает `break` —
# оставшиеся бакеты не пробуются вовсе (fetch_city). То есть блок = прогон
# оборван, сколько бы лотов он ни успел взять до этого; на проде это дало
# 13 из 13 «успешных» прогонов с blocked=1 (39-464 лота вместо ~6300).
# banned (а не failed): это external constraint, не наш баг, и тот же
# статус, что #2642 дал cian/yandex — доступен как триггер ротации IP
# (#2611; сама ротация здесь не вызывается). Пометка прокси-пула живёт
# отдельно и раньше — fetcher.report_ban в serp.py (#2600 п.1).
if counters.blocked:
logger.error(
"domclick-sweep run_id=%d: QRATOR block aborted remaining room buckets "
"(lots=%d, errors=%d) — marking banned (#2657)",
run_id,
counters.lots_fetched,
counters.errors_count,
)
runs.mark_banned(
db,
run_id,
f"QRATOR block aborted sweep — {counters.lots_fetched} listings "
"collected before abort (#2657)",
counters.to_dict(),
# #2687: эта ветка входится ТОЛЬКО при распознанном QRATOR-блоке
# (counters.blocked == 1, DomClickBlockedError — площадка показала
# block-страницу). Это ровно определение BAN_KIND_PLATFORM
# («площадка нас заблокировала»), а не 'unknown'. Раньше домкликовый
# свип не передавал ban_kind и все его баны падали в 'unknown', хотя
# диагноз здесь установлен, а не «не установлен» (#2764).
ban_kind=BAN_KIND_PLATFORM,
)
elif 0 < counters.buckets_completed < counters.buckets_total:
# #2670: прогон оборван на середине — прошёл часть комнатных бакетов и
# бросил остальные (битый бакет → continue, снятие фазы по таймауту).
# Лоты у него есть, известной ошибки нет — и до этой ветки он отчитывался
# успехом. «Успех» определялся как «не поймали известную ошибку», а не как
# «сделали то, что собирались»: сравниваем с ожидаемым охватом, не с нулём.
logger.error(
"domclick-sweep run_id=%d: пройдено %d бакетов из %d "
"(lots=%d, errors=%d) — marking failed (#2670)",
run_id,
counters.buckets_completed,
counters.buckets_total,
counters.lots_fetched,
counters.errors_count,
)
runs.mark_failed(
db,
run_id,
f"sweep оборван: пройдено {counters.buckets_completed} комнатных "
f"бакетов из {counters.buckets_total}, собрано "
f"{counters.lots_fetched} лотов (#2670)",
counters.to_dict(),
)
elif counters.lots_fetched == 0 and counters.errors_count > 0:
logger.error(
"domclick-sweep run_id=%d: 0 listings with errors=%d — marking failed",
run_id,
counters.errors_count,
)
runs.mark_failed(
db,
run_id,
"fetch errors — 0 listings",
counters.to_dict(),
)
else:
runs.mark_done(db, run_id, counters.to_dict())
logger.info(
"domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) "
"buckets=%d/%d pages=%d errors=%d blocked=%d geo_filtered=%d",
run_id,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.buckets_completed,
counters.buckets_total,
counters.pages_fetched,
counters.errors_count,
counters.blocked,
counters.geo_filtered,
)
return counters
except Exception as exc:
logger.exception("domclick-sweep run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise