
写爬虫最烦的就是页面结构说变就变正则刚写顺前端一改版全白搭。这套 Python 手记的第九篇我拿 LXML 库和 XPath 做了一次完整的书目抓取实践目标站点是晋江文学城的小说频道。选它的原因很直接页面结构规整、书目信息字段丰富、反爬强度适中特别适合用来把 XPath 的定位逻辑练扎实。文章会从页面分析、表达式编写、数据清洗到常见坑位排查完整走一遍代码可以直接改改 URL 复用适合刚学完 Python 语法、想进阶爬虫解析能力的读者当作实战副本。1. 项目拆解这次爬虫到底要做什么1.1 目标页面与数据字段晋江文学城的站点体量很大频道路径也分得细。我做抓取时选了“全部小说”这类聚合列表页没有直接去啃某个单独分类。列表页的好处是每条书目占一个独立块内部结构高度重复XPath 写一套就能批量提取。具体字段我当时定了六个书名、作者、最新章节、最近更新时间、字数、作品编号。后面两个是额外加的因为字数可以用于后续的排序分析作品编号则是抓详情页或者做增量更新的唯一标识。初始化任务时不要太贪字段越少XPath 的调试成本越低等这套流程跑通了再慢慢扩充。1.2 技术选型为什么是 LXML XPath 组合之前几篇手记里我用过正则、用过 BeautifulSoup。正则写字段提取时遇到嵌套标签就头大BeautifulSoup 虽然定位方便但解析速度在大批量页面下还是偏慢。LXML 走的 C 语言底层解析性能实测下来比纯 Python 实现的解析器快不少再叠加上 XPath 这种声明式的路径匹配语法两者配合起来属于“找东西快、定位准”的组合。用 XPath 还有一个直观优势浏览器开发者工具里直接就能复制元素的 XPath 路径。虽然复制出来的路径经常又臭又长但至少能帮你快速确认某个节点的相对位置在此基础上改写成相对路径或者按属性匹配比从零开始猜快得多。注意浏览器复制出来的 XPath 通常是绝对路径依赖页面层级对页面改版很敏感。实操时建议把它当作参考而不是直接落到代码里。2. 页面结构与 XPath 定位分析2.1 请求头与页面下载写爬虫第一步不是写解析代码而是确认能不能顺利拿到 HTML。很多新手上来就用 requests.get(url) 裸奔结果永远拿到一个验证页或者 403。问题不是什么高深的反爬策略就是缺少 User-Agent 标识服务端觉得你不是正常浏览器。我当时构造的请求头比较简单HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.jjwxc.net/ }这里的 Referer 不是必须的但带上之后能够模拟从首页跳转过去的真实浏览路径。我先用 requests.get() 拿到响应再通过 resp.encoding 检查页面的编码。晋江的页面早期用的是 GBK 编码体系后来大部分频道已经切换到 UTF-8但保不齐某个子页面还是旧编码所以稳妥起见我在代码里做了编码自适应import requests resp requests.get(url, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding html resp.textresp.apparent_encoding 是基于内容分析出来的编码比直接写死更保险。遇到 Charset 识别不准的情况再手动指定 resp.encoding gbk 或 utf-8 去覆盖。2.2 XPath 表达式设计拿到 HTML 之后先别急着写代码我习惯先用浏览器开发者工具观察目标元素的层级关系。打开“所有小说”的列表页按 F12 找到一本小说的书名元素往上查看包裹它的父容器会发现每条书目都待在一个相对独立的 div 里。我当时看中的结构大致是div classbooklist ul li span classbooknamea href...书名/a/span span classauthor作者/span span classupdatetime更新时间/span /li /ul /div注意真实页面的 class 名不一定长这样写代码之前一定要自己去开发者工具里核对。这里我用抽象结构来说明 XPath 的匹配思路。针对上面的结构获取整个书籍列表的 XPath 可以写成book_items html_tree.xpath(//div[contains(class, booklist)]//li)这里用 contains() 是因为很多框架会动态拼接多个 class直接用等于号 classbooklist 容易匹配不到。contains(class, booklist) 的意思是只要 class 属性里有 booklist 这个词就算匹配上容错率更高。拿到 book_items 列表之后再对每个 item 做二次 XPath 定位。注意一个关键细节二次解析时XPath 要以当前节点为上下文表达式前面必须用点开头。写 ./ 表示从当前节点查找如果漏掉点就会变成从整个文档重新搜索很容易取到第一本书的数据然后每一行都拿到重复值。书名提取book_name item.xpath(.//span[contains(class, bookname)]/a/text())[0]如果目标元素里没有 a 标签直接把路径缩短成book_name item.xpath(.//span[contains(class, bookname)]/text())[0]作者字段类似author item.xpath(.//span[contains(class, author)]/text())[0]更新时间、最新章节这些字段一样按照实际页面里的 class 名称去替换即可。每写完一个表达式我习惯先跑一次打印出来看看对不对不要一次性把所有字段写完到时候报错都不知道是谁的问题。3. 核心代码实现3.1 从 HTML 字符串到 lxml 节点树请求拿到了 HTML 文本但 XPath 没法直接对字符串操作得先交给 lxml 解析成节点树。这是整个流程里承上启下的一步。from lxml import etree tree etree.HTML(html)etree.HTML() 会做容错处理即使页面存在未闭合的标签也能解析出一棵基本可用的树。lxml 底层会尝试自动修复一些 HTML 语法问题这个特性在爬虫场景里很实用因为你拿不到页面源码的“标准版”只有浏览器渲染前的那份原始字符串。提示如果页面里有 iframe 嵌入的内容etree.HTML() 拿不到 iframe 内部的东西。好消息是晋江的书目信息基本都写死在主文档里不需要额外处理 iframe。3.2 解析页面并提取书目字段下面给出一份可以直接跑通的示例代码页面结构调整后你需要对照上面的 XPath 设计替换其中的 class 名称import requests from lxml import etree HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } def fetch_html(url): resp requests.get(url, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding return resp.text def parse_books(html): tree etree.HTML(html) items tree.xpath(//div[contains(class, booklist)]//li) books [] for item in items: title item.xpath(.//span[contains(class, bookname)]/a/text()) author item.xpath(.//span[contains(class, author)]/text()) update_time item.xpath(.//span[contains(class, updatetime)]/text()) # 字段可能缺失先做空值保护 books.append({ title: title[0].strip() if title else , author: author[0].strip() if author else , update_time: update_time[0].strip() if update_time else , }) return books if __name__ __main__: url https://www.jjwxc.net/bookbase.php html fetch_html(url) result parse_books(html) for book in result[:20]: print(book)这里我特意对每个字段都加了“取空保护”。XPath 的坑和字典取 key 还不一样它取不到时不是返回 None而是返回一个空列表直接取 [0] 必定 IndexError。这种细节放到线上环境会拖垮整个采集流程因为只要一条数据字段缺失整批任务就崩了。3.3 数据清洗与结构化存储XPath 抓回来的原始文本通常带一堆换行、空格和全角符号直接入库会非常脏。我给每条记录增加了一条清洗逻辑把不可见字符清掉顺便把全角数字转成半角方便后续排序。import re def clean_text(text): text re.sub(r\s, , text) text text.replace(\u3000, ) # 去掉全角空格 return text.strip()清洗完成之后存储方案我推荐先用 CSV 保底好排查问题。CSV 的优点是直接用 Excel 打开就能看爬完先肉眼扫一遍书名有没有混入分类名、作者有没有被 HTML 转义符污染。import csv def save_to_csv(data_list, filenamejjwxc_books.csv): if not data_list: print(没有数据可写) return fieldnames list(data_list[0].keys()) with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(data_list)编码选择 utf-8-sig 是给 Excel 准备的。它会写入 BOM字节序标记防止 Windows 下的 Excel 打开 CSV 时出现中文乱码。如果你数据量后面涨上来了再切换到 SQLite 或 MySQL 即可CSV 只适合冷启动阶段。存进 CSV 之后先别急着跑全量。我当时的做法是先抓两页人工核对这些字段有没有错位书名对书名、作者对作者。错位这种问题在列表页里不容易发生因为所有字段都来自同一个 li 节点但保不齐有个别的书目区块里少了一个 span那就得看容错逻辑是否生效。4. 常见问题与调试实录4.1 XPath 死活取不到值这是我遇到次数最多的一个问题表现形态也很极端代码不报错但打印出来的列表是空的。第一次遇到这种情况差点怀疑是不是页面用了 JavaScript 动态渲染检查之后发现数据是静态的问题完全出在 XPath 本身。空结果的排查顺序我一直固定如下先在浏览器里复制目标元素的 XPath确认页面里确实存在这个节点。检查 lxml 是否真的拿到了 HTML打印 tree.xpath(//html)看看节点数量。检查 class 是否包含多个类名用 contains(class, 目标类名) 而不是 class目标类名。检查是不是有 iframelxml 里无法直接解析 iframe 子文档。最后再确认缩进和空格比如手打 XPath 时混入了全角括号也经常导致匹配失败。第二点特别容易忽略。很多时候请求没带 header返回的是个跳转页或者验证页解析代码还一头雾水地报空。遇到空结果第一反应应该是把 html 前 500 个字符打印出来看看而不是反复修改 XPath。4.2 编码乱码问题晋江页面早期大量使用 GBK 编码如果你直接 write 进 CSV再用记事本打开中文全是乱码。虽然我在前面用了 resp.apparent_encoding但这个函数也不是百分之百准确的它只是根据字节特征猜测。有一种组合策略我用了很久requests 的响应对象里其实藏着编码线索。# 如果 apparent_encoding 猜错了手动指定候选编码 if resp.encoding and utf not in resp.encoding.lower(): resp.encoding gbk也可以直接把 HTML 的 meta 标签里声明的编码抓出来做参照。更极端的情况是页面编码写错了但实际内容却是另一种编码那就只能用 chardet 或者走 UnicodeDammit 自动纠错。这套路有点超出本文范围但你只要记住看到乱码先别慌99% 是编码问题定位到具体编码就能解。4.3 字段错位与缺失列表页的每个 li 里字段数量不一定完全相同。有些书可能没有“最新章节”这个 span或者作者信息被放在另一个嵌套 DOM 里。一旦某个节点缺失第二个 item 的字段位置可能整体偏移导致你提取出来的书名其实是上一本的作者。为了避免这个问题我建议在 XPath 里就做好防御全程使用相对路径加文本节点直接提取而不是依赖固定的索引位置# 推荐按 class 找目标节点 .//span[contains(class, author)]/text() # 不推荐按相对位置硬数 ./span[2]/text()第二条路径一旦中间的 span 缺失数据就错位。按 class 匹配相当于给每个字段加了标签页面再怎么微调只要 class 不改逻辑就是稳的。4.4 访问频率控制与缓存策略爬虫能不能长期稳定跑关键不在解析写得多好而在请求频率控制。爬太快容易被封 IP爬太慢又影响效率。我当时的节奏是每抓一页随机 sleep 1 到 3 秒import random import time time.sleep(random.uniform(1, 3))sleep 的区间不要固定写死否则请求节奏会被服务端识别成机器行为。随机延迟的目的就是把请求间隔模拟成人手工浏览时的自然波动。再多说一句缓存。如果脚本中途断了重跑时前几页已经抓过了这时如果重新请求既浪费流量又增加风险。我给自己的脚本加了一层简单去重把已经写入 CSV 的书名加载进一个集合遇到重复数据直接跳过。这种方式虽然不优雅但做中小规模采集完全够用不值得为这个小项目引入 Scrapy 或者复杂的持久化中间件。提示不论 Scrapy 还是 requests爬取公共站点时不要做高并发请求。个人学习用途控制在每秒几页以内是合理区间。5. 实操心得从“能跑”到“跑得稳”的三点调整第一点调整是打印日志。很多人写爬虫只在报错时 print平时静悄悄。我后来给代码加了简单的阶段日志比如“正在抓取第 1 页”“本页提取 20 条”“累计 100 条”。不要小看这个细节一旦脚本跑在远程服务器上日志就是唯一的排障窗口。page_num 1 total 0 for url in url_list: html fetch_html(url) books parse_books(html) total len(books) print(f第 {page_num} 页完成累计 {total} 条) page_num 1 time.sleep(random.uniform(1, 3))第二点调整是重用 HTML 解析函数。我的解析函数是纯粹的数据处理不负责请求、不负责存储。这样设计的好处是后面如果想把 requests 换成 httpx 或者异步库解析部分完全不用动只需要改入口函数。第三点调整是给自己留一条“慢速重试”机制。遇到网络超时不直接抛异常而是等待更长时间再试一次def fetch_html_with_retry(url, retries3): for i in range(retries): try: return fetch_html(url) except requests.RequestException as e: print(f第 {i 1} 次请求失败: {e}) time.sleep(5 * (i 1)) return None重试时间线性增长第一次失败等 5 秒第二次 10 秒第三次 15 秒。这个机制不需要引入复杂的重试库手写几行就够了。6. 这套流程还能怎么扩展爬完目录页只是第一步。我后来基于同样的 XPath 逻辑继续往下做了三类扩展你如果感兴趣可以接着练。第一类是详情页抓取。列表页里每本书都有一个详情页链接我通过 XPath 把 href 属性一起提取出来再放进队列继续请求。详情页里能拿到简介、标签、全文字数等列表页看不到的信息。提取链接的表达式很简单book_url item.xpath(.//span[contains(class, bookname)]/a/href)拿到 href 之后遇到一个相对路径问题。很多站点的链接是相对路径比如 /book/123456.aspx直接请求会报错。需要跟站点域名拼接成完整的绝对 URL这个步骤在处理任何新站点时都值得留意。第二类是数据去重和增量更新。书目信息每天都在变化重新全量抓太浪费。我把作品编号当成主键本地存一份历史数据新抓到的记录先跟历史记录比对编号存在就跳过不存在才落库。这种增量逻辑在真正的工作场景里比全量重刷更常用。第三类是切换成异步或者并发。项目刚上手时用 requests 同步请求完全没问题等页面量级上了 1000 页同步等待就比较难受了。这时候可以把请求层替换成 httpx 的 AsyncClient解析层不用动速度提升会非常明显。不过要提醒一句并发量提升之后对目标网站的负载也变大仅限于有授权的数据采集场景平时的练习任务不建议把并发开得太高。回到最初的目标用 LXML 加 XPath 抓取晋江书目本身不是一个复杂工程但把这个流程走通你能掌握一套通用能力拿到一个陌生列表页先观察结构、再写表达式、然后清洗入库。这套能力换到其他书城、招聘站点、电商平台都能复用。我个人的体会是XPath 的调试功夫七分在页面分析上三分在代码编写上遇到问题多回页面里看结构比盲目试表达式高效得多。