fix(tradein/avito): обогащать деталями листинги всей области, не только ЕКБ (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
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) Successful in 2m27s

This commit is contained in:
bot-backend 2026-07-31 16:33:04 +03:00
parent 910ef2503c
commit 10442d0187
2 changed files with 132 additions and 25 deletions

View file

@ -30,6 +30,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError,
)
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@ -70,6 +71,16 @@ __all__ = [
"run_avito_detail_backfill",
]
# #2576 этап B: oblast-города (region 66, вне ЕКБ) уже дают листинги (Каменск-
# Уральский), но snapshot-SELECT ниже раньше фильтровал ЖЁСТКО '%/ekaterinburg/%' —
# у всех остальных detail_enriched_at оставался NULL навсегда (без detail-страницы
# нет lat/lon -> листинг молча выпадает из подбора аналогов по радиусу).
# CITY_LOCATIONS.avito_slug — единственный источник правды для avito URL-слага
# города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис,
# verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы
# risk дрейфа при добавлении новых oblast-городов.
_OBLAST_AVITO_URL_PATTERNS = tuple(f"%/{loc.avito_slug}/%" for loc in CITY_LOCATIONS.values())
@dataclass
class AvitoDetailBackfillResult:
@ -102,7 +113,12 @@ async def run_avito_detail_backfill(
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
Params (from default_params jsonb in scrape_schedules):
batch_size: int -- snapshot size (SELECT LIMIT), default 800.
batch_size: int -- ЕКБ snapshot size (SELECT LIMIT), default 800 (unchanged,
#2576 -- volume/order for ЕКБ stay byte-identical to pre-oblast behaviour).
oblast_batch_size: int -- ДОПОЛНИТЕЛЬНАЯ reserved-квота для листингов
области (#2576), default 100. Отдельный LIMIT, НЕ отъедает от batch_size
ЕКБ -- гарантирует области честную обработку и одновременно не даёт
всплеску свежих oblast-листингов вытеснить ЕКБ из top-N по scraped_at.
budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 6.0s.
max_consecutive_blocks: int -- abort threshold, default 5.
@ -111,6 +127,7 @@ async def run_avito_detail_backfill(
mark_done (incl. partial/block-abort) / mark_failed (exception only).
"""
batch_size = int(params.get("batch_size", 800))
oblast_batch_size = int(params.get("oblast_batch_size", 100))
budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 6.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
@ -189,30 +206,59 @@ async def run_avito_detail_backfill(
runs_mod.update_heartbeat(db, run_id, current_counters)
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Scope (#1814): только активные ЕКБ-листинги. region_code на insert
# хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный
# признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy
# Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch
# на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта.
# Не тратим фетчи на мёртвые (is_active) и не-ЕКБ.
# Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast-
# городов (region 66). region_code на insert хардкодится в 66 (base.py) →
# НЕ дискриминирует город; реальный признак города у Avito — путь URL
# (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt-
# peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в
# oblast CTE). browser-fetch на legacy не-ЕКБ/не-oblast спотыкается →
# curl-fallback → 429-бан curl-фингерпринта. Не тратим фетчи на мёртвые
# (is_active) и на регионы вне scope.
#
# Два CTE вместо одного WHERE ... OR ...: ekb сохраняет ТОЧНО прежний
# LIMIT/ORDER (#2576 требование "ЕКБ не деградирует") -- oblast НЕ может
# вытеснить ЕКБ из batch_size ни при каком всплеске свежих oblast-строк
# (ORDER BY ... scraped_at DESC в общем WHERE отдал бы приоритет самым
# свежим независимо от города). oblast получает отдельную честную квоту
# oblast_batch_size, добавленную ПОСЛЕ ekb-квоты (не вычтенную из неё).
snapshot = (
db.execute(
text(
"""
SELECT id, source_url
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE '%/ekaterinburg/%'
-- сперва листинги без координат (#1967 — detail-страница даёт
-- координаты здания), затем по свежести
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
WITH ekb AS (
SELECT id, source_url, 'ekb' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE '%/ekaterinburg/%'
-- сперва листинги без координат (#1967 — detail-страница
-- даёт координаты здания), затем по свежести
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
),
oblast AS (
SELECT id, source_url, 'oblast' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE ANY(CAST(:oblast_patterns AS text[]))
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:oblast_batch_size AS int)
)
SELECT id, source_url, city_scope FROM ekb
UNION ALL
SELECT id, source_url, city_scope FROM oblast
"""
),
{"batch_size": batch_size},
{
"batch_size": batch_size,
"oblast_patterns": list(_OBLAST_AVITO_URL_PATTERNS),
"oblast_batch_size": oblast_batch_size,
},
)
.mappings()
.all()
@ -227,11 +273,16 @@ async def run_avito_detail_backfill(
runs_mod.mark_done(db, run_id, current_counters)
return counters
# #2576: разбивка ekb/oblast только для наблюдаемости -- .get() консервативен
# (city_scope нет в mock-снапшотах старых тестов, дефолт "ekb" их не ломает).
oblast_count = sum(1 for row in snapshot if row.get("city_scope") == "oblast")
logger.info(
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
"delay=%.1fs max_blocks=%d mode=%s)",
"avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, "
"budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)",
run_id,
len(snapshot),
len(snapshot) - oblast_count,
oblast_count,
budget_sec,
request_delay_sec,
max_consecutive_blocks,

View file

@ -14,6 +14,7 @@ import pytest # noqa: E402
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # noqa: E402
_OBLAST_AVITO_URL_PATTERNS,
AvitoDetailBackfillResult,
run_avito_detail_backfill,
)
@ -353,11 +354,14 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None:
@pytest.mark.asyncio
async def test_backfill_snapshot_filters_ekb_active_only() -> None:
"""Снапшот-SELECT (#1814) фильтрует только активные ЕКБ-листинги.
"""Снапшот-SELECT (#1814, расширено #2576) фильтрует активные ЕКБ- И
известные oblast-листинги (region 66), НЕ всё подряд.
Проверяем, что текст запроса содержит `is_active = TRUE` и
`LIKE '%/ekaterinburg/%'` legacy не-ЕКБ (moskva/spb/tyumen) и мёртвые
листинги не попадают в фетч, иначе browser спотыкается curl-бан 429.
Проверяем, что текст запроса содержит `is_active = TRUE`, `LIKE
'%/ekaterinburg/%'` (ekb CTE, LIMIT batch_size НЕ сокращён) и `LIKE ANY(...)`
по oblast-паттернам (oblast CTE, отдельный LIMIT oblast_batch_size) legacy
не-ЕКБ/не-область (moskva/spb/tyumen) и мёртвые листинги не попадают в фетч,
иначе browser спотыкается curl-бан 429.
"""
db = _mock_db([])
runs = MagicMock()
@ -376,8 +380,60 @@ async def test_backfill_snapshot_filters_ekb_active_only() -> None:
sql_text = str(snapshot_call.args[0])
assert "is_active = TRUE" in sql_text
assert "/ekaterinburg/" in sql_text
assert "LIKE ANY(CAST(:oblast_patterns AS text[]))" in sql_text
assert "detail_enriched_at IS NULL" in sql_text
assert "(lat IS NULL) DESC" in sql_text
assert "CAST(:batch_size AS int)" in sql_text
assert "CAST(:oblast_batch_size AS int)" in sql_text
# ekb-квота передаётся batch_size БЕЗ урезания (#2576 требование "ЕКБ не
# деградирует") — oblast получает отдельный (не вычтенный) bind-параметр.
bind_params = snapshot_call.args[1]
assert bind_params["batch_size"] == 10
assert bind_params["oblast_batch_size"] == 100 # default
assert set(bind_params["oblast_patterns"]) == set(_OBLAST_AVITO_URL_PATTERNS)
def test_oblast_avito_url_patterns_cover_region66_cities() -> None:
"""#2576: _OBLAST_AVITO_URL_PATTERNS строится из CITY_LOCATIONS.avito_slug —
список должен покрывать реальные Avito-слаги oblast-городов (в т.ч. те, что
ОТЛИЧАЮТСЯ от нашего city_slug: kamensk-uralskiy через дефис, а не
kamensk_uralskiy)."""
assert "%/kamensk-uralskiy/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/nizhniy_tagil/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/pervouralsk/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/verhnyaya_pyshma/%" in _OBLAST_AVITO_URL_PATTERNS
assert "%/serov/%" in _OBLAST_AVITO_URL_PATTERNS
# ЕКБ обрабатывается отдельным жёстко закодированным паттерном (ekb CTE),
# НЕ через этот oblast-список — не должен в него затесаться.
assert not any("ekaterinburg" in p for p in _OBLAST_AVITO_URL_PATTERNS)
def test_oblast_avito_url_patterns_include_oblast_and_ekb_exclude_foreign_region() -> None:
"""#2576 DoD: листинг города области и екатеринбургский листинг проходят
scope-фильтр; листинг чужого региона (Москва/СПб) нет.
Постгресовый `LIKE '%pat%'` эквивалентен fnmatch с `%` -> `*` (сам паттерн
без иных SQL-метасимволов) реплицируем ту же семантику локально, чтобы
проверить реальные продовые паттерны (_OBLAST_AVITO_URL_PATTERNS) без
поднятия БД (юнит-тесты этого файла её не используют)."""
import fnmatch
def _in_scope(source_url: str) -> bool:
if fnmatch.fnmatchcase(source_url, "*/ekaterinburg/*"):
return True
return any(
fnmatch.fnmatchcase(source_url, pat.replace("%", "*"))
for pat in _OBLAST_AVITO_URL_PATTERNS
)
# Область (Каменск-Уральский, #2576 — реальный кейс из тикета) -- проходит.
assert _in_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123")
# ЕКБ — по-прежнему проходит (не деградировал).
assert _in_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456")
# Чужой регион — НЕ проходит (иначе поехали бы Москва/СПб/Тюмень legacy-строки).
assert not _in_scope("https://www.avito.ru/moskva/kvartiry/prodam_789")
assert not _in_scope("https://www.avito.ru/sankt-peterburg/kvartiry/prodam_000")
@pytest.mark.asyncio