Авито detail-бэкфилл отрезает ?context= — теряется searchHash, карточка выглядит пришедшей ниоткуда #3035

Open
opened 2026-08-21 13:31:40 +00:00 by lekss361 · 2 comments
Owner

Найдено 2026-08-21 сверкой живого браузера со скраппером. Вероятный корень того, что avito_detail_backfill отказывает в 91-96% попыток.

Что происходит

backend/app/tasks/avito_detail_backfill.py:433:

item_url = urlparse(source_url).path

Query отрезается целиком. Живая карточка при этом несёт ?context=<gzip+base64>, внутри которого PHP-serialize:

a:2:{s:13:"localPriority";b:0;s:1:"x";s:16:"Ptcli1K6r7hEonM3";}

А запрос с той же страницы:

GET /web/1/items/phone/8139872797?searchHash=Ptcli1K6r7hEonM3&…

searchHash == context.x. Проверено на двух карточках подряд:

Объявление context.x searchHash
8139872797 Ptcli1K6r7hEonM3 Ptcli1K6r7hEonM3
4165182516 4jxkuhucoP7CLwTF 4jxkuhucoP7CLwTF

Контекст выпускает выдача, персонально под каждое объявление, и карточка несёт его дальше. Мы запрашиваем карточку голым путём — для площадки это карточка, на которую никто ниоткуда не переходил.

Честная оговорка

Не доказано, что Авито именно отклоняет запрос без контекста. В коде записано «доказано пробами на проде: 22/22 карточек подряд, 0 блоков» — когда-то работало без него. Но это ровно та проверка, которую вводят в первую очередь, и она объясняет, почему detail сыпется сильнее свипа: свип приходит с легальным Referer, карточка — из ниоткуда.

Прежде чем переделывать архитектуру, стоит проверить дёшево: один прогон с сохранённым контекстом против одного без.

Архитектурное следствие

Бэкфилл ходит по карточкам через сутки после свипа. Даже если начать сохранять context вместе с объявлением, к моменту использования он может протухнуть — это одноразовый токен выдачи, а не постоянный идентификатор.

Честное решение — тянуть детали в той же сессии сразу за выдачей, а не отдельным заданием на следующий день. Это меняет форму пайплайна, поэтому решение принимать после дешёвой проверки выше.

Про телефоны — отдельно, и это тупик

Даже с валидным searchHash эндпоинт отдаёт номер картинкой:

{"anonymImage64":"data:image/png;base64,…","isAnonym":true,
 "sellerName":"Новый Ориентир","anonymPhoneTitle":"Временный номер",
 "anonymPhoneNote":"…Не сохраняйте его: скоро телефон заменится на другой…"}

Ни одной цифры, у агентств номер ротируемый. И это не важно: listings.phones пуст у всех 105 958 строк, по всем пяти источникам — колонка участвует в 41-колоночном апсерте и в гейте IS DISTINCT FROM (#2992), но её никто никогда не заполнял.

То есть телефоны собирать не нужно. Ценность контекста — в самой карточке, не в номере.

Приёмка

  • Проверено дёшево: прогон с контекстом против прогона без, доля отказов
  • Если разница есть — контекст сохраняется и доезжает до запроса карточки
  • Если контекст протухает за сутки — решение по переносу detail в сессию свипа принято и записано
  • Отдельно: решить судьбу мёртвой колонки listings.phones

Scope: backend/app/tasks/avito_detail_backfill.py, packages/scraper-kit/src/scraper_kit/providers/avito/{serp,detail}.py.

Найдено 2026-08-21 сверкой живого браузера со скраппером. Вероятный корень того, что `avito_detail_backfill` отказывает в 91-96% попыток. ## Что происходит `backend/app/tasks/avito_detail_backfill.py:433`: ```python item_url = urlparse(source_url).path ``` Query отрезается целиком. Живая карточка при этом несёт `?context=<gzip+base64>`, внутри которого PHP-serialize: ``` a:2:{s:13:"localPriority";b:0;s:1:"x";s:16:"Ptcli1K6r7hEonM3";} ``` А запрос с той же страницы: ``` GET /web/1/items/phone/8139872797?searchHash=Ptcli1K6r7hEonM3&… ``` **`searchHash` == `context.x`.** Проверено на двух карточках подряд: | Объявление | `context.x` | `searchHash` | |---|---|---| | 8139872797 | `Ptcli1K6r7hEonM3` | `Ptcli1K6r7hEonM3` | | 4165182516 | `4jxkuhucoP7CLwTF` | `4jxkuhucoP7CLwTF` | Контекст выпускает выдача, персонально под каждое объявление, и карточка несёт его дальше. Мы запрашиваем карточку голым путём — для площадки это карточка, на которую никто ниоткуда не переходил. ## Честная оговорка **Не доказано, что Авито именно отклоняет** запрос без контекста. В коде записано «доказано пробами на проде: 22/22 карточек подряд, 0 блоков» — когда-то работало без него. Но это ровно та проверка, которую вводят в первую очередь, и она объясняет, почему detail сыпется сильнее свипа: свип приходит с легальным Referer, карточка — из ниоткуда. Прежде чем переделывать архитектуру, стоит проверить дёшево: один прогон с сохранённым контекстом против одного без. ## Архитектурное следствие Бэкфилл ходит по карточкам **через сутки** после свипа. Даже если начать сохранять `context` вместе с объявлением, к моменту использования он может протухнуть — это одноразовый токен выдачи, а не постоянный идентификатор. Честное решение — тянуть детали **в той же сессии сразу за выдачей**, а не отдельным заданием на следующий день. Это меняет форму пайплайна, поэтому решение принимать после дешёвой проверки выше. ## Про телефоны — отдельно, и это тупик Даже с валидным `searchHash` эндпоинт отдаёт номер **картинкой**: ```json {"anonymImage64":"data:image/png;base64,…","isAnonym":true, "sellerName":"Новый Ориентир","anonymPhoneTitle":"Временный номер", "anonymPhoneNote":"…Не сохраняйте его: скоро телефон заменится на другой…"} ``` Ни одной цифры, у агентств номер ротируемый. И это не важно: **`listings.phones` пуст у всех 105 958 строк**, по всем пяти источникам — колонка участвует в 41-колоночном апсерте и в гейте `IS DISTINCT FROM` (#2992), но её никто никогда не заполнял. То есть телефоны собирать не нужно. Ценность контекста — в самой карточке, не в номере. ## Приёмка - [ ] Проверено дёшево: прогон с контекстом против прогона без, доля отказов - [ ] Если разница есть — контекст сохраняется и доезжает до запроса карточки - [ ] Если контекст протухает за сутки — решение по переносу detail в сессию свипа принято и записано - [ ] Отдельно: решить судьбу мёртвой колонки `listings.phones` Scope: `backend/app/tasks/avito_detail_backfill.py`, `packages/scraper-kit/src/scraper_kit/providers/avito/{serp,detail}.py`.
lekss361 added the
bug
priority/p1
scope/backend
scrapers
tradein
labels 2026-08-21 13:32:32 +00:00
Author
Owner

Поправка: context уже сохраняется, я ошибся в постановке

В теле issue написано «даже если начать сохранять context…» — это неверно, сохранять уже не нужно. Замер:

SELECT count(*) AS total,
       count(*) FILTER (WHERE source_url LIKE '%context=%') AS with_context
FROM listings WHERE source='avito' AND scraped_at > now() - interval '2 days';
-- total=1363, with_context=1363

Все 1363 объявления за двое суток несут ?context= прямо в source_url. Пример:

https://www.avito.ru/verhnyaya_pyshma/kvartiry/kvartira-studiya_323_m_1616_et._8278397365
  ?context=H4sIAAAAAAAA_wE_AMD_YToyOntzOjEzOiJsb2NhbFByaW9yaXR5IjtiOjA7czoxOiJ4IjtzOjE2OiJBMTZEYUFobXhEWDF6TFpRIjt9AfKIkD8AAAA

SERP-парсер честно кладёт полный URL. Контекст теряется на выходе из БД, одной строкой в avito_detail_backfill.py:433:

item_url = urlparse(source_url).path

Это сильно меняет оценку работы: если гипотеза подтвердится, правка — снять .path, а не проектировать хранение токена.

Что осталось выяснить

Два вопроса, оба решаются одним экспериментом (запущен):

  1. Влияет ли контекст вообще. Прямых доказательств, что Авито отклоняет запрос без него, у меня нет — в коде записано «22/22 карточек подряд, 0 блоков» без контекста, значит когда-то работало.
  2. Протухает ли контекст за сутки. Это одноразовый токен поисковой сессии, а бэкфилл ходит по карточкам через день после свипа. Если протухает — однострочник не спасёт, и detail придётся переносить в сессию свипа.

Эксперимент: три группы по 10 объявлений — свежие с контекстом, те же без контекста, двух-трёхдневные с контекстом. Потолок 30 запросов, последовательно, с прерыванием на двух подряд банах: прокси боевые, только сегодня обновлены.

Отрицательный результат тоже засчитывается — он закроет гипотезу и развернёт поиск причины в другую сторону.

## Поправка: `context` уже сохраняется, я ошибся в постановке В теле issue написано «даже если начать сохранять `context`…» — это неверно, **сохранять уже не нужно**. Замер: ```sql SELECT count(*) AS total, count(*) FILTER (WHERE source_url LIKE '%context=%') AS with_context FROM listings WHERE source='avito' AND scraped_at > now() - interval '2 days'; -- total=1363, with_context=1363 ``` **Все 1363 объявления за двое суток несут `?context=` прямо в `source_url`.** Пример: ``` https://www.avito.ru/verhnyaya_pyshma/kvartiry/kvartira-studiya_323_m_1616_et._8278397365 ?context=H4sIAAAAAAAA_wE_AMD_YToyOntzOjEzOiJsb2NhbFByaW9yaXR5IjtiOjA7czoxOiJ4IjtzOjE2OiJBMTZEYUFobXhEWDF6TFpRIjt9AfKIkD8AAAA ``` SERP-парсер честно кладёт полный URL. Контекст теряется **на выходе из БД**, одной строкой в `avito_detail_backfill.py:433`: ```python item_url = urlparse(source_url).path ``` Это сильно меняет оценку работы: если гипотеза подтвердится, правка — снять `.path`, а не проектировать хранение токена. ## Что осталось выяснить Два вопроса, оба решаются одним экспериментом (запущен): 1. **Влияет ли контекст вообще.** Прямых доказательств, что Авито отклоняет запрос без него, у меня нет — в коде записано «22/22 карточек подряд, 0 блоков» без контекста, значит когда-то работало. 2. **Протухает ли контекст за сутки.** Это одноразовый токен поисковой сессии, а бэкфилл ходит по карточкам через день после свипа. Если протухает — однострочник не спасёт, и detail придётся переносить в сессию свипа. Эксперимент: три группы по 10 объявлений — свежие с контекстом, те же без контекста, двух-трёхдневные с контекстом. Потолок 30 запросов, последовательно, с прерыванием на двух подряд банах: прокси боевые, только сегодня обновлены. Отрицательный результат тоже засчитывается — он закроет гипотезу и развернёт поиск причины в другую сторону.
Author
Owner

Разбирал вместе с #3044 (PR #3065) и не стал брать в тот же коммит. Замер изменил картину в обе стороны.

Что подтвердилось

Query действительно режется у всех avito-URL, а не у части. Замер на проде (tradein-postgres, 23-24.08):

with_query | with_context | total
     55140 |        55140 | 55140

То есть ?context= несут 100 % строк, и urlparse(source_url).path выбрасывает его у каждой без исключения. Это не редкий случай.

Что изменило оценку

Обрезка стоит не в одном месте, а в трёх — и комментарий в бэкфилле («mirrors run_avito_city_sweep detail-phase, kit») описывает это как намеренное зеркалирование, а не как копипасту:

  • tradein-mvp/backend/app/tasks/avito_detail_backfill.py:433
  • tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py:909
  • tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py:1512

Починить только бэкфилл — значит развести его с kit'ом при том, что паритет между ними явно поддерживается (есть даже отдельные тесты *_kit_parity.py).

Почему не стал делать молча

Знак эффекта неизвестен, и не только в смысле «выигрыша может не быть» — возможен минус. searchHash в ?context= привязан к конкретной поисковой сессии, а бэкфилл ходит по карточкам, найденным другим прогоном и, судя по глубине очереди (11 021 карточка при ~700-800/сутки), нередко неделями раньше. Отдавать площадке протухший хэш чужой сессии может выглядеть подозрительнее, чем не отдавать никакого — это ровно тот сигнал, по которому антибот и отличает браузер от скрипта.

Плюс исходная гипотеза уже потеряла главное основание: реальным виновником detail-отказов оказался транспорт (#3049, починено 22.08), а не отсутствие контекста. Исторически без контекста фиксировалось «22/22 подряд, 0 блоков».

Что предлагаю

Не правку вслепую, а дешёвый замер: прогнать N карточек с сохранённым query и N без — на одном пуле, в одном окне — и сравнить долю блоков. Если разницы нет, менять три места ради гигиены не стоит; если есть — станет понятно, в какую сторону.

До этого замера issue лучше держать открытой, но не в очереди «сделать сейчас».

Разбирал вместе с #3044 (PR #3065) и **не стал** брать в тот же коммит. Замер изменил картину в обе стороны. ## Что подтвердилось Query действительно режется у **всех** avito-URL, а не у части. Замер на проде (`tradein-postgres`, 23-24.08): ``` with_query | with_context | total 55140 | 55140 | 55140 ``` То есть `?context=` несут 100 % строк, и `urlparse(source_url).path` выбрасывает его у каждой без исключения. Это не редкий случай. ## Что изменило оценку **Обрезка стоит не в одном месте, а в трёх** — и комментарий в бэкфилле (`«mirrors run_avito_city_sweep detail-phase, kit»`) описывает это как намеренное зеркалирование, а не как копипасту: - `tradein-mvp/backend/app/tasks/avito_detail_backfill.py:433` - `tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py:909` - `tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py:1512` Починить только бэкфилл — значит развести его с kit'ом при том, что паритет между ними явно поддерживается (есть даже отдельные тесты `*_kit_parity.py`). ## Почему не стал делать молча Знак эффекта неизвестен, и не только в смысле «выигрыша может не быть» — **возможен минус**. `searchHash` в `?context=` привязан к конкретной поисковой сессии, а бэкфилл ходит по карточкам, найденным другим прогоном и, судя по глубине очереди (11 021 карточка при ~700-800/сутки), нередко **неделями раньше**. Отдавать площадке протухший хэш чужой сессии может выглядеть подозрительнее, чем не отдавать никакого — это ровно тот сигнал, по которому антибот и отличает браузер от скрипта. Плюс исходная гипотеза уже потеряла главное основание: реальным виновником detail-отказов оказался транспорт (#3049, починено 22.08), а не отсутствие контекста. Исторически без контекста фиксировалось «22/22 подряд, 0 блоков». ## Что предлагаю Не правку вслепую, а дешёвый замер: прогнать N карточек с сохранённым query и N без — на одном пуле, в одном окне — и сравнить долю блоков. Если разницы нет, менять три места ради гигиены не стоит; если есть — станет понятно, в какую сторону. До этого замера issue лучше держать открытой, но не в очереди «сделать сейчас».
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#3035
No description provided.