fix(tradein/scraper): проставлять город объявления из контекста развёртки (#2594) #2598
7 changed files with 564 additions and 13 deletions
44
tradein-mvp/backend/data/sql/196_listings_city.sql
Normal file
44
tradein-mvp/backend/data/sql/196_listings_city.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
-- 196_listings_city.sql
|
||||
-- Issue #2594 — критичный дефект: скрапер знает город в момент сбора (city_slug из
|
||||
-- CITY_LOCATIONS/CITY_ANCHORS, packages/scraper-kit/.../orchestration/pipeline.py), но
|
||||
-- НИКУДА его не пишет. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
|
||||
-- ("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30") — cian даже явно вырезает
|
||||
-- location-часть перед записью (skip_types = {"location", "metro"}, providers/cian/serp.py).
|
||||
-- Без явного города такой адрес при геокодинге считается «город не назван» → попадает
|
||||
-- в EKB-only локальные реестры (ekb_geoportal_buildings/gendesign_cad_buildings) и
|
||||
-- коллизирует с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни
|
||||
-- совпадений) → объявление получает координаты Екатеринбурга и тянет медиану чужих цен.
|
||||
--
|
||||
-- Fix:
|
||||
-- Add listings.city TEXT column. Проставляется НЕПОСРЕДСТВЕННО из контекста
|
||||
-- развёртки (город известен вызывающему коду — city_slug/CITY_LOCATIONS для oblast,
|
||||
-- "Екатеринбург" для EKB-развёрток) — НЕ парсингом текста адреса. См.
|
||||
-- scraper_kit.base.save_listings(..., city=...) + scraper_kit.orchestration.pipeline
|
||||
-- .resolve_city_name(). Раздельная колонка (а не дописывание города в address) —
|
||||
-- исходный текст адреса не портится, downstream text-парсеры (geocoder._parse_street_house,
|
||||
-- geocoder._names_non_ekb_city, estimator._parse_street_house, house-matching) продолжают
|
||||
-- работать НЕИЗМЕНЁННЫМИ на исходном сыром тексте — риск регрессии на bare-form адресах
|
||||
-- без street-маркера ("Дружинина, 33") исключён.
|
||||
--
|
||||
-- Scope (#2594): только write-path для НОВЫХ листингов (go-forward). Бэкфилл city для
|
||||
-- уже накопленных строк (restore по тому, какая развёртка их когда-то принесла) —
|
||||
-- отдельная задача, НЕ эта миграция.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS — safe on re-run.
|
||||
-- BEGIN/COMMIT block.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 002_core_tables.sql (listings table).
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE listings ADD COLUMN IF NOT EXISTS city text;
|
||||
|
||||
COMMENT ON COLUMN listings.city IS
|
||||
'Город объявления (#2594) — проставляется из контекста развёртки '
|
||||
'(city_slug city-sweep / "Екатеринбург" default), НЕ парсингом address. '
|
||||
'NULL — листинг записан до этой миграции ИЛИ путём, ещё не проставляющим город '
|
||||
'(admin ad-hoc /admin/scrape, manual ingest-скрипты).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -27,6 +27,41 @@ def test_ekb_anchors_count() -> None:
|
|||
assert isinstance(name, str) and name
|
||||
|
||||
|
||||
# ── resolve_city_name (#2594) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_resolve_city_name_known_oblast_slugs() -> None:
|
||||
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя."""
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name
|
||||
|
||||
expected = {
|
||||
"nizhniy_tagil": "Нижний Тагил",
|
||||
"kamensk_uralskiy": "Каменск-Уральский",
|
||||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
# CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS
|
||||
# (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени).
|
||||
assert set(expected) == set(CITY_LOCATIONS)
|
||||
for slug, name in expected.items():
|
||||
assert resolve_city_name(slug) == name
|
||||
|
||||
|
||||
def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
|
||||
"""city_slug=None — ЕКБ-развёртка той же функции, НЕ «город неизвестен» (#2594 симметрия)."""
|
||||
from scraper_kit.orchestration.pipeline import EKATERINBURG_CITY_NAME, resolve_city_name
|
||||
|
||||
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
|
||||
|
||||
|
||||
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
|
||||
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
|
||||
from scraper_kit.orchestration.pipeline import resolve_city_name
|
||||
|
||||
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
|
||||
|
||||
|
||||
# ── CitySweepCounters ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
232
tradein-mvp/backend/tests/test_listings_city_from_sweep.py
Normal file
232
tradein-mvp/backend/tests/test_listings_city_from_sweep.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""#2594: listings.city проставляется из контекста развёртки, не парсингом адреса.
|
||||
|
||||
Критичный дефект: скрапер ЗНАЕТ город в момент сбора (city_slug из
|
||||
scraper_kit.orchestration.pipeline.CITY_LOCATIONS/CITY_ANCHORS), но раньше нигде его
|
||||
не записывал. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
|
||||
("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30" — cian даже явно вырезает
|
||||
location-часть, providers/cian/serp.py `_format_address` skip_types={"location",...}).
|
||||
Без города такой адрес при геокодинге считался «город не назван» и коллизировал с
|
||||
одноимённой ЕКБ-улицей (Ленина/Победы/Тенистая — сотни совпадений в ЕКБ-реестре).
|
||||
|
||||
Fix: отдельная колонка `listings.city`, проставляется из sweep-контекста (НЕ парсингом
|
||||
address) через `scraper_kit.base.save_listings(..., city=...)` +
|
||||
`scraper_kit.orchestration.pipeline.resolve_city_name(city_slug)`. Тесты здесь проверяют
|
||||
write-path (save_listings SQL) и pure resolve_city_name; orchestration-level проверки
|
||||
(save_listings вызывается с правильным city= из каждого sweep) — в
|
||||
test_scraper_kit_pipeline_parity.py / test_scraper_kit_pipeline_parity2.py.
|
||||
|
||||
Границы (#2594): бэкфилл уже накопленных строк — НЕ в этой задаче.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import ScrapedLot as KitLot
|
||||
from scraper_kit.base import save_listings as kit_save_listings
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _nested_ctx() -> Any:
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _mock_db_insert_path(listing_id: int = 42) -> MagicMock:
|
||||
"""Session mock для fresh INSERT path (xmax = 0 → inserted)."""
|
||||
insert_row = MagicMock()
|
||||
insert_row.id = listing_id
|
||||
insert_row.inserted = True
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
res.fetchone.return_value = insert_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
db.begin_nested.side_effect = _nested_ctx
|
||||
return db
|
||||
|
||||
|
||||
def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]:
|
||||
for call in db.execute.call_args_list:
|
||||
sql = str(call.args[0])
|
||||
if needle in sql:
|
||||
params = call.args[1] if len(call.args) > 1 else {}
|
||||
return sql, params
|
||||
raise AssertionError(f"SQL containing {needle!r} not found")
|
||||
|
||||
|
||||
def _kit_matcher() -> MagicMock:
|
||||
matcher = MagicMock()
|
||||
matcher.match_or_create_house.return_value = (101, 1.0, "new")
|
||||
matcher.upsert_listing_source.return_value = None
|
||||
return matcher
|
||||
|
||||
|
||||
def _lot(source: str = "avito", source_id: str = "1", address: str | None = None) -> KitLot:
|
||||
return KitLot(
|
||||
source=source,
|
||||
source_url=f"https://www.{source}.ru/item/{source_id}",
|
||||
source_id=source_id,
|
||||
address=address,
|
||||
price_rub=3_000_000,
|
||||
)
|
||||
|
||||
|
||||
# ── save_listings(..., city=...) — INSERT path ────────────────────────────────
|
||||
|
||||
|
||||
def test_save_listings_writes_city_into_insert_sql() -> None:
|
||||
"""city="Нижний Тагил" передаётся в SQL params И колонка есть в INSERT-списке."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Победы, 30")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Нижний Тагил")
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "city" in sql, "city column must be in INSERT column list"
|
||||
assert params["city"] == "Нижний Тагил"
|
||||
# address НЕ тронут — критичное требование #2594 (раздельная колонка, а не
|
||||
# дописывание города в текст адреса, чтобы не сломать downstream text-парсеры).
|
||||
assert params["address"] == "ул. Победы, 30"
|
||||
|
||||
|
||||
def test_save_listings_city_defaults_to_none_backward_compat() -> None:
|
||||
"""Caller без city= (старые/ad-hoc пути) — колонка остаётся NULL, backward-compatible."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Малышева, 30")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
_sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert params["city"] is None
|
||||
|
||||
|
||||
def test_save_listings_ekaterinburg_city_written_unchanged_address() -> None:
|
||||
"""ЕКБ-развёртка (city="Екатеринбург") — тот же путь, address не деградирует."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Малышева, 30")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Екатеринбург")
|
||||
|
||||
_sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert params["city"] == "Екатеринбург"
|
||||
assert params["address"] == "ул. Малышева, 30"
|
||||
|
||||
|
||||
# ── ON CONFLICT DO UPDATE / reconcile UPDATE — COALESCE не затирает known city ──
|
||||
|
||||
|
||||
def test_save_listings_on_conflict_coalesces_city() -> None:
|
||||
"""ON CONFLICT DO UPDATE — city = COALESCE(EXCLUDED.city, listings.city), не blind overwrite."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Победы, 30")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Нижний Тагил")
|
||||
|
||||
sql, _params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "city = COALESCE(EXCLUDED.city, listings.city)" in sql
|
||||
|
||||
|
||||
def test_save_listings_reconcile_update_coalesces_city() -> None:
|
||||
"""dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:city, city), не blind overwrite."""
|
||||
import psycopg.errors
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
uv_orig = psycopg.errors.UniqueViolation()
|
||||
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
|
||||
rec_row = MagicMock()
|
||||
rec_row.id = 88
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
raise integrity_err
|
||||
elif "UPDATE listings" in s and "SET dedup_hash" in s:
|
||||
res.fetchone.return_value = rec_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
|
||||
@contextmanager
|
||||
def _nested() -> Any:
|
||||
try:
|
||||
yield MagicMock()
|
||||
except IntegrityError:
|
||||
raise
|
||||
|
||||
db.begin_nested.side_effect = _nested
|
||||
|
||||
lot = _lot(source="avito", source_id="7960764619", address="ул. Тенистая, 17")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Серов")
|
||||
|
||||
sql, params = _find_call(db, "SET dedup_hash")
|
||||
assert "city = COALESCE(:city, city)" in sql
|
||||
assert params["city"] == "Серов"
|
||||
|
||||
|
||||
# ── Migration 196: listings.city column ────────────────────────────────────────
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_196 = _SQL_DIR / "196_listings_city.sql"
|
||||
|
||||
|
||||
def test_migration_196_exists() -> None:
|
||||
assert _MIGRATION_196.is_file(), f"missing migration: {_MIGRATION_196}"
|
||||
|
||||
|
||||
def test_migration_196_is_transactional() -> None:
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_196_idempotent_add_column() -> None:
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert "ADD COLUMN IF NOT EXISTS city" in sql
|
||||
|
||||
|
||||
def test_migration_196_no_psycopg_cast_trap() -> None:
|
||||
"""psycopg v3: никаких :param::type (не применимо тут — чистый DDL — но проверяем
|
||||
на будущее, если файл когда-нибудь обрастёт bind-параметрами)."""
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
def test_migration_196_non_destructive() -> None:
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert "DROP" not in sql.upper()
|
||||
assert "DELETE" not in sql.upper()
|
||||
assert "TRUNCATE" not in sql.upper()
|
||||
|
|
@ -93,6 +93,7 @@ class _Scenario:
|
|||
avito_serp_ok_not_banned: bool = True,
|
||||
avito_proxy_max_rotations: int = 0,
|
||||
lots_have_house_url: bool = False,
|
||||
city_slug: str | None = None,
|
||||
) -> None:
|
||||
self.anchors = anchors
|
||||
self.per_anchor = per_anchor
|
||||
|
|
@ -105,6 +106,9 @@ class _Scenario:
|
|||
self.avito_serp_ok_not_banned = avito_serp_ok_not_banned
|
||||
self.avito_proxy_max_rotations = avito_proxy_max_rotations
|
||||
self.lots_have_house_url = lots_have_house_url
|
||||
# #2594: city_slug развёртки — прокидывается в run_avito_city_sweep(city_slug=...)
|
||||
# для проверки, что save_listings получает правильный city=... из контекста.
|
||||
self.city_slug = city_slug
|
||||
|
||||
def _config(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
|
|
@ -170,11 +174,16 @@ def _async_session_cm() -> MagicMock:
|
|||
return sess
|
||||
|
||||
|
||||
async def _drive(scenario: _Scenario) -> _DriveResult:
|
||||
async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||
"""capture: опциональный dict — если передан, кладём туда save_mock (#2594) для
|
||||
инспекции call_args (city=...) без изменения возвращаемого _DriveResult (backward-compat
|
||||
для всех существующих вызовов _drive без capture)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = _make_db(scenario)
|
||||
scraper = _make_scraper(scenario, AvitoBlockedError)
|
||||
save_mock = MagicMock(side_effect=scenario._save_side_effects())
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
|
||||
imv_res = None
|
||||
if scenario.imv_result is not None:
|
||||
|
|
@ -208,6 +217,7 @@ async def _drive(scenario: _Scenario) -> _DriveResult:
|
|||
shutdown_requested=lambda: False,
|
||||
radius_m=1000,
|
||||
anchors=scenario.anchors,
|
||||
city_slug=scenario.city_slug,
|
||||
pages_per_anchor=1,
|
||||
enrich_houses=scenario.enrich_houses,
|
||||
detail_top_n=scenario.detail_top_n,
|
||||
|
|
@ -307,3 +317,43 @@ async def test_imv_phase_counters() -> None:
|
|||
assert counters["imv_attempted"] == 3
|
||||
assert counters["imv_enriched"] == 2
|
||||
assert counters["imv_failed"] == 1
|
||||
|
||||
|
||||
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
|
||||
#
|
||||
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
|
||||
# писала — адрес без города в тексте ("ул. Победы, 30") при геокодинге считался
|
||||
# «город не назван» и коллизировал с одноимённой ЕКБ-улицей. Тесты проверяют, что
|
||||
# save_listings() теперь получает правильный city= для обоих случаев: явный
|
||||
# oblast-город (city_slug задан) И EKB-развёртка той же функции (city_slug=None —
|
||||
# симметрия, а не «не знаем город»).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_city_stamped_from_city_slug() -> None:
|
||||
"""city_slug='nizhniy_tagil' → save_listings(..., city='Нижний Тагил')."""
|
||||
scenario = _Scenario(
|
||||
anchors=[(56.84, 60.60, "A1")],
|
||||
per_anchor=[("lots", 3, 3, 0)],
|
||||
city_slug="nizhniy_tagil",
|
||||
)
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive(scenario, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Нижний Тагил"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_city_defaults_to_ekaterinburg_when_no_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-развёртка той же run_avito_city_sweep) →
|
||||
save_listings(..., city='Екатеринбург') — симметрия с oblast-городами (#2594),
|
||||
а не оставленный NULL."""
|
||||
scenario = _Scenario(
|
||||
anchors=[(56.84, 60.60, "A1")],
|
||||
per_anchor=[("lots", 3, 3, 0)],
|
||||
city_slug=None,
|
||||
)
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive(scenario, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
||||
|
|
|
|||
|
|
@ -136,7 +136,10 @@ def _yandex_scraper(combos: list[tuple[str, list[Any]]]) -> MagicMock:
|
|||
return _ctx_scraper(fetch_around_multi_room=_fetch)
|
||||
|
||||
|
||||
async def _drive_yandex_city() -> _DriveResult:
|
||||
async def _drive_yandex_city(
|
||||
*, city_slug: str | None = None, capture: dict[str, Any] | None = None
|
||||
) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
combos = [
|
||||
|
|
@ -145,6 +148,8 @@ async def _drive_yandex_city() -> _DriveResult:
|
|||
]
|
||||
scraper = _yandex_scraper(combos)
|
||||
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
enrichment = MagicMock()
|
||||
enrichment.record_yandex_price_history = MagicMock(return_value=5)
|
||||
|
|
@ -160,6 +165,7 @@ async def _drive_yandex_city() -> _DriveResult:
|
|||
enrichment=enrichment,
|
||||
run_id=1,
|
||||
anchors=None,
|
||||
city_slug=city_slug,
|
||||
pages_per_anchor=1,
|
||||
request_delay_sec=0.0,
|
||||
enrich_address=False,
|
||||
|
|
@ -185,13 +191,18 @@ def _cian_lot(segment: str) -> MagicMock:
|
|||
return MagicMock(listing_segment=segment, house_source=None, house_ext_id=None)
|
||||
|
||||
|
||||
async def _drive_cian_city() -> _DriveResult:
|
||||
async def _drive_cian_city(
|
||||
*, city_slug: str | None = None, capture: dict[str, Any] | None = None
|
||||
) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
# 3 novostroyki + 2 secondary → newbuilding_only оставит 3.
|
||||
lots = [_cian_lot("novostroyki")] * 3 + [_cian_lot("vtorichnaya")] * 2
|
||||
scraper = _ctx_scraper(fetch_around_multi_room=AsyncMock(return_value=lots))
|
||||
save_mock = MagicMock(side_effect=[(3, 0)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
with (
|
||||
patch(f"{PFX}.CianScraper", return_value=scraper),
|
||||
|
|
@ -204,6 +215,7 @@ async def _drive_cian_city() -> _DriveResult:
|
|||
matcher=MagicMock(),
|
||||
run_id=1,
|
||||
anchors=[(56.84, 60.60, "A1")],
|
||||
city_slug=city_slug,
|
||||
radius_m=1000,
|
||||
pages_per_anchor=1,
|
||||
request_delay_sec=0.0,
|
||||
|
|
@ -228,7 +240,10 @@ async def test_cian_city_sweep() -> None:
|
|||
# ── DomClick city sweep ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
|
||||
async def _drive_domclick(
|
||||
*, lots_n: int, blocked: bool, capture: dict[str, Any] | None = None
|
||||
) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
lots = [MagicMock() for _ in range(lots_n)]
|
||||
|
|
@ -239,6 +254,8 @@ async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
|
|||
fetch_errors=0,
|
||||
)
|
||||
save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else [])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
with (
|
||||
patch(f"{PFX}.DomClickScraper", return_value=scraper),
|
||||
|
|
@ -272,7 +289,8 @@ async def test_domclick_city_sweep_blocked_failed() -> None:
|
|||
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _drive_nb_sweep() -> _DriveResult:
|
||||
async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
lots = [MagicMock() for _ in range(6)]
|
||||
|
|
@ -281,6 +299,8 @@ async def _drive_nb_sweep() -> _DriveResult:
|
|||
scraper._browser = None
|
||||
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
|
||||
save_mock = MagicMock(side_effect=[(5, 1)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
with (
|
||||
patch(f"{PFX}.AvitoScraper", return_value=scraper),
|
||||
|
|
@ -319,7 +339,8 @@ def _full_load_scraper(buckets: list[tuple[str, list[Any]]]) -> MagicMock:
|
|||
return scraper
|
||||
|
||||
|
||||
async def _drive_full_load(*, source: str) -> _DriveResult:
|
||||
async def _drive_full_load(*, source: str, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
buckets = [
|
||||
|
|
@ -328,6 +349,8 @@ async def _drive_full_load(*, source: str) -> _DriveResult:
|
|||
]
|
||||
scraper = _full_load_scraper(buckets)
|
||||
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
|
||||
fn_map = {
|
||||
|
|
@ -365,3 +388,77 @@ async def test_full_load_smoke(source: str) -> None:
|
|||
assert counters["saved_inserted"] == 3
|
||||
assert counters["saved_updated"] == 0
|
||||
assert calls[-1][0] == "mark_done"
|
||||
|
||||
|
||||
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
|
||||
#
|
||||
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
|
||||
# писала. Тесты проверяют save_listings(..., city=...) для yandex/cian city-sweep
|
||||
# (oblast + EKB-симметрия), domclick (EKB-only city_id) и full_load'ов (ЕКБ вторичка).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yandex_city_sweep_stamps_city_from_slug() -> None:
|
||||
"""city_slug='kamensk_uralskiy' → save_listings(..., city='Каменск-Уральский')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_yandex_city(city_slug="kamensk_uralskiy", capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args_list[-1].kwargs["city"] == "Каменск-Уральский"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yandex_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_yandex_city(city_slug=None, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args_list[-1].kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_city_sweep_stamps_city_from_slug() -> None:
|
||||
"""city_slug='pervouralsk' → save_listings(..., city='Первоуральск')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_cian_city(city_slug="pervouralsk", capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Первоуральск"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_cian_city(city_slug=None, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domclick_city_sweep_stamps_ekaterinburg_for_default_city_id() -> None:
|
||||
"""city_id=DOMCLICK_DEFAULT_CITY_ID (4, ЕКБ) → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_domclick(lots_n=4, blocked=False, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avito_newbuilding_sweep_stamps_ekaterinburg() -> None:
|
||||
"""Citywide novostroyka-обход — только ЕКБ → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_nb_sweep(capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
||||
async def test_full_load_stamps_ekaterinburg(source: str) -> None:
|
||||
"""Exhaustive региональный сбор — только ЕКБ вторичка → city='Екатеринбург' на КАЖДОМ
|
||||
бакете (on_bucket сохраняет инкрементально, не один batch на весь run)."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_full_load(source=source, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_count > 0
|
||||
for call in save_mock.call_args_list:
|
||||
assert call.kwargs["city"] == "Екатеринбург"
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ def save_listings(
|
|||
region_code: int,
|
||||
run_id: int | None = None,
|
||||
skip_seen_today: bool = False,
|
||||
city: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
|
||||
|
||||
|
|
@ -324,6 +325,14 @@ def save_listings(
|
|||
Используется full_load для экономии redundant upsert + price-trigger churn
|
||||
при повторном прогоне в тот же день. Новые листинги (prior_row=None) всегда
|
||||
вставляются. False = старое поведение (всегда upsert).
|
||||
city: человекочитаемое имя города-цели ЭТОГО batch'а (#2594), например
|
||||
"Нижний Тагил"/"Екатеринбург". Развёртка знает город из своего контекста
|
||||
(city_slug) — ОДИН на весь вызов save_listings (все lots одного batch'а
|
||||
принадлежат одному city-sweep run'у), поэтому это kwarg, а НЕ поле
|
||||
ScrapedLot. None (default) — вызывающая сторона город не знает (ad-hoc
|
||||
admin/manual пути) — колонка остаётся NULL, backward-compatible.
|
||||
ON CONFLICT — COALESCE (новое значение НЕ затирает уже известный город
|
||||
NULL'ом, если какой-то caller ещё не передаёт city).
|
||||
|
||||
Returns:
|
||||
(inserted, updated) — counters для логов.
|
||||
|
|
@ -380,6 +389,7 @@ def save_listings(
|
|||
"dedup": dedup,
|
||||
"region_code": region_code,
|
||||
"address": lot.address,
|
||||
"city": city,
|
||||
"lat": lot.lat,
|
||||
"lon": lot.lon,
|
||||
"rooms": lot.rooms,
|
||||
|
|
@ -439,7 +449,7 @@ def save_listings(
|
|||
"""
|
||||
INSERT INTO listings (
|
||||
source, source_url, source_id, dedup_hash,
|
||||
address, lat, lon, region_code,
|
||||
address, city, lat, lon, region_code,
|
||||
rooms, area_m2, floor, total_floors, year_built,
|
||||
house_type, repair_state, has_balcony,
|
||||
kitchen_area_m2, ceiling_height, ceiling_height_m,
|
||||
|
|
@ -460,7 +470,7 @@ def save_listings(
|
|||
scraped_at, last_seen_at
|
||||
) VALUES (
|
||||
:source, :source_url, :source_id, :dedup,
|
||||
:address, :lat, :lon, :region_code,
|
||||
:address, :city, :lat, :lon, :region_code,
|
||||
:rooms, :area_m2, :floor, :total_floors, :year_built,
|
||||
:house_type, :repair_state, :has_balcony,
|
||||
-- ceiling: один param :ceiling_height_m пишем в ОБЕ колонки —
|
||||
|
|
@ -516,6 +526,9 @@ def save_listings(
|
|||
metro_stations = EXCLUDED.metro_stations,
|
||||
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
|
||||
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
|
||||
-- #2594: город развёртки — COALESCE, чтобы caller без city (ad-hoc
|
||||
-- admin/manual пути, city=None) не затирал уже известный город.
|
||||
city = COALESCE(EXCLUDED.city, listings.city),
|
||||
-- kitchen/ceiling (#2007): COALESCE — SERP re-scrape источника без
|
||||
-- этих полей (avito SERP → NULL) НЕ затирает detail-enriched значение
|
||||
-- (avito_detail пишет ceiling_height_m отдельным UPDATE).
|
||||
|
|
@ -627,6 +640,7 @@ def save_listings(
|
|||
metro_stations = CAST(:metro_stations AS jsonb),
|
||||
listing_date = COALESCE(:listing_date, listing_date),
|
||||
area_m2 = COALESCE(:area_m2, area_m2),
|
||||
city = COALESCE(:city, city),
|
||||
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
|
||||
ceiling_height = COALESCE(:ceiling_height_m, ceiling_height),
|
||||
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
|
||||
|
|
|
|||
|
|
@ -343,6 +343,41 @@ def get_city_location(city_slug: str | None) -> CityLocation | None:
|
|||
return CITY_LOCATIONS.get(city_slug)
|
||||
|
||||
|
||||
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
|
||||
# Развёртка ЗНАЕТ город из своего контекста (city_slug), но раньше нигде его не
|
||||
# записывала — листинг терял привязку к городу и адрес без города в тексте
|
||||
# ("ул. Победы, 30" — так отдают и Avito, и Cian, см. providers/cian/serp.py
|
||||
# `_format_address` skip_types={"location",...}) при геокодинге считался «город не
|
||||
# назван» и коллизировал с одноимённой ЕКБ-улицей. Ключи СОВПАДАЮТ с CITY_LOCATIONS/
|
||||
# CITY_ANCHORS; значения — те же формы, что уже есть в geocoder.SVERDLOVSK_OBLAST_CITIES
|
||||
# (lower + word-boundary матчинг там регистронезависим, поэтому регистр здесь не
|
||||
# критичен, но человекочитаемый — для админки/логов/дальнейшего QA).
|
||||
CITY_DISPLAY_NAMES: dict[str, str] = {
|
||||
"nizhniy_tagil": "Нижний Тагил",
|
||||
"kamensk_uralskiy": "Каменск-Уральский",
|
||||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
EKATERINBURG_CITY_NAME = "Екатеринбург"
|
||||
|
||||
|
||||
def resolve_city_name(city_slug: str | None) -> str:
|
||||
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
|
||||
|
||||
city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с
|
||||
get_city_location/get_city_anchors (тот же None-путь = ЕКБ-дефолт): EKB-варианты
|
||||
city-sweep функций (run_avito_city_sweep и т.д., вызванные БЕЗ city_slug) реально
|
||||
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" —
|
||||
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет».
|
||||
Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и
|
||||
get_city_location/get_city_anchors для неизвестных slug'ов.
|
||||
"""
|
||||
if city_slug is None:
|
||||
return EKATERINBURG_CITY_NAME
|
||||
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
|
||||
|
||||
|
||||
_CHROME_HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
|
|
@ -896,6 +931,9 @@ async def run_avito_city_sweep(
|
|||
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
|
||||
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
|
||||
_avito_slug = _loc.avito_slug if _loc else city_slug
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
|
||||
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
counters = CitySweepCounters(anchors_total=len(_anchors))
|
||||
all_touched_house_ids: set[int] = set()
|
||||
|
||||
|
|
@ -1035,7 +1073,11 @@ async def run_avito_city_sweep(
|
|||
if anchor_lots:
|
||||
try:
|
||||
ins, upd = save_listings(
|
||||
db, anchor_lots, matcher=matcher, region_code=region_code
|
||||
db,
|
||||
anchor_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
city=_city_name,
|
||||
)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
|
|
@ -1662,7 +1704,14 @@ async def run_avito_newbuilding_sweep(
|
|||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
try:
|
||||
ins, upd = save_listings(db, lots, matcher=matcher, region_code=region_code)
|
||||
# #2594: citywide novostroyka-обход — только ЕКБ (см. docstring).
|
||||
ins, upd = save_listings(
|
||||
db,
|
||||
lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
except Exception as save_exc:
|
||||
|
|
@ -1782,6 +1831,8 @@ async def run_yandex_city_sweep(
|
|||
# city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP
|
||||
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
|
||||
_loc = get_city_location(city_slug)
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
|
||||
_rooms_list = rooms_list or list(ROOM_PATH.keys())
|
||||
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
|
||||
|
|
@ -1880,7 +1931,12 @@ async def run_yandex_city_sweep(
|
|||
counters.lots_fetched += len(new_lots)
|
||||
try:
|
||||
ins, upd = save_listings(
|
||||
db, new_lots, matcher=matcher, region_code=region_code, run_id=run_id
|
||||
db,
|
||||
new_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_city_name,
|
||||
)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
|
|
@ -2288,6 +2344,8 @@ async def run_cian_city_sweep(
|
|||
# city_slug (#12): region_id города-цели → CianScraper.city_region_id скоупит SERP
|
||||
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
|
||||
_loc = get_city_location(city_slug)
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
counters = CianCitySweepCounters(anchors_total=len(_anchors))
|
||||
consecutive_failures = 0
|
||||
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
|
||||
|
|
@ -2383,7 +2441,12 @@ async def run_cian_city_sweep(
|
|||
counters.lots_dropped_secondary += _before - len(anchor_lots)
|
||||
if anchor_lots:
|
||||
inserted, updated = save_listings(
|
||||
db, anchor_lots, matcher=matcher, region_code=region_code, run_id=run_id
|
||||
db,
|
||||
anchor_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_city_name,
|
||||
)
|
||||
counters.lots_inserted += inserted
|
||||
counters.lots_updated += updated
|
||||
|
|
@ -2785,6 +2848,8 @@ async def run_cian_full_load(
|
|||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
skip_seen_today=config.scraper_skip_seen_today,
|
||||
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
# save_listings вызывает db.commit() внутри — данные в БД сразу
|
||||
counters.saved_inserted += inserted
|
||||
|
|
@ -3084,6 +3149,8 @@ async def run_yandex_full_load(
|
|||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
skip_seen_today=config.scraper_skip_seen_today,
|
||||
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
# save_listings вызывает db.commit() внутри — данные в БД сразу
|
||||
counters.saved_inserted += inserted
|
||||
|
|
@ -3289,6 +3356,8 @@ async def run_avito_full_load(
|
|||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
skip_seen_today=config.scraper_skip_seen_today,
|
||||
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
# save_listings вызывает db.commit() внутри — данные в БД сразу
|
||||
counters.saved_inserted += inserted
|
||||
|
|
@ -3499,8 +3568,18 @@ async def run_domclick_city_sweep(
|
|||
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
|
||||
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
|
||||
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
|
||||
# угадывается.
|
||||
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
|
||||
inserted, updated = save_listings(
|
||||
db, lots, matcher=matcher, region_code=region_code, run_id=run_id
|
||||
db,
|
||||
lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_dc_city,
|
||||
)
|
||||
counters.lots_inserted += inserted
|
||||
counters.lots_updated += updated
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue