Some checks failed
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 18s
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) Failing after 6m17s
Скрапер один давал 3006 error-строк в сутки из ~3700 по всему Trade-In — ленту перестали читать, и настоящая поломка терялась в ней. Принцип: ожидаемый исход сбора (площадка забанила, пул прокси пуст, капча/недогруз, серия блоков перевалила порог circuit breaker) — это состояние работы против недружелюбного источника, а не инцидент. В error остаётся только неожиданное: изменившаяся вёрстка/схема (Cian markup change), просроченный токен ротации прокси (ASocks 401), неразобранное исключение. Переведено error -> warning в 9 файлах, 12 мест: "СТОП — пул прокси пуст" (avito/domclick/cian_history/yandex_newbuilding_sweep x2), "пул прокси исчерпан" (cian_session, cian_price_history, yandex_address_backfill), FAIL-CLOSED без здорового узла для source (proxy_egress), ABORT по счётчику подтверждённых блоков площадки (avito, domclick, yandex_detail_backfill). Оставлено error намеренно: cookie-алерты Cian/DomClick (#2658, #2674) — они рассчитаны именно на LoggingIntegration(event_level=ERROR) в scheduler_main.py и без него молчат по 37 дней; ABORT по смешанным/soft причинам без единого подтверждённого блока площадки (#3272, #2674/#3196) — это может быть наш баг, а не бан, сигнал сознательно не приглушали. GlitchTip: сентри-интеграция скрапера уже настроена как LoggingIntegration(level=INFO, event_level=ERROR) в scheduler_main.py — отдельной правки sentry_scrub.py не требуется, понижение уровня logger само убирает эти записи из GlitchTip. Итоговая FINISHED-строка со счётчиками (attempted/enriched/blocked/failed) уже существует в каждом detail_backfill — новую не добавлял. Refs #3471
584 lines
33 KiB
Python
584 lines
33 KiB
Python
"""Scheduled backfill: detail-enrichment for legacy yandex listings (#1553).
|
||
|
||
Nightly window 12:00-15:00 UTC (migration 113, source=yandex_detail_backfill).
|
||
Offset from avito_detail_backfill (09-12 UTC) to avoid parallel egress on shared IP.
|
||
|
||
Problem: ~3952 yandex listings have detail_enriched_at IS NULL.
|
||
Yandex city sweep does not call YandexDetailScraper — SERP data only.
|
||
area_m2 coverage 23%, living/kitchen 0%, repair_state 1% on prod.
|
||
|
||
Solution: single snapshot SELECT at start (guarantees termination), fetch each
|
||
offer detail page via curl_cffi AsyncSession (kit-профиль `DEFAULT_IMPERSONATE` + proxy) — mirrors
|
||
yandex_address_backfill.py which already gets full HTML from Yandex on prod.
|
||
Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via
|
||
save_detail_enrichment. Track consecutive parse→None results; abort after
|
||
max_consecutive_blocks. Прогон с нулём обогащений теперь 'failed', не 'done'
|
||
(#2674, runs.mark_backfill_finished): на проде 31 прогон из 52 упирался ровно в
|
||
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
|
||
снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
||
|
||
Почему брейкер срабатывал так часто (разобрано 2026-08-06, замеры в комментарии
|
||
у OFFER_URL_PATTERN): в очереди лежали карточки новостроек, у которых source_url
|
||
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
|
||
такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они
|
||
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
|
||
целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
|
||
|
||
Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий
|
||
у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
|
||
собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
|
||
очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
|
||
url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько
|
||
не адресуемо вообще (ни offer-URL, ни числового source_id).
|
||
|
||
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
||
fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML
|
||
→ parse always returns None → backfill would be 0% effective. The
|
||
curl_cffi path (kit-профиль impersonation + mobile proxy) is already proven
|
||
by yandex_address_backfill, which fetches identical offer detail pages.
|
||
|
||
Природа блока (#3196): счётчика blocked у Яндекса не было вовсе, поэтому ветка
|
||
перевода прогона в 'banned' (scrape_runs.mark_backfill_finished) была недостижима
|
||
по построению — за 14 дней 12 done, 1 failed, 1 zombie и НОЛЬ банов. Теперь
|
||
non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса
|
||
(ban_kind_from_status: 403/429 → platform, 5xx → infra, прочее → unknown), а не
|
||
из текстовых маркеров страницы, снятых с чужой площадки.
|
||
|
||
Полнота страницы (#3191): HTTP 200 + разобравшийся parse ещё не означают карточку.
|
||
Недорендеренная страница (1,8 МБ вместо 3,9, без блока контактов) парсится молча и
|
||
раньше уезжала в БД с detail_enriched_at, выбывая из очереди навсегда. Теперь она
|
||
отсеивается ДО parse (detail_incomplete_reason: структурный маркер контактов +
|
||
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
||
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
||
(detail_enriched_at IS NULL) возьмёт её снова.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
from collections import Counter
|
||
from dataclasses import dataclass, field
|
||
|
||
from curl_cffi.requests import AsyncSession
|
||
from scraper_kit.browser_fetcher import ban_kind_from_status
|
||
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||
from scraper_kit.providers.yandex.detail import (
|
||
YandexDetailScraper,
|
||
detail_incomplete_reason,
|
||
save_detail_enrichment,
|
||
)
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.services import scrape_runs as runs_mod
|
||
from app.services.proxy_egress import resolve_proxy_url
|
||
from app.services.scrape_runs import BAN_KIND_UNKNOWN
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
__all__ = [
|
||
"CANONICAL_URL_SQL",
|
||
"OFFER_ID_PATTERN",
|
||
"OFFER_URL_PATTERN",
|
||
"YandexDetailBackfillResult",
|
||
"run_yandex_detail_backfill",
|
||
]
|
||
|
||
# Условие, при котором обогащение этого объявления вообще возможно (#2723-класс).
|
||
# `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` и без него
|
||
# возвращает None ЕЩЁ ДО обращения к HTML (providers/yandex/detail.py:150) — то есть
|
||
# отказ предрешён регуляркой, а не капчей.
|
||
#
|
||
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
|
||
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
|
||
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
|
||
#
|
||
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
|
||
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
|
||
# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды.
|
||
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
|
||
OFFER_URL_PATTERN = "/offer/[0-9]+"
|
||
|
||
# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
|
||
# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
|
||
# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
|
||
#
|
||
# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
|
||
# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
|
||
# вставленных после самой свежей его строки, id 2583989), и выйти из него
|
||
# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
|
||
# множество и обязано быть константой — вопрос был не «почему не растёт», а
|
||
# «правда ли они непригодны».
|
||
#
|
||
# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
|
||
# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
|
||
# него собирается — это инвариант #2235 (`_canonical_source_url` в
|
||
# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
|
||
# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
|
||
# curl_cffi с kit-профилем (на момент замера — Chrome 120), тот же parse): 6 из 6
|
||
# — HTTP 200 и parse OK, включая строки, чей сохранённый source_url — рекламный
|
||
# редирект na100.pro/go.php.
|
||
#
|
||
# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
|
||
# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
|
||
# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
|
||
# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
|
||
# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
|
||
# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
|
||
#
|
||
# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
|
||
# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
|
||
# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
|
||
# на сайты застройщиков вместо Яндекса.
|
||
OFFER_ID_PATTERN = "^[0-9]+$"
|
||
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
||
|
||
|
||
@dataclass
|
||
class YandexDetailBackfillResult:
|
||
"""Counters for one yandex detail backfill run."""
|
||
|
||
attempted: int = 0
|
||
enriched: int = 0
|
||
# Отказы площадки среди попыток (non-200 ответ) с разобранной природой.
|
||
# Подмножество failed, а не отдельная корзина: смысл failed («попытка не дала
|
||
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
|
||
# 'banned' у Яндекса недостижима по построению (#3196).
|
||
blocked: int = 0
|
||
# Недогруженные страницы среди попыток (#3191): HTTP 200, валидный HTML, но без
|
||
# блока контактов / заметно меньше нормы. Тоже подмножество failed — попытка была,
|
||
# обогащения не случилось; detail_enriched_at не проставляется, объявление остаётся
|
||
# в очереди (снапшот берётся по detail_enriched_at IS NULL).
|
||
incomplete: int = 0
|
||
failed: int = 0
|
||
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
|
||
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
|
||
# прогону; замер на месте = очередь снова читается не тем признаком.
|
||
url_from_offer_id: int = 0
|
||
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
||
unenrichable_pending: int = 0
|
||
duration_sec: float = field(default=0.0)
|
||
|
||
def to_dict(self) -> dict[str, int]:
|
||
return {
|
||
"attempted": self.attempted,
|
||
"enriched": self.enriched,
|
||
"blocked": self.blocked,
|
||
"incomplete": self.incomplete,
|
||
"failed": self.failed,
|
||
"url_from_offer_id": self.url_from_offer_id,
|
||
"unenrichable_pending": self.unenrichable_pending,
|
||
"duration_sec": int(self.duration_sec),
|
||
}
|
||
|
||
|
||
async def run_yandex_detail_backfill(
|
||
db: Session,
|
||
*,
|
||
run_id: int,
|
||
params: dict,
|
||
) -> YandexDetailBackfillResult:
|
||
"""Backfill detail_enriched_at for legacy yandex listings via curl_cffi + parse.
|
||
|
||
Params (from default_params jsonb in scrape_schedules):
|
||
batch_size: int -- snapshot size (SELECT LIMIT), default 800.
|
||
budget_sec: float -- wall-clock budget per run, default 3600s.
|
||
request_delay_sec: float -- delay between listings, default 5s.
|
||
max_consecutive_blocks: int -- consecutive parse→None before abort, default 5.
|
||
|
||
Fetch mechanism:
|
||
curl_cffi AsyncSession(impersonate=DEFAULT_IMPERSONATE) + scraper_proxy_url — mirrors
|
||
yandex_address_backfill. On HTTP 200: pass resp.text to
|
||
YandexDetailScraper().parse(html, offer_url). parse→None counts as a fail
|
||
(possible captcha wall); consecutive None → abort after max_consecutive_blocks.
|
||
|
||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||
mark_backfill_finished (done / failed при нуле обогащений, #2674);
|
||
mark_failed напрямую — только при исключении.
|
||
"""
|
||
batch_size = int(params.get("batch_size", 800))
|
||
budget_sec = float(params.get("budget_sec", 3600))
|
||
request_delay_sec = float(params.get("request_delay_sec", 5.0))
|
||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
||
|
||
counters = YandexDetailBackfillResult()
|
||
current_counters: dict[str, int] = counters.to_dict()
|
||
|
||
start = time.monotonic()
|
||
|
||
try:
|
||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||
|
||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
|
||
# В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
|
||
# сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
|
||
# собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
|
||
# этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
|
||
snapshot = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
SELECT id,
|
||
CASE
|
||
WHEN source_url ~ CAST(:offer_url_pattern AS text)
|
||
THEN source_url
|
||
ELSE {CANONICAL_URL_SQL}
|
||
END AS source_url
|
||
FROM listings
|
||
WHERE source = 'yandex'
|
||
AND detail_enriched_at IS NULL
|
||
AND (
|
||
(
|
||
source_url IS NOT NULL
|
||
AND source_url ~ CAST(:offer_url_pattern AS text)
|
||
)
|
||
OR source_id ~ CAST(:offer_id_pattern AS text)
|
||
)
|
||
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||
LIMIT CAST(:batch_size AS int)
|
||
"""
|
||
# f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
|
||
# не пользовательский ввод. Всё изменяемое — bind-параметры.
|
||
),
|
||
{
|
||
"batch_size": batch_size,
|
||
"offer_url_pattern": OFFER_URL_PATTERN,
|
||
"offer_id_pattern": OFFER_ID_PATTERN,
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
|
||
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
|
||
# разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
|
||
# «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
|
||
pending = db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
count(*) FILTER (
|
||
WHERE source_id ~ CAST(:offer_id_pattern AS text)
|
||
) AS url_from_offer_id,
|
||
count(*) FILTER (
|
||
WHERE source_id IS NULL
|
||
OR source_id !~ CAST(:offer_id_pattern AS text)
|
||
) AS unenrichable_pending
|
||
FROM listings
|
||
WHERE source = 'yandex'
|
||
AND detail_enriched_at IS NULL
|
||
AND (
|
||
source_url IS NULL
|
||
OR source_url !~ CAST(:offer_url_pattern AS text)
|
||
)
|
||
"""
|
||
),
|
||
{"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
|
||
).one()
|
||
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
||
counters.unenrichable_pending = int(pending.unenrichable_pending)
|
||
if counters.url_from_offer_id:
|
||
logger.info(
|
||
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
||
"source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
|
||
"source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
|
||
run_id,
|
||
counters.url_from_offer_id,
|
||
)
|
||
if counters.unenrichable_pending:
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни "
|
||
"offer-URL (%s), ни числового source_id — адресовать их нечем",
|
||
run_id,
|
||
counters.unenrichable_pending,
|
||
OFFER_URL_PATTERN,
|
||
)
|
||
|
||
if not snapshot:
|
||
logger.info(
|
||
"yandex_detail_backfill: run_id=%d -- no pending listings "
|
||
"(detail_enriched_at IS NULL = 0), done",
|
||
run_id,
|
||
)
|
||
# to_dict(), а не current_counters: пустая очередь при непустом
|
||
# unenrichable_pending — самый важный случай этого счётчика.
|
||
runs_mod.mark_done(db, run_id, counters.to_dict())
|
||
return counters
|
||
|
||
logger.info(
|
||
"yandex_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
|
||
"delay=%.1fs max_consecutive_none=%d)",
|
||
run_id,
|
||
len(snapshot),
|
||
budget_sec,
|
||
request_delay_sec,
|
||
max_consecutive_blocks,
|
||
)
|
||
|
||
# Build proxies dict once — mirrors yandex_address_backfill.py.
|
||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если
|
||
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
|
||
# нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError
|
||
# (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже
|
||
# (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с
|
||
# понятным логом, отдельный catch не нужен.
|
||
_proxy = resolve_proxy_url(db, "yandex")
|
||
_proxies = {"http": _proxy, "https": _proxy} if _proxy else None
|
||
|
||
consecutive_none = 0
|
||
# #3196: два счётчика серий — разной природы. consecutive_none — весь подряд
|
||
# идущий неуспех (фетч-ошибка, non-200, парс-None) и двигает только ABORT
|
||
# парс-None (наш дефект/дрейф разметки). consecutive_blocks — подряд идущие
|
||
# ТОЛЬКО non-200 ответы и двигает aborted_by_blocks/'banned': серию промахов
|
||
# парсера при HTTP 200 нельзя засчитывать как серию блоков площадки.
|
||
consecutive_blocks = 0
|
||
do_sleep = False
|
||
aborted_by_blocks = False
|
||
# Перепись диагнозов блоков (kind -> сколько раз). Кратности нужны целыми:
|
||
# доминирующий вид выбирает _dominant_ban_kind в scrape_runs.py (#3178).
|
||
block_ban_kinds: Counter[str] = Counter()
|
||
scraper = YandexDetailScraper()
|
||
|
||
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 session:
|
||
for idx, row in enumerate(snapshot):
|
||
# Budget guard
|
||
elapsed = time.monotonic() - start
|
||
if elapsed > budget_sec:
|
||
logger.info(
|
||
"yandex_detail_backfill: run_id=%d -- budget %.0fs exhausted "
|
||
"(elapsed=%.1fs), stopping at #%d/%d",
|
||
run_id,
|
||
budget_sec,
|
||
elapsed,
|
||
idx,
|
||
len(snapshot),
|
||
)
|
||
break
|
||
|
||
# Delay before each request except the first
|
||
if do_sleep:
|
||
await asyncio.sleep(request_delay_sec)
|
||
do_sleep = True
|
||
|
||
source_url: str = row["source_url"]
|
||
listing_id: int = row["id"]
|
||
counters.attempted += 1
|
||
|
||
try:
|
||
try:
|
||
resp = await session.get(source_url, allow_redirects=True)
|
||
except Exception as fetch_exc:
|
||
consecutive_none += 1
|
||
counters.failed += 1
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d listing_id=%d "
|
||
"fetch error (consecutive=%d): %s",
|
||
run_id,
|
||
listing_id,
|
||
consecutive_none,
|
||
fetch_exc,
|
||
)
|
||
if consecutive_none >= max_consecutive_blocks:
|
||
logger.error(
|
||
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||
"errors. enriched=%d attempted=%d",
|
||
run_id,
|
||
consecutive_none,
|
||
counters.enriched,
|
||
counters.attempted,
|
||
)
|
||
break
|
||
continue
|
||
|
||
if resp.status_code != 200:
|
||
# Природу отказа берём из HTTP-статуса, а не из текста
|
||
# страницы (#3196): 403/429 -> platform, 5xx -> infra.
|
||
# Статус, который о блоке ничего не сообщает, честно
|
||
# остаётся 'unknown' — это «не знаем, чей отказ», а не
|
||
# «отказа не было».
|
||
ban_kind = ban_kind_from_status(resp.status_code) or BAN_KIND_UNKNOWN
|
||
counters.blocked += 1
|
||
block_ban_kinds[ban_kind] += 1
|
||
consecutive_none += 1
|
||
consecutive_blocks += 1
|
||
counters.failed += 1
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d listing_id=%d "
|
||
"HTTP %d ban_kind=%s (consecutive=%d)",
|
||
run_id,
|
||
listing_id,
|
||
resp.status_code,
|
||
ban_kind,
|
||
consecutive_blocks,
|
||
)
|
||
if consecutive_blocks >= max_consecutive_blocks:
|
||
aborted_by_blocks = True
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||
"non-200 responses. enriched=%d attempted=%d",
|
||
run_id,
|
||
consecutive_blocks,
|
||
counters.enriched,
|
||
counters.attempted,
|
||
)
|
||
break
|
||
continue
|
||
|
||
# Недогруз — отказ, а не успех (#3191). Проверка ДО parse: parse
|
||
# частичную страницу разберёт молча (JSON-состояние на месте), и
|
||
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
|
||
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
|
||
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
|
||
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
|
||
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
|
||
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
|
||
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
|
||
incomplete_reason = detail_incomplete_reason(
|
||
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
|
||
)
|
||
if incomplete_reason is not None:
|
||
counters.incomplete += 1
|
||
counters.failed += 1
|
||
consecutive_none += 1
|
||
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
|
||
consecutive_blocks = 0
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
|
||
run_id,
|
||
listing_id,
|
||
source_url,
|
||
incomplete_reason,
|
||
consecutive_none,
|
||
)
|
||
if consecutive_none >= max_consecutive_blocks:
|
||
logger.error(
|
||
# consecutive_none смешанный (фетч-ошибка + parse-None +
|
||
# недогруз) — «недогруженных» назвало бы только один вклад.
|
||
"yandex_detail_backfill: run_id=%d ABORT -- %d подряд "
|
||
"без обогащения. enriched=%d attempted=%d",
|
||
run_id,
|
||
consecutive_none,
|
||
counters.enriched,
|
||
counters.attempted,
|
||
)
|
||
break
|
||
continue
|
||
|
||
enrichment = scraper.parse(resp.text, offer_url=source_url)
|
||
|
||
if enrichment is None:
|
||
# parse→None: captcha wall / shell-HTML / no JSON-LD.
|
||
# Do not mark listing as done — retry next night.
|
||
# НАРОЧНО не считаем блоком (#3196): при HTTP 200 отличить
|
||
# капчу от промаха нашего парсера нечем, а записав это в
|
||
# blocked, мы объявляли бы 'banned' (внешняя причина) любой
|
||
# прогон, сломанный на нашей стороне, и потеряли бы сигнал
|
||
# 'failed', ради которого он заведён (#2674).
|
||
consecutive_none += 1
|
||
# Площадка ОТВЕТИЛА (HTTP 200) — серии подтверждённых блоков
|
||
# нет, что бы ни случилось дальше с парсингом (#3196).
|
||
consecutive_blocks = 0
|
||
counters.failed += 1
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||
"-> parse None (consecutive=%d)",
|
||
run_id,
|
||
listing_id,
|
||
source_url,
|
||
consecutive_none,
|
||
)
|
||
if consecutive_none >= max_consecutive_blocks:
|
||
logger.error(
|
||
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||
"parse-None results (captcha wall?). enriched=%d attempted=%d",
|
||
run_id,
|
||
consecutive_none,
|
||
counters.enriched,
|
||
counters.attempted,
|
||
)
|
||
break
|
||
continue
|
||
|
||
consecutive_none = 0
|
||
consecutive_blocks = 0
|
||
if save_detail_enrichment(db, listing_id, enrichment):
|
||
counters.enriched += 1
|
||
else:
|
||
# #3338 (та же дыра, что #3332 у domclick): страница взята и
|
||
# разобрана, а UPDATE не задел ни одной строки — объявление
|
||
# удалено/деактивировано между снимком и записью. Попытка была,
|
||
# исхода не было: attempted переставал сходиться с enriched +
|
||
# failed, и расхождение читается как потерянный отказ площадки.
|
||
# Исход failed: непрошедший UPDATE — не успех и не блок.
|
||
counters.failed += 1
|
||
logger.warning(
|
||
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||
"-- карточка разобрана, но UPDATE не задел ни одной строки",
|
||
run_id,
|
||
listing_id,
|
||
source_url,
|
||
)
|
||
|
||
except Exception as exc:
|
||
counters.failed += 1
|
||
logger.warning(
|
||
"yandex_detail_backfill: save/iteration error for listing_id=%d: %s",
|
||
listing_id,
|
||
exc,
|
||
)
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
|
||
if counters.attempted % 25 == 0:
|
||
current_counters = counters.to_dict()
|
||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||
|
||
counters.duration_sec = time.monotonic() - start
|
||
current_counters = counters.to_dict()
|
||
runs_mod.mark_backfill_finished(
|
||
db,
|
||
run_id,
|
||
current_counters,
|
||
source="yandex_detail_backfill",
|
||
aborted_by_blocks=aborted_by_blocks,
|
||
ban_kinds=block_ban_kinds,
|
||
)
|
||
logger.info(
|
||
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||
"blocked=%d incomplete=%d failed=%d duration=%.1fs",
|
||
run_id,
|
||
counters.attempted,
|
||
counters.enriched,
|
||
counters.blocked,
|
||
counters.incomplete,
|
||
counters.failed,
|
||
counters.duration_sec,
|
||
)
|
||
return counters
|
||
|
||
except Exception as exc:
|
||
counters.duration_sec = time.monotonic() - start
|
||
logger.exception(
|
||
"yandex_detail_backfill: run_id=%d FAILED after %.1fs",
|
||
run_id,
|
||
counters.duration_sec,
|
||
)
|
||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
|
||
raise
|