Коллектор ДомКлика умеет Московскую область, а не только Москву #3510
2 changed files with 157 additions and 26 deletions
|
|
@ -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` 7–10 с), `--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`) общий для обеих платформ —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue