资讯动态

Selenium反爬实战:稳定采集招聘网站公开职位数据

发布时间:2026/10/21 9:03:56 来源:尧图企业网站定制
这个项目起因其实很朴素我需要整理一批公开招聘信息用于行业岗位需求分析于是想到用Selenium驱动本地Chrome把某招聘平台Web端的职位列表页抓下来。网上这类教程一抓一大把但真正跑起来才发现能让你跑通一次的代码和能稳定跑一整天的代码完全是两回事。标题里的“绕过反爬”四个字我先说清楚本文讲的不是攻击和破坏而是理解常见的自动化检测机制在合规前提下做有限度的数据采集实验。如果你打算批量抓取用户隐私信息、骚扰求职者、或者做任何损人利己的事请立刻关掉这篇文章。以下所有经验只服务于正常的学习和数据分析场景。1. 反爬的本质是风控分层先弄清楚你的对手再动手1.1 招聘网站为什么把反爬做得这么严招聘平台和一般内容站不一样它的数据有两层敏感属性一是企业端的职位信息涉及招聘预算和人才策略二是求职者端的简历信息属于强隐私数据。所以这类平台的风控强度天然比普通资讯站高一个量级。套用我当时的心理活动“我就抓个职位标题和薪资范围至于吗”——事实是平台根本不知道你抓的是标题还是简历在风控眼里所有非人类操作的流量都是潜在风险。这种风控通常是分层设计的我把它理解成三层门卫第一层是基础特征校验请求头、User-Agent、IP归属地、Cookie一致性这一层过滤掉90%的初级爬虫。第二层是行为环境校验浏览器指纹、JavaScript执行结果、DOM操作痕迹、WebDriver标志。这一层专门对付Selenium、Playwright这类自动化工具。第三层是行为序列分析点击轨迹、停留时长、鼠标移动、滚动节奏、并发频率。这一层最阴险它可以做到“你看起来全是真的但就是快了那么一点”。很多教程告诉你“加个UA、用个代理、随机延时”就能解决实际上只绕过了第一层甚至第一层都没绕过。我自己在这上面吃了整整两天的亏。1.2 为什么本地Chrome比模拟请求更靠谱最初我也想过用requests直接拉接口看起来效率高得多。但实际操作起来你会发现招聘网站的主要数据已经全面改为JavaScript动态渲染接口参数里带着一串加密签名签名算法会不定期更换。每次签名变更你都得重新逆向JS这在工程上是个无底洞。用Selenium驱动本地Chrome的思路等于雇了一个“真人替身”去操作浏览器页面渲染不用管、JS执行不用管、接口签名不用管浏览器自己会做。代价是速度慢、资源占用高、容易被行为检测抓。但对应付招聘网站这种级别的反爬Selenium在“效果”和“开发成本”之间反而是最优解尤其是你手里有一个真实的浏览器环境时。这一步的选型理由说白了就一句话打得过就硬打打不过就让真的浏览器去跑。1.3 合规先行哪些事绝对不能做在展开技术细节之前我把底线放在前面避免有朋友看完文章走偏只采集公开展示的职位信息不碰任何需要登录才能看到的个人数据。控制请求频率不对平台服务器造成压力甚至要比“正常人慢”更慢。不用采集到的数据进行商业牟利不二次转卖不骚扰任何用户。如果平台条款明确禁止爬虫或者robots.txt中标注了不允许抓取的目录要主动放弃对应区域。说实话招聘数据本身的价值远没有很多人想象中那么大真正有价值的是数据里面的趋势判断。而为了一篇文章或一次分析去挑战平台底线、甚至把自己送进风险名单非常不值得。2. 环境准备与初版脚本从能跑通到能稳定跑2.1 版本组合是第一个隐形的大坑Selenium这个库有个老毛病版本和浏览器驱动必须严格匹配一个对不上就会出现各种莫名其妙的报错。我当时的稳定组合是Python 3.11、Selenium 4.15、Chrome 118.x对应的Chromedriver 118。你不需要照抄我的版本但你需要记住一个原则Chromedriver的大版本号必须和Chrome的大版本号完全一致。我有一次偷懒用Chrome 122的浏览器配了一个Chromedriver 116当时理论上说兼容结果启动浏览器后页面白屏、元素定位随机超时。后来切换到完全匹配的版本组合问题立刻消失。这类坑不会出现在任何教程里只会藏在你的报错日志里。给所有第一次接触的人一个建议先去chrome://version把浏览器版本抄下来再到对应地址下载同版本驱动不要直接装最新版。2.2 第一版脚本三步就撞上人机验证初次脚本写得很简单逻辑上就三步打开浏览器、访问职位列表页、读取页面上的职位卡片数据。代码量不大但跑起来不到30秒就被拦了。from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By import time options webdriver.ChromeOptions() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64)) service Service(chromedriver.exe) driver webdriver.Chrome(serviceservice, optionsoptions) driver.get(https://example.job.list.page) time.sleep(5) items driver.find_elements(By.CLASS_NAME, job-card) for item in items[:10]: title item.find_element(By.CSS_SELECTOR, .job-title).text print(title) driver.quit()这个脚本的问题很明显真实浏览器每次都干干净净的而自动化启动的Chrome会带上一堆自动化痕迹。哪怕我明确加了disable-blink-featuresAutomationControlled也只是改了一个可检测项真正的检测逻辑藏在JS执行环境里。2.3 手动操作对比最快定位差异的方法遇到拦截时第一反应不要急着改代码而是先做对比实验。我当时的操作是手动打开同一个页面正常点鼠标、滚动、关闭全程无拦截然后用自动化脚本再去打开立刻触发滑块验证。这个过程说明问题不在IP、不在UA而在自动化工具的表征上。这个对比方法到现在我都觉得是调试反爬问题的第一课。你不用去猜测是哪一行代码出了问题直接对比“真人操作”和“自动化操作”的差异就行。把差异点一点点消除比盲猜效率高很多。判断逻辑大致如下手动正常、自动被拦大概率是自动化特征太明显。手动被拦、自动也被拦可能是IP或UA出问题跟Selenium无关。直接requests正常Selenium被拦说明平台用JS检测了window属性。找到了差异才能确定后续的修复方向。3. 踩坑实录五个让人掉头发的反爬细节3.1 WebDriver特征修复navigator.webdriver不是唯一破绽Selenium启动的Chrome最著名的标志是window.navigator.webdriver会变成true正常浏览器这个值是false或者undefined。很多教程教你用add_experimental_option(excludeSwitches, [enable-automation])来隐藏但我实测下来只靠这一个参数在较新的Chrome版本上并不可靠。真正有效的做法是配合Chrome DevTools Protocol在页面加载前注入一段JS改写掉自动化相关属性。核心思路是抢在页面脚本执行之前先把环境“伪装”成正常浏览器。options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) driver webdriver.Chrome(serviceservice, optionsoptions) driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); })这里有个细节很多人会忽略execute_cdp_cmd必须在driver.get()之前执行因为它的作用是在每个“新文档”开始加载时注入脚本如果页面已经加载完再执行等于什么也没干。不过我也得说实话这招只能应对基础检测。真正成熟的风控还会检查navigator.plugins的完整性、chrome对象是否存在、window.outerSize和window.innerSize的差值甚至监听MutationObserver里的DOM操作异常。所以这不是银弹但确实能让通过率从20%提升到80%以上。3.2 滑块验证与高频封禁我最后选择了人工介入很多人最关心的是滑块验证怎么自动过但我必须告诉你一个我不太愿意公开的结论在招聘网站这个级别自己写代码破解滑块的可行性很低且很容易触犯法律和平台协议。滑块验证的后端会综合判断拖动轨迹、速度曲线、停顿、压力传感器移动端、环境指纹等几十个特征单纯模拟一个“完美拖动”反而会被立刻标记。我当时的解决方案很“土”写一个滑块出现后的自动暂停机制让脚本停下来并推送一个提醒然后由我亲自去拖动滑块完成验证之后脚本再继续。听起来很没技术含量但它是最稳定、最安全、最不惹麻烦的方案。我中途也试过第三方打码平台稳定性忽高忽低费用倒是花了不少后来彻底放弃了。具体实现思路是一个“人机协同”的保护壳def check_slider(driver): try: slider driver.find_element(By.XPATH, //div[contains(class,slider)]) if slider.is_displayed(): print([检测] 出现滑块验证等人工介入) # 这里可以拼一个消息通知或者直接input等待 input(滑完后回车继续...) time.sleep(2) return True except Exception: pass return False说句题外话用input阻塞脚本看起来简陋但你人在电脑前时会非常实用。等跑完一轮你会发现绝大多数精力消耗根本不是写代码而是如何优雅地处理这些意外中断。3.3 请求频率、IP限制与重试策略低调才是王道即便你完美伪装了浏览器特征只要频率上来了一样会被封。我做过一个侧写用同一个IP、无间隔连续抓取大约在第163次请求时开始出现频繁滑块验证。后面我加入了随机延时和指数退避同样量级的任务跑了整整一上午一次验证码都没出。这里的关键不是“延时多久”而是“延时要有随机性”。固定每隔3秒一次的请求在风控眼里和机器没两样而人的操作总是长短不一的。我当时的延时区间是2到6秒随机并且每隔10个请求额外执行一次暂停模拟人去查资料、思考的状态。import random import time def human_delay(): base random.uniform(2, 6) time.sleep(base) if random.random() 0.1: time.sleep(random.uniform(5, 10))指数退避则用在请求失败或验证出现时。第一次失败等5秒第二次等10秒第三次等20秒最多到60秒封顶。实测下来这个策略能显著降低连续失败后的临时封禁概率。另外说一句免费共享代理的问题很多延迟高、掉线频繁更危险的是可能会被平台直接标记如果业务条件允许单IP低频率反而比动态IP更安全。3.4 动态加载、懒加载与等待策略不要再用time.sleep糊弄了招聘平台的列表页几乎全是懒加载鼠标滚动到页面底部才会触发下一页的请求。这对Selenium来说有两个直接后果一是driver.find_element经常找不到元素因为元素还没出现在DOM里二是直接滚到最底又会被检测到“非人类滚动节奏”。正确的做法是用Selenium提供的显式等待配合滚动而不是无脑time.sleep(5)。显式等待的本质是每隔一段时间检查一次条件是否成立条件满足就立刻继续比固定等待更高效、语义也更清晰。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10) cards wait.until( EC.presence_of_all_elements_located((By.CLASS_NAME, job-card)) )这里我踩过一个更细的坑presence_of_element_located和visibility_of_element_located的区别。前者只检查元素出现在DOM里不检查是否可见后者要求元素在页面上可见、有尺寸、不隐藏。如果你用前者去定位一个被CSS遮挡的元素后面调用.click()大概率会报“element is not clickable”。我后来养成一个习惯凡是涉及点击的都用element_to_be_clickable凡是涉及读取文字的都用presence_of_element_located两者职责不同混用就出问题。对于懒加载我的方案是用“渐进式滚动”替代“一键滚到底”。每次滚动一屏然后等待0.5到1秒重复若干次。这样既触发了动态加载又不会产生机械式的瞬移痕迹。def scroll_page(driver, steps8): for _ in range(steps): driver.execute_script(window.scrollBy(0, window.innerHeight * 0.7);) time.sleep(random.uniform(0.8, 1.5))3.5 Cookie与登录态手动扫码一次脚本复用一周招聘网站的很多数据接口虽然不强制登录但登录态的Cookie可以显著降低风控等级。我的做法是先用脚本打开浏览器手动扫码登录一次然后把登录后的Cookie序列化存到本地JSON文件。后续每次启动脚本时先加载这个Cookie文件再刷新页面。核心代码思路大概是这样的import json, time from selenium import webdriver # 第一次手动登录后保存Cookie def save_cookies(driver, pathcookies.json): time.sleep(20) # 留出扫码时间 with open(path, w, encodingutf-8) as f: json.dump(driver.get_cookies(), f) # 后续加载Cookie def load_cookies(driver, pathcookies.json): with open(path, r, encodingutf-8) as f: cookies json.load(f) driver.get(https://example.com) for cookie in cookies: driver.add_cookie(cookie) driver.refresh()Cookie会过期我的经验是每天跑任务前先检测一下登录态。判断方法很简单看页面头像区域是否还显示登录状态如果变了就重新扫码。整个过程中注意不要通过任何手段去获取他人私密信息只维护自己的账号环境。4. 稳定版采集流程把代码组织成能长期跑的样子4.1 整体结构三个类分离职责第一版脚本写成了面条式代码所有逻辑揉在一起改一个地方就得动全局。后来我重构成了三个核心类浏览器管理、页面抓取、数据提取。这个结构很老套但非常抗折腾。BrowserManager负责驱动初始化和登录态恢复PageFetcher负责访问页面、滚动、处理验证码中断DataExtractor负责从已加载的页面里抽字段、去重、落盘。class BrowserManager: def __init__(self): options webdriver.ChromeOptions() options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) self.driver webdriver.Chrome(optionsoptions) self.driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator,webdriver,{get:()undefined}) }) def restore_login(self, cookie_pathcookies.json): self.driver.get(https://example.com) with open(cookie_path, r, encodingutf-8) as f: cookies json.load(f) for cookie in cookies: self.driver.add_cookie(cookie) self.driver.refresh()4.2 稳定版数据抓取流程主流程的逻辑核心是“每页抓完先休息”配合超时重试。每一轮抓取之前先检测滑块再检查页面元素是否加载然后抽取数据。数据抽取不追求复杂关键是稳定。我用CSS选择器代替XPath因为XPath在页面结构微调时经常碎成一地CSS选择器相对弹性更好。def fetch_page(driver, url): driver.get(url) check_slider(driver) scroll_page(driver) return extract_data(driver) def extract_data(driver): items driver.find_elements(By.CLASS_NAME, job-card) result [] for item in items: try: title item.find_element(By.CSS_SELECTOR, .job-title).text salary item.find_element(By.CSS_SELECTOR, .salary).text company item.find_element(By.CSS_SELECTOR, .company-name).text if title and company: result.append({title: title, salary: salary, company: company}) except Exception: continue return result4.3 去重与落盘爬虫工程的另一半采集数据最容易被忽略的是去重。你重跑一次任务之前已经拿到的数据可能又重复抓一遍存进文件里全是脏数据。我的做法是在本地维护一个基于公司名和职位名的指纹集合新抓来的数据先计算指纹有重复就跳过只在新增时写入文件。seen set() def load_seen(pathseen.txt): if os.path.exists(path): with open(path, encodingutf-8) as f: return set(line.strip() for line in f) return set() def add_records(records): global seen new_records [] for r in records: fingerprint f{r[company]}|{r[title]} if fingerprint not in seen: seen.add(fingerprint) new_records.append(r) return new_records同时我把每次抓取任务的开始时间、结束时间、成功条数、失败条数都记录到日志中。这不是形式主义而是在后续排查“为什么这次被封”时数据能帮你判断是时间窗口的问题、还是延时策略失效。4.4 任务量级与观测数据我用这套流程跑了一个完整的实验任务目标抓取某职业方向的前300条公开职位信息。实际环境是单机、单IP、本地Chrome、无代理。结果见下表任务阶段初始脚本加入反检测后加入延时退避后有效抓取条数47286300触发滑块次数1140临时封禁次数210总耗时18分钟39分钟62分钟看出规律了吗越是“慢”的流程越是稳定。从18分钟变成62分钟换来了零封禁这个代价完全值得。因为一旦被封等待时间是非常不确定的可能是半小时也可能是一天综合算下来反而是慢流程更高效。5. 常见问题与排查技巧速查表5.1 高频问题与解决方案现象原因解决方案启动后页面是空白Chrome版本和驱动不匹配检查chromedriver与chrome版本一致性所有元素找不到页面被验证码拦截或懒加载未触发先检查滑块再执行滚动再定位Element not interactable元素在DOM中但未显示使用element_to_be_clickable而非presence启动后闪退浏览器多开冲突关闭其他Chrome进程或加--user-data-dir参数出现中文乱码文件编码问题保存文件统一encodingutf-8滑块无限重复频率太高或特征暴露降低频率增加随机延时考虑人工介入Cookie加载后未登录Cookie域名或路径不对登录后立即保存加载时先访问主页再add_cookie5.2 我个人的一些避坑体悟日志先行是最容易被新手忽略的习惯。刚开始我总想着把所有逻辑写完美后再跑结果每次报错都要在几十行代码里猜。后来养成习惯每次请求、每次失败都打一条日志所有问题都是看日志定位效率直线上升。还有一个小技巧是用浏览器远程调试端口来复用登录会话。--remote-debugging-port9222可以让Selenium连接一个已经打开并登录好的浏览器实例这样你只登录一次后续所有脚本共用一个会话既省了Cookie管理的麻烦也让浏览器环境更像真人。我现在的做法就是提前手动打开浏览器并登录再让Selenium连接使用代码稳定性提高了不少。如果你只是个人使用这个方案甚至比Cookie方案更省心。写在最后一点真实的经验做了几次采集任务之后我最大的体会是“反爬对抗”这件事在工程上其实没有终极解法。你把navigator.webdriver改了平台就检测CDP你把CDP优化了平台就分析鼠标轨迹你把轨迹模拟了平台就切到手机号实名登录加短信验证。这是一场没有尽头的军备竞赛。真正让你的脚本能长期跑下去的不是某个奇技淫巧而是三个字别滥用。降低频率、控制量级、守好边界反而比你费尽心思研究如何绕过所有验证更可靠。这套代码和思路可能会过时但“克制采集、合规优先”这一点什么时候都不会过时。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑