Indeed 是网络上最大的招聘平台之一,汇聚了跨行业、跨雇主和跨地区的数百万条公开职位招聘。每条招聘信息都携带着支撑劳动力市场研究、招聘竞争分析和自定义求职工具所需的结构化信号:职位名称、招聘公司、地点、公开时的薪资范围,以及完整招聘页面的链接。问题在于 Indeed 通过 JavaScript 构建搜索结果,并将招聘数据深埋在页面内部,普通的 HTTP 请求给您返回的是一个几乎空白的外壳,而不是您所需要的职位信息。
本指南将向您展示如何用 Python 以可靠的方式抓取 Indeed 职位招聘。您将构建一个小型可运行的爬虫,通过 Crawling API 获取渲染后的搜索页面,从页面中提取嵌入的职位数据,解析每个字段,处理分页,并将结果导出为 JSON 和 CSV。整个演示仅限于公开职位招聘数据的范围,文末的合法性部分并非样板文字,请在将此方法用于任何实际规模前先行阅读。
您将构建什么
一个 Python 脚本,接收 Indeed 的公开搜索 URL,通过 Crawling API 获取渲染后的 HTML,并为页面上的每条职位招聘提取结构化记录。我们以开发者职位搜索为示例,每条招聘提取以下字段:
-
职位名称:招聘的职位,从每张卡片的
title字段读取。 - 公司:招聘背后的雇主。
-
地点:职位所在地,来自
formattedLocation。 -
薪资范围:Indeed 公开时的最低和最高薪资,来自
extractedSalary。 -
职位键和链接:
jobkey标识符和招聘 URL,供后续按职位跟进。
为什么普通请求在 Indeed 上会失败
如果您使用基础 HTTP 客户端请求 Indeed 搜索 URL,会得到状态码 200 的响应,但正文中几乎没有任何招聘数据。有两个因素会阻碍您。首先,Indeed 通过 JavaScript 渲染结果,因此初始 HTML 只是一个外壳,在页面脚本运行后才会填充内容。其次,Indeed 通过 CAPTCHA 挑战和按 IP 的限速积极防御自动化访问,因此即使是来自被标记地址的渲染请求也会得到挑战页面而非职位信息。
因此,一个可用的 Indeed 爬虫在单次请求中需要两件事:能够实际渲染页面的浏览器,以及让平台认为是真实访客的 IP。您可以自行组装无头浏览器和轮换住宅代理池,但维护这套组合才是大部分工作所在。Crawling API 将两者合并为一次调用:您发送带有 JavaScript 令牌的 URL,它在可信 IP 后面渲染页面,并返回完整的 HTML 供您解析。
Crawlbase 提供两种令牌类型。普通令牌获取静态 HTML;JavaScript (JS) 令牌则先在真实浏览器中渲染页面。Indeed 是客户端渲染的,因此此处需要 JS 令牌。使用普通令牌返回的结果与普通请求一样,只是一个空壳,无法从中解析出任何内容。
前提条件
开始编写代码前,您需要准备几样东西,每样都不需要很长时间。
基础 Python 知识。您应能编写和运行 Python 脚本,并使用 pip 安装包。此处的解析依赖正则表达式和 JSON 而非 DOM 库,但如果您也想要一个基于选择器的入门,我们关于如何在 Python 中使用 BeautifulSoup 的指南涵盖了基础知识。
Python 3.8 或更高版本。使用 python --version 确认版本。如果尚未安装,请从 python.org 安装,或通过 Anaconda 等发行版安装。
Crawlbase 账户和 JS 令牌。注册后,打开控制台,从账户文档页面复制您的 JavaScript (JS) 令牌。像对待密码一样保管令牌:它用于验证您的请求身份,请勿将其提交到版本控制系统。免费套餐包含 1,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 库。
第一步:获取渲染后的搜索页面
首先获取完整的页面。在 Indeed 主页上进行搜索时,它会重定向到类似 https://www.indeed.com/jobs?q=Web+Developer&l=Virginia 的 URL,其中 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 后面的 JavaScript 渲染页面,否则其 CAPTCHA 和限速会让您一无所获。Crawling API 接受 JS 令牌,在真实浏览器中运行页面,并在服务端轮换住宅 IP,让您无需自行运行无头浏览器群和代理池。先在免费套餐上指向公开搜索页面试试看。
第二步:定位嵌入的职位数据
您可以用 CSS 或 XPath 选择器解析渲染后的职位卡片,但 Indeed 给了您一个更简单、更稳定的钩子。每个搜索页面都将完整的招聘信息作为 JSON 文档嵌入在 script 标签中,赋值给名为 window.mosaic.providerData["mosaic-provider-jobcards"] 的 JavaScript 变量。读取这一单个数据块即可获得卡片显示的每个字段,已经结构化,无需应对使类名选择器在每次部署时失效的标记变动。
一个简短的正则表达式就能从 HTML 中提取该 JSON。解析后,招聘信息按可预测的路径存放,同级的块包含分页所需的结果计数:
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 中的每个条目都是一个丰富的对象,但您只需要其中几个字段就能得到整洁的记录。
第三步:提取所需字段
一个原始招聘对象包含数十个内部键。将每张卡片映射到重要的字段,对可选字段进行防御性处理,防止缺失薪资时导致运行崩溃。薪资在 extractedSalary 下以 min、max 和 type 的形式存放(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,并更新模式。定期维护对任何生产级爬虫来说都是正常的。
第四步:处理分页
一个搜索页面只显示第一批职位。Indeed 使用 start 查询参数以 10 为步长偏移结果,因此通过递增该参数可以进入更深的结果集。第二步中的层级摘要告诉您总共有多少匹配的职位,让您能将爬取上限设在合理的 max_results 而不是获取每一页。使用 urlencode 构建每个页面 URL,获取并累积提取的记录。
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
第一次请求身兼两职:提供第一页职位,以及揭示总匹配数的层级摘要。从这里开始,循环以 10 为步长生成页面 URL 直至您的上限,并获取每一页,复用同一套解析和提取逻辑。页面之间的 time.sleep(2) 是刻意为之的。即使渲染和轮换都已为您处理好,连续发出请求也是触发限速的最快方式。
第五步:整合并导出 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 提取模式也适用于其他平台:参阅如何用 Python 抓取 Monster 职位和如何抓取 Glassdoor。
抓取 Indeed 是否合法?
抓取 Indeed 是否被允许,取决于 Indeed 的服务条款、您所在的司法管辖区以及您对数据的使用方式。Indeed 的条款限制自动访问,且该网站主动部署 CAPTCHA 和限速来阻止此类行为,因此无论您的工具多么谨慎,抓取都可能违反这些条款。本文中的代码并不会改变这一点,只是让技术层面的工作能够运行。请阅读 Indeed 的服务条款及其 robots.txt,并将两者视为您采集内容的边界。
几条值得坚守的底线。只采集公开职位招聘数据:任何人无需登录即可在公开搜索页面看到的职位名称、公司、地点、公开薪资范围和招聘链接。尊重 Indeed 明示的速率预期,将请求量控制在不给服务器造成压力的水平。涉及个人数据时,GDPR 和 CCPA 等隐私法律即适用,因此请将您的工作严格限定在公开招聘本身。
本指南有意将范围限定在公开职位招聘,因为这是让工作具有可辩护性的边界。它不涵盖应聘者数据、简历、候选人档案、招聘人员联系方式,或登录墙或付费层级后的任何内容,也不尝试绕过身份验证。求职者和招聘人员的个人信息是应当完全回避的数据类型。Indeed 还运营着官方发布者 API 和合作伙伴计划,提供对其职位数据的授权访问,如果您的项目需要大规模获取超出公开招聘范围的数据,该计划才是正确路径,而不是设计更精巧的爬虫。
核心要点
- Indeed 是 JavaScript 渲染且有主动防御的。普通请求返回空壳,被标记的 IP 会收到 CAPTCHA,因此必须在可信 IP 后面渲染后再进行解析。
-
数据嵌入在页面中,不在标记里。每个搜索页面将招聘信息隐藏为
mosaic-provider-jobcards变量中的 JSON;一个短正则表达式加json.loads比 CSS 选择器更稳定。 -
只映射您所需的字段。提取 title、company、
formattedLocation、extractedSalary和jobkey链接,对可选字段进行防御性处理,防止缺失薪资时导致运行崩溃。 -
以 start 分页并导出两种格式。以 10 为步长递增
start参数,上限来自层级摘要,然后写入 JSON 和 CSV。 - 坚守公开招聘范围。遵守 Indeed 的服务条款和 robots.txt,对大规模用途优先使用官方发布者 API,绝不触碰应聘者或招聘人员的个人数据。
常见问题
可以抓取 Indeed 吗?
从技术上可以,但 Indeed 通过 CAPTCHA 挑战和按 IP 的限速来防御此类行为,其条款也限制自动化访问。要获取公开搜索页面,您需要在真实浏览器中在可信 IP 后面渲染页面,这正是 Crawling API 的 JS 令牌在解析之前所处理的事情。对于大规模授权访问,Indeed 的官方发布者 API 和合作伙伴计划是获批准的路径。
Indeed 需要普通令牌还是 JS 令牌?
JS 令牌。Indeed 通过 JavaScript 渲染结果,因此普通令牌返回的是与普通请求相同的空壳。JS 令牌先在真实浏览器中渲染页面,这使得您解析的 HTML 中嵌入的 mosaic-provider-jobcards JSON 得以出现。
为什么要解析嵌入的 JSON 而不用 CSS 选择器?
Indeed 将完整的招聘数据作为 JSON 数据块嵌入页面,赋值给 window.mosaic.providerData["mosaic-provider-jobcards"]。读取这一对象即可获得所有已结构化的字段,且不受在每次部署时使基于选择器的爬虫失效的哈希类名影响。一个正则表达式即可提取它,然后 json.loads 将其转换为 Python 字典。
如何处理 Indeed 的分页?
Indeed 使用 start 查询参数以 10 为步长偏移结果。从第一页的层级摘要读取总匹配数,将其上限设为合理的 max_results,然后以 10、20、30 等为偏移量生成页面 URL 并获取和解析每一页。在循环中加入短暂停顿以避免被限速。
我可以从 Indeed 抓取应聘者数据或完整简历吗?
不能,本指南也不涉及这些。应聘者数据、简历以及候选人或招聘人员档案都是个人信息,或者位于登录墙后而非公开的职位招聘数据之中。抓取登录墙后的内容或绕过身份验证访问任何此类内容,违反 Indeed 的条款并触发 GDPR 和 CCPA 等隐私法律。将您的范围限定在搜索页面上的公开招聘。
如何避免在抓取 Indeed 时被封锁?
保持较低的每 IP 请求速率,交替不同的查询而不是循环同一条搜索路径,并通过轮换住宅 IP 路由流量以防止任何单一地址触发限速。Crawling API 为您管理轮换和可信 IP 池;如果您自建技术栈,这是最值得投入的部分。密切关注状态码,一旦开始看到 CAPTCHA 挑战就立即退让。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
