Дисклеймер. Автоматический сбор данных с 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. Порядок предпочтения примерно такой:
role/aria-label— семантика, ломается реже всего;data-*с читаемым значением;структура (потомок конкретного контейнера);
сгенерированный класс — только если больше нечего.
И заведите отдельный тест "селекторы ещё живы", который гоняется раз в неделю. Иначе про поломку вы узнаете из пустого выходного файла.
Грабля 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 оказались устойчивыми у вас — и какие мусорные домены стоит добавить в блок-лист.

