fix(tradein): фильтр выдачи Яндекса мёртв — slug ЖК ищем по странице объекта (#2860) #2923
4 changed files with 260 additions and 32 deletions
|
|
@ -326,7 +326,31 @@ async def _job_yandex_newbuilding_sweep(
|
|||
request_delay_sec=float(params.get("request_delay_sec", 8.0)),
|
||||
city=str(params.get("city", "ekaterinburg")),
|
||||
)
|
||||
ctx.runs.mark_done(db, run_id, result.to_dict())
|
||||
counters = result.to_dict()
|
||||
# #2860: прогон, который обработал дома и не разрешил НИ ОДНОГО, успешным
|
||||
# называть нельзя. Четырнадцать таких прогонов подряд (16.07-17.08.2026)
|
||||
# стояли `done` с пустым error_text — механизм исполнялся, счётчик был
|
||||
# честный, вывод из него не делал никто, и очередь тихо росла 351 → 397.
|
||||
#
|
||||
# НЕ подгоняем счётчик: если дома действительно не разрешаются, честный
|
||||
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
|
||||
processed = int(counters.get("processed") or 0)
|
||||
succeeded = int(counters.get("succeeded") or 0)
|
||||
if processed > 0 and succeeded == 0:
|
||||
logger.warning(
|
||||
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
|
||||
"помечаю прогон неуспешным",
|
||||
run_id,
|
||||
processed,
|
||||
)
|
||||
ctx.runs.mark_failed(
|
||||
db,
|
||||
run_id,
|
||||
f"обработано {processed} домов, разрешено 0 — полный отказ разрешения slug",
|
||||
counters,
|
||||
)
|
||||
else:
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception:
|
||||
logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
"""#2860: обход, не разрешивший ни одного дома, не должен числиться успешным.
|
||||
|
||||
Четырнадцать прогонов подряд (16.07-17.08.2026) стояли `status='done'` с пустым
|
||||
`error_text` при счётчиках `processed 5, succeeded 0`. Исключения не было —
|
||||
поэтому `mark_done`; но прогон не сделал ничего из заявленного, очередь за это
|
||||
время выросла 351 → 397, а витрина `market.yandex_jk_enrichment` замерла на
|
||||
34 строках с 15.07.
|
||||
|
||||
Счётчик при этом был честный. Не хватало вывода из него — ровно тот класс,
|
||||
который эпик #2674 называет самым частым: «механизм исполняется, счётчик
|
||||
честный, вывод из него никто не делает».
|
||||
|
||||
НЕ подгоняем succeeded: если дома не разрешаются, честный исход — назвать
|
||||
прогон неуспешным, а не дотянуть счётчик до зелёного.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Settings требует DATABASE_URL на импорте; соседние тесты хендлеров делают так же
|
||||
# (см. test_scrape_skip_visibility.py) — коннекта не будет, нужен только парс.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services.product_handlers import _job_yandex_newbuilding_sweep
|
||||
|
||||
|
||||
def _ctx() -> SimpleNamespace:
|
||||
return SimpleNamespace(runs=MagicMock())
|
||||
|
||||
|
||||
def _result(processed: int, succeeded: int) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.to_dict = MagicMock(
|
||||
return_value={"processed": processed, "succeeded": succeeded, "total": 425}
|
||||
)
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_resolved_marks_run_failed() -> None:
|
||||
"""processed > 0, succeeded == 0 → прогон неуспешен, причина названа."""
|
||||
ctx = _ctx()
|
||||
with patch(
|
||||
"app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep",
|
||||
AsyncMock(return_value=_result(5, 0)),
|
||||
):
|
||||
await _job_yandex_newbuilding_sweep(MagicMock(), 4156, {}, ctx)
|
||||
|
||||
ctx.runs.mark_done.assert_not_called()
|
||||
ctx.runs.mark_failed.assert_called_once()
|
||||
reason = ctx.runs.mark_failed.call_args[0][2]
|
||||
assert "разрешено 0" in reason, reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_success_still_done() -> None:
|
||||
"""Контроль: хотя бы один разрешённый дом — прогон по-прежнему успешен.
|
||||
|
||||
Зелёный с обеих сторон правки. Без него правка могла бы объявить неуспешным
|
||||
любой неполный прогон — а `succeeded: 1 из 5` был нормой в июле, когда
|
||||
механизм работал.
|
||||
"""
|
||||
ctx = _ctx()
|
||||
with patch(
|
||||
"app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep",
|
||||
AsyncMock(return_value=_result(5, 1)),
|
||||
):
|
||||
await _job_yandex_newbuilding_sweep(MagicMock(), 1489, {}, ctx)
|
||||
|
||||
ctx.runs.mark_failed.assert_not_called()
|
||||
ctx.runs.mark_done.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_queue_is_not_a_failure() -> None:
|
||||
"""Контроль: обрабатывать было нечего (processed == 0) — это не отказ.
|
||||
|
||||
Иначе гейт краснел бы каждый раз, когда очередь разобрана до конца, то есть
|
||||
ровно в успешном состоянии.
|
||||
"""
|
||||
ctx = _ctx()
|
||||
with patch(
|
||||
"app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep",
|
||||
AsyncMock(return_value=_result(0, 0)),
|
||||
):
|
||||
await _job_yandex_newbuilding_sweep(MagicMock(), 9999, {}, ctx)
|
||||
|
||||
ctx.runs.mark_failed.assert_not_called()
|
||||
ctx.runs.mark_done.assert_called_once()
|
||||
|
|
@ -158,6 +158,78 @@ async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch):
|
|||
assert kwargs.get("endpoint") is None
|
||||
|
||||
|
||||
# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
|
||||
#
|
||||
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
|
||||
# больше не применяется — отдаётся общий список новостроек (1.7 МБ, 35 разных
|
||||
# ЖК), запрошенного id среди них нет. Разметка при этом цела: прежний regex
|
||||
# находил 128 ссылок нужной формы, просто ни одной с нужным id. Из-за этого
|
||||
# обход не разрешил НИ ОДНОГО дома с 16.07.2026 (14 прогонов подряд 5/0).
|
||||
|
||||
_JK_PAGE_HTML = (
|
||||
"<html><head><title>ЖК «Успенский»</title></head><body>"
|
||||
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">эта же страница</a>'
|
||||
'<a href="/ekaterinburg/kupit/novostrojka/uspenskij-286394/">ЖК «Успенский»</a>'
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
# Общий список: ссылок много, с нужным id — ни одной. Ровно то, что прод
|
||||
# отдавал на `?siteId=`.
|
||||
_GENERAL_LIST_HTML = (
|
||||
'<a href="/ekaterinburg/kupit/novostrojka/shishkinn-2671892/">ШишкINN</a>'
|
||||
'<a href="/ekaterinburg/kupit/novostrojka/parkovyj-1637230/">Парковый</a>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypatch):
|
||||
"""Запрашивается страница ЖК по id, а НЕ выдача с ?siteId=.
|
||||
|
||||
Это и есть суть правки: адрес запроса, а не разбор ответа.
|
||||
"""
|
||||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
||||
|
||||
await resolve_yandex_jk_slug("286394")
|
||||
|
||||
url = spy.return_value.fetch.call_args[0][0]
|
||||
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
|
||||
assert url.endswith("/kupit/novostrojka/zhk-286394/"), url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
|
||||
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
|
||||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
||||
|
||||
assert await resolve_yandex_jk_slug("286394") == "uspenskij"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
|
||||
"""Отрицательный контроль: ссылки есть, с нашим id — нет → None, а не чужой slug.
|
||||
|
||||
Прод-эквивалент: несуществующий id 999999999 отдаёт общий список.
|
||||
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
|
||||
"""
|
||||
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
|
||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
||||
|
||||
assert await resolve_yandex_jk_slug("286394") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch):
|
||||
"""Если на странице только наша же ссылка — это не разрешение, а эхо."""
|
||||
spy = _spy_browser_fetcher(
|
||||
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
|
||||
)
|
||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
||||
|
||||
assert await resolve_yandex_jk_slug("286394") is None
|
||||
|
||||
|
||||
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
||||
# NOTE (#2397 Part D3): this provider does NOT use BrowserFetcher (unlike
|
||||
# fetch_newbuilding above) — it builds its own curl_cffi session via
|
||||
|
|
|
|||
|
|
@ -314,7 +314,25 @@ class YandexNewbuildingScraper(BaseScraper):
|
|||
)
|
||||
|
||||
|
||||
# ── Slug resolution via SERP ──────────────────────────────────────────────────
|
||||
# ── Slug resolution по id страницы ЖК ─────────────────────────────────────────
|
||||
|
||||
|
||||
# Плейсхолдер вместо неизвестного slug: в URL ЖК авторитетен ИМЕННО id, а slug
|
||||
# — косметика. Проверено на проде 19.08.2026: /zhk-286394/ отдаёт страницу
|
||||
# ЖК «Успенский», /zhk-2671892/ — «ШишкINN». Значение слова роли не играет,
|
||||
# важно лишь, что оно не пустое (Яндекс не принимает `/-<id>/`).
|
||||
_JK_SLUG_PLACEHOLDER = "zhk"
|
||||
|
||||
|
||||
def _slug_link_re(jk_id: str) -> re.Pattern[str]:
|
||||
"""Ссылка на ЖК С ЭТИМ ЖЕ id: /kupit/novostrojka/<slug>-<jk_id>/.
|
||||
|
||||
id зашит в шаблон намеренно — общий `_JK_SLUG_RE` матчит ЛЮБУЮ пару
|
||||
slug-id и на общем списке новостроек даёт 128 совпадений, среди которых
|
||||
нужного может не быть вовсе. Привязка к id — то, что отличает «нашли ЖК»
|
||||
от «нашли какой-то ЖК».
|
||||
"""
|
||||
return re.compile(rf"/kupit/novostrojka/([a-z0-9-]+)-{re.escape(str(jk_id))}/")
|
||||
|
||||
|
||||
async def resolve_yandex_jk_slug(
|
||||
|
|
@ -323,14 +341,26 @@ async def resolve_yandex_jk_slug(
|
|||
*,
|
||||
config: ScraperConfig | None = None,
|
||||
) -> str | None:
|
||||
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id) через SERP.
|
||||
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id).
|
||||
|
||||
Стратегия (#974 — зеркало resolve_cian_zhk_url_via_search):
|
||||
1. Запросить поисковую страницу Yandex Realty через BrowserFetcher.
|
||||
URL: /ekaterinburg/kupit/novostrojka/?siteId=<jk_id>
|
||||
2. В HTML найти первую ссылку вида /<city>/kupit/novostrojka/<slug>-<jk_id>/
|
||||
через regex _JK_SLUG_RE.
|
||||
3. Вернуть slug или None при любой ошибке.
|
||||
Стратегия: запросить страницу ЖК по id с плейсхолдером вместо slug
|
||||
(`/{city}/kupit/novostrojka/zhk-<jk_id>/`) и прочитать настоящий slug из
|
||||
ссылок на самой странице. В URL Яндекса авторитетен id, поэтому любой
|
||||
непустой slug доезжает до нужной страницы.
|
||||
|
||||
ПОЧЕМУ НЕ SERP (замер 19.08.2026, issue #2860). До этой правки slug искали
|
||||
на поисковой выдаче с фильтром `?siteId=<jk_id>`. Фильтр Яндексом больше НЕ
|
||||
применяется: страница отдаёт общий список новостроек — 1.7 МБ, 35 разных
|
||||
ЖК, и запрошенного id среди них нет. Проверено на двух разных id, ответы
|
||||
почти совпадают. Разметка при этом цела: старый regex находил 128 ссылок
|
||||
нужной формы — то есть парсер работал, а стратегия умерла.
|
||||
|
||||
Из-за этого обход не разрешал НИ ОДНОГО дома с 16.07.2026: четырнадцать
|
||||
прогонов подряд `processed 5, succeeded 0`, витрина замерла на 34 строках,
|
||||
очередь выросла 351 → 397.
|
||||
|
||||
Отрицательный контроль (тот же замер): несуществующий id 999999999 отдаёт
|
||||
общий список, ссылок с этим id нет → функция вернёт None, а не чужой slug.
|
||||
|
||||
Caller несёт ответственность за anti-bot sleep (зеркало cian_newbuilding.py).
|
||||
BrowserFetcher обязателен — Yandex Realty JS/anti-bot, httpx/curl не работают.
|
||||
|
|
@ -338,42 +368,50 @@ async def resolve_yandex_jk_slug(
|
|||
Args:
|
||||
jk_id: Yandex Realty ext_id ЖК.
|
||||
city: город (по умолчанию ekaterinburg).
|
||||
config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher
|
||||
(#2322 fix: раньше не принимался вообще, BrowserFetcher(source="yandex")
|
||||
конструировался без endpoint= → TypeError на любом вызове).
|
||||
config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher.
|
||||
|
||||
Returns:
|
||||
slug (str без id-суффикса), или None при ошибке / не найден.
|
||||
slug (str без id-суффикса), или None. Причина None всегда попадает в лог
|
||||
ОТДЕЛЬНОЙ формулировкой — «не ответил браузер», «пустой HTML» и «страницы
|
||||
такого ЖК нет» требуют разных действий, и сливать их в одно сообщение
|
||||
значит заставлять следующего читателя гадать.
|
||||
"""
|
||||
# Yandex Realty SERP: фильтр по siteId → первый результат = нужный ЖК.
|
||||
# Альтернативный путь через Яндекс Поиск (web SERP) менее надёжен из-за
|
||||
# вариативности разметки. Прямой realty.yandex.ru SERP — стабильнее.
|
||||
serp_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/?siteId={jk_id}"
|
||||
page_url = (
|
||||
f"https://realty.yandex.ru/{city}/kupit/novostrojka/"
|
||||
f"{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
|
||||
)
|
||||
endpoint = config.browser_http_endpoint if config is not None else None
|
||||
try:
|
||||
async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher:
|
||||
html = await fetcher.fetch(serp_url)
|
||||
html = await fetcher.fetch(page_url)
|
||||
except Exception as exc:
|
||||
logger.warning("resolve_yandex_jk_slug jk_id=%s browser fetch failed: %s", jk_id, exc)
|
||||
logger.warning("resolve_yandex_jk_slug jk_id=%s: браузер не ответил: %s", jk_id, exc)
|
||||
return None
|
||||
|
||||
if not html:
|
||||
logger.warning("resolve_yandex_jk_slug jk_id=%s: empty HTML from browser", jk_id)
|
||||
logger.warning("resolve_yandex_jk_slug jk_id=%s: пустой HTML от браузера", jk_id)
|
||||
return None
|
||||
|
||||
# Ищем ссылку вида /{city}/kupit/novostrojka/<slug>-<id>/
|
||||
# Ограничиваем: id в ссылке должен совпадать с искомым jk_id.
|
||||
for m in _JK_SLUG_RE.finditer(html):
|
||||
if m.group(2) == str(jk_id):
|
||||
slug = m.group(1)
|
||||
logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug)
|
||||
return slug
|
||||
# Плейсхолдер отбрасываем: страница ссылается сама на себя запрошенным
|
||||
# адресом, и без этого фильтра мы бы «разрешили» slug в тот, что сами же
|
||||
# и придумали — то есть записали бы выдумку как факт.
|
||||
slugs = [
|
||||
slug
|
||||
for slug in _slug_link_re(jk_id).findall(html)
|
||||
if slug != _JK_SLUG_PLACEHOLDER
|
||||
]
|
||||
if not slugs:
|
||||
logger.warning(
|
||||
"resolve_yandex_jk_slug jk_id=%s: на странице нет ни одной ссылки с этим id "
|
||||
"(ЖК снят с публикации либо id больше не действителен); длина HTML=%d",
|
||||
jk_id,
|
||||
len(html),
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"resolve_yandex_jk_slug jk_id=%s: no matching slug in SERP HTML (markup drift?)",
|
||||
jk_id,
|
||||
)
|
||||
return None
|
||||
slug = slugs[0]
|
||||
logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug)
|
||||
return slug
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue