第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
- 1Python 爬虫入门实战:服务端渲染页面怎么取数(CH-001)
- 2Python 爬虫接口实战:翻页取完再累加(CH-002)
- 3Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
- 4Python 爬虫 TLS 指纹实战:requests 被挂住时怎么查(CH-004)
- 5Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
- 6Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
- 7Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
- 8Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
- 9Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
- 10第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
- 11第 11 关:无限 debugger + VM 混淆 + 签名请求头
- 12第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
- 13第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
- 14第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
- 15第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的本文
本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是最普通不过的一个 HTML 列表页:不加密、不验签、不弹验证码,5 页 × 10 条公告,谁都能抓。难的是你不知道自己抓到的是不是真的 —— 页面里埋了三类「只给爬虫准备」的东西:看不见的行会被当成数据,看不见的链接和字段碰一下就进小黑屋,之后拿到的每一份数据都对不上。这一关真正要带走的两条习惯是:抓表格前先做可见性过滤、拿到数据先做一致性校验。
靶场:/playground/trap(登录后打开) 参考解法:参考代码/s17.py
现象
- 打开靶场,就是一个普通的公告列表:
公告编号 / 项目名称 / 金额(万元)/ 更新于,页脚写着「本页 10 条 · 第 1 / 5 页」。 - 分页也普通:
1 2 3 4 5+「下一页」,下面还有个「跳转到第 N 页」的下拉框。 - 但 F12 一看就不对劲:DOM 里
table tbody tr有 13 个,页脚却说 10 条。 - 用最常见的一把梭抓法(
pandas.read_html)抓下来 → 每页 12 行(它只认内联display:none,靠类名隐藏的两行照样抓)→ 5 页求和 → 提交上去判错。 - 更阴的是:如果你遍历了页面上所有
<a href>(或者把页面里所有<input>拼成请求参数),接下来同一页两次抓到的数字就不一样了,而且整页「更新于」全挤在同一秒。 - 全程没有任何报错:不 403、不弹验证码、不封 IP,HTTP 200,页面长得一模一样。
为什么「喂假数据」比「把门关上」更狠
| 拦截方式 | 爬虫的感受 | 拦截方的成本 |
|---|---|---|
| 403 / 封 IP / 加验证码 | 立刻知道被拦了 —— 换 IP、换 UA、上打码、上代理池 | 要维护封禁名单与验证码服务,还会误伤真人 |
| 蜜罐 + 污染数据 | 根本不知道被拦了,拿着错数据继续跑几小时,最后算出来的结果一直是错的 | 只要多做一套假数据;真人访问完全无感 |
现实里的例子:招聘网站给爬虫返回”看起来正常、但根本不存在”的职位;电商给爬虫返回随机价格;行政区划接口给爬虫塞几个幽灵条目。目的都一样 —— 让你拿着脏数据走,而不是被你发现。
所以这一关的判错不会告诉你”你踩陷阱了”,只会说”答案不正确”。你得自己证明手里的数据是真的。
第一步:把「看不见的东西」找出来
「看不见」在网页里不止一种写法,抓取时要一起判断:
| 写法 | 谁看不见 | 备注 |
|---|---|---|
内联 style="display:none" | 所有人 | 不从渲染树里出现,pandas.read_html 也会跳过它 |
CSS 类 display:none | 所有人 | ⚠️ pandas.read_html 照样吃(它只认内联样式,不解析样式表),按 <tr> 抓就更不用说了 |
CSS 类 visibility:hidden | 所有人 | ⚠️ 占着位置、read_html 也照吃 —— 最容易漏的一种 |
离屏 position:absolute; left:-9999px; width:1px; height:1px | 所有人 | 无障碍工具的经典写法,常被拿来当蜜罐 |
opacity:0 / height:0 / overflow:hidden | 所有人 | 同上 |
aria-hidden="true" / hidden 属性 | 所有人 + 屏幕阅读器 | 语义层就已经声明”别读” |
<template> / <script type="text/template"> | 所有人 | 模板数据,不是渲染结果 |
所以判断”能不能抓”的正确姿势是:先把页面里的 CSS 规则读明白,再逐行判断,而不是只认 display:none:
import refrom bs4 import BeautifulSoup
HIDDEN_HINTS = ("display:none", "visibility:hidden", "left:-9999px", "opacity:0", "height:0", "width:0")
def hidden_class_names(html: str) -> set[str]: """把 <style> 里认定「看不见」的类名挑出来。""" soup = BeautifulSoup(html, "html.parser") css = "\n".join(tag.get_text() for tag in soup.find_all("style")) names = set() for match in re.finditer(r"\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}", css): body = re.sub(r"\s+", "", match.group(2)).lower() if any(hint in body for hint in HIDDEN_HINTS): names.add(match.group(1)) return names
def is_hidden(tag, hidden_classes: set[str]) -> bool: """这个元素人眼看得见吗?(内联样式 / 类名 / aria-hidden / hidden 属性)""" inline = re.sub(r"\s+", "", (tag.get("style") or "")).lower() if any(hint in inline for hint in HIDDEN_HINTS): return True if set(tag.get("class") or []) & hidden_classes: return True if tag.get("aria-hidden") == "true" or tag.has_attr("hidden"): return True parent = tag.parent # 隐藏也可能是包在看不见的父节点里 while parent is not None and getattr(parent, "name", None): if parent.get("aria-hidden") == "true" or parent.has_attr("hidden"): return True parent = parent.parent return False这一关的三行幽灵数据,正好用了三种不同的隐藏方式:内联
display:none、CSS 类display:none(.row-ghost)、CSS 类visibility:hidden(.sp-invisible)。少了任何一种判断,都会多抓 3 行。
第二步:三类典型蜜罐(这一关全有)
一、隐藏行(幽灵数据)
页面表格里塞几行”人看不见、DOM 里在”的记录,编号和金额都做得和真数据同量级 —— 不会一眼假。踩中的人通常是:把整张表抓下来的人(//tr、table > tbody > tr、read_html)。这一关的三行幽灵行,read_html 会漏掉其中 1 行(内联 display:none 那个),另外两行照抓 —— 这就是”半吊子过滤”的代价:数字看着像对的,一算就偏。
破法:可见性过滤(第一步)+ 用页面自己给的条数校验。这一关的页脚明明白白写着「本页 10 条」,抓到 13 行(或者 12 行)就该停下来查。
二、隐藏链接(把爬虫往坑里带)
页面里除了正常的分页链接,还有几个离屏的链接:一个 “下一页”、一个 “接口调试入口”、一个带 _hp=1 的 “备用线路”。它们用的是 .visually-hidden(无障碍样式的经典写法)和 .seo-aux(SEO 辅助块)这种看着完全无害的类名。
注意一个容易搞错的点:这些链接在自动化工具眼里是”可见”的 —— Playwright 的 is_visible() 只看有没有 1×1 的盒子,离屏元素照样返回 true。所以”人看不见”和”工具认为可见”是两件事,脚本得自己按几何位置判断:
box = link.bounding_box() # 离屏元素:x = -9999if box and box["x"] < 0: continue # 这种东西不是给人点的,别跟破法:分页只跟随看得见、在文档流里的链接;把页面里所有 <a href> 都跟一遍是最容易被钓鱼的习惯。
三、隐藏表单字段(看着像统计参数)
页面里有一个离屏的 GET 表单,字段是 utm_source / utm_medium / refer(名字取得很像埋点统计参数)。正常分页只需要一个 page 参数 —— 一旦你的请求 URL 里出现这几个字段,就等于举手说”我是把页面所有 <input> 拼起来发请求的脚本”。
破法:只带接口需要的参数;不要无脑收集页面上的所有输入(尤其 hidden 类型的)。
# 反面教材:把页面里所有 input 都拼成参数params = {field.get("name"): field.get("value") for field in soup.find_all("input")} # ← 会带上蜜罐字段# 正面:只带你确认需要的params = {"page": 3}第三步:识别自己被「喂了假数据」
被污染之后,服务端不会发任何通知,但留下的破绽有三处(这一关都埋了):
| 信号 | 怎么用 |
|---|---|
| 同一页两次抓取不一致 | 最可靠的一招:抓两次比对金额;变了就说明数据是现造的 |
| 整页「更新于」挤在同一秒 | 假数据是同一批生成的,时间戳没有分散性;正常页面各行更新时间不同 |
| 页脚条数 ≠ 你抓到的行数 | 可能是幽灵行,也可能是数据被动过 |
把这三条写成脚本里的自检,比事后对着答案发呆强得多:
first = fetch(1)if [row["amount"] for row in visible_rows(first)] != [row["amount"] for row in visible_rows(fetch(1))]: print("⚠️ 同一页两次金额不一样:踩到蜜罐了,现在停手等冷却") return # 拿脏数据算下去只会得到错答案if len(visible_rows(first)) != footer_count(first): print(f"⚠️ 抓到 {len(visible_rows(first))} 行,页脚说 {footer_count(first)} 条:先查隐藏行")为什么必须”停手”:这一关的冷却会叠加(45 秒起步,每多踩一次 +30 秒,上限 180 秒)。越”多试几次找规律”,被封得越久 —— 真实站点的风控台账也是这么累积的。
第四步:被标记了怎么办
- 本关的污染会自己到期(冷却结束、数据恢复原样);真实站点不会,只能降低自己的可疑度或换出口。
- 换 IP / 清 Cookie 不一定有用:风控看的是”这个会话/账号做过什么”。本关按
user:<id>记账,没登录才退化成按 IP。 - 真正该做的是回头看你请求了什么:是不是跟了隐藏链接、是不是把隐藏字段带上了。把行为改干净了,污染才不会再累积。
服务端那边只留一行日志,不会回给调用方(这就是真实站点的做法):
CH-015 蜜罐命中 identity=user:1 kind=hidden-link:/playground/trap/next hits=1 接下来 45s 返回被污染的数据(不报错、不封禁)第五步:完整脚本
# 1) 浏览器登录靶场,F12 -> Application -> Cookies,抄下 session 的值# 2) 填进 参考代码/s17.py 的 SESSION_COOKIEpython 参考代码/s17.py脚本干了四件事:
- 抓第 1 页,先把页面里的蜜罐列出来(隐藏链接、隐藏表单字段)—— 知道坑在哪,才知道别碰;
- 做可见性过滤,把 13 行收敛到 10 行,并和页脚的「本页 N 条」对上;
- 同一页连抓两次做一致性校验,一旦发现被污染就当场停手;
- 5 页求和,提交。
反爬方视角:蜜罐怎么设计才有效
- 诱饵要比正路”更好走”:隐藏的”下一页”看着比真分页还像分页,隐藏字段看着比真参数还像参数。
- 踩中不报错:一旦返回 403 或验证码,对方就知道被发现了;安静地喂假数据才有杀伤力。
- 假数据要”像”:同量级、同格式、有正常的分页。一眼假的(金额 999999)只会提醒对方。
- 留一条可自证的破绽(本关是同页两次不一致 + 时间戳同秒):这是教学靶场的善意;真实站点往往把假数据做得每次一致,那就只能靠”用另一个数据源交叉验证”了。
- 记台账、能累积:踩过几次、踩了什么,都记下来,越踩越严 —— 这就是本关冷却叠加的由来。
排查清单(⚠️ 先看前三条)
- 抓到的行数 ≠ 页脚条数 → 先做可见性过滤(
display:none、visibility:hidden、离屏、aria-hidden),别急着怀疑算法。 - 同一页两次数据不一样 → 你被污染了:停手、等冷却(别多试),并且别再碰隐藏链接/字段。
- 整页「更新于」只剩一种取值 → 假数据的味道,同上处理。
pandas.read_html只认内联display:none:靠类名隐藏的行它照吃不误(本站 3 行幽灵行里它会漏掉 1 行、吃掉 2 行)—— 正确做法是”按 CSS 规则判断可见性”,不是只查一个属性。- 别把页面里所有
<input>拼成 params(尤其utm_*/refer/_hp这种”统计参数”)。 - 别遍历所有
<a href>;分页只跟随看得见、在文档流里的链接。 - 数据入库前先做校验:条数、唯一编号去重、同页两次一致、与另一个来源交叉比对。
- 请求头(Referer / UA)也保持像正常浏览 —— 这一关不看,真实站点会看。
- 答案不对时,先怀疑数据来源,再怀疑算法。这一关就是专门治”算法没问题但结果是错的”。
- 被污染之后重新抓一遍,看看数字有没有变:变了说明还在冷却里。
FAQ
Q:这关有加密吗? 没有。这一关就是要说明:反爬不一定是加密。校验可见性、校验一致性,是抓数里最容易被忽略、又最便宜有效的两道防线。
Q:为什么隐藏行的金额不写成离谱的大数? 因为那样只会提醒你”这行是假的”。真实蜜罐的原则是”像”—— 同量级、同格式,让它在汇总里悄悄偏掉,你只会觉得”怎么算都不对”。
Q:Playwright 的 is_visible() 说隐藏链接是可见的?
对,它只看有没有 1×1 的盒子。判断”人能不能看见”要看位置和尺寸:box["x"] < 0、宽高 ≤ 1px 的,都不该去点。
Q:能绕过污染吗? 等冷却到期就行(背几个 45 秒)。但真正要练的是”别踩”:只抓该抓的、只带该带的参数 —— 真实站点的污染不会自己到期。
Q:页脚的「本页 10 条」是不是太便宜我了? 它是这一关的”校验基准”。真实页面上到处都有这种基准(条数、合计、校验位、编号规律),只是大多数人抓完就走,从来不看。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












