ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

爬虫对抗JS挑战与滑块验证:Selenium模拟真实浏览器实战

爬虫对抗JS挑战与滑块验证:Selenium模拟真实浏览器实战 前几天接了个数据分析需求要把某个财经社区的公开讨论内容抓下来做观点分析。我一开始真没当回事心想无非是requests加xpath的老套路最多再补个 UA 伪装。结果真上手才发现这个站点的反爬强度完全超出了我的预期——请求发过去直接 403加上伪装的 User-Agent 勉强返回 200页面里却是各种 JS 挑战和滑块验证。连续碰壁之后我反而冷静下来复盘最后决定换一条“最原始”的路子不跟它的风控体系硬碰直接用真实浏览器模拟人保留人工兜底。这篇文章就是把这段爬虫与反爬对抗的完整过程记录下来包括我踩过的坑、排查思路、每一步的实现细节以及一些可以复用到其他数据采集场景的经验。1. 首次交锋requests 裸请求被秒拒伪装 UA 也只换来一个验证页1.1 任务背景一个看似普通的公开数据采集要采集的目标是一个财经资讯社区的行情讨论版块数据本身是公开可见的不需要登录没有付费墙页面结构也很规整。按以往经验这类站点属于“半个小时内就能搞定”的类型。我需要的字段也不复杂帖子标题、正文、作者、发布时间还有对应的详情页 URL。数据量不大预估计几万条后续用来做简单的情绪分析和关键词聚类。这种需求在数据采集领域非常常见说白了就是“你帮我抓一批公开数据我拿去跑分析”。但问题恰恰出在我对它的难度判断上——看了一眼页面是静态渲染的就直接打开 PyCharm 写脚本连目标站点的反爬策略都没做前期探测。这里想先分享一个教训判断一个站点“好不好爬”不能只看页面是静态还是动态更要看对方在响应头、Cookie、JS 里埋了多少检测逻辑。很多老手翻车都是栽在轻视这一环节上。1.2 第一轮尝试从 403 到 JS 挑战页我的第一版代码非常简单直接用requests带一个浏览器 UA 去请求列表页import requests url https://example.com/forum/list/1 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, Referer: https://example.com/, } resp requests.get(url, headersheaders, timeout10) print(resp.status_code) print(resp.text[:500])第一次跑状态码直接是 403。我当时以为是 UA 太新导致被识别于是换了旧版 Chrome 的 UA 字符串加了一堆Accept、Connection之类的请求头再把Referer对齐到站内页面。第二次请求确实返回了 200但响应内容里根本没有帖子数据而是一段完整的 JS 挑战逻辑页面先执行一段混淆过的 JavaScript动态计算出一个 token 写进 Cookie然后带着新 Cookie 重新刷新页面才能看到真实内容。这种机制有个大家熟悉的名字——JS 挑战反爬。它的核心思路不是封禁你的 IP而是让服务器只信任“能够执行浏览器 JS 环境”的客户端。requests这类库没有 JS 执行引擎天然过不了这关。1.3 第二轮尝试接口签名让 Ajax 方案直接夭折列表页走不通我立刻想到换一条路很多网站的真实数据在浏览器里是通过 Ajax 接口异步加载的页面只是壳。只要找到接口直接调用接口拿 JSON效率更高还能绕过页面渲染的怪问题。用 Chrome DevTools 的 Network 面板刷新了一下页面果然看到一个返回 JSON 的列表接口路径类似/api/forum/post/list。我复制了完整的请求头包括Accept、Content-Type、x-requested-with甚至把 Cookie 原样粘了进去。第一次请求竟然成功了返回了第一页数据。但再翻一页立刻被拒。对比两次请求参数后我发现在查询字符串里多了一个sign参数和一个timestamp参数。timestamp是毫秒级时间戳sign是一串固定长度的十六进制字符串明显是把某些参数拼接后做了 MD5、HMAC 或更复杂的签名。浏览器里每个请求的sign都会重新计算而且算法隐藏在压缩后的 JS bundle 里。我当时也考虑过直接把那段混淆代码扒下来分析签名算法但看了一眼压缩后十几 KB 的脚本决定放弃。不是做不到而是性价比太低——就算这次破解成功了对方改一次算法整套逻辑又要重写。对于“数据采集”这种偏工程的临时需求来说跟反爬系统玩军备竞赛是没有终点的。2. 复盘根因反爬真正拦截的不是“爬虫”而是“不真实的人”2.1 我说的三板斧为什么失效在爬虫圈子里最常被提起的反爬应对手段无非就是三样修改 User-Agent、维护 Cookie、降低请求频率。很多新手遇到 403 第一反应就是换 UA、加延时运气好能过一些初级站点但面对专业风控基本没用。为什么呢因为我后来的分析发现目标站点的风控系统根本不是在判断“你是不是爬虫”而是在判断“你像不像一个真实的人”。这个听起来有点抽象拆开来看就清晰了。真实浏览器发出的请求在服务端能看到的特征包括完整的 HTTP 头顺序、TLS 指纹、HTTP/2 指纹、Canvas 指纹、WebGL 渲染结果、字体列表、屏幕分辨率、时区、语言列表、鼠标轨迹、滚动行为、键盘输入间隔甚至电池状态和传感器数据。requests发出的请求无论怎么加 Header在这些特征里都是“裸奔”的。更关键的是目标站点的风控里大概率配置了浏览器环境检测脚本。当你打开它的页面时JS 会尝试读取window.chrome对象、navigator.webdriver属性、document.hidden状态等。requests拿回来的 HTML 虽然长得一样但一执行这些检测就露馅了。2.2 从响应头和 JS 挑战反推风控逻辑我在浏览器里打开同一个列表页用 DevTools 对比了正常请求和我的脚本请求的响应头发现几个关键差异。正常的浏览器请求返回的响应头里带有一组set-cookie里面有_sid、_gid这类标识还会不定期刷新。而我的requests请求即使成功带上了旧 Cookie服务端也会因为缺少某个 JS 设置的前置 Cookie 而拒绝。另外我把被 JS 挑战拦截的那个页面源码完整保存下来把中间那段混淆脚本放进格式化工具里阅读发现它的逻辑大致是检查浏览器是否支持WebAssembly不支持则判定为异常客户端。通过正则匹配navigator.userAgent并尝试在window对象上找几个隐蔽的 Chrome 专属字段。动态生成一个sign值写进 Cookie再触发一次location.reload()。说白了服务端已经把“执行浏览器代码”本身当成了访问门票。这种情况下任何用库模拟 HTTP 请求的方案都处于先天劣势。2.3 转换思路承认攻防成本选择“最原始的路”我复盘了整整一天最后把思路彻底扭了过来既然对方要的是“真实浏览器环境”那我不如直接把真实浏览器拉进来当执行引擎让访问行为和真人没有本质区别。这就是标题里说的“最原始的方式”——不做任何加密破解不碰混淆代码不维护代理池而是退回到人最常用的访问方式打开一个浏览器滚动页面、点击下一页、手动过一遍滑块。只不过这些重复操作里一部分交给了自动化脚本另一部分关键决策比如遇到验证码留给人工。这里有一个重要的心态转变做数据采集的目的是拿到数据、完成分析而不是“赢过某个网站”。以这个目标为前提任何成本高、维护难、收益不确定的对抗方案都应该被抛弃。用 Selenium 驱动真实浏览器恰恰是最符合这个目标的路径。3. 最原始方案落地Selenium 驱动真实浏览器把人工决策留在流程里3.1 选型对比为什么是 Selenium 而不是 Playwright确定“浏览器自动化”这个大方向之后接下来是选工具。市面上主流的方案有三个Selenium、Playwright、Puppeteer。我最终选了 Selenium原因有几点。生态最成熟Selenium 在 Python 社区里的资料和踩坑案例最丰富。遇到问题搜索解决方案几乎都能找到现成的答案这对快速落地很重要。WebDriver 协议兼容性好它通过 WebDriver 协议和浏览器通信看似笨重但恰恰是这种通用性让它能适配老站点的一些诡异行为。人工介入方便我的方案里需要保留“人工过滑块”的入口Selenium 启动的浏览器窗口就是普通浏览器窗口人工可以直接操作脚本和人工共用同一个会话不存在切换成本。Playwright 的优势在自动等待和更现代的 API但在“有头模式 人工兜底”这个场景下Selenium 更直接。Puppeteer 只支持 Node.js我不打算为一个小任务再多开一套技术栈。3.2 环境准备浏览器驱动版本匹配是最容易踩的坑用 Selenium 之前需要先装好三样东西Python 的selenium库、浏览器本体、对应的浏览器驱动。pip install seleniumChrome 的话需要下载chromedriverFirefox 需要geckodriver。这里最大的坑是版本必须和浏览器主版本严格匹配比如 Chrome 122 的浏览器配 120 的驱动大概率启动失败。我一开始图省事直接下载了最新驱动结果本地浏览器版本落后报了一堆SessionNotCreatedException。后来检查了chrome://version里的精确版本号才把配套驱动找准。需要说明的是新版本的 Selenium Manager 会自动下载匹配驱动确实省了很多事。但如果你在离线环境或内网采集手动下载驱动依然是最稳的方式。3.3 核心脚本框架打开浏览器、优雅等待、滚动加载接下来的脚本我不再设计“直接拿到 HTML 字符串”而是模拟一个真实用户的行为路径打开列表页、等待页面渲染完成、滚动到底部触发懒加载、点击下一页或更多按钮、重复操作。为了避免被navigator.webdriver特征暴露我加上了这两个参数的调整from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options Options() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_experimental_option(excludeSwitches, [enable-automation]) driver webdriver.Chrome(optionsoptions) driver.get(https://example.com/forum/list/1)注意--disable-blink-featuresAutomationControlled和excludeSwitches能去掉部分自动化标识但不要迷信它能完全绕过所有检测。真实场景里更重要的还是后面的随机延迟和人工兜底。处理滚动加载时我写了一个循环每次向下滚动固定像素然后判断页面里的帖子数量是否增加last_count 0 for i in range(20): driver.execute_script(window.scrollBy(0, 800);) WebDriverWait(driver, 5).until( lambda d: len(d.find_elements(By.XPATH, //div[contains(class,post-item)])) last_count ) last_count len(driver.find_elements(By.XPATH, //div[contains(class,post-item)])) time.sleep(random.uniform(1.5, 3.5))这里我刻意没有用固定的time.sleep(2)而是用了随机延迟区间。原因后面会专门讲。3.4 不无头浏览但不等于不能无桌面运行很多人一提到自动化爬虫习惯性就加--headless觉得这样效率高、不弹窗。但面对这个反爬强度的站点headless 模式的风险很大——无头浏览器缺少真实渲染环境Canvas、WebGL、字体指纹和正常浏览器差异明显很容易被 JS 检测识别。我的做法是直接用有头模式也就是屏幕上真的会弹出浏览器窗口。这在本地操作没问题如果部署在 Linux 服务器上也可以用xvfb这类虚拟显示工具包一个虚拟屏幕照样能跑xvfb-run python3 collect_forum.py这样既保留了真实浏览器的渲染环境又能在没有显示器的机器上执行。4. 验证码与滑块的兜底设计不破解、不调用打码平台给人工留一个入口4.1 为什么不建议暴力破解或接打码平台跑起来之后真实浏览器确实绕过了大部分反爬检测但还没消停多久滑块验证码就出现了。目标站点在连续访问十几页后弹出一次滑块验证要求把拼图拖到指定位置。遇到这种场景很多人的第一反应是接打码平台或者用selenium模拟拖拽轨迹、用图像识别定位缺口。说实话这两条路我都试过类似的方案但都放弃了。打码平台按次收费短时间用几百次还行长期采集成本不低而且对方平台的服务质量不稳定偶尔还会因为大风控封号牵连账号。暴力模拟拖拽呢看上去高级实际实现细节非常多需要先截图、再识别缺口坐标、计算拖动轨迹、模拟人手的抖动和加速度每一步都可能被风控判定为“非人类”。就算调通了网站换一套验证码样式代码又要重写。4.2 “人工过码、脚本继续”的实现细节我最后设计的方案特别“原始”却也特别稳定脚本检测到验证码弹出时自动暂停采集提醒我移动鼠标到浏览器窗口手动拖一下滑块拖完之后脚本检测到验证码消失继续干活。实现上并不复杂核心是一个轮询检测函数def wait_for_human_if_captcha(driver, timeout600): try: WebDriverWait(driver, 2).until( EC.presence_of_element_located((By.XPATH, //div[contains(class,captcha-slider)])) ) print([!] 检测到滑块验证码请手动完成验证脚本将在验证通过后继续...) WebDriverWait(driver, timeout).until( EC.invisibility_of_element_located((By.XPATH, //div[contains(class,captcha-slider)])) ) print([] 验证已通过继续采集) except: pass在实际运行中我把这个函数放在了“翻页”和“进入详情页”这两个最容易触发验证码的节点之间。每执行一次翻页就调用一次检测有验证码就停下来等人没有就继续走。这个方案的好处是整个流程不会因为验证码而中断也不会因为破解成本高而失控。全局只需要一个兼职盯着屏幕的人形兜底甚至可以在脚本里加上声音提醒验证码弹出来时播放提示音人再过来处理。4.3 频率控制与随机延迟把请求节奏调得更像人除了验证码目标站点另一个敏感点就是“访问节奏”。我观察到如果连续快速翻页哪怕用的是真实浏览器也会在第十页左右触发滑块。但把每次翻页的间隔拉长并且让间隔不规律触发频率会明显降低。我最终采用的节奏是这样列表页翻页间隔随机 5 到 15 秒。进入详情页读取内容随机 3 到 8 秒。每采集 50 条数据主动暂停 2 到 5 分钟让页面停在某个位置模拟阅读。import random import time def human_delay(mu8, sigma2, min_delay3, max_delay20): delay random.gauss(mu, sigma) delay max(min_delay, min(max_delay, delay)) time.sleep(delay)用高斯分布而不是均匀分布是为了让延迟更接近人的行为特征——大部分操作间隔在平均值附近偶尔有较长的停顿。均匀分布的数字一眼就能看出机器痕迹。关于这个频率控制我多说一句合理的延迟不仅是为了过反爬更是对目标网站服务器的尊重。一个个人采集任务完全没有必要给对端造成过大的并发压力。5. 取数之后XPath 定位、text 函数陷阱与增量去重5.1 XPath 的 text() 和浏览器 text 属性不是一回事列表数据加载出来之后我原本打算直接用requests加xpath的老方式取数据结果发现 Selenium 取文本的时候和我想的不太一样。在lxml里我经常写这样的表达式node.xpath(.//a[contains(class,title)]/text())但在 Selenium 的find_element里不能直接把/text()放到末尾而是要用.text属性去拿。比如title_el item.find_element(By.XPATH, .//a[contains(class,title)]) title title_el.text.strip()这里面的区别在于XPath的text()返回的是该节点直接子节点中的文本节点而 Selenium 的.text返回的是整个元素内的可见文本包括所有子文本节点的拼接。遇到嵌套标签较多的情况时直接使用.text拿到的内容会更完整但也更容易带上多余字符所以取完之后要统一做一次清洗。清洗我用的是正则和字符串替换结合import re def clean_text(raw): raw raw.replace(\u00a0, ) raw re.sub(r\s, , raw) raw re.sub(r.*?, , raw) return raw.strip()这里特别提一下\u00a0也就是不换行空格。很多页面的排版会把普通空格转成这个字符如果不处理后续做关键词统计时会发现词被硬生生切开了。5.2 动态加载页面的等待策略比取数本身更影响成功率Selenium 取数最大的敌人不是定位写错而是“元素还没渲染出来就去取”。早期版本我习惯用time.sleep(3)等页面加载后来发现这种固定等待既慢又不稳定。更好的做法是配合显式等待也就是前面代码里用到的WebDriverWait。取数前先等某个关键元素出现再开始遍历WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, post-list)) )这行代码的意思是最多等 10 秒直到.post-list出现在 DOM 里才继续。它能自动处理“页面慢半拍”的情况比固定 sleep 灵活得多。我还遇到过一个更隐蔽的问题某些页面的帖子内容渲染在iframe里直接用主文档的find_element永远找不到。解决办法是先切换上下文iframe driver.find_element(By.TAG_NAME, iframe) driver.switch_to.frame(iframe) # 取数逻辑... driver.switch_to.default_content()这类问题在爬虫实战文章里很少被提及但实际遇到时非常浪费时间。一旦发现元素定位不上优先检查是不是iframe的问题。5.3 用 SQLite 做增量存储断点续采不再重复采集过程中免不了中断可能是网络波动、验证码卡住也可能是脚本自己崩了。为了让采集做到“可以随时停止、随时续上”我直接把数据存进了 SQLite并用详情页 URL 作为唯一主键。建表语句很简单CREATE TABLE IF NOT EXISTS posts ( url TEXT PRIMARY KEY, title TEXT, content TEXT, author TEXT, published_at TEXT, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP );写入时用INSERT OR IGNORE重复采集同一个页面时不会产生重复记录import sqlite3 conn sqlite3.connect(forum_posts.db) conn.execute( INSERT OR IGNORE INTO posts (url, title, content, author, published_at) VALUES (?,?,?,?,?), (url, title, content, author, published_at) ) conn.commit()有了这个唯一键哪怕脚本跑到一半断了重新启动后只需要从待采集队列里拿未入库的 URL 继续就行。我在实际操作中还做了一个简单的“待采队列”表把列表页解析出来的所有详情页 URL 先入队再逐个取详情、写内容。这样列表页可以反复抓但详情页只会真正解析一次整体效率高很多。6. 哪些经验能复用哪些教训让我重新审视爬虫边界6.1 合规边界只做公开数据的克制采集讨论技术归讨论技术但有些底线我希望每个做数据采集的人都能守住。这次的采集目标是公开发布、无需登录、无付费墙的讨论内容。即便如此我依然做了几件事检查了目标站点的robots.txt确认对爬虫的抓取约束。控制请求频率绝不在短时间内给服务器造成压力。不对数据进行二次转售不使用账号突破权限边界不采集非公开的隐私信息。如果目标站点的用户协议明确禁止自动化抓取或者数据本身需要登录甚至付费才能看到那无论技术上能不能做到都不应该去做。数据采集的边界不在技术能力而在数据是否公开、用途是否正当。6.2 这个方案的适用与不适用场景这次“真实浏览器 人工兜底”的方案适合以下情况目标数据量在几万条以内不追求全站秒级同步。目标站点的反爬强度处于“中等偏上”主要依赖 JS 执行验证和行为检测。采集任务是一次性或短期的不值得为长期维护投入成本。不适合的情况也有值得说清楚如果目标站点要求登录态才能采集数据Selenium 能帮你登录但登录后的每一次请求都在风控眼皮底下账号风险极高不建议继续。如果数据量达到百万级单机浏览器自动化的速度跟不上需要考虑分布式采集那又是另一套完整的工程设计。如果目标站点使用了高强度的前端加密比如自定义协议、非标准 TLS 指纹校验Selenium 加真实浏览器也未必能突破这时候需要重新评估项目需求本身。6.3 最后一点经验总结这次的采集任务最终在两天内完成了全部数据的抓取和入库。整个过程下来我最大的收获不是“破解了哪家网站”而是想明白一件事反爬的本质是模拟真实而模拟真实的核心不是把代码写得越来越像人而是允许自己在合适的节点把控制权交还给真人。这套思路后来也用在了其他几次采集任务里凡是遇到验证码、登录墙、动态渲染这类问题优先想的是“能不能让浏览器自己去处理”而不是“能不能破解”。如果你也在做类似的数据采集项目我的建议是先花十分钟判断目标站点的反爬等级再决定投入多少精力。如果对方只是初级的 UA 校验requests就能解决如果一上来就是 JS 挑战加接口签名直接上 Selenium 加人工兜底别在中间层和它消耗。数据采集的核心目标是数据不是和风控系统的较量——把这一点想清楚很多技术选型都会变得简单。
返回列表