fix(tradein/yandex): в очередь обогащения не берём то, что парсер отвергает до сети (#2674) #2738
2 changed files with 123 additions and 4 deletions
|
|
@ -17,6 +17,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
|
|||
этот брейкер (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 берёт только то, что парсер в принципе может
|
||||
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
|
||||
|
||||
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
|
||||
|
|
@ -43,10 +51,27 @@ from app.services import scrape_runs as runs_mod
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"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, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
|
||||
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
|
||||
#
|
||||
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
|
||||
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
|
||||
# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды.
|
||||
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
|
||||
OFFER_URL_PATTERN = "/offer/[0-9]+"
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexDetailBackfillResult:
|
||||
|
|
@ -55,6 +80,7 @@ class YandexDetailBackfillResult:
|
|||
attempted: int = 0
|
||||
enriched: int = 0
|
||||
failed: int = 0
|
||||
unenrichable_pending: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
|
|
@ -62,6 +88,7 @@ class YandexDetailBackfillResult:
|
|||
"attempted": self.attempted,
|
||||
"enriched": self.enriched,
|
||||
"failed": self.failed,
|
||||
"unenrichable_pending": self.unenrichable_pending,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +132,9 @@ async def run_yandex_detail_backfill(
|
|||
|
||||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
|
||||
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
|
||||
# (см. комментарий у константы): в очередь не берём то, что заведомо
|
||||
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
|
||||
snapshot = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -114,23 +144,53 @@ async def run_yandex_detail_backfill(
|
|||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND source_url ~ CAST(:offer_url_pattern AS text)
|
||||
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
"""
|
||||
),
|
||||
{"batch_size": batch_size},
|
||||
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
|
||||
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
|
||||
counters.unenrichable_pending = int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
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)
|
||||
"""
|
||||
),
|
||||
{"offer_url_pattern": OFFER_URL_PATTERN},
|
||||
).scalar_one()
|
||||
)
|
||||
if counters.unenrichable_pending:
|
||||
logger.info(
|
||||
"yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: "
|
||||
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
|
||||
"до сети",
|
||||
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,
|
||||
)
|
||||
runs_mod.mark_done(db, run_id, current_counters)
|
||||
# to_dict(), а не current_counters: пустая очередь при непустом
|
||||
# unenrichable_pending — самый важный случай этого счётчика.
|
||||
runs_mod.mark_done(db, run_id, counters.to_dict())
|
||||
return counters
|
||||
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -50,11 +51,13 @@ def _make_snapshot(n: int) -> list[dict]:
|
|||
]
|
||||
|
||||
|
||||
def _mock_db(snapshot: list[dict]) -> MagicMock:
|
||||
"""Fake Session: first execute() returns snapshot via .mappings().all()."""
|
||||
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
|
||||
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
|
||||
.scalar_one() — размер отброшенной (непарсимой) части очереди."""
|
||||
db = MagicMock()
|
||||
sel = MagicMock()
|
||||
sel.mappings.return_value.all.return_value = snapshot
|
||||
sel.scalar_one.return_value = unenrichable
|
||||
db.execute.return_value = sel
|
||||
return db
|
||||
|
||||
|
|
@ -448,3 +451,59 @@ def test_save_detail_enrichment_rowcount_zero_returns_false() -> None:
|
|||
|
||||
saved = save_detail_enrichment(db, listing_id=404, e=enrichment)
|
||||
assert saved is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Очередь не должна содержать того, что парсер отвергает до сети (2026-08-06)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Реальные source_url с прода (2026-08-06). Верх очереди на момент прогона 3300
|
||||
# состоял ровно из таких строк: 5 попыток, 5 parse-None, abort за 23 секунды.
|
||||
_PROD_QUEUE_HEAD = [
|
||||
("https://macroserver.ru/id/224566/", False),
|
||||
("https://prospect-federation.ru/flat/192", False),
|
||||
("https://macroserver.ru/id/7223953/", False),
|
||||
("https://strana.com/ekaterinburg/flat/1234", False),
|
||||
("https://realty.yandex.ru/offer/7416316701146842927/", True),
|
||||
("https://realty.yandex.ru/offer/7298311881327827251/", True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
|
||||
"""Снапшот-SELECT судит по тому же признаку, что и парсер, — сторожем, а не на слово.
|
||||
|
||||
`YandexDetailScraper.parse` возвращает None по регулярке в URL, ещё не
|
||||
заглянув в HTML. Строки шире этого условия гарантированно дают parse-None и
|
||||
пачкой выбивают брейкер «5 подряд», обрывая ВЕСЬ прогон (32 прогона из 53 на
|
||||
проде). Проверяем на одних и тех же прод-URL обе стороны + что отброшенное
|
||||
посчитано, а не молча исчезло.
|
||||
"""
|
||||
from scraper_kit.providers.yandex.detail import YandexDetailScraper
|
||||
|
||||
db = _mock_db([], unenrichable=3535)
|
||||
runs = MagicMock()
|
||||
session_cls, _session = _make_session_ctx([])
|
||||
|
||||
with (
|
||||
patch(_ASYNC_SESSION, session_cls),
|
||||
patch(_RUNS, runs),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=42, params={"batch_size": 10, "budget_sec": 60}
|
||||
)
|
||||
|
||||
snapshot_call = db.execute.call_args_list[0]
|
||||
assert "source_url ~ CAST(:offer_url_pattern AS text)" in str(snapshot_call.args[0])
|
||||
pattern = snapshot_call.args[1]["offer_url_pattern"]
|
||||
|
||||
scraper = YandexDetailScraper()
|
||||
for url, enrichable in _PROD_QUEUE_HEAD:
|
||||
assert (re.search(pattern, url) is not None) is enrichable, url
|
||||
if not enrichable:
|
||||
# HTML тут любой: отказ предрешён до его разбора.
|
||||
assert scraper.parse("<html><body>сайт застройщика</body></html>", url) is None, url
|
||||
|
||||
assert result.unenrichable_pending == 3535
|
||||
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue