视频加载失败

第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的

3239 字
16 分钟
第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的

本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是最普通不过的一个 HTML 列表页:不加密、不验签、不弹验证码,5 页 × 10 条公告,谁都能抓。难的是你不知道自己抓到的是不是真的 —— 页面里埋了三类「只给爬虫准备」的东西:看不见的行会被当成数据,看不见的链接和字段碰一下就进小黑屋,之后拿到的每一份数据都对不上。这一关真正要带走的两条习惯是:抓表格前先做可见性过滤、拿到数据先做一致性校验。

靶场:/playground/trap(登录后打开) 参考解法:参考代码/s17.py

现象#

  1. 打开靶场,就是一个普通的公告列表:公告编号 / 项目名称 / 金额(万元)/ 更新于,页脚写着「本页 10 条 · 第 1 / 5 页」。
  2. 分页也普通:1 2 3 4 5 +「下一页」,下面还有个「跳转到第 N 页」的下拉框。
  3. 但 F12 一看就不对劲:DOM 里 table tbody tr 有 13 个,页脚却说 10 条。
  4. 用最常见的一把梭抓法(pandas.read_html)抓下来 → 每页 12 行(它只认内联 display:none,靠类名隐藏的两行照样抓)→ 5 页求和 → 提交上去判错。
  5. 更阴的是:如果你遍历了页面上所有 <a href>(或者把页面里所有 <input> 拼成请求参数),接下来同一页两次抓到的数字就不一样了,而且整页「更新于」全挤在同一秒。
  6. 全程没有任何报错:不 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 re
from 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 = -9999
if 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 返回被污染的数据(不报错、不封禁)

第五步:完整脚本#

Terminal window
# 1) 浏览器登录靶场,F12 -> Application -> Cookies,抄下 session 的值
# 2) 填进 参考代码/s17.py 的 SESSION_COOKIE
python 参考代码/s17.py

脚本干了四件事:

  1. 抓第 1 页,先把页面里的蜜罐列出来(隐藏链接、隐藏表单字段)—— 知道坑在哪,才知道别碰;
  2. 做可见性过滤,把 13 行收敛到 10 行,并和页脚的「本页 N 条」对上;
  3. 同一页连抓两次做一致性校验,一旦发现被污染就当场停手;
  4. 5 页求和,提交。

反爬方视角:蜜罐怎么设计才有效#

  1. 诱饵要比正路”更好走”:隐藏的”下一页”看着比真分页还像分页,隐藏字段看着比真参数还像参数。
  2. 踩中不报错:一旦返回 403 或验证码,对方就知道被发现了;安静地喂假数据才有杀伤力。
  3. 假数据要”像”:同量级、同格式、有正常的分页。一眼假的(金额 999999)只会提醒对方。
  4. 留一条可自证的破绽(本关是同页两次不一致 + 时间戳同秒):这是教学靶场的善意;真实站点往往把假数据做得每次一致,那就只能靠”用另一个数据源交叉验证”了。
  5. 记台账、能累积:踩过几次、踩了什么,都记下来,越踩越严 —— 这就是本关冷却叠加的由来。

排查清单(⚠️ 先看前三条)#

  1. 抓到的行数 ≠ 页脚条数 → 先做可见性过滤(display:none、visibility:hidden、离屏、aria-hidden),别急着怀疑算法。
  2. 同一页两次数据不一样 → 你被污染了:停手、等冷却(别多试),并且别再碰隐藏链接/字段。
  3. 整页「更新于」只剩一种取值 → 假数据的味道,同上处理。
  4. pandas.read_html 只认内联 display:none:靠类名隐藏的行它照吃不误(本站 3 行幽灵行里它会漏掉 1 行、吃掉 2 行)—— 正确做法是”按 CSS 规则判断可见性”,不是只查一个属性。
  5. 别把页面里所有 <input> 拼成 params(尤其 utm_* / refer / _hp 这种”统计参数”)。
  6. 别遍历所有 <a href>;分页只跟随看得见、在文档流里的链接。
  7. 数据入库前先做校验:条数、唯一编号去重、同页两次一致、与另一个来源交叉比对。
  8. 请求头(Referer / UA)也保持像正常浏览 —— 这一关不看,真实站点会看。
  9. 答案不对时,先怀疑数据来源,再怀疑算法。这一关就是专门治”算法没问题但结果是错的”。
  10. 被污染之后重新抓一遍,看看数字有没有变:变了说明还在冷却里。

FAQ#

Q:这关有加密吗? 没有。这一关就是要说明:反爬不一定是加密。校验可见性、校验一致性,是抓数里最容易被忽略、又最便宜有效的两道防线。

Q:为什么隐藏行的金额不写成离谱的大数? 因为那样只会提醒你”这行是假的”。真实蜜罐的原则是”像”—— 同量级、同格式,让它在汇总里悄悄偏掉,你只会觉得”怎么算都不对”。

Q:Playwright 的 is_visible() 说隐藏链接是可见的? 对,它只看有没有 1×1 的盒子。判断”人能不能看见”要看位置和尺寸:box["x"] < 0、宽高 ≤ 1px 的,都不该去点。

Q:能绕过污染吗? 等冷却到期就行(背几个 45 秒)。但真正要练的是”别踩”:只抓该抓的、只带该带的参数 —— 真实站点的污染不会自己到期。

Q:页脚的「本页 10 条」是不是太便宜我了? 它是这一关的”校验基准”。真实页面上到处都有这种基准(条数、合计、校验位、编号规律),只是大多数人抓完就走,从来不看。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
https://jsnote.top/posts/ls-ch15/
作者
xiajiao
发布于
2026-10-03
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
第 11 关:无限 debugger + VM 混淆 + 签名请求头
爬虫靶场本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最"脏":无限 debugger(关键字还是拼出来的)、base64 源码交给 new Function 在运行时编译、请求头 m 要现算签名,票价则靠 base6…
2
第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
爬虫靶场本文是 LearnSpider 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字,但接口只认一个签过名的 sign Cookie:这个 Cookie 是前端一段混淆过的 JS连着过七层算法算出来的(拼盐 → MD5 派生密…
3
第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
爬虫靶场本文是 LearnSpider 第 12 关的解密教程。这一关换了个思路:页面本身几乎是空的,真正的防守逻辑不在静态文件里,而是登录后由接口现发一段 JS 下来。这段 JS 里:关键字全被十六进制转义映射($dbsm_0x5d57)、一段字…
4
第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
爬虫靶场本文是 LearnSpider 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起:请求头 sm 是每次现算的,报文也不是明文,靶场还只放你书单里的前四页。
5
第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
爬虫靶场本文是 LearnSpider 第 13 关的解密教程。这一关的靶场是一张点选验证码:把图上所有文字点掉。看着像「识图题」,真正的坎却在后面 —— 提交的不是「点了哪几个坐标」,而是一整段鼠标轨迹(时间、坐标、速度),用 AES+RSA 加…
随机文章随机推荐

评论区

Profile Image of the Author
xiajiao
写爬虫,也写防爬虫的靶场。
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
15
分类
1
标签
33
总字数
22,317
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录