☰
Selenium招聘数据采集实战:动态加载、反爬应对与数据清洗
2026/10/14 19:58:57 网站建设 项目流程

1. 招聘数据采集这件事,为什么值得认真做一遍

招聘网站上的岗位信息,本质上是一份实时更新的行业切片。哪些岗位在扩招、哪些技能被反复提及、薪资区间怎么分布、不同城市的需求差异有多大——这些问题如果只靠手动翻页去看,效率低不说,还很容易被自己的主观印象带偏。我最早做这类采集,是因为想搞清楚一个具体问题:某个技术方向在本地到底还有没有真实需求,还是只是招聘网站上的"僵尸岗位"。

手动翻了十几页之后我放弃了,因为信息太散,而且页面上的岗位随时在变。后来我用 Selenium 写了一个采集脚本,把前五页的岗位信息完整抓下来,存成结构化数据,再去做统计和筛选。这套流程跑通之后,我发现它的价值远不止"抓数据"本身——它逼着你去理解页面结构、反爬机制、数据清洗、字段对齐这一整条链路,而这些能力在任何一个需要"从网页拿数据"的场景里都是通用的。

这篇内容适合两类人看:一类是刚接触 Selenium、想找一个真实项目练手的朋友;另一类是有采集需求、但被动态加载和反爬卡住的人。我会把整个流程拆开讲,包括每一步为什么这么做、哪些地方容易翻车、以及我踩过的那些坑。需要说明的是,本文只讨论技术实现思路,实际使用时请务必遵守目标网站的服务条款和相关法律法规,控制请求频率,不要对服务器造成压力。

2. 动手之前,先把采集目标和页面结构想清楚

2.1 到底要抓哪些字段

很多人一上来就写代码,结果抓到一半发现字段不够用,又回头改。我的习惯是先明确"最小可用字段集"。对于招聘信息采集,我一般会抓这几项:

  • 岗位名称
  • 公司名称
  • 工作地点
  • 薪资范围
  • 学历要求
  • 经验要求
  • 发布时间
  • 岗位详情链接

这八个字段基本能支撑后续的大部分分析。如果你还想做更细的文本分析,可以额外抓岗位职责和任职要求的正文,但那会显著增加请求量和解析复杂度,建议第一版先不做。

这里有个细节值得说:薪资字段在页面上往往是一个字符串,比如"1.5-2万/月"或者"15-20万/年",直接存字符串后续没法计算。我的做法是保留原始字符串,同时额外生成两个数值字段(下限和上限,统一换算成"元/月"),这样既保留了原始信息,又方便做统计。

2.2 列表页和详情页的分工

招聘网站的典型结构是:搜索列表页展示岗位摘要,点进去是详情页展示完整描述。前五页的列表页信息通常已经包含了上面说的八个字段中的大部分,所以第一版可以只抓列表页,不进入详情页。这样做的好处是请求量小、速度快、被封的风险低。

如果你确实需要岗位职责正文,那就必须进详情页,请求量会翻好几倍。这时候更要控制节奏,比如每抓一条随机等待 2 到 5 秒,并且做好断点续抓,避免中途失败要从头再来。

2.3 动态加载带来的第一个坑

招聘网站的列表页很多是动态渲染的,也就是说你直接请求 HTML 拿到的可能是空壳,真正的岗位数据是页面加载后通过接口异步填充的。这就是为什么用普通请求库抓不到内容,而必须用 Selenium 这类能驱动真实浏览器的工具。

但即便用了 Selenium,也有个常见误区:页面load事件触发时,岗位列表可能还没渲染完。如果你在driver.get()之后立刻去查找元素,大概率会报NoSuchElementException。正确的做法是配合显式等待,等到目标元素真正出现再往下走。这一点我在第 4 节会详细展开。

3. 环境搭建与驱动配置:别在第一步就卡住

3.1 依赖安装

我用的技术栈是 Python + Selenium + pandas,这套组合足够应付大部分采集任务。安装命令如下:

pip install selenium pandas openpyxl

openpyxl是为了最后把数据导出成 Excel,方便非技术同事查看。如果你只打算存 CSV,可以不装。

3.2 浏览器驱动的版本匹配问题

Selenium 需要浏览器驱动才能工作。早期版本里,驱动版本必须和浏览器版本严格对应,版本不匹配就会报错,这是新手最容易卡住的地方。好消息是现在 Selenium 4.6 以上版本内置了驱动管理功能,可以自动下载匹配的驱动,省去了手动配置的麻烦。

from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("--start-maximized") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) driver = webdriver.Chrome(options=options) driver.execute_cdp_cmd( "Page.addScriptToEvaluateOnNewDocument", {"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"} )

上面这几行配置的作用是降低被识别为自动化工具的概率。navigator.webdriver是一个很容易被检测的特征,默认情况下 Selenium 驱动的浏览器里这个值是true,通过 CDP 命令把它改掉,能规避一部分基础检测。需要强调的是,这只是让浏览器行为更接近正常用户,不代表可以无视网站规则去高频请求。

3.3 无头模式的取舍

无头模式(headless)跑起来不弹窗口,适合放在服务器上定时执行。但我的经验是,调试阶段千万别开无头模式,因为你根本看不到页面到底加载成什么样了。等脚本稳定之后,再切换到无头模式部署。

# 调试完成后可启用 # options.add_argument("--headless=new")

另外,无头模式下有些网站的渲染行为和有头模式不一样,可能出现元素定位失败的情况。如果遇到这种问题,先切回有头模式确认逻辑没问题,再排查无头模式的差异。

4. 翻页逻辑与元素定位的实战细节

4.1 显式等待比 sleep 靠谱得多

新手最爱用的是time.sleep(),但它的问题是:等短了元素没出来,等长了浪费时间。显式等待(WebDriverWait)会轮询检查条件,条件满足就立刻继续,效率高得多。

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 15) job_cards = wait.until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, ".job-list-item")) )

这里的 15 秒是最大等待时间,不是固定等待时间。如果元素 2 秒就出现了,它 2 秒就返回。选择器.job-list-item只是示例,实际使用时你需要打开浏览器的开发者工具,找到包裹每个岗位卡片的真实类名。

4.2 翻页的两种思路

翻页有两种做法:一是找到"下一页"按钮然后点击,二是直接构造每一页的 URL。我更推荐第二种,因为 URL 翻页更稳定,不容易受页面弹窗、广告遮挡的影响。

base_url = "https://example.com/jobs?keyword=python&page={}" for page in range(1, 6): driver.get(base_url.format(page)) # 等待并解析

用 URL 翻页时要注意,有些网站的页码参数是从 0 开始的,有些是 1 开始,还有些用的是offset而不是page。这些都需要你手动翻几页,观察地址栏的变化来确认。

4.3 解析岗位卡片时的容错处理

列表页里每个岗位卡片的内部结构可能不完全一致,比如有的岗位没有标注薪资,有的公司名特别长被截断。如果你直接按固定顺序取元素,遇到缺失字段就会报错或者错位。

我的做法是给每个字段单独做容错:

def safe_text(card, selector): try: return card.find_element(By.CSS_SELECTOR, selector).text.strip() except Exception: return ""

这样即使某个字段缺失,也只是返回空字符串,不会让整个采集流程崩掉。采集这种活儿,稳定性比"抓得全"更重要,因为一条数据出错导致整个脚本挂掉,损失的是前面所有的进度。

4.4 一个容易被忽略的细节:滚动加载

有些招聘网站的列表不是一次性全部渲染的,而是滚动到可视区域才加载。这种情况下,即使你等待了元素出现,也可能只等到前几条。解决办法是在解析前先执行一次滚动到底部的操作:

driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(2)

滚动之后最好再等一两秒,给页面渲染留出时间。这个坑我在第一次做采集时踩得很惨,明明页面上肉眼能看到 30 条,代码只抓到 10 条,排查了半天才发现是懒加载的问题。

5. 数据清洗与存储:抓下来只是半成品

5.1 薪资字段的标准化处理

前面提到薪资要拆成数值。这里给一个实际可用的处理函数:

import re def parse_salary(text): if not text: return None, None text = text.replace(" ", "") # 匹配 "1.5-2万/月" 这类格式 match = re.search(r"(\d+\.?\d*)-(\d+\.?\d*)(万|千)?/(月|年)", text) if not match: return None, None low, high, unit, period = match.groups() low, high = float(low), float(high) if unit == "万": low, high = low * 10000, high * 10000 elif unit == "千": low, high = low * 1000, high * 1000 if period == "年": low, high = low / 12, high / 12 return round(low), round(high)

这个函数覆盖了大部分常见格式,但招聘网站上的薪资写法五花八门,比如"面议"、"15K-25K"、"6-8千/月"等等。我的建议是先把所有原始薪资字符串去重打印出来,看看实际有几种格式,再针对性地写正则。不要指望一个正则搞定所有情况。

5.2 去重逻辑

同一个岗位可能在不同页面重复出现,尤其是搜索结果排序不稳定的时候。去重我一般用"岗位名称 + 公司名称 + 工作地点"作为联合主键,三个字段都相同就认为是重复。

df = df.drop_duplicates(subset=["岗位名称", "公司名称", "工作地点"], keep="first")

用keep="first"保留第一次出现的记录,因为通常第一次出现的位置更靠前,可能是更匹配的结果。

5.3 存储格式的选择

采集过程中我建议先存 CSV,因为写入简单、随时可以中断、出问题也容易查看。等数据量大了或者需要多人协作,再考虑存数据库。

df.to_csv("jobs.csv", index=False, encoding="utf-8-sig")

注意encoding="utf-8-sig"这个细节。如果直接用utf-8,用 Excel 打开 CSV 时中文会乱码,加上-sig就能正常显示。这个坑几乎每个做中文数据采集的人都踩过。

6. 反爬应对与采集节奏控制

6.1 为什么不能贪快

采集最大的风险不是技术难度,而是请求频率过高导致 IP 被限制。一旦被限制,轻则返回验证码页面,重则直接封禁。所以节奏控制是采集脚本的核心组成部分,不是可选项。

我的经验值是:列表页每页之间随机等待 3 到 8 秒,详情页每条之间随机等待 2 到 5 秒。这个速度看起来慢,但前五页也就几十条数据,几分钟就跑完了,完全没必要为了省几分钟去冒被封的风险。

import random, time def random_delay(low=3, high=8): time.sleep(random.uniform(low, high))

用随机而不是固定值,是因为固定间隔的请求模式很容易被识别出来。

6.2 请求头与浏览器指纹

虽然 Selenium 驱动的是真实浏览器,但默认的请求头里还是有一些自动化特征。可以通过 CDP 命令修改 User-Agent:

driver.execute_cdp_cmd("Network.setUserAgentOverride", { "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..." })

不过说实话,对于招聘网站这种量级的采集,只要你不是高频请求,基础的指纹伪装加上合理的等待间隔,基本就够了。过度追求"完美伪装"反而容易陷入无休止的对抗,性价比不高。

6.3 遇到验证码怎么办

如果采集过程中遇到验证码页面,最稳妥的做法是暂停脚本,手动处理后再继续。我不建议去研究自动识别验证码,一方面技术门槛高,另一方面这已经越过了合理使用的边界。

实际做法是在脚本里加一个检测:如果当前 URL 或页面标题包含验证相关关键词,就打印提示并暂停,等待人工介入。

if "verify" in driver.current_url.lower(): input("检测到验证页面,请手动处理后按回车继续...")

6.4 断点续抓的设计

采集任务最怕跑到一半失败,前面抓的全丢了。我的做法是每抓完一页就把数据追加写入文件,而不是全部抓完再统一保存。

import os def save_append(df, path="jobs.csv"): header = not os.path.exists(path) df.to_csv(path, mode="a", header=header, index=False, encoding="utf-8-sig")

这样即使脚本中途挂了,已经抓到的数据也还在文件里,下次可以从失败的那一页继续。

7. 几个我实际踩过的坑和排查思路

7.1 元素定位突然失效

最常见的原因是网站改版,类名变了。排查方法是打开开发者工具,重新确认目标元素的类名或结构。我的习惯是把所有选择器集中定义在脚本开头,这样改版时只需要改一处。

SELECTORS = { "job_card": ".job-list-item", "job_name": ".job-name", "company": ".company-name", "salary": ".salary", "location": ".work-location", }

集中管理选择器还有一个好处:不同页面的结构如果有细微差异,可以在这里做统一适配。

7.2 抓到的数据错位

如果发现岗位名称和公司名称对不上,大概率是解析逻辑的问题。常见原因是把整个页面的元素列表当成每个卡片的子元素来取,导致索引错位。正确的做法是先定位到每个卡片,再在卡片内部查找子元素。

cards = driver.find_elements(By.CSS_SELECTOR, SELECTORS["job_card"]) for card in cards: name = safe_text(card, SELECTORS["job_name"]) company = safe_text(card, SELECTORS["company"])

注意find_elements和find_element的区别:前者返回列表,后者返回单个元素。在卡片内部查找时用card.find_element,这样作用域就限定在当前卡片内。

7.3 中文乱码

前面提过 CSV 的编码问题,这里再补充一个场景:如果你在 Windows 命令行里直接 print 中文,可能会报编码错误。解决办法是在脚本开头设置标准输出编码:

import sys sys.stdout.reconfigure(encoding="utf-8")

7.4 脚本跑着跑着浏览器卡死

长时间运行的 Selenium 脚本偶尔会遇到浏览器无响应的情况。我的处理方式是给关键操作加上超时,并在异常时重启浏览器实例。虽然麻烦,但比整个任务失败要好。

driver.set_page_load_timeout(30)

设置页面加载超时后,如果某个页面 30 秒还没加载完,会抛出异常,你可以在异常处理里决定是重试还是跳过。

8. 采集之后:数据能拿来做什么

数据抓下来只是起点,真正有价值的是后续分析。我一般会做这几件事:

第一,按城市统计岗位数量,看看需求集中在哪些地方。第二,按薪资区间做分布,了解市场行情。第三,对岗位名称做词频统计,提取高频技能关键词。第四,按发布时间排序,看看最近一周新增了哪些岗位。

这些分析用 pandas 几行代码就能搞定:

# 按城市统计 city_counts = df["工作地点"].value_counts().head(10) # 薪资区间分布 df["薪资下限"] = df["薪资下限"].astype(float) salary_bins = pd.cut(df["薪资下限"], bins=[0, 8000, 15000, 25000, 50000, 999999], labels=["8K以下", "8K-15K", "15K-25K", "25K-50K", "50K以上"]) salary_dist = salary_bins.value_counts()

做词频统计时,中文需要先分词。可以用 jieba:

import jieba from collections import Counter all_titles = " ".join(df["岗位名称"].tolist()) words = jieba.lcut(all_titles) words = [w for w in words if len(w) > 1] top_words = Counter(words).most_common(20)

这些分析结果能帮你快速判断一个方向的市场热度,比看各种行业报告来得直接。

9. 关于合规和长期维护的几点个人体会

做采集这些年,我最大的体会是:技术实现从来不是最难的部分,难的是把握好边界。招聘网站的数据是公开的,但公开不等于可以随意批量抓取。我的原则是:只抓自己分析需要的最小数据量,控制频率,不对外传播原始数据,不用于商业用途。这既是尊重网站的劳动,也是保护自己。

另一个体会是关于脚本的维护。招聘网站的页面结构会变,今天能跑的脚本,可能下个月就失效了。所以我在写的时候会尽量把易变的部分(选择器、URL 模板、字段解析规则)抽出来单独维护,核心的采集流程保持稳定。这样改版时只需要调整配置,不用重写整个脚本。

最后说一个实用的小技巧:如果你只是偶尔需要采集一次,没必要把脚本写得太复杂,能跑通、能导出数据就行。但如果你打算定期采集,那就值得花时间把断点续抓、异常重试、日志记录这些基础设施做好,因为长期来看,这些投入会省下大量排查问题的时间。我自己就是从"一次性脚本"慢慢演进到"带日志和重试的采集框架"的,这个过程本身就是很好的学习路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询