В 2018 Google Maps стал показывать рядом с каждым рестораном «Your Match» - процент от 0 до 100% насколько вероятно, что мне тут понравится.

Так как они базировались на прошлых оценках мест, я стал старательно писать ревью на все кафе, где был, чтобы получить максимально точные рекомендации новых. Для меня это работало классно (особенно для матчей больше 90%), намного информативнее среднего рейтинга заведения. Но в 2023 Google фичу тихо убрал.

В недельных лимитах Claude осталось много токенов, которые скоро сгорали. Так что я попробовал вернуть себе фичу своими силами.

Почему «Your Match» убрали, непонятно, но я нашел на реддите такие отзывы:

My wife is vegetarian (was vegan) and has been visiting and reviewing vegan or vegetarian restaurants for 10+ years. It constantly recommended 95% matches of steakhouses and bbq places to her.

Может алгоритм что-то знал про людей, что они сами не хотели признавать. Но скорее всего просто матчинг плохо работал для большинства. Ведь отзывы пишет микроскопическая доля аудитории, и у алгоритма просто нет данных на которых посчитать матч для большинства.

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

Можно использовать user-based подход: найти людей, чьи оценки коррелируют с вашими, и предложить то, что понравилось им, а вы ещё не пробовали. Или можно item-based: найти объекты, которые обычно нравятся одним и тем же людям. То есть если вам понравились A и B, а кому-то понравились A, B и C - вероятно, вам понравится и C.

Постановка задачи

На входе список мест, которые вам понравились. На выходе - рейтинг остальных мест в заданном регионе. Дальше технические детали. Если вы хотите просто то же самое сделать, то внизу ссылка на репозиторий со скиллом, просто скормите его Claude/Codex.

Две проблемы:

  • Без нормировки на первое место всегда выезжает самое посещаемое место в городе. В моей реализации эта ловушка не решена (не стал заморачиваться)

  • Человек, оставивший 900 отзывов и поставивший пятёрку каждой заправке между домом и аэропортом, не должен весить столько же, сколько тот, у кого 40 содержательных отзывов.

Почему Google Maps не скрейпятся

Логика подсказывает: страница профиля рецензента открыта, отзывы на ней есть, значит открываем в headless-браузере, скроллим и парсим. Но:

1. Программный скролл не подгружает список.

pane.scrollTop = pane.scrollHeight;

Панель реально прокручивается, scrollTop меняется, событие scroll летит, сентинел внизу становится видимым - и ничего не подгружается. Синтетическое колесо тоже не помогает:

pane.dispatchEvent(new WheelEvent('wheel', {deltaY: 1000, bubbles: true}));

У такого события isTrusted: false, и Maps его игнорируют. Загрузку следующей страницы триггерит только настоящее колесо от слоя автоматизации - Input.dispatchMouseEvent через CDP.

2. Список виртуализирован.

Даже когда подгрузка идёт, в DOM одновременно живёт 10-30 карточек, остальные переиспользуются. То есть document.querySelectorAll('[data-review-id]').length после скролла до конца вернёт вам 10 - и это выглядит как «у человека всего 10 отзывов». У него их 249. Собирать надо непрерывно во время прокрутки, с дедупликацией по data-review-id.

Вместе с пунктом 1 это означает: профиль на 300 отзывов требует сотен настоящих скролл-действий, каждое из которых - раунд-трип через инструмент автоматизации.

3. Сжать карточки, чтобы влезало больше, не помогает.

Логичная оптимизация: вкрутить CSS, обрезать каждый отзыв до 26 пикселей, и тогда за один скролл пролетает не три карточки, а пятьдесят.

style.textContent = '[data-review-id]{max-height:26px!important;overflow:hidden!important}';

Контент перестаёт переполнять контейнер - скроллбар исчезает вместе с подгрузкой. Ставим 170 пикселей: скролл есть, подгрузки всё равно нет. Потому что триггер - не «доехали до низа», а именно доверенное событие колеса.

4. Внутренние ручки не переигрываются.

  • POST /maps/rpc/listugcposts отдаёт 403 без валидной сессии: в параметре pb нужен идентификатор, который вы не сгенерируете.

  • Список отзывов автора листается через POST /maps/_/MapsWizUi/data/batchexecute. Тело, конечно, тоже можно снять и продиффать - но токен пагинации там привязан к сессии, и повторное проигрывание с подменённым токеном у меня не завелось. Дешёвый трюк «сравнить два URL и подставить следующий», который иногда спасает с GET-эндпоинтами, здесь недоступен.

  • GET /locationhistory/preview/mas возвращает вкусный JSON с названиями, адресами и координатами по пути j[22][1] - но это поток фотографий, а не отзывов, и звёзд в нём нет.

Отдельная мелочь: чистый браузерный профиль сначала упирается в consent.google.com. После «Reject all» переход на длинный /maps/place/... теряет сегмент data=. Вход по ?cid= переживает это нормально.

Я не утверждаю, что Google Maps в принципе неотскрейпиваемы: сам актор, которым я в итоге пользуюсь, как-то же достаёт эти данные. Лобовой путь «headless-браузер плюс скролл» не работает.

Что заработало

Два платных API, оба pay-as-you-go.

Отзывы seed-места - DataForSEO, эндпоинт business_data/google/reviews. Он тасковый: task_post, потом опрашиваете task_get. Здесь спрятана мина, на которой у меня упал первый прогон: пока задача в работе, task_get возвращает статус 40601 «Task Handed» или 40602 «Task In Queue». Оба ≥ 40000, и наивная проверка

if task["status_code"] >= 40000:
    die("task failed")

убивает каждый запуск. Эти два кода означают «продолжай опрашивать». Обычно всё готово за 30-90 секунд. Параметр depth - потолок отзывов на место: 100 стоит $0.0075, 700 - $0.0525.

История рецензентов - Apify, актор johnvc/google-maps-contributor-reviews-api:

  • maxResultsPerContributor упирается в 200, поднять нельзя.

  • Дефолтный таймаут прогона - 300 секунд. На моих данных актор обрабатывал примерно одного человека за 6 секунд, то есть в дефолт помещалось около полусотни профилей, а дальше прогон умирал со статусом TIMED-OUT - но с наполовину заполненным датасетом. Мой первый запуск на 92 профилях выдал 52. Скорость - замер, а не контракт; контракт здесь только сам дефолт в 300 секунд. Лечится параметром ?timeout=3600 в URL запуска.

  • Когорту надо шардировать по параллельным прогонам. Шесть шардов превратили час последовательной работы в двенадцать минут.

Арифметика cid. Длинный URL места содержит !1s0xAAAA:0xBBBB. Половина после двоеточия - это CID в шестнадцатеричном виде:

cid = int("1a2c52e103ddf424", 16)   # 1885973470347392036
url = f"https://maps.google.com/?cid={cid}"

Проверено на трёх местах: ссылка открывает нужную карточку. data_id в ответах обоих API приходит в том же виде 0xAAA:0xBBB, так что кликабельная ссылка в выгрузке собирается из него.

Чтобы дальнейшие цифры не выглядели взятыми с потолка: я гонял это на двух кафе в качестве seed. Из их отзывов собралась когорта примерно в семьсот тридцать человек, у которых набралось около тридцати двух с половиной тысяч отзывов. На выходе получилось почти четыре тысячи мест-кандидатов. Все суммы ниже относятся к этому прогону.

Про деньги

Актор берёт $0.0015 за каждую строку отзыва, а не за прогон. Тридцать две с половиной тысячи строк - это $48.67

Я, читая прайс актора по API, увидел строку apify-default-dataset-item = 1e-05, обрадовался и посчитал стоимость прогона в тридцать центов. Настоящее событие называется review_scraped, и оно в 150 раз дороже. DataForSEO на этом фоне бесплатен: $0.0075 за сотню отзывов, то есть шесть центов за оба заведения.

Поэтому в скрипте теперь есть предварительная смета и флаг --max-cost: он печатает ожидаемый счёт и отказывается стартовать, пока вы не подтвердите. Хотите дешевле - берите seed поменьше или снижайте --per-contributor: с 200 до 50 счёт падает вчетверо, а теряется в основном старая история самых активных.

Тот же пайплайн с одним seed-местом отвечает на зеркальный вопрос: кто аудитория конкретного заведения и куда она ещё ходит. Владельцу кафе это, наверное, интереснее, чем туристу.

Почему не хватило одного DataForSEO

Первый вопрос, который тут напрашивается: DataForSEO же тоже отдаёт отзывы, зачем второй дорогой провайдер?

business_data/google/reviews принимает заведение и возвращает отзывы этого заведения. На втором шаге мне нужно обратное: взять человека и получить все его отзывы по всем местам. Такой функции у DataForSEO нет: в business_data/google живут только reviews, extended_reviews, hotel_info, hotel_searches, my_business_info, my_business_updates и questions_and_answers. Попытка передать contributor_id в reviews/task_post честно отвечает 40501 Invalid Field: 'keyword' - эндпоинт знает только идентификатор места.

Обидно это потому, что за отзыв DataForSEO дешевле примерно в двадцать раз: $0.000075 против $0.0015 у Apify. Те же 32 629 отзывов там стоили бы $2.45 вместо $48.67.

И есть второй путь: не идти от людей к местам, а прочесать все заведения региона и пересечь ID рецензентов с когортой. business_listings/search перечисляет точки за $0.012 за запрос плюс $0.00036 за результат. Дальше отзывы каждого места: порядка шести тысяч точек питания при глубине 100 отзывов - это примерно $45. Деньги те же.

Но отзывы места публичны независимо от настроек приватности автора. И потолка в 200 отзывов на человека тоже нет. Заодно бесплатно приезжает общий рейтинг каждого места.

В общем, для небольшой когорты дешевле идти от людей, а для большой когорты в компактном регионе стоит посчитать оба варианта.

Скоринг

Простой подсчёт голов - это чарт популярности, а не рекомендация. Итоговая формула:

score(p) = Σ по сторонникам m:  affinity(m) × activity_damp(m) × stars(m,p)

  affinity(m)      = (сколько ваших seed-мест человек тоже одобрил) ** 1.5
  activity_damp(m) = 1 / log2(2 + всего отзывов в профиле m)
  stars(m,p)       = 1.0 за 5★, 0.55 за 4★

affinity даёт вес тем, чей вкус совпал с вашим не в одной точке, а в нескольких. activity_damp - та самая защита от человека с девятьюстами отзывами.

Считать демпфер надо от общего числа отзывов в профиле, а не от того, сколько мы смогли собрать. Иначе человек с 1075 отзывами, обрезанный на потолке в 200, получает такой же вес, как настоящий двухсотник, - то есть ровно тот случай, от которого демпфер и защищает, он и пропускает.

Разница видна сразу. Вот верх реального прогона:

Верх рейтинга: у Pionero сторонников больше, но наверху Whatever
Верх рейтинга: у Pionero сторонников больше, но наверху Whatever

У Pionero 26 сторонников против 23 у Whatever, но Whatever наверху: за него голосует более придирчивая часть когорты. Если у вас взвешенный топ совпадает с топом по головам - когорта слишком однородная, и рекомендации из неё так себе.

Сырой счётчик я оставил отдельной колонкой supporters - если хочется невзвешенного ответа, сортируйте по ней. И ещё: колонка называется relative_score, потому что это не вероятность и не «процент совпадения». Это балл, отнормированный так, чтобы у победителя было 100; между разными прогонами он несравним. Рядом лежит raw_score.

Чего это не покажет

Закрытые профили. Кто скрыл историю отзывов в настройках, отдаёт только отзыв на seed-место.

Потолок в 200 отзывов. Обрезаются самые активные - а именно у них самая плотная местная история.

Смещение в сторону пишущих. Метод измеряет, кто оставляет отзывы, а не кто ходит. Заведения, популярные у пишущей публики, всегда будут выше равных им по качеству, но молчаливых. Это не чинится весами, это свойство данных.

Популярность не отнормирована. Та самая первая ловушка из раздела про CF. Балл аддитивный: место, куда сходили тридцать человек из когорты просто потому, что туда ходят все, обгонит нишевое, которое понравилось пятерым, но по-настоящему похожим на вас. Правильное лечение - считать не сумму, а превышение над базовой популярностью (lift, PMI, байесовское сравнение с фоном). У меня этого нет. Читайте балл как вес свидетельств, а не как «насколько вам понравится».

И это не Netflix. Здесь нет ни матричного разложения, ни обучаемых похожестей, ни валидации на отложенной выборке. Похожесть на вас = сколько ваших seed-мест человек одобрил, и всё. Если взять один-единственный туристический seed, метод честно выродится в «популярное у туристов».

Про правовую сторону

Дополнительные условия Google Maps ограничивают копирование и массовую выгрузку контента, и то, что данные видны публично, само по себе права на переиспользование не даёт. Оценивать допустимость конкретного сценария приходится самостоятельно, и это не юридический совет.

Выгрузка по умолчанию агрегатная: счётчики и баллы по местам, без имён.

Про сами LLM, раз уж они в заголовке. Четыре тупика со скроллом выше нашёл агент, а не я: я смотрел, как он по очереди перебирает очевидное и упирается. Он же неправильно прочитал прайс Apify и попал на $49 вместо ожидаемых сорока центов. Поймала это вторая модель на ревью кода. Так что «вечер вместо недели» это не «агент всё сделал сам», а «агент сделал, другая модель проверила, решения принимал я».

Код

Репозиторий: github.com/kravetssss/taste-match

Оформлено как скилл для Claude Code - то есть там лежит SKILL.md, по которому агент сам понимает, когда это звать и в каком порядке дёргать скрипты. Но это обычный markdown, а скрипты - питон на стандартной библиотеке без зависимостей, так что запускается из любой обвязки и просто руками.

cp .env.example .env          # два аккаунта: DataForSEO и Apify
python3 scripts/doctor.py     # ключи живы, баланс есть?

python3 scripts/fetch_seed_reviews.py \
        --seeds "<длинный maps-url>" "<ещё один>" --out cache/seeds.json

python3 scripts/fetch_histories.py \
        --cohort cache/seeds.json --out cache/histories.json

python3 scripts/rank.py --cohort cache/seeds.json --histories cache/histories.json \
        --region asturias --food-only --min-supporters 2 --out out/ranking.csv

Каждый шаг пишет файл, который читает следующий: упало на середине - продолжаете со следующей команды, а не с начала. Регионы есть пресетами (asturias, madrid, barcelona, lisbon, berlin), либо --bbox lat_min,lat_max,lng_min,lng_max. Добавить свой - четыре числа в common.py.


Ещё пару лет назад такая работа заняла бы неделю, сейчас это час вечером. Я в своём телеграм-канале t.me/alexey_kravetsss пишу про то, как применяю LLM в повседневной работе и бизнесе. Если пост был полезен - буду рад видеть.