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 或 JSONrequestsSessionHeaders3解析内容定位目标节点lxmlXPathtext()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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }) resp session.get(https://spiderdemo.example/t5/list, timeout10) resp.raise_for_status()重点解释两点第一timeout必须设置否则某个接口卡住时整个程序会挂死第二raise_for_status()可以把 HTTP 错误变成异常方便统一处理。不要把这些步骤当成“形式主义”很多反爬就是从请求头特征开始识别的。当然设置 UA 只是模拟正常浏览器的基本行为真正的合规爬虫还要遵守目标站点的 robots 协议这点后面专门说。2.2 XPath 中 text() 函数用不好必踩坑T5 的详情页 DOM 结构类似这样article h1 classtitleSpiderDemo T5 实战记录/h1 div classmeta span2025-01-15/span span作者测试员/span /div div classcontent 第一段正文。 p第二段正文。/p b加粗内容/b /div /article很多人在提取正文时写//div[classcontent]/text()然后发现只拿到了“第一段正文。”和几个换行符p和b里的文字全部消失。这就是text()的典型陷阱它只会返回当前节点的直接文本子节点不会返回后代节点里的文本。正确的做法是如果想提取一个区域内所有可见文本可以用//div[classcontent]//text()拿到该节点下所有文本节点的列表再用join拼起来。当然这样会把换行、空白符也带过来所以要做清洗。还有一种更简洁的方式是string(//div[classcontent])它会递归地把子文本拼接成字符串。我 T5 里用的就是前者因为方便控制分隔符。from lxml import html doc html.fromstring(resp.text) title doc.xpath(//h1[classtitle]/text())[0].strip() content_parts doc.xpath(//div[classcontent]//text()) content .join([part.strip() for part in content_parts if part.strip()]) meta_text doc.xpath(string(//div[classmeta])).strip()另外XPath 里匹配 class 也要小心。classcontent main这种多值属性直接用classcontent会失败。正确写法是contains(class, content)它能做部分匹配。这个技巧在处理真实站点时几乎必用。2.3 动态加载页面的接口分析与 JSON 解析T5 的列表页一开始用requests.get抓回来能取到页面框架但文章列表所在的位置是空的。这就是动态渲染数据由浏览器执行 JavaScript 后从接口拉取并填充。碰到这种情况最靠谱的思路不是盲目去找渲染工具而是先分析接口。操作很简单在开发者工具的 Network 面板筛选 XHR 或者 Fetch刷新列表页观察哪个请求返回的是 JSON 数据。T5 的列表接口大概是/t5/api/articles?page1返回内容如下{ 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 以上即可。建议用虚拟环境避免污染系统 Pythonpython3 -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, timeout10, **kwargs) resp.raise_for_status() return resp def parse_detail(url): resp fetch(url) doc html.fromstring(resp.text) title doc.xpath(//h1[classtitle]//text()) title .join(title).strip() content_parts doc.xpath(//div[classcontent]//text()) content .join([p.strip() for p in content_parts if p.strip()]) publish_time doc.xpath(string(//div[classmeta]//time)).strip() return {title: title, content: content, publish_time: publish_time} # 1. 获取列表接口 list_resp fetch(BASE /api/articles?page1) 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, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, publish_time, url, content]) writer.writeheader() writer.writerows(results) print(f完成共 {len(results)} 条列表页总数 {total})这段代码里有两个点值得展开一是encodingutf-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[classmeta]//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-Agent403 Forbidden缺少 Referer 或 Ajax 标识对比浏览器真实请求补全 Header403 Forbidden请求频率过高增加 sleep 间隔降低并发403 ForbiddenIP 被临时限制先停止请求过段时间再试切忌硬刚T5 的列表接口要求带X-Requested-With: XMLHttpRequest这是一个很典型的动态接口校验。浏览器真实请求里带了你的代码没带接口就可能返回 403。解决办法就是在 Network 面板里“照抄”请求头不需要多但关键一个都不能少。4.2 提取结果为空不要怀疑人生先验证 XPath如果在浏览器里明明能看到内容但 XPath 提取回来是空列表九成是下面几种情况页面是动态渲染的请参考 2.3 节找接口、XPath 路径写错、text()用错节点层级。我写了一个速查表适合在排查时对照场景报错 / 现象正确思路想取h1文本//h1返回元素对象加/text()或用string(//h1)想取容器内所有文字//div[classa]//text()返回很多碎片join后清洗或直接用string()class 多值匹配classcontent为空换成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、禁止 CtrlU这些都只能挡住最普通的人反而会影响正常用户体验。那前端能做什么只能提高分析门槛。比如对 HTML 结构做混淆让 XPath 路径不稳定把数据通过 Ajax 加载让初始源码里没有关键信息给接口参数加动态 token但 token 本身也要在前端生成所以依然可追踪。真正的防护重点应该放在服务端权限校验、频率控制、核心数据不公开接口。前端防爬是“防君子不防小人”SpiderDemo 的 T5 就是一个很好的例子它的页面源码很干净数据全在接口里但接口依然可以被 Network 面板看到所以前端混淆不能作为安全兜底。5.4 合规爬虫的学习路径建议说了这么多技术细节最后还是要回到边界问题。爬虫本身是中性工具搜索引擎、价格监控、数据分析都离不开它。但使用爬虫必须遵守规则查看并尊重目标网站的robots.txt不对公开接口做超出正常频率的请求不采集个人信息和受版权保护的内容在需要大量抓取时优先联系站点获取授权或使用官方 API。我也理解很多新人一开始只是“想练手”但练手不应该以牺牲他人服务稳定性为代价。SpiderDemo 这类练习网站的价值就在这里你可以无限试错所有反爬逻辑都是预设好的学习关卡不会造成真实影响。我自己的习惯是开发爬虫前先写一个“请求策略表”包括目标域名、robots 允许范围、预计请求量、请求间隔、异常退出条件。看起来很麻烦但这是对整个技术生涯负责。我现在已经刷到 T5 之后了回头总结最想分享的一点是爬虫学习最快的路径不是追新工具、不是囤一堆框架而是把一个练习网站的任务从头到尾吃透。T5 这趟记录里最值钱的经验其实不是那段能跑的代码而是我在排查text()函数和动态接口时建立起来的排查思路。以后再遇到任何页面解析不出来我都会先问自己三个问题数据是不是接口返回的XPath 是不是写在了正确的层级请求头是不是漏了什么关键字段这三个问题解决掉绝大多数爬虫任务已经完成了八成。最后补一个小技巧在开始写代码之前花十分钟手动画一张目标页面的结构草图把列表、详情、分页之间的关系标清楚。这个习惯我从 T1 坚持到 T5每次都能帮我提前发现那些需要单独处理的边缘页面。希望这份记录也能给你的爬虫学习之路带来一些参考。