fix(tradein/yandex): в очередь обогащения не берём то, что парсер отвергает до сети (#2674) (#2738)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m7s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m13s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m7s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m13s
This commit is contained in:
parent
f0968c8513
commit
4aec49f7fb
2 changed files with 123 additions and 4 deletions
|
|
@ -17,6 +17,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
|
||||||
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
|
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
|
||||||
снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
снапшота уедет в следующую ночь через 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:
|
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||||
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
||||||
fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"OFFER_URL_PATTERN",
|
||||||
"YandexDetailBackfillResult",
|
"YandexDetailBackfillResult",
|
||||||
"run_yandex_detail_backfill",
|
"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
|
@dataclass
|
||||||
class YandexDetailBackfillResult:
|
class YandexDetailBackfillResult:
|
||||||
|
|
@ -55,6 +80,7 @@ class YandexDetailBackfillResult:
|
||||||
attempted: int = 0
|
attempted: int = 0
|
||||||
enriched: int = 0
|
enriched: int = 0
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
unenrichable_pending: int = 0
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, int]:
|
def to_dict(self) -> dict[str, int]:
|
||||||
|
|
@ -62,6 +88,7 @@ class YandexDetailBackfillResult:
|
||||||
"attempted": self.attempted,
|
"attempted": self.attempted,
|
||||||
"enriched": self.enriched,
|
"enriched": self.enriched,
|
||||||
"failed": self.failed,
|
"failed": self.failed,
|
||||||
|
"unenrichable_pending": self.unenrichable_pending,
|
||||||
"duration_sec": int(self.duration_sec),
|
"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.
|
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||||
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
|
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
|
||||||
|
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
|
||||||
|
# (см. комментарий у константы): в очередь не берём то, что заведомо
|
||||||
|
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
|
||||||
snapshot = (
|
snapshot = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -114,23 +144,53 @@ async def run_yandex_detail_backfill(
|
||||||
WHERE source = 'yandex'
|
WHERE source = 'yandex'
|
||||||
AND detail_enriched_at IS NULL
|
AND detail_enriched_at IS NULL
|
||||||
AND source_url IS NOT 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
|
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||||||
LIMIT CAST(:batch_size AS int)
|
LIMIT CAST(:batch_size AS int)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"batch_size": batch_size},
|
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.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:
|
if not snapshot:
|
||||||
logger.info(
|
logger.info(
|
||||||
"yandex_detail_backfill: run_id=%d -- no pending listings "
|
"yandex_detail_backfill: run_id=%d -- no pending listings "
|
||||||
"(detail_enriched_at IS NULL = 0), done",
|
"(detail_enriched_at IS NULL = 0), done",
|
||||||
run_id,
|
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
|
return counters
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
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:
|
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
|
||||||
"""Fake Session: first execute() returns snapshot via .mappings().all()."""
|
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
|
||||||
|
.scalar_one() — размер отброшенной (непарсимой) части очереди."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
sel = MagicMock()
|
sel = MagicMock()
|
||||||
sel.mappings.return_value.all.return_value = snapshot
|
sel.mappings.return_value.all.return_value = snapshot
|
||||||
|
sel.scalar_one.return_value = unenrichable
|
||||||
db.execute.return_value = sel
|
db.execute.return_value = sel
|
||||||
return db
|
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)
|
saved = save_detail_enrichment(db, listing_id=404, e=enrichment)
|
||||||
assert saved is False
|
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