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

Зачем вообще

Задача звучала просто: получить список компаний определённого профиля в конкретном городе — с сайтом и способом связаться. Не телефоном, а email / Telegram / WhatsApp.

Итоговый скрипт — примерно 400 строк: Playwright для Maps, requests + BeautifulSoup для сайтов, регексы для контактов, txt на выходе. Ниже — не туториал "как повторить", а список мест, где всё ломается.

Вот весь конвейер целиком, с отмеченными точками отказа — дальше по статье разберём каждую:

Грабля 0: регион в URL почти ничего не решает

Первая наивная версия строила URL так:

url = f"https://www.google.com/maps/search/{quote(query)}/@{lat},{lon},{zoom}z"

Логика понятная: центрируем карту на нужном городе — получаем результаты из этого города. На практике @lat,lon,zoom — это подсказка вьюпорта, а не фильтр. Google охотно подмешивает в выдачу места из соседних регионов, а иногда просто игнорирует координаты и отдаёт что-то по своему усмотрению.

Что реально сужает выдачу — регион в тексте запроса:

full_query = f"{query} {region}".strip() if region else query
url = f"https://www.google.com/maps/search/{quote(full\\_query)}"

В итоге я оставил оба механизма — текст запроса как основной фильтр, координаты как дополнительный намёк:

def build_maps_url(query: str, region: str = "") -> str:
    full_query = f"{query} {region}".strip() if region else query
    url = f"https://www.google.com/maps/search/{urllib.parse.quote(full\\_query)}"

    if region:
        coords = geocode_region(region)
        if coords:
            lat, lon, zoom = coords
            url += f"/@{lat},{lon},{zoom}z"

    return url

Порядок важности именно такой: без региона в тексте координаты не спасают, без координат текст работает почти так же хорошо.

Грабля 1: откуда взять zoom

Раз координаты всё-таки добавляем, их надо где-то взять. Геокодер — Nominatim (OpenStreetMap), бесплатный, с обязательным User-Agent и просьбой не долбить чаще раза в секунду:

resp = requests.get(
    "https://nominatim.openstreetmap.org/search",
    params={"q": region, "format": "json", "limit": 1},
    headers={"User-Agent": HEADERS["User-Agent"]},
    timeout=REQUEST_TIMEOUT,
)

С lat/lon всё очевидно, а вот zoom Nominatim не отдаёт. Зато отдаёт boundingbox — четыре числа: south, north, west, east.

Дальше — школьная геометрия. Уровень зума в тайловых картах устроен так, что на зуме z весь мир (360°) разбит на 2^z тайлов. Значит, чтобы регион шириной Δ° поместился примерно в один экран:

def bbox_to_zoom(bbox) -> int:
    try:
        south, north, west, east = map(float, bbox)
    except (TypeError, ValueError):
        return 6
    max_diff = max(abs(north - south), abs(east - west))
    if max_diff <= 0:
        return 12
    zoom = math.log2(360 / max_diff)
    return int(max(3, min(15, round(zoom))))

Оценка грубая — она игнорирует и проекцию Меркатора, и соотношение сторон окна. Но для задачи "показать город целиком, а не одну улицу" этого хватает: страна получает зум 4–6, крупный город — 10–12. Клампы по краям нужны, потому что на вырожденных bbox (точка на карте) log2 уезжает в бесконечность.

Грабля 2: a.hfpxzc — самый хрупкий селектор в проекте

Карточки в списке результатов Google Maps ищутся так:

cards = feed.locator('a.hfpxzc')

hfpxzc — сгенерированный класс. Он не значит ничего, не документирован и меняется при очередном релизе фронтенда Google. Гуглите его — найдёте десяток статей и репозиториев со скраперами, и в половине из них он уже другой.

Практический вывод: обфусцированный класс — это индикатор, что вы взяли неустойчивый якорь. В том же коде есть селекторы, которые живут годами, потому что завязаны на семантику, а не на стили:

page.wait_for_selector('div[role="feed"]', timeout=20000)   # ARIA-роль
page.locator('a[data-item-id="authority"]').first           # data-атрибут = ссылка на сайт
page.locator('div[role="main"]')                            # ARIA-роль

role="feed", role="main" — часть ARIA-разметки, её ломать дороже, чем поменять класс. data-item-id="authority" — внутренний, но осмысленный идентификатор ("сайт организации"), у него тоже нет причин меняться при рестайлинге.

Правило, которое я бы вынес отдельно: ищите якорь, у которого есть смысл, а не тот, который первым предложил DevTools. Порядок предпочтения примерно такой:

  1. role / aria-label — семантика, ломается реже всего;

  2. data-* с читаемым значением;

  3. структура (потомок конкретного контейнера);

  4. сгенерированный класс — только если больше нечего.

И заведите отдельный тест "селекторы ещё живы", который гоняется раз в неделю. Иначе про поломку вы узнаете из пустого выходного файла.

Грабля 3: как понять, что лента кончилась

Список результатов подгружается скроллом. Наивные варианты — "проскроллить 20 раз" или "скроллить, пока не наберём N" — оба плохие: в первом случае вы недобираете на длинных выдачах, во втором виснете навсегда на коротких, где мест физически меньше, чем N.

Рабочий вариант — считать не итерации, а "раунды без прогресса":

last_count = 0
stagnant_rounds = 0
while True:
    cards = feed.locator('a.hfpxzc')
    count = cards.count()
    if count >= max_results:
        break
    if count == last_count:
        stagnant_rounds += 1
        if stagnant_rounds >= 5:
            break
    else:
        stagnant_rounds = 0
    last_count = count

    feed.evaluate("el => el.scrollBy(0, el.scrollHeight)")
    time.sleep(1.2)

Пять пустых раундов подряд — значит, дошли до конца. Порог именно 5, а не 1–2, потому что подгрузка иногда тормозит на секунду-другую, и один пустой раунд ещё ничего не значит.

Здесь же — первая честная слабость кода: time.sleep(1.2) вместо ожидания события. Правильнее ждать увеличения количества элементов с таймаутом:

# вместо time.sleep(1.2)
try:
    page.wait_for_function(
        "([el, n]) => el.querySelectorAll('a.hfpxzc').length > n",
        arg=[feed.element_handle(), count],
        timeout=3000,
    )
except PWTimeout:
    pass  # прогресса нет — это и есть stagnant round

Так медленный интернет не превращается в потерянные результаты, а быстрый — не заставляет ждать лишнюю секунду на каждом шаге.

Грабля 4: клик по карточке и фиксированная пауза

Чтобы получить сайт организации, надо открыть её панель:

card.click()
page.wait_for_timeout(1800)

Это работает ровно до первого лага. wait_for_timeout — тайминговое ожидание, самый надёжный способ сделать скрапер флаки. Правильно — ждать появления самой панели:

card.click()
page.wait_for_selector('div[role="main"] h1', timeout=8000)

Отдельная тонкость: панель переиспользуется между кликами. Если новая организация не успела отрисоваться, вы прочитаете данные предыдущей — и не заметите этого, потому что HTML валидный, поля заполнены, ошибок нет. Самый неприятный класс багов: тихий, правдоподобный мусор в данных.

Лечится сравнением заголовка панели с aria-label карточки — если не совпало, ждём дальше.

Грабля 5: email-регекс собирает не email

Регекс простой и вроде бы корректный:

EMAIL_RE = re.compile(r"[a-zA-Z0-9.+_-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")

А потом смотришь на результаты и видишь logo@2x.png, sprite@2x.jpg, abc123@sentry.io, wix@wixpress.com, noreply@example.com.

Разбор, откуда что берётся:

  • @2x.png / @2x.jpg — retina-ассеты. Формально подходят под регекс: слева от собаки текст, справа "домен" с "TLD" png. Классика.

  • @sentry.io — DSN системы мониторинга, вшитый в JS-бандл.

  • @wixpress.com, @shopify.com — служебные адреса платформы, а не компании.

  • example.com, yourdomain.com — недоделанные шаблоны, которые так и уехали в прод.

Отсюда блок-лист:

BAD_EMAIL_SUBSTRINGS = (
    "example.com", "sentry.io", "wixpress.com", "yourdomain",
    "@2x", "png", "jpg", "jpeg", "svg", "gif",
)

Список открытый, его придётся дополнять под каждую новую нишу сайтов. .webp, .avif, @3x, @sentry-cdn, godaddy, hostinger — сразу кандидаты.

Грабля 6: sorted(emails)[0] — это баг, а не выбор

Вот эта строка выглядела безобидно ровно до просмотра результатов:

return sorted(emails)[0] if emails else ""

Она берёт алфавитно первый адрес. То есть на сайте, где есть info@, sales@ и abuse@, победит abuse@. admin@ выиграет у info@. Никакой логики в этом нет — просто sorted() оказался под рукой.

Нужна не сортировка, а приоритеты:

PREFERRED_LOCAL_PARTS = ("info", "contact", "hello", "office", "sales", "mail")
DEPRIORITIZED = ("noreply", "no-reply", "abuse", "postmaster", "webmaster", "privacy")

def pick_email(emails: set[str], site_domain: str = "") -> str:
    def score(e: str) -> tuple:
        local, _, domain = e.lower().partition("@")
        return (
            0 if local in PREFERRED_LOCAL_PARTS else 1,      # предпочтительный ящик
            0 if site_domain and site_domain in domain else 1,  # адрес на домене сайта
            1 if local.startswith(DEPRIORITIZED) else 0,     # мусорные — в конец
            len(e),                                          # короткий обычно "главнее"
        )
    return min(emails, key=score) if emails else ""

Второй критерий отдельно полезен: почта на домене самого сайта почти всегда правильнее, чем случайный gmail разработчика из футера.

Грабля 7: перебор /contacts вместо чтения ссылок

Если в панели Maps контактов не нашлось, скрипт идёт на сайт. И делает это перебором:

CONTACT_PAGES = [
    "/contacts", "/contacts/", "/contact", "/contact/",
    "/about", "/about/", "/about-us", "/o-nas", "/o-kompanii",
    "/kontakty", "/company", "/ru/contacts", "/en/contact",
]

Четырнадцать запросов вслепую на каждый сайт. Из них попадает в лучшем случае один, остальные тринадцать — 404 и потраченные секунды. А если у сайта контакты лежат на /svyaz или /impressum — не попадает ни один.

Правильнее один раз загрузить главную и достать ссылки, которые уже там есть:

CONTACT_HINTS = ("контакт", "contact", "kontakt", "связь", "about", "о нас", "impressum")

def find_contact_links(base: str, html: str, limit: int = 5) -> list[str]:
    soup = BeautifulSoup(html, "html.parser")
    found = []
    for a in soup.find_all("a", href=True):
        haystack = (a.get_text(" ", strip=True) + " " + a["href"]).lower()
        if any(h in haystack for h in CONTACT_HINTS):
            url = urllib.parse.urljoin(base, a["href"])
            if url.startswith(base) and url not in found:
                found.append(url)
    return found[:limit]

Одна загрузка главной вместо четырнадцати, и покрытие выше — потому что ищем то, что сайт сам про себя говорит, а не то, что мы угадали.

Мелочь, но важная: перебор с CONTACT_PAGES стоит оставить как фолбэк на случай, если на главной ссылок не нашлось (например, весь сайт — SPA, и ссылки рисует JS).

Что осталось нерешённым

Честный список, чтобы не выглядело, будто всё под контролем:

Всё синхронно. Один браузер, один поток, клик → пауза → обход сайта → следующий. Сотня карточек превращается в десятки минут. Обход сайтов (requests) отлично параллелится в пуле потоков — это самый дешёвый способ ускориться раза в 5, и он в текущей версии не сделан.

Нет ретраев. requests.RequestException → возвращаем пустую строку и идём дальше. Один сетевой всхлип — и контакты компании потеряны навсегда. Нужен urllib3.Retry с бэкоффом хотя бы на 502/503/504.

Дедупликация по имени ломает счётчик. Вот здесь:

if name in seen_names:
    continue
seen_names.add(name)

total посчитан заранее как min(cards.count(), max_results), а continue съедает итерацию. Пятнадцать дублей в выдаче — и вместо ста мест вы получите восемьдесят пять, молча. Нужен либо цикл while len(results) < max_results, либо честный запас по индексу.

Капча и рейт-лимит. С одного IP при аккуратной частоте я на них не наткнулся, но защиты от них в коде нет никакой — при появлении капчи скрипт просто соберёт пустую страницу и радостно отчитается о нуле результатов.

Нет метрик. Тот случай, когда пишешь статью и понимаешь, чего не хватает: скрипт печатает лог, но не считает ничего. Полезно было бы знать долю мест с сайтом, долю сайтов, где нашёлся email, и сколько адресов отсеял блок-лист — это ровно те числа, по которым видно, стоит ли вообще игра свеч.

Про легальность — без этого нельзя

Три разных вопроса, которые часто путают в одну кучу:

Условия использования Google. Автоматизированный сбор данных с сервисов Google их ToS прямо запрещают. Технических барьеров вы можете не встретить, но это не разрешение — это отсутствие препятствия. Официальный путь — Places API, у него свои ограничения на кэширование и отображение данных, и email там, повторюсь, нет.

Персональные данные. Собранный email вида ivan.petrov@company.com — это персональные данные со всеми вытекающими: в РФ — 152-ФЗ (включая требование о локализации баз), в ЕС и для клиентов из ЕС — GDPR. "Адрес был опубликован на сайте компании" не является основанием для обработки сам по себе. Корпоративный info@ в этом смысле безопаснее именного ящика, но серую зону не отменяет.

Что вы с этим делаете дальше. Собрать список — одно, разослать по нему — совсем другое, и регулируется отдельно. Холодная рассылка без согласия получателя — это ст. 18 ФЗ "О рекламе" в РФ, CAN-SPAM в США, GDPR + ePrivacy в ЕС. Штрафы там существенно неприятнее, чем за сам сбор.

Мой личный вывод: как инженерное упражнение — интересно и полезно, вопросов ноль. Как основа коммерческого продукта — нужен юрист раньше, чем программист.

Итого

Скрипт на 400 строк, из которых собственно "логика" — строк пятьдесят. Всё остальное — обвязка против реальности: обфусцированные классы, ленивая подгрузка, retina-ассеты, притворяющиеся адресами, и сайты, на которых контакты лежат где угодно, кроме /contacts.

Что я бы сформулировал как главный вывод: в скрапинге ломается не то, что сложно написать, а то, что просто. Регекс для email — четыре строки и половина мусора на выходе. Выбор адреса из множества — одна строка и systematic bias. Ожидание загрузки — одна строка и флаки-тесты. Сложные части (геокодинг, детект конца ленты) как раз работают, потому что над ними пришлось подумать.

Буду рад в комментариях услышать, какие ещё якоря в Maps оказались устойчивыми у вас — и какие мусорные домены стоит добавить в блок-лист.