Черновик регулярного сбора по Москве и области: сетка якорей, ID площадок, гео-профили ДомКлика #3533

Merged
lekss361 merged 6 commits from feat/msk-sweep-draft into main 2026-09-15 17:51:32 +00:00
Owner

Пункт 1 долга — черновик скрапперов (запрошен именно как черновик). Регулярного сбора по 77/50 нет, корпус живёт разовой загрузкой; ниже — то, без чего свип по Москве физически невозможен.

1. ДомКлик: гео-профиль вместо зашитого ЕКБ

_EKB_ADDRESS_GUID / _EKB_AREA_ID были модульными константами — прогон с region_code != 66 собрал бы Екатеринбург и проштамповал его чужим регионом.

Новый DomClickGeoProfile(region_code, address_guid, aids, origin_url, bbox, region_name, city_name) + реестр DOMCLICK_GEO_PROFILES + geo_profile_for_region() (ValueError на неизвестном регионе). _build_offers_url / _build_count_url берут address_guid и aids keyword-only; aids=None → параметр не добавляется.

GUID Москвы 1d1463ae-… и области 9930cc20-… проверены живым запросом 12.09.2026. aids вне ЕКБ не нужен — счётчик без него совпадает с сайтом. _is_geo_ok теперь по bbox профиля, имя региона сверяется только когда оно задано: вне ЕКБ offerRegionName непригоден (Новая Москва приходит как «г. Говорово»).

Страница прогрева 77/50 перепроверена живьём 15.09.2026 — черновик угадал неверно, исправлено вторым коммитом:

msk.domclick.ru                -> 301 https://domclick.ru/...
moskovskaya-oblast.domclick.ru -> 301 https://domclick.ru/...
domclick.ru                    -> 401 (QRATOR-челлендж — живая выдача)
ekaterinburg.domclick.ru       -> 401 (контроль)

У Москвы своего субдомена нет, апекс и есть московская выдача; прогрев по 301 потерял бы куку QRATOR, а подзапрос к BFF без неё отдаёт ChallengeTimeout. Профиль 66 — байт в байт прежний.

2. Авито / Циан / Яндекс: снятие привязки к ЕКБ

  • CityLocation.cian_host (по умолчанию https://ekb.cian.ru, для Москвы https://www.cian.ru) → CianScraper(city_host=…). Раньше домен был зашит в providers/cian/serp.py:128.
  • Три новых скоупа: moskva (cian region=1 / rgid 587795), moskovskaya_oblast (4593 / 587654), moskva_i_mo (-1 / 741964). У всех avito_slug="moskva_i_mo" плюс новый флаг avito_slug_is_region=True — у Авито Москва и область один скоуп, отдельного URL области нет.
  • MULTI_CITY_SLUGSresolve_city_name возвращает None → city=NULL вместо ложного города.
  • Все три резолвера теперь бросают ValueError на неизвестном слаге; раньше get_city_anchors / get_city_location молча отдавали ЕКБ.

Сетка якорей Москвы — 25 точек (5×5, шаг 0.10° по широте = 11.13 км, 0.15° по долготе = 9.39 км). Она привязана к MOSCOW_ANCHOR_RADIUS_M = 8000: полудиагональ ячейки 7.27 км < 8 км, дыр нет. Расписания обязаны передавать radius_m=8000 — дефолтные 1500 оставят дыры. Центр (55.75/37.60) стоит первым, потому что get_city_anchor_point берёт anchors[0]; радиус гео-штампа Москвы — 30 км.

Область: 12 спутниковых якорей из _COVERAGE_NEGATIVE_CENTROIDS_DEG. В коде оставлен TODO на Домодедово, Раменское, Щёлково, Сергиев Посад, Ногинск, Электросталь, Серпухов, Коломну, Орехово-Зуево, Наро-Фоминск — координат этих городов нет ни в одном источнике репозитория, и выдумывать их агент не стал.

3. Ценовые коридоры: планировщик + учёт усечения

Разведка исходила из того, что бисекцию надо портировать из scripts/local-avito-msk/collect.py. Это оказалось неверно: pricing/bisection.py::walk_price_range существует, асинхронный, им пользуются все четыре провайдера. Не хватало другого — чистого тестируемого планировщика и учёта усечения.

Новый pricing/planner.py::plan_price_corridors(...) -> PricePlan со статистикой PlanStats(corridors, truncated_corridors, missed_estimate, unknown_corridors, probes, max_depth_reached, root_count) и флагом complete. В живом движке — BisectionStats, параметр stats опциональный (по умолчанию None, новых ветвлений в обходе нет).

Попутно найден и закрыт дефект оригинала из collect.py: geometric_mid при hi - lo <= 1 возвращал саму границу (max(low+1, …) перебивал min(hi-1, …)) → рекурсия на себя, спасал только MAX_DEPTH. Добавлен третий предохранитель: атомарный коридор помечается, а не делится.

price_bisection_enabled(region_code): False для 66 и None, True для прочих.

Ограничение, которое надо снять отдельным заходом: ни один провайдер пока не передаёт ни планировщик, ни stats= — это рабочие сиды, но сейчас мёртвый код, и усечение в счётчики прогона реально не попадёт. Нужны 4 call-site.

Тесты

53 (ДомКлик) + 91 (реестр площадок) + 37 (планировщик) зелёные.

Чего в этом PR НЕТ

Самих расписаний scrape_schedules для 77/50 — они пойдут отдельно, после того как первый ручной прогон подтвердит, что профили живые.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs

Пункт 1 долга — **черновик скрапперов** (запрошен именно как черновик). Регулярного сбора по 77/50 нет, корпус живёт разовой загрузкой; ниже — то, без чего свип по Москве физически невозможен. ## 1. ДомКлик: гео-профиль вместо зашитого ЕКБ `_EKB_ADDRESS_GUID` / `_EKB_AREA_ID` были модульными константами — прогон с `region_code != 66` собрал бы Екатеринбург и проштамповал его чужим регионом. Новый `DomClickGeoProfile(region_code, address_guid, aids, origin_url, bbox, region_name, city_name)` + реестр `DOMCLICK_GEO_PROFILES` + `geo_profile_for_region()` (ValueError на неизвестном регионе). `_build_offers_url` / `_build_count_url` берут `address_guid` и `aids` keyword-only; `aids=None` → параметр не добавляется. GUID Москвы `1d1463ae-…` и области `9930cc20-…` проверены живым запросом 12.09.2026. `aids` вне ЕКБ не нужен — счётчик без него совпадает с сайтом. `_is_geo_ok` теперь по bbox профиля, имя региона сверяется только когда оно задано: вне ЕКБ `offerRegionName` непригоден (Новая Москва приходит как «г. Говорово»). Страница прогрева 77/50 **перепроверена живьём 15.09.2026** — черновик угадал неверно, исправлено вторым коммитом: ``` msk.domclick.ru -> 301 https://domclick.ru/... moskovskaya-oblast.domclick.ru -> 301 https://domclick.ru/... domclick.ru -> 401 (QRATOR-челлендж — живая выдача) ekaterinburg.domclick.ru -> 401 (контроль) ``` У Москвы своего субдомена нет, апекс и есть московская выдача; прогрев по 301 потерял бы куку QRATOR, а подзапрос к BFF без неё отдаёт ChallengeTimeout. Профиль 66 — байт в байт прежний. ## 2. Авито / Циан / Яндекс: снятие привязки к ЕКБ - `CityLocation.cian_host` (по умолчанию `https://ekb.cian.ru`, для Москвы `https://www.cian.ru`) → `CianScraper(city_host=…)`. Раньше домен был зашит в `providers/cian/serp.py:128`. - Три новых скоупа: `moskva` (cian `region=1` / rgid `587795`), `moskovskaya_oblast` (`4593` / `587654`), `moskva_i_mo` (`-1` / `741964`). У всех `avito_slug="moskva_i_mo"` плюс новый флаг `avito_slug_is_region=True` — у Авито Москва и область один скоуп, отдельного URL области нет. - `MULTI_CITY_SLUGS` → `resolve_city_name` возвращает None → `city=NULL` вместо ложного города. - Все три резолвера теперь бросают ValueError на неизвестном слаге; раньше `get_city_anchors` / `get_city_location` молча отдавали ЕКБ. **Сетка якорей Москвы — 25 точек (5×5, шаг 0.10° по широте = 11.13 км, 0.15° по долготе = 9.39 км).** Она привязана к `MOSCOW_ANCHOR_RADIUS_M = 8000`: полудиагональ ячейки 7.27 км < 8 км, дыр нет. **Расписания обязаны передавать `radius_m=8000`** — дефолтные 1500 оставят дыры. Центр (55.75/37.60) стоит первым, потому что `get_city_anchor_point` берёт `anchors[0]`; радиус гео-штампа Москвы — 30 км. Область: 12 спутниковых якорей из `_COVERAGE_NEGATIVE_CENTROIDS_DEG`. В коде оставлен TODO на Домодедово, Раменское, Щёлково, Сергиев Посад, Ногинск, Электросталь, Серпухов, Коломну, Орехово-Зуево, Наро-Фоминск — координат этих городов нет ни в одном источнике репозитория, и выдумывать их агент не стал. ## 3. Ценовые коридоры: планировщик + учёт усечения Разведка исходила из того, что бисекцию надо портировать из `scripts/local-avito-msk/collect.py`. Это оказалось неверно: `pricing/bisection.py::walk_price_range` существует, асинхронный, им пользуются все четыре провайдера. Не хватало другого — чистого тестируемого планировщика и **учёта усечения**. Новый `pricing/planner.py::plan_price_corridors(...) -> PricePlan` со статистикой `PlanStats(corridors, truncated_corridors, missed_estimate, unknown_corridors, probes, max_depth_reached, root_count)` и флагом `complete`. В живом движке — `BisectionStats`, параметр `stats` опциональный (по умолчанию None, новых ветвлений в обходе нет). Попутно найден и закрыт дефект оригинала из `collect.py`: `geometric_mid` при `hi - lo <= 1` возвращал саму границу (`max(low+1, …)` перебивал `min(hi-1, …)`) → рекурсия на себя, спасал только MAX_DEPTH. Добавлен третий предохранитель: атомарный коридор помечается, а не делится. `price_bisection_enabled(region_code)`: False для 66 и None, True для прочих. **Ограничение, которое надо снять отдельным заходом:** ни один провайдер пока не передаёт ни планировщик, ни `stats=` — это рабочие сиды, но сейчас мёртвый код, и усечение в счётчики прогона реально не попадёт. Нужны 4 call-site. ## Тесты 53 (ДомКлик) + 91 (реестр площадок) + 37 (планировщик) зелёные. ## Чего в этом PR НЕТ Самих расписаний `scrape_schedules` для 77/50 — они пойдут отдельно, после того как первый ручной прогон подтвердит, что профили живые. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs
lekss361 added 4 commits 2026-09-15 17:10:07 +00:00
Черновик регионального свипа: GUID, aids, страница прогрева QRATOR, bbox гарда
и имя города собраны в DomClickGeoProfile и реестр DOMCLICK_GEO_PROFILES
(66/77/50). Раньше это были модульные константы _EKB_*, поэтому строка
расписания с region_code 77/50 собрала бы Екатеринбург и записала его под чужим
регионом молча — pipeline отбивал такой прогон ValueError'ом; теперь вместо
гарда резолвится профиль, а падение остаётся только на коде без профиля.

Значения 66 сохранены байт в байт (тест сверяет offers- и count-URL с
литералами, снятыми до правки). У Москвы и области aids не добавляется: без
него счётчик совпадает с сайтом (замер 12.09.2026). Имя региона сверяется
только там, где задано: offerRegionName вне ЕКБ ненадёжен — офферы Новой
Москвы приходят как «г. Говорово», поэтому для 77/50 гард только по bbox.
bbox 77/50 продублированы из app.services.regions REGIONS[code].bbox_region —
кит не имеет права импортировать app.*.

Не проверено живьём: страница прогрева для 77/50 (msk.domclick.ru) — единственное
поле профилей, взятое по схеме субдомена, а не замером.
Черновик московской развёртки: реестры городов перестают быть свердловскими.

- cian: хост уехал из классового base_url в CityLocation.cian_host — поддомен
  региональный (ekb.cian.ru — вся Свердловская обл., www.cian.ru — Москва и МО)
  и обязан ехать вместе с region=, иначе они разъедутся молча. ЕКБ идёт по
  None-пути: URL байт в байт прежний.
- реестры: moskva (cian region=1 / rgid 587795), moskovskaya_oblast (4593 /
  587654) и объединённый moskva_i_mo (-1 / 741964) — значения замерены живьём
  10-12.09.2026. У avito слаг один на оба региона (moskva_i_mo), деление по
  регионам делает импорт по полигонам.
- якоря Москвы: сетка 5x5 = 25 узлов по ядру 55.55..55.95 / 37.30..37.90, шаг
  0.10x0.15 градуса — полудиагональ ячейки 7.27 км против радиуса запроса 8 км,
  дыр в покрытии нет. Центр ядра стоит первым: его берёт гео-гард штампа.
  У области — по якорю на город-спутник, координаты из реестра репозитория.
- радиус доверия штампа Москвы 30 км (дальний угол ядра 29.1 км) — дефолтные
  15 км срезали бы city у легитимных лотов за МКАД.
- avito_serp_ekb_only перестал быть глобальным «только Екатеринбург»: на слаге
  РЕГИОНА per-card фильтр выключается (в ссылках карточек /moskva/, /himki/ —
  слаг moskva_i_mo там не встречается, фильтр выбросил бы всю выдачу).
- неизвестный слаг теперь ПАДАЕТ во всех трёх резолверах вместо тихого отката
  на Екатеринбург; у многогородних скоупов city=NULL вместо ложного штампа.

Строк расписаний и ценовой бисекции здесь нет, живых прогонов не было.
Потолок выдачи площадки (Avito ~1500 / Cian 1512 / Yandex 500 / DomClick 2000)
по Свердловской области не мешал — вторичка влезает в сид-брекеты. По Москве и
области лотов десятки тысяч, и прогон молча забирал верхушку выдачи, рапортуя
успех. Движок бисекции в ките уже был, но усечение он нигде не фиксировал.

pricing/planner.py — синхронный ЧИСТЫЙ планировщик plan_price_corridors():
на вход count_of(lo, hi), на выход коридоры + PlanStats. Алгоритм перенесён из
обкатанного живьём scripts/local-avito-msk/collect.py: геометрическая середина
sqrt(lo*hi) (цены логнормальны — арифметическая отрезает пустую верхнюю
половину), поиск верхней границы удвоением от 8 млн, предохранители MAX_DEPTH=12
и MIN_WIDTH_RATIO=1.05. Неделимый переполненный коридор помечается truncated, а
его недобор попадает в missed_estimate.

Добавлен третий предохранитель, которого в образце нет: атомарный коридор
(hi-lo<=1) не имеет строгой внутренней точки, geometric_mid вернул бы саму
границу и половина совпала бы с родителем — рекурсия по себе, от которой
collect.py спасает только MAX_DEPTH. Покрыто тестом.

pricing/bisection.py — BisectionStats: probes/leaves/truncated_leaves/
missed_estimate/unknown_leaves/skipped_probe_fail/degraded. Параметр stats
опционален и по умолчанию None, поэтому дерево рекурсии не меняется ни на
ветку — golden-parity всех четырёх провайдеров на месте (21 существующий тест
бисекции зелёный).

Регион 66 и НЕИЗВЕСТНЫЙ регион — бисекция выключена: там она не нужна (всё
влезает в сид-брекеты), а лишний probe — лишний запрос под антибот. None
трактуется как 66, а не как «включить»: провайдеры фоллбэчат на ЕКБ-дефолт, и
«безопаснее включить» означало бы молча поменять боевое поведение прогонов,
которые просто не прокинули параметр. Выключенный режим всё равно делает
корневой probe и репортит усечение — молчания нет и там.
fix(domclick): страница прогрева Москвы и области — апексный домен
Some checks failed
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m25s
02d4691841
Черновик профилей 77/50 угадывал субдомен по схеме `<город>.domclick.ru`.
Замер 15.09.2026 показал, что у Москвы своего субдомена нет:

  msk.domclick.ru                -> 301 https://domclick.ru/...
  moskovskaya-oblast.domclick.ru -> 301 https://domclick.ru/...
  domclick.ru                    -> 401 (QRATOR-челлендж — живая выдача)
  ekaterinburg.domclick.ru       -> 401 (контроль)

Прогрев по 301 ушёл бы на другой URL и с большой вероятностью потерял бы
куку QRATOR, а подзапрос к BFF без неё отдаёт ChallengeTimeout. Профиль 66
не тронут.
bot-backend added 1 commit 2026-09-15 17:17:50 +00:00
feat(scraper): якоря области — 22 города + 22 кластера лот-массы вместо TODO
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m9s
4646c3b79e
Черновик оставлял TODO: 10 крупных городов области без координат («выдуманные
координаты хуже отсутствующих») и без замера, сколько лотов у какого города.
Оба пробела закрыты измерением, ни одна координата не выдумана.

Слой 1 — города-спутники, 22 вместо 12. Недостающие 10 (Домодедово, Раменское,
Щёлково, Сергиев Посад, Ногинск, Электросталь, Серпухов, Коломна, Орехово-Зуево,
Наро-Фоминск) взяты запросом к Nominatim /search 15.09.2026, все вернулись как
place=city/town.

Слой 2 — кластеры лот-массы, 22 точки: центроиды активных объявлений области в
ячейках 0.05°, добранные жадным покрытием поверх слоя 1; подпись — ближайший НП
по обратному геокодированию, справочная.

Второй слой нужен потому, что городов мало. Замер на проде по 70 996 активным
объявлениям region_code=50: 22 городских якоря радиусом 10 км накрывают 73.8%
лот-массы, вместе со вторым слоем — 95.2%.

Порядок в обоих слоях — по убыванию измеренной лот-массы; в комментарии у каждой
строки лоты в 10 км (слой 1) или прирост покрытия (слой 2). Это и есть тот замер,
которого раньше не было — TODO о ранжировании снят.

MOSCOW_OBLAST_ANCHOR_RADIUS_M = 10000 зафиксирован константой: 95.2% относятся
именно к 10 км, меньший radius_m в расписании даст дыры.

Оговорка записана в коде: лот-масса взята из уже собранного корпуса (разовая
загрузка 10-12.09.2026), поэтому 95.2% — оценка по тому, что видно, а не
доказательство полноты. Первый регулярный прогон обязан пересчитать покрытие.
bot-backend added 1 commit 2026-09-15 17:45:50 +00:00
fix(scheduler): опечатка в city расписания падает, а не собирает Екатеринбург
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
450940cafb
CI ветки краснел на test_job_avito_city_sweep_unknown_city_falls_back_to_none:
тест закреплял прежний контракт «неизвестный слаг → anchors=None», а реестр теперь
бросает ValueError вместо молчаливого отката на ЕКБ.

Правильное поведение — падать. anchors=None уводит свип на EKB_ANCHORS, то есть
опечатка в default_params.city давала полноценный прогон по Екатеринбургу,
проштампованный именем чужого города. Тик планировщика ValueError ловит
(logger.exception в _run), цикл не умирает — крашится только этот прогон.

Отсутствие ключа city по-прежнему даёт anchors=None и citywide-свип ЕКБ: это
контракт старых расписаний, он не тронут. Комментарий на call-site приведён в
соответствие — он всё ещё описывал возврат None.
lekss361 merged commit 6da7de06a4 into main 2026-09-15 17:51:32 +00:00
lekss361 deleted branch feat/msk-sweep-draft 2026-09-15 17:51:32 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3533
No description provided.