From 8863781589339df4b2aa65aa87d5fcef19b37db5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 12 Sep 2026 16:34:32 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BB=D0=BB=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=94=D0=BE=D0=BC=D0=9A=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=83=D0=BC=D0=B5=D0=B5=D1=82=20=D0=9C=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=D1=81=D0=BA=D1=83=D1=8E=20=D0=BE=D0=B1=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=8C,=20=D0=B0=20=D0=BD=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=9C=D0=BE=D1=81=D0=BA=D0=B2=D1=83?= =?UTF-8?q?=20(#3510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/scripts/local-avito-msk/README.md | 27 ++- .../scripts/local-avito-msk/collect.py | 156 +++++++++++++++--- 2 files changed, 157 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/scripts/local-avito-msk/README.md b/tradein-mvp/scripts/local-avito-msk/README.md index 72d581ce..b285b8d6 100644 --- a/tradein-mvp/scripts/local-avito-msk/README.md +++ b/tradein-mvp/scripts/local-avito-msk/README.md @@ -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--` — платформа в имени, -чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`, +от `--platform`/`--region`), `--batch-id` (дефолт `msk-serp--`, а при +`--platform domclick --region 50` — `mo-serp-domclick-`: платформа/регион в имени, +чтобы прогоны разных площадок и регионов не затирали друг другу план/CSV — регион в +таблице `msk_raw.domclick_cards` иначе не выражается, отдельной колонки под него нет), +`--out-dir`, `--ssh-host/--container/--db-user/--db-name`. `AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ — diff --git a/tradein-mvp/scripts/local-avito-msk/collect.py b/tradein-mvp/scripts/local-avito-msk/collect.py index 30cd801d..a5192186 100644 --- a/tradein-mvp/scripts/local-avito-msk/collect.py +++ b/tradein-mvp/scripts/local-avito-msk/collect.py @@ -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