feat(msk-collector): Яндекс как третья площадка сбора по Москве
rgid Москвы установлен эмпирически из разметки realty.yandex.ru и подтверждён счётчиком офферов gate-API: 587795, вторичка 18 705 против 4 060 у ЕКБ (559132). МО — 587654, Москва+МО — 741964, взят дефолтом по аналогии с region=-1 у Циана. Адаптер повторяет контракт PlatformAdapter, но не тащит DOM-парсер: Яндекс отдаёт SERP через gate-API, из кита берутся только чистые функции разбора gate-payload. `YandexRealtyScraper` не создаётся вовсе — он существует ради BrowserFetcher и пула прокси, а транспорт здесь прежний, вкладка Chrome владельца по CDP. Chrome отдаёт gate-JSON текстом внутри <pre>, поэтому экранирование разворачивается ДО json.loads, иначе описания приезжают битыми. Два изменения общего кода, не косметические: - `--target-count` стал платформо-зависимым (`PlatformAdapter.default_target`): 1500 у Авито и Циана без изменений, 500 у Яндекса. У Яндекса потолок пагинации — 25 страниц по 20 офферов, то есть 500 на набор фильтров, втрое ниже соседей; цель коридора выше потолка означала бы, что каждый коридор штатно недобирается. - `Sink.add` отсеивает `source_id` вне signed bigint: offerId Яндекса 19-значный, выход за диапазон уронил бы `\copy` всего батча, а не одну строку. Пробный прогон 100 загрузок при задержке 8 с: ни одного признака блока, 350 карточек в `msk_raw.yandex_cards`, координаты и адрес у 100%. В отличие от Авито, геокод Яндексу не нужен. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
This commit is contained in:
parent
996b814919
commit
c5186883a9
2 changed files with 307 additions and 18 deletions
|
|
@ -1,11 +1,12 @@
|
||||||
# Локальный сбор SERP Авито/Циан по Москве и МО (эпик #2989, трек 1)
|
# Локальный сбор SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1)
|
||||||
|
|
||||||
`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито
|
`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито,
|
||||||
или Циан (вторичка, Москва + МО) и заливает их в прод-схему `msk_raw`. Площадка —
|
Циан или Яндекс.Недвижимости (вторичка, Москва + МО) и заливает их в прод-схему
|
||||||
ключ `--platform {avito,cian}` (дефолт `avito`). Платформо-зависимые куски (URL
|
`msk_raw`. Площадка — ключ `--platform {avito,cian,yandex}` (дефолт `avito`).
|
||||||
коридора, счётчик, парс карточек, потолок пагинации, целевая таблица) вынесены в
|
Платформо-зависимые куски (URL коридора, счётчик, парс карточек, потолок пагинации,
|
||||||
`PlatformAdapter` / `ADAPTERS` в `collect.py` — общая часть (бисекция по цене, guard
|
целевая таблица) вынесены в `PlatformAdapter` / `ADAPTERS` в `collect.py` — общая
|
||||||
на блок, накопитель/заливка в psql) одна на обе платформы.
|
часть (бисекция по цене, guard на блок, накопитель/заливка в psql) одна на все три
|
||||||
|
площадки.
|
||||||
|
|
||||||
Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**.
|
Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**.
|
||||||
Браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из
|
Браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из
|
||||||
|
|
@ -45,8 +46,8 @@
|
||||||
Больше ничего ставить не нужно: только stdlib + playwright + импорт `scraper_kit`
|
Больше ничего ставить не нужно: только stdlib + playwright + импорт `scraper_kit`
|
||||||
(путь `packages/scraper-kit/src` скрипт добавляет в `sys.path` сам, от `__file__`).
|
(путь `packages/scraper-kit/src` скрипт добавляет в `sys.path` сам, от `__file__`).
|
||||||
|
|
||||||
4. **Миграция под Циан применена на проде** — до первого не-`--dry-run` прогона
|
4. **Миграция под Циан/Яндекс применена на проде** — до первого не-`--dry-run`
|
||||||
`--platform cian` выполнить
|
прогона `--platform cian` / `--platform yandex` выполнить
|
||||||
`tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql`
|
`tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql`
|
||||||
(таблицы `msk_raw.cian_cards` в проде пока нет). Путь указан именно так:
|
(таблицы `msk_raw.cian_cards` в проде пока нет). Путь указан именно так:
|
||||||
каталога `backend/data/sql` в корне репозитория не существует, файл лежит внутри
|
каталога `backend/data/sql` в корне репозитория не существует, файл лежит внутри
|
||||||
|
|
@ -64,13 +65,18 @@ python .\collect.py --dry-run --measure 5
|
||||||
# 0б) то же для Циан
|
# 0б) то же для Циан
|
||||||
python .\collect.py --platform cian --dry-run --measure 5
|
python .\collect.py --platform cian --dry-run --measure 5
|
||||||
|
|
||||||
|
# 0в) то же для Яндекса
|
||||||
|
python .\collect.py --platform yandex --dry-run --measure 5
|
||||||
|
|
||||||
# 1) обязательный первый прогон — замер (дефолт, 100 загрузок страниц)
|
# 1) обязательный первый прогон — замер (дефолт, 100 загрузок страниц)
|
||||||
python .\collect.py
|
python .\collect.py
|
||||||
python .\collect.py --platform cian
|
python .\collect.py --platform cian
|
||||||
|
python .\collect.py --platform yandex
|
||||||
|
|
||||||
# 2) полный проход — только явно
|
# 2) полный проход — только явно
|
||||||
python .\collect.py --full --batch-id msk-serp-avito-20260908
|
python .\collect.py --full --batch-id msk-serp-avito-20260908
|
||||||
python .\collect.py --platform cian --full --batch-id msk-serp-cian-20260908
|
python .\collect.py --platform cian --full --batch-id msk-serp-cian-20260908
|
||||||
|
python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
|
||||||
|
|
||||||
# 3) продолжить прерванный прогон по сохранённому плану коридоров
|
# 3) продолжить прерванный прогон по сохранённому плану коридоров
|
||||||
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
||||||
|
|
@ -79,10 +85,11 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
||||||
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
|
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
|
||||||
Дефолт — `--platform avito`.
|
Дефолт — `--platform avito`.
|
||||||
|
|
||||||
Ключи: `--platform {avito,cian}` (дефолт avito), `--delay` (пауза между загрузками,
|
Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
|
||||||
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
|
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
|
||||||
`request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
|
`request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
|
||||||
`--target-count` (целевой размер коридора, дефолт 1500), `--base-url` (дефолт зависит
|
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
|
||||||
|
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
|
||||||
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
|
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
|
||||||
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
|
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
|
||||||
`--ssh-host/--container/--db-user/--db-name`.
|
`--ssh-host/--container/--db-user/--db-name`.
|
||||||
|
|
@ -99,6 +106,7 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Авито | 50 (`AVITO_PAGE_SIZE`) | 30 (`AVITO_MAX_PAGES`) | **1500** |
|
| Авито | 50 (`AVITO_PAGE_SIZE`) | 30 (`AVITO_MAX_PAGES`) | **1500** |
|
||||||
| Циан | 28 (`_CIAN_OFFERS_PER_PAGE`) | 54 (см. `CianScraper._paginate_leaf_bucket`, "hard cap ~54") | **1512** |
|
| Циан | 28 (`_CIAN_OFFERS_PER_PAGE`) | 54 (см. `CianScraper._paginate_leaf_bucket`, "hard cap ~54") | **1512** |
|
||||||
|
| Яндекс | 20 (`pager.pageSize`) | 25 (`pager.totalPages`, потолок; `page=26` → HTTP 301) | **500** |
|
||||||
|
|
||||||
Любой коридор, где `count` больше потолка платформы, целиком не добирается, поэтому
|
Любой коридор, где `count` больше потолка платформы, целиком не добирается, поэтому
|
||||||
строится план ценовых коридоров:
|
строится план ценовых коридоров:
|
||||||
|
|
@ -120,6 +128,83 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
||||||
План лежит в `runs/plan-<batch_id>.json` (включает `platform`, чтобы `--resume` не
|
План лежит в `runs/plan-<batch_id>.json` (включает `platform`, чтобы `--resume` не
|
||||||
перепутал план Авито с планом Циан) и обновляется после каждой страницы.
|
перепутал план Авито с планом Циан) и обновляется после каждой страницы.
|
||||||
|
|
||||||
|
## Яндекс: gate-API вместо разметки
|
||||||
|
|
||||||
|
Яндекс.Недвижимость — единственная из трёх площадок, у которой **нет разбора DOM**:
|
||||||
|
выдача берётся из gate-API и приходит готовым JSON. DOM-парсера у неё нет и в
|
||||||
|
`scraper_kit`, поэтому адаптер `yandex` в `collect.py` тащит из кита ровно чистые
|
||||||
|
функции разбора gate-payload (`_parse_gate_json`, `_extract_gate_data`,
|
||||||
|
`_extract_json_from_content`), а класс `YandexRealtyScraper` не создаёт вовсе: тот
|
||||||
|
существует ради camoufox-транспорта и пула прокси, а здесь транспорт — вкладка
|
||||||
|
Chrome владельца, как и у остальных.
|
||||||
|
|
||||||
|
Запрос:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://realty.yandex.ru/gate/react-page/get/
|
||||||
|
?rgid=741964&type=SELL&category=APARTMENT&newFlat=NO
|
||||||
|
&_pageType=search&_providers=react-search-results-data
|
||||||
|
&priceMin=<lo>&priceMax=<hi>&page=<1..25>
|
||||||
|
```
|
||||||
|
|
||||||
|
`page` **1-based и проставляется всегда**, в том числе на первой странице:
|
||||||
|
`page=0` gate считает ошибкой (у Авито/Циан, наоборот, `p` на первой странице
|
||||||
|
опускается).
|
||||||
|
|
||||||
|
### rgid
|
||||||
|
|
||||||
|
Снят эмпирически 11.09 из SSR-разметки `realty.yandex.ru` (`"geo":{...,"rgid":...}`
|
||||||
|
и `page.params`), не из памяти. Счётчики — `pager.totalItems` живого gate-запроса
|
||||||
|
по вторичке:
|
||||||
|
|
||||||
|
| Скоуп | rgid | Офферов вторички |
|
||||||
|
|---|---|---|
|
||||||
|
| Москва | 587795 | 18 705 |
|
||||||
|
| Московская область | 587654 | 14 242 |
|
||||||
|
| **Москва и МО (дефолт)** | **741964** | **31 074** |
|
||||||
|
| Екатеринбург (`_EKB_RGID`, контроль) | 559132 | 4 060 |
|
||||||
|
|
||||||
|
Дефолт — 741964: единый скоуп «Москва и МО», прямой аналог `region=-1` у Циан и
|
||||||
|
`moskva_i_mo` у Авито. Другой скоуп задаётся через `--base-url` с нужным `rgid`.
|
||||||
|
|
||||||
|
### Границы коридора включающие с обеих сторон
|
||||||
|
|
||||||
|
Замер 11.09 (Москва+МО): `priceMax=5000000` отдаёт выдачу с максимальной ценой
|
||||||
|
ровно 5 000 000 (`totalItems=2998`), `priceMin=5000000&priceMax=5000000` — 1279
|
||||||
|
(округлая цена популярна у нижней границы рынка), `priceMin=5000000&priceMax=5100000`
|
||||||
|
— 1377 = 1279 + 98. То есть **обе границы включающие**. Соседние коридоры бисекции
|
||||||
|
`(lo, mid)` и `(mid, upper)` поэтому пересекаются ровно по цене `mid`: дубли гасит
|
||||||
|
`ON CONFLICT (source_id,batch_id,kind) DO NOTHING`, потерь между коридорами нет —
|
||||||
|
ровно как у Авито и Циан.
|
||||||
|
|
||||||
|
### Потолок пагинации — 500, а не 1500
|
||||||
|
|
||||||
|
`pager` отдаёт `pageSize=20` и `totalPages=25` даже при `totalItems=31 074`;
|
||||||
|
`page=26` отвечает HTTP 301. То есть на один набор фильтров Яндекс отдаёт максимум
|
||||||
|
**500 офферов** — втрое меньше Авито и Циан. Поэтому у адаптера свой
|
||||||
|
`default_target = 500`: цель коридора не может быть выше потолка площадки, иначе
|
||||||
|
бисекция штатно оставляла бы недобранные коридоры. Полный проход Москва+МО — это
|
||||||
|
≥ 62 коридора по построению, и в среднем их больше, так что план у Яндекса заметно
|
||||||
|
дробнее цианского.
|
||||||
|
|
||||||
|
### Блок
|
||||||
|
|
||||||
|
Капча Яндекса приходит HTML-страницей на месте JSON, HTTP-статус при этом обычный.
|
||||||
|
`_yandex_detect_block` смотрит первые 4 КБ на `smartcaptcha`/`showcaptcha`/`captcha`,
|
||||||
|
затем — извлекается ли из документа JSON вообще. Вторая сеть общая с остальными
|
||||||
|
площадками: `parse_page` останавливает прогон, если нет ни счётчика, ни карточек.
|
||||||
|
Ждать готовности нечего — это текстовый документ, `_yandex_wait_ready` ждёт `<pre>`
|
||||||
|
три секунды и не тратит полный таймаут на селекторы, которых тут не бывает.
|
||||||
|
|
||||||
|
Chrome экранирует в тексте `<pre>` символы `&`, `<`, `>`; `_unescape_pre_text`
|
||||||
|
разворачивает их обратно **до** `json.loads`, иначе описания приезжают с `&`.
|
||||||
|
|
||||||
|
### Координаты
|
||||||
|
|
||||||
|
`location.point.latitude/longitude` заполнены **у 100 %** карточек (замер на живой
|
||||||
|
выдаче), плюс `geocoderAddress` с номером дома. Геокодировать Яндекс, в отличие от
|
||||||
|
Авито (`lat`/`lon` пусты у всех 50 335 карточек), не требуется.
|
||||||
|
|
||||||
## Стоп на первом признаке блока
|
## Стоп на первом признаке блока
|
||||||
|
|
||||||
Проверки в фиксированном порядке, первое срабатывание = немедленный стоп
|
Проверки в фиксированном порядке, первое срабатывание = немедленный стоп
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Локальный ручной сборщик SERP Авито/Циан по Москве и МО (эпик #2989, трек 1).
|
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1).
|
||||||
|
|
||||||
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
||||||
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
||||||
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
||||||
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
||||||
|
|
||||||
Платформа выбирается ключом --platform {avito,cian} (дефолт avito) — см. класс
|
Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) — см.
|
||||||
PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой билдер
|
класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
|
||||||
URL коридора и своя целевая таблица в msk_raw.
|
билдер URL коридора и своя целевая таблица в msk_raw.
|
||||||
|
|
||||||
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
||||||
"""
|
"""
|
||||||
|
|
@ -48,6 +48,16 @@ from scraper_kit.providers.cian.serp import ( # noqa: E402
|
||||||
CianScraper,
|
CianScraper,
|
||||||
_CIAN_OFFERS_PER_PAGE,
|
_CIAN_OFFERS_PER_PAGE,
|
||||||
)
|
)
|
||||||
|
# Яндекс ходит в gate-API и отдаёт JSON, а не SERP-разметку: DOM-парсера у него нет
|
||||||
|
# и тащить его сюда нечего. Из кита берём ровно чистые функции разбора gate-payload —
|
||||||
|
# класс YandexRealtyScraper не нужен (он существует ради BrowserFetcher/camoufox и
|
||||||
|
# пула прокси, а здесь транспорт — вкладка Chrome владельца).
|
||||||
|
from scraper_kit.providers.yandex.serp import ( # noqa: E402
|
||||||
|
_extract_gate_data,
|
||||||
|
_extract_json_from_content,
|
||||||
|
_is_gate_error,
|
||||||
|
_parse_gate_json,
|
||||||
|
)
|
||||||
|
|
||||||
# Вкладка, открытая у владельца: вторичка, Москва + МО.
|
# Вкладка, открытая у владельца: вторичка, Москва + МО.
|
||||||
DEFAULT_AVITO_BASE_URL = (
|
DEFAULT_AVITO_BASE_URL = (
|
||||||
|
|
@ -75,6 +85,33 @@ DEFAULT_CIAN_BASE_URL = (
|
||||||
"®ion=-1&object_type%5B0%5D=1&sort=creation_date_desc"
|
"®ion=-1&object_type%5B0%5D=1&sort=creation_date_desc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Яндекс.Недвижимость: gate-API (JSON), а не HTML-выдача.
|
||||||
|
#
|
||||||
|
# rgid установлен ЭМПИРИЧЕСКИ 11.09, а не взят из памяти: в SSR-разметке
|
||||||
|
# realty.yandex.ru/moskva/kupit/kvartira/vtorichniy-rynok/ лежит
|
||||||
|
# "geo":{"id":213,"type":"CITY","rgid":587795,...,"name":"Москва",
|
||||||
|
# "parents":[{"id":1,"name":"Москва и МО","rgid":"741964",...}]}
|
||||||
|
# и там же page.params = {"rgid":587795,"type":"SELL","category":"APARTMENT",
|
||||||
|
# "newFlat":"NO"} — ровно параметры gate-запроса. Аналогично со страницы МО снят
|
||||||
|
# rgid 587654 ("Московская область", SUBJECT_FEDERATION).
|
||||||
|
#
|
||||||
|
# Счётчики вторички gate-API (pager.totalItems, живой запрос 11.09):
|
||||||
|
# rgid=587795 (Москва) 18 705
|
||||||
|
# rgid=587654 (МО) 14 242
|
||||||
|
# rgid=741964 (Москва и МО) 31 074
|
||||||
|
# rgid=559132 (ЕКБ, _EKB_RGID) 4 060 ← контроль: московский скоуп в 7.6 раза больше
|
||||||
|
# Дефолт — 741964: единый скоуп «Москва и МО», как region=-1 у Циан и
|
||||||
|
# moskva_i_mo у Авито.
|
||||||
|
_YANDEX_MSK_RGID = 587795
|
||||||
|
_YANDEX_MO_RGID = 587654
|
||||||
|
_YANDEX_MSK_MO_RGID = 741964
|
||||||
|
|
||||||
|
DEFAULT_YANDEX_BASE_URL = (
|
||||||
|
"https://realty.yandex.ru/gate/react-page/get/"
|
||||||
|
f"?rgid={_YANDEX_MSK_MO_RGID}&type=SELL&category=APARTMENT&newFlat=NO"
|
||||||
|
"&_pageType=search&_providers=react-search-results-data"
|
||||||
|
)
|
||||||
|
|
||||||
# Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек
|
# Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек
|
||||||
# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
|
# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
|
||||||
# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
|
# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
|
||||||
|
|
@ -89,6 +126,18 @@ AVITO_MAX_PAGES = 30 # потолок пагинации Авито
|
||||||
CIAN_PAGE_SIZE = _CIAN_OFFERS_PER_PAGE
|
CIAN_PAGE_SIZE = _CIAN_OFFERS_PER_PAGE
|
||||||
CIAN_MAX_PAGES = 54
|
CIAN_MAX_PAGES = 54
|
||||||
|
|
||||||
|
# Замерено живым gate-запросом 11.09, не взято из кита: pager отдаёт
|
||||||
|
# pageSize=20 и totalPages=25 ДАЖЕ когда totalItems=31 074 (то есть 1554
|
||||||
|
# страницы по построению). page=26 отвечает HTTP 301 — редирект, не выдача.
|
||||||
|
# Значит жёсткий потолок Яндекса = 25*20 = 500 офферов на один набор фильтров,
|
||||||
|
# втрое ниже авитовского и цианского. Константа _GATE_MAX_PAGES_CAP=50 в
|
||||||
|
# scraper_kit — исторический запас, живьём недостижима.
|
||||||
|
YANDEX_PAGE_SIZE = 20
|
||||||
|
YANDEX_MAX_PAGES = 25
|
||||||
|
# Целевой размер коридора не может превышать hard_cap площадки, иначе бисекция
|
||||||
|
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
|
||||||
|
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
|
||||||
|
|
||||||
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
||||||
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
||||||
PRICE_CEIL = 2_000_000_000
|
PRICE_CEIL = 2_000_000_000
|
||||||
|
|
@ -334,6 +383,129 @@ def _cian_parse_cards(scraper: CianScraper, html: str) -> list[Any]:
|
||||||
return lots
|
return lots
|
||||||
|
|
||||||
|
|
||||||
|
# --- Яндекс: gate-API (JSON), общего с DOM-платформами только конвейер ---------
|
||||||
|
|
||||||
|
# Chrome отдаёт JSON-документ как текстовый узел внутри <pre>, а сериализатор
|
||||||
|
# экранирует в тексте ровно три символа. Разэкранируем их обратно ДО json.loads:
|
||||||
|
# иначе описания приезжают с &/< вместо & и <. Порядок обязателен —
|
||||||
|
# "&" последним, иначе "&lt;" из описания превратился бы в "<".
|
||||||
|
def _unescape_pre_text(text: str) -> str:
|
||||||
|
return text.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_payload(scraper: Any, html: str) -> dict[str, Any] | None:
|
||||||
|
"""gate-payload из содержимого вкладки, с мемоизацией на один HTML.
|
||||||
|
|
||||||
|
parse_page дёргает сначала extract_total_count, потом parse_cards — оба по
|
||||||
|
одной и той же строке. JSON тут на сотни КБ, поэтому разбираем один раз и
|
||||||
|
кладём на scraper вместе с самой строкой. Сравнение — `is`, и ссылка на
|
||||||
|
строку хранится: по id() было бы неверно, id освобождённой строки может
|
||||||
|
достаться следующей.
|
||||||
|
"""
|
||||||
|
if getattr(scraper, "_payload_html", None) is html:
|
||||||
|
return scraper._payload
|
||||||
|
json_text = _extract_json_from_content(html)
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
if json_text:
|
||||||
|
try:
|
||||||
|
payload = json.loads(_unescape_pre_text(json_text))
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
payload = None
|
||||||
|
if payload is not None and _is_gate_error(payload):
|
||||||
|
payload = None
|
||||||
|
scraper._payload_html = html
|
||||||
|
scraper._payload = payload
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
|
||||||
|
"""URL коридора Яндекса: priceMin/priceMax + page (1-based).
|
||||||
|
|
||||||
|
page=0 gate-API считает ошибкой, поэтому page проставляется ВСЕГДА, в том
|
||||||
|
числе на первой странице, — в отличие от Авито/Циан, где `p` на первой
|
||||||
|
странице опускается.
|
||||||
|
"""
|
||||||
|
parts = urlsplit(base_url)
|
||||||
|
q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
||||||
|
if k not in {"page", "priceMin", "priceMax"}]
|
||||||
|
if lo is not None:
|
||||||
|
q.append(("priceMin", str(int(lo))))
|
||||||
|
if hi is not None:
|
||||||
|
q.append(("priceMax", str(int(hi))))
|
||||||
|
q.append(("page", str(max(1, page))))
|
||||||
|
return urlunsplit(
|
||||||
|
(parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_extract_total(scraper: Any, html: str) -> int | None:
|
||||||
|
payload = _yandex_payload(scraper, html)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
extracted = _extract_gate_data(payload)
|
||||||
|
if extracted is None:
|
||||||
|
return None
|
||||||
|
_entities, pager = extracted
|
||||||
|
total = pager.get("totalItems")
|
||||||
|
return int(total) if total is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_parse_cards(scraper: Any, html: str) -> list[Any]:
|
||||||
|
"""Разбор gate-payload в ScrapedLot теми же функциями, что и прод-скрейпер.
|
||||||
|
|
||||||
|
page_param берём из самого ответа (response.pageParams.params.page), а не из
|
||||||
|
аргумента: контракт адаптера номера страницы не передаёт, а в raw_payload
|
||||||
|
должна лечь та страница, которую реально отдал gate.
|
||||||
|
"""
|
||||||
|
payload = _yandex_payload(scraper, html)
|
||||||
|
if payload is None:
|
||||||
|
scraper.last_raw_count = 0
|
||||||
|
return []
|
||||||
|
extracted = _extract_gate_data(payload)
|
||||||
|
if extracted is None:
|
||||||
|
scraper.last_raw_count = 0
|
||||||
|
return []
|
||||||
|
entities, _pager = extracted
|
||||||
|
# Сырое число — до отбраковки лотов без offerId/цены в _entity_to_lot.
|
||||||
|
scraper.last_raw_count = len(entities)
|
||||||
|
try:
|
||||||
|
page_param = int(payload["response"]["pageParams"]["params"]["page"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
page_param = 1
|
||||||
|
return _parse_gate_json(payload, page_param=page_param, new_flat="NO")
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_detect_block(html: str) -> tuple[str, str] | None:
|
||||||
|
"""Блок Яндекса: SmartCaptcha приходит HTML-страницей на месте JSON.
|
||||||
|
|
||||||
|
Порядок важен: сперва ищем маркеры капчи (иначе «нет JSON» рапортовалось бы
|
||||||
|
как schema-drift), потом проверяем, что документ вообще похож на gate-ответ.
|
||||||
|
Пустой gate-payload без response — тоже стоп: это заглушка, а не выдача.
|
||||||
|
"""
|
||||||
|
head = html[:4096].lower()
|
||||||
|
if "smartcaptcha" in head or "showcaptcha" in head or "captcha" in head:
|
||||||
|
return "challenge", "SmartCaptcha Яндекса вместо gate-ответа"
|
||||||
|
json_text = _extract_json_from_content(html)
|
||||||
|
if not json_text:
|
||||||
|
return "challenge", "во вкладке нет JSON (gate отдал не тот документ)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _yandex_wait_ready(page: Any) -> None:
|
||||||
|
"""gate-API — документ JSON, клиентской дорисовки нет.
|
||||||
|
|
||||||
|
Ждать `[data-marker=item]` (Авито) или window._cianConfig (Циан) здесь
|
||||||
|
бессмысленно — их не будет никогда, каждая загрузка стоила бы полного
|
||||||
|
таймаута. Chrome заворачивает текстовый документ в <pre>, его и ждём, коротко:
|
||||||
|
к моменту domcontentloaded он, как правило, уже на месте, а на капче его нет
|
||||||
|
вовсе — и тогда через 3 с отработает _yandex_detect_block.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector("pre", timeout=3_000)
|
||||||
|
except Exception: # noqa: BLE001 — капча/заглушка разбирается detect_block ниже
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PlatformAdapter:
|
class PlatformAdapter:
|
||||||
"""Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
|
"""Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
|
||||||
|
|
@ -349,12 +521,21 @@ class PlatformAdapter:
|
||||||
parse_cards: Callable[[Any, str], list[Any]]
|
parse_cards: Callable[[Any, str], list[Any]]
|
||||||
detect_block: Callable[[str], tuple[str, str] | None]
|
detect_block: Callable[[str], tuple[str, str] | None]
|
||||||
wait_ready: Callable[[Any], Any] # корутина: дождаться готовности страницы
|
wait_ready: Callable[[Any], Any] # корутина: дождаться готовности страницы
|
||||||
|
# Дефолт --target-count. Он обязан быть <= hard_cap площадки, иначе бисекция
|
||||||
|
# штатно оставляет коридоры, которые заведомо не вычитываются до конца.
|
||||||
|
default_target: int = 1500
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hard_cap(self) -> int:
|
def hard_cap(self) -> int:
|
||||||
return self.page_size * self.max_pages
|
return self.page_size * self.max_pages
|
||||||
|
|
||||||
def make_scraper(self) -> Any:
|
def make_scraper(self) -> Any:
|
||||||
|
if self.name == "yandex":
|
||||||
|
# У Яндекса разбор — чистые функции над gate-JSON; YandexRealtyScraper
|
||||||
|
# нужен только ради camoufox-транспорта и пула прокси, которых здесь
|
||||||
|
# нет (транспорт — вкладка Chrome владельца). Носитель состояния для
|
||||||
|
# мемоизации payload и last_raw_count — пустой namespace.
|
||||||
|
return SimpleNamespace()
|
||||||
if self.name == "avito":
|
if self.name == "avito":
|
||||||
# avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё,
|
# avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё,
|
||||||
# где в URL нет /ekaterinburg/ — то есть все подмосковные слаги
|
# где в URL нет /ekaterinburg/ — то есть все подмосковные слаги
|
||||||
|
|
@ -393,6 +574,19 @@ ADAPTERS: dict[str, PlatformAdapter] = {
|
||||||
detect_block=_cian_detect_block,
|
detect_block=_cian_detect_block,
|
||||||
wait_ready=_cian_wait_ready,
|
wait_ready=_cian_wait_ready,
|
||||||
),
|
),
|
||||||
|
"yandex": PlatformAdapter(
|
||||||
|
name="yandex",
|
||||||
|
table="yandex_cards",
|
||||||
|
default_base_url=DEFAULT_YANDEX_BASE_URL,
|
||||||
|
page_size=YANDEX_PAGE_SIZE,
|
||||||
|
max_pages=YANDEX_MAX_PAGES,
|
||||||
|
build_url=_yandex_build_url,
|
||||||
|
extract_total_count=_yandex_extract_total,
|
||||||
|
parse_cards=_yandex_parse_cards,
|
||||||
|
detect_block=_yandex_detect_block,
|
||||||
|
wait_ready=_yandex_wait_ready,
|
||||||
|
default_target=YANDEX_TARGET_COUNT,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -728,6 +922,12 @@ class Sink:
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self.skipped_non_numeric += 1
|
self.skipped_non_numeric += 1
|
||||||
return
|
return
|
||||||
|
# offerId Яндекса — 19-значный (напр. 3937530341842304552), это уже
|
||||||
|
# впритык к signed bigint. Число сверх диапазона уронило бы \copy всего
|
||||||
|
# батча целиком, а не одну строку, поэтому отсекаем здесь же.
|
||||||
|
if not (-(2**63) <= source_id < 2**63):
|
||||||
|
self.skipped_non_numeric += 1
|
||||||
|
return
|
||||||
payload = lot.model_dump(mode="json")
|
payload = lot.model_dump(mode="json")
|
||||||
self.buffer.append({
|
self.buffer.append({
|
||||||
"source_id": source_id,
|
"source_id": source_id,
|
||||||
|
|
@ -1015,7 +1215,7 @@ async def collect(args: argparse.Namespace) -> int:
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
prog="collect.py",
|
prog="collect.py",
|
||||||
description="Ручной сбор SERP Авито/Циан (вторичка, Москва+МО) в прод-схему msk_raw.",
|
description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.",
|
||||||
)
|
)
|
||||||
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
||||||
help="площадка сбора (дефолт avito)")
|
help="площадка сбора (дефолт avito)")
|
||||||
|
|
@ -1033,8 +1233,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
|
help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
|
||||||
p.add_argument("--batch-size", type=int, default=1000,
|
p.add_argument("--batch-size", type=int, default=1000,
|
||||||
help="карточек в одной заливке (дефолт 1000)")
|
help="карточек в одной заливке (дефолт 1000)")
|
||||||
p.add_argument("--target-count", type=int, default=1500,
|
p.add_argument("--target-count", type=int, default=None,
|
||||||
help="целевой размер коридора; больше — делим (дефолт 1500)")
|
help="целевой размер коридора; больше — делим "
|
||||||
|
"(дефолт зависит от --platform: 1500 у avito/cian, "
|
||||||
|
"500 у yandex — там потолок пагинации 25*20)")
|
||||||
p.add_argument("--batch-id", default=None,
|
p.add_argument("--batch-id", default=None,
|
||||||
help="batch_id в msk_raw.batches (дефолт msk-serp-<platform>-<UTC>)")
|
help="batch_id в msk_raw.batches (дефолт msk-serp-<platform>-<UTC>)")
|
||||||
p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"),
|
p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"),
|
||||||
|
|
@ -1047,6 +1249,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
if args.base_url is None:
|
if args.base_url is None:
|
||||||
args.base_url = ADAPTERS[args.platform].default_base_url
|
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:
|
if args.batch_id is None:
|
||||||
args.batch_id = (
|
args.batch_id = (
|
||||||
f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue