Локальные бизнес-листинги являются одним из наиболее полезных публичных наборов данных в интернете. Поиск по каталогу вроде "сантехники в Остине" или "рестораны в Денвере" возвращает структурированную сетку компаний, каждая из которых содержит название, адрес, номер телефона, категорию и звёздный рейтинг с количеством отзывов. Отделы продаж, маркетинга и исследований извлекают эти данные для формирования списков потенциальных клиентов по городам, обогащения CRM-записей проверенными контактными данными и составления карты плотности конкурентов по рынкам. Ручной сбор не масштабируется за пределы нескольких результатов, поэтому такую работу следует автоматизировать.
В этом руководстве показано, как парсить локальные бизнес-листинги на Python надёжным способом. Вы создадите небольшой работающий парсер, который получает отрендеренную страницу результатов каталога через Crawling API, парсит каждую карточку листинга с BeautifulSoup и извлекает чистую запись на каждый бизнес: название, адрес, телефон, категория, рейтинг, количество отзывов и веб-сайт. Весь разбор ограничен публичной бизнес-информацией, а раздел о легальности в конце не является формальностью, поэтому прочитайте его перед запуском на реальных объёмах.
Что вы создадите
Python-скрипт, который принимает категорию и город, получает отрендеренную страницу листингов через Crawling API и извлекает структурированную запись по каждому бизнесу. В качестве примера используем поиск по Yellow Pages и извлекаем из каждой карточки результата следующие поля:
- Name название бизнеса, например "Austin Plumbing Co".
- Address адрес, указанный на карточке.
- Phone публично указанный номер телефона бизнеса.
- Category основная категория, под которой каталог относит данный бизнес.
- Rating средний звёздный рейтинг, если у бизнеса он есть.
- Reviews количество отзывов за этим рейтингом.
- Website ссылка на собственный сайт бизнеса, если указана.
Почему обычный запрос не работает на сайтах листингов
Сбор листингов в масштабе, это не просто отправка запросов и парсинг HTML; есть две причины, которые усугубляются по мере роста объёма запросов.
Во-первых, результаты зависят от геолокации. Голый запрос "сантехники" возвращает совершенно разные компании в зависимости от того, поступает ли запрос из Остина, Денвера или Феникса. Для получения согласованного набора данных необходимо контролировать как запрос (включать город), так и местоположение запроса (геотаргетинг), иначе результаты будут непредсказуемо меняться от запуска к запуску.
Во-вторых, современные каталоги защищаются от автоматизированного трафика и всё чаще рендерят листинги на стороне клиента. Многие платформы возвращают тонкую HTML-оболочку и затем добавляют реальные карточки компаний с помощью JavaScript, поэтому стандартный HTTP-запрос возвращает страницу без листингов. При превышении нескольких запросов платформа также начинает применять IP-блокировки, CAPTCHA-проверки и ограничение скорости. Поэтому рабочий парсер должен решать две задачи в одном запросе: браузер, который рендерит страницу, и IP-адрес, который платформа воспринимает как настоящего посетителя. Можно собрать такое решение самостоятельно из headless-браузера и пула ротирующихся резидентных прокси, но поддержание их в рабочем состоянии занимает большую часть времени. Crawling API объединяет оба компонента в одном вызове: вы передаёте URL, API рендерит страницу за доверенным резидентным IP и возвращает готовый HTML для парсинга.
Crawlbase предлагает два типа токенов. Обычный токен получает статический HTML; JavaScript (JS) токен сначала рендерит страницу в реальном браузере. Статические страницы каталогов нормально парсятся с обычным токеном, но платформы, добавляющие листинги на стороне клиента (Google Maps, Yelp), требуют JS-токена. Подбирайте токен под страницу: используйте обычный для простых страниц и JS-токен для динамических.
Предварительные требования
Перед написанием кода необходимо выполнить несколько условий. Это не займёт много времени.
Базовые знания Python. Вы должны уметь писать и запускать Python-скрипты, а также устанавливать пакеты через pip. Если вы новичок в BeautifulSoup, введение в использование BeautifulSoup в Python охватывает основы работы с селекторами, на которые опирается этот туториал.
Python 3.8 или выше. Проверьте версию командой python --version. Если она не установлена, скачайте её с python.org или через дистрибутив вроде Anaconda.
Аккаунт Crawlbase и токен. Зарегистрируйтесь, откройте панель управления и скопируйте токен со страницы документации аккаунта. Относитесь к токену как к паролю: он аутентифицирует ваши запросы, поэтому не добавляйте его в систему контроля версий.
Настройка проекта
Создайте виртуальное окружение, чтобы зависимости проекта были изолированы, затем установите две библиотеки, необходимые парсеру.
python --version python -m venv listings_env source listings_env/bin/activate pip install crawlbase beautifulsoup4
В Windows активируйте окружение командой listings_env\Scripts\activate вместо строки source. Две зависимости выполняют основную работу: crawlbase является официальным клиентом для Crawling API, а beautifulsoup4 парсит возвращаемый HTML, позволяя извлекать каждое поле из карточки листинга по CSS-селектору.
Понимание структуры страницы листингов
Страница результатов каталога представляет собой столбец карточек листингов, по одной на каждый бизнес. Каждая карточка содержит один и тот же набор полей: название, адрес, номер телефона, категорию, рейтинг с количеством отзывов. На карточке присутствует ссылка "посетить веб-сайт", если бизнес её предоставил. Под столбцом расположены элементы управления пагинацией для навигации по дополнительным страницам результатов того же запроса.
Перед написанием селекторов откройте страницу результатов в браузере, нажмите правой кнопкой мыши на карточку листинга и выберите "Inspect" (Просмотр кода). На Yellow Pages каждый результат обёрнут в контейнер div.result, где название находится в a.business-name, адрес в div.street-address и div.locality, телефон в div.phones, основная категория в div.categories, рейтинг раскрывается через класс в div.result-rating, количество отзывов в span.count, а веб-сайт в a.track-visit-website. Именно эти селекторы вы и используете.
Шаг 1: Получение отрендеренной страницы листингов
Начните с получения готовой страницы. Импортируйте класс CrawlingAPI, инициализируйте его токеном, создайте URL поиска из категории и города и выполните запрос. Проверка кода статуса перед парсингом позволяет выявлять ошибки явно, а не незаметно.
from urllib.parse import quote_plus from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def build_url(category, city): terms = quote_plus(category) geo = quote_plus(city) return f"https://www.yellowpages.com/search?search_terms={terms}&geo_location_terms={geo}" def crawl(page_url): options = {"country": "US"} response = api.get(page_url, options) if response["status_code"] == 200: return response["body"].decode("latin1") print(f"Request failed: {response['status_code']}") return None if __name__ == "__main__": url = build_url("plumbers", "Austin, TX") html = crawl(url) print(html[:500] if html else "No HTML returned")
Вспомогательная функция build_url собирает URL поиска из двух параметров запроса: search_terms для категории и geo_location_terms для города, оба URL-кодированы, чтобы пробелы и запятые сохранялись при передаче. Опция country закрепляет запрос за американским IP, что является частью геотаргетинга: запрос "сантехники в Остине" возвращает адекватные результаты только тогда, когда запрос также выглядит как поступивший с нужного рынка. Тело декодируется как latin1, поскольку страницы каталогов могут содержать символы, на которых строгое декодирование UTF-8 ломается. Запустите скрипт, и вы должны увидеть реальную разметку листинга, а не пустую оболочку или страницу блокировки. Это подтверждает работу получения данных до написания первого селектора.
Этот единственный вызов api.get выполнил ту часть, которая обычно занимает неделю: он получил страницу листингов за доверенным резидентным IP, закреплённым за нужной страной, поэтому каталог вернул реальные карточки вместо страницы блокировки. Crawling API берёт на себя рендеринг, ротацию IP и геотаргетинг, избавляя вас от необходимости самостоятельно управлять флотом headless-браузеров и пулом прокси. Начните с одного города на бесплатном тарифе.
Шаг 2: Парсинг карточек листинга с BeautifulSoup
Получив HTML, загрузите его в BeautifulSoup, найдите каждую карточку листинга и извлеките каждое поле по его селектору. Каждый бизнес обёрнут в контейнер div.result, где название, адрес, телефон, категория, рейтинг, количество отзывов и веб-сайт каждый раскрываются через собственный класс. Оборачивайте каждую карточку в try/except, чтобы одна повреждённая карточка не прерывала весь запуск.
from bs4 import BeautifulSoup def text_of(card, selector): el = card.select_one(selector) return el.get_text(strip=True) if el else None def parse_rating(card): el = card.select_one("div.result-rating") if not el: return None words = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5} rating = None for cls in el.get("class", []): base = cls.replace("-half", "") if base in words: rating = words[base] + (0.5 if "-half" in cls else 0) return rating def scrape_results(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select("div.result") results = [] for card in cards: try: website = card.select_one("a.track-visit-website") results.append({ "name": text_of(card, "a.business-name"), "address": text_of(card, "div.street-address"), "locality": text_of(card, "div.locality"), "phone": text_of(card, "div.phones"), "category": text_of(card, "div.categories"), "rating": parse_rating(card), "reviews": text_of(card, "span.count"), "website": website["href"] if website else None, }) except Exception as e: print(f"Skipped a card: {e}") return results
Вспомогательная функция text_of запрашивает один элемент внутри одной карточки и возвращает None при его отсутствии, вместо того чтобы выбрасывать исключение при вызове .get_text() на несуществующем элементе. Это делает извлечение устойчивым при отсутствии поля, что часто встречается, поскольку не каждый листинг содержит веб-сайт или рейтинг. Вспомогательная функция parse_rating считывает звёздный рейтинг из списка классов в div.result-rating, где каталог записывает оценку словами, такими как four или four half, и преобразует её в число. Количество отзывов берётся из span.count, а веб-сайт считывается из атрибута href якорного элемента, а не из его текста. Названия, адреса, телефоны и категории каждое сопоставляется со своим селектором.
Имена классов каталогов изменяются без предупреждения. Рассматривайте приведённые выше селекторы как начальный шаблон, а не как неизменный контракт. Когда поле возвращается как None для каждой карточки, заново проинспектируйте живую страницу результатов в инструментах разработчика браузера и обновите селектор. Периодическое обслуживание селекторов нормально для любого рабочего парсера, это не признак неисправности.
Шаг 3: Всё вместе и экспорт
Теперь соедините запрос и парсинг в один работающий скрипт и запишите записи в JSON и CSV, чтобы они сразу попадали в таблицу или базу данных. Получите отрендеренную страницу, передайте её парсеру, затем выгрузите структурированные записи.
import csv import json from urllib.parse import quote_plus from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def build_url(category, city): terms = quote_plus(category) geo = quote_plus(city) return f"https://www.yellowpages.com/search?search_terms={terms}&geo_location_terms={geo}" def crawl(page_url): response = api.get(page_url, {"country": "US"}) if response["status_code"] == 200: return response["body"].decode("latin1") print(f"Request failed: {response['status_code']}") return None def text_of(card, selector): el = card.select_one(selector) return el.get_text(strip=True) if el else None def parse_rating(card): el = card.select_one("div.result-rating") if not el: return None words = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5} rating = None for cls in el.get("class", []): base = cls.replace("-half", "") if base in words: rating = words[base] + (0.5 if "-half" in cls else 0) return rating def scrape_results(html): soup = BeautifulSoup(html, "html.parser") results = [] for card in soup.select("div.result"): try: website = card.select_one("a.track-visit-website") results.append({ "name": text_of(card, "a.business-name"), "address": text_of(card, "div.street-address"), "locality": text_of(card, "div.locality"), "phone": text_of(card, "div.phones"), "category": text_of(card, "div.categories"), "rating": parse_rating(card), "reviews": text_of(card, "span.count"), "website": website["href"] if website else None, }) except Exception as e: print(f"Skipped a card: {e}") return results def save(rows, name): with open(f"{name}.json", "w") as f: json.dump(rows, f, indent=2) if rows: with open(f"{name}.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) def main(): url = build_url("plumbers", "Austin, TX") html = crawl(url) if not html: return data = scrape_results(html) save(data, "listings") print(json.dumps(data, indent=2)) if __name__ == "__main__": main()
Как выглядят результаты
Запустите полный скрипт командой python scraper.py, и вы получите чистый список записей, по одной на каждый бизнес, записанный в listings.json и listings.csv с выводом в консоль.
[ { "name": "Austin Plumbing Co", "address": "1200 W 5th St", "locality": "Austin, TX 78703", "phone": "(512) 555-0142", "category": "Plumbers, Water Heaters", "rating": 4.5, "reviews": "(38)", "website": "https://www.austinplumbingco.example" }, { "name": "Lone Star Drain & Sewer", "address": "904 E Cesar Chavez St", "locality": "Austin, TX 78702", "phone": "(512) 555-0188", "category": "Plumbers", "rating": null, "reviews": null, "website": null } ]
Вторая запись демонстрирует устойчивость в действии: у этого бизнеса нет рейтинга, количества отзывов и веб-сайта в базе, поэтому эти поля возвращаются как null, не прерывая запуск. CSV-версия содержит те же столбцы в том же порядке, готова для открытия в таблице или загрузки в базу данных. Если вам нужна памятка по сплющиванию вложенных записей в строки, руководство по парсингу таблиц с сайта охватывает ту же форму экспорта.
Масштабирование по городам и страницам
Один запрос в одном городе, это демонстрация. Реальная ценность данных листинга приходит от запуска одной категории по многим рынкам, и именно здесь согласованность важна больше всего. Перебирайте список городов, пагинируйте каждый параметром &page= до тех пор, пока страница не вернёт пустых карточек, и собирайте всё в один набор данных.
import time def scrape_city(category, city, max_pages=5): base = build_url(category, city) collected = [] for page in range(1, max_pages + 1): html = crawl(f"{base}&page={page}") if not html: break rows = scrape_results(html) if not rows: break for row in rows: row["city"] = city collected.extend(rows) print(f"{city} page {page}: {len(rows)} listings") time.sleep(2) return collected def scrape_cities(category, cities): all_rows = [] for city in cities: all_rows.extend(scrape_city(category, city)) return all_rows data = scrape_cities("restaurants", ["Austin, TX", "Denver, CO", "Phoenix, AZ"]) save(data, "multi_city")
Ограничение max_pages удерживает каждый город в разумных рамках, чтобы широкий запрос не крутился бесконечно, а прерывание при пустых результатах останавливает вас раньше, когда в каталоге заканчиваются страницы. Пометка каждой строки её city позволяет разграничивать рынки в одном файле. Пауза time.sleep(2) между страницами регулирует скорость запросов, предотвращая перегрузку каталога, что является самым быстрым способом получить ограничение. Этот многогородской цикл, тот же паттерн, что лежит в основе инструмента сравнения цен: один запрос, множество источников, нормализованных в один набор данных.
Как оставаться незаблокированным
Даже при наличии правильного получения данных каталоги отслеживают трафик, похожий на парсер. Несколько привычек позволяют поддерживать запуск в рабочем состоянии, и они применимы к любому коммерческому ресурсу.
-
Регулируйте скорость запросов. Распределяйте запросы с задержкой между страницами и варьируйте запросы, а не краулите один термин на полной скорости. Пауза
time.sleepв цикле, это минимум, а не максимум. - Используйте ротацию. Пул резидентных IP распределяет запросы по множеству реальных пользовательских адресов, чтобы ни один не превысил лимит. Crawling API выполняет это за вас; если вы используете собственный стек, это ключевая часть, которую нужно реализовать правильно.
- Учитывайте геолокацию. Привязывайте страну запроса к рынку, который вы запрашиваете, чтобы результаты оставались согласованными и трафик выглядел местным, а не неуместным.
- Следите за кодами статуса. Запуск, который начинает возвращать проверки или ошибки, сигнализирует о том, что текущая скорость или уровень IP больше недостаточны. Воспринимайте это как сигнал для снижения активности, а не как шум, который можно игнорировать.
Подробный сценарий поддержания парсера в рабочем состоянии описан в статье как парсить сайты, не получая блокировок. Когда вы перерастаете запросы по требованию и вам нужно одновременно отправлять тысячи пар город-категория, асинхронный Crawler обрабатывает большие пакеты в фоновом режиме и доставляет результаты на вебхук или в Cloud Storage, поэтому вы используете тот же парсер без самостоятельного управления очередью запросов.
Законен ли парсинг бизнес-листингов?
Допустимость парсинга каталога листингов зависит от условий использования платформы, вашей юрисдикции и того, что вы делаете с данными. Большинство каталогов ограничивают автоматизированный доступ в своих условиях, поэтому парсинг может противоречить этим условиям независимо от аккуратности используемых инструментов. Ни один из приведённых здесь кодов не меняет этого; он лишь делает техническую часть работоспособной. Прочитайте Условия использования платформы и её robots.txt, и рассматривайте оба документа как границу того, что вы собираете и с какой скоростью.
Несколько принципов, которых стоит придерживаться. Собирайте только публичную бизнес-информацию: названия, адреса, номера телефонов, категории, рейтинги, количество отзывов и ссылки на веб-сайты, которые любой может видеть на странице результатов без аккаунта. Контактные данные бизнеса являются публичными деловыми данными, но не собирайте персональные данные о физических лицах, включая личные контактные данные, личности рецензентов или что-либо, связанное с конкретным человеком, помимо того, что публикует сам бизнес. Поддерживайте объём запросов на достаточно низком уровне, чтобы не нагружать серверы платформы, и соблюдайте любые заявленные ограничения скорости. Если вы планируете коммерческое повторное использование данных, получите разрешение или официальное соглашение, а не предполагайте, что молчание означает согласие.
Это руководство намеренно ограничено публичными страницами листингов, поскольку именно это позволяет работе оставаться в рамках допустимого. Оно не охватывает ничего за логином, данные аккаунта или сбор персональной информации о реальных людях. Там, где платформа предлагает санкционированный путь, предпочитайте его: провайдеры карт и геолокации публикуют официальные API, возвращающие те же поля листингов на чётких условиях, и это правильный инструмент, когда вам нужны большие объёмы, гарантированная структура или коммерческие права. Если вашему проекту нужно больше, чем публичные листинги, официальный API или соглашение о данных является правильным путём, а не более хитроумный парсер.
Ключевые выводы
- Листинги зависят от геолокации. Включайте город в запрос и закрепляйте страну запроса, иначе одна и та же категория возвращает непоследовательные компании от запуска к запуску.
- Получение данных, самая сложная часть. Crawling API получает страницу за доверенным резидентным IP и рендерит её при необходимости, поэтому вы получаете реальные карточки вместо страницы блокировки или пустой оболочки.
-
BeautifulSoup выполняет извлечение. Перебирайте карточки
div.resultи сопоставляйте name, address, phone, category, rating, reviews и website с текущими селекторами, ожидая их изменения. -
Масштабируйте по городам и страницам. Перебирайте список городов, пагинируйте параметром
&page=до опустения страницы, помечайте каждую строку городом и добавляйте паузы между запросами. - Оставайтесь в рамках публичных данных. Соблюдайте Условия использования и robots.txt платформы, предпочитайте официальный API карт или геолокации для лицензированных или массовых данных, и никогда не собирайте персональную информацию о физических лицах.
Часто задаваемые вопросы
Нужен ли обычный токен или JS-токен для листингов?
Зависит от каталога. Статические страницы результатов, как Yellow Pages, нормально парсятся с обычным токеном. Платформы, добавляющие листинги на стороне клиента, такие как Google Maps и Yelp, требуют JS-токена, чтобы страница была отрендерена в реальном браузере перед возвратом HTML. Подбирайте токен под страницу: начните с обычного токена и переключитесь на JS-токен, если карточки отсутствуют в теле ответа.
Как получить точные результаты для конкретного города?
Нужны две вещи одновременно. Укажите город в самом запросе через параметр geo_location_terms и привяжите запрос к нужному рынку с помощью опции country в Crawling API. Локальный поиск привязан к местоположению, поэтому запрос без обоих компонентов возвращает результаты, которые непредсказуемо меняются в зависимости от того, откуда, по всей видимости, поступает запрос.
Можно ли парсить несколько городов за один запуск?
Да. Передайте список городов и переберите ту же категорию по каждому из них, объединяя результаты в один набор данных. Помечайте каждую запись городом перед слиянием, чтобы рынки оставались различимыми, и добавляйте небольшую задержку между запросами для регулировки скорости.
Мои селекторы возвращают None. Что изменилось?
Почти наверняка разметка каталога. Имена классов, такие как a.business-name для названия или div.result-rating для рейтинга, изменяются без предупреждения. Заново проинспектируйте живую страницу результатов в инструментах разработчика браузера, обновите селектор в соответствии с ней и перезапустите. Периодическое обслуживание селекторов нормально для любого рабочего парсера.
Можно ли парсить личные контактные данные из листингов?
Нет, и это руководство не охватывает такой случай. Ограничивайтесь публичной бизнес-информацией: названием, адресом, указанным телефоном, категорией, рейтингом, количеством отзывов и веб-сайтом. Персональные данные о физических лицах, личные контактные данные или личности рецензентов выходят за рамки допустимого и противоречат условиям большинства платформ. Для более богатых или лицензированных данных правильным путём является официальный API карт или геолокации.
Как справляться с очень большими задачами по сотням городов?
Для работы по требованию Crawling API вполне достаточен, но когда вы одновременно отправляете тысячи пар город-категория, переходите на асинхронный Crawler. Вы отправляете URL и получаете результаты через вебхук или Cloud Storage вместо ожидания каждого запроса, что улучшает пропускную способность и устраняет узкие места. Тот же парсер из этого руководства обрабатывает возвращаемый HTML без изменений.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

