From 034ab48cfe5df853e80aea6576bd4249166f8d8a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 2 Aug 2026 14:00:22 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/scraper):=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D0=B8=D1=80=D0=B0=D1=82=D1=8C=20=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=87=D0=BA=D1=83=20=D0=A6=D0=B8=D0=B0=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D0=B8,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/204_cian_oblast_sweeps_secondary.sql | 72 +++++++++++ ...ration_204_cian_oblast_sweeps_secondary.py | 121 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql create mode 100644 tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py diff --git a/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql b/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql new file mode 100644 index 00000000..7894964f --- /dev/null +++ b/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql @@ -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[] +-- .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; diff --git a/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py b/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py new file mode 100644 index 00000000..df2dfaf2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py @@ -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 "_" внутри кавычек. + 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 -- 2.45.3 From 6dda48fc6c95c92a64d428da9a336139b39f39f6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 2 Aug 2026 14:07:56 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/scraper):=20=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D0=92=D0=B5=D1=80=D1=85?= =?UTF-8?q?=D0=BD=D1=8E=D1=8E=20=D0=9F=D1=8B=D1=88=D0=BC=D1=83=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=A6=D0=B8=D0=B0=D0=BD=D0=B0=20=E2=80=94=20=D0=B3=D1=80=D1=8F?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=8F=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=8F=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/204_cian_oblast_sweeps_secondary.sql | 29 +++++++++++---- ...ration_204_cian_oblast_sweeps_secondary.py | 35 +++++++++++++------ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql b/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql index 7894964f..146bca44 100644 --- a/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql +++ b/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql @@ -1,6 +1,6 @@ -- 204_cian_oblast_sweeps_secondary.sql --- Включить сбор вторички Циана по 5 областным city-sweep'ам (Свердловская обл., --- миграция 179 — nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). +-- Включить сбор вторички Циана по 4 областным city-sweep'ам (Свердловская обл., +-- миграция 179 — nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov). -- -- ПРОБЛЕМА: _job_cian_city_sweep (scraper_kit.orchestration.scheduler:570) читает -- newbuilding_only = bool(default_params.get("newbuilding_only", True)) — дефолт True. @@ -20,12 +20,28 @@ -- pervouralsk 55 увидено, 53 выброшено (сохранено 2) -- kamensk_uralskiy 113 увидено, 108 выброшено (сохранено 5) -- nizhniy_tagil 184 увидено, 176 выброшено (сохранено 3) --- verkhnyaya_pyshma 38 увидено, 16 выброшено (сохранено 9) +-- verkhnyaya_pyshma 38 увидено, 16 выброшено (сохранено 9) -- см. EXCLUSION ниже -- --- FIX: newbuilding_only: false для ПЯТИ областных source'ов. cian_city_sweep (ЕКБ, +-- FIX: newbuilding_only: false для ЧЕТЫРЁХ областных source'ов. cian_city_sweep (ЕКБ, -- БЕЗ суффикса города) НЕ трогаем — для него дефолт корректен (вторичку ЕКБ -- собирает cian_full_load), включение дало бы дублирующую нагрузку на источник. -- +-- !!! EXCLUSION: cian_city_sweep_verkhnyaya_pyshma НЕ включён в эту миграцию !!! +-- Верхняя Пышма физически ~15 км от центра Екатеринбурга — geo-проверка по +-- ST_DWithin (координаты listings vs центр города) показала, что 5 из 22 (23%) +-- текущих cian-строк с меткой city="Верхняя Пышма" физически лежат в 15 км от +-- центра ЕКБ, т.е. это загрязнённая городская разметка (sweep по anchor'у В.Пышмы +-- зацепляет краевые екатеринбургские объявления и подписывает их не тем городом). +-- Колонка listings.city — money-critical: её читает asking_to_sold_ratio.py +-- (city-скоуп ASKING vs SOLD стороны, #2583 H2) — неверная метка двигает выкупные +-- цены. При newbuilding_only=false объём cian-строк под меткой В.Пышма вырастет с +-- 22 до нескольких сотен (те же ~38 увидено/16 выброшено за один run, помноженные +-- на число прогонов) — 23%-загрязнение умножилось бы пропорционально. +-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov — загрязнение по той же +-- geo-проверке НУЛЕВОЕ (0 из 8/5/2 соответственно физически в ЕКБ) — включать +-- безопасно. cian_city_sweep_verkhnyaya_pyshma будет включён ОТДЕЛЬНОЙ миграцией +-- после починки городской разметки sweep'а (правится параллельно) — НЕ забыт. +-- -- Нагрузка на источник (см. PR description / vault fix-запись для полного разбора): -- fetch_around_multi_room (providers/cian/serp.py:209) НЕ принимает newbuilding_only/ -- secondary_only — SERP-фаза (все rooms×pages) выполняется ОДИНАКОВО независимо от @@ -37,7 +53,7 @@ -- -- Дубли: run_cian_full_load всегда region_code=EKB (city_region_id=4743 через -- CianScraper() без city_slug), областные sweeps используют CITY_LOCATIONS[] --- .cian_region_id (4886/4781/4925/4697/4982 — все != 4743) — SERP-запросы физически +-- .cian_region_id (4886/4781/4925/4982 — все != 4743) — SERP-запросы физически -- разных региональных выдач. dedup_hash = sha256(source|source_id) — глобальный -- Cian offer_id, ON CONFLICT (dedup_hash) DO UPDATE — даже в теоретическом edge-case -- совпадения upsert НЕ создаёт дубль-строку. @@ -51,7 +67,7 @@ -- 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'ов). +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица), 179 (seed этих source'ов). -- deploy order: только миграция — код scheduler.py/pipeline.py НЕ меняется в этом PR, -- дефолт newbuilding_only=True в коде остаётся (правильный fallback для будущих -- source'ов без явного default_params override). @@ -65,7 +81,6 @@ 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' ); diff --git a/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py b/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py index df2dfaf2..5fbfa03f 100644 --- a/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py +++ b/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py @@ -1,13 +1,17 @@ -"""Static guards for migration 204 (включить сбор вторички Циана по 5 областным +"""Static guards for migration 204 (включить сбор вторички Циана по 4 областным 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; включение дало бы дублирующую нагрузку на источник). +отсутствие psycopg CAST-ловушки, jsonb-мердж (не перезапись), ровно 4 таргетных +source'а — и, главное, ДВА regression-guard'а: + 1. 'cian_city_sweep' (ЕКБ, без суффикса города) НЕ фигурирует — для него текущий + дефолт newbuilding_only=True в коде корректен (вторичку ЕКБ авторитетно собирает + run_cian_full_load; включение дало бы дублирующую нагрузку на источник). + 2. 'cian_city_sweep_verkhnyaya_pyshma' НЕ фигурирует — geo-проверка (ST_DWithin от + центра ЕКБ) показала 23% (5 из 22) загрязнение городской метки cian-строк В.Пышмы + екатеринбургскими объявлениями; listings.city — money-critical (читает + asking_to_sold_ratio.py). Включат отдельной миграцией после починки разметки. """ from __future__ import annotations @@ -22,7 +26,6 @@ _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", ) @@ -76,13 +79,14 @@ def test_migration_204_updates_default_params_via_jsonb_merge() -> None: assert "set default_params = '{" not in flat -def test_migration_204_targets_exactly_five_oblast_sources() -> None: +def test_migration_204_targets_exactly_four_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 (...) — не больше, не меньше. + # Ровно 4 закавыченных source-литерала в WHERE ... IN (...) — не больше, не меньше. quoted = re.findall(r"'(cian_city_sweep_[a-z_]+)'", flat) assert sorted(set(quoted)) == sorted(_OBLAST_SOURCES) + assert len(quoted) == 4 def test_migration_204_does_not_touch_ekaterinburg_schedule() -> None: @@ -103,11 +107,22 @@ def test_migration_204_does_not_touch_ekaterinburg_schedule() -> None: "nizhniy_tagil", "kamensk_uralskiy", "pervouralsk", - "verkhnyaya_pyshma", "serov", } +def test_migration_204_does_not_touch_verkhnyaya_pyshma() -> None: + """Regression-guard против «Пышму забыли обратно включить»: geo-проверка + (ST_DWithin от центра ЕКБ) показала 23% (5 из 22) cian-строк с меткой + city="Верхняя Пышма" физически лежат в 15 км от центра Екатеринбурга — загрязнённая + городская разметка. listings.city — money-critical (asking_to_sold_ratio.py читает + его для city-скоупа ASKING vs SOLD стороны). Включение вторички умножило бы это + загрязнение (22 → несколько сотен строк). Пышму включат отдельной миграцией + ПОСЛЕ починки городской разметки sweep'а — сейчас её НЕ должно быть в WHERE.""" + flat = _flat(_executable_sql()) + assert "cian_city_sweep_verkhnyaya_pyshma" not in flat + + def test_migration_204_no_psycopg_cast_trap() -> None: """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо в чистом .sql без bind params, но проверяем на регресс copy-paste из Python).""" -- 2.45.3