fix(tradein/scraper): собирать вторичку Циана по области, а не только новостройки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m38s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m38s
This commit is contained in:
parent
f488cbcf03
commit
034ab48cfe
2 changed files with 193 additions and 0 deletions
|
|
@ -0,0 +1,72 @@
|
|||
-- 204_cian_oblast_sweeps_secondary.sql
|
||||
-- Включить сбор вторички Циана по 5 областным city-sweep'ам (Свердловская обл.,
|
||||
-- миграция 179 — nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov).
|
||||
--
|
||||
-- ПРОБЛЕМА: _job_cian_city_sweep (scraper_kit.orchestration.scheduler:570) читает
|
||||
-- newbuilding_only = bool(default_params.get("newbuilding_only", True)) — дефолт True.
|
||||
-- run_cian_city_sweep (pipeline.py:2436) фильтрует SERP-результат на
|
||||
-- listing_segment == "novostroyki" ДО save_listings, вторичку отбрасывает
|
||||
-- (counters.lots_dropped_secondary).
|
||||
--
|
||||
-- Дефолт осмыслен для ЕКБ: docstring run_cian_city_sweep прямо говорит, что
|
||||
-- вторичку авторитетно собирает run_cian_full_load (exhaustive региональный сбор).
|
||||
-- НО run_cian_full_load (pipeline.py:2790) хардкодит city=EKATERINBURG_CITY_NAME —
|
||||
-- параметра города там нет вообще, область не покрывает. Итог: областную вторичку
|
||||
-- Циана не собирает НИКТО (городская развёртка её выбрасывает, full_load туда не
|
||||
-- ходит) — областные schedule'ы склонированы с ЕКБ (миграция 179) и унаследовали
|
||||
-- предположение, которое для них неверно.
|
||||
--
|
||||
-- Прод-счётчики (scrape_runs.counters, последние runs на 2026-08-02) подтверждают:
|
||||
-- pervouralsk 55 увидено, 53 выброшено (сохранено 2)
|
||||
-- kamensk_uralskiy 113 увидено, 108 выброшено (сохранено 5)
|
||||
-- nizhniy_tagil 184 увидено, 176 выброшено (сохранено 3)
|
||||
-- verkhnyaya_pyshma 38 увидено, 16 выброшено (сохранено 9)
|
||||
--
|
||||
-- FIX: newbuilding_only: false для ПЯТИ областных source'ов. cian_city_sweep (ЕКБ,
|
||||
-- БЕЗ суффикса города) НЕ трогаем — для него дефолт корректен (вторичку ЕКБ
|
||||
-- собирает cian_full_load), включение дало бы дублирующую нагрузку на источник.
|
||||
--
|
||||
-- Нагрузка на источник (см. PR description / vault fix-запись для полного разбора):
|
||||
-- fetch_around_multi_room (providers/cian/serp.py:209) НЕ принимает newbuilding_only/
|
||||
-- secondary_only — SERP-фаза (все rooms×pages) выполняется ОДИНАКОВО независимо от
|
||||
-- этого флага. Фильтр в pipeline.py:2436 применяется ПОСЛЕ фетча, ДО save — чисто
|
||||
-- in-memory отсечение уже оплаченных запросов. HTTP-нагрузка на cian.ru НЕ меняется;
|
||||
-- меняется только объём save_listings (DB-writes) — на порядок больше СОХРАНЯЕМЫХ
|
||||
-- строк, не больше запросов к источнику. detail_top_n=10 detail-фетчей тоже не растёт
|
||||
-- (LIMIT :lim константен, лишь конкурирующий пул кандидатов расширяется).
|
||||
--
|
||||
-- Дубли: run_cian_full_load всегда region_code=EKB (city_region_id=4743 через
|
||||
-- CianScraper() без city_slug), областные sweeps используют CITY_LOCATIONS[<slug>]
|
||||
-- .cian_region_id (4886/4781/4925/4697/4982 — все != 4743) — SERP-запросы физически
|
||||
-- разных региональных выдач. dedup_hash = sha256(source|source_id) — глобальный
|
||||
-- Cian offer_id, ON CONFLICT (dedup_hash) DO UPDATE — даже в теоретическом edge-case
|
||||
-- совпадения upsert НЕ создаёт дубль-строку.
|
||||
--
|
||||
-- listing_segment: providers/cian/serp.py:892 — вторичка получает
|
||||
-- listing_segment = "vtorichka" (НЕ NULL) → проходит фильтр
|
||||
-- "listing_segment IS NULL OR listing_segment = 'vtorichka'" в asking_to_sold_ratio.py
|
||||
-- и buildings_query.py — новые лоты попадут в оценку без доп. кода.
|
||||
--
|
||||
-- Мердж jsonb (COALESCE || ...), НЕ перезапись — сохраняет city/radius_m/detail_top_n/
|
||||
-- enrich_houses/pages_per_anchor/request_delay_sec (см. 179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
-- за текущими прод-значениями). Idempotent: повторный прогон ставит то же значение.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица), 179 (seed этих 5 source'ов).
|
||||
-- deploy order: только миграция — код scheduler.py/pipeline.py НЕ меняется в этом PR,
|
||||
-- дефолт newbuilding_only=True в коде остаётся (правильный fallback для будущих
|
||||
-- source'ов без явного default_params override).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET default_params = COALESCE(default_params, '{}'::jsonb)
|
||||
|| '{"newbuilding_only": false}'::jsonb
|
||||
WHERE source IN (
|
||||
'cian_city_sweep_nizhniy_tagil',
|
||||
'cian_city_sweep_kamensk_uralskiy',
|
||||
'cian_city_sweep_pervouralsk',
|
||||
'cian_city_sweep_verkhnyaya_pyshma',
|
||||
'cian_city_sweep_serov'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"""Static guards for migration 204 (включить сбор вторички Циана по 5 областным
|
||||
city-sweep'ам — Свердловская обл., см. миграцию 179).
|
||||
|
||||
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует
|
||||
живой БД; здесь фиксируем структурные инварианты: транзакционность, отсутствие DDL,
|
||||
отсутствие psycopg CAST-ловушки, jsonb-мердж (не перезапись), ровно 5 таргетных
|
||||
source'ов — и, главное, ЯВНЫЙ regression-guard, что екатеринбургское расписание
|
||||
('cian_city_sweep' без суффикса города) в списке НЕ фигурирует (для него текущий
|
||||
дефолт newbuilding_only=True в коде корректен — вторичку ЕКБ собирает
|
||||
run_cian_full_load; включение дало бы дублирующую нагрузку на источник).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_204 = _SQL_DIR / "204_cian_oblast_sweeps_secondary.sql"
|
||||
|
||||
_OBLAST_SOURCES = (
|
||||
"cian_city_sweep_nizhniy_tagil",
|
||||
"cian_city_sweep_kamensk_uralskiy",
|
||||
"cian_city_sweep_pervouralsk",
|
||||
"cian_city_sweep_verkhnyaya_pyshma",
|
||||
"cian_city_sweep_serov",
|
||||
)
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
return _MIGRATION_204.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _executable_sql() -> str:
|
||||
"""SQL без построчных `--`-комментариев — только исполняемый код."""
|
||||
lines = []
|
||||
for raw in _sql().splitlines():
|
||||
code = raw.split("--", 1)[0]
|
||||
if code.strip():
|
||||
lines.append(code)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip().lower()
|
||||
|
||||
|
||||
def test_migration_204_exists() -> None:
|
||||
assert _MIGRATION_204.exists(), f"missing migration: {_MIGRATION_204}"
|
||||
|
||||
|
||||
def test_migration_204_is_transactional() -> None:
|
||||
sql = _sql()
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_204_no_ddl() -> None:
|
||||
"""Только UPDATE данных (default_params) — никакого ALTER/CREATE/DROP TABLE/TRUNCATE."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "alter table" not in flat
|
||||
assert "create table" not in flat
|
||||
assert "drop table" not in flat
|
||||
assert "truncate" not in flat
|
||||
|
||||
|
||||
def test_migration_204_updates_default_params_via_jsonb_merge() -> None:
|
||||
"""COALESCE(default_params, '{}'::jsonb) || '{...}'::jsonb — мердж, НЕ перезапись
|
||||
(соседние ключи city/radius_m/detail_top_n/enrich_houses/pages_per_anchor/
|
||||
request_delay_sec должны сохраниться)."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "update scrape_schedules" in flat
|
||||
assert "set default_params = coalesce(default_params, '{}'::jsonb)" in flat
|
||||
assert "|| '{\"newbuilding_only\": false}'::jsonb" in flat
|
||||
# Regression guard: перезапись без COALESCE/|| стёрла бы соседние ключи.
|
||||
assert "set default_params = '{" not in flat
|
||||
|
||||
|
||||
def test_migration_204_targets_exactly_five_oblast_sources() -> None:
|
||||
flat = _flat(_executable_sql())
|
||||
for source in _OBLAST_SOURCES:
|
||||
assert f"'{source}'" in flat, f"missing target source: {source}"
|
||||
# Ровно 5 закавыченных source-литералов в WHERE ... IN (...) — не больше, не меньше.
|
||||
quoted = re.findall(r"'(cian_city_sweep_[a-z_]+)'", flat)
|
||||
assert sorted(set(quoted)) == sorted(_OBLAST_SOURCES)
|
||||
|
||||
|
||||
def test_migration_204_does_not_touch_ekaterinburg_schedule() -> None:
|
||||
"""Regression-guard против «включили всем»: 'cian_city_sweep' (ЕКБ, БЕЗ суффикса
|
||||
города) НЕ должен фигурировать в списке таргетов миграции. Для него текущий
|
||||
дефолт newbuilding_only=True (код) корректен — вторичку ЕКБ авторитетно собирает
|
||||
run_cian_full_load; включение дало бы дублирующий сбор той же вторички."""
|
||||
sql = _sql()
|
||||
# Каждое вхождение 'cian_city_sweep' в исполняемом SQL обязано иметь city-суффикс —
|
||||
# ищем токен 'cian_city_sweep' НЕ followed immediately by "_<city>" внутри кавычек.
|
||||
for match in re.finditer(r"'cian_city_sweep([a-z_]*)'", sql):
|
||||
suffix = match.group(1)
|
||||
assert suffix.startswith("_"), (
|
||||
"нашли bare 'cian_city_sweep' (ЕКБ-расписание) среди таргетов миграции — "
|
||||
"это регресс: ЕКБ-вторичку собирает run_cian_full_load, включать её здесь нельзя"
|
||||
)
|
||||
assert suffix[1:] in {
|
||||
"nizhniy_tagil",
|
||||
"kamensk_uralskiy",
|
||||
"pervouralsk",
|
||||
"verkhnyaya_pyshma",
|
||||
"serov",
|
||||
}
|
||||
|
||||
|
||||
def test_migration_204_no_psycopg_cast_trap() -> None:
|
||||
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо
|
||||
в чистом .sql без bind params, но проверяем на регресс copy-paste из Python)."""
|
||||
assert not re.search(r":\w+::", _sql())
|
||||
|
||||
|
||||
def test_migration_204_idempotent_by_construction() -> None:
|
||||
"""UPDATE ... SET x = merge(x, const) — повторный прогон ставит то же значение,
|
||||
отдельного guard-условия (IF NOT EXISTS/ON CONFLICT) не требуется."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert flat.count("update scrape_schedules") == 1
|
||||
Loading…
Add table
Reference in a new issue