1. 先把需求和边界讲清楚
标题里说的“爬取文档分享站点的文档”,实际操作起来远不是requests.get()一把梭那么简单。我这两年接过好几个类似的小需求:有的是同行想把公开分享的行业报告归档做知识库,有的是做内容审核的同事要批量核对自家上传的文档有没有显示异常,还有的是学生想把公开的课件存下来离线看。需求五花八门,但技术路径高度重合——文档预览页的渲染方式决定了一切。下文我统一用“某在线文档平台”来代指这类站点,避免指向具体产品,讲的都是通用方法,你换个站点照样能套。
先说清楚这个内容能帮你解决什么。这类站点有个共同特点:你在浏览器里看到的“文档”,在网页源码里几乎搜不到正文,右键另存为得到的只是预览容器本身。真正的文档内容被拆成了几十上百个资源,分散在预览页、接口响应和图片地址里。所以要从零拿到一份完整可读的文件,必须搞清楚三件事:预览器是怎么把内容画出来的、这些碎片资源的地址规律是什么、翻页时客户端和服务端各自做了什么。这三件事吃透了,剩下就是工程活了——限速、重试、合并、校验。
适合谁看?我把它分成三档。第一档是有 Python 基础、写过 requests 的读者,照着步骤能跑通单篇下载;第二档是接过批量任务、需要稳定性的,重点看第 5 章和第 6 章;第三档是把这类脚本当生产工具用的,第 7、8 章的组织方式和踩坑记录对你更有价值。完全没写过爬虫的朋友也别慌,我会把每一步的意图讲透,代码只是手段。
1.1 这类站点到底在抓什么
很多人对“爬文档”有个误解,以为是下一个 HTML 页面就完事。实际上现代文档预览站点普遍采用“图片流 + 文本层”的双轨方案。图片流负责视觉呈现,每一页渲染成一张图或者一张切图;文本层负责选中复制和全文检索,通常是 SVG 路径或者叠加的 DOM 文本。你在页面上能拖选文字,说明文本层存在;你截图能看清内容,说明图片层存在。两者是独立加载的,抓哪个取决于你要什么。
这就带来一个关键选择:要图还是要字。要图,得到的是扫描件式 PDF,视觉还原度高,但正文不可检索、体积大;要字,得到的是纯文本,检索方便但丢失排版。我一般建议两条线都走:图片用于归档,文本层用于建索引。后面第 4 章会分开讲。
再往深一层,图片本身也分两种。一种是服务器预渲染好的整页图,请求一次拿一张,地址里带页码;另一种是切片图,一页被裁成 3 到 10 张横向小图,前端拼接显示。切片方案的目的是提高首屏速度,但对爬取方来说意味着请求数乘以切片数,限速策略得跟着调整。判断方法很简单:打开开发者工具的 Network 面板,筛选 Img 类型,翻一页看请求数量,一张就是一页一图,多张就是切片。
1.2 合规边界:哪些能做、哪些碰不得
这一节我想说得重一点,因为见过太多人从“学习练手”一路滑到不该去的地方。技术是中性的,但使用场景有明确的分野。下面这张表是我自己执行的标准,你可以拿去当检查清单。
| 场景 | 判断 | 理由 |
|---|---|---|
| 抓取自己上传的文档做备份 | 可以做 | 内容权属清晰,属于个人数据导出 |
| 抓取明确标注公开、可自由传播的文档 | 谨慎做,控制频率 | 需确认站点服务条款是否允许自动化访问 |
| 抓取付费/会员专属文档 | 绝对不做 | 绕过访问控制存在法律风险 |
| 抓取他人原创文档用于再发布 | 绝对不做 | 侵犯著作权 |
| 抓取后用于个人学习研究、不传播 | 谨慎做 | 仍需遵守站点条款与访问频率限制 |
我的做法是:脚本开头就写死一个域名白名单和最大并发数,跑之前先看站点的访问规则声明,遇到明确禁止自动访问的直接放弃。另外永远不要尝试去碰登录态校验、验证码识别这类东西,那不是爬虫该解决的问题,走了那一步性质就变了。能公开访问的,规规矩矩慢慢取;不能公开访问的,换个合法途径拿授权。这条线守住了,后面所有技术讨论才有意义。
还有一个容易忽略的点:版权归属。哪怕文档是公开可访问的,著作权依然在原作者手里。我的习惯是抓取结果里保留来源标识和抓取时间,做成内部索引时也标注出处,绝不二次分发。这不是技术问题,是习惯问题,早点养成能省掉很多麻烦。
2. 文档预览的实现原理拆解
要写脚本,先得当一回“前端侦探”。我在动手前通常会花二十分钟做一次完整的抓包分析,这二十分钟能省掉后面两小时的瞎试。分析的目标有三个:预览容器的初始化参数在哪、单页资源地址怎么拼、翻页时是否重新请求。下面按这三块拆。
2.1 三种主流渲染方案与对应的抓取策略
我见过的文档预览实现,基本逃不出这三种:
第一种,服务端预渲染图片流。页面加载后,前端拿到一个包含总页数的 JSON,然后按docId加页码拼接图片地址,逐页请求。这种最好对付,地址规律往往是.../{docId}/{pageIndex}.jpg或带一个签名参数?token=xxx&page=N。签名参数一般在页面初始化时下发,一次会话内有效,所以抓取时要复用同一个会话对象,别每次新建。
第二种,Canvas 分片渲染。图片被切成小块,前端用 Canvas 逐块绘制,还能做缩放重绘。这种方案的资源地址通常藏在一个“页配置”接口里,返回结构类似{page: 1, tiles: [{x:0,y:0,w:1024,h:1024,url:"..."}]}。抓取时要先请求配置接口,再按 tile 列表逐个下载,最后按坐标拼回整页。拼接逻辑不难,难在有些站点会做瓦片乱序,需要读配置里的坐标还原,不能想当然按请求顺序拼。
第三种,文本层 SVG + 字体渲染。这类站点不给你图片,正文是一堆<path>或者被特殊字体编码过的字符。直接抓 DOM 得到的是乱码或空壳。应对方法是抓 SVG 里的路径数据,或者拿字体文件做字符映射还原。后者工作量大,一般只有对文本精度要求极高时才值得做。
判断属于哪种,最快的办法是打开页面后禁用 JavaScript 刷新一次。如果页面白屏或者只剩框架,说明内容全靠脚本渲染,属于前两种或第三种;如果还能看到部分文字,说明有服务端直出,抓 HTML 就有收获。
2.2 抓包定位真实资源地址的实操步骤
具体怎么找?我的固定动作是这样的:
- 打开目标文档页,按 F12 调出开发者工具,切到 Network 面板,勾选 Preserve log,清空现有记录。
- 刷新页面,等首屏加载完,先看 XHR/Fetch 里有没有返回 JSON 的请求。重点找响应体里带
pageCount、totalPage、pageNum这类字段的。 - 切到 Img 筛选,看图片请求。观察 URL 里的规律,把其中变化的片段(通常是数字或长串 ID)记下来。
- 手动点击“下一页”,观察新增的请求。如果只是新增图片请求、没有新的接口调用,说明翻页是纯前端逻辑,页码直接拼;如果每次翻页都发一个接口,说明页码背后有校验,必须按接口走。
- 把关键请求右键复制为 cURL,粘到本地转成 Python 代码,作为脚本的骨架。
这一步有个细节要注意:很多站点会在请求头里带Referer,不带就返回 403。还有的会校验Accept和User-Agent的一致性。我的习惯是把浏览器里复制的完整请求头原样带上跑通一次,再逐个删减,看哪些是必需的。这样比一个个试快得多。
提示:抓包时用的浏览器最好保持登录态一致但不要开启无痕模式,有些站点无痕模式下发的 token 和正常模式不同,会导致脚本跑通一阵后突然失效。
2.3 页面数据结构与关键字段梳理
跑通单个请求之后,我会把拿到的响应结构整理成一张字段表。以典型的文档元信息接口为例,常见字段大致如下:
| 字段名 | 含义 | 用途 |
|---|---|---|
docId/doc_id | 文档唯一标识 | 拼接所有资源地址的基础 |
title | 文档标题 | 本地文件命名 |
pageCount | 总页数 | 控制循环边界 |
width/height | 单页渲染尺寸 | 合并 PDF 时设定画布 |
token/sign | 临时签名 | 请求图片时携带 |
ext | 文档原始格式 | 决定后处理方式(PDF/PPT/DOC) |
这张表不是抄来的,是我每次分析新站点时自己填的。字段名各家不同,但语义高度一致。填完这张表,脚本的变量命名就有了依据,后面维护起来舒服很多。我见过不少人变量名叫a、b、c,过两周自己都看不懂,改一个字段要翻半天。
3. 工具选型与环境准备
工具这块我不喜欢堆砌,够用就行。核心就两个问题:同步还是异步、要不要上浏览器自动化。答案取决于你的任务规模和对稳定性的要求。
3.1 同步 requests 与异步 httpx 的取舍
单篇文档、几十页的量,requests完全够。它的好处是调试直观,出错栈清晰,配合Session复用连接和 Cookie,几十个请求跑下来毫无压力。我早期所有脚本都是 requests 写的,现在也没觉得需要换。
量级上去之后,比如一次要处理几百篇、每篇上百页,同步串行就慢了。这时候换httpx或者aiohttp做异步,把并发控制在 4 到 8 之间,整体耗时能压到原来的三分之一左右。但异步带来的问题是错误处理变复杂,一个asyncio.gather里某个任务抛异常,得靠return_exceptions=True兜住,否则整批中断。
我的建议是:先用同步版本把逻辑跑通、把边界情况摸清,再考虑异步化。顺序反了会很难受,因为异步下的报错信息往往不直观,你还得回头写同步版本验证到底是逻辑问题还是并发问题。
至于浏览器自动化(Playwright、Selenium 这类),我只在一种情况下用:内容必须执行 JS 才能拿到,且接口分析成本高于直接驱动浏览器。比如文本层是靠 Canvas 动态绘制的,抓接口拿不到原始文字。这种情况用 Playwright 打开页面,等渲染完成后截取指定区域,或者直接取 Canvas 的toDataURL()。代价是资源占用大、速度慢,不适合大批量。我一般把它当“兜底方案”,不当作主力。
3.2 依赖清单与初始化骨架
下面是我常用的依赖组合,按需取用:
pip install requests httpx pillow pymupdf pdfplumber tqdm tenacity对应职责:requests/httpx发请求,Pillow处理图片拼接和格式转换,PyMuPDF(导入名fitz)做 PDF 合成与页面操作,pdfplumber做文本层提取校验,tqdm出进度条,tenacity做重试装饰器。这几个库都很成熟,文档齐全,遇到问题搜得到答案。
初始化的骨架我习惯这么写,把配置集中在一处,方便改:
import os import time import requests from pathlib import Path CONFIG = { "base_dir": Path("./output"), "max_retry": 3, "interval": 0.6, # 每页请求间隔,秒 "timeout": 15, "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", } def build_session(cookie: str = "") -> requests.Session: s = requests.Session() s.headers.update({ "User-Agent": CONFIG["ua"], "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", }) if cookie: s.headers["Cookie"] = cookie return s def ensure_dir(p: Path) -> Path: p.mkdir(parents=True, exist_ok=True) return pinterval这个参数我特意单独拎出来,因为它是整个脚本里最需要根据实际情况调的。设太小容易触发站点限流,设太大任务跑不完。我一般从 0.5 秒起步,观察响应状态码,出现 429 就往上加到 1.5 秒并加随机抖动。
4. 核心抓取流程落地
流程本身不复杂,难在细节的健壮性。我把它拆成四步:拿元信息、下图片、合成 PDF、提取文本。每步都可以单独测试、单独重跑,这样出问题时定位快。
4.1 文档元信息解析与页码边界确认
第一步的目标是把docId、pageCount、签名参数这三样拿到手。我的做法是先请求文档详情页,从 HTML 里用正则捞初始化脚本中的配置对象,因为很多站点会把配置直接内联在<script>里,形如window.__INIT__ = {...}。正则匹配到 JSON 串之后json.loads一下就有了。
import re import json def parse_meta(html: str, session: requests.Session, detail_url: str) -> dict: m = re.search(r"window\.__INIT__\s*=\s*(\{.*?\});", html, re.S) if m: raw = json.loads(m.group(1)) return { "doc_id": raw.get("docId") or raw.get("doc_id"), "page_count": int(raw.get("pageCount") or raw.get("totalPage") or 0), "title": raw.get("title", "untitled"), "sign": raw.get("token") or raw.get("sign", ""), } # 兜底:从页面里的分页控件文本中提取总页数 m2 = re.search(r"共\s*(\d+)\s*页", html) if not m2: raise RuntimeError("未能解析到文档元信息,请检查页面结构是否变化") return {"doc_id": "", "page_count": int(m2.group(1)), "title": "untitled", "sign": ""}这段代码有两个设计点值得说。一是raw.get("docId") or raw.get("doc_id")这种写法,因为不同站点的字段命名差异很大,我把常见变体都列上,能兼容多种情况。二是兜底分支——线上页面结构说变就变,主逻辑挂了至少还能靠分页控件上的“共 N 页”拿到页数,脚本不至于彻底停摆。
拿到page_count之后,我强烈建议先打印出来跟页面上显示的数字对一下。见过有人兴冲冲跑完一百页,最后发现页数解析成了 0 或者多了两页,白跑。边界确认这一步花三秒钟,能省三十分钟。
4.2 分页图片批量下载与落盘
这是最耗时的环节。核心逻辑不复杂,就是循环拼地址、请求、写文件,但要注意四点:地址拼接的索引基数、请求间隔、失败重试、文件命名。
索引基数这个坑我踩过。有的站点页码从 1 开始,有的是 0,还有的图片文件名比页码大 1 或者加了前导零对齐。判断办法:拿第一页和最后一页的地址对比,看数字是不是正好差page_count - 1。
import random from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(CONFIG["max_retry"]), wait=wait_exponential(multiplier=1, min=1, max=8)) def fetch_page_image(session, url, dest: Path): resp = session.get(url, timeout=CONFIG["timeout"]) if resp.status_code == 429: raise RuntimeError("rate limited") if resp.status_code != 200 or len(resp.content) < 1024: raise RuntimeError(f"bad response: {resp.status_code}, size={len(resp.content)}") dest.write_bytes(resp.content) return True def download_pages(session, url_tpl, page_count, out_dir: Path): ensure_dir(out_dir) failed = [] for i in range(1, page_count + 1): dest = out_dir / f"{i:04d}.jpg" if dest.exists() and dest.stat().st_size > 1024: continue # 断点续传:已存在则跳过 url = url_tpl.format(page=i) try: fetch_page_image(session, url, dest) except Exception as e: failed.append((i, str(e))) time.sleep(CONFIG["interval"] + random.uniform(0, 0.3)) return failedlen(resp.content) < 1024这个判断看似粗暴,实际很有效。不少站点在请求失败或者触发限流时,仍然返回 200,但内容是一个几百字节的错误提示图或者空白图。如果不做尺寸校验,你会得到一堆灰白占位图,合成出来的 PDF 翻页全是空白,还查不出原因。
{i:04d}补齐四位是为了文件排序稳定。直接1.jpg、10.jpg、2.jpg这种排序在某些工具里会乱序,合成 PDF 之后页码全错。这个细节不值钱,但漏了很要命。
4.3 图片合并为 PDF 的关键参数计算
合并这块,我选PyMuPDF,理由是它对图片转 PDF 的处理比较可控,能指定页面尺寸和压缩质量。Pillow的save(append_images=...)也能做,但遇到大量高分辨率图容易吃满内存。
关键参数是页面尺寸。如果所有图片尺寸一致,直接按图片的像素宽高设成 PDF 页面尺寸最简单。但实际场景里经常出现首页宽、内页窄,或者中途换尺寸的情况。我的做法是先扫一遍所有图片的尺寸,取众数作为基准,然后把尺寸不一致的图缩放到基准尺寸,居中放置,多余部分留白。
import fitz from PIL import Image from collections import Counter def pick_canvas(img_dir: Path): sizes = [] for f in sorted(img_dir.glob("*.jpg")): with Image.open(f) as im: sizes.append(im.size) return Counter(sizes).most_common(1)[0][0] def images_to_pdf(img_dir: Path, pdf_path: Path): canvas_w, canvas_h = pick_canvas(img_dir) doc = fitz.open() for f in sorted(img_dir.glob("*.jpg")): with Image.open(f) as im: w, h = im.size page = doc.new_page(width=canvas_w, height=canvas_h) scale = min(canvas_w / w, canvas_h / h) new_w, new_h = w * scale, h * scale x0 = (canvas_w - new_w) / 2 y0 = (canvas_h - new_h) / 2 page.insert_image(fitz.Rect(x0, y0, x0 + new_w, y0 + new_h), filename=str(f), keep_proportion=True) doc.save(str(pdf_path), deflate=True, garbage=4) doc.close()deflate=True开启流压缩,garbage=4清理无用对象,这两个参数在大文件上能把体积压掉 20% 到 40%。我实测过一份 180 页的扫描文档,不压缩 96 MB,压缩后 61 MB,清晰度肉眼无差。
关于 DPI,很多人纠结。我的经验是:别在合成阶段做重采样。图片下载下来是什么分辨率就用什么,只需要在插入时按画布缩放。如果强行统一到某个 DPI,反而会引入二次插值,文字边缘发虚。真要压缩体积,用Pillow以quality=85重存一遍 JPEG,比改 DPI 划算得多。
4.4 文本层提取与 OCR 兜底方案
如果站点提供了文本层(比如 PDF 原件直接内嵌文字),那恭喜你,pdfplumber几行代码就搞定,还能保留段落结构。但如果拿到的是纯图片流,就得靠 OCR。
OCR 这块我不推荐一上来就上重型方案。先用轻量的思路试:很多文档预览站点的文本层是 SVG 路径,路径数量与字符数量对应,提取d属性里的坐标信息能还原出字符位置,再配合字体映射还原文字。这个方案精度高、速度快,但需要针对具体站点做适配,通用性差。
通用方案还是 OCR。我的实践是分两步:先对图片做预处理(灰度化、二值化、去噪、适当放大),再送识别。预处理做得好,识别率能提十几个百分点。特别是那种页面边缘有淡淡水印的文档,直接识别会把水印当成正文,二值化之后水印基本消失。
注意:OCR 是有成本的,一份百页文档跑下来 CPU 占用不低。如果只是为了建检索索引,建议先按页抽关键词做粗筛,只对命中关键词的页面做精细识别,能省大量时间。
5. 稳定性与工程化细节
脚本能跑通和能稳定跑,中间隔着一个鸿沟。这一章讲的全是“跑通之后”的事,也是我把脚本从玩具变成工具的关键。
5.1 限速、重试与断点续传的组合设计
限速是礼貌,也是自保。我见过太多脚本因为并发太高被封了 IP,然后开始怀疑是代码写错了,其实是节奏问题。合理的节奏应该是:单域名并发不超过 4,请求间隔带随机抖动,遇到 429 或 503 立刻退避并延长后续间隔。
随机抖动这点经常被忽略。固定间隔的请求序列在服务端看起来非常规律,容易被识别为自动化流量。加个 ±30% 的随机量,波形就自然多了。这不是什么高深技巧,是常识。
重试用tenacity的指数退避最省事,但要区分错误类型。网络超时、连接重置这类瞬时错误值得重试;404、403 这类确定性错误重试多少次都没用,反而增加无谓请求。我的做法是在重试装饰器里加判断,只对特定异常重试。
断点续传是长时间任务的生命线。核心就是 4.2 里那句if dest.exists() and dest.stat().st_size > 1024: continue。跑挂了重跑,已完成的页直接跳过,只补缺的。我还会额外维护一个failed.json,记录失败页和失败原因,任务结束后单独补跑这一批。
5.2 并发控制与内存占用的实际观察
异步方案好看,但内存是真会炸。100 页高清图同时读进内存做拼接,2 GB 内存的机器直接卡死。我的处理原则是:下载可以并发,合并不并发。下载阶段用异步把网络等待时间重叠起来,图片一落地就释放内存;合并阶段严格串行,一张一张读、一张一张写。
实测数据:60 页、单页 1600×2200 的文档,同步下载约 75 秒,异步(并发 6)约 22 秒;合并阶段两者都是 9 秒左右,因为合并是 CPU 密集型的,并发没有收益反而增加内存压力。这个对比挺有说服力,说明优化要打在对的地方。
另外提醒一句,tqdm进度条在异步场景下会乱跳,建议用tqdm.asyncio或者干脆只在完成计数上加锁打印。这种小细节不影响功能,但影响你盯日志时的判断效率。
5.3 日志与任务状态管理
我在脚本里会打三类日志:请求级(每页的状态码、耗时、字节数)、任务级(当前文档、完成页数、失败页数)、异常级(完整堆栈)。前两类写文件,第三类同时打屏幕和文件。
为什么这么设计?因为批量任务出错是常态,你不可能一直盯着屏幕。事后看日志时,最想知道的是“哪一篇、哪一页、什么错”。如果日志里只有Exception occurred,那你得重新复现一遍才能定位,成本极高。
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s", handlers=[ logging.FileHandler("crawl.log", encoding="utf-8"), logging.StreamHandler(), ], ) logger = logging.getLogger("doc-crawler")任务状态我用一个简单的 JSON 文件维护,结构是{doc_id: {"status": "done|partial|failed", "pages": N, "failed": [...]}}。每次任务开始前读一次,结束后写回。这样中断之后重跑能准确知道哪些文档需要补。这个方案土但可靠。用数据库反而增加部署负担。
6. 常见问题排查实录
这一章是全文我最想让你记住的部分,因为里面每条都是我真实踩过的。
6.1 高频问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 请求返回 403 | 缺少 Referer 或 UA 不匹配 | 对比浏览器请求头,逐个补齐 | 补全 Referer,UA 与浏览器一致 |
| 返回 200 但图片打不开 | 返回的是错误占位图 | 用十六进制工具看文件头 | 加内容长度与文件头校验 |
| 图片全白或全灰 | 页码基准搞错,取到了无效页 | 打印前 3 页和最后 3 页的尺寸 | 确认索引从 0 还是 1 开始 |
| 合出的 PDF 页码错乱 | 文件名排序问题 | 检查目录下文件名 | 文件名补零对齐 |
| 跑一半开始大量失败 | 触发限流 | 看状态码分布 | 降并发、加间隔、加退避 |
| 图片能下但文字模糊 | 拿到了低清版本 | 对比图片实际像素 | 找带高分辨率参数的原图地址 |
| 文本层提取为空 | 内容是图片而非文字 | 用 pdfplumber 查看字符数 | 转 OCR 方案 |
| 内存暴涨被系统杀掉 | 一次性加载过多图片 | 监控 RSS | 合并阶段串行,及时释放 |
6.2 三个踩过的坑和修复思路
坑一:以为签名参数是长期的。有次写好脚本丢在服务器上跑,第二天起来发现全部 403。查了半天发现是签名 token 有效期只有 2 小时。修复办法不是在脚本里伪造签名,而是每次任务开始前重新请求一次详情页拿新 token,并且把 403 作为“需刷新会话”的信号,自动重取一次再重试。
坑二:图片尺寸不一致导致排版错位。首页往往是封面,尺寸和内页不一样,我早期直接用首页尺寸做画布,结果后面几十页全部被裁。修复就是 4.3 里那个“取众数”逻辑。这个改动只有几行代码,但把成品质量从“能看”提到了“能用”。
坑三:OCR 把水印识别成正文。一份带斜向水印的文档,识别结果里每段都夹着几个莫名其妙的字。试了好几种预处理都不理想,最后发现水印颜色比正文浅,做一个亮度阈值筛选就能滤掉。这个思路比调 OCR 参数有效得多,因为问题的根源在输入质量,不在识别算法。
心得:排查这类问题有个通用顺序——先确认拿到的原始数据对不对(文件头、尺寸、字符数),再确认处理逻辑对不对(排序、拼接、参数),最后才怀疑库或算法。我在实际排查中发现,八成的问题出在前两步,大家却习惯性先去怀疑第三步。
7. 后处理与二次利用
拿到 PDF 只是阶段性成果,真正体现价值的是后续怎么用。这一章讲两个我常用的方向。
7.1 清晰度与体积的平衡取舍
归档场景下,体积是硬约束。我做过一个统计:同样一份 120 页文档,原图合成 78 MB,quality=85重存后 34 MB,quality=70重存后 19 MB。肉眼看,85 和 70 在屏幕上的差别很小,但打印出来 70 那份文字边缘会有点糊。
所以我的选择是分场景:屏幕阅读和检索用 70,需要打印或存档用 85,几乎不用 95 以上,因为从 85 提到 95,体积翻倍但肉眼收益极小。这是典型的边际收益递减,钱要花在刀刃上。
另外有个容易被忽略的点:如果文档大量是纯文字排版,转成灰度图能再省一半体积,且不影响可读性。判断标准是有没有依赖颜色的内容(图表、标注),没有就转灰度。这个操作在Pillow里就一行im.convert("L")。
7.2 批量任务的组织方式
单篇跑通之后,批量任务的组织方式决定了你后面维护的痛苦程度。我的结构是这样的:
project/ ├── config.py # 全局配置 ├── fetch_meta.py # 元信息解析 ├── fetch_images.py # 图片下载 ├── merge_pdf.py # 合成 ├── extract_text.py # 文本提取 ├── run_batch.py # 批量调度 └── output/ └── {doc_id}/ ├── meta.json ├── images/ └── final.pdf拆分的目的不是好看,是让每一步可以单独重跑。合成出问题不用重新下载,提取出问题不用重新合成。批量调度只负责读任务清单、按顺序调各步骤、记录状态。这套结构我用了很久,加新站点时只需要改fetch_meta和fetch_images两个文件,其余不动。
调度层面我会加两个保护:单文档失败不影响后续(try/except 包住),以及全局任务数上限(避免跑一晚上把磁盘塞满)。这两个都是吃过亏之后加的。
8. 一些实操层面的体会
写这类脚本有个心态问题我觉得挺重要:不要追求一次写对,要追求出了问题能快速定位。我早期写脚本喜欢一口气写两百行,跑挂了从头看到尾,效率极低。后来改成每步单独可跑、每步输出中间结果、每步有日志,反而整体快了。因为绝大多数时间不是花在写代码上,是花在“为什么这页是空白”这种问题上。
还有个体会是关于参数调优的。很多人喜欢问“间隔设多少合适”“并发开几个”,其实没有标准答案,取决于站点、时段、你的网络。我的做法是先设保守值跑十页,看失败率,再逐步放开,找到一个稳定点就固定下来。这个过程大概五分钟,比到处问人快。
最后说一句关于工具边界的事。这类脚本本质是数据搬运,它的价值在于把散落的东西整理成可用的形态,而不是突破什么限制。我在实际使用中一直守着这个定位:只处理能正常访问的内容,只做归档和检索,不碰需要授权的东西。守住这条,技术手段怎么折腾都不心虚;守不住,代码写得再漂亮也是给自己找麻烦。