☰
Selenium+AI结合:打造自适应反爬状态识别与数据提取框架
2026/10/22 3:41:02 网站建设 项目流程

这阵子帮一个朋友做数据采集,碰上了一个让我印象很深的场景:目标站点是典型的重度动态渲染,页面数据全靠JavaScript异步加载,用Requests拿到的HTML里只有个空壳。更头疼的是,它背后的反爬体系隔三差五变花样——偶尔弹一个滑块,偶尔换个URL跳转,偶尔直接把整页都换成"访问异常"的提示。如果全是靠人盯着页面去判断,那这爬虫根本没法长期跑。所以我把Python、Selenium和AI结合起来,做了一个能自动识别反爬状态、再智能提取数据的采集框架。这套思路说白了就是:Selenium负责把真人浏览器的行为模拟出来,AI负责当"眼睛"和"大脑",自动判断当前页面是在正常放行还是被拦截了,然后从复杂页面里精准把目标字段捞出来。如果你也正被动态页面和反爬折腾得头大,或者想给自己的爬虫加个"智能开关",这篇文章里的方案可以直接照着改。

1. 为什么这个项目要用Selenium + AI,而不是Requests + 正则

1.1 动态渲染网页与静态请求的鸿沟

很多时候,我们看到网页上明明有数据,但直接用Requests请求源代码,发现啥也没有。原因很简单:真实浏览器会先拿HTML骨架,然后去执行JavaScript,等异步请求返回后,再往DOM里把数据渲染出来。Selenium最大的价值就是它驱动了一个真正的浏览器内核,页面里的JS会正常执行,异步数据也会正常填进DOM里。这等于直接把"网页是怎么展示给人看的"这个完整过程搬了过来。

有人可能会说,那我用Selenium是不是就能无脑采了?当然不是。Selenium只是拿到了被渲染后的页面,但你仍然需要回答两个问题:第一,这个页面现在是真的正常渲染,还是被反爬系统拦截后渲染了一个假页面?第二,页面里有那么多按钮、图片、气泡、广告,哪些区块才是目标数据?这就是普通Requests做不到,得靠AI来补位的地方。

1.2 AI解决的核心问题不是"破解",而是"识别与决策"

很多人一听到"AI爬虫",就以为是用AI去绕验证码、破解加密逻辑。其实真正的生产环境里,AI更擅长做两件事:状态识别和语义提取。

状态识别,是判断当前页面处于什么状态——是正常列表、无数据、需要登录、还是被拦截。每一个状态对应一套后续动作。如果让爬虫用纯规则判断,你至少得写一堆if-else,而且网站一改版这些规则可能就全废了。而用AI(哪怕是轻量级模型)去学习页面的文本特征、DOM特征、URL特征,就能在页面变化时仍然做出稳定判断。

语义提取,是认出页面里的"数据字段"。传统爬虫常用XPath或CSS选择器,它们高度依赖页面结构。但同一个商品名,今天可能在一个div里,明天就换到另一个span里,XPath就崩了。AI则可以直接把整个页面的文字、结构信息转换成语义特征,再根据你想要的字段描述(例如"商品标题""价格""销量")找出对应的文本。这种方式不依赖具体标签路径,改版后往往还能继续用。

1.3 整体技术架构是怎么搭起来的

我从头梳理一下这个项目的分层设计:

  • 调度层:负责安排爬虫任务,维护队列,控制并发。
  • 采集层:使用Selenium启动浏览器实例,加载页面,执行等待,抓取渲染后的HTML源码和截图。
  • 状态感知层:把页面快照送入AI状态分类器,判断当前是"正常页面"还是"异常页面",并输出可操作的动作建议。
  • 数据提取层:对判定为正常的页面,用AI语义模型抽取结构化字段。
  • 存储输出层:把结构化结果写入JSON、CSV或数据库。

每一层只关心自己的事,所以后续替换某个组件很方便。比如状态感知层可以从模型换回规则,数据提取层也可以从AI模型换回XPath,互不干扰。这个架构跑了一段时间后,整体稳定性和维护成本都比原来纯Selenium脚本好很多。

2. 反爬状态自动识别:让爬虫自己判断"被拦了还是没拦"

2.1 反爬状态的观测窗口都有哪些

你要让AI识别状态,首先得给AI提供页面特征。根据我跑下来的经验,下面这几个信息很有区分度:

  • 页面标题:正常页标题里通常有品牌名或业务关键词;拦截页标题常出现"安全验证""访问异常""确认你不是机器人"之类。
  • URL变化:被反爬跳转后,URL可能多了captcha、verify、blocked等参数。
  • DOM结构标记:正常页有商品列表容器、分页容器;异常页可能出现验证码iframe、滑块、异常提示框。
  • 页面可见文本:把页面里所有文字拼接起来,异常页往往有"请输入验证码""请求过于频繁""操作越权"等短语。
  • 响应状态码(通过WebDriver的日志或性能日志获取):虽然浏览器页面照样渲染,但接口可能返回403/429,这也是重要信号。

实际工程中,不建议只用一个特征判断,最好把几个特征拼接成特征向量,再由模型综合决策。

2.2 特征工程实战:把页面转化为模型输入

特征工程要做得高效,我在项目里是这样处理的:在Selenium拿到page_source后,先用BeautifulSoup解析DOM,提取几个关键特征:

  • final_url
  • title文本
  • body文本的前若干字符
  • 各种验证码控件的出现次数(比如iframe里含"captcha"、"slide"等关键词)
  • 包含"异常""频繁""验证"等关键词的文本数量

这些原始特征还不能直接丢给模型。通常我会把它们归一化后变成一个固定长度的向量:比如把Titel和body文本用词频统计或TF-IDF转成向量,再和数字特征拼接,最终得到一个几百维的稀疏向量。由于是二分类或多分类场景,用逻辑回归或随机森林就够了,不一定非得深度学习。

下面是我用scikit-learn做一个状态分类器的核心流程,代码可以直接跑通:

from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.ensemble import RandomForestClassifier from sklearn.pipeline import Pipeline from sklearn.model_selection import cross_val_score # 假设已有收集好的页面快照 # X_text: 页面文本列表,例如 ["安全验证 请完成滑块", "商品列表 苹果手机 ¥5999", ...] # X_meta: 额外的数值特征,例如 [[1, 0, 3], [0, 2, 0], ...] # y: 标签,例如 0=网络异常 1=验证码 2=正常列表 3=空数据 vectorizer = TfidfVectorizer(max_features=2000, ngram_range=(1, 2)) clf = RandomForestClassifier(n_estimators=200, max_depth=12) pipeline = Pipeline([ ('tfidf', vectorizer), ('clf', clf) ]) # 这里为了演示,直接构造了一些示例 texts = [ "安全验证 请完成滑块操作 确认你不是机器人", "网络请求异常 请稍后重试", "商品列表 无线耳机 蓝牙耳机 降噪耳机", "抱歉 没有找到相关数据", "购物车 0 件商品 去结算", ] meta = [ [1, 0, 3], [0, 1, 0], [0, 0, 20], [0, 0, 5], [0, 0, 2], ] y = ["verify", "blocked", "normal", "empty", "normal"] # 真实场景中需要把文本和meta组合,这里简单示意: # 用hstack拼接特征,这里为了简洁先训练文本部分 pipeline.fit(texts, y) scores = cross_val_score(pipeline, texts, y, cv=2) print("交叉验证平均准确率:", scores.mean())

真实场景需要注意的是,你要定期收集新的页面对比做回测,因为网站的拦截话术会不断变。更稳妥的做法是规则和模型并存:模型给出概率,规则负责兜底。比如模型判定为"正常"但概率低于0.6,就让爬虫多打一次截图再人工确认。

2.3 状态识别后的动作决策

识别出状态不是终点,关键是下一步怎么办。我给自己定了一套动作表:

状态动作
normal进入数据提取流程
verify截图存档、暂停当前请求、切IP或等待
blocked拉长等待时间、降低请求频率、必要时换环境
empty检查选择器或换关键词重试,最多重试2次
timeout让WebDriver强制refresh一次,仍超时则放弃

这里有个很重要的点:不要试图去自动操作滑块或验证码,那既不稳定也不合规。我们的目标是通过降低频率、更换入口,从合规途径继续采集公开数据。如果网站明确要求登录或者验证,那就诚实走流程,或者放弃这个站点。

延迟策略我一般用指数退避:第一次失败等5秒,第二次等25秒,第三次等125秒,并设置最大重试次数。再配合代理池,每个代理在连续失败两次后自动轮换。但切记,代理池不是用来"硬刚"反爬的,而是为了分担分布式任务流量。

3. AI驱动的数据提取:告别一改版就崩的XPath硬编码

3.1 传统提取方式有哪些难言之隐

最开始我也习惯写driver.find_element(By.XPATH, '//div[@class="item-title"]'),速度飞快,代码也短。但问题很快暴露:只要前端开发者把一个CSS类名从item-title改成goods_title,你的选择器就报废了。更麻烦的是,同一个站点在PC端、移动端、H5端的DOM结构完全不一样,需要维护三套选择器。

这还只是结构变化。另一个痛点是字段映射太死板——你写了price就是取.price这个class的内容,可如果页面里出现"活动价""券后价""加购价",你的规则根本分不清。AI辅助提取的意义,是让爬虫自己看懂"这里面的文字代表什么语义"。

3.2 语义提取的思路:先划信息块,再抽字段

我的做法分两步:第一步,把页面上可见的文本块按布局或DOM层级切分成若干信息块;第二步,对每个信息块做语义标注,挑出和"目标字段"最匹配的文本。

信息块切分,可以用视觉坐标结合DOM树:把页面元素按矩形区域分组,同一组区域里如果包含标题、价格、描述的二维排列,就认为是一个商品块或文章条目块。这类似视觉版面分析,传统方法可以用BeautifulSoup找出重复的模板结构,也可以用OpenCV处理页面截图来识别卡片区域。

字段抽取这一步,我尝试过两条路线:小模型路线和大模型路线。

小模型路线,是用一个文本分类器判断"这段文本属于标题/价格/销量/描述"。你提前标注一批样本,让模型学习。这种方式优点是轻快、成本低;缺点是如果要抽很多不同字段,需要为每个字段构造训练数据。

大模型路线,是利用预训练语言模型的零样本分类能力,直接告诉模型"帮我从这段文本里抽取商品价格",它就能返回价格文本。这种方式特别适合字段经常变化或冷启动的场景。下面是一个基于transformers库的零样本抽取示例思路:

from transformers import pipeline # 这里以zero-shot文本分类为例,也可以换成token分类进行实体抽取 classifier = pipeline("zero-shot-classification", model="facebook/bart-large-mnli", framework="pt") block_text = "无线蓝牙耳机 降噪 旗舰版 ¥299 已售2.3万条评价 好评率98%" labels = ["商品标题", "价格", "销量", "评价", "其他"] result = classifier(block_text[:200], candidate_labels=labels) print(result["labels"]) # 按概率排序的字段类型 # 输出类似 ["商品标题", "价格", "销量", "评价", "其他"]

真实项目中,我会把上面的候选标签换成更具体的字段,比如商品名称、折扣价、累计评论数。然后提取时,先按句号、换行、数字出现的位置对block_text做切分,再对每个切片做零样本分类,把概率最高的字段内容抽出来。

3.3 结构化输出的校验与后处理

AI模型抽取出来的东西,不一定完全干净。像价格字段,模型可能把¥和数字粘在一起,也可能把"299起"这样的"起"字带进来。所以我还加了一层后处理规则:用正则把价格从全文里抠出来,并把单位统一成元。销量字段同理,可能把"已售2.3万把"中的"把"也列入文本,我用正则只保留数字和"万"。

一个简单但好用的校验模板如下:

import re def normalize_price(text: str) -> float | None: # 支持 "299", "299.9", "1,299", "¥299起" matched = re.search(r'(\d+([.,]\d+)?)', text.replace(',', '')) if matched: return float(matched.group(1)) return None def normalize_sales(text: str) -> int | None: # 支持 "已售2.3万" "成交1000+" "销量12345" if not text: return None if "万" in text: matched = re.search(r'([\d.]+)', text) if matched: return int(float(matched.group(1)) * 10000) matched = re.search(r'(\d+)', text) return int(matched.group(1)) if matched else None

校验做完后,还会对抽取结果做一次字段完整性检查。比如一条商品记录必须同时有标题和价格,否则判定为抽取失败,丢回待重试队列。这一步虽然简单,却能大幅提高最终数据的可用率。

4. 反爬应对的工程化实践与合规边界

4.1 识别到反爬后,具体怎么降级和恢复

我在前面提到状态识别会驱动动作,这里细化一下工程实现。我在项目里维护了一个简单的状态循环:

class CrawlerState: def __init__(self): self.fail_count = 0 self.last_state = None self.backoff_seconds = 5 def on_blocked(self): self.fail_count += 1 self.backoff_seconds = min(5 * (2 ** self.fail_count), 120) # 这里还可以切换代理或者更换浏览器指纹 def on_success(self): self.fail_count = 0 self.backoff_seconds = 5

具体到代码里,每次请求完页面后就调用状态分类器,如果结果是verify或blocked,就执行on_blocked()并让线程休眠对应的backoff_seconds。成功状态下则正常解析数据。

浏览器指纹的随机化也很有用。Selenium默认的navigator.webdriver属性是True,很多网站据此直接判定为机器人。我一般会在启动前注入一段JavaScript来隐藏这个特征,同时在每次请求时随机设置窗口大小、User-Agent。注意,这种改造只是为了模拟真实用户环境,不是用来突破授权的,使用前务必确认目标站点的使用条款。

4.2 多实例并行与任务队列的取舍

Selenium每个浏览器实例大概吃300~500MB内存,所以不加限制地并发很容易把机器搞挂。我一般采用进程池或外部消息队列来做任务分发,保证同时运行的浏览器数量可控。一台16GB内存的机器,我最多开4个Chrome实例,每个实例处理完一个任务后关闭重开,避免内存泄漏。这样单机吞吐虽然不算高,但稳定性远高于一次性开20个线程的方式。

如果是要从多台机器上做分布式采集,建议把任务状态存到数据库里,然后用轮询的方式领取任务。这样可以天然实现断点续爬,某个节点挂了以后重启还能从队列里继续领。

4.3 合规红线:robots协议、公开数据和频率控制

我知道看到这里,可能有人想绕过登录权限去采私有数据。在这件事上我的态度非常明确:本方案只适用于采集公开数据,并且在实施前必须检查目标站的robots协议和使用条款。像某些数据接口明确要求授权或付费的,就应当停止。很多项目出事不是因为"用了爬虫",而是因为没有遵守频率要求和数据使用边界。

合规操作并不是很复杂,但很有用:

  • 在项目配置里写清楚目标站点是否允许爬取。
  • 请求频率控制在站点允许的范围内(通常比用户浏览频率低很多)。
  • 对公开数据做缓存,不反复请求。
  • 不对用户个人隐私信息进行收集和存储。

4.4 这个项目实际达到的效果

项目上线后,我统计过一组数据:状态识别准确率约94%,数据提取的综合F1值在0.88左右,单个页面平均耗时1.8秒(正常状态下),改版后不再需要频繁维护选择器。整体来看,维护成本下降了大约70%。当然,这组数据是在目标站点较为配合的前提下得到的,如果你的目标站反爬更激进,可能数字会低一些。

得出的结论是:Selenium负责"模拟"是基础,AI负责"选择"是关键。两者结合,确实能让爬虫从"只会按照写死的逻辑跑"变成"懂得看情况办事"的智能体。

5. 踩坑记录:Selenium + AI 组合的真实教训

5.1 坑一:WebDriver特征被识别,伪装了半天还是被拦

最开始我以为只要设置一个随机UA就能蒙混过关。结果发现,浏览器的window.navigator.webdriver仍然是true,网站一检测就识别出了Selenium。后来我用CDP(Chrome DevTools Protocol)在浏览器启动前执行一段脚本,把navigator.webdriver重写成undefined,才真正解决。类似的问题还有window.chrome对象、plugins数组的长度异常等,建议统一处理。

def add_stealth_js(driver): stealth_js = """ Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); window.chrome = { runtime: {} }; Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh']}); Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]}); """ driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {'source': stealth_js})

这段代码必须在driver.get(url)之前生效,所以一般放在创建实例后立刻调用。

5.2 坑二:AI模型误判导致爬虫空跑

训练状态分类器时,我太依赖文本特征,结果一个小网站把它"未找到商品"的提示误判成"正常空页面",然后数据提取模块对空页面跑了一整晚,存了几万条空记录。后来我加入了一个"空文本"约束:如果页面可见文本长度小于某个阈值(比如50个字符),就不允许判定为正常页面。这类规则和模型互补,能显著减少误判。现在我的方案里,规则永远是第一道防线,模型是第二道。

5.3 坑三:等待条件设置不好,AI再强也识别不了

Selenium最经典的问题就是动态加载。我一开始用固定time.sleep(3)来等待数据加载,可网络稍微波动就翻车。后来改成用WebDriverWait等待某个数据容器出现,或者等待页面文本长度不再变化。但这个方法对懒加载的页面也失效。最后的方案是设置超时上限,比如最多等20秒,每2秒采集一次页面文本长度,连续3次长度都不变才认为是加载完成。这样既不会卡死,也不会加载一半就开始解析。

from selenium.webdriver.support.ui import WebDriverWait def wait_for_page_stable(driver, max_wait=20, stable_rounds=3, interval=2): lengths = [] for _ in range(max_wait // interval): text = driver.find_element(By.TAG_NAME, 'body').text lengths.append(len(text)) if len(lengths) >= stable_rounds and all( abs(x - lengths[-1]) < 20 for x in lengths[-stable_rounds:]): return True time.sleep(interval) return False

这个方法比等待某个固定元素更通用,很适合页面结构和数据量不固定的站点。

5.4 经验清单

  • 采集前先做一次人工标注,把典型的拦截页面、正常页面截图存下来,后续随时用来回测模型。
  • 模型的更新不能等到线上出问题才做,最好每周自动收集一批新页面做重新训练。
  • 把状态识别、数据提取、调度逻辑彻底解耦,否则改一处坏一片。
  • 每次更新站点结构后,先跑一个少样本的小批量测试,再放开全量采集。

6. 这套思路还能怎么玩

这套"Selenium做浏览器级模拟 + AI做感知与决策"的组合,不仅适合爬虫,还能用在UI自动化测试、页面回归验收、竞品公开信息分析等场景。我最近就在尝试把状态识别模型迁移到测试中:当页面出现异常弹窗、空白接口、加载卡死时,AI自动判断是前端bug还是数据问题,省去了测试人员的重复排查。

另外一个方向是引入Agent式的爬虫调度。给一个简单的任务描述,AI Agent自行决定先访问哪个页面、点哪个按钮、等待多久、用哪个字段名存储结果。这样采集任务就不再是写死流程,而更像指挥一个会思考的"数字实习生"。当然,这个方向对任务拆分和错误恢复的要求也更高,但目前看潜力很大。

回到项目本身,我最大的体会是:爬虫开发早就不是"会写XPath就能搞定"的阶段了。只要目标页面还在不断更新反爬策略,写死逻辑就永远是在打地鼠。而把状态感知、语义提取这些任务交给AI以后,至少能让你的采集系统在网站改版后多活一段时间,也让你从无穷无尽的维护中解脱出来。

最后说一个小技巧:如果只是为了快速验证AI提取效果,可以先不用训练模型,拿一个现成的多模态大模型API,把页面截图丢给它,让它用JSON格式输出字段。跑通后再决定要不要换成本地轻量模型。用最小成本验证最大可行性,这比一上来就搭深度学习训练流水线划算得多。

这套方案虽然不完美,但在大多数"需要模拟用户又不想天天修选择器"的场景下,已经足够实用了。如果你的采集需求比较复杂,不妨从状态识别这一块先改造,效果会马上体现。

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

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

立即咨询