学Python爬虫这件事我见过太多人卡在同一个地方理论看了一堆正则、requests、BeautifulSoup都过了一遍真到自己动手抓一个像样的网站时却不知道从哪下手。这次Python手记第9篇我用一个非常贴近实际需求的案例——用Python爬取晋江文学城的书目列表——来把LXML库和XPath这套组合彻底讲透。技术栈其实很清晰LXML负责解析HTMLXPath负责精确定位元素爬取对象是晋江的书目数据。为什么拿晋江练手因为它页面结构规整、书目字段丰富书名、作者、简介、字数、分类都有又是典型的服务端渲染页面非常适合理解XPath定位的完整流程。这篇东西我尽量按真实踩坑的顺序写从分析页面到代码落地再到处理反爬和解析异常你看完能直接照着跑通。1. 先把晋江书目这个目标拆清楚再动手1.1 为什么拿晋江练手字段规整度和解析难度刚刚好很多初学爬虫的人一上来就去抓淘宝、京东、B站结果被各种动态加载、加密参数、登录校验劝退。晋江这个站好在哪它的书目列表页在PC端是服务端渲染的也就是说你直接请求URL拿到的HTML里就能看见书名、作者、简介这些信息不需要逆向JS也不需要处理Ajax接口签名。这一点对学习XPath来说太重要了——你能明确看到目标数据在HTML源码里的原始位置才能验证自己写的XPath表达式到底对不对。再一个原因晋江书目的字段足够丰富。一本书的书名、作者、类型、字数、状态、简介、最新章节、总点击数、总评论数这些全在一个列表页里。拿这些字段练手你能一次性把XPath的文本提取、属性提取、兄弟节点定位、父节点回溯这些操作全部过一遍。如果只爬一个普普通通的文章标题列表学完还是不会写复杂表达式。第三个原因是我个人体感晋江的HTML结构在同类网站里算是“有点旧但很有规律”的那种。它不像现在很多前端框架渲染的页面那样堆满随机class名反而保留了大量有语义的class和id这对写XPath非常友好。当然页面结构也会改版但这不影响我们学习定位思路遇到结构变了重新观察一次就行。1.2 打开开发者工具把页面结构摸一遍我不管抓什么网站第一步永远是打开Chrome开发者工具按F12切到Elements面板然后手动翻几页把目标列表的整体结构摸清楚。爬虫代码写得好不好80%的功夫在页面分析上。以晋江的榜单/筛选页面为例我观察到的结构大体是这样的整个书目列表由若干行组成每一行是一个独立的数据块里面包含书名链接、作者链接、简介文字等。在Elements面板里你可以直接在节点上右键选择Copy → Copy XPath先拿一个自动生成的绝对路径看看效果。不过Chrome自动生成的XPath通常长这样/html/body/div[3]/div[2]/div/table/tbody/tr[2]/td[1]/a这种路径看起来能用但实际上非常脆。只要页面里多一个广告位、少一条数据索引就全乱了。所以看Chrome生成的路径只是第一步真正要做的是找到那一个能稳定代表“每一本书”的容器节点。我当时的做法是在Elements里选中一行书目往上翻它的父节点找到一个class或id有规律、并且每个书目块都重复出现的节点。找到之后用这个节点作为XPath的基准点。比如某个页面的结构是这样的逻辑关系列表容器 → 单条书目块 → 书名a标签 作者a标签 简介div那么我的XPath思路就明确了先定位所有书目块再在每个块内部用相对XPath取字段。这个思路能大幅提升代码的稳定性后面无论页面添加多少元素只要容器节点不变爬虫就不用改。2. LXML XPath这套组合到底比BeautifulSoup强在哪2.1 速度差异和容错机制我用Python写爬虫头两年一直用BeautifulSoup后来转到LXML之后就再也没回去过。不是BeautifulSoup不好而是LXML在这个场景下优势太明显了。先说话。BeautifulSoup默认的解析器是Python内置的html.parser速度一般换lxml作为BeautifulSoup的解析引擎之后会快一些但既然都引入lxml了为什么不直接用lxml自己的etree接口呢我自己跑过一个简单的对比测试同一份大概几百KB的HTML文档BeautifulSoup加lxml引擎解析一遍和直接用etree.HTML解析一遍后者大概能快3到4倍。这个差距在单页场景下感知不强但是当你分页爬到几百上千个页面时累计就是几分钟和十几分钟的差别。再说容错。网页HTML不是严格的XML很多标签是不闭合的比如meta charsetgbk这种单标签又比如各种嵌套写错的div。如果直接把HTML丢给XML解析器分分钟报错崩溃。但lxml.etree.HTML()这个函数是专门为“不规范的HTML”设计的它内部会基于HTML解析器做容错处理自动补全缺失的标签、修正错位的结构最后生成一棵可遍历的树。这就是为什么我反复强调解析网页用etree.HTML()不要用etree.fromstring()或etree.parse()去读网页源码。还有一点是XPath表达式本身的可读性。BeautifulSoup的查找方式通常是find和find_all叠加各种条件写多了会变成一长串嵌套调用而XPath写出来是路径式的结构清晰而且和浏览器开发者工具里调试的思路保持一致。出了问题直接把表达式丢回浏览器Console里用$x()验证效率高得多。2.2 XPath表达式从入门到够用XPath说难不难但有几个概念直接决定了你会不会写。遇到一个解析需求先别急着抄路径花二十分钟把下面这套语法过一遍基本就够用了。第一是绝对路径和相对路径的区别。以//开头的表示在整个文档中查找以/开头的表示从根节点按层级走。我平时90%的表达式都以//开头因为绝对路径太脆了。比如//div[classbookname]/a的意思是在任意层级下找class为bookname的div再取它下面的a标签。第二是谓词筛选也就是方括号里的条件。//a[href]表示所有带href属性的a标签//a[classname]精准匹配class等于name的a标签。这里有一个高频坑HTML元素的class往往不止一个值比如classbookname first这时候用classbookname是匹配不到的必须写成contains(class, bookname)。我来回踩过这个坑现在写class条件时都习惯性用contains除非我能确定这个class只有一个值。第三是文本提取。//a/text()取的是a标签的直接文本子节点如果标签里还嵌套着别的标签比如a书名span番外/span/a直接text()只能拿到“书名”两个字“番外”就丢了。这种时候要用normalize-space(string(.))这串表达式的意思是把当前节点内部的全部文本取出来再压缩多余空白。用不用这个组合在爬简介和详情这种多层级文本时差别非常大。第四是轴操作。//div[classbookitem]/following-sibling::div这种写法可以拿到当前节点后面同级的div//a[contains(href, onebook.php)]/ancestor::tr可以从一个链接往上层跳到它所在的整行。轴操作用得最多的地方就是“我想根据书名的链接找到作者”或者说“我想提取整块容器里的所有信息”。我把这套语法和前面分析的页面结构一结合代码基本就是水到渠成了先找书目容器再容器内用相对路径取字段。接下来直接看完整实现。3. 爬取晋江书目的完整实现3.1 请求阶段UA伪装、编码与超时写爬虫第一步是拿到网页源码。晋江这个站对爬虫不算特别狠但裸用requests默认UA直接访问大概率会被拦或者返回一个让你登录的跳转页。我正常访问一个网站之前都会先看浏览器实际发出的请求头。方法很简单在Network面板里随便点开一个请求找到Request Headers里的User-Agent和Referer原样搬过来。我的请求头通常这样写import requests 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, Referer: https://www.jjwxc.net/, Connection: keep-alive, } url https://www.jjwxc.net/bookbase.php?fw00fbsj0bqsortType3isfinish0collectiontypes0searchkeywordspage1 resp requests.get(url, headersheaders, timeout15) resp.raise_for_status()关于编码这里是第一个重点。晋江的页面用的是GBK如果你直接用resp.textrequests会按照响应头里的charset去解码但有时候响应头没写charsetgbkrequests默认会用UTF-8去猜内容里一旦有非ASCII字符拿到的就是一片乱码或者一堆替换符。我踩过的坑是响应头声明text/html但没带charsetrequests用ISO-8859-1解码导致整页中文全乱。解决办法是拿到响应后看字节然后按GB18030手动解码GB18030是GBK的超集兼容性更好resp.encoding gb18030 html_text resp.text如果发现解码后仍然有个别乱码字符就用resp.content.decode(gb18030, errorsreplace)至少保证整个解析过程不会因为解码问题直接中断。我在实战中把resp.encoding resp.apparent_encoding这一招也用过它会让requests根据网页内容自动猜编码但“猜”这个东西不可控对于已知来源的网站直接指定编码才是最稳的。3.2 解析阶段etree.HTML和XPath提取拿到干净的HTML文本后进入核心解析阶段from lxml import etree html etree.HTML(html_text)先解释一下这个etree.HTML()返回的是什么它是一个Element对象代表整个文档的根节点。之后所有XPath都是从这个根节点开始执行。如果这一步得到的是None说明传入的内容连基本HTML结构都没有那种情况后面专门讲。接下来假设我观察到的页面结构是每一本书都在一个div classbookitem容器里容器内部书名在div[contains(class, bookname)] a作者在div[contains(class, author)] a简介在div[contains(class, intro)]。那么提取所有书目的代码可以这样写book_blocks html.xpath(//div[contains(class, bookitem)]) for block in book_blocks: title_node block.xpath(.//div[contains(class, bookname)]/a) author_node block.xpath(.//div[contains(class, author)]/a) intro_node block.xpath(.//div[contains(class, intro)]) title title_node[0].text.strip() if title_node else author author_node[0].text.strip() if author_node else # 简介可能包含多层级文本必须用string(.)取全部文本 intro if intro_node: intro intro_node[0].xpath(normalize-space(string(.))) print(title, author, intro)注意几个细节。第一我在容器内部的XPath前面都加了.比如.//div[contains(...)]这个点表示“从当前节点开始往下找”而不是从整个文档根节点找。很多人写容器内相对路径时不加点结果所有块都取到了第一本书的数据。这是XPath里最经典的低级错误。第二XPath找到的元素是Element对象需要.text属性才能拿到文本。但如果一个标签内部还有其他标签.text只能拿到第一段直接文本所以简介我用了normalize-space(string(.))。string(.)会自动拼接当前节点下所有后代文本节点。第三元素可能不存在。当某一行缺少作者或者某个字段为空时XPath会返回空列表所以每一处都要做判空。代码里用了条件表达式标题取不到就填空字符串不会让整个程序崩掉。3.3 数据落地清洗后存CSV和JSON提取出来的原始字段还需要清洗。比如晋江的字数统计往往是“123456字”这样带单位的字符串如果你后面要排序、统计、画图就得把“字”去掉转成int。比如书籍状态可能是“连载中”和“完结”你可以在这一层就统一成布尔值。还有一些简介文本里会夹着nbsp;、换行符、多余空格用re.sub(r\s, , text)做个归一化处理干净多了。清洗完之后我通常会把数据同时存成CSV和JSON两份。CSV适合Excel直接打开人工筛选JSON适合后续接到其他程序里做分析。这里有一个编码坑在Windows上用Python写CSV时如果直接用open(books.csv, w, encodingutf-8)Excel打开会乱码正确姿势是用encodingutf-8-sig让文件头带上BOMExcel才能正确识别UTF-8。这个细节我吃过亏换成utf-8-sig之后世界清净了。import csv import json import re def clean_text(text): return re.sub(r\s, , text).strip() books [] # ... 上面循环里的解析结果整理成字典后append进books ... # 写CSV with open(books.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, author, intro]) writer.writeheader() writer.writerows(books) # 写JSON with open(books.json, w, encodingutf-8) as f: json.dump(books, f, ensure_asciiFalse, indent2)ensure_asciiFalse一定不能漏否则json.dump会把所有中文转成\uXXXX转义序列文件打开全是乱码虽然数据没丢但可读性极差。做爬虫的人经常有这种体会爬数据不难存得让人能用才麻烦。4. 爬取过程中的几个坑和排查方法4.1 etree解析结果为空先别急着改XPath我遇到过一种情况页面能正常请求HTML也打印出来了但html.xpath(//div[contains(class, bookitem)])返回空列表。这时候不少人第一反应是XPath写错了其实不一定。我后来排查发现问题出在HTML解析阶段——响应内容可能被压缩传输了或者包含了一些特殊字符导致etree在构建树时把部分节点结构扭曲了。正确的排查链路是这样的先打印len(html.xpath(//div))看看div节点总数是不是0。如果整个文档连div都解析不出来说明html_text本身有问题先检查编码和解码。如果div总数正常只有目标class找不到再打印html.xpath(//div[contains(class, bookitem)])看看有没有其他class前缀如果连字符串都确实不存在那就不是XPath的问题而是页面对我返回的版本和你手动打开浏览器看到的版本不一样可能触发了移动端页面或者验证码页。另一个容易忽略的原因是页面里有大量注释节点。有些网站会把真正的列表内容藏在HTML注释里比如!-- div classbookitem.../div --。这时候XPath默认是找不到注释内容的除非你用//comment()把注释先提出来再解析。我在抓一些老牌网站时遇到过几次这种操作它本质上是网站的反爬手段之一让爬虫以为页面是空的。4.2 中文乱码编码声明和实际返回不一致乱码这个坑几乎人人都会踩。我总结的排查顺序是打印前500字节用resp.content[:500]直接看原始字节或者直接搜HTML里的meta charset...声明。但注意声明和实际编码经常不是一回事。有的页面声明了UTF-8实际返回的是GBK字节也有人遇到相反的情况。最靠谱的做法不是看声明而是手动尝试解码。先把响应内容存成bytes然后用GB18030和UTF-8各解码一次哪个出来的中文正常就用哪个import requests resp requests.get(url, headersheaders, timeout15) raw resp.content for enc in [utf-8, gb18030, big5]: try: test_text raw.decode(enc) if 小说 in test_text or 书名 in test_text: html_text test_text print(使用编码:, enc) break except UnicodeDecodeError: continue这种“探测法”比直接信任响应头要稳妥得多尤其是遇到那种只声明不执行的老旧站点。一旦确定正确编码后面就都用它。4.3 请求被拦合理限速与Referer处理晋江的抗爬不算特别强但如果你用单线程一口气无间隔刷500页很快会发现某次请求返回的页面里没有书目数据只有一句“访问过于频繁”之类的提示话术。它的封锁通常是临时性的过几分钟自己就解了但如果你需要的量大这种临封足够让你跑崩。我的应对方案分三层。第一层是放慢速度每请求一页后time.sleep(random.uniform(1, 3))把请求间隔打散避免规律性过强。第二层是补Referer把Referer设置成晋江首页或者上一页的URL有些服务器会校验这个头缺失或错误时直接拒绝响应。第三层是失败重试检测到目标字段为空时不要立刻放弃等几秒再重试一次连续三次都失败就跳过当前页记入日志。这里顺便说一下异常捕获。requests请求可能因为网络抖动、连接超时等原因抛出异常我一般用try/excel包裹请求部分遇到异常先做退避重试而不是让整个进程死亡import time import random def fetch_page(url, headers, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersheaders, timeout15) resp.raise_for_status() return resp.content except Exception as e: print(f第{attempt 1}次请求失败: {e}) if attempt max_retries - 1: time.sleep(random.uniform(2, 5)) return None这套重试机制看起来简单但在真实爬取中能把成功率从60%拉到95%以上。对个人学习项目来说完全够用不建议一上来就上代理池之类的重型方案容易把自己绕晕。5. 从单页到批量爬虫健壮性优化方向5.1 分页循环和去重单页爬通之后下一步就是把页码变量化放进循环里批量跑。晋江这种站点的分页参数通常是URL里的page比如page1、page2直到末页。怎么知道总页数最简单的方式是在列表页底部找分页组件观察“末页”链接对应的页码用XPath提取出来作为循环上限。分页循环里最容易被忽略的是去重。同一个书目可能出现在多个榜单或筛选中如果你爬多个分类同一个小说会重复出现。我建议在解析时提取每本书的唯一ID晋江的书名链接URL里通常带novelidxxx这个ID才是真正唯一的。存数据时用字典以novelid为key已经存在就跳过不存在才新增。5.2 请求频率控制与任务日志批量爬几百页的时候建议分批次执行比如每爬50页休息30秒。这不是玄学是为了降低触发风控的概率。同时我强烈安利一个习惯给爬虫加日志。不需要用多复杂的logging模块哪怕只是把“当前页码、成功条数、失败条数”写进一个txt文件也能在程序跑挂之后快速定位断点。我在实际项目中这样设计过每页解析完无论成功失败都往日志文件里追加一行。这样不仅能看到整体进度还能发现某一页连续失败很快锁定是不是被反爬拦截了。这个习惯后来救了我很多次也推荐给你。5.3 还能怎么扩展从书目到详情页书目列表只是入门口。当你把列表页的XPath写顺了完全可以继续往下挖。列表页每个书名都对应一个详情页URL我之前存数据时就把链接一起存下来了。下一步可以写一个详情页爬虫把目录、文案、标签、最近更新这些更细的数据爬下来。但是注意进入详情页之后页面结构会比列表页复杂不少而且可能涉及动态加载章节列表的情况。这时候依然可以用同样的思路先开发者工具观察真实请求再etree解析再XPath定位。爬虫的核心方法论从来没有变过变的只是页面而已。我自己在实际操作中的体会是LXML配合XPath最大的价值不只是快而是它让你形成了一种“先定位容器、再取字段”的思维模式。这个模式一旦建立不管以后遇到什么网站都能迅速拆解出一个稳定的解析方案。最后再分享一个小技巧写XPath时每写一步都在浏览器Console里用$x(你的表达式)验证一步别一次性写一长串。分段验证能让你在几秒钟内定位到到底哪一步路径断了。
阅读完成 · 觉得有帮助?