feat(tradein): вся Свердловская область — 40 городов в city-sweep (#2879)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m33s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m38s

This commit is contained in:
lekss361 2026-08-13 19:08:44 +00:00
parent a4d6cbba25
commit 01b5e73ea4
6 changed files with 1681 additions and 10 deletions

View file

@ -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
)

View file

@ -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/<slug>/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_<city> — ТОЛЬКО 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,
0,
((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_<city> — ВСЕ 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,
0,
((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,
0,
((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_<city> — 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,
0,
((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,
0,
((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;

View file

@ -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

View file

@ -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 ───────────────────────────────────────────────────────

View file

@ -0,0 +1,452 @@
"""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_<slug>
строку в 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:
"""Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler
это поддерживает явно (`window_end_hour <= window_start_hour` окно через
полночь, см. scraper_kit.orchestration.scheduler)."""
for provider, slug, start, end in _row_windows(_MIGRATION_262):
expected = (start + 1) % 24
assert (
end == expected
), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})"
def test_migration_262_window_hours_satisfy_db_check_constraint() -> None:
"""Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе.
Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка
«end == start + 1» это пропускала, а прод-констрейнт `window_end_range`
(052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23))
нет, и весь INSERT падал в CI на реальном postgres.
"""
for provider, slug, start, end in _row_windows(_MIGRATION_262):
assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23"
assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23"
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

View file

@ -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_<city>
# ТОЛЬКО там, где 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_<city> 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_<city> для городов без
# 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_"
"<city> schedule для него вовсе."
)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard