SpiderDemo 是我最近一直在刷的一套爬虫练习网站,从最简单的静态页面抓取开始,一路做到第 5 期任务(T5)。这篇记录就想把 T5 的完整过程拆开来讲:从任务分析、页面结构定位、XPath 坑点,到最终的数据落盘和异常排查,全部按实战顺序梳理一遍。如果你是刚接触 Python 爬虫,或者练过几个 demo 但总在解析环节卡壳,这篇文章应该能给你一条比较顺畅的路径。
先说清楚 T5 是个什么难度:前几期练的是单页抓取和列表页循环,T5 开始加入“详情页”和“动态接口”。也就是说,你在浏览器里看到的列表数据,并不完全写在初始 HTML 里,而是后端通过 XHR 接口返回 JSON,再由前端 JS 渲染出来。这一步非常贴近真实站点,也是初学者最容易栽跟头的地方。我把 SpidersDemo 当靶场,就是因为它能在一个安全可控的环境里把这些坑全部暴露出来,练完再去碰真实项目会踏实很多。
1. 项目整体设计与思路拆解
1.1 SpiderDemo 到底练什么,为什么不能直接拿真实网站练手
SpiderDemo 这类练习网站存在的意义,就是给爬虫学习一个“合法、低风险、有反馈”的场地。真实网站往往有复杂的反爬逻辑、页面结构随时会变、请求频率过高还可能影响线上服务甚至触发法律责任。新手直接拿热门站点练手,很难判断是自己代码问题还是被反爬拦了,学习成本会非常高。
SpiderDemo 模拟了常见的网页结构:列表页、详情页、分页、关键词搜索、登录态、动态异步加载等。每个任务都明确告诉你目标数据是什么,比如“提取前 5 页文章的标题、发布时间和正文摘要”。你可以在上面反复试错而不必担心给对方服务器造成压力。T5 的任务就是这样一个经典组合:列表页展示文章卡片,但正文内容在独立的详情页里,列表页的数据通过接口异步加载。
1.2 T5 用到的技术栈,以及为什么这么选
我 T5 用的是 Python 3 + requests + lxml,没有上 Scrapy。不是 Scrapy 不好,而是这个任务量级用不到。Scrapy 适合大规模采集、需要分布式调度、中间件扩展的场景;T5 总共几十篇文章,最核心的目的是吃透“请求—解析—提取—存储”这条链路。先用轻量库把每一步的细节看明白,后面再上框架会顺手很多。
requests 负责发起 HTTP 请求,处理请求头、Session、超时;lxml 负责解析 HTML,尤其用到 XPath 定位元素。为什么不用正则?正则处理 HTML 容易写成一团乱麻,遇到标签嵌套和属性变化会非常脆弱。XPath 是面向 DOM 结构的查询语言,用表达路径去选节点,比如//article//h2/a,直观且健壮。T5 里的一个隐藏测试点就是用到了text()函数,这个函数在提取文本节点时非常容易用错,后面我专门讲。
1.3 T5 的整体执行流程
整个 T5 可以分成 6 个环节,我习惯在动手前先列出一个流程表,避免写着写着思路断掉。
| 步骤 | 目标 | 主要工具 / 方法 |
|---|---|---|
| 1 | 分析页面结构,找出数据来源 | 浏览器开发者工具 Network 面板 |
| 2 | 构造请求,获取 HTML 或 JSON | requests,Session,Headers |
| 3 | 解析内容,定位目标节点 | lxml,XPath,text() |
| 4 | 清洗数据,处理链接和空白 | 字符串处理,datetime 解析 |
| 5 | 循环翻页并抓取详情页 | for 循环,time.sleep 限速 |
| 6 | 保存结果并校验 | csv / json 模块,抽样检查 |
任务本身的复杂度不高,但每一步都有可以展开的细节。后面我会按这个流程把关键代码和踩过的坑逐一说明。
2. 核心细节解析与实操要点
2.1 requests 请求头与 Session 的管理
写爬虫第一件事不是写代码,而是“先看请求长什么样”。打开浏览器开发者工具,切到 Network 面板,刷新页面,找到真正的数据请求,然后看看它的 Headers。我看到很多新手直接requests.get(url)拿到 200 就开始解析,结果可能已经触发反爬被返回了空白页。T5 虽然只是一个练习站,但同样设置了 User-Agent 校验,没有合法 UA 会直接 403。
推荐用requests.Session()保持一个会话,这样 Cookies、连接池都能复用。请求头里至少要设置User-Agent和Accept-Language,有些页面还需要Referer来标识来源。下面是我 T5 里用的请求头模板:
import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) resp = session.get("https://spiderdemo.example/t5/list", timeout=10) resp.raise_for_status()重点解释两点:第一,timeout必须设置,否则某个接口卡住时整个程序会挂死;第二,raise_for_status()可以把 HTTP 错误变成异常,方便统一处理。不要把这些步骤当成“形式主义”,很多反爬就是从请求头特征开始识别的。当然,设置 UA 只是模拟正常浏览器的基本行为,真正的合规爬虫还要遵守目标站点的 robots 协议,这点后面专门说。
2.2 XPath 中 text() 函数用不好必踩坑
T5 的详情页 DOM 结构类似这样:
<article> <h1 class="title">SpiderDemo T5 实战记录</h1> <div class="meta"> <span>2025-01-15</span> <span>作者:测试员</span> </div> <div class="content"> 第一段正文。 <p>第二段正文。</p> <b>加粗内容</b> </div> </article>很多人在提取正文时写//div[@class="content"]/text(),然后发现只拿到了“第一段正文。”和几个换行符,<p>和<b>里的文字全部消失。这就是text()的典型陷阱:它只会返回当前节点的直接文本子节点,不会返回后代节点里的文本。
正确的做法是:如果想提取一个区域内所有可见文本,可以用//div[@class="content"]//text()拿到该节点下所有文本节点的列表,再用join拼起来。当然,这样会把换行、空白符也带过来,所以要做清洗。还有一种更简洁的方式是string(//div[@class="content"]),它会递归地把子文本拼接成字符串。我 T5 里用的就是前者,因为方便控制分隔符。
from lxml import html doc = html.fromstring(resp.text) title = doc.xpath("//h1[@class='title']/text()")[0].strip() content_parts = doc.xpath("//div[@class='content']//text()") content = "".join([part.strip() for part in content_parts if part.strip()]) meta_text = doc.xpath("string(//div[@class='meta'])").strip()另外,XPath 里匹配 class 也要小心。class="content main"这种多值属性,直接用@class="content"会失败。正确写法是contains(@class, "content"),它能做部分匹配。这个技巧在处理真实站点时几乎必用。
2.3 动态加载页面的接口分析与 JSON 解析
T5 的列表页一开始用requests.get抓回来,能取到页面框架,但文章列表所在的位置是空的。这就是动态渲染:数据由浏览器执行 JavaScript 后从接口拉取并填充。碰到这种情况,最靠谱的思路不是盲目去找渲染工具,而是先分析接口。
操作很简单:在开发者工具的 Network 面板筛选 XHR 或者 Fetch,刷新列表页,观察哪个请求返回的是 JSON 数据。T5 的列表接口大概是/t5/api/articles?page=1,返回内容如下:
{ "code": 0, "data": { "total": 42, "list": [ { "id": 101, "title": "SpiderDemo T5 文章标题", "publish_time": "2025-01-15 08:30", "url": "/t5/detail/101.html" } ] } }拿到这个接口后,直接解析 JSON 比解析 HTML 稳定得多。这也解释了一个常见疑问:为什么需要“爬虫抓包”?因为请求在网络层发生的事情,最终都可以通过 Network 面板看清。练好接口分析能力,比依赖任何自动化浏览器工具都重要。
解析 JSON 时记得把total取出来,作为翻页循环的终点判断,而不是自己数列表数量。T5 的接口设计很规矩,page参数从 1 开始递增,返回list为空表示到底。这里有一个小细节:code字段要判断,有些站点在需要登录时会返回错误码而不是直接抛 HTTP 错误,只看状态码会漏掉问题。
2.4 数据清洗与字段规整
从 HTML 和 JSON 里拿到的数据通常是不干净的。常见情况有:
- 字符串里混着
\u3000、\xa0等不间断空格,直接strip()去不掉; - 相对链接,比如
/t5/detail/101.html,需要和域名拼成完整 URL; - 日期时间字段是字符串格式,需要转成 Python 的
datetime对象才能排序和统计。
我的清洗顺序是先拼 URL,再去空白,最后统一处理时间字符串。对 T5 来说,URL 拼接用urllib.parse.urljoin最方便,它会自动处理根路径。时间解析直接用datetime.strptime,格式和接口里的%Y-%m-%d %H:%M对应。
from urllib.parse import urljoin from datetime import datetime base_url = "https://spiderdemo.example" full_url = urljoin(base_url, item["url"]) clean_title = re.sub(r"[\s\u3000]+", " ", item["title"]).strip() publish_dt = datetime.strptime(item["publish_time"], "%Y-%m-%d %H:%M")为什么要这么细?因为后续无论做数据分析还是数据展示,脏数据都是灾难。爬虫的核心不只是“抓到内容”,而是“拿到能用的结构化数据”。这也是为什么我不建议一上来就追求爬取数量,质量更重要。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
T5 的开发环境不需要很复杂,Python 3.10 以上即可。建议用虚拟环境,避免污染系统 Python:
python3 -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install requests lxmlrequests 负责 HTTP 请求,lxml 负责 HTML/XML 解析,这两个库足够完成 T5。如果你后面想用更上层的解析器,可以再考虑parsel,它封装了 XPath 和正则匹配,但底层还是 lxml。T5 我刻意没加pandas,因为 CSV 用标准库就能写,加太多依赖反而影响思路清晰。
3.2 主流程代码:列表页接口请求与详情页解析
下面是我 T5 的核心代码,已经做了简化,但流程完整,可以直接参考。为了避免请求过快,我在每次详情页请求之间加了time.sleep;重试逻辑用了一个简单的装饰器,最多重试两次。
import csv import random import time import requests from lxml import html from urllib.parse import urljoin BASE = "https://spiderdemo.example/t5" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Referer": BASE + "/list.html", "X-Requested-With": "XMLHttpRequest", } session = requests.Session() session.headers.update(HEADERS) def fetch(url, **kwargs): resp = session.get(url, timeout=10, **kwargs) resp.raise_for_status() return resp def parse_detail(url): resp = fetch(url) doc = html.fromstring(resp.text) title = doc.xpath("//h1[@class='title']//text()") title = "".join(title).strip() content_parts = doc.xpath("//div[@class='content']//text()") content = "".join([p.strip() for p in content_parts if p.strip()]) publish_time = doc.xpath("string(//div[@class='meta']//time)").strip() return {"title": title, "content": content, "publish_time": publish_time} # 1. 获取列表接口 list_resp = fetch(BASE + "/api/articles?page=1") data = list_resp.json()["data"] total = data["total"] articles = data["list"] results = [] for item in articles: detail_url = urljoin(BASE + "/", item["url"]) try: detail = parse_detail(detail_url) detail["url"] = detail_url results.append(detail) print("OK:", detail["title"]) except Exception as exc: print("FAIL:", detail_url, exc) # 限速:随机 0.5-1.5 秒 time.sleep(random.uniform(0.5, 1.5)) # 2. 保存 CSV with open("t5_results.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "publish_time", "url", "content"]) writer.writeheader() writer.writerows(results) print(f"完成,共 {len(results)} 条,列表页总数 {total}")这段代码里有两个点值得展开:一是encoding="utf-8-sig",如果不加,在 Windows 上用 Excel 打开 CSV 会乱码;二是newline="",如果不加,Windows 下 CSV 会多出空行。这些都属于“文档里不写,但实战中一定会遇到”的细节。
3.3 健壮性到底要怎么做,才算合格
很多新手写完一个能跑的爬虫就停手了,这是不对的。一个合格的爬虫,必须考虑网络波动、页面结构缺失、反爬限流这三大问题。我 T5 里做了三件事:
第一,try/except不能只包一整块,要在关键的解析步骤外面包,这样才能知道具体是请求失败还是解析失败。上面代码里parse_detail内部请求和解析都有独立异常传播,外部捕获后打印 URL,定位问题非常快。
第二,time.sleep(random.uniform(...))不是仪式感。频繁请求会造成服务器压力,也是触发 IP 封禁的主要原因之一。随机间隔比固定间隔更接近人类操作,同时防止多个线程同时睡醒造成请求波峰。
第三,字段提取要容忍缺失。比如某条详情页没有publish_time,就不应该让[0]抛异常让程序中断。更稳妥的写法是先从列表提取字段,取不到就赋默认值:
time_tag = doc.xpath("//div[@class='meta']//time/text()") publish_time = time_tag[0].strip() if time_tag else ""这段逻辑的关键在于“先判空,再取值”,看起来啰嗦,但能避免整个任务因为一条脏数据报废。真实项目中我还会记录失败 URL 到日志文件,方便任务结束后重试。
3.4 校验结果:不仅仅是看打印
代码跑完后,print显示抓了 42 条,不等于任务就成功了。我会做两步校验:第一步,确认len(results) == total,如果少于总数,说明有详情页解析失败;第二步,从 CSV 里随机抽 3 条,对比网页实际内容,确认字段没有张冠李戴。
还有一个经常被忽略的校验是“重复检查”。如果列表接口翻页时重复返回了上一页的数据,或者二次抓取时页面被重定向,结果里就会出现重复 ID。所以我写代码时会顺手在循环里加一个seen_ids集合,遇到重复直接跳过。这个习惯后来在做真实项目时救了我很多次。
4. 常见问题与排查技巧实录
4.1 403 Forbidden:先从请求头找原因
403 是爬虫练习中最常见的报错。SpiderDemo 的 T5 也设置了基础防护,如果直接requests.get裸奔,大概率会收到 403。排查顺序我一般这样排:
| 症状 | 可能原因 | 排查方法 / 解决建议 |
|---|---|---|
| 403 Forbidden | 缺少合法的 User-Agent | 设置完整的浏览器 User-Agent |
| 403 Forbidden | 缺少 Referer 或 Ajax 标识 | 对比浏览器真实请求,补全 Header |
| 403 Forbidden | 请求频率过高 | 增加 sleep 间隔,降低并发 |
| 403 Forbidden | IP 被临时限制 | 先停止请求,过段时间再试,切忌硬刚 |
T5 的列表接口要求带X-Requested-With: XMLHttpRequest,这是一个很典型的动态接口校验。浏览器真实请求里带了,你的代码没带,接口就可能返回 403。解决办法就是在 Network 面板里“照抄”请求头,不需要多,但关键一个都不能少。
4.2 提取结果为空:不要怀疑人生,先验证 XPath
如果在浏览器里明明能看到内容,但 XPath 提取回来是空列表,九成是下面几种情况:页面是动态渲染的(请参考 2.3 节找接口)、XPath 路径写错、text()用错节点层级。我写了一个速查表,适合在排查时对照:
| 场景 | 报错 / 现象 | 正确思路 |
|---|---|---|
想取<h1>文本 | //h1返回元素对象 | 加/text()或用string(//h1) |
| 想取容器内所有文字 | //div[@class="a"]//text()返回很多碎片 | join后清洗,或直接用string() |
| class 多值匹配 | @class="content"为空 | 换成contains(@class, "content") |
| 元素在 iframe 中 | XPath 找不到节点 | 先请求 iframe 的 src 地址再解析 |
另外,我强烈建议在写完整流程前,先打开 Python 交互环境,把单独一个详情页的 XPath 跑一遍,确认无误再写进主循环。一次只验证一个变量,比在长脚本里反复 print 高效得多。
4.3 中文乱码与编码问题
T5 的页面是 UTF-8,但如果换到其他站点,很容易遇到 GBK 编码的页面。requests默认会用响应头里的 charset 去解码,但有些页面不写 charset,请求库就会猜,猜错了就乱码。我的处理方法是显式设置:
resp.encoding = resp.apparent_encoding不过apparent_encoding会读取整个内容做分析,有一定的性能开销。更好的做法是先从resp.headers.get("Content-Type")里找 charset,找不到再回退到apparent_encoding。中文乱码问题本身不难,难在养成“先看编码再解析”的习惯。
4.4 动态页面无法获取数据:先看接口,再看渲染
遇到动态页面,有人会立刻去装 Selenium 或者 Playwright,这没错,但属于“用大炮打蚊子”。T5 之所以把数据放在接口里,是为了模拟真实站点的前后端分离架构。这种情况下,直接请求接口的效率远高于启动一个浏览器内核。我先尝试找 JSON 接口,只有接口加密严重或者需要复杂交互时才考虑渲染工具。这个先后顺序能帮你节省 90% 的资源消耗。
当然,有些接口会对参数做签名甚至对响应内容做混淆,这就是另一层对抗了。但作为学习和练习,我们应该优先选择合法的、公开的接口,而不是去破解别人的保护机制。爬虫技术的价值在于处理公开数据和合规授权数据,这点要一直记住。
5. 爬虫练习之外的防护思考
5.1 练习站在设计防爬时到底在想什么
SpiderDemo 本身也是一个教学工具,它的反爬设计其实模拟了真实站点的通用策略。作为爬虫学习者,如果只学会“攻”,不懂“防”,思路会缺一半。从开发者视角看,防爬不是为了“不让搜索引擎收录”,而是为了防恶意程序:
- 防止高频请求打垮应用服务;
- 防止批量注册、刷接口等行为;
- 防止核心数据被无授权批量抓取。
所以网站通常会用 User-Agent 识别、IP 频率限制、登录态校验、验证码等方式。这些手段并非为了难为普通用户,而是用来区分“正常访问”和“机器行为”。明白了这层逻辑,你写爬虫时也会更清楚:哪些行为是合理的,哪些行为会触线。
5.2 Java Controller 层的通用防护思路
很多热搜词都指向“Java controller 层如何防护防止爬虫”。这说明不少后端开发者也关心接口安全。T5 的列表接口虽然没有写 Java 代码,但原理是相通的:Java 后端可以在进入 Controller 之前加一个拦截器(HandlerInterceptor),统一做三件事:
- 读取请求头中的
User-Agent,对明显是爬虫库的 UA 做过滤; - 用 Redis 统计 IP 维度的请求频率,超过阈值直接返回 429;
- 对敏感接口要求必须携带签名参数,防止随意拼接 URL。
这些方法适用于普通业务接口。需要注意的是,防护策略不能误伤正常用户,比如同一个公司 NAT 出口的多个用户可能共享一个 IP,粗暴封禁会影响一批人。所以更精细的做法是结合设备指纹或行为特征判断,而不是只看 IP。T5 的接口设计其实也体现了一点:它要求带X-Requested-With头,模拟对异步请求的校验。这只是入门级防护,真正复杂的防护要考虑参数合法性校验、请求时序分析等。
5.3 前端“防查看源码”到底靠不靠谱
热搜词里还有“前端安全技术操作、防止查看页面源码、防止打开”。我直接说结论:前端不可能真正防止别人查看源码。浏览器为了渲染页面,必须把 HTML、CSS、JavaScript 下载到本地,用户只要打开开发者工具就能看到网络请求和资源文件。禁用右键、屏蔽 F12、禁止 Ctrl+U,这些都只能挡住最普通的人,反而会影响正常用户体验。
那前端能做什么?只能提高分析门槛。比如:
- 对 HTML 结构做混淆,让 XPath 路径不稳定;
- 把数据通过 Ajax 加载,让初始源码里没有关键信息;
- 给接口参数加动态 token,但 token 本身也要在前端生成,所以依然可追踪。
真正的防护重点应该放在服务端:权限校验、频率控制、核心数据不公开接口。前端防爬是“防君子不防小人”,SpiderDemo 的 T5 就是一个很好的例子,它的页面源码很干净,数据全在接口里,但接口依然可以被 Network 面板看到,所以前端混淆不能作为安全兜底。
5.4 合规爬虫的学习路径建议
说了这么多技术细节,最后还是要回到边界问题。爬虫本身是中性工具,搜索引擎、价格监控、数据分析都离不开它。但使用爬虫必须遵守规则:查看并尊重目标网站的robots.txt;不对公开接口做超出正常频率的请求;不采集个人信息和受版权保护的内容;在需要大量抓取时,优先联系站点获取授权或使用官方 API。
我也理解很多新人一开始只是“想练手”,但练手不应该以牺牲他人服务稳定性为代价。SpiderDemo 这类练习网站的价值就在这里:你可以无限试错,所有反爬逻辑都是预设好的学习关卡,不会造成真实影响。我自己的习惯是,开发爬虫前先写一个“请求策略表”:包括目标域名、robots 允许范围、预计请求量、请求间隔、异常退出条件。看起来很麻烦,但这是对整个技术生涯负责。
我现在已经刷到 T5 之后了,回头总结,最想分享的一点是:爬虫学习最快的路径不是追新工具、不是囤一堆框架,而是把一个练习网站的任务从头到尾吃透。T5 这趟记录里,最值钱的经验其实不是那段能跑的代码,而是我在排查text()函数和动态接口时建立起来的排查思路。以后再遇到任何页面解析不出来,我都会先问自己三个问题:数据是不是接口返回的?XPath 是不是写在了正确的层级?请求头是不是漏了什么关键字段?这三个问题解决掉,绝大多数爬虫任务已经完成了八成。
最后补一个小技巧:在开始写代码之前,花十分钟手动画一张目标页面的结构草图,把列表、详情、分页之间的关系标清楚。这个习惯我从 T1 坚持到 T5,每次都能帮我提前发现那些需要单独处理的边缘页面。希望这份记录也能给你的爬虫学习之路带来一些参考。