Коллектор ДомКлика умеет Московскую область, а не только Москву #3510

Merged
lekss361 merged 1 commit from feat/domclick-collect-region-50 into main 2026-09-12 16:34:33 +00:00
2 changed files with 157 additions and 26 deletions

View file

@ -80,18 +80,37 @@ python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
# 3) продолжить прерванный прогон по сохранённому плану коридоров
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
# 4) DomClick по Московской области (--region — только для domclick)
python .\collect.py --platform domclick --region 50 --full `
--batch-id mo-serp-domclick-20260912-1200
```
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
Дефолт — `--platform avito`.
Дефолт — `--platform avito --region 77` (Москва).
Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
`--region {77,50}` переключает регион **только для `--platform domclick`**: у DomClick
Москва и область — разные GUID листинга (`MSK_DOMCLICK_GUID` / `MO_DOMCLICK_GUID` в
`collect.py`) и разные прогоны. У Авито/Циана/Яндекса единый скоуп «Москва и МО»
собирается одним прогоном (`moskva_i_mo` / `region=-1` / `rgid=741964`) и делится на
регионы уже при импорте в `listings` — отдельного прогона по области у них нет, и
`--region 50` с любой площадкой кроме domclick падает `SystemExit` при разборе аргументов.
Скрипт также сверяет GUID, зашитый в фактический `base_url` (параметр `address`), с
GUID, ожидаемым для выбранного `--region` — расхождение (например, ручной `--base-url`
с московским GUID при `--region 50`) останавливает прогон до первого запроса, а не
заливает чужой регион под областной `batch_id`.
Ключи: `--platform {avito,cian,yandex,domclick}` (дефолт avito), `--region {77,50}`
(дефолт 77, только domclick), `--delay` (пауза между загрузками,
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
`request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
от `--platform`/`--region`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>`, а при
`--platform domclick --region 50``mo-serp-domclick-<UTC>`: платформа/регион в имени,
чтобы прогоны разных площадок и регионов не затирали друг другу план/CSV — регион в
таблице `msk_raw.domclick_cards` иначе не выражается, отдельной колонки под него нет),
`--out-dir`,
`--ssh-host/--container/--db-user/--db-name`.
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —

View file

@ -6,9 +6,19 @@
(подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) см.
класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
билдер URL коридора и своя целевая таблица в msk_raw.
Платформа выбирается ключом --platform {avito,cian,yandex,domclick} (дефолт
avito) см. класс PlatformAdapter ниже. У каждой платформы свой потолок
пагинации, свой билдер URL коридора и своя целевая таблица в msk_raw.
--region {77,50} (дефолт 77 Москва) переключает регион сбора и работает
ТОЛЬКО с --platform domclick: у DomClick Москва и область разные GUID и
разные прогоны. У Авито/Циана/Яндекса корпус Москва+МО собирается одним
прогоном (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже
при импорте, поэтому --region 50 с любой другой площадкой падает SystemExit.
Пример прогона по Московской области (DomClick, полный проход):
python collect.py --platform domclick --region 50 --full \
--batch-id mo-serp-domclick-20260912-1200
Дефолтный режим --measure 100 (замер): полный проход только по явному --full.
"""
@ -153,7 +163,9 @@ YANDEX_MAX_PAGES = 25
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
# DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1).
# DomClick: JSON BFF listing API, Москва + отдельно Московская область
# (эпик #2989, трек 1). У DomClick, в отличие от Авито/Циана/Яндекса, Москва и
# область — РАЗНЫЕ прогоны с разными GUID (--region), а не один общий скоуп.
#
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
@ -162,28 +174,74 @@ YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
# листинга точно совпадает с сайтом.
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
DEFAULT_DOMCLICK_BASE_URL = (
"https://bff-search-web.domclick.ru/api/offers/v1"
f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
# GUID Московской области проверен живым запросом 12.09: тот же listing-эндпойнт
# с этим address отдаёт result.pagination.total = 17572, aids тоже не нужен.
MO_DOMCLICK_GUID = "9930cc20-32c6-4f6f-a55e-cd67086c5171"
def _domclick_base_url(guid: str) -> str:
"""Base_url DomClick BFF listing API под конкретный GUID региона.
Обе региональные константы (MSK/MO) обязаны строиться этой функцией, а не
двумя независимыми f-строками, иначе расхождение параметров (limit,
offset, sort) между Москвой и областью было бы легко внести незаметно.
"""
return (
"https://bff-search-web.domclick.ru/api/offers/v1"
f"?address={guid}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
DEFAULT_DOMCLICK_BASE_URL = _domclick_base_url(MSK_DOMCLICK_GUID)
MO_DOMCLICK_BASE_URL = _domclick_base_url(MO_DOMCLICK_GUID)
def _domclick_extract_guid(base_url: str) -> str | None:
"""GUID региона, зашитый в параметр address базового URL DomClick."""
return dict(parse_qsl(urlsplit(base_url).query)).get("address")
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
# страницу (20 items), offset=2000 отвечает HTTP 400
# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации —
# {"statusCode":400,"error":"Bad Request"}. Одинаково и для Москвы, и для
# области (проверено на обоих GUID) — значит жёсткий потолок пагинации общий:
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
DOMCLICK_PAGE_SIZE = 20
DOMCLICK_MAX_PAGES = 100
# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick.
# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с
# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча
# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит:
# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально
# всю московскую выдачу.
# bbox — единственный надёжный гео-гард для DomClick, отдельно для Москвы (с
# ТиНАО) и для Московской области. offerRegionName использовать НЕЛЬЗЯ: часть
# офферов Новой Москвы приходит с именами вида "г. Говорово", а не "Москва" —
# гард по имени региона молча вырезал бы легитимные лоты. scraper._is_geo_ok()
# кита сюда тоже не подходит: он захардкожен на offerRegionName ==
# "Екатеринбург" и отбросил бы буквально всю выдачу обоих регионов.
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
# bbox Московской области — литерал, РАВНЫЙ bbox_region региона 50 в реестре
# tradein-mvp/backend/app/services/regions.py (сослались на реестр, значение
# впечатано сюда буквально: этот скрипт лежит вне пакета backend и не может
# его импортировать). Московский bbox область не накрывает: Серпухов
# (lat~54.9), Коломна (lon~39.4), Дубна (lat~56.7) все лежат вне _MSK_*.
_MO_LAT_MIN, _MO_LAT_MAX = 54.20, 56.96
_MO_LON_MIN, _MO_LON_MAX = 35.14, 40.21
_MSK_BBOX: tuple[float, float, float, float] = (
_MSK_LAT_MIN, _MSK_LAT_MAX, _MSK_LON_MIN, _MSK_LON_MAX,
)
_MO_BBOX: tuple[float, float, float, float] = (
_MO_LAT_MIN, _MO_LAT_MAX, _MO_LON_MIN, _MO_LON_MAX,
)
# Активный гео-гард, который реально читает _domclick_parse_cards. Модульная
# переменная, а НЕ аргумент функции: сигнатура parse_cards зафиксирована типом
# PlatformAdapter.parse_cards (Callable[[Any, str], list[Any]]) и завязана на
# него все остальные платформы, менять её ради одного DomClick нельзя. collect()
# выставляет это значение один раз при старте прогона, по --region, до первого
# запроса к сети.
_DOMCLICK_BBOX: tuple[float, float, float, float] = _MSK_BBOX
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
PRICE_CEIL = 2_000_000_000
@ -712,8 +770,8 @@ def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
lon = float(loc["lon"])
except (KeyError, TypeError, ValueError):
continue
if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
lat_min, lat_max, lon_min, lon_max = _DOMCLICK_BBOX
if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max):
continue
lot = scraper._map_item(item)
if lot is not None:
@ -1411,6 +1469,31 @@ async def collect(args: argparse.Namespace) -> int:
)
base_url = plan.base_url if plan is not None else args.base_url
# Гвард региона DomClick — САМОЕ ВАЖНОЕ в этом прогоне. --base-url можно
# передать вручную с любым GUID, а base_url резюме приходит из сохранённого
# плана в обход --region вовсе; расхождение GUID и --region залило бы
# Москву под областным batch_id (или наоборот) и отравило бы корпус.
# Проверка стоит здесь намеренно: этой точки достигают все три источника
# base_url — дефолтный (выставлен в parse_args по --region), переданный
# вручную через --base-url и восстановленный из плана при --resume — уже
# ПОСЛЕ сверки plan.base_url != args.base_url чуть выше, так что при
# --resume расхождение платформы/URL с CLI отловится раньше и с более
# точным сообщением.
if args.platform == "domclick":
expected_guid = MO_DOMCLICK_GUID if args.region == 50 else MSK_DOMCLICK_GUID
actual_guid = _domclick_extract_guid(base_url)
if actual_guid != expected_guid:
raise SystemExit(
f"--region {args.region} ожидает GUID {expected_guid!r} в base_url"
f" (параметр address), а фактический base_url несёт GUID"
f" {actual_guid!r}. Поправь --region либо --base-url — иначе"
f" прогон зальёт чужой регион под batch_id={args.batch_id}."
)
global _DOMCLICK_BBOX
_DOMCLICK_BBOX = _MO_BBOX if args.region == 50 else _MSK_BBOX
print(f"DomClick: --region={args.region}, guid={expected_guid}, "
f"bbox={_DOMCLICK_BBOX}", flush=True)
page_budget = None if args.full else args.measure
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; "
@ -1523,8 +1606,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
)
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
help="площадка сбора (дефолт avito)")
p.add_argument("--region", type=int, choices=(77, 50), default=77,
help="регион сбора: 77 Москва (дефолт), 50 Московская область"
" (поддерживается только --platform domclick)")
p.add_argument("--base-url", default=None,
help="базовый URL выдачи (дефолт зависит от --platform)")
help="базовый URL выдачи (дефолт зависит от --platform/--region)")
p.add_argument("--measure", type=int, default=100, metavar="N",
help="режим замера: не больше N загрузок страниц (дефолт 100)")
p.add_argument("--full", action="store_true",
@ -1551,16 +1637,42 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p.add_argument("--db-user", default="tradein")
p.add_argument("--db-name", default="tradein")
args = p.parse_args(argv)
if args.region == 50 and args.platform != "domclick":
p.error(
"--region 50 поддерживается только для --platform domclick: корпус"
" Авито/Циана/Яндекса по Москве и области собирается ОДНИМ прогоном"
" (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже"
" при импорте — отдельного областного прогона у этих площадок нет."
)
if args.base_url is None:
args.base_url = ADAPTERS[args.platform].default_base_url
if args.platform == "domclick" and args.region == 50:
args.base_url = MO_DOMCLICK_BASE_URL
else:
args.base_url = ADAPTERS[args.platform].default_base_url
if args.target_count is None:
args.target_count = ADAPTERS[args.platform].default_target
if args.batch_id is None:
args.batch_id = (
f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
prefix = (
"mo-serp-domclick" if (args.platform == "domclick" and args.region == 50)
else f"msk-serp-{args.platform}"
)
args.batch_id = prefix + "-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if not _BATCH_ID_RE.match(args.batch_id):
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
# batch_id — ЕДИНСТВЕННАЯ метка региона в msk_raw.domclick_cards: колонки
# региона там нет, партии потом разделяются по префиксу имени батча. Имя,
# заданное руками вразрез с --region, сделало бы областную партию
# неотличимой от московской уже после заливки, когда чинить поздно. Поэтому
# префикс сверяется с регионом так же строго, как GUID в base_url.
if args.platform == "domclick":
want = "mo-serp-domclick" if args.region == 50 else "msk-serp-domclick"
other = "msk-serp-domclick" if args.region == 50 else "mo-serp-domclick"
if args.batch_id.startswith(other) or not args.batch_id.startswith(want):
p.error(
f"--batch-id должен начинаться с {want!r} при --region"
f" {args.region}: имя батча — единственный признак региона в"
f" msk_raw.domclick_cards, колонки региона там нет."
)
if args.measure < 1:
p.error("--measure должен быть >= 1")
return args