Яндекс: авторизованная сессия открывает контакты продавца (author.phoneNumbers) — хранилище сессии и извлечение телефонов #3192

Closed
opened 2026-08-28 18:41:14 +00:00 by bot-backend · 4 comments
Collaborator

Измерено 28.08 — авторизация Яндекса даёт контакты продавца

Замер через прод-сайдкар, две карточки, режимы вперемежку:

offer режим размер mainPhone уник. телефонов
52275656 без кук 3 920 275 0 90
52275656 с куками 4 061 961 1 101
12108408 без кук 4 731 805 0 89
12108408 с куками 4 876 497 1 100

Устойчиво на обеих: появляется ключ mainPhone и +11 уникальных номеров.

Поправка к первому выводу

Сперва я сравнил залогиненный браузер с анонимным сайдкаром, увидел 91 телефон против 90 и заключил, что авторизация ничего не даёт. Вывод был неверный: те девяносто — номера Яндекса и агентств, они присутствуют всегда. Разница именно в mainPhone и одиннадцати номерах поверх фона, и видна она только при сравнении одного и того же тракта с куками и без.

Урок тот же, что и с Авито сегодня: сравнивать можно только один тракт с собой.

Почему это ценно

listings.phones (jsonb) существует в схеме и пуст у всех 51 251 активного объявления по всем четырём источникам. Колонку завели и никогда не заполняли. Яндекс станет первым источником с контактами.

Что мешает сегодня

  1. Хранилища сессии Яндекса нет. Для Циана и Домклика оно есть и работает в проде (cian_session_cookies 2 записи, domclick_session_cookies 1), у Яндекса — ничего.
  2. Парсер телефоны не извлекает вообще — в providers/yandex/detail.py ни одного упоминания phone. Даже с куками номер осядет в HTML и никуда не попадёт.
  3. Контракт кук в сайдкаре ущербный. server.py:1197-1203 и :1266-1272: формат провода — плоский dict[name→value], а домен сервер фабрикует из URL (cookie_domain = f".{urlparse(url).hostname}"). Для Яндекса это теряет разделение доменов (.yandex.ru / .passport.yandex.ru / .realty.yandex.ru) и схлопывает дубли имён (pi ×4, yashr ×4, pu ×3).
    При этом замер выше сделан именно в этом урезанном формате и он работает. То есть контракт — не блокер, а долг: работает по счастливой случайности, потому что нужные куки попадают на домен realty.

Разбивка

  • #3192 (эта задача, часть 1) — хранилище сессии: миграция 274_yandex_session_cookies.sql, services/yandex_session.py по образцу cian_session.py (pgp_sym_encrypt, TTL, предупреждение о протухании), админ-роуты upload/status.
  • Часть 2 — проброс кук в providers/yandex/detail.py + извлечение mainPhone/телефонов в listings.phones.
  • Часть 3 (долг, не блокер) — двусторонний контракт кук в сайдкаре: принимать полные словари с domain/path/secure/httpOnly/expires, отдавать обновлённые куки обратно. Нужен и для Авито (#3179).

Важно про фильтрацию кук

Замер сделан на полном наборе из 44 кук. Какая из них существенна — неизвестно. Поэтому сохраняем набор целиком (минус очевидная аналитика _ym_*, yabs-*, _yasc), а список критичных (Session_id, sessionid2, sessar, i, L, sessguard, yandexuid, yandex_login) используем только для проверки, что дамп похож на авторизованную сессию. Не превращать его в фильтр «что сохранять» — сломается то, что измерено работающим.

Риски

  • Сессия протухает; ротация — ручная заливка через админку, как у Домклика.
  • Персонифицированный сбор: собираем контакты под конкретной учёткой. Рамка отличается от анонимного чтения публичной выдачи — решение владельца.
  • Куки — живые credentials. В БД шифрованно, в вольт сырой дамп не кладём.

Refs #3191

## Измерено 28.08 — авторизация Яндекса даёт контакты продавца Замер через **прод-сайдкар**, две карточки, режимы вперемежку: | offer | режим | размер | `mainPhone` | уник. телефонов | |---|---|---|---|---| | 52275656 | без кук | 3 920 275 | 0 | 90 | | 52275656 | **с куками** | 4 061 961 | **1** | **101** | | 12108408 | без кук | 4 731 805 | 0 | 89 | | 12108408 | **с куками** | 4 876 497 | **1** | **100** | Устойчиво на обеих: появляется ключ `mainPhone` и **+11 уникальных номеров**. ### Поправка к первому выводу Сперва я сравнил залогиненный **браузер** с анонимным **сайдкаром**, увидел 91 телефон против 90 и заключил, что авторизация ничего не даёт. Вывод был неверный: те девяносто — номера Яндекса и агентств, они присутствуют всегда. Разница именно в `mainPhone` и одиннадцати номерах поверх фона, и видна она только при сравнении **одного и того же тракта** с куками и без. Урок тот же, что и с Авито сегодня: сравнивать можно только один тракт с собой. ## Почему это ценно `listings.phones` (jsonb) существует в схеме и **пуст у всех 51 251 активного объявления по всем четырём источникам**. Колонку завели и никогда не заполняли. Яндекс станет первым источником с контактами. ## Что мешает сегодня 1. **Хранилища сессии Яндекса нет.** Для Циана и Домклика оно есть и работает в проде (`cian_session_cookies` 2 записи, `domclick_session_cookies` 1), у Яндекса — ничего. 2. **Парсер телефоны не извлекает вообще** — в `providers/yandex/detail.py` ни одного упоминания `phone`. Даже с куками номер осядет в HTML и никуда не попадёт. 3. **Контракт кук в сайдкаре ущербный.** `server.py:1197-1203` и `:1266-1272`: формат провода — плоский `dict[name→value]`, а домен сервер фабрикует из URL (`cookie_domain = f".{urlparse(url).hostname}"`). Для Яндекса это теряет разделение доменов (`.yandex.ru` / `.passport.yandex.ru` / `.realty.yandex.ru`) и схлопывает дубли имён (`pi` ×4, `yashr` ×4, `pu` ×3). **При этом замер выше сделан именно в этом урезанном формате и он работает.** То есть контракт — не блокер, а долг: работает по счастливой случайности, потому что нужные куки попадают на домен realty. ## Разбивка - **#3192 (эта задача, часть 1)** — хранилище сессии: миграция `274_yandex_session_cookies.sql`, `services/yandex_session.py` по образцу `cian_session.py` (pgp_sym_encrypt, TTL, предупреждение о протухании), админ-роуты upload/status. - **Часть 2** — проброс кук в `providers/yandex/detail.py` + извлечение `mainPhone`/телефонов в `listings.phones`. - **Часть 3 (долг, не блокер)** — двусторонний контракт кук в сайдкаре: принимать полные словари с `domain/path/secure/httpOnly/expires`, отдавать обновлённые куки обратно. Нужен и для Авито (#3179). ## Важно про фильтрацию кук Замер сделан на **полном наборе из 44 кук**. Какая из них существенна — неизвестно. Поэтому сохраняем набор целиком (минус очевидная аналитика `_ym_*`, `yabs-*`, `_yasc`), а список критичных (`Session_id`, `sessionid2`, `sessar`, `i`, `L`, `sessguard`, `yandexuid`, `yandex_login`) используем только для **проверки**, что дамп похож на авторизованную сессию. Не превращать его в фильтр «что сохранять» — сломается то, что измерено работающим. ## Риски - Сессия протухает; ротация — ручная заливка через админку, как у Домклика. - Персонифицированный сбор: собираем контакты под конкретной учёткой. Рамка отличается от анонимного чтения публичной выдачи — решение владельца. - Куки — живые credentials. В БД шифрованно, в вольт сырой дамп не кладём. Refs #3191
bot-backend added the
enhancement
priority/p1
scope/backend
scrapers
tradein
labels 2026-08-28 18:41:15 +00:00
Author
Collaborator

Важная поправка: mainPhone — НЕ то, что нужно собирать. Это телефон НАШЕЙ учётки.

Разобрал контекст ключа (цифры замаскированы):

"login":"...","mainPhone":"+###########","passHost":"https://pass.yandex.ru",
"passportApiHost":"https://api.passport.yandex.ru","passportHost":"https://passport.yandex.ru",
"passportOrigin":"realty_moscow","passportPhones":["+###########"]

mainPhone лежит в паспортном блоке залогиненного пользователя — это номер аккаунта, под которым мы зашли. Собирать его нельзя: это персональные данные владельца, и как данные об объявлении он бесполезен. Явно исключить из извлечения.

Где настоящая разница

Пере-замерил точными счётчиками, две карточки, тот же тракт с куками и без:

без кук с куками
"phoneNumbers" 0 / 0 2 / 2
"phones":["+7… у автора 24 / 22 27 / 25
"encryptedPhones" 54 / 54 54 / 54
"redirectId" 101 / 101 111 / 111

Одинаково на обеих карточках. Структура, которая появляется только с куками:

"author": {"category": "OWNER", "agentName": "…",
           "phones": ["+###########"],
           "phoneNumbers": [{"tag": "serp", "phone": "+###########", "redirectId": "…"},
                            {"tag": "mapsLost", "phone": "+###########", "redirectId": "…"}]}

Это продавец с расшифрованным номером и тегами каналов.

encryptedPhones при этом одинаков в обоих режимах (54) — зашифрованные токены отдаются всегда, различие не в них.

Уточнение для части 2

Извлекать: author.phoneNumbers[].phonetag и redirectId) и author.phones[].
Не извлекать: mainPhone, passportPhones и всё из паспортного блока — это мы сами.

В listings.phones (jsonb, пуст у всех 51 251 активного объявления) класть номера продавца с тегом канала, не плоский список: redirectId и tag показывают, что это за номер, а не просто цифры.

Про мои метания по этому вопросу

Три захода, каждый исправлял предыдущий:

  1. «Авторизация ничего не даёт» — сравнивал залогиненный браузер с анонимным сайдкаром, разные тракты, 91 против 90 телефонов. Неверно.
  2. «Даёт mainPhone и +11 номеров» — тракт один, но не разобрал, что за поля. mainPhone оказался нашим собственным.
  3. Текущий — точные счётчики по конкретным полям, один тракт, две карточки, совпадающие числа.

Правило, которое стоит записать: сравнивать только один тракт с собой, и до вывода разбирать, что за поле изменилось, а не только насколько.

**Важная поправка: `mainPhone` — НЕ то, что нужно собирать. Это телефон НАШЕЙ учётки.** Разобрал контекст ключа (цифры замаскированы): ``` "login":"...","mainPhone":"+###########","passHost":"https://pass.yandex.ru", "passportApiHost":"https://api.passport.yandex.ru","passportHost":"https://passport.yandex.ru", "passportOrigin":"realty_moscow","passportPhones":["+###########"] ``` `mainPhone` лежит в паспортном блоке залогиненного пользователя — это номер аккаунта, под которым мы зашли. Собирать его нельзя: это персональные данные владельца, и как данные об объявлении он бесполезен. **Явно исключить из извлечения.** ## Где настоящая разница Пере-замерил точными счётчиками, две карточки, тот же тракт с куками и без: | | без кук | с куками | |---|---|---| | `"phoneNumbers"` | **0 / 0** | **2 / 2** | | `"phones":["+7…` у автора | 24 / 22 | **27 / 25** | | `"encryptedPhones"` | 54 / 54 | 54 / 54 | | `"redirectId"` | 101 / 101 | **111 / 111** | Одинаково на обеих карточках. Структура, которая появляется только с куками: ```json "author": {"category": "OWNER", "agentName": "…", "phones": ["+###########"], "phoneNumbers": [{"tag": "serp", "phone": "+###########", "redirectId": "…"}, {"tag": "mapsLost", "phone": "+###########", "redirectId": "…"}]} ``` Это продавец с расшифрованным номером и тегами каналов. `encryptedPhones` при этом **одинаков в обоих режимах** (54) — зашифрованные токены отдаются всегда, различие не в них. ## Уточнение для части 2 Извлекать: `author.phoneNumbers[].phone` (с `tag` и `redirectId`) и `author.phones[]`. **Не извлекать:** `mainPhone`, `passportPhones` и всё из паспортного блока — это мы сами. В `listings.phones` (jsonb, пуст у всех 51 251 активного объявления) класть номера продавца с тегом канала, не плоский список: `redirectId` и `tag` показывают, что это за номер, а не просто цифры. ## Про мои метания по этому вопросу Три захода, каждый исправлял предыдущий: 1. «Авторизация ничего не даёт» — сравнивал залогиненный **браузер** с анонимным **сайдкаром**, разные тракты, 91 против 90 телефонов. Неверно. 2. «Даёт `mainPhone` и +11 номеров» — тракт один, но не разобрал, что за поля. `mainPhone` оказался нашим собственным. 3. Текущий — точные счётчики по конкретным полям, один тракт, две карточки, совпадающие числа. Правило, которое стоит записать: **сравнивать только один тракт с собой, и до вывода разбирать, что за поле изменилось, а не только насколько.**
bot-backend changed title from Яндекс: авторизованная сессия даёт контакты продавца (mainPhone +11 номеров) — хранилище сессии и извлечение телефонов to Яндекс: авторизованная сессия открывает контакты продавца (author.phoneNumbers) — хранилище сессии и извлечение телефонов 2026-08-28 18:44:48 +00:00
Author
Collaborator

Опровержение: авторизованная сессия Яндекса не даёт НИЧЕГО по объявлению

Замер на прод-транспорте (curl_cffi + прокси из пула — тот же путь, что у yandex_detail_backfill), а не на сайдкаре. Предыдущий вывод был неверен, разбор ниже.

Что я мерил раньше и почему это было не то

Я считал вхождения "phoneNumbers" в HTML: анонимно 0, с куками 3, 4, 5, 6 на четырёх карточках подряд. Вывод «куки раскрывают контакты» был сделан из этой разницы.

Счётчик рос ровно на единицу с каждым моим фетчем. Это не свойство карточек — это рост моего собственного списка просмотренных.

Где phoneNumbers лежит на самом деле

Обход всего window.INITIAL_STATE по ключам phones/phoneNumbers, 41 попадание:

offerCard.visitedOffers.[0..8].author.phoneNumbers      <- ЕДИНСТВЕННЫЙ источник phoneNumbers
offerCard.authorStats.phones
offerCard.card.siteInfo.developers.[0].phones
offerCard.nearbySitesData.[*].{developers,salesDepartment}.phones
offerCard.siteCard.{developer,developers,salesDepartments}.phones

offerCard.card.author — целевая карточка — в списке отсутствует.

visitedOffers это история просмотров НАШЕЙ учётки. Анонимно её нет (список пуст), с куками в ней 9-10 записей — те карточки, которые я сам открыл во время замера. К объявлению, которое мы обогащаем, она отношения не имеет.

Целевая карточка: 12 из 12 без телефонов

Случайные 12 объявлений, с куками:

category n author.phones author.encryptedPhones author.redirectPhones
AGENCY 6 нет ни у одной 1 есть
DEVELOPER 6 нет ни у одной 1 есть

Ключи author целевой карточки всегда одни и те же: agentName, allowedCommunicationChannels, category, creationDate, encryptedPhones, id, name, organization, photo/humanPhoto, redirectPhones, redirectPhonesFailed. Ни phones, ни phoneNumbers.

Прямое A/B на одних и тех же карточках, порядок чередуется

anon COOKIES
authorStats.phones +7 (3***06 +7 (3***06тот же
card.author.phones пусто пусто
card.author.encryptedPhones 1 1
visitedOffers 0 9-10
HTTP / captcha 200 / нет 200 / нет

Единственное, что меняют куки — размер нашей же истории просмотров.

Побочно: authorStats.phones (телефон коммутатора застройщика) отдаётся анонимно. Авторизация для него не нужна.

Что из этого следует

  1. Часть 2 (_parse_author_phones из card["author"]) не поедет — на проде вернёт [] всегда. Код читает правильный узел (заражения номерами из visitedOffers нет), но узел пуст. Не мержу.
  2. Хранилище сессий (#3195, уже в main) осталось без обоснования. Оно строилось ровно под раскрытие контактов. Данных в нём нет — дамп я не заливал.
  3. Настоящий телефон продавца лежит за encryptedPhones (1 токен) + redirectPhones, то есть за отдельным вызовом «показать телефон». Это другая задача и другой разговор про допустимость.

Ставлю status/needs-human: решение по судьбе #3195 (оставить таблицу под будущее или снести) — за владельцем, DROP TABLE без явного одобрения не делаю.

## Опровержение: авторизованная сессия Яндекса не даёт НИЧЕГО по объявлению Замер на прод-транспорте (`curl_cffi` + прокси из пула — тот же путь, что у `yandex_detail_backfill`), а не на сайдкаре. Предыдущий вывод был неверен, разбор ниже. ### Что я мерил раньше и почему это было не то Я считал вхождения `"phoneNumbers"` в HTML: анонимно 0, с куками 3, 4, 5, 6 на четырёх карточках подряд. Вывод «куки раскрывают контакты» был сделан из этой разницы. Счётчик рос **ровно на единицу с каждым моим фетчем**. Это не свойство карточек — это рост моего собственного списка просмотренных. ### Где `phoneNumbers` лежит на самом деле Обход всего `window.INITIAL_STATE` по ключам `phones`/`phoneNumbers`, 41 попадание: ``` offerCard.visitedOffers.[0..8].author.phoneNumbers <- ЕДИНСТВЕННЫЙ источник phoneNumbers offerCard.authorStats.phones offerCard.card.siteInfo.developers.[0].phones offerCard.nearbySitesData.[*].{developers,salesDepartment}.phones offerCard.siteCard.{developer,developers,salesDepartments}.phones ``` `offerCard.card.author` — целевая карточка — в списке **отсутствует**. `visitedOffers` это история просмотров НАШЕЙ учётки. Анонимно её нет (список пуст), с куками в ней 9-10 записей — те карточки, которые я сам открыл во время замера. К объявлению, которое мы обогащаем, она отношения не имеет. ### Целевая карточка: 12 из 12 без телефонов Случайные 12 объявлений, с куками: | category | n | `author.phones` | `author.encryptedPhones` | `author.redirectPhones` | |---|---|---|---|---| | AGENCY | 6 | нет ни у одной | 1 | есть | | DEVELOPER | 6 | нет ни у одной | 1 | есть | Ключи `author` целевой карточки всегда одни и те же: `agentName, allowedCommunicationChannels, category, creationDate, encryptedPhones, id, name, organization, photo/humanPhoto, redirectPhones, redirectPhonesFailed`. Ни `phones`, ни `phoneNumbers`. ### Прямое A/B на одних и тех же карточках, порядок чередуется | | anon | COOKIES | |---|---|---| | `authorStats.phones` | `+7 (3***06` | `+7 (3***06` — **тот же** | | `card.author.phones` | пусто | пусто | | `card.author.encryptedPhones` | 1 | 1 | | `visitedOffers` | 0 | 9-10 | | HTTP / captcha | 200 / нет | 200 / нет | Единственное, что меняют куки — размер нашей же истории просмотров. Побочно: `authorStats.phones` (телефон коммутатора застройщика) отдаётся **анонимно**. Авторизация для него не нужна. ### Что из этого следует 1. **Часть 2 (`_parse_author_phones` из `card["author"]`) не поедет** — на проде вернёт `[]` всегда. Код читает правильный узел (заражения номерами из `visitedOffers` нет), но узел пуст. Не мержу. 2. **Хранилище сессий (#3195, уже в main) осталось без обоснования.** Оно строилось ровно под раскрытие контактов. Данных в нём нет — дамп я не заливал. 3. Настоящий телефон продавца лежит за `encryptedPhones` (1 токен) + `redirectPhones`, то есть за отдельным вызовом «показать телефон». Это другая задача и другой разговор про допустимость. Ставлю `status/needs-human`: решение по судьбе #3195 (оставить таблицу под будущее или снести) — за владельцем, `DROP TABLE` без явного одобрения не делаю.
bot-backend added the
needs-human
label 2026-08-28 19:49:48 +00:00
Author
Collaborator

Судьба таблицы: не отдельный DROP, а часть #3179

Проверил, нет ли уже начатой работы по единому хранилищу кук. Ветки нет, но есть тикет — #3179 (Foundation эпика #3177): там спроектирована одна общая scraper_sessions (source / account_label / cookies / proxy_id / здоровье сессии), которая должна заменить пер-источниковые таблицы.

Инвентарь на проде (28.08): cian_session_cookies — 3 живых записи, domclick_session_cookies — 2, yandex_session_cookies — 0. То есть яндексовая таблица не «лишняя сущность на пустом месте», а третья из трёх, которые #3179 и так сводит вместе.

Поэтому отдельную миграцию с DROP TABLE не завожу: удаление 274 логичнее делать одной миграцией вместе с 027 и 174, когда данные переедут. Развёрнутый инвентарь расхождений (тип ключа, формат кук, фильтр на сохранение) выложен в #3179.

Снимаю needs-human: решение больше не висит, у него есть адрес.

## Судьба таблицы: не отдельный DROP, а часть #3179 Проверил, нет ли уже начатой работы по единому хранилищу кук. Ветки нет, но есть тикет — **#3179** (Foundation эпика #3177): там спроектирована одна общая `scraper_sessions` (`source` / `account_label` / `cookies` / `proxy_id` / здоровье сессии), которая должна заменить пер-источниковые таблицы. Инвентарь на проде (28.08): `cian_session_cookies` — 3 живых записи, `domclick_session_cookies` — 2, `yandex_session_cookies` — 0. То есть яндексовая таблица не «лишняя сущность на пустом месте», а третья из трёх, которые #3179 и так сводит вместе. Поэтому отдельную миграцию с `DROP TABLE` не завожу: удаление 274 логичнее делать одной миграцией вместе с 027 и 174, когда данные переедут. Развёрнутый инвентарь расхождений (тип ключа, формат кук, фильтр на сохранение) выложен в #3179. Снимаю `needs-human`: решение больше не висит, у него есть адрес.
bot-backend removed the
needs-human
label 2026-08-28 19:58:38 +00:00
Author
Collaborator

Закрываю: посылка опровергнута автором 28.08 — mainPhone оказался НАШЕЙ учёткой, счётчик просмотров рос от наших же заходов (commit c995af73 фиксирует опровержение). Живой остаток про единое хранилище сессий поглощён #3179. Ревизия 01.09.2026.

Закрываю: посылка опровергнута автором 28.08 — mainPhone оказался НАШЕЙ учёткой, счётчик просмотров рос от наших же заходов (commit c995af73 фиксирует опровержение). Живой остаток про единое хранилище сессий поглощён #3179. Ревизия 01.09.2026.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3192
No description provided.