Локальные бизнес-листинги являются одним из наиболее полезных публичных наборов данных в интернете. Поиск по каталогу вроде "сантехники в Остине" или "рестораны в Денвере" возвращает структурированную сетку компаний, каждая из которых содержит название, адрес, номер телефона, категорию и звёздный рейтинг с количеством отзывов. Отделы продаж, маркетинга и исследований извлекают эти данные для формирования списков потенциальных клиентов по городам, обогащения 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 для парсинга.

Обычный токен против JS-токена

Crawlbase предлагает два типа токенов. Обычный токен получает статический HTML; JavaScript (JS) токен сначала рендерит страницу в реальном браузере. Статические страницы каталогов нормально парсятся с обычным токеном, но платформы, добавляющие листинги на стороне клиента (Google Maps, Yelp), требуют JS-токена. Подбирайте токен под страницу: используйте обычный для простых страниц и JS-токен для динамических.

Предварительные требования

Перед написанием кода необходимо выполнить несколько условий. Это не займёт много времени.

Базовые знания Python. Вы должны уметь писать и запускать Python-скрипты, а также устанавливать пакеты через pip. Если вы новичок в BeautifulSoup, введение в использование BeautifulSoup в Python охватывает основы работы с селекторами, на которые опирается этот туториал.

Python 3.8 или выше. Проверьте версию командой python --version. Если она не установлена, скачайте её с python.org или через дистрибутив вроде Anaconda.

Аккаунт Crawlbase и токен. Зарегистрируйтесь, откройте панель управления и скопируйте токен со страницы документации аккаунта. Относитесь к токену как к паролю: он аутентифицирует ваши запросы, поэтому не добавляйте его в систему контроля версий.

Настройка проекта

Создайте виртуальное окружение, чтобы зависимости проекта были изолированы, затем установите две библиотеки, необходимые парсеру.

bash
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 поиска из категории и города и выполните запрос. Проверка кода статуса перед парсингом позволяет выявлять ошибки явно, а не незаметно.

python
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 ломается. Запустите скрипт, и вы должны увидеть реальную разметку листинга, а не пустую оболочку или страницу блокировки. Это подтверждает работу получения данных до написания первого селектора.

Crawlbase Crawling API

Этот единственный вызов api.get выполнил ту часть, которая обычно занимает неделю: он получил страницу листингов за доверенным резидентным IP, закреплённым за нужной страной, поэтому каталог вернул реальные карточки вместо страницы блокировки. Crawling API берёт на себя рендеринг, ротацию IP и геотаргетинг, избавляя вас от необходимости самостоятельно управлять флотом headless-браузеров и пулом прокси. Начните с одного города на бесплатном тарифе.

Шаг 2: Парсинг карточек листинга с BeautifulSoup

Получив HTML, загрузите его в BeautifulSoup, найдите каждую карточку листинга и извлеките каждое поле по его селектору. Каждый бизнес обёрнут в контейнер div.result, где название, адрес, телефон, категория, рейтинг, количество отзывов и веб-сайт каждый раскрываются через собственный класс. Оборачивайте каждую карточку в try/except, чтобы одна повреждённая карточка не прерывала весь запуск.

python
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, чтобы они сразу попадали в таблицу или базу данных. Получите отрендеренную страницу, передайте её парсеру, затем выгрузите структурированные записи.

python
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 с выводом в консоль.

json
[
  {
    "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= до тех пор, пока страница не вернёт пустых карточек, и собирайте всё в один набор данных.

python
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 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга