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` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито
|
||||
или Циан (вторичка, Москва + МО) и заливает их в прод-схему `msk_raw`. Площадка —
|
||||
ключ `--platform {avito,cian}` (дефолт `avito`). Платформо-зависимые куски (URL
|
||||
коридора, счётчик, парс карточек, потолок пагинации, целевая таблица) вынесены в
|
||||
`PlatformAdapter` / `ADAPTERS` в `collect.py` — общая часть (бисекция по цене, guard
|
||||
на блок, накопитель/заливка в psql) одна на обе платформы.
|
||||
`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито,
|
||||
Циан или Яндекс.Недвижимости (вторичка, Москва + МО) и заливает их в прод-схему
|
||||
`msk_raw`. Площадка — ключ `--platform {avito,cian,yandex}` (дефолт `avito`).
|
||||
Платформо-зависимые куски (URL коридора, счётчик, парс карточек, потолок пагинации,
|
||||
целевая таблица) вынесены в `PlatformAdapter` / `ADAPTERS` в `collect.py` — общая
|
||||
часть (бисекция по цене, guard на блок, накопитель/заливка в psql) одна на все три
|
||||
площадки.
|
||||
|
||||
Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**.
|
||||
Браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из
|
||||
|
|
@ -45,8 +46,8 @@
|
|||
Больше ничего ставить не нужно: только stdlib + playwright + импорт `scraper_kit`
|
||||
(путь `packages/scraper-kit/src` скрипт добавляет в `sys.path` сам, от `__file__`).
|
||||
|
||||
4. **Миграция под Циан применена на проде** — до первого не-`--dry-run` прогона
|
||||
`--platform cian` выполнить
|
||||
4. **Миграция под Циан/Яндекс применена на проде** — до первого не-`--dry-run`
|
||||
прогона `--platform cian` / `--platform yandex` выполнить
|
||||
`tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql`
|
||||
(таблицы `msk_raw.cian_cards` в проде пока нет). Путь указан именно так:
|
||||
каталога `backend/data/sql` в корне репозитория не существует, файл лежит внутри
|
||||
|
|
@ -64,13 +65,18 @@ python .\collect.py --dry-run --measure 5
|
|||
# 0б) то же для Циан
|
||||
python .\collect.py --platform cian --dry-run --measure 5
|
||||
|
||||
# 0в) то же для Яндекса
|
||||
python .\collect.py --platform yandex --dry-run --measure 5
|
||||
|
||||
# 1) обязательный первый прогон — замер (дефолт, 100 загрузок страниц)
|
||||
python .\collect.py
|
||||
python .\collect.py --platform cian
|
||||
python .\collect.py --platform yandex
|
||||
|
||||
# 2) полный проход — только явно
|
||||
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 yandex --full --batch-id msk-serp-yandex-20260911
|
||||
|
||||
# 3) продолжить прерванный прогон по сохранённому плану коридоров
|
||||
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` и полный проход **не начинает**.
|
||||
Дефолт — `--platform avito`.
|
||||
|
||||
Ключи: `--platform {avito,cian}` (дефолт avito), `--delay` (пауза между загрузками,
|
||||
Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
|
||||
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
|
||||
`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>` — платформа в имени,
|
||||
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
|
||||
`--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** |
|
||||
| Циан | 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` больше потолка платформы, целиком не добирается, поэтому
|
||||
строится план ценовых коридоров:
|
||||
|
|
@ -120,6 +128,83 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
|||
План лежит в `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
|
||||
"""Локальный ручной сборщик SERP Авито/Циан по Москве и МО (эпик #2989, трек 1).
|
||||
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1).
|
||||
|
||||
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
||||
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
||||
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
||||
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
||||
|
||||
Платформа выбирается ключом --platform {avito,cian} (дефолт avito) — см. класс
|
||||
PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой билдер
|
||||
URL коридора и своя целевая таблица в msk_raw.
|
||||
Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) — см.
|
||||
класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
|
||||
билдер URL коридора и своя целевая таблица в msk_raw.
|
||||
|
||||
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
||||
"""
|
||||
|
|
@ -48,6 +48,16 @@ from scraper_kit.providers.cian.serp import ( # noqa: E402
|
|||
CianScraper,
|
||||
_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 = (
|
||||
|
|
@ -75,6 +85,33 @@ DEFAULT_CIAN_BASE_URL = (
|
|||
"®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 карточек
|
||||
# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
|
||||
# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
|
||||
|
|
@ -89,6 +126,18 @@ AVITO_MAX_PAGES = 30 # потолок пагинации Авито
|
|||
CIAN_PAGE_SIZE = _CIAN_OFFERS_PER_PAGE
|
||||
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_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
||||
PRICE_CEIL = 2_000_000_000
|
||||
|
|
@ -334,6 +383,129 @@ def _cian_parse_cards(scraper: CianScraper, html: str) -> list[Any]:
|
|||
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)
|
||||
class PlatformAdapter:
|
||||
"""Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
|
||||
|
|
@ -349,12 +521,21 @@ class PlatformAdapter:
|
|||
parse_cards: Callable[[Any, str], list[Any]]
|
||||
detect_block: Callable[[str], tuple[str, str] | None]
|
||||
wait_ready: Callable[[Any], Any] # корутина: дождаться готовности страницы
|
||||
# Дефолт --target-count. Он обязан быть <= hard_cap площадки, иначе бисекция
|
||||
# штатно оставляет коридоры, которые заведомо не вычитываются до конца.
|
||||
default_target: int = 1500
|
||||
|
||||
@property
|
||||
def hard_cap(self) -> int:
|
||||
return self.page_size * self.max_pages
|
||||
|
||||
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":
|
||||
# avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё,
|
||||
# где в URL нет /ekaterinburg/ — то есть все подмосковные слаги
|
||||
|
|
@ -393,6 +574,19 @@ ADAPTERS: dict[str, PlatformAdapter] = {
|
|||
detect_block=_cian_detect_block,
|
||||
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):
|
||||
self.skipped_non_numeric += 1
|
||||
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")
|
||||
self.buffer.append({
|
||||
"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:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="collect.py",
|
||||
description="Ручной сбор SERP Авито/Циан (вторичка, Москва+МО) в прод-схему msk_raw.",
|
||||
description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.",
|
||||
)
|
||||
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
||||
help="площадка сбора (дефолт avito)")
|
||||
|
|
@ -1033,8 +1233,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
|
||||
p.add_argument("--batch-size", type=int, default=1000,
|
||||
help="карточек в одной заливке (дефолт 1000)")
|
||||
p.add_argument("--target-count", type=int, default=1500,
|
||||
help="целевой размер коридора; больше — делим (дефолт 1500)")
|
||||
p.add_argument("--target-count", type=int, default=None,
|
||||
help="целевой размер коридора; больше — делим "
|
||||
"(дефолт зависит от --platform: 1500 у avito/cian, "
|
||||
"500 у yandex — там потолок пагинации 25*20)")
|
||||
p.add_argument("--batch-id", default=None,
|
||||
help="batch_id в msk_raw.batches (дефолт msk-serp-<platform>-<UTC>)")
|
||||
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)
|
||||
if args.base_url is None:
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue