Indeed, одна из крупнейших бирж вакансий в интернете, агрегирующая миллионы публичных объявлений по отраслям, работодателям и регионам. Каждое объявление несёт структурированную информацию, полезную для исследования рынка труда, конкурентного анализа рекрутёров и создания собственных инструментов поиска работы: название должности, нанимающая компания, местоположение, диапазон зарплаты (если он опубликован) и ссылка на полное объявление. Загвоздка в том, что Indeed формирует результаты поиска с помощью JavaScript и помещает данные о вакансиях глубоко внутри страницы, поэтому обычный HTTP-запрос возвращает почти пустую оболочку вместо искомых вакансий.

В этом руководстве показано, как парсить вакансии Indeed с помощью Python надёжным способом. Вы создадите небольшой рабочий скрапер, который загружает отрендеренную страницу поиска через Crawling API, извлекает встроенные данные о вакансиях, разбирает каждое поле, обрабатывает пагинацию и экспортирует результаты в JSON и CSV. Всё руководство ограничено публичными данными объявлений о работе, а раздел о легальности в конце не является стандартной оговоркой, поэтому прочитайте его перед тем, как запускать скрапер в промышленных масштабах.

Что вы создадите

Скрипт на Python, который принимает публичный URL поиска на Indeed, загружает отрендеренный HTML через Crawling API и извлекает структурированную запись для каждого объявления на странице. В качестве основного примера мы используем поиск вакансий разработчиков и извлекаем следующие поля для каждого объявления:

  • Название должности рекламируемая роль, считываемая из поля title каждой карточки.
  • Компания работодатель, разместивший объявление.
  • Местоположение место работы, из поля formattedLocation.
  • Диапазон зарплаты минимальная и максимальная оплата, когда Indeed её раскрывает, из поля extractedSalary.
  • Ключ вакансии и ссылка идентификатор jobkey и URL объявления, позволяющие перейти к конкретной роли.

Почему обычный запрос не работает на Indeed

Если запросить поисковый URL Indeed с помощью обычного HTTP-клиента, вы получите ответ со статусом 200 и почти без данных о вакансиях в теле. Против вас работают два фактора. Во-первых, Indeed рендерит результаты с помощью JavaScript, поэтому исходный HTML является оболочкой, которая заполняется только после выполнения скриптов страницы. Во-вторых, Indeed активно защищается от автоматического доступа с помощью CAPTCHA и лимитов по IP, поэтому даже отрендеренный запрос с заблокированного адреса получает страницу с проверкой, а не вакансии.

Поэтому рабочему скраперу Indeed нужны сразу две вещи в одном запросе: браузер, который реально рендерит страницу, и IP-адрес, который платформа воспринимает как настоящего посетителя. Можно собрать это самостоятельно с помощью headless-браузера и пула ротируемых резидентных прокси, но поддержание такой связки в рабочем состоянии, это и есть основная работа. Crawling API объединяет оба компонента в одном вызове: вы отправляете URL с JavaScript-токеном, API рендерит страницу за доверенным IP и возвращает готовый HTML для парсинга.

Зачем нужен JS-токен

Crawlbase предлагает два типа токенов. Обычный токен загружает статический HTML; JavaScript (JS) токен сначала рендерит страницу в реальном браузере. Indeed является клиентским рендерингом, поэтому здесь нужен JS-токен. При использовании обычного токена вы получите ту же пустую оболочку, что и при обычном запросе, и парсить там будет нечего.

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

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

Базовые знания Python. Вы должны уметь писать и запускать скрипты Python, а также устанавливать пакеты через pip. Парсинг здесь опирается на регулярные выражения и JSON, а не на DOM-библиотеку, но если вам нужно введение в работу с селекторами, в нашем руководстве как использовать BeautifulSoup в Python объясняются основы.

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

Аккаунт Crawlbase и JS-токен. Зарегистрируйтесь, откройте панель управления и скопируйте JavaScript (JS) токен со страницы документации аккаунта. Обращайтесь с токеном как с паролем: он аутентифицирует ваши запросы, поэтому не добавляйте его в систему контроля версий. Бесплатный тариф включает до 5 000 бесплатных запросов, чего достаточно для прохождения этого руководства от начала до конца, и плата взимается только за успешные запросы.

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

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

bash
python --version

python -m venv indeed_env
source indeed_env/bin/activate

pip install crawlbase

В Windows активируйте окружение командой indeed_env\Scripts\activate вместо строки с source. Пакет crawlbase является официальным клиентом для Crawling API. Поскольку Indeed встраивает данные объявлений как JSON внутрь страницы, модули стандартной библиотеки re и json выполняют парсинг, поэтому устанавливать HTML-библиотеку для страниц поиска не нужно.

Шаг 1: Загрузка отрендеренной страницы поиска

Начните с получения готовой страницы. При поиске на главной странице Indeed вы получаете URL вида https://www.indeed.com/jobs?q=Web+Developer&l=Virginia, где q, запрос, а l, местоположение. Импортируйте класс CrawlingAPI, инициализируйте его своим JS-токеном и запросите этот URL. Передайте параметр country, чтобы запрос поступал с американского IP, поскольку Indeed возвращает локализованные результаты. Проверка кода статуса перед парсингом позволяет выявлять ошибки явно, а не молча.

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

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

if __name__ == "__main__":
    page_url = "https://www.indeed.com/jobs?q=Web+Developer&l=Virginia"
    html = crawl(page_url)
    print(html[:500] if html else "No HTML returned")

Запустите скрипт командой python scraper.py, и вы увидите настоящую разметку страницы, а не пустую оболочку, которую возвращает обычный запрос. Мы используем декодирование с latin1, а не utf-8, поскольку страницы Indeed содержат смешанные байтовые последовательности, а latin1 корректно отображает каждый байт, сохраняя встроенный JSON нетронутым. Это подтверждает, что рендеринг работает, прежде чем вы напишете хоть один парсер.

Crawlbase Crawling API

Indeed требует отрендеренной страницы за доверенным IP в одном вызове, иначе CAPTCHA и лимиты запросов останавливают вас немедленно. Crawling API принимает JS-токен, запускает страницу в реальном браузере и на стороне сервера ротирует резидентные IP, поэтому вам не нужно самостоятельно управлять парком headless-браузеров и пулом прокси. Попробуйте на публичной странице поиска в рамках бесплатного тарифа.

Шаг 2: Извлечение встроенных данных о вакансиях

Можно было бы парсить отрендеренные карточки вакансий с помощью CSS или XPath-селекторов, но Indeed предоставляет более простой и стабильный способ. Каждая страница поиска встраивает полный набор объявлений как JSON-документ внутри тега script, присваивая его JavaScript-переменной window.mosaic.providerData["mosaic-provider-jobcards"]. Чтение этого единственного блока даёт вам все поля, которые отображаются в карточках, уже структурированные, без риска поломки при изменении имён классов.

Короткое регулярное выражение извлекает этот JSON из HTML. После разбора объявления находятся по предсказуемому пути, а соседний блок содержит счётчики результатов, необходимые для пагинации:

python
import re
import json

def parse_search_page_html(html):
    data = re.findall(r'window.mosaic.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});', html)
    data = json.loads(data[0])
    model = data["metaData"]["mosaicProviderJobCardsModel"]
    return {
        "results": model["results"],
        "meta": model["tierSummaries"],
    }

Функция находит переменную mosaic-provider-jobcards, разбирает JSON и возвращает два компонента: results, список объявлений о вакансиях, и meta, сводные данные по тирам с информацией о количестве совпадающих вакансий по категориям. Каждая запись в results является объектом с большим количеством полей, но для чистой записи нужна лишь небольшая их часть.

Шаг 3: Извлечение нужных полей

Объект необработанного объявления содержит десятки внутренних ключей. Преобразуйте каждую карточку в нужные поля, защищая необязательные из них, чтобы отсутствующая зарплата не прерывала выполнение. Зарплата находится в extractedSalary как минимум, максимум и тип, когда Indeed её раскрывает, а ссылка на объявление формируется из jobkey.

python
def format_salary(card):
    salary = card.get("extractedSalary")
    if not salary:
        return ""
    low, high = salary.get("min"), salary.get("max")
    unit = salary.get("type", "")
    if low == high:
        return f"{low} {unit}".strip()
    return f"{low}-{high} {unit}".strip()

def extract_jobs(results):
    jobs = []
    for card in results:
        job_key = card.get("jobkey", "")
        jobs.append({
            "title": card.get("title", ""),
            "company": card.get("company", ""),
            "location": card.get("formattedLocation", ""),
            "salary": format_salary(card),
            "posted": card.get("formattedRelativeTime", ""),
            "job_key": job_key,
            "link": f"https://www.indeed.com/viewjob?jk={job_key}",
        })
    return jobs

Каждое считываемое поле проходит через .get() со значением по умолчанию, поэтому объявление без зарплаты или названия компании вернёт пустую строку, а не KeyError. Значение jobkey стоит сохранять отдельно: оно уникально идентифицирует объявление и позволяет сформировать прямую ссылку viewjob, что удобно, если позже вам нужно будет загрузить полное описание вакансии для конкретной роли.

Селекторы устаревают

Имя переменной mosaic-provider-jobcards и ключи полей выше отражают текущую структуру страницы Indeed и могут измениться при редизайне. Воспринимайте их как отправную точку, а не контракт. Если регулярное выражение перестало совпадать или поле возвращается пустым для каждой карточки, повторно проверьте живую страницу поиска в инструментах разработчика браузера, найдите встроенный JSON заново и обновите шаблон. Периодическое обслуживание нормально для любого производственного скрапера.

Шаг 4: Обработка пагинации

На одной странице поиска отображается только первая партия вакансий. Indeed использует параметр запроса start, который смещает результаты с шагом 10, поэтому для просмотра следующих страниц нужно увеличивать это значение. Сводные данные по тирам из шага 2 показывают общее количество совпадающих вакансий, что позволяет ограничить обход разумным значением max_results вместо загрузки всех страниц. Формируйте URL каждой страницы с помощью urlencode, загружайте её и накапливайте извлечённые записи.

python
import time
from urllib.parse import urlencode

def make_search_url(query, location, offset):
    params = {"q": query, "l": location, "filter": 0, "start": offset}
    return f"https://www.indeed.com/jobs?{urlencode(params)}"

def scrape_indeed_search(query, location, max_results=50):
    print(f"Scraping first page: query={query}, location={location}")
    html = crawl(make_search_url(query, location, 0))
    if not html:
        return []

    first = parse_search_page_html(html)
    jobs = extract_jobs(first["results"])
    total = sum(c["jobCount"] for c in first["meta"])
    total = min(total, max_results)

    for offset in range(10, total, 10):
        print(f"Scraping page at offset {offset}")
        page_html = crawl(make_search_url(query, location, offset))
        if page_html:
            page = parse_search_page_html(page_html)
            jobs.extend(extract_jobs(page["results"]))
        time.sleep(2)
    return jobs

Первый запрос выполняет двойную задачу: возвращает первую страницу вакансий и сводные данные по тирам с общим количеством совпадений. Далее цикл формирует URL страниц с шагом смещения 10 до установленного ограничения и загружает каждую из них, повторно используя одну и ту же пару парсинга и извлечения. Пауза time.sleep(2) между страницами намеренна. Отправка запросов подряд без задержки, самый быстрый способ получить блокировку, даже когда рендеринг и ротация обрабатываются за вас.

Шаг 5: Объединение и экспорт в JSON и CSV

Теперь объедините загрузку, парсер, извлечение полей и цикл пагинации в один рабочий скрипт, а затем запишите записи в JSON и CSV. JSON сохраняет структуру для последующего кода; CSV открывается прямо в таблице.

python
import re
import json
import csv
import time
from urllib.parse import urlencode
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

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 parse_search_page_html(html):
    data = re.findall(r'window.mosaic.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});', html)
    data = json.loads(data[0])
    model = data["metaData"]["mosaicProviderJobCardsModel"]
    return {"results": model["results"], "meta": model["tierSummaries"]}

def format_salary(card):
    salary = card.get("extractedSalary")
    if not salary:
        return ""
    low, high = salary.get("min"), salary.get("max")
    unit = salary.get("type", "")
    if low == high:
        return f"{low} {unit}".strip()
    return f"{low}-{high} {unit}".strip()

def extract_jobs(results):
    jobs = []
    for card in results:
        job_key = card.get("jobkey", "")
        jobs.append({
            "title": card.get("title", ""),
            "company": card.get("company", ""),
            "location": card.get("formattedLocation", ""),
            "salary": format_salary(card),
            "posted": card.get("formattedRelativeTime", ""),
            "job_key": job_key,
            "link": f"https://www.indeed.com/viewjob?jk={job_key}",
        })
    return jobs

def make_search_url(query, location, offset):
    params = {"q": query, "l": location, "filter": 0, "start": offset}
    return f"https://www.indeed.com/jobs?{urlencode(params)}"

def scrape_indeed_search(query, location, max_results=50):
    html = crawl(make_search_url(query, location, 0))
    if not html:
        return []
    first = parse_search_page_html(html)
    jobs = extract_jobs(first["results"])
    total = min(sum(c["jobCount"] for c in first["meta"]), max_results)
    for offset in range(10, total, 10):
        page_html = crawl(make_search_url(query, location, offset))
        if page_html:
            jobs.extend(extract_jobs(parse_search_page_html(page_html)["results"]))
        time.sleep(2)
    return jobs

def save_results(jobs):
    with open("indeed_jobs.json", "w") as f:
        json.dump(jobs, f, indent=2)
    if jobs:
        with open("indeed_jobs.csv", "w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=jobs[0].keys())
            writer.writeheader()
            writer.writerows(jobs)

def main():
    jobs = scrape_indeed_search("Web Developer", "Virginia")
    save_results(jobs)
    print(f"Saved {len(jobs)} jobs to indeed_jobs.json and indeed_jobs.csv")

if __name__ == "__main__":
    main()

Как выглядит результат

Запустите полный скрипт командой python scraper.py и вы получите список чистых структурированных записей, по одной на каждое объявление, записанных в indeed_jobs.json и indeed_jobs.csv.

json
[
  {
    "title": "Front Desk Agent",
    "company": "The Inn at Little Washington",
    "location": "Washington, VA 22747",
    "salary": "22 hourly",
    "posted": "20 days ago",
    "job_key": "72ed373141879fd4",
    "link": "https://www.indeed.com/viewjob?jk=72ed373141879fd4"
  },
  {
    "title": "Web Developer",
    "company": "Acme Digital",
    "location": "Richmond, VA",
    "salary": "75000-95000 yearly",
    "posted": "3 days ago",
    "job_key": "6a45faa5d8d817fa",
    "link": "https://www.indeed.com/viewjob?jk=6a45faa5d8d817fa"
  }
]

Объявления без публичной зарплаты возвращаются с пустой строкой salary, что ожидаемо, поскольку не каждый работодатель публикует оплату. Те же записи находятся в CSV с одной строкой на вакансию, готовые для открытия в таблице или загрузки в базу данных для анализа.

Как оставаться незаблокированным

Даже при обработке рендеринга и ротации, Indeed внимательно следит за трафиком, типичным для скраперов, и является одной из наиболее агрессивных бирж вакансий в этом отношении. Несколько привычек помогают поддерживать работоспособность парсера, и они применимы к любой защищённой коммерческой цели.

  • Соблюдайте темп запросов. Сохраняйте паузу time.sleep между страницами и варьируйте запросы, а не обходите один поисковый путь на полной скорости.
  • Опирайтесь на ротацию. Пул резидентных IP распределяет запросы по многим адресам реальных пользователей, чтобы ни один из них не превысил лимит. Crawling API делает это за вас; если вы собираете собственный стек, именно эта часть требует тщательной проработки.
  • Следите за кодами статуса. Запуск, возвращающий CAPTCHA или коды не-200, сигнализирует о том, что текущий темп или уровень IP больше недостаточны. Воспринимайте это как сигнал для снижения темпа, а не как шум, который можно игнорировать.

Для изучения общей стратегии смотрите руководство как парсить сайты, не попадая под блокировку, и углублённый материал о краулинге JavaScript-сайтов, в котором рассматривается проблема рендеринга, затрудняющая большинство скраперов бирж вакансий. Если вы формируете обширный набор данных о вакансиях, аналогичный шаблон извлечения JSON работает и на других биржах: смотрите руководства как парсить вакансии Monster на Python и как парсить Glassdoor.

Легально ли парсить Indeed?

Допустимость парсинга Indeed зависит от условий обслуживания Indeed, вашей юрисдикции и того, что вы делаете с данными. Условия Indeed ограничивают автоматический доступ, а сайт активно применяет CAPTCHA и лимиты по IP, чтобы воспрепятствовать этому, поэтому парсинг может противоречить этим условиям вне зависимости от тщательности вашего инструментария. Ни один код в этом руководстве не меняет этого; он лишь обеспечивает техническую сторону. Прочитайте Условия обслуживания Indeed и его robots.txt, и воспринимайте оба документа как границы того, что вы собираете.

Несколько правил, которых стоит придерживаться. Собирайте только публичные данные объявлений о вакансиях: название должности, компанию, местоположение, публичный диапазон зарплаты и ссылку на объявление, всё то, что любой пользователь видит на публичной странице поиска без входа в систему. Уважайте заявленные ожидания Indeed по частоте запросов и поддерживайте объём запросов достаточно низким, чтобы не перегружать серверы. При работе с персональными данными применяются законы о конфиденциальности, такие как GDPR и CCPA, поэтому строго ограничьте свою работу публичными объявлениями.

Это руководство намеренно ограничено публичными объявлениями о вакансиях, поскольку это граница, которая делает работу обоснованной. Оно не охватывает данные соискателей, резюме, профили кандидатов, контактные данные рекрутёров или что-либо за входом в систему или платным тарифом, и не предпринимает попыток обойти аутентификацию. Персональные данные соискателей и рекрутёров, это именно те данные, которых следует избегать. Indeed также предоставляет официальный Publisher API и партнёрскую программу для лицензированного доступа к данным о вакансиях, и если вашему проекту нужно больше, чем публичные объявления в масштабе, правильный путь, именно эта программа, а не более изощрённый скрапер.

Итоги

Ключевые выводы

  • Indeed использует JavaScript-рендеринг и защищён от ботов. Обычный запрос возвращает пустую оболочку, а заблокированный IP получает CAPTCHA, поэтому перед парсингом необходимо выполнить рендеринг за доверенным IP.
  • Данные встроены, а не в разметке. Каждая страница поиска скрывает объявления в виде JSON в переменной mosaic-provider-jobcards; короткое регулярное выражение плюс json.loads надёжнее, чем CSS-селекторы.
  • Извлекайте только нужные поля. Считывайте title, company, formattedLocation, extractedSalary и ссылку jobkey, защищая необязательные поля, чтобы отсутствующая зарплата не прерывала выполнение.
  • Используйте пагинацию через start и экспортируйте оба формата. Увеличивайте параметр start с шагом 10 до ограничения из сводных данных, затем записывайте JSON и CSV.
  • Оставайтесь в рамках публичных объявлений. Соблюдайте ToS и robots.txt Indeed, предпочитайте официальный Publisher API для работы в масштабе и никогда не касайтесь персональных данных соискателей или рекрутёров.

Часто задаваемые вопросы

Можно ли парсить Indeed?

Технически да, но Indeed защищается от этого с помощью CAPTCHA и лимитов по IP, а его условия ограничивают автоматический доступ. Для загрузки публичной страницы поиска нужна страница, отрендеренная в реальном браузере за доверенным IP, именно это обеспечивает JS-токен Crawling API перед любым парсингом. Для лицензированного доступа в масштабе официальный Publisher API и партнёрская программа Indeed являются санкционированным путём.

Нужен ли обычный токен или JS-токен для Indeed?

JS-токен. Indeed рендерит результаты с помощью JavaScript, поэтому обычный токен возвращает ту же пустую оболочку, что и обычный запрос. JS-токен сначала рендерит страницу в реальном браузере, что обеспечивает наличие встроенного JSON mosaic-provider-jobcards в HTML, который вы парсите.

Почему парсить встроенный JSON лучше, чем использовать CSS-селекторы?

Indeed включает полные данные объявлений в виде JSON-блока внутри страницы, присвоенного переменной window.mosaic.providerData["mosaic-provider-jobcards"]. Чтение этого одного объекта даёт вам все поля в уже структурированном виде, и это сохраняется при обновлении хешированных имён классов, которые ломают скраперы на основе селекторов при каждом деплое. Единственное регулярное выражение извлекает его, а затем json.loads превращает его в словарь Python.

Как обрабатывать пагинацию Indeed?

Indeed смещает результаты с помощью параметра запроса start с шагом 10. Считайте общее количество совпадений из сводных данных на первой странице, ограничьте его разумным значением max_results, затем генерируйте URL страниц со смещениями 10, 20, 30 и так далее, загружая и парся каждую. Делайте паузы в цикле с помощью небольшой задержки, чтобы не попасть под блокировку.

Можно ли парсить данные соискателей или полные резюме с Indeed?

Нет, и это руководство не охватывает данную тему. Данные соискателей, резюме и профили кандидатов или рекрутёров являются персональными данными или находятся за входом в систему, а не в публичных объявлениях о вакансиях. Парсинг контента, закрытого входом в систему, или обход аутентификации противоречит условиям Indeed и нарушает законы о конфиденциальности, такие как GDPR и CCPA. Ограничьте свою область публичными объявлениями на страницах поиска.

Как избежать блокировки при парсинге Indeed?

Поддерживайте низкую частоту запросов с одного IP, варьируйте запросы вместо повторения одного пути поиска и используйте ротацию резидентных IP, чтобы ни один адрес не превысил лимит. Crawling API управляет ротацией и пулом доверенных IP за вас; если вы строите собственный стек, именно в эту часть стоит инвестировать. Следите за кодами статуса и снижайте темп, как только начинают появляться CAPTCHA.

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

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