All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
Запрет на --source domclick --region 50 был верен ровно до тех пор, пока областного корпуса не существовало. Теперь он собран отдельным прогоном с областным GUID, поэтому запрет снят и заменён настоящим фильтром. Регион читается первым компонентом адреса. Замер живьём на API ДомКлика 12.09.2026: у московских карточек первый компонент «Москва» — так у всех 22 836 строк прод-корпуса; у областных «Московская область» — так у всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и симметричное тому, что уже сделано для Яндекса. Фильтр здесь не вторая линия обороны, а единственный способ развести регионы: обе партии лежат в одной таблице msk_raw.domclick_cards, колонки региона в ней нет, а вью domclick_latest отдаёт их вперемешку. Поэтому в тестах появился случай со смешанной страницей сырья: из двух карточек при region=50 отбирается ровно одна, вторая уходит в счётчик не целевого региона. Прежний тест ожидал SystemExit и заменён поведенческими. Московский путь не тронут.
879 lines
48 KiB
Python
879 lines
48 KiB
Python
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
|
||
|
||
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
|
||
несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
|
||
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
|
||
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
|
||
|
||
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
|
||
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
||
опознаётся — байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
|
||
— 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox — 17 576 (это
|
||
Московская область, регион 50); без округа вне bbox — 7 337. Отдельно 212
|
||
карточек с адресом вида «Екатеринбург (Cian)» — артефакт парсера, считаются
|
||
своим счётчиком, чтобы не растворяться в «не целевой регион».
|
||
|
||
Область (регион 50) у Циана в адресе НЕ видна вовсе — берём по ПОДДОМЕНУ
|
||
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` ⇒ область. Замер по
|
||
`msk_raw.cian_latest` 12.09.2026: `www` — 38 030 карточек, из них 36 569 с
|
||
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
|
||
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
|
||
mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с префиксом округа,
|
||
итого не-www ≈ 24 784. Поддомен и префикс округа нигде не противоречат друг
|
||
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область —
|
||
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`) →
|
||
карточка НЕ область (консервативно, счётчик «не целевой регион»).
|
||
|
||
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с
|
||
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
|
||
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
|
||
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр —
|
||
для ЛЮБОГО целевого региона.
|
||
|
||
Два сигнала, и оба нужны — ни один по отдельности не годится.
|
||
|
||
1. Город по версии самого Авито — слаг в `source_url`
|
||
(`avito.ru/<slug>/kvartiry/...`). Заполнен у 100% карточек, стоит 0 вызовов:
|
||
`moskva` — 21 841 карточка, остальное — муниципалитеты области (balashiha,
|
||
himki, podolsk, …). Это единственный ТОЧНЫЙ признак населённого пункта, но
|
||
регион по нему не выводится: Троицк/Щербинка/Коммунарка/Московский/
|
||
Зеленоград — свои слаги, а регион у них 77 (Новая Москва и ЗелАО).
|
||
|
||
2. Координаты и регион — DaData /suggest/address с `locations`-констрейнтом.
|
||
Слаг сужает констрейнт (`moskva` → только регион «Москва», иначе — «Москва»
|
||
И «Московская» разом), но РЕГИОН БЕРЁТСЯ ИЗ ОТВЕТА (`region_kladr_id`), а не
|
||
из слага — иначе Новая Москва уехала бы в область.
|
||
|
||
Почему не наоборот (только геокод, без слага). Замер на 200 случайных адресах
|
||
Авито с констрейнтом «Москва + Московская»: дом с координатами нашёлся у 184
|
||
(92%), но верхний кандидат разошёлся со слагом у 28 из 184 (15%) — и почти
|
||
всегда в пользу Москвы («пр-т Мира,19» при слаге fryazino → «г Москва, пр-кт
|
||
Мира, 19»). Голый адрес без города DaData тянет в столицу; 15% чужих домов в
|
||
регионе 77 — ровно та ошибка, ради которой стоял `--allow-unfiltered`.
|
||
С сужением по слагу (`moskva` → регион «Москва») резолв 77 из 82 (94%),
|
||
qc_geo=0 у 97% найденных.
|
||
|
||
Что куда едет:
|
||
* регион == `--region` → в `listings`, С координатами (geom есть сразу,
|
||
radius-подбор аналогов работает без ожидания `geocode_missing`);
|
||
* адрес разрешился, но регион ответа не совпал с `--region` → не пишется,
|
||
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` — прогон с другим
|
||
`--region` подхватит её из кэша без единого внешнего вызова;
|
||
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») →
|
||
свой счётчик, карточка не пишется.
|
||
|
||
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
|
||
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||
|
||
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
|
||
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
|
||
то есть по ссылке на ЖК, а не по продаже застройщиком. Замер по всем 60 464:
|
||
`raw_payload.is_from_developer` = true у НУЛЯ карточек, false у 29 000,
|
||
отсутствует у 31 464 — застройщик в этом корпусе не продаёт ничего, это вся
|
||
вторичка. В estimator'е стоит гвард (`estimator.py:5992-5995`): в аналоги идут
|
||
только строки с `listing_segment IS NULL` или 'vtorichka'. Скопируй мы метку
|
||
кита — 29 000 карточек выпали бы из подбора. Поэтому метка считается заново:
|
||
is_from_developer is True → 'novostroyki', иначе → 'vtorichka'.
|
||
|
||
Идемпотентность — на стороне `save_listings`: он делает upsert
|
||
`ON CONFLICT (dedup_hash) DO UPDATE` плюс reconcile-UPDATE по
|
||
`(source, source_id)` на случай дрейфа хеша. `dedup_hash` = sha256(source +
|
||
source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), цена в ключ не
|
||
входит. Повторный прогон поэтому обновляет те же строки, а не плодит дубли;
|
||
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
|
||
одинаковы.
|
||
|
||
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
||
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
|
||
вторым компонентом. Замер по 21 393 карточкам первого прохода — ровно два
|
||
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
|
||
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
|
||
|
||
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
|
||
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
|
||
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
|
||
`mo-serp-domclick-20260912`) — оба пишут в один и тот же
|
||
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
|
||
`msk_raw.domclick_latest` отдаёт обе партии вперемешку — курсор по `id` не
|
||
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
|
||
опциональная вторая линия, а единственный способ развести регионы.
|
||
|
||
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
|
||
12.09.2026: московские карточки — «Москва, Генерала Дорохова проспект, 49» и
|
||
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
|
||
областные карточки — «Московская область, Химки, 7-й м-н, проспект
|
||
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
||
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
|
||
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
|
||
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
|
||
Москва приходит как «Москва, пос. Птичное, …» — посёлок стоит вторым
|
||
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
|
||
заполнены у 100% карточек в обоих прогонах.
|
||
|
||
Запуск:
|
||
python -m app.tasks.msk_raw_import --dry-run
|
||
python -m app.tasks.msk_raw_import --limit 500
|
||
python -m app.tasks.msk_raw_import --source yandex
|
||
python -m app.tasks.msk_raw_import --source yandex --region 50
|
||
python -m app.tasks.msk_raw_import --source cian --region 50
|
||
python -m app.tasks.msk_raw_import --source domclick
|
||
python -m app.tasks.msk_raw_import --source domclick --region 50
|
||
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
|
||
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
|
||
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass
|
||
from urllib.parse import urlsplit
|
||
|
||
from pydantic import ValidationError
|
||
from scraper_kit.base import ScrapedLot, save_listings
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import SessionLocal
|
||
from app.services import dadata
|
||
from app.services.geocoder import normalize_address
|
||
from app.services.scraper_adapters import RealMatcherAdapter
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MOSCOW_REGION_CODE = 77
|
||
MOSCOW_CITY = "Москва"
|
||
# Московская область в реестре `app.services.regions` заведена, но своего
|
||
# единого города у неё нет (`canonical_city is None`) — в listings.city для
|
||
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
|
||
OBLAST_REGION_CODE = 50
|
||
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
|
||
DEFAULT_BATCH_SIZE = 500
|
||
|
||
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
|
||
AVITO_MOSCOW_SLUG = "moskva"
|
||
# Имена регионов для DaData-констрейнта `locations`. DaData хранит имя БЕЗ типа
|
||
# («Москва», «Московская»), тип лежит отдельно в `region_type` — с типом
|
||
# hard-фильтр молча схлопывает выдачу в ноль (та же грабля, что в
|
||
# `geocoder.SVERDLOVSK_OBLAST_REGION`).
|
||
DADATA_MOSCOW_REGION = "Москва"
|
||
DADATA_OBLAST_REGION = "Московская"
|
||
# Первые две цифры КЛАДР региона в ответе DaData → код региона.
|
||
_KLADR_TO_REGION = {"77": MOSCOW_REGION_CODE, "50": OBLAST_REGION_CODE}
|
||
# Дневной потолок внешних вызовов. Free tier DaData /suggest — 10 000/сутки на
|
||
# аккаунт, и тот же аккаунт обслуживает автокомплит формы оценки; берём с запасом.
|
||
DEFAULT_GEOCODE_LIMIT = 9000
|
||
# Сколько запросов к DaData держим в полёте одновременно. Больше смысла нет:
|
||
# упираемся не в нас, а в квоту.
|
||
_GEOCODE_CONCURRENCY = 6
|
||
|
||
# Префиксы административных округов Москвы — единственный признак города в адресе
|
||
# карточки Циана (сам город в адрес не попадает).
|
||
MOSCOW_OKRUGS = (
|
||
"ЦАО",
|
||
"САО",
|
||
"СВАО",
|
||
"ВАО",
|
||
"ЮВАО",
|
||
"ЮАО",
|
||
"ЮЗАО",
|
||
"ЗАО",
|
||
"СЗАО",
|
||
"ЗелАО",
|
||
"НАО",
|
||
"ТАО",
|
||
)
|
||
# Lookahead вместо \b: следом за округом идёт запятая/пробел, но НЕ буква — иначе
|
||
# «ЗАО» матчило бы начало гипотетического «ЗАОзёрная».
|
||
_MOSCOW_OKRUG_RE = re.compile(
|
||
r"^(?:" + "|".join(MOSCOW_OKRUGS) + r")(?![А-Яа-яЁёA-Za-z])",
|
||
)
|
||
# Артефакт парсера: адрес вида «Екатеринбург (Cian)» в московском корпусе.
|
||
_ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
|
||
|
||
# Вью-источники. Только whitelist: имя подставляется в SQL текстом, параметром
|
||
# идентификатор не передать.
|
||
SOURCE_VIEWS = {
|
||
"cian": "msk_raw.cian_latest",
|
||
"avito": "msk_raw.avito_latest",
|
||
"yandex": "msk_raw.yandex_latest",
|
||
"domclick": "msk_raw.domclick_latest",
|
||
}
|
||
|
||
_PAGE_SQL = """
|
||
SELECT id, payload
|
||
FROM {view}
|
||
WHERE id > :after
|
||
ORDER BY id
|
||
LIMIT :limit
|
||
"""
|
||
|
||
|
||
# ── Пред-геокод Авито ────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class GeoPoint:
|
||
"""Разрешённая точка адреса Авито. `region_code` — из ответа, не из слага."""
|
||
|
||
lat: float
|
||
lon: float
|
||
region_code: int
|
||
full_address: str
|
||
|
||
|
||
@dataclass
|
||
class GeocodeBudget:
|
||
"""Дневной потолок внешних вызовов на прогон.
|
||
|
||
Кончился — не падаем, а перестаём спрашивать: карточки без точки просто не
|
||
пишутся в этот заход, а на следующем подхватятся с того же места (уже
|
||
разрешённые адреса лежат в кэше и квоты не стоят).
|
||
"""
|
||
|
||
remaining: int
|
||
spent: int = 0
|
||
exhausted: bool = False
|
||
|
||
def take(self, n: int) -> int:
|
||
"""Сколько из `n` запросов позволено сделать сейчас."""
|
||
allowed = max(0, min(n, self.remaining))
|
||
if allowed < n:
|
||
self.exhausted = True
|
||
self.remaining -= allowed
|
||
self.spent += allowed
|
||
return allowed
|
||
|
||
|
||
# Кэш пред-геокода. Живёт в `msk_raw` (рядом с сырьём, а не в прикладной схеме):
|
||
# это свойство КОРПУСА, а не приложения, и переживает пересбор listings.
|
||
# `geocode_cache` приложения сознательно не трогаем — там другой ключ (адрес +
|
||
# city_hint) и другой TTL, а нам нужен слаг в ключе и код региона в значении.
|
||
# Он же — «полка ожидания» для области: строки с region_code=50 никуда не
|
||
# пишутся, но остаются разрешёнными, и когда регион 50 появится в реестре,
|
||
# прогон по ним не потратит ни одного внешнего вызова.
|
||
#
|
||
# Строка с lat IS NULL — ОТРИЦАТЕЛЬНЫЙ результат («DaData дома не знает»), и он
|
||
# тоже кэшируется: без этого каждый повторный прогон заново тратил бы квоту на
|
||
# те же ~8% неразрешимых адресов (ЖК без улицы, «Мкр-н имени В.Н. Махалина,
|
||
# 33»). Передумать можно руками:
|
||
# DELETE FROM msk_raw.avito_geocode WHERE lat IS NULL.
|
||
_GEO_CACHE_DDL = """
|
||
CREATE TABLE IF NOT EXISTS msk_raw.avito_geocode (
|
||
cache_key text PRIMARY KEY,
|
||
slug text,
|
||
address text NOT NULL,
|
||
lat double precision,
|
||
lon double precision,
|
||
region_code smallint,
|
||
full_address text,
|
||
resolved_at timestamptz NOT NULL DEFAULT NOW()
|
||
)
|
||
"""
|
||
_GEO_CACHE_EXISTS = "SELECT to_regclass('msk_raw.avito_geocode')"
|
||
_GEO_CACHE_SELECT = """
|
||
SELECT cache_key, lat, lon, region_code, full_address
|
||
FROM msk_raw.avito_geocode
|
||
WHERE cache_key = ANY(:keys)
|
||
"""
|
||
_GEO_CACHE_UPSERT = """
|
||
INSERT INTO msk_raw.avito_geocode
|
||
(cache_key, slug, address, lat, lon, region_code, full_address)
|
||
VALUES (:key, :slug, :address, :lat, :lon, :region, :full)
|
||
ON CONFLICT (cache_key) DO UPDATE
|
||
SET lat = EXCLUDED.lat,
|
||
lon = EXCLUDED.lon,
|
||
region_code = EXCLUDED.region_code,
|
||
full_address = EXCLUDED.full_address,
|
||
resolved_at = NOW()
|
||
"""
|
||
|
||
|
||
def avito_city_slug(payload: dict) -> str | None:
|
||
"""Слаг города из `source_url`: `avito.ru/<slug>/kvartiry/...` → `<slug>`.
|
||
|
||
Единственный признак населённого пункта, который Авито отдаёт честно и
|
||
даром. Регион из него НЕ выводится (см. докстринг модуля) — он лишь сужает
|
||
констрейнт геокодера.
|
||
"""
|
||
url = payload.get("source_url")
|
||
if not isinstance(url, str) or not url:
|
||
return None
|
||
parts = [p for p in urlsplit(url).path.split("/") if p]
|
||
return parts[0] if parts else None
|
||
|
||
|
||
def geo_cache_key(address: str, slug: str | None) -> str:
|
||
"""Ключ кэша.
|
||
|
||
Слаг — ЧАСТЬ ключа, не украшение: один и тот же текст адреса встречается в
|
||
разных муниципалитетах (замер: 1110 адресов из 21 570 живут сразу под
|
||
несколькими слагами), и это РАЗНЫЕ дома.
|
||
"""
|
||
return f"{slug or '-'}|{normalize_address(address)}"
|
||
|
||
|
||
def _dadata_regions_for(slug: str | None) -> list[str]:
|
||
"""Констрейнт `locations` по слагу.
|
||
|
||
Москва — только столица; иначе оба региона, потому что слаг может оказаться
|
||
Новой Москвой (Троицк/Щербинка/Коммунарка/Московский) или Зеленоградом — у
|
||
них свои слаги, а регион 77.
|
||
"""
|
||
if slug == AVITO_MOSCOW_SLUG:
|
||
return [DADATA_MOSCOW_REGION]
|
||
return [DADATA_MOSCOW_REGION, DADATA_OBLAST_REGION]
|
||
|
||
|
||
def _point_from_suggestions(suggestions: list) -> GeoPoint | None:
|
||
"""Лучший ДОМ с координатами из выдачи DaData, иначе None.
|
||
|
||
Берём только house-level (`kind == 'house'`) с координатами: улица/город
|
||
дают точку в середине улицы или в центре НП — для radius-подбора аналогов
|
||
это хуже, чем отсутствие точки (сосед через квартал попадёт в выборку, а
|
||
настоящий сосед — нет). Регион — первые две цифры КЛАДР ответа.
|
||
"""
|
||
for s in suggestions:
|
||
if s.kind != "house" or s.lat is None or s.lon is None:
|
||
continue
|
||
region = _KLADR_TO_REGION.get((s.kladr_id or "")[:2])
|
||
if region is None:
|
||
continue # ни 77, ни 50 — констрейнт пробит, такой ответ не берём
|
||
return GeoPoint(
|
||
lat=float(s.lat),
|
||
lon=float(s.lon),
|
||
region_code=region,
|
||
full_address=s.unrestricted_value,
|
||
)
|
||
return None
|
||
|
||
|
||
async def _geocode_many(items: list[tuple[str, str, str | None]]) -> dict[str, GeoPoint | None]:
|
||
"""`[(cache_key, address, slug)]` → `{cache_key: GeoPoint | None}`.
|
||
|
||
Параллелим с потолком `_GEOCODE_CONCURRENCY` — упираемся в квоту, а не в нас.
|
||
Отказ DaData (сеть/429/401) выглядит как пустая выдача: `suggest_addresses`
|
||
гасит исключения сам и возвращает []. Такой адрес получит None — и он, как и
|
||
честное «дома не знаю», уедет в кэш отрицательным. Отсюда правило прогона:
|
||
увидел в итоге всплеск `не разрешён` — сначала проверь логи DaData, потом
|
||
чисти отрицательные строки кэша (SQL выше), иначе разовый 429 замолчит
|
||
адреса до ручной чистки.
|
||
"""
|
||
sem = asyncio.Semaphore(_GEOCODE_CONCURRENCY)
|
||
|
||
async def one(key: str, address: str, slug: str | None) -> tuple[str, GeoPoint | None]:
|
||
async with sem:
|
||
found = await dadata.suggest_addresses(
|
||
address, limit=10, regions=_dadata_regions_for(slug)
|
||
)
|
||
return key, _point_from_suggestions(found)
|
||
|
||
done = await asyncio.gather(*(one(k, a, sl) for k, a, sl in items))
|
||
return dict(done)
|
||
|
||
|
||
def _resolve_points(
|
||
db: Session,
|
||
rows: list,
|
||
budget: GeocodeBudget,
|
||
*,
|
||
dry_run: bool,
|
||
) -> dict[str, GeoPoint | None]:
|
||
"""Точки для всех адресов страницы: сначала кэш, остаток — у DaData.
|
||
|
||
Ключ дедуплицируется в пределах страницы: одна и та же связка слаг+адрес
|
||
(несколько квартир в одном доме — обычное дело, 50 335 карточек на 21 570
|
||
адресов) стоит ОДИН внешний вызов.
|
||
"""
|
||
wanted: dict[str, tuple[str, str | None]] = {}
|
||
for row in rows:
|
||
payload = row["payload"] or {}
|
||
address = payload.get("address")
|
||
if not address or is_artifact_address(address):
|
||
continue
|
||
slug = avito_city_slug(payload)
|
||
wanted.setdefault(geo_cache_key(address, slug), (address, slug))
|
||
if not wanted:
|
||
return {}
|
||
|
||
points: dict[str, GeoPoint | None] = {}
|
||
# Кэш создаётся только боевым прогоном (`geocode and not dry_run`), поэтому на
|
||
# первом `--dry-run` таблицы ещё нет и SELECT по ней роняет весь замер — то
|
||
# есть ломается ровно та репетиция, ради которой сухой прогон и существует.
|
||
# Проверяем наличие отношения, а не ловим исключение: в Postgres упавший
|
||
# оператор кладёт транзакцию целиком, и except потребовал бы rollback.
|
||
cache_exists = db.execute(text(_GEO_CACHE_EXISTS)).scalar() is not None
|
||
cached = (
|
||
db.execute(text(_GEO_CACHE_SELECT), {"keys": list(wanted)}).mappings().all()
|
||
if cache_exists
|
||
else ()
|
||
)
|
||
for row in cached:
|
||
points[row["cache_key"]] = (
|
||
None
|
||
if row["lat"] is None
|
||
else GeoPoint(
|
||
lat=row["lat"],
|
||
lon=row["lon"],
|
||
region_code=row["region_code"],
|
||
full_address=row["full_address"] or "",
|
||
)
|
||
)
|
||
|
||
misses = [(key, *wanted[key]) for key in wanted if key not in points]
|
||
if not misses:
|
||
return points
|
||
allowed = budget.take(len(misses))
|
||
if allowed == 0:
|
||
return points
|
||
fresh = asyncio.run(_geocode_many(misses[:allowed]))
|
||
points.update(fresh)
|
||
if dry_run:
|
||
return points # замер не пишет даже кэш — прогон остаётся повторяемым
|
||
for key, point in fresh.items():
|
||
address, slug = wanted[key]
|
||
db.execute(
|
||
text(_GEO_CACHE_UPSERT),
|
||
{
|
||
"key": key,
|
||
"slug": slug,
|
||
"address": address,
|
||
"lat": None if point is None else point.lat,
|
||
"lon": None if point is None else point.lon,
|
||
"region": None if point is None else point.region_code,
|
||
"full": None if point is None else point.full_address,
|
||
},
|
||
)
|
||
return points
|
||
|
||
|
||
@dataclass
|
||
class ImportCounters:
|
||
"""Разбор прогона. Числа обязаны сходиться, см. `check()`."""
|
||
|
||
read: int = 0
|
||
skipped_artifact: int = 0
|
||
# Карточка сама говорит про другой регион (префикс округа / поддомен
|
||
# Циана / компонент адреса не совпал с целевым `--region`).
|
||
skipped_not_target_region: int = 0
|
||
skipped_invalid: int = 0
|
||
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
|
||
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
|
||
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
|
||
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
|
||
# не «в целевом регионе стало меньше домов».
|
||
skipped_geo_other_region: int = 0
|
||
skipped_ungeocoded: int = 0
|
||
selected: int = 0
|
||
inserted: int = 0
|
||
updated: int = 0
|
||
geocode_calls: int = 0 # фактически потраченных внешних вызовов
|
||
geocode_budget_exhausted: bool = False
|
||
|
||
@property
|
||
def written(self) -> int:
|
||
return self.inserted + self.updated
|
||
|
||
@property
|
||
def writer_skipped(self) -> int:
|
||
"""Отобрано, но писатель строку не тронул.
|
||
|
||
`save_listings` возвращает только (inserted, updated); неизменные строки,
|
||
уже виденные сегодня, он пропускает своим гейтом (#2992). Остаток честно
|
||
показываем отдельно, а не растворяем в «записано».
|
||
"""
|
||
return self.selected - self.written
|
||
|
||
def check(self) -> bool:
|
||
return (
|
||
self.read
|
||
== self.selected
|
||
+ self.skipped_artifact
|
||
+ self.skipped_not_target_region
|
||
+ self.skipped_geo_other_region
|
||
+ self.skipped_ungeocoded
|
||
+ self.skipped_invalid
|
||
)
|
||
|
||
|
||
def is_artifact_address(address: str | None) -> bool:
|
||
"""Адрес чужого города в московском корпусе (артефакт парсера)."""
|
||
return bool(address) and _ARTIFACT_RE.search(address) is not None
|
||
|
||
|
||
def is_moscow_address(address: str | None) -> bool:
|
||
"""Москва опознаётся префиксом административного округа."""
|
||
if not address:
|
||
return False
|
||
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
|
||
|
||
|
||
def cian_subdomain(payload: dict) -> str | None:
|
||
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
|
||
|
||
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
|
||
область читается из URL. Поддомен и префикс округа не противоречат друг
|
||
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
|
||
`www` — 38 030 карточек (36 569 с префиксом округа, это Москва); все
|
||
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
|
||
khimki, mytishchi, podolsk, odintsovo, …) — 0 карточек с префиксом округа.
|
||
Хост не `*.cian.ru` или `source_url` отсутствует → None (консервативно:
|
||
региону не сопоставляем).
|
||
"""
|
||
url = payload.get("source_url")
|
||
if not isinstance(url, str) or not url:
|
||
return None
|
||
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
|
||
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
|
||
# суффикса и уехал в «не целевой регион».
|
||
host = urlsplit(url).hostname or ""
|
||
if not host.endswith(".cian.ru"):
|
||
return None
|
||
sub = host[: -len(".cian.ru")]
|
||
return sub or None
|
||
|
||
|
||
def is_cian_oblast_payload(payload: dict) -> bool:
|
||
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
|
||
sub = cian_subdomain(payload)
|
||
return sub is not None and sub != "www"
|
||
|
||
|
||
def is_moscow_yandex_address(address: str | None) -> bool:
|
||
"""У Яндекса регион — второй компонент полного адреса.
|
||
|
||
Адрес приходит нормализованным и с городом: «Россия, Москва, Коробейников
|
||
переулок, 1». Замер по 21 393 карточкам первого прохода: во втором
|
||
компоненте ровно ДВА значения — «Москва» 10 610 и «Московская область»
|
||
10 783, третьего не встречается. Поэтому ни префикса округа (как у Циана),
|
||
ни внешнего геокода (как у Авито) источнику не нужно: разделение 77 и 50
|
||
читается из самой карточки и стоит ноль вызовов.
|
||
|
||
Новая Москва отдельным значением НЕ приходит — Троицк и Зеленоград Яндекс
|
||
кладёт под «Москва», что совпадает с кодом региона 77.
|
||
"""
|
||
parts = [part.strip() for part in (address or "").split(",")]
|
||
return len(parts) > 1 and parts[1] == "Москва"
|
||
|
||
|
||
def is_oblast_yandex_address(address: str | None) -> bool:
|
||
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
|
||
|
||
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
|
||
ровно два значения (см. докстринг модуля), третьего нет — среди карточек
|
||
Яндекса «не Москва» и означает «область».
|
||
"""
|
||
parts = [part.strip() for part in (address or "").split(",")]
|
||
return len(parts) > 1 and parts[1] == "Московская область"
|
||
|
||
|
||
def is_moscow_domclick_address(address: str | None) -> bool:
|
||
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
|
||
|
||
Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно
|
||
отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье
|
||
и не должно быть. Фильтр здесь — вторая линия: сменится GUID в сборщике или
|
||
появится второй город в той же таблице — импорт не потащит его в Москву
|
||
молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно
|
||
одно значение, «Москва», областных нет ни одной.
|
||
|
||
Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, …»,
|
||
«Москва, пос. Птичное, …» — посёлок стоит ВТОРЫМ компонентом, первый всегда
|
||
город, что совпадает с кодом региона 77.
|
||
"""
|
||
parts = [part.strip() for part in (address or "").split(",")]
|
||
return bool(parts) and parts[0] == "Москва"
|
||
|
||
|
||
def is_oblast_domclick_address(address: str | None) -> bool:
|
||
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
|
||
|
||
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
|
||
копит ДВА разных прогона сборщика (московский батч
|
||
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
|
||
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
|
||
вперемешку — фильтр по адресу обязателен, не опционален. Замер живьём на
|
||
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
|
||
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
||
Звенигород, 3-й м-н, 28» и подобные — первый компонент «Московская
|
||
область» у всех 140 карточек выборки с семи разных смещений выдачи.
|
||
"""
|
||
parts = [part.strip() for part in (address or "").split(",")]
|
||
return bool(parts) and parts[0] == "Московская область"
|
||
|
||
|
||
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
|
||
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
|
||
return lambda payload: fn(payload.get("address"))
|
||
|
||
|
||
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
|
||
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
|
||
# молчит. Ключа нет только для источника, который вообще не умеет отличать
|
||
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
|
||
# фильтр обязан быть в реестре явно.
|
||
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
|
||
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
|
||
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
|
||
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
|
||
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
|
||
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
|
||
("domclick", OBLAST_REGION_CODE): _by_address(is_oblast_domclick_address),
|
||
}
|
||
|
||
|
||
def recompute_listing_segment(payload: dict) -> str:
|
||
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
|
||
|
||
Обоснование — в докстринге модуля: метка кита означает лишь ссылку на ЖК.
|
||
"""
|
||
raw = payload.get("raw_payload") or {}
|
||
if not isinstance(raw, dict):
|
||
return "vtorichka"
|
||
return "novostroyki" if raw.get("is_from_developer") is True else "vtorichka"
|
||
|
||
|
||
def build_lot(payload: dict) -> ScrapedLot:
|
||
"""`payload` → `ScrapedLot` с пересчитанным сегментом.
|
||
|
||
Ключи, которых в модели нет, отбрасываем явно (по `model_fields`), а не
|
||
полагаемся на настройку extra у pydantic-модели.
|
||
"""
|
||
known = {k: v for k, v in payload.items() if k in ScrapedLot.model_fields}
|
||
known["listing_segment"] = recompute_listing_segment(payload)
|
||
return ScrapedLot(**known)
|
||
|
||
|
||
def _iter_pages(db: Session, view: str, *, batch_size: int, limit: int | None):
|
||
"""Keyset-пагинация по `id` — весь корпус в память не тянем."""
|
||
after = 0
|
||
taken = 0
|
||
sql = text(_PAGE_SQL.format(view=view))
|
||
while True:
|
||
page_size = batch_size
|
||
if limit is not None:
|
||
page_size = min(batch_size, limit - taken)
|
||
if page_size <= 0:
|
||
return
|
||
rows = db.execute(sql, {"after": after, "limit": page_size}).mappings().all()
|
||
if not rows:
|
||
return
|
||
after = rows[-1]["id"]
|
||
taken += len(rows)
|
||
yield rows
|
||
|
||
|
||
def import_msk_raw(
|
||
db: Session,
|
||
*,
|
||
source: str = "cian",
|
||
region: int = MOSCOW_REGION_CODE,
|
||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||
limit: int | None = None,
|
||
dry_run: bool = False,
|
||
allow_unfiltered: bool = False,
|
||
geocode: bool = False,
|
||
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
|
||
) -> ImportCounters:
|
||
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
|
||
if region not in SUPPORTED_REGIONS:
|
||
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
|
||
|
||
view = SOURCE_VIEWS[source]
|
||
counters = ImportCounters()
|
||
matcher = RealMatcherAdapter()
|
||
budget = GeocodeBudget(remaining=max(0, geocode_limit))
|
||
|
||
# Источник, который сам говорит про целевой регион: у Циана это префикс
|
||
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
|
||
# первый. Авито не говорит ничего ни для какого региона, ему нужен
|
||
# пред-геокод, поэтому в реестре его нет вовсе.
|
||
city_filter = REGION_FILTERS.get((source, region))
|
||
if geocode and city_filter is not None:
|
||
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
|
||
# незачем.
|
||
raise SystemExit(f"source={source}: --geocode нужен только для avito")
|
||
if city_filter is None and not geocode:
|
||
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
|
||
# прочих: ни города в адресе, ни координат. Пишем только по явному
|
||
# разрешению.
|
||
if not (dry_run or allow_unfiltered):
|
||
raise SystemExit(
|
||
f"source={source}: адрес не содержит признака города, регион "
|
||
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
|
||
"--allow-unfiltered (аварийный) или --dry-run."
|
||
)
|
||
logger.warning(
|
||
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
|
||
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
|
||
source,
|
||
region,
|
||
)
|
||
if geocode and not dry_run:
|
||
db.execute(text(_GEO_CACHE_DDL))
|
||
db.commit()
|
||
|
||
for rows in _iter_pages(db, view, batch_size=batch_size, limit=limit):
|
||
points = _resolve_points(db, rows, budget, dry_run=dry_run) if geocode else {}
|
||
lots: list[ScrapedLot] = []
|
||
for row in rows:
|
||
counters.read += 1
|
||
payload = row["payload"] or {}
|
||
address = payload.get("address")
|
||
if is_artifact_address(address):
|
||
counters.skipped_artifact += 1
|
||
continue
|
||
if city_filter is not None and not city_filter(payload):
|
||
counters.skipped_not_target_region += 1
|
||
continue
|
||
if geocode:
|
||
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
|
||
if point is None:
|
||
counters.skipped_ungeocoded += 1
|
||
continue
|
||
if point.region_code != region:
|
||
counters.skipped_geo_other_region += 1
|
||
continue
|
||
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
|
||
# трогаем, пересбор корпуса от этого не зависит. geom появляется
|
||
# сразу — карточка идёт в radius-подбор аналогов, не дожидаясь
|
||
# ночного `geocode_missing`.
|
||
payload = {
|
||
**payload,
|
||
"lat": point.lat,
|
||
"lon": point.lon,
|
||
"geo_precision": "house",
|
||
}
|
||
try:
|
||
lots.append(build_lot(payload))
|
||
except ValidationError as exc:
|
||
counters.skipped_invalid += 1
|
||
logger.warning("msk_raw id=%s не собрался в ScrapedLot: %s", row["id"], exc)
|
||
|
||
counters.selected += len(lots)
|
||
if dry_run or not lots:
|
||
continue
|
||
|
||
# У региона 50 своего единого города нет (`canonical_city is None` в
|
||
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
|
||
# и существующее значение не затирает. Подбор аналогов не страдает:
|
||
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
|
||
# ключуется на `deals.city`, а не на `listings.city`.
|
||
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
|
||
inserted, updated = save_listings(
|
||
db,
|
||
lots,
|
||
matcher=matcher,
|
||
region_code=region,
|
||
city=city,
|
||
)
|
||
counters.inserted += inserted
|
||
counters.updated += updated
|
||
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
|
||
logger.info(
|
||
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||
source,
|
||
region,
|
||
counters.read,
|
||
counters.selected,
|
||
counters.written,
|
||
counters.inserted,
|
||
counters.updated,
|
||
)
|
||
|
||
counters.geocode_calls = budget.spent
|
||
counters.geocode_budget_exhausted = budget.exhausted
|
||
if budget.exhausted:
|
||
logger.warning(
|
||
"msk_raw %s: дневной бюджет геокода (%d) исчерпан — остаток корпуса "
|
||
"подхватит следующий прогон, разрешённые адреса уже в кэше",
|
||
source,
|
||
geocode_limit,
|
||
)
|
||
logger.info(
|
||
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
|
||
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||
"геокод-вызовов=%d | сходится=%s",
|
||
source,
|
||
region,
|
||
" (dry-run)" if dry_run else "",
|
||
counters.read,
|
||
counters.selected,
|
||
counters.written,
|
||
counters.inserted,
|
||
counters.updated,
|
||
counters.writer_skipped if not dry_run else 0,
|
||
counters.skipped_not_target_region,
|
||
counters.skipped_geo_other_region,
|
||
counters.skipped_ungeocoded,
|
||
counters.skipped_artifact,
|
||
counters.skipped_invalid,
|
||
counters.geocode_calls,
|
||
counters.check(),
|
||
)
|
||
return counters
|
||
|
||
|
||
def main() -> None:
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
)
|
||
parser = argparse.ArgumentParser(
|
||
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
|
||
)
|
||
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
|
||
parser.add_argument(
|
||
"--region",
|
||
type=int,
|
||
choices=SUPPORTED_REGIONS,
|
||
default=MOSCOW_REGION_CODE,
|
||
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
|
||
)
|
||
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
|
||
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
|
||
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
|
||
parser.add_argument(
|
||
"--allow-unfiltered",
|
||
action="store_true",
|
||
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
|
||
)
|
||
parser.add_argument(
|
||
"--geocode",
|
||
action="store_true",
|
||
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
|
||
"в listings уходит только целевой регион (--region), прочее пропускается",
|
||
)
|
||
parser.add_argument(
|
||
"--geocode-limit",
|
||
type=int,
|
||
default=DEFAULT_GEOCODE_LIMIT,
|
||
help=f"потолок внешних вызовов за прогон (по умолчанию {DEFAULT_GEOCODE_LIMIT}; "
|
||
"free tier DaData — 10 000/сутки на аккаунт, его же ест автокомплит формы)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
import_msk_raw(
|
||
db,
|
||
source=args.source,
|
||
region=args.region,
|
||
batch_size=args.batch_size,
|
||
limit=args.limit,
|
||
dry_run=args.dry_run,
|
||
allow_unfiltered=args.allow_unfiltered,
|
||
geocode=args.geocode,
|
||
geocode_limit=args.geocode_limit,
|
||
)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|