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/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.example-doc-site.com/", }这里有几个点值得说:
- User-Agent用主流浏览器的即可,不用追求最新版本号,稳定就行。
- Referer有些站点会校验来源页,带上目标站点的首页地址能提高通过率。
- Accept-Language带上中文偏好,有些站点会根据语言返回不同模板。
注意:请求头不是越多越好。带一堆用不上的字段反而可能暴露异常。核心就是 UA、Accept、Referer 这几个,够用了。
3.2 XPath 定位正文:从“大容器”到“精确节点”
打开开发者工具,找到正文所在的 DOM 结构,这是整个项目最关键的一步。原创力文档这类站点的预览区通常有一个包裹层,比如类似这样的结构:
<div class="preview-content"> <div class="page-item"> <p>正文段落一</p> <p>正文段落二</p> </div> <div class="page-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-8'apparent_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/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } try: resp = requests.get(url, headers=headers, timeout=15) 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_dir="output"): import os os.makedirs(output_dir, exist_ok=True) # 文件名去掉非法字符 safe_title = re.sub(r'[\\/:*?"<>|]', '_', title)[:80] filepath = os.path.join(output_dir, f"{safe_title}.txt") with open(filepath, 'w', encoding='utf-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_workers=5) 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_file="done.txt"): if os.path.exists(done_file): with open(done_file, 'r', encoding='utf-8') as f: return set(line.strip() for line in f) return set() def mark_done(url, done_file="done.txt"): with open(done_file, 'a', encoding='utf-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 结构。 - 在开发者工具里用
Ctrl+F搜 XPath,确认路径能选中元素。 - 注意
iframe:如果正文在 iframe 里,requests 拿到的外层 HTML 是不含 iframe 内容的,需要单独请求 iframe 的 src。
5.3 中文乱码
前面说过,手动设response.encoding。如果还乱,检查是不是 gzip 压缩没解——requests 默认会自动解 gzip,一般不用管。极少数情况是站点用了非标准编码,那就用response.content.decode('gbk', errors='ignore')手动解。
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 名带空格。@class="preview 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(recover=True)加容错,避免解析异常中断。
存储批量化。不要每篇文档都 open/close 一次文件,攒一批再写,或者用数据库。小规模用 txt 就行,上千篇建议上 SQLite。
6.2 扩展方向
这个脚本的骨架可以往几个方向扩展:
- 结构化输出:把标题、正文、页码分别存成 JSON,方便后续做文本分析。
- 增量更新:记录已采集的文档 ID,只采新增的。
- 多站点适配:把 XPath 规则抽成配置,不同站点用不同配置,一套代码跑多个目标。
- 内容去重:用 simhash 或 minhash 做相似度检测,避免重复采集。
6.3 关于合规的再提醒
技术本身是中性的,但用法有边界。采集公开的、用于个人学习的内容,和批量抓取用于商业分发,性质完全不同。我的建议是:控制频率、尊重 robots、不碰付费内容、不用于商业用途。这既是合规要求,也是让这个技术能长期用下去的前提。
7. 我个人的几点实操体会
跑了这么些年采集脚本,最大的体会是:选择器维护比代码编写更花时间。页面一改,XPath 就失效,这是常态。所以我现在写脚本,都会把 XPath 规则单独抽出来放配置里,改的时候只动配置不动逻辑。另外,日志一定要打全,哪个 URL 失败了、失败原因是什么,记清楚,不然批量跑完发现少了几篇,根本不知道从哪查。
还有一点,别追求一次写完美。先跑通单页,再跑通十页,再加并发,再加断点续采。每一步都验证过再往下走,比一口气写完再调试效率高得多。我见过太多人一上来就写几百行,结果一个编码问题卡半天,最后全推倒重来。
最后分享一个小技巧:调试 XPath 的时候,别在代码里反复改反复跑。把 HTML 存到本地,用 Python 交互式环境或者在线 XPath 测试工具调,调好了再写进代码。这个习惯能帮你省下大量时间。