Предыстория

Если вы когда‑нибудь собирали базу организаций из Яндекс Карт, вы знаете эту боль. Вариантов немного: платные SaaS‑сервисы с подпиской, самописные скрипты на Python с Selenium (которые ломаются при каждом чихе фронтендеров Яндекса), либо ручное копипащество — отдельный вид извращения.

Я пошёл другим путём: расширение для Chrome, которое работает прямо в браузере, перехватывает те же самые запросы, что делает сам Яндекс, и складывает всё в локальную базу. Без серверов, без прокси, без подписок. Данные не покидают браузер вообще — это важно и для приватности, и для прохождения модерации Chrome Web Store.

В этой статье расскажу, что умеет расширение (включая свежие фичи: сбор товаров/услуг и режим «Собрать всё»), как оно устроено внутри, и с какими граблями я столкнулся при работе с API Яндекс Карт.

Что умеет расширение

Пять режимов сбора, работающих последовательно поверх одной базы:

1. Организации. Базовый режим. Автоскролл выдачи с регулируемой скоростью, перехват сетевых ответов /maps/api/search, дополнительное извлечение из state‑view скриптов в DOM. Дедупликация по ID организации. По каждой организации: название, адрес, город, регион, координаты, телефоны, сайты, соцсети, режим работы, рейтинг, количество оценок и отзывов.

2. Отзывы. Обход сохранённой базы организаций, настраиваемый лимит на карточку, сортировка (по умолчанию / новые / негативные / позитивные), лайки/дизлайки, ответы бизнеса, фото из отзывов.

3. Медиа (фото и видео). Ссылки на изображения и ролики из карточек, с дедупликацией по ID/URL.

4. Новости (посты). Публикации из карточек организаций с текстом, датой и фото. Тут была интересная особенность — пагинация через offset, о ней ниже.

5. Товары и услуги. Меню ресторанов, прайсы копицентров, абонементы боксёрских клубов — всё, что бизнес завёл в карточку: название, описание, цена, валюта, объём, фото, категория, ссылка на источник.

Плюс отдельная фича — сбор email: расширение обходит сайты организаций из базы и выдёргивает почтовые адреса из HTML. С группировкой по доменам и вежливыми задержками между запросами, чтобы не долбить серверы.

Нововведение № 1: «Собрать всё (без лимита)»

Раньше каждый режим был ограничен лимитом на организацию (50 отзывов, 30 медиа и так далее). Пользователи просили: «хочу ВСЁ». Теперь в каждой карточке режима есть чекбокс, который блокирует поле лимита и отключает ограничения.

Казалось бы, triviaльно — убери slice и всё. Но каждый режим пагинирует по‑своему, и логика «собрать всё» для каждого своя:

  • Отзывы: игнорируем result.length >= limit и крутим страницы до page >= totalPages;

  • Медиа: просто не делаем .slice(0, limit) — там один запрос;

  • Новости: убираем allItems.length < limit из условия while, крутим до allItems.length >= total;

  • Товары: API возвращает всё одним ответом, пагинации нет вообще — просто не режем массив.

Нововведение № 2: товары и услуги

Вот тут начинается самое интересное. Расскажу, как я продебажил эту фичу, потому что это классическая история про работу с чужим недокументированным API.

Поиск эндпоинта

Товары живут в том же эндпоинте, что и поиск организаций — /maps/api/search, но с параметром business_oid=<id> вместо text=<query>. Ответ содержит topObjects.categories[].categoryItems[] (популярное) и fullObjects.categories[].categoryItems[] (полный список).

Баг № 1: вампирский CSRF‑токен

Первая версия молча ничего не собирала. Оказалось: когда CSRF‑токен из HTML страницы протухает, API не возвращает 403, как любой нормальный сервер. Он возвращает HTTP 200 с телом {"csrfToken": "новый_токен"}.

Мой парсер получал такой ответ, не находил товаров, честно возвращал пустой массив, а движок честно помечал организацию как «товары собраны» (с нулём товаров). При повторном запуске все организации уже были «обработаны» — сбор мгновенно «завершался» успехом. Идеальный шторм молчаливых отказов.

Лечение — распознавание такого ответа и автоматический retry со свежим токеном:

for (let attempt = 0; attempt < 2; attempt += 1) {
  const tokenRefresh = payload && typeof payload === "object" && !Array.isArray(payload)
    && typeof payload.csrfToken === "string"
    && !payload.topObjects && !payload.fullObjects && !payload.data && !payload.results
    && Object.keys(payload).length <= 3;
  if (!tokenRefresh) break;
  csrfToken = payload.csrfToken;
  response = await fetchJson(buildUrl(csrfToken), window.location.href);
  payload = response.payload;
}

Плюс одноразовая миграция, сбрасывающая ложные пометки «собрано» из отравленной базы.

Баг № 2: HTTP 400 из‑за хеша s

Каждый запрос к API Яндекс Карт подписан параметром s — это djb2-хеш отсортированных query‑параметров. Я решил «оптимизировать» и вычислять хеш только по части параметров (как мне показалось, делает Яндекс — в реальном URL s стоит не в конце). Получил HTTP 400 на все запросы.

Разгадка оказалась проще: параметры в URL просто отсортированы по алфавиту, и s как строка попадает между rearr и search_add_snippet. Хешируется всё, кроме самого s.

Страховка: кража шаблона запроса у самого Яндекса

Финальный уровень паранойи. Реальный запрос товаров содержит кучу параметров: experimental[0]rearr[0..2]test-bucketsyandex_gidsearch_add_snippet... Гарантировать, что мой минимальный набор параметров всегда прокатит, нельзя.

Решение: расширение перехватывает настоящий запрос, который делает сам Яндекс при открытии карточки организации, и сохраняет его URL как шаблон. При сборе товаров я клонирую все параметры шаблона и подменяю только четыре: business_oidcsrfTokensessionId и s. Сервер получает запрос, неотличимый от родного.

Умная буферизация

Расширение перехватывает ответы API в фоне ещё до нажатия «Начать сбор». Полистали выдачу, почитали карточки — а данные уже в буфере (до 50 пакетов, FIFO). При старте сбора появляется inline‑плашка: «Найдено N организаций в буфере» с выбором — забрать их или начать с чистого листа.

Буфер живёт только в памяти вкладки, на диск не пишется, при перезагрузке страницы сбрасывается. Отдельный переключатель позволяет выключить перехват полностью (с мгновенной очисткой) — для тех, кто работает часами и бережёт RAM.

Архитектура: как это устроено под капотом

popup.js / sidepanel (UI)
    ↕ chrome.storage + runtime messages
maps-content.js (content script, изолированный мир)
    ↕ window.postMessage (bridge)
maps-injected.js (page world: перехват fetch/XHR)
    ↕ chrome.runtime messages
background.js (service worker: датасет, экспорт, лицензии)

Зачем injected‑скрипт в page world: перехват window.fetch и XMLHttpRequest возможен только в контексте самой страницы, content scripts живут в изолированном мире. Поэтому content script встраивает maps-injected.js через <script src=chrome.runtime.getURL(...)> и общается с ним через postMessage.

Несколько неочевидных моментов:

  • Чтобы наш собственный fetch не перехватил сам себя, все активные запросы помечаются заголовком x-ymds-direct-request: 1, и перехватчик их игнорирует.

  • Дедупликация в датасете — через Map‑индексы по бизнес‑ключам (у товаров, кстати, нет ID от API — ключ пришлось делать из title + businessId).

  • Повторный запуск любого режима пропускает уже обработанные организации — по полю <mode>CollectionCompletedAt.

  • Лицензия: токен подписан Ed25519 на стороне сайта‑генератора (tlimit.ru), расширение проверяет подпись локально публичным ключом. Проверка привязана к времени ответа сервера Яндекса, а не к системным часам — их можно перевести, а ответ сервера подделать сложнее.

    Сейчас лицензия нужна только чтобы смотреть статистику, так как расширение не отправляет никакие данные.

Экспорт

Четыре формата на выбор:

  • XLSX — листы «Организации», «Отзывы», «Медиа», «Новости», «Товары» с русскими заголовками;

  • CSV — отдельный файл на каждый тип данных;

  • JSON — нормализованная структура;

  • RAW JSON — сырые ответы API без обработки (для разработчиков; включается отдельным тумблером, чтобы не раздувать хранилище).

Можно выгружать выборочно: только организации, только отзывы, только товары и так далее

Плюшки интерфейса

Popup и Side Bar (боковая панель Chrome), живой статус‑бар, счётчики собранных данных, тултипы с пояснениями к каждой настройке. Отдельная радость — d‑pad навигации по карте: стрелками (кнопками или с клавиатуры) можно панорамировать карту прямо из панели расширения, с настраиваемым шагом в пикселях. Реализовано через синтетические PointerEvent‑ы.

Юридическая оговорка

Парсинг выполняется только из текущей открытой страницы Яндекс Карт, данные собираются для легитимных целей (аналитика, лидогенерация, исследования). Расширение не обходит защиты, не ломает авторизацию и не превышает то, что браузер и так получает от сервера. Полнота данных зависит от того, что реально есть в карточке организации.

Итог

Расширение Яндекс парсер бесплатное, лицензионный ключ (нужен только для сохранения данных) — тоже бесплатный, генерируется на tlimit.ru за пару секунд.

Ссылки: парсер · tlimit.ru (получение ключа) · Telegram: @azuncion · Почта: yandexparser@inbox.ru