Yandex Maps Scraper: как я написал расширение для Chrome, которое парсит Яндекс Карты без серверов, прокси и боли
Предыстория
Если вы когда‑нибудь собирали базу организаций из Яндекс Карт, вы знаете эту боль. Вариантов немного: платные 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-buckets, yandex_gid, search_add_snippet... Гарантировать, что мой минимальный набор параметров всегда прокатит, нельзя.
Решение: расширение перехватывает настоящий запрос, который делает сам Яндекс при открытии карточки организации, и сохраняет его URL как шаблон. При сборе товаров я клонирую все параметры шаблона и подменяю только четыре: business_oid, csrfToken, sessionId и 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