Monster 是网络上较大的招聘网站之一,有数千条横跨各行业和地区的活跃列表。每条公开帖子都携带着能为劳动力市场研究、招聘方竞品分析和定制求职工具提供动力的那类结构化信号:一个职位名称、招聘公司、一个地点、一个发布日期,以及一个指向完整帖子的链接。问题在于,Monster 用 JavaScript 在浏览器里构建它的搜索结果,并随着你滚动而懒加载更多卡片,所以一个普通 HTTP 请求交给你的是一个近乎空白的外壳,而不是你想要的列表。

本指南将向你展示如何以可靠的方式用 Python 抓取 Monster 职位。你会构建一个小巧、可运行的抓取脚本,它通过 Crawling API 获取一个已渲染的搜索页,用 BeautifulSoup 解析每张职位卡片,并打印出干净的结构化输出。我们把整个演示限定在公开的职位列表数据范围内,文末附近的合法性章节并非样板话,所以在把它指向任何真实规模的目标之前请先读它。

你将构建什么

一个 Python 脚本,它接收一个公开的 Monster 搜索 URL,通过 Crawling API 取回已渲染的 HTML,并为页面上的每条帖子提取一条结构化记录。我们以一次开发者职位搜索作为贯穿示例,逐条帖子提取以下字段:

  • 职位名称 招聘的岗位,例如“Java Developer”。
  • 公司 列表背后的雇主。
  • 地点 职位所在地,比如“New York, NY”。
  • 发布日期 列表上线有多近,当 Monster 展示它时。
  • 职位 URL 指向完整帖子的链接,这样你就能逐个岗位跟进。

为什么普通获取在 Monster 上会失败

如果你用一个裸 HTTP 客户端请求一个 Monster 搜索 URL,你会得到一个状态为 200 的响应,而正文里几乎没有列表数据。有两件事在跟你作对。第一,Monster 用 JavaScript 在浏览器里渲染它的职位卡片,所以初始 HTML 只是一个外壳,要等页面脚本运行之后才会填充。第二,结果页使用基于滚动的分页:当一个真实用户向下滚动时更多卡片才加载,所以即便是一份过早拍下的渲染快照,也只捕获到头几个职位。

因此,一个能用的 Monster 抓取器在一次请求里需要三样东西:一个真正渲染页面的浏览器、一个被平台视为真实访客的 IP,以及一种驱动滚动以让懒加载卡片出现的办法。你可以用一个无头浏览器、一个滚动脚本和一池轮换的住宅代理自己搭建这一切,但把它们拼起来并保持健康才是大部分工作量所在。Crawling API 把这三样都收进单次调用:你把 URL 连同一个 JavaScript token 发给它,它在受信任的 IP 背后渲染并滚动页面,并返回成品 HTML 供你解析。

为什么用 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 会先在一个真实浏览器里渲染页面。Monster 是客户端渲染的,所以这里你需要 JS token。使用普通 token 返回的是与普通获取相同的空壳,里面没有什么可解析的。

前置条件

在写任何代码之前,你需要准备好几样东西。它们都不会花太久。

基本的 Python。 你应当能自如地编写并运行一个 Python 脚本,并用 pip 安装包。如果你刚接触解析 HTML,我们关于如何在 Python 中使用 BeautifulSoup 的入门读物涵盖了本教程所依赖的选择器基础。

Python 3.8 或更高版本。python --version 确认你的版本。如果没有,请从 python.org 或像 Anaconda 这样的发行版安装它。

一个 Crawlbase 账户和 JS token。 注册、打开你的仪表板,并从账户文档页复制你的 JavaScript(JS)token。把 token 当作密码对待:它用于验证你的请求身份,所以请别把它放进版本控制里。免费套餐包含足够从头到尾跟完本指南的请求。

搭建项目

创建一个虚拟环境以便让项目依赖保持隔离,然后安装抓取器所需的两个库。

bash
python --version

python -m venv monster_env
source monster_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,用 monster_env\Scripts\activate 而不是 source 那一行来激活环境。两个依赖完成全部工作:crawlbase 是 Crawling API 的官方客户端,而 beautifulsoup4 解析返回的 HTML,让你能按 CSS 选择器提取各个字段。

第 1 步:获取已渲染的搜索页

先取到成品页面。导入 CrawlingAPI 类,用你的 JS token 初始化它,并请求搜索 URL。在解析之前检查状态码能让失败响亮地暴露出来,而不是悄无声息。注意这两个等待选项:ajax_wait 告诉 API 等待异步内容加载完成,而 page_wait 保持固定的毫秒数,让那些迟渲染的卡片在页面被捕获之前出现。

python
from crawlbase import CrawlingAPI

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

def crawl(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    page_url = "https://www.monster.com/jobs/search?q=Java+Developer&where=New+York"
    html = crawl(page_url)
    print(html[:500] if html else "No HTML returned")

python scraper.py 运行脚本,你应当看到真实的职位卡片标记,而不是普通获取返回的那个空壳。五秒是一个合理的起始 page_wait;如果卡片回来时是空的,就把它调高。这在你写下一个选择器之前就确认了渲染是有效的。

Crawlbase Crawling API

Monster 需要一个在受信任 IP 背后渲染好、滚动好的页面,一次调用搞定。Crawling API 接收一个 JS token,在一个真实浏览器里运行页面,驱动滚动以让懒加载卡片出现,并在服务端通过住宅 IP 轮换,所以你无需自己运行一支无头舰队和一个代理池。先在免费套餐上把它指向一个公开搜索页。

第 2 步:检查职位卡片的结构

在写选择器之前,在你的浏览器里打开一个 Monster 搜索页,并用开发者工具检查一张职位卡片。在撰写本文时,每条列表坐落在一个携带 data-testid="svx_jobCard" 属性的 <article> 里,并被归在一个 id 为 JobCardGrid 的容器内。在每张卡片内,你想要的字段挂在稳定的 test id 上:

  • 职位名称和 URL 位于一个 <a data-testid="jobTitle"> 上:链接文本是名称,href 是帖子 URL。
  • 公司 位于一个 <span data-testid="company"> 里。
  • 地点 位于一个 <span data-testid="jobDetailLocation"> 里。
  • 发布日期 当 Monster 为那条列表展示它时,出现在一个 <span data-testid="jobDetailDateRecency"> 里。

瞄准 data-testid 属性而不是 CSS 类名是有意为之。Monster 送来的是哈希的、构建生成的类名,它们每次部署都翻搅一遍,而 test id 往往会保持原位,因为站点自己的测试套件依赖它们。它们是页面给你的最耐用的抓手。

第 3 步:用 BeautifulSoup 解析职位卡片

手里有了已渲染的 HTML,就把它加载进 BeautifulSoup,选取每张职位卡片,并按其 test id 从卡片里取出每个字段。把每次字段读取包在一个小巧的辅助函数里,它在某个元素缺失时返回空字符串,这样一个缺失的字段就永不会让运行崩溃。

python
from bs4 import BeautifulSoup

def text_of(card, selector):
    el = card.select_one(selector)
    return el.get_text(strip=True) if el else ""

def parse_jobs(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select('div#JobCardGrid article[data-testid="svx_jobCard"]')

    jobs = []
    for card in cards:
        title_link = card.select_one('a[data-testid="jobTitle"]')
        jobs.append({
            "title": title_link.get_text(strip=True) if title_link else "",
            "company": text_of(card, 'span[data-testid="company"]'),
            "location": text_of(card, 'span[data-testid="jobDetailLocation"]'),
            "posted": text_of(card, 'span[data-testid="jobDetailDateRecency"]'),
            "url": title_link["href"] if title_link else "",
        })
    return jobs

职位名称链接读取一次并复用,因为它在它的 href 上同时携带名称文本和帖子 URL。其余一切都流经 text_of 辅助函数,它查询单个元素,并在它缺失时返回空字符串,而不是在对着空值的 .get_text() 调用上抛错。这能在某张卡片省略了某个字段时让提取保持稳健,而这很常见,因为并非每条列表都暴露一个发布日期。

选择器会漂移

Monster 的标记会不经通知地变化,上面的 data-testid 值在一次未来的改版里可能被重命名。把它们当作一个起始模板,而非一份契约。当某个字段在每张卡片上都返回为空时,在你的浏览器开发者工具里重新检查一个实时搜索页,并更新选择器。对任何生产级抓取器来说,定期的选择器维护都属正常,不是出了什么问题的迹象。

第 4 步:用滚动捕获完整结果页

第 1 步里的获取渲染了页面,但 Monster 只加载第一批卡片,直到用户滚动。要在一次请求里拉取整个结果集,把滚动的活儿交给 Crawling API,而不是自己运行一个无头浏览器。两个选项控制它:scroll 打开基于滚动的分页,而 scroll_interval 设置 API 持续滚动多久,以秒计,最多 60 秒。当滚动启用时,无需再设 page_wait,因为滚动窗口已经给了内容加载的时间。

python
def crawl_with_scroll(page_url):
    options = {
        "ajax_wait": "true",
        "scroll": "true",
        "scroll_interval": "60",
    }
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

当你想要完整页面而不只是头几张卡片时,把 crawl_with_scroll 换进来替代普通的 crawl。第 3 步里的解析器不变:它仍然选取返回 HTML 里的每个 svx_jobCard,只是现在数量更多了。

第 5 步:把它组装起来并保存为 JSON

现在把滚动获取、解析器和一个小巧的 JSON 写入器接成一个可运行的脚本。获取已渲染、已滚动的 HTML,把它交给解析器,并把结构化记录写入磁盘。

python
import json
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

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

def crawl_with_scroll(page_url):
    options = {"ajax_wait": "true", "scroll": "true", "scroll_interval": "60"}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    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 ""

def parse_jobs(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select('div#JobCardGrid article[data-testid="svx_jobCard"]')
    jobs = []
    for card in cards:
        title_link = card.select_one('a[data-testid="jobTitle"]')
        jobs.append({
            "title": title_link.get_text(strip=True) if title_link else "",
            "company": text_of(card, 'span[data-testid="company"]'),
            "location": text_of(card, 'span[data-testid="jobDetailLocation"]'),
            "posted": text_of(card, 'span[data-testid="jobDetailDateRecency"]'),
            "url": title_link["href"] if title_link else "",
        })
    return jobs

def main():
    page_url = "https://www.monster.com/jobs/search?q=Java+Developer&where=New+York"
    html = crawl_with_scroll(page_url)
    if not html:
        return
    jobs = parse_jobs(html)
    with open("monster_jobs.json", "w") as f:
        json.dump(jobs, f, indent=2)
    print(f"Saved {len(jobs)} jobs to monster_jobs.json")

if __name__ == "__main__":
    main()

输出长什么样

python scraper.py 运行完整脚本,你会得到一份干净的结构化记录列表,每条帖子一条,可以写入 CSV 或一个数据库。

json
[
  {
    "title": "Java Developer (Core Java)",
    "company": "Georgia IT Inc.",
    "location": "New York, NY",
    "posted": "2 days ago",
    "url": "https://www.monster.com/job-openings/java-developer-core-java-new-york-ny"
  },
  {
    "title": "Java Backend Developer",
    "company": "Diverse Lynx",
    "location": "Manhattan, NY",
    "posted": "5 days ago",
    "url": "https://www.monster.com/job-openings/java-backend-developer-manhattan-ny"
  }
]

遍历结果页

一个搜索页只是演示;一项真实的任务会跨许多页面和查询运行。Monster 用一个 page 查询参数分页,所以你可以通过递增它来遍历各页,并复用同一对获取与解析。因为每个结果页都共享相同的卡片结构,你已经写好的解析器无需改动就能在所有页面上运行。给循环设定节奏,这样你就不会一个接一个地发请求。

python
import time

def scrape_pages(query, where, pages=3):
    all_jobs = []
    for page in range(1, pages + 1):
        url = (
            "https://www.monster.com/jobs/search"
            f"?q={query}&where={where}&page={page}"
        )
        html = crawl_with_scroll(url)
        if html:
            all_jobs.extend(parse_jobs(html))
        time.sleep(2)
    return all_jobs

各页之间的 time.sleep(2) 是有意为之。在一个紧凑的循环里猛攻搜索,是被限流的最快方式,即便渲染和轮换已替你处理好。把请求分散开来,并在某一页返回零张新卡片时尽早停止。

保持不被封锁

即便渲染和滚动已被处理好,Monster 也会监视抓取器形态的流量。几个习惯能让一次运行保持健康,它们适用于任何难啃的商业目标。

  • 给你的请求设定节奏。 把请求分散开来,并变换你的查询,而不要全速爬一条搜索路径。
  • 依靠轮换。 一池住宅 IP 会把请求分散到许多真实用户地址上,这样就没有任何单个地址会触发速率限制。Crawling API 替你处理这件事;如果你自己搭建技术栈,这就是要做好的那一部分。
  • 读懂状态码。 一次运行开始返回挑战或错误,就是在告诉你当前的速率或 IP 等级已经不够了。把它当作后退的信号,而不是可忽略的噪声。

关于更宽泛的策略手册,参阅如何在不被封锁的情况下抓取网站,以及关于网页抓取时如何绕过验证码的更深入探讨。如果你的项目还涉及职业人脉网络,我们关于如何抓取 LinkedIn 的指南涵盖了一个可比的、需登录感知的目标。而如果你更想把自己的流量通过一池轮换代理路由,而不是使用托管的 API,Smart AI Proxy(也叫 AI Proxy)以一个即插即用的代理端点为你提供同样的住宅 IP 轮换。

抓取 Monster 合法吗?

抓取 Monster 是否被允许,取决于 Monster 的服务条款、你所在的司法辖区,以及你拿数据做什么。Monster 的条款限制自动化访问,所以无论你的工具有多谨慎,抓取都可能违背这些条款。这里的任何代码都改变不了这一点;它只是把技术部分做通而已。请阅读 Monster 服务条款及其 robots.txt,并把两者都当作你采集内容的边界。

有几条值得守住的底线。只采集公开的职位列表数据:任何人不登录就能在一个公开搜索页上看到的职位名称、公司、地点、发布日期和帖子 URL。尊重 Monster 所声明的速率预期,并把你的请求量保持得足够低,以免给它的服务器造成压力。把你的工作严格限定在公开职位帖子上。

本指南刻意只限于公开职位列表,因为那是让这项工作站得住脚的那条线。它不涵盖求职者或招聘方的个人数据、简历或候选人资料、任何登录或付费层后面的内容,或任何绕过认证的尝试。求职者和招聘方的个人信息正是那类应当置之不理的数据。如果你的项目需要超出公开帖子的内容,正确的路径是一份官方数据协议或 Monster 自己的雇主工具,而不是一个更聪明的抓取器。

回顾

核心要点

  • Monster 是客户端渲染的。 普通获取返回一个空壳,所以你必须先渲染页面再解析它。
  • 渲染、受信任的 IP 和滚动要一起到位。 带 JS token 的 Crawling API 一次调用就把这三样都做了;scrollscroll_interval 把懒加载卡片驱动进视野。
  • BeautifulSoup 负责提取。 把职位名称、公司、地点、发布日期和 URL 映射到卡片的 data-testid 抓手,并预期那些抓手会漂移。
  • 通过遍历页面扩展。 用同一个解析器遍历 Monster 的 page 参数,并给循环设定节奏,这样你就不会被限流。
  • 坚守公开列表。 尊重 Monster 的 ToS 和 robots.txt,并绝不触碰求职者或招聘方的个人数据、简历或登录墙后面的页面。

常见问题

我可以只用 requests 和 BeautifulSoup 抓取 Monster 职位吗?

不可靠。Monster 用 JavaScript 在浏览器里渲染它的职位卡片,并随你滚动而懒加载更多,所以一个原始 requests 调用返回的是状态 200,而列表为空。你需要某种先渲染页面并驱动滚动的东西,而这正是 Crawling API 的 JS token 加滚动选项在 BeautifulSoup 看到 HTML 之前所处理的事。

对于 Monster,我需要普通 token 还是 JS token?

JS token。普通 token 获取静态 HTML,在 Monster 上那是与普通获取相同的空壳。JS token 会先在一个真实浏览器里渲染页面再交回 HTML,所以当 BeautifulSoup 解析时职位卡片就在那里。

我该如何处理 Monster 的滚动分页?

在请求选项里传入 scroll: "true"scroll_interval: "60"。Crawling API 随后会把页面滚动最多 60 秒,即上限,让懒加载卡片在 HTML 被捕获之前渲染。启用滚动后,你不再需要 page_wait。要超出一个结果页,递增 Monster 的 page 查询参数,并复用同一对获取与解析。

我的选择器返回空字符串。是什么变了?

几乎可以肯定是 Monster 的标记。解析器依赖的 data-testid 抓手可能在一次改版里被重命名,而构建生成的类名一直在不断翻搅。在你的浏览器开发者工具里重新检查一个实时搜索页,并更新选择器。对任何生产级抓取器来说,定期的选择器维护都属正常。

我可以从 Monster 抓取简历或招聘方联系方式吗?

不行,而且本指南也不涵盖它。简历、候选人资料以及招聘方或求职者的个人数据坐落在一个登录后面,或属于个人信息,而不是公开的职位列表数据。抓取登录墙后面的内容,或绕过认证去触及它,都属于本文范围之外,并违背 Monster 的条款。把你的范围限定在搜索页和列表页上的公开帖子。

抓取 Monster 时我该如何避免被封?

把你的每 IP 请求速率保持得低,变换你的查询而不是循环一条搜索路径,并通过轮换的住宅 IP 路由,这样就没有任何单个地址会触发速率限制。Crawling API 替你管理轮换和一个受信任的 IP 池;如果你搭建自己的技术栈,那就是要投入的那部分。留意状态码,并在你开始看到挑战时后退。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量