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)),
|
request_delay_sec=float(params.get("request_delay_sec", 8.0)),
|
||||||
city=str(params.get("city", "ekaterinburg")),
|
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:
|
except Exception:
|
||||||
logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id)
|
logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id)
|
||||||
try:
|
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
|
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 ─────────────────────────
|
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
||||||
# NOTE (#2397 Part D3): this provider does NOT use BrowserFetcher (unlike
|
# NOTE (#2397 Part D3): this provider does NOT use BrowserFetcher (unlike
|
||||||
# fetch_newbuilding above) — it builds its own curl_cffi session via
|
# 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(
|
async def resolve_yandex_jk_slug(
|
||||||
|
|
@ -323,14 +341,26 @@ async def resolve_yandex_jk_slug(
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig | None = None,
|
||||||
) -> str | 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):
|
Стратегия: запросить страницу ЖК по id с плейсхолдером вместо slug
|
||||||
1. Запросить поисковую страницу Yandex Realty через BrowserFetcher.
|
(`/{city}/kupit/novostrojka/zhk-<jk_id>/`) и прочитать настоящий slug из
|
||||||
URL: /ekaterinburg/kupit/novostrojka/?siteId=<jk_id>
|
ссылок на самой странице. В URL Яндекса авторитетен id, поэтому любой
|
||||||
2. В HTML найти первую ссылку вида /<city>/kupit/novostrojka/<slug>-<jk_id>/
|
непустой slug доезжает до нужной страницы.
|
||||||
через regex _JK_SLUG_RE.
|
|
||||||
3. Вернуть slug или None при любой ошибке.
|
ПОЧЕМУ НЕ 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).
|
Caller несёт ответственность за anti-bot sleep (зеркало cian_newbuilding.py).
|
||||||
BrowserFetcher обязателен — Yandex Realty JS/anti-bot, httpx/curl не работают.
|
BrowserFetcher обязателен — Yandex Realty JS/anti-bot, httpx/curl не работают.
|
||||||
|
|
@ -338,42 +368,50 @@ async def resolve_yandex_jk_slug(
|
||||||
Args:
|
Args:
|
||||||
jk_id: Yandex Realty ext_id ЖК.
|
jk_id: Yandex Realty ext_id ЖК.
|
||||||
city: город (по умолчанию ekaterinburg).
|
city: город (по умолчанию ekaterinburg).
|
||||||
config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher
|
config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher.
|
||||||
(#2322 fix: раньше не принимался вообще, BrowserFetcher(source="yandex")
|
|
||||||
конструировался без endpoint= → TypeError на любом вызове).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
slug (str без id-суффикса), или None при ошибке / не найден.
|
slug (str без id-суффикса), или None. Причина None всегда попадает в лог
|
||||||
|
ОТДЕЛЬНОЙ формулировкой — «не ответил браузер», «пустой HTML» и «страницы
|
||||||
|
такого ЖК нет» требуют разных действий, и сливать их в одно сообщение
|
||||||
|
значит заставлять следующего читателя гадать.
|
||||||
"""
|
"""
|
||||||
# Yandex Realty SERP: фильтр по siteId → первый результат = нужный ЖК.
|
page_url = (
|
||||||
# Альтернативный путь через Яндекс Поиск (web SERP) менее надёжен из-за
|
f"https://realty.yandex.ru/{city}/kupit/novostrojka/"
|
||||||
# вариативности разметки. Прямой realty.yandex.ru SERP — стабильнее.
|
f"{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
|
||||||
serp_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/?siteId={jk_id}"
|
)
|
||||||
endpoint = config.browser_http_endpoint if config is not None else None
|
endpoint = config.browser_http_endpoint if config is not None else None
|
||||||
try:
|
try:
|
||||||
async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher:
|
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:
|
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
|
return None
|
||||||
|
|
||||||
if not html:
|
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
|
return None
|
||||||
|
|
||||||
# Ищем ссылку вида /{city}/kupit/novostrojka/<slug>-<id>/
|
# Плейсхолдер отбрасываем: страница ссылается сама на себя запрошенным
|
||||||
# Ограничиваем: id в ссылке должен совпадать с искомым jk_id.
|
# адресом, и без этого фильтра мы бы «разрешили» slug в тот, что сами же
|
||||||
for m in _JK_SLUG_RE.finditer(html):
|
# и придумали — то есть записали бы выдумку как факт.
|
||||||
if m.group(2) == str(jk_id):
|
slugs = [
|
||||||
slug = m.group(1)
|
slug
|
||||||
logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug)
|
for slug in _slug_link_re(jk_id).findall(html)
|
||||||
return slug
|
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(
|
slug = slugs[0]
|
||||||
"resolve_yandex_jk_slug jk_id=%s: no matching slug in SERP HTML (markup drift?)",
|
logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug)
|
||||||
jk_id,
|
return slug
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue