Python3 XPath 实战:原创力文档正文采集与清洗
1. 拆解需求为什么偏偏是 XPath 和原创力文档做数据采集这行的朋友都有一个共识目标站点的页面结构决定了你用什么工具。原创力文档这类站点页面内容大部分是服务端渲染出来的静态结构文档预览区的 DOM 层级相对规整这就给了 XPath 很大的发挥空间。相比正则表达式XPath 能直接按节点层级定位写起来直观、改起来也快相比 BeautifulSoup 的 CSS 选择器XPath 在处理“父节点往上找”“按文本内容定位”这类需求时更顺手。先说清楚这个项目要干什么用 Python3 写一个采集脚本通过 XPath 从原创力文档的预览页面里把文档正文内容提取出来最终落地成可读的文本或结构化数据。它解决的核心问题是——当你需要批量整理某类资料、做内容归档、或者做文本分析时手动一篇篇复制粘贴效率太低脚本能把这个过程自动化。适合谁来参考有 Python 基础、了解 requests 和 lxml 基本用法、想做第一个“真实站点采集项目”的朋友最合适。完全零基础也能看但建议先把 requests 发请求、XPath 语法这两块补一下不然中间会卡壳。需要提前说明的是这类站点的页面结构会不定期调整本文给出的 XPath 是基于常见页面布局的通用思路和示例实际跑的时候你需要打开开发者工具按当时的 DOM 结构微调。这不是脚本的问题是采集工作的常态——选择器跟着页面走页面变了选择器就得改。2. 整体设计思路与技术选型2.1 为什么是 requests lxml而不是 Selenium很多人一提到采集就想到 Selenium觉得能模拟浏览器就万能。但在这个场景里Selenium 属于“杀鸡用牛刀”。原因有三点第一速度。Selenium 要启动浏览器内核、加载完整页面、等 JS 执行单页耗时通常在 3 到 8 秒而 requests 直接拿 HTML 源码配合 lxml 解析单页往往在 1 秒以内。批量采集几百篇文档时这个差距是几十分钟和几个小时的差别。第二资源占用。Selenium 每个实例吃几百 MB 内存跑多线程容易把机器拖垮requests 是纯网络请求内存占用小得多开个线程池跑并发很轻松。第三稳定性。浏览器版本、驱动版本、无头模式的各种坑踩过的人都懂。requests 只要请求头对、网络通基本不会出幺蛾子。那什么时候才需要 Selenium当页面正文是 JS 动态渲染、源码里根本拿不到内容的时候。判断方法很简单用 requests 请求一次看返回的 HTML 里有没有你要的正文文字。有就用 requests没有再考虑浏览器方案。原创力文档的预览页大部分内容是服务端直出的所以 requests 方案完全够用。2.2 解析库选 lxml 的理由Python 里解析 HTML 的库主要有三个BeautifulSoup、lxml、pyquery。BeautifulSoup 上手最简单但底层解析慢而且默认用 html.parser 时对不规范 HTML 的容错一般。pyquery 语法像 jQuery写起来舒服但本质也是包了 lxml。lxml 是 C 语言实现的解析速度快XPath 支持最完整还能处理一些畸形 HTML。既然标题明确要用 XPath那 lxml 就是最自然的选择。它的etree.HTML()方法能把字符串直接转成可 XPath 查询的树对象配合xpath()方法返回列表用起来非常直接。2.3 整体流程设计整个脚本的骨架可以拆成五步构造请求带上合理的请求头向目标预览页发 GET 请求。拿到 HTML检查状态码确认返回的是正常页面而不是验证页。解析定位用 lxml 建树用 XPath 定位正文容器。提取清洗把节点里的文本抽出来去掉多余空白、页眉页脚、页码等噪声。落地存储按文档标题命名写入 txt 或 json 文件。这个流程看着简单但每一步都有细节。比如请求头里 User-Agent 不带很多站点直接返回 403比如正文容器里往往混着广告位和推荐位XPath 要精确到内容节点而不是整个大容器。下面逐个拆。3. 核心细节解析与实操要点3.1 请求头的构造别让第一关就把你拦下requests 默认的 User-Agent 是python-requests/x.x.x这个特征太明显稍微有点防护的站点都会拦。所以第一步就是把请求头伪装成正常浏览器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,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, Referer: https://www.example-doc-site.com/, }这里有几个点值得说User-Agent用主流浏览器的即可不用追求最新版本号稳定就行。Referer有些站点会校验来源页带上目标站点的首页地址能提高通过率。Accept-Language带上中文偏好有些站点会根据语言返回不同模板。注意请求头不是越多越好。带一堆用不上的字段反而可能暴露异常。核心就是 UA、Accept、Referer 这几个够用了。3.2 XPath 定位正文从“大容器”到“精确节点”打开开发者工具找到正文所在的 DOM 结构这是整个项目最关键的一步。原创力文档这类站点的预览区通常有一个包裹层比如类似这样的结构div classpreview-content div classpage-item p正文段落一/p p正文段落二/p /div div classpage-item p正文段落三/p /div /div对应的 XPath 可以这样写# 定位所有正文段落 paragraphs tree.xpath(//div[contains(class, preview-content)]//p/text())这里用了contains(class, ...)而不是class...原因是很多站点的 class 名会带动态后缀比如preview-content active或者preview-content-v2用精确匹配很容易失效。contains容错性更好。如果正文不在p标签里而是在div或span里就把//p换成对应的标签。判断方法在开发者工具里选中正文文字看它被什么标签包着。3.3 文本清洗抽出来的东西往往不能直接用xpath(.../text())返回的是一个字符串列表但里面通常混着这些东西大量空白和换行符页码“第 1 页 / 共 10 页”页眉页脚站点名、版权信息广告文案清洗的基本套路import re def clean_text(raw_list): cleaned [] for item in raw_list: text item.strip() # 跳过空行 if not text: continue # 跳过页码类噪声 if re.match(r^第\s*\d\s*页, text): continue # 合并多余空白 text re.sub(r\s, , text) cleaned.append(text) return cleaned实操心得清洗规则不要一开始就写太复杂。先把原始数据抽出来看一眼统计一下有哪些噪声再针对性地写规则。我见过有人上来就写十几条正则结果把正文里的正常内容也误杀了。3.4 编码问题中文乱码的根源requests 的response.text有时候会乱码原因是它根据响应头里的 charset 猜编码猜错了就乱。解决办法是手动指定response.encoding response.apparent_encoding # 或者直接指定 response.encoding utf-8apparent_encoding会用 chardet 库去猜准确率还行。如果确定页面是 utf-8直接写死最稳。判断方法看 HTML 源码里meta charset...写的是什么。4. 完整实操流程与代码实现4.1 环境准备pip install requests lxml就这两个库不需要更多。如果你要并发再加个内置的concurrent.futures就够了不用装第三方。4.2 单页采集的完整代码import requests from lxml import etree import re import time def fetch_page(url): 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,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } try: resp requests.get(url, headersheaders, timeout15) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as e: print(f请求失败: {e}) return None def parse_content(html): if not html: return [] tree etree.HTML(html) # 定位正文段落实际使用时按页面结构调整 raw tree.xpath(//div[contains(class, preview-content)]//p/text()) return clean_text(raw) def clean_text(raw_list): cleaned [] for item in raw_list: text item.strip() if not text: continue if re.match(r^第\s*\d\s*页, text): continue text re.sub(r\s, , text) cleaned.append(text) return cleaned def save_content(title, paragraphs, output_diroutput): import os os.makedirs(output_dir, exist_okTrue) # 文件名去掉非法字符 safe_title re.sub(r[\\/:*?|], _, title)[:80] filepath os.path.join(output_dir, f{safe_title}.txt) with open(filepath, w, encodingutf-8) as f: f.write(\n.join(paragraphs)) print(f已保存: {filepath}) if __name__ __main__: url https://www.example-doc-site.com/preview/xxxxx html fetch_page(url) paragraphs parse_content(html) if paragraphs: save_content(示例文档, paragraphs) else: print(未提取到内容请检查 XPath 是否匹配当前页面结构)这段代码可以直接跑你只需要把url换成真实的目标地址把 XPath 换成当前页面实际的路径。4.3 批量采集加个线程池单页跑通之后批量就是加一层循环。但要注意不要用 for 循环串行跑几百个页面能跑到天荒地老。用线程池from concurrent.futures import ThreadPoolExecutor, as_completed def process_one(url): html fetch_page(url) paragraphs parse_content(html) return url, paragraphs urls [https://www.example-doc-site.com/preview/1, https://www.example-doc-site.com/preview/2, # ... ] with ThreadPoolExecutor(max_workers5) as executor: futures {executor.submit(process_one, u): u for u in urls} for future in as_completed(futures): url, paragraphs future.result() if paragraphs: save_content(url.split(/)[-1], paragraphs) time.sleep(0.5) # 控制节奏max_workers设多少合适我的经验是3 到 5。设太高容易触发站点的频率限制反而拖慢整体进度。设太低又浪费带宽。5 是个比较稳的平衡点。注意time.sleep(0.5)放在循环里是控制请求节奏的。虽然线程池本身有并发但每个任务完成后稍微停一下能显著降低被限流的概率。这个 0.5 秒不是拍脑袋定的是实测下来既能保持速度又不触发限制的经验值。4.4 断点续采别让一次中断白跑批量任务最怕跑到一半断了重跑又得从头来。加个简单的记录机制import os def load_done(done_filedone.txt): if os.path.exists(done_file): with open(done_file, r, encodingutf-8) as f: return set(line.strip() for line in f) return set() def mark_done(url, done_filedone.txt): with open(done_file, a, encodingutf-8) as f: f.write(url \n)每次处理前检查 URL 是否在已完成集合里处理完就追加记录。这样中断后重跑已完成的会自动跳过。5. 常见问题与排查技巧实录5.1 请求返回 403 或验证页这是最常见的问题。排查顺序检查请求头UA 带了吗Referer 带了吗检查频率是不是请求太快了把max_workers降到 2加长 sleep。检查 Cookie有些站点需要先访问首页拿到 Cookie再访问详情页。用requests.Session()保持会话session requests.Session() session.headers.update(headers) session.get(https://www.example-doc-site.com/) # 先访问首页 resp session.get(detail_url) # 再访问详情页5.2 XPath 返回空列表这是第二常见的问题。原因通常是页面结构和你想的不一样。排查方法把resp.text存到本地文件用浏览器打开看实际 DOM 结构。在开发者工具里用CtrlF搜 XPath确认路径能选中元素。注意iframe如果正文在 iframe 里requests 拿到的外层 HTML 是不含 iframe 内容的需要单独请求 iframe 的 src。5.3 中文乱码前面说过手动设response.encoding。如果还乱检查是不是 gzip 压缩没解——requests 默认会自动解 gzip一般不用管。极少数情况是站点用了非标准编码那就用response.content.decode(gbk, errorsignore)手动解。5.4 内容抽出来缺斤少两有时候 XPath 只匹配到部分段落。原因可能是正文分页了当前页只有一部分内容。部分段落用了不同的标签比如有的用p有的用div。懒加载滚动到可视区域才渲染。针对分页需要分析翻页规律构造多页请求针对标签不统一XPath 用//*[self::p or self::div]这种写法兼容针对懒加载那就得回到 Selenium 方案了。5.5 常见问题速查表问题现象可能原因解决方向403 Forbidden请求头缺失或频率过高补 UA/Referer降并发加延时返回验证页触发风控用 Session 保持会话放慢节奏XPath 返回空页面结构不符存 HTML 本地核对检查 iframe中文乱码编码判断错误手动设 response.encoding内容不全分页/懒加载/标签不一分析翻页规律兼容多标签跑一半中断网络波动或限流加断点续采和重试机制5.6 几个踩过的坑坑一class 名带空格。classpreview content这种用contains(class, preview)更稳但要注意别匹配到preview-header这种无关元素。必要时用contains(concat( , class, ), preview )精确匹配单词。坑二text() 只取直接文本。如果节点里还嵌套了子标签/text()只拿直接文本子标签里的拿不到。要用//text()或者string(.)。区别是前者返回列表后者返回拼接后的字符串。坑三动态 class 名。有些站点每次请求 class 名都变比如带随机后缀这种情况 XPath 就不能依赖 class得靠层级关系或文本内容定位比如//div[contains(text(), 正文开始)]/following-sibling::div。坑四忽略 robots 和版权。采集前看一眼站点的 robots.txt尊重站点规则。采集到的内容仅用于个人学习研究不要用于商业分发。这是基本的职业操守也是保护自己不踩线的前提。6. 性能优化与扩展方向6.1 性能优化的几个抓手脚本能跑通之后下一步就是让它跑得快、跑得稳。三个优化方向连接复用。用requests.Session()代替每次新建连接TCP 握手开销能省不少。批量任务里这个提升很明显。解析提速。lxml 本身已经很快了但如果 HTML 特别大可以用etree.HTMLParser(recoverTrue)加容错避免解析异常中断。存储批量化。不要每篇文档都 open/close 一次文件攒一批再写或者用数据库。小规模用 txt 就行上千篇建议上 SQLite。6.2 扩展方向这个脚本的骨架可以往几个方向扩展结构化输出把标题、正文、页码分别存成 JSON方便后续做文本分析。增量更新记录已采集的文档 ID只采新增的。多站点适配把 XPath 规则抽成配置不同站点用不同配置一套代码跑多个目标。内容去重用 simhash 或 minhash 做相似度检测避免重复采集。6.3 关于合规的再提醒技术本身是中性的但用法有边界。采集公开的、用于个人学习的内容和批量抓取用于商业分发性质完全不同。我的建议是控制频率、尊重 robots、不碰付费内容、不用于商业用途。这既是合规要求也是让这个技术能长期用下去的前提。7. 我个人的几点实操体会跑了这么些年采集脚本最大的体会是选择器维护比代码编写更花时间。页面一改XPath 就失效这是常态。所以我现在写脚本都会把 XPath 规则单独抽出来放配置里改的时候只动配置不动逻辑。另外日志一定要打全哪个 URL 失败了、失败原因是什么记清楚不然批量跑完发现少了几篇根本不知道从哪查。还有一点别追求一次写完美。先跑通单页再跑通十页再加并发再加断点续采。每一步都验证过再往下走比一口气写完再调试效率高得多。我见过太多人一上来就写几百行结果一个编码问题卡半天最后全推倒重来。最后分享一个小技巧调试 XPath 的时候别在代码里反复改反复跑。把 HTML 存到本地用 Python 交互式环境或者在线 XPath 测试工具调调好了再写进代码。这个习惯能帮你省下大量时间。