diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index a45af2d3..d572c1bd 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -109,9 +109,16 @@ __all__ = [ # матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ # матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный # слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан. +# +# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города +# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_ +# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них +# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального +# avito URL-сегмента и создал бы ложный LIKE-матч. _OBLAST_AVITO_URL_PATTERNS = tuple( "%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%" for loc in CITY_LOCATIONS.values() + if loc.avito_slug is not None ) diff --git a/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql new file mode 100644 index 00000000..8a0c0520 --- /dev/null +++ b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql @@ -0,0 +1,951 @@ +-- 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — WAVE 2: +-- avito/cian/yandex city-sweep за пределами Екатеринбурга для оставшихся 40 городов +-- области (wave 1 — 179_scrape_schedules_seed_oblast_city_sweeps.sql, 5 городов: +-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). Объявления +-- по области сейчас 3229 против 20111 по ЕКБ — wave 2 заводит оставшийся охват +-- Свердловской обл. Domclick (BFF, city_id-based) — отдельный rollout, сюда НЕ входит. +-- +-- Координаты городов (lat/lon/название) — проверены на проде (геокодер + независимая +-- сверка медианой координат сделок Росреестра по городу, exclusion в радиусе 12км от +-- ЕКБ). CITY_ANCHORS-записи для всех 40 slug'ов — тот же PR, +-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py. +-- +-- БИСЕРТЬ ИСКЛЮЧЕНА ЦЕЛИКОМ (не 41, а 40 городов): у Циана её нет вообще — поиск на +-- любой запрос ("Бисерть", "пгт Бисерть") отдаёт Сысерть id=176028. Это пгт, а не +-- город области — не заводится ни в CITY_ANCHORS, ни здесь. +-- +-- ═══ ГЛАВНОЕ ОТЛИЧИЕ ОТ ПЕРВОЙ ВЕРСИИ ЭТОГО ФАЙЛА ═══ +-- Первая версия (до ревью) заводила 41 город × 3 источника = 123 строки для ВСЕХ +-- источников сразу, планируя добыть provider-идентификаторы (avito_slug/cian_region_id/ +-- yandex_rgid) ПОСЛЕ. Это оказалось бы РОВНО тем самым багом, о котором предупреждала её +-- же шапка: без подтверждённого идентификатора run_avito_city_sweep/run_yandex_city_sweep +-- падают на ЕКБ-дефолт (region_id/rgid Екатеринбурга) — развёртка "включена", но реально +-- собирает ЕКБ под меткой чужого города, порча данных под видом покрытия. +-- +-- Идентификаторы теперь ДОБЫТЫ И ВАЛИДИРОВАНЫ (см. CITY_LOCATIONS-коммент в pipeline.py: +-- cian_id — api.cian.ru/geo-suggest/v1/suggest; yandex_rgid — realty.yandex.ru/gate/ +-- region_suggest/suggest; avito_slug — живой GET avito.ru//kvartiry; все три метода +-- валидированы 5/5 на wave-1 городах с уже известными значениями). Но НЕ у каждого города +-- подтверждены ВСЕ ТРИ идентификатора. Правило этой миграции: **строка заводится ТОЛЬКО +-- там, где идентификатор подтверждён**. Развёртка, которая молча соберёт Екатеринбург, +-- хуже отсутствующей — недостающие источники НЕ заводим вовсе (а не заводим с заглушкой/ +-- fallback). +-- +-- Дополнительный defensive guard в коде (тот же PR, pipeline.py): если КОГДА-ЛИБО +-- run_avito_city_sweep/run_yandex_city_sweep будет вызван с city_slug, у которого в +-- CITY_LOCATIONS известный город, но конкретный provider-идентификатор всё ещё None — +-- функция явно падает `ValueError` (НЕ молчаливый ЕКБ-дефолт). При штатной эксплуатации +-- этой миграции (schedule заводится только при подтверждённом идентификаторе) этот +-- ValueError сработать не должен — он ловит будущий рассинхрон данных, не текущий. +-- +-- ИТОГО 102 строки (не 123): +-- cian_city_sweep_* — 40 строк (cian_region_id подтверждён у ВСЕХ 40 городов). +-- yandex_city_sweep_* — 39 строк (ВСЕ, КРОМЕ mikhaylovsk — Михайловск Нижнесергинского +-- р-на ОТСУТСТВУЕТ в гео-базе Яндекс.Недвижимости вообще: единственный "Михайловск" +-- там — ставропольский, rgid 586221, подставлять чужой регион нельзя. Это +-- подтверждённое ОТСУТСТВИЕ данных у источника, не "не проверили" — довести +-- нечем, ждать нечего). +-- avito_city_sweep_* — 23 строки. avito_slug НЕ подтверждён для 17 городов: +-- revda, polevskoy, berezovskiy, zarechny, kachkanar, sredneuralsk, degtyarsk, +-- artemovskiy, kamyshlov, sukhoy_log, kushva, karpinsk, nizhnyaya_tura, +-- nizhnie_sergi, lesnoy, verkhoturye, mikhaylovsk. +-- Причина по каждому — либо чистый 404 на опробованных вариантах slug'а (omonym- +-- коллизия с городом в другом регионе — нужна avito-специфичная дизамбигуация, +-- которой в проверке не делали), либо 403/429 из-за исчерпания пула прокси во +-- время проверки (кандидат НЕ опровергнут, просто НЕ подтверждён — это единственная +-- категория из трёх, которую стоит ПЕРЕПРОВЕРИТЬ на свежем пуле и добрать отдельной +-- миграцией; остальные — city_rgid mikhaylovsk и omonym-404 avito — подтверждённое +-- отсутствие/коллизия, довести нечем). +-- +-- !!! DORMANT BY DESIGN !!! Все 102 строки ship enabled = false. Оператор включает +-- ВРУЧНУЮ по одному городу за раз (как в wave 1), волнами после деплоя: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep_revda'; +-- Capability уже полностью wired — тот же механизм, что и wave 1 (pipeline.CITY_ANCHORS/ +-- get_city_anchors, scheduler._job_{avito,cian,yandex}_city_sweep читают +-- default_params->>'city', wildcard-registry "*_city_sweep_*" в +-- scraper_kit.orchestration.scheduler._default_kit_handlers) — код скраперов/хендлеров +-- НЕ меняется (кроме defensive-guard в pipeline.py выше, не меняющего штатный путь). +-- +-- default_params — за основу взяты прод-дефолты enabled-городов wave 1 (см. 179_ + +-- 206_), с тремя отличиями: +-- 1. radius_m = 3000 у avito/cian (было 1500 в 179_) — один anchor на город должен +-- покрыть город целиком; сама 179_ предупреждала, что 1500м мало для городов +-- крупнее одного круга. yandex — 25000 как есть (gate-API город скоупит city_rgid, +-- lat/lon/radius_m игнорирует целиком, см. run_yandex_city_sweep docstring — +-- radius_m там мёртвый default). +-- 2. detail_top_n = 0 у avito (было 20 в 179_) — Avito detail-страницы сейчас отдают +-- HTTP 439 firewall независимо от IP (issue #2827). Обречённые detail-запросы на +-- 23 подтверждённых города только приблизят бан общего прокси-пула зря — не тратим +-- их, пока #2827 не починен. cian detail_top_n = 10 — оставлен как в 179_. +-- 3. interval_days = 3 у всех трёх источников — тот же такт, на который migration 206_ +-- перевела wave-1 15 job'ов после замера (daily избыточен, независимая проверка по +-- listings_snapshots показала ~0.02-0.15%/сутки волатильности цены). +-- +-- window_start_hour/window_end_hour (UTC, 1-часовые окна): 24 часа в сутках, 102 новые +-- строки — полная уникальность окна на строку математически невозможна для cian/yandex +-- (40 и 39 > 16-18 свободных часов), возможна для avito (23 <= 18). Тот же round-robin +-- scheme, что в первой версии файла (координаты НЕ пересчитывались — просто отфильтрован +-- набор строк по подтверждённым идентификаторам, часы у оставшихся ГОРОДОВ не менялись): +-- окна исключают ПОЛНОСТЬЮ (а) EKB-окна (avito 6-7, cian 2-5, yandex 16-17) и (б) окна +-- wave-1 179_ (avito {0,1,5,7,8}, cian {9,10,11,12,13}, yandex {14,15,17,18,19}); внутри +-- оставшихся свободных часов round-robin по городам в исходном 41-городском TSV-порядке +-- (novouralsk..bisert, bisert выброшен целиком), затем строка эмитится, только если +-- источник подтверждён для этого города. Итоговый максимум коллизий ОДНОГО источника в +-- одном часе: avito <= 2, cian <= 3, yandex <= 3 (ниже, чем было бы при полных 41 — +-- меньше строк на источник). Разные провайдеры МОГУТ делить час — не ограничивалось (см. +-- 179_/206_ — proxy-pool уже не единственный узел). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 179_ (wave 1, +-- CITY_ANCHORS-механизм и wildcard resolve_handler — не переопределяются здесь). +-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален +-- по построению (40 городов × подтверждённые источники, ни один не пересекается с +-- wave-1 5 городами). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +-- ── avito_city_sweep_ — ТОЛЬКО 23 города с подтверждённым avito_slug +-- (radius_m 3000, detail_top_n 0 — issue #2827, enrich_houses true, +-- pages_per_anchor 3, request_delay_sec 7, interval_days 3) ────────────── +( + 'avito_city_sweep_novouralsk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'avito_city_sweep_asbest', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'avito_city_sweep_bogdanovich', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'avito_city_sweep_irbit', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'avito_city_sweep_krasnoufimsk', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'avito_city_sweep_krasnoturinsk', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'avito_city_sweep_severouralsk', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'avito_city_sweep_ivdel', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'avito_city_sweep_tavda', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'avito_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'avito_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_salda', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nizhnyaya_salda', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nevyansk', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'avito_city_sweep_alapaevsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'avito_city_sweep_krasnouralsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'avito_city_sweep_verkhniy_tagil', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'avito_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'avito_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'avito_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_tura', + false, + 23, + 24, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'avito_city_sweep_talitsa', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'avito_city_sweep_novaya_lyalya', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── cian_city_sweep_ — ВСЕ 40 городов (cian_id подтверждён у всех) +-- (radius_m 3000, detail_top_n 10, enrich_houses true, pages_per_anchor 3, +-- request_delay_sec 5, interval_days 3) ───────────────────────────────── +( + 'cian_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'cian_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'cian_city_sweep_polevskoy', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'cian_city_sweep_asbest', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'cian_city_sweep_bogdanovich', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'cian_city_sweep_irbit', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'cian_city_sweep_krasnoufimsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'cian_city_sweep_berezovskiy', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'cian_city_sweep_zarechny', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'cian_city_sweep_kachkanar', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'cian_city_sweep_krasnoturinsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'cian_city_sweep_severouralsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'cian_city_sweep_ivdel', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'cian_city_sweep_tavda', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'cian_city_sweep_turinsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'cian_city_sweep_sysert', + false, + 23, + 24, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'cian_city_sweep_sredneuralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'cian_city_sweep_degtyarsk', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_salda', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_salda', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nevyansk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'cian_city_sweep_artemovskiy', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'cian_city_sweep_kamyshlov', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'cian_city_sweep_alapaevsk', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'cian_city_sweep_sukhoy_log', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'cian_city_sweep_kushva', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'cian_city_sweep_krasnouralsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'cian_city_sweep_karpinsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_tura', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_verkhniy_tagil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'cian_city_sweep_nizhnie_sergi', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'cian_city_sweep_lesnoy', + false, + 23, + 24, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'cian_city_sweep_rezh', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'cian_city_sweep_aramil', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'cian_city_sweep_volchansk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_tura', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_mikhaylovsk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "mikhaylovsk"}'::jsonb +), +( + 'cian_city_sweep_verkhoturye', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'cian_city_sweep_talitsa', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'cian_city_sweep_novaya_lyalya', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── yandex_city_sweep_ — 39 городов (ВСЕ, КРОМЕ mikhaylovsk — города +-- нет в гео-базе Яндекса вообще) (radius_m 25000, pages_per_anchor 3, +-- request_delay_sec 9, interval_days 3) ─────────────────────────────── +( + 'yandex_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'yandex_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'yandex_city_sweep_polevskoy', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'yandex_city_sweep_asbest', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'yandex_city_sweep_bogdanovich', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'yandex_city_sweep_irbit', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'yandex_city_sweep_krasnoufimsk', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'yandex_city_sweep_berezovskiy', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'yandex_city_sweep_zarechny', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'yandex_city_sweep_kachkanar', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'yandex_city_sweep_krasnoturinsk', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'yandex_city_sweep_severouralsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'yandex_city_sweep_ivdel', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'yandex_city_sweep_tavda', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'yandex_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'yandex_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'yandex_city_sweep_sredneuralsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'yandex_city_sweep_degtyarsk', + false, + 23, + 24, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_salda', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_salda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nevyansk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'yandex_city_sweep_artemovskiy', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'yandex_city_sweep_kamyshlov', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'yandex_city_sweep_alapaevsk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'yandex_city_sweep_sukhoy_log', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'yandex_city_sweep_kushva', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'yandex_city_sweep_krasnouralsk', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'yandex_city_sweep_karpinsk', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_tura', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhniy_tagil', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'yandex_city_sweep_nizhnie_sergi', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'yandex_city_sweep_lesnoy', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'yandex_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'yandex_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'yandex_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_tura', + false, + 23, + 24, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhoturye', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'yandex_city_sweep_talitsa', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'yandex_city_sweep_novaya_lyalya', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). Источники перечислены в ' + 'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях ' + 'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: 102 wave-2 oblast ' + 'city-sweep source''ы (40 городов, только подтверждённые provider-id: ' + 'cian x40 / yandex x39 (без mikhaylovsk) / avito x23, #262 — все enabled=false, ' + 'defensive ValueError guard в pipeline.py против молчаливого ЕКБ-fallback).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 6b449178..4b98c273 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -250,3 +250,4 @@ 259_data_quality_drop_pct_cadastr.sql 260_houses_drop_has_panorama.sql 261_listings_search_mv_drop_placeholder_columns.sql +262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 3f132e1f..e138a150 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None: def test_resolve_city_name_known_oblast_slugs() -> None: - """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.""" - from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name + """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя. - expected = { + #262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2 + городов) — вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и + ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_ + NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город + бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты + этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только + cian+yandex / только cian). + """ + from scraper_kit.orchestration.pipeline import ( + CITY_DISPLAY_NAMES, + CITY_LOCATIONS, + resolve_city_name, + ) + + wave1_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 set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) + assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items() + for slug, name in wave1_expected.items(): + assert resolve_city_name(slug) == name + # wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito + # НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника). + for slug, name in { + "novouralsk": "Новоуральск", + "revda": "Ревда", + "mikhaylovsk": "Михайловск", + }.items(): + assert slug in CITY_LOCATIONS assert resolve_city_name(slug) == name @@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: assert resolve_city_name("nonexistent_city") == "Екатеринбург" +# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный +# ЕКБ-fallback) ─────────────────────────────────────────────────────────────────── + + +async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None: + """revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён). + + run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода — иначе + `_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой + 'revda'. Raise происходит в самом начале функции (до await) — MagicMock() для + config/matcher/enrichment безопасен, до них не доходит.""" + from scraper_kit.orchestration.pipeline import run_avito_city_sweep + + with pytest.raises(ValueError, match="revda"): + await run_avito_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="revda", + ) + + +async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None: + """mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город + отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО + любого сетевого/DB похода — иначе YandexRealtyScraper(city_rgid=None) молча + взял бы ЕКБ rgid.""" + from scraper_kit.orchestration.pipeline import run_yandex_city_sweep + + with pytest.raises(ValueError, match="mikhaylovsk"): + await run_yandex_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="mikhaylovsk", + ) + + +async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None: + """city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard + условие `_loc is not None`, а get_city_location(None) возвращает None.""" + from scraper_kit.orchestration.pipeline import get_city_location + + assert get_city_location(None) is None + + # ── CitySweepCounters ─────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py new file mode 100644 index 00000000..23cf7656 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -0,0 +1,433 @@ +"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой +БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL, +отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между +data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline` +(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по +`default_params->>'city'`): + + - каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS — иначе + `get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на + EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург); + - и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule- + строки где-то (179_ ИЛИ 262_); + - #262 ревью (после первой версии файла): строка для (provider, city) существует + ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS + подтверждён (не None) — НЕ "каждый город получает все 3 источника". Первая версия + заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ — это был бы + ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл + проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/ + cian_region_id. + +Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в +slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча +резолвится на EKB_ANCHORS или ЕКБ region_id/rgid. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql" +_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql" + +_PROVIDERS = ("avito", "cian", "yandex") + +# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',". +# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header- +# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';"). +_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE) + +_WAVE1_SLUGS = { + "nizhniy_tagil", + "kamensk_uralskiy", + "pervouralsk", + "verkhnyaya_pyshma", + "serov", +} +_WAVE2_SLUGS = { + "novouralsk", + "revda", + "polevskoy", + "asbest", + "bogdanovich", + "irbit", + "krasnoufimsk", + "berezovskiy", + "zarechny", + "kachkanar", + "krasnoturinsk", + "severouralsk", + "ivdel", + "tavda", + "turinsk", + "sysert", + "sredneuralsk", + "degtyarsk", + "verkhnyaya_salda", + "nizhnyaya_salda", + "nevyansk", + "artemovskiy", + "kamyshlov", + "alapaevsk", + "sukhoy_log", + "kushva", + "krasnouralsk", + "karpinsk", + "nizhnyaya_tura", + "verkhniy_tagil", + "nizhnie_sergi", + "lesnoy", + "rezh", + "aramil", + "volchansk", + "verkhnyaya_tura", + "mikhaylovsk", + "verkhoturye", + "talitsa", + "novaya_lyalya", +} +assert len(_WAVE2_SLUGS) == 40 +assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) + + +def _sql(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path) -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql(path).splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _row_sources(path: Path) -> list[tuple[str, str]]: + """(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов).""" + return _ROW_SOURCE_RE.findall(_executable_sql(path)) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +# ── existence / basic shape ─────────────────────────────────────────────────── + + +def test_migration_262_exists() -> None: + assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}" + + +def test_migration_262_is_transactional() -> None: + sql = _sql(_MIGRATION_262) + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_262_no_ddl() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + 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_262_no_psycopg_cast_trap() -> None: + assert not re.search(r":\w+::", _sql(_MIGRATION_262)) + + +def test_migration_262_idempotent_on_conflict_do_nothing() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "on conflict (source) do nothing" in flat + # Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT + # покрывает весь батч разом; повторный прогон всего файла — no-op. + assert flat.count("insert into scrape_schedules") == 1 + + +def test_migration_262_excludes_bisert_entirely() -> None: + """Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть). + + Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL.""" + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "bisert" not in flat + + +# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ─────────────────────── + + +def test_migration_262_has_exactly_102_rows() -> None: + rows = _row_sources(_MIGRATION_262) + assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}" + + +def test_migration_262_row_counts_per_provider() -> None: + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + assert len(by_provider["avito"]) == 23 + assert len(by_provider["cian"]) == 40 + assert len(by_provider["yandex"]) == 39 + # cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех). + assert by_provider["cian"] == _WAVE2_SLUGS + # yandex — все, КРОМЕ mikhaylovsk. + assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"} + # avito — подмножество (только подтверждённые avito_slug), не пересекается с + # городами, у которых avito_slug=None в CITY_LOCATIONS. + assert by_provider["avito"] <= _WAVE2_SLUGS + + +def test_migration_262_no_duplicate_sources() -> None: + rows = _row_sources(_MIGRATION_262) + sources = [f"{p}_city_sweep_{s}" for p, s in rows] + assert len(sources) == len(set(sources)), "duplicate source в 262_" + + +def test_migration_262_does_not_touch_wave1_cities() -> None: + """Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов — + они уже сидированы 179_ и живут (enabled управляется отдельно от них).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + overlap = slugs & _WAVE1_SLUGS + assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}" + + +def test_migration_262_all_rows_enabled_false() -> None: + """Все 102 строки должны быть enabled=false (dormant by design — оператор включает + волнами вручную). Считаем по числу `false,` сразу после source-литерала.""" + executable = _executable_sql(_MIGRATION_262) + tuples = re.findall( + r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),", + executable, + ) + assert len(tuples) == 102 + assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта" + + +# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ─────────── + + +def test_migration_262_row_exists_iff_identifier_confirmed() -> None: + """Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий + provider-идентификатор в CITY_LOCATIONS подтверждён (не None). + + Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3 + строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а + "строка существует ровно там, где есть подтверждённый id". + """ + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + + for slug in _WAVE2_SLUGS: + loc = CITY_LOCATIONS[slug] + + has_avito_row = slug in by_provider["avito"] + has_avito_id = loc.avito_slug is not None + assert has_avito_row == has_avito_id, ( + f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но " + f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_cian_row = slug in by_provider["cian"] + has_cian_id = loc.cian_region_id is not None + assert has_cian_row == has_cian_id, ( + f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но " + f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_yandex_row = slug in by_provider["yandex"] + has_yandex_id = loc.yandex_rgid is not None + assert has_yandex_row == has_yandex_id, ( + f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но " + f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + +def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None: + """Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_ + строку в 262_ (wave-2) или 179_ (wave-1) — и наоборот, каждая avito-строка + принадлежит городу с непустым avito_slug.""" + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"} + scheduled_avito = rows_262 | rows_179 + + confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} + assert scheduled_avito == confirmed_avito, ( + f"missing schedule rows: {confirmed_avito - scheduled_avito}; " + f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}" + ) + + +def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"} + scheduled_yandex = rows_262 | rows_179 + + confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} + assert scheduled_yandex == confirmed_yandex, ( + f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; " + f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}" + ) + + +def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"} + scheduled_cian = rows_262 | rows_179 + + confirmed_cian = { + slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None + } + assert scheduled_cian == confirmed_cian, ( + f"missing schedule rows: {confirmed_cian - scheduled_cian}; " + f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}" + ) + + +def test_mikhaylovsk_has_only_cian_row() -> None: + """Regression-guard для самого узкого случая: mikhaylovsk — единственный город без + yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug — должен иметь + РОВНО одну строку (cian), НЕ три.""" + rows = _row_sources(_MIGRATION_262) + mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"} + assert mikhaylovsk_providers == { + "cian" + }, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}" + + +# ── CITY_ANCHORS parity ───────────────────────────────────────────────────── + + +def test_migration_262_slugs_all_exist_in_city_anchors() -> None: + """Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug) + вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_migration_179_slugs_all_exist_in_city_anchors() -> None: + """Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR.""" + rows = _row_sources(_MIGRATION_179) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_city_anchors_has_no_slug_without_schedule_rows() -> None: + """Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в + 179_ ИЛИ 262_ — мёртвая запись без сидов сигнализирует рассинхрон/опечатку.""" + seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | { + slug for _p, slug in _row_sources(_MIGRATION_262) + } + orphaned = set(CITY_ANCHORS) - seeded_slugs + assert ( + not orphaned + ), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" + + +def test_city_anchors_wave2_count_and_content() -> None: + """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — + ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" + assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS + assert len(CITY_ANCHORS) == 45 + + +def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: + """Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в + pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'.""" + for slug in _WAVE2_SLUGS: + anchors = CITY_ANCHORS[slug] + assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}" + lat, lon, label = anchors[0] + assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл." + assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл." + assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'" + + +def test_city_locations_wave2_count_and_content() -> None: + """CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе + get_city_location(slug) вернёт None для известного anchor'ами города и сборка + молча уйдёт на ЕКБ region_id/rgid).""" + assert set(CITY_LOCATIONS) == set(CITY_ANCHORS) + + +def test_city_locations_cian_region_id_never_none() -> None: + """cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное + поле CityLocation, у которого нет Optional-пути.""" + for slug, loc in CITY_LOCATIONS.items(): + assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None" + + +# ── window scheduling ─────────────────────────────────────────────────────── + +_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}} +_WAVE1_OCCUPIED = { + "avito": {0, 1, 5, 7, 8}, + "cian": {9, 10, 11, 12, 13}, + "yandex": {14, 15, 17, 18, 19}, +} +# Математический минимум коллизий одного источника в одном часе (round-robin по +# 23/40/39 городам на 18/16/18 свободных часов соответственно). +_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3} + + +def _row_windows(path: Path) -> list[tuple[str, str, int, int]]: + """(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки.""" + executable = _executable_sql(path) + pattern = re.compile( + r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),", + ) + return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)] + + +def test_migration_262_windows_are_one_hour() -> None: + for provider, slug, start, end in _row_windows(_MIGRATION_262): + assert end == start + 1, f"{provider}_{slug}: window [{start},{end}) не 1-часовое" + + +def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None: + """Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5, + yandex 16-17) или на окна wave-1 (179_) — тот же provider.""" + for provider, slug, start, _end in _row_windows(_MIGRATION_262): + occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider] + assert start not in occupied, ( + f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый " + f"ЕКБ/wave-1 час ({sorted(occupied)})" + ) + + +def test_migration_262_same_provider_collisions_within_math_minimum() -> None: + """Коллизии ОДНОГО источника в одном окне не превышают математический минимум + (round-robin по подтверждённым городам на свободные часы), и распределены + равномерно (не сконцентрированы в 1-2 часах).""" + from collections import Counter + + rows = _row_windows(_MIGRATION_262) + for provider in _PROVIDERS: + hours = [start for p, _slug, start, _end in rows if p == provider] + counts = Counter(hours) + assert max(counts.values()) <= _MAX_COLLISIONS[provider], ( + f"{provider}: час {counts.most_common(1)} превышает математический минимум " + f"коллизий ({_MAX_COLLISIONS[provider]})" + ) + + +def test_migration_262_cross_provider_overlap_allowed() -> None: + """Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) — + документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке.""" + rows = _row_windows(_MIGRATION_262) + avito_hours = {start for p, _s, start, _e in rows if p == "avito"} + cian_hours = {start for p, _s, start, _e in rows if p == "cian"} + assert avito_hours and cian_hours diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index b3d462d5..81631271 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -272,12 +272,65 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [ # default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек # на большой город), эти города компактнее — одного anchor'а достаточно. # Domclick (BFF, city_id-based, НЕ anchors-based) сюда не входит — отдельный B2 rollout. +# +# Wave 1 (5 городов, ниже) — enabled на проде. Wave 2 (40 городов, оставшаяся часть +# Свердловской обл. КРОМЕ Бисерти — пгт, не город, Циан её не резолвит вообще, см. +# CITY_LOCATIONS-коммент ниже; data/sql/262_scrape_schedules_seed_oblast_city_sweeps_ +# wave2.sql) — координаты проверены оператором (прод-геокодер + независимая сверка +# медианой координат сделок Росреестра по городу), все schedule-строки enabled=false +# (оператор включает волнами вручную). CITY_LOCATIONS заведён для всех 40 (см. ниже) — +# но НЕ у каждого города подтверждены все 3 provider-идентификатора: run_avito_city_ +# sweep/run_yandex_city_sweep явно падают (ValueError), если для известного city_slug +# соответствующий идентификатор не подтверждён (None), вместо молчаливого ЕКБ-дефолта. +# 262_ заводит schedule-строку ТОЛЬКО там, где идентификатор источника подтверждён — +# поэтому этот ValueError в проде не должен срабатывать при штатной эксплуатации. CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = { "nizhniy_tagil": [(57.910, 59.980, "Н.Тагил центр")], "kamensk_uralskiy": [(56.414, 61.918, "Каменск центр")], "pervouralsk": [(56.908, 59.943, "Первоуральск центр")], "verkhnyaya_pyshma": [(56.976, 60.578, "В.Пышма центр")], "serov": [(59.604, 60.578, "Серов центр")], + # ── wave 2 (40 городов, dormant — см. коммент выше; Бисерть исключена целиком) ── + "novouralsk": [(57.2470, 60.0958, "Новоуральск центр")], + "revda": [(56.8001, 59.9087, "Ревда центр")], + "polevskoy": [(56.4958, 60.2370, "Полевской центр")], + "asbest": [(57.0141, 61.4659, "Асбест центр")], + "bogdanovich": [(56.7683, 62.0521, "Богданович центр")], + "irbit": [(57.6810, 63.0585, "Ирбит центр")], + "krasnoufimsk": [(56.6181, 57.7713, "Красноуфимск центр")], + "berezovskiy": [(56.9098, 60.8120, "Берёзовский центр")], + "zarechny": [(56.8023, 61.3202, "Заречный центр")], + "kachkanar": [(58.6995, 59.4811, "Качканар центр")], + "krasnoturinsk": [(59.7646, 60.1936, "Краснотурьинск центр")], + "severouralsk": [(60.1502, 59.9605, "Североуральск центр")], + "ivdel": [(60.6973, 60.4173, "Ивдель центр")], + "tavda": [(58.0414, 65.2720, "Тавда центр")], + "turinsk": [(58.0525, 63.6909, "Туринск центр")], + "sysert": [(56.5054, 60.8097, "Сысерть центр")], + "sredneuralsk": [(56.9916, 60.4689, "Среднеуральск центр")], + "degtyarsk": [(56.6983, 60.0868, "Дегтярск центр")], + "verkhnyaya_salda": [(58.0494, 60.5473, "В.Салда центр")], + "nizhnyaya_salda": [(58.0714, 60.7147, "Н.Салда центр")], + "nevyansk": [(57.4924, 60.2105, "Невьянск центр")], + "artemovskiy": [(57.3386, 61.8941, "Артёмовский центр")], + "kamyshlov": [(56.8462, 62.7122, "Камышлов центр")], + "alapaevsk": [(57.8508, 61.6950, "Алапаевск центр")], + "sukhoy_log": [(56.9047, 62.0350, "Сухой Лог центр")], + "kushva": [(58.2817, 59.7625, "Кушва центр")], + "krasnouralsk": [(58.3571, 60.0374, "Красноуральск центр")], + "karpinsk": [(59.7668, 60.0009, "Карпинск центр")], + "nizhnyaya_tura": [(58.6208, 59.8473, "Н.Тура центр")], + "verkhniy_tagil": [(57.3745, 59.9321, "В.Тагил центр")], + "nizhnie_sergi": [(56.6619, 59.3000, "Н.Серги центр")], + "lesnoy": [(58.6364, 59.7897, "Лесной центр")], + "rezh": [(57.3706, 61.4043, "Реж центр")], + "aramil": [(56.6963, 60.8331, "Арамиль центр")], + "volchansk": [(59.9347, 60.0780, "Волчанск центр")], + "verkhnyaya_tura": [(58.3605, 59.8182, "В.Тура центр")], + "mikhaylovsk": [(56.4445, 59.1242, "Михайловск центр")], + "verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")], + "talitsa": [(57.0148, 63.7302, "Талица центр")], + "novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")], } @@ -325,23 +378,86 @@ class CityLocation: avito_slug — city-slug в пути avito URL (ОТЛИЧАЕТСЯ от нашего slug у некоторых городов: kamensk-uralskiy через дефис, verhnyaya_pyshma без kh). + Optional (#262 wave 2): у части городов НЕ подтверждён (403/429 — + пул исчерпан на проверке, либо omonym-коллизия). None здесь — + ЖЁСТКИЙ инвариант "нет достоверного значения", НЕ "бери ЕКБ" — + run_avito_city_sweep явно падает (ValueError), если city_slug + известен, а avito_slug для него None (см. её тело). cian_region_id — числовой region= в ekb.cian.ru/cat.php (ГОРОД, не область). - yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город). + Подтверждён у ВСЕХ городов wave 1+2 — required, не Optional. + yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город). Optional (#262 wave + 2): mikhaylovsk отсутствует в гео-базе Яндекса вообще (единственный + "Михайловск" там — ставропольский, чужой rgid подставлять нельзя). + Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep + явно падает (ValueError), если city_slug известен, а yandex_rgid + для него None. """ - avito_slug: str + avito_slug: str | None cian_region_id: int - yandex_rgid: int + yandex_rgid: int | None # Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах, # в карте держать НЕ обязательно (None-путь = ЕКБ). Города Свердловской обл. (region 66): +# +# Wave 2 (#262, 40 городов ниже wave-1 пятёрки) — идентификаторы добыты и валидированы +# отдельно по каждому provider (метод + валидация 5/5 на wave-1 городах — см. шапку +# data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql). Бисерть ИСКЛЮЧЕНА +# целиком (не город, пгт — у Циана её нет, любой запрос отдаёт Сысерть id=176028). +# avito_slug=None у 17 городов (не подтверждён — пул исчерпан на 429, ПЕРЕПРОВЕРИТЬ +# позже, не опровергнут); yandex_rgid=None только у mikhaylovsk (город отсутствует в +# гео-базе Яндекса — это НЕ "не проверили", это подтверждённое отсутствие данных). CITY_LOCATIONS: dict[str, CityLocation] = { "nizhniy_tagil": CityLocation("nizhniy_tagil", 4886, 328286), "kamensk_uralskiy": CityLocation("kamensk-uralskiy", 4781, 328468), "pervouralsk": CityLocation("pervouralsk", 4925, 328379), "verkhnyaya_pyshma": CityLocation("verhnyaya_pyshma", 4697, 166366), "serov": CityLocation("serov", 4982, 328238), + # ── wave 2: avito+cian+yandex все подтверждены (23 города) ────────────────── + "novouralsk": CityLocation("novouralsk", 4900, 327473), + "asbest": CityLocation("asbest", 4659, 328344), + "bogdanovich": CityLocation("bogdanovich", 4684, 327697), + "irbit": CityLocation("irbit", 4773, 327182), + "krasnoufimsk": CityLocation("krasnoufimsk", 4826, 326938), + "krasnoturinsk": CityLocation("krasnoturinsk", 4824, 328232), + "severouralsk": CityLocation("severouralsk", 4979, 328576), + "ivdel": CityLocation("ivdel", 4769, 328471), + "tavda": CityLocation("tavda", 5007, 328612), + "turinsk": CityLocation("turinsk", 176151, 327028), + "sysert": CityLocation("sysert", 176028, 327787), + "verkhnyaya_salda": CityLocation("verhnyaya_salda", 4698, 328268), + "nizhnyaya_salda": CityLocation("nizhnyaya_salda", 4887, 328267), + "nevyansk": CityLocation("nevyansk", 4879, 327237), + "alapaevsk": CityLocation("alapaevsk", 4642, 328292), + "krasnouralsk": CityLocation("krasnouralsk", 4825, 328611), + "verkhniy_tagil": CityLocation("verhniy_tagil", 4695, 328510), + "rezh": CityLocation("rezh", 4957, 328306), + "aramil": CityLocation("aramil", 174206, 327631), + "volchansk": CityLocation("volchansk", 4710, 328231), + "verkhnyaya_tura": CityLocation("verhnyaya_tura", 4699, 328489), + "talitsa": CityLocation("talitsa", 176053, 328369), + "novaya_lyalya": CityLocation("novaya_lyalya", 175466, 328244), + # ── wave 2: avito НЕ подтверждён (429/403 — пул исчерпан, не опровергнут; либо + # omonym без avito-специфичной дизамбигуации) — только cian+yandex (16 городов) ── + "revda": CityLocation(None, 4956, 328540), + "polevskoy": CityLocation(None, 4937, 328436), + "berezovskiy": CityLocation(None, 4679, 559131), + "zarechny": CityLocation(None, 4754, 328386), + "kachkanar": CityLocation(None, 4792, 327958), + "sredneuralsk": CityLocation(None, 5000, 327474), + "degtyarsk": CityLocation(None, 4730, 328381), + "artemovskiy": CityLocation(None, 4657, 156418), + "kamyshlov": CityLocation(None, 4784, 328411), + "sukhoy_log": CityLocation(None, 5004, 328395), + "kushva": CityLocation(None, 4836, 328492), + "karpinsk": CityLocation(None, 4790, 328477), + "nizhnyaya_tura": CityLocation(None, 4888, 328487), + "nizhnie_sergi": CityLocation(None, 175439, 328114), + "lesnoy": CityLocation(None, 4844, 327968), + "verkhoturye": CityLocation(None, 174499, 328251), + # ── wave 2: ни avito, ни yandex — только cian (1 город) ───────────────────── + "mikhaylovsk": CityLocation(None, 175332, None), } @@ -367,6 +483,50 @@ CITY_DISPLAY_NAMES: dict[str, str] = { "pervouralsk": "Первоуральск", "verkhnyaya_pyshma": "Верхняя Пышма", "serov": "Серов", + # ── wave 2 (40 городов, #262) — заведены для ВСЕХ, независимо от того, какие + # provider-идентификаторы подтверждены в CITY_LOCATIONS: без записи здесь + # resolve_city_name() тихо вернула бы "Екатеринбург" для листингов города, + # у которого хотя бы один sweep-источник реально enabled. + "novouralsk": "Новоуральск", + "revda": "Ревда", + "polevskoy": "Полевской", + "asbest": "Асбест", + "bogdanovich": "Богданович", + "irbit": "Ирбит", + "krasnoufimsk": "Красноуфимск", + "berezovskiy": "Берёзовский", + "zarechny": "Заречный", + "kachkanar": "Качканар", + "krasnoturinsk": "Краснотурьинск", + "severouralsk": "Североуральск", + "ivdel": "Ивдель", + "tavda": "Тавда", + "turinsk": "Туринск", + "sysert": "Сысерть", + "sredneuralsk": "Среднеуральск", + "degtyarsk": "Дегтярск", + "verkhnyaya_salda": "Верхняя Салда", + "nizhnyaya_salda": "Нижняя Салда", + "nevyansk": "Невьянск", + "artemovskiy": "Артёмовский", + "kamyshlov": "Камышлов", + "alapaevsk": "Алапаевск", + "sukhoy_log": "Сухой Лог", + "kushva": "Кушва", + "krasnouralsk": "Красноуральск", + "karpinsk": "Карпинск", + "nizhnyaya_tura": "Нижняя Тура", + "verkhniy_tagil": "Верхний Тагил", + "nizhnie_sergi": "Нижние Серги", + "lesnoy": "Лесной", + "rezh": "Реж", + "aramil": "Арамиль", + "volchansk": "Волчанск", + "verkhnyaya_tura": "Верхняя Тура", + "mikhaylovsk": "Михайловск", + "verkhoturye": "Верхотурье", + "talitsa": "Талица", + "novaya_lyalya": "Новая Ляля", } EKATERINBURG_CITY_NAME = "Екатеринбург" @@ -994,6 +1154,21 @@ async def run_avito_city_sweep( # None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже). _anchors = anchors if anchors is not None else EKB_ANCHORS _loc = get_city_location(city_slug) + # #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города + # он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия). + # Известный city_slug БЕЗ подтверждённого avito_slug — explicit fail, а НЕ молчаливый + # fallback на ЕКБ: `_city_seg()` в AvitoScraper делает `target_city_slug or + # "ekaterinburg"`, поэтому city_slug=None ниже незаметно собрал бы Екатеринбург под + # меткой города-цели. scrape_schedules-сиды (262_) заводят avito_city_sweep_ + # ТОЛЬКО там, где avito_slug подтверждён — этот раздел кода не должен исполняться в + # штатной эксплуатации; сработавший ValueError сигнализирует рассинхрон данных. + if _loc is not None and _loc.avito_slug is None: + raise ValueError( + f"run_avito_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но " + "avito_slug не подтверждён (None) — отказ от молчаливого fallback на " + "ekaterinburg. Подтверди avito_slug для этого города, прежде чем заводить " + "avito_city_sweep_ schedule." + ) # kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug — # вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor. _avito_slug = _loc.avito_slug if _loc else city_slug @@ -1932,6 +2107,20 @@ async def run_yandex_city_sweep( # city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP # на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе. _loc = get_city_location(city_slug) + # #262 wave 2: yandex_rgid у CityLocation Optional — mikhaylovsk (Нижнесергинский + # р-н) отсутствует в гео-базе Яндекс.Недвижимости вообще (единственный там — + # ставропольский Михайловск, rgid 586221, подставлять чужой rgid нельзя). Известный + # city_slug БЕЗ подтверждённого yandex_rgid — explicit fail, а НЕ молчаливый fallback + # на ЕКБ rgid (YandexRealtyScraper(city_rgid=None) конструктор молча берёт ЕКБ). + # scrape_schedules-сиды (262_) НЕ заводят yandex_city_sweep_ для городов без + # rgid — этот раздел не должен исполняться в штатной эксплуатации. + if _loc is not None and _loc.yandex_rgid is None: + raise ValueError( + f"run_yandex_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но " + "yandex_rgid не подтверждён (None) — отказ от молчаливого fallback на ЕКБ " + "rgid. Город отсутствует в гео-базе Яндекса — не заводи yandex_city_sweep_" + " schedule для него вовсе." + ) # #2594: город для save_listings(..., city=...) — один на весь sweep. _city_name = resolve_city_name(city_slug) # Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard