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 для парсинга.
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 бесплатных запросов, чего достаточно для прохождения этого руководства от начала до конца, и плата взимается только за успешные запросы.
Настройка проекта
Создайте виртуальное окружение, чтобы изолировать зависимости проекта, затем установите единственную библиотеку, необходимую скраперу.
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 возвращает локализованные результаты. Проверка кода статуса перед парсингом позволяет выявлять ошибки явно, а не молча.
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 нетронутым. Это подтверждает, что рендеринг работает, прежде чем вы напишете хоть один парсер.
Indeed требует отрендеренной страницы за доверенным IP в одном вызове, иначе CAPTCHA и лимиты запросов останавливают вас немедленно. Crawling API принимает JS-токен, запускает страницу в реальном браузере и на стороне сервера ротирует резидентные IP, поэтому вам не нужно самостоятельно управлять парком headless-браузеров и пулом прокси. Попробуйте на публичной странице поиска в рамках бесплатного тарифа.
Шаг 2: Извлечение встроенных данных о вакансиях
Можно было бы парсить отрендеренные карточки вакансий с помощью CSS или XPath-селекторов, но Indeed предоставляет более простой и стабильный способ. Каждая страница поиска встраивает полный набор объявлений как JSON-документ внутри тега script, присваивая его JavaScript-переменной window.mosaic.providerData["mosaic-provider-jobcards"]. Чтение этого единственного блока даёт вам все поля, которые отображаются в карточках, уже структурированные, без риска поломки при изменении имён классов.
Короткое регулярное выражение извлекает этот JSON из HTML. После разбора объявления находятся по предсказуемому пути, а соседний блок содержит счётчики результатов, необходимые для пагинации:
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.
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, загружайте её и накапливайте извлечённые записи.
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 открывается прямо в таблице.
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.
[ { "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 запросов бесплатно, без карты.
