中医典籍 TXT 获取与 OCR 流水线
何时用
用户手上一批扫描版 PDF(古籍、医学书、档案),想要纯文本:投喂知识库、全文检索、整理成 TXT。
核心原则:先算覆盖率,再决定做不做 OCR。别一上手就 OCR 几百本。
第 0 步:先诊断,别急着动手
0.1 编码体检(BOM 必须最先判)
gb18030 几乎从不抛异常 —— 它会把 UTF-16 文件强解成乱码且不报错。实测某批 1577 个 TXT 里 17 个是 UTF-16,若按 gb18030 批量转码会静默把好文件转废。
def read_text(p):
b = open(p, "rb").read()
if b[:2] in (b"\xff\xfe", b"\xfe\xff"): # ① BOM 优先,且必须放最前
try: return b.decode("utf-16")
except Exception: pass
if b[:3] == b"\xef\xbb\xbf":
try: return b.decode("utf-8-sig")
except Exception: pass
for enc in ("utf-8", "gb18030", "big5"): # ② gb18030 兜底放最后
try: return b.decode(enc)
except Exception: pass
return b.decode("utf-8", "replace")
判乱码用「高频常用字覆盖率」而不是 try/except:按 gb2312 可编码率算,正常中文 ≥0.20,乱码近 0。
def common_ratio(t):
cjk = [c for c in t[:5000] if "\u4e00" <= c <= "\u9fff"]
if not cjk: return 0.0
ok = sum(1 for c in cjk if _enc_ok(c)) # 能 encode('gb2312') 即常用字
return ok / len(cjk)
0.2 剥广告水印必须逐行扫,不能只看头尾
TXT 资源站的推广块常伪装成编号列表(1、xxx 2、xxx … 7、xxx免费索取),前几行不含关键词 → "剥离头部连续噪音行"的做法会在第 0 行就停住,广告全留下。
正确做法:全局按行剔除(不限头尾),条件 = 行含强特征词(淘宝/www./http/VIP/QQ/微信/扫码/论坛/@/提取码)或纯符号行,且长度 < 200(防止误伤正文长行)。
if len(s) <= 200 and (any(w in s for w in STRONG) or re.fullmatch(r"[+\-=*#\s\d、]+", s)):
drop()
0.3 归一化次序(不可调换)
去噪行 → 去标记/标点(只留汉字)→ 繁转简(zhconv 'zh-cn') → 再比对
注意:模板前缀只剥不剔 —— 把 属性:/<篇名> 写进「剔除整行」正则会剔掉大量正文。
0.4 清单
- 列文件清单,按目录统计数量、体积、扩展名分布。
- 测文本层(决定一切):
pymupdf抽 5 页取get_text(),算平均字符/页。
->200 字/页→ 有文本层,直接提取,无需 OCR
-≤30 字/页→ 纯扫描图,必须 OCR 或找外部 TXT
抽样要覆盖首/中/尾,样本 ≥40 本(小样本会误判)。
第 1 步:查网上现成 TXT(成本几乎为零,必须先做)
1.1 先复核本地库
若用户已有 TXT 库,做归一化模糊匹配而非文件名精确匹配:
NOISE = ["待考","点校本","修订版","增订本","第二版","校注语译","上、下册",
"上下册","全二册","试行本","good","简体","修正版"]
def clean(s):
s = re.sub(r"[((【\[][^))】\]]*[))】\]]", "", s) # 去括号内容
s = re.sub(r"约?\s*(1[5-9]\d{2}|20\d{2})\s*年?", "", s) # 去年份
for w in NOISE: s = s.replace(w, "")
s = re.sub(r"[\s\-_—–·・::,,\.。、;;!!\??\"“”'‘’《》<>]", "", s)
return s.translate(TBL) # TBL: 繁简异体归一表
再建"书名核心"次级索引:按 - 切分取第一段(书名-朝代-作者 形式)。
异体字表必须包含这几对(否则漏判重复):桉→案("医桉"="医案")、沉→沈("沉氏"→沈氏、"苏沉良方"→苏沈良方)、菴→庵、彙→汇、証→证。
1.1b 关键陷阱:算"新增"时必须排除已下载目录
若用户已把某合集下载解压进本地库,该目录必须从「原有库存 base」里排除,否则:
- 本地 TXT 数会虚增上千
- "另一合集新增 N 本"会塌缩成 1–2 本,得出完全错误结论
做法:base = ROOT 下所有文件 − 新下载目录,再对它算 known()。
1.1d 重复治理:必须用 bottom-K 指纹,不要用等距采样
血泪教训(2026-09-25 实测):用"全文均匀取 8 段 × 400 字"做指纹,会把《普济方》这类 350 万字的大书误判 —— 采样点只覆盖 0.07%,两边采样点落在不同章节 → 相似度算成 0。
实测同一批数据:
| 方法 | A(正文完全相同) | 重复合计 |
|---|---|---|
| 等距采样 8 段 | 303 | 843 |
| bottom-K | 902 | 917 |
正确做法(纯 Python 无 numpy,2631 个文件约 2 分钟):
import heapq
K, N = 512, 8
def bottomk(t, k=K, n=N):
"""流式维护 k 个最小 hash,内存 O(k),覆盖全文无采样偏差"""
if len(t) < n: return set(), 0
hs = heapq.nsmallest(k, (hash(t[i:i+n]) for i in range(len(t)-n+1)))
return set(hs), len(t)
# 倒排:gram -> 文件,内存 = 文件数 x K,可控
inv = defaultdict(list)
for i, b in enumerate(BASE):
for h in b["fp"]: inv[h].append(i)
# 候选 -> 反推覆盖率(bottom-K Jaccard)
J = c / (a + b - c) # a,b = 两边指纹大小
inter = J * (a + b) / (1 + J)
cont = inter / min(a, b)
ratio = min(字数A, 字数B) / max(字数A, 字数B)
三个分档修正:
- ratio < 0.45 → 含摄关系(大类书含小书 / 分卷 vs 合订本),不能当重复删
- bottom-K 是低估,阈值可放心(cont ≥ 0.85 即同文)
- 判完必须抽验:从 A 取 3 段 80 字,检验是否原样 in B 的归一化全文(实测硬命中率 94.4%)
1.1e 重复治理的正确顺序:先清重复,再统一编码
不要先转 UTF-8。实测一批 1577 个 TXT 里 917 个(58%)是重复 —— 先转码等于白做一半以上。
顺序:BOM 体检 → 分级转码(utf-16 单独通道) → 剥广告 → 统一 utf-8-sig,且只在去重后执行。
1.1c 快速判定两合集是否同源:查编号体系
很多 TXT 合集源自同一套底本(如"中医书籍1360册目录"),文件名带 3 位序号 ###-书名.txt。
取两边编号集合求交:若交集/分母 > 95%,则后者是前者的子集 → 不必重复下载。
比逐本比对书名单快且不受异体字干扰。(实测:中医古典TXT版 635 个编号中 634 个与中医药TXT版重合,唯一差异项 700-吴普本草 在对方以 001-吴普本草 存在。)
1.2 中文互联网上的中医 TXT 大合集(夸克网盘)
已知合集(需登录才能下载,但列目录不需要登录):
| 合集 | 项数 | pwd_id |
|---|---|---|
| 中医药TXT版 | 1578 | 61beed906512 |
| 医藏-TXT版 | 878 | 618efe13cf82 |
| 中医古典TXT版 | 635 | 0aac532ea102 |
| 中医古籍 8GB | 668 | de57031fb85a |
| 中医丛书系列 22.5GB | 1211 | e1b89e893d29 |
| 中医书合集 19.7GB | 564 | 269e9ea15d46 |
分享页:https://pan.quark.cn/s/<pwd_id>
拉清单代码(关键:stoken 必须 urlencode,否则报 14001 非法token):
import json, urllib.request, urllib.parse, ssl
UA = {"User-Agent": "Mozilla/5.0 ... Chrome/120", "Referer": "https://pan.quark.cn/",
"Origin": "https://pan.quark.cn", "Content-Type": "application/json"}
def post(url, payload):
r = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=UA)
return json.loads(urllib.request.urlopen(r, timeout=25).read().decode("utf-8","replace"))
def token(pwd_id):
d = post("https://drive-h.quark.cn/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc",
{"pwd_id": pwd_id, "passcode": ""})
return d["data"]["stoken"]
def ls(pwd_id, stoken, fid="0", page=1):
q = urllib.parse.urlencode({ # ← 必须 urlencode
"pr":"ucpro","fr":"pc","pwd_id":pwd_id,"stoken":stoken,"pdir_fid":fid,
"force":"0","_page":page,"_size":100,
"_sort":"file_type:asc,file_name:asc",
"_fetch_banner":"0","_fetch_share":"1","_fetch_total":"1"})
return urllib.request.urlopen(
urllib.request.Request(
"https://drive-h.quark.cn/1/clouddrive/share/sharepage/detail?"+q,
headers=UA), timeout=25).read().decode("utf-8","replace")
file_type == 0是文件夹 → 递归;单页上限 100 条,用"返回条数 < 100 则停"判断翻页- 下载/转存必须登录:
POST /share/sharepage/save无登录返回401 require login [guest] /share/sharepage/download已废弃(404)
★ 接口变更(2026-09-30 实测,旧代码会静默返回 0 项):
- 返回字段名从
file_list改成list。data现在只有 3 个键:is_owner / share / list。
老代码d.get("file_list")恒为[],遍历一圈得到 0 项且不报错 —— 极易误判成"合集已失效"。
防御写法:fl = d.get("list") or d.get("file_list") or [] - 根 fid 不在 token 返回里。
token的data只有subscribed/stoken/share_type/author/url_type/expired_type/expired_at/title,
没有first_fid。first_fid藏在detail(pdir_fid="0")的data.share.first_fid。
正确顺序:① token 取 stoken → ② detail(pdir_fid="0") 取data.share.first_fid→ ③ 从该 fid 递归。
直接拿pdir_fid="0"去列文件,file_list 也是空的。 - 调试手法:先打印
data.keys()再取值,别凭旧记忆硬取路径 —— 上面两个坑都是靠打印键名一次定位的。
1.2b GitHub 直连(2026-09-30 实测:比夸克更实用)
夸克下载需登录,只能"列清单证明存在";GitHub 仓库 zip 可直接下载,是真正能落地的路径。
本机直连 github.com 会被代理挡(CONNECT tunnel failed, response 502),git clone 失败。
但 codeload.github.com 走系统代理可通(实测 HTTP 200):
url = f"https://codeload.github.com/{owner}/{repo}/zip/refs/heads/{branch}" # branch 试 main / master
data = urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=300).read()
# 解压:zipfile.ZipFile(io.BytesIO(data)),中文名用 n.encode('cp437').decode('gbk') 还原
实测可用仓库(分支不同,两个都试):
| 仓库 | 分支 | TXT 数 | zip 体积 |
|---|---|---|---|
jobkoko/tcm-database |
main | 848 | 121 MB |
DA9-TECH/TCM-Ancient-Books |
master | 701 | 80 MB |
wenyuange/medicine(医藏) |
master | 911 | 119 MB |
> 国内镜像多不可用(实测 ghfast 403、mirror.ghproxy 502、gitclone 502),
> 优先直接打 codeload,不要浪费时间试镜像。
1.3 可达性备忘(实测 2026-09)
- ✅ ctext.org、国学大师、书格、子阳医馆、pan.quark.cn、codeload.github.com
- ❌ archive.org(藏有大量中医书 + tesseract OCR 文本层,需换网络)、中医世家、中医古籍网
- ❌ 123pan 分享链接返回 404(域名已变)
- ❌
git clone https://github.com/...(代理 502),改走 codeload zip
1.4 判据
如果外部合集覆盖率 < 5%,立刻停止搜索,转 OCR。 原因:用户的"待找"清单往往是从大合集里筛过一遍的剩余,天然不在合集里。
★"筛剩效应"实测(2026-09-30,699 本扫描 PDF):这条判据比想象中更狠 ——
| 比对对象 | 覆盖率 | 结论 |
|---|---|---|
| 夸克 6 合集(5544 项,去重 4317 书名) | 310/699 = 44% | 看着高,但命中里 305 个是 PDF、仅 5 个是 TXT,PDF 还得 OCR,等于白找 |
| 3 个 GitHub 仓库(2460 个真 TXT) | 10/699 = 1.4% | ★ 真正的 TXT 覆盖率只有 1.4%,直接触发停止线 |
经验:
- 夸克命中数虚高,因为合集里 PDF 占绝大多数。必须按扩展名拆开统计命中,
只看总数会被"44% 覆盖率"骗去白干(且下载还要登录)。
- 用户的"待找清单"通常是从这些合集里下载过一轮后剩下的,所以连 GitHub 同源仓库也补不上。
- 但顺带有真收获:2460 个下载 TXT 对用户现有库的新增率是 77%,
去重后真实新增 59 本 / 1654 万字(普济方四库 615 万字、证治准绳 195 万字等大部头)。
→ 即使"待找清单"没命中,也务必顺手跑一次「下载全集 vs 现有库」的新增比对,这步常有意外收获。
- 规模参考:2460 个 TXT 做 bottom-K 指纹约 4 分钟,9020 个库文件约再 4 分钟(含读盘解码)。
第 2 步:OCR 可行性评估(小样先行,必修)
绝不能直接全量 OCR。 先按版式分档,因为准确率差异是天壤之别。
2.1 版式抽样判定(竖排 vs 横排)
对每个目录抽 12 本,各取中间一页渲染 200DPI 跑 OCR,统计返回框的宽高比:
高 > 1.4×宽 的框占比 > 60% → 竖排。
2.2 实测质量预期(RapidOCR + onnxruntime CPU,2026-09)
| 版式 | 置信度 | 可用性 | 典型错法 |
|---|---|---|---|
| 横排简体单栏(现代铅印) | 0.72–0.85 | ✅ 可用 | 错字 3–5%,药材名/剂量重灾区 |
| 横排简体双栏 | 0.76–0.85 | ⚠️ 需分栏 | 左右栏交错,句序错乱 |
| 竖排繁体 | 0.60–0.63 | ❌ 不可用 | 错字 30–40%;转 90°/270° 无改善 |
| 手写影印 | — | ❌ 完全废 | 整页只识出几个字 |
注意:古籍影印件不一定竖排——很多"明清"PDF 其实是现代铅印《XX医学全书》,横排简体,很好识别。必须实测,别凭书名猜。
2.3 双栏处理(横排双栏必修)
RapidOCR 默认按 y 坐标排段落,双栏书会把左右栏交错。做法:
按文本框中心 x 坐标做二分聚类 → 分成左栏/右栏 → 栏内按 y 排序 → 拼成"左栏全文 + 右栏全文"。
同时过滤页眉页脚(y 坐标在 5%/95% 之外的短行)。
2.4 后处理
- 上中医术语词表纠错(药材名、方剂名、剂量词),否则错字污染知识库
- 统计可疑字(生僻字、非汉字符号占比)标记待人工复核页
第 3 步:并行批量 OCR
本机参考:16 逻辑核 / 13.7GB 内存 / 无 GPU。
- 单页 300DPI 约 4 秒;200DPI 约 2 秒(古籍 200DPI 够用)
- 并行度:内存可用 < 6GB 时用 8 进程(每进程 onnxruntime 模型约 300–400MB),别开满 16
- 15 万页 ≈ 单进程 167 小时 → 8 进程 15–20 小时
- 断点续传:每本写完 .txt + 记录 done.json,重跑跳过已完成
- 输出:每本一个 .txt,文件名与 PDF 同名;不要合并成大文件(便于溯源)
安装
<python>/Scripts/pip.exe install rapidocr-onnxruntime onnxruntime
坑清单
str.maketrans的 key 长度必须为 1,不能塞词组({"醫案":"医案"}会抛 ValueError)- 用
urllib拼 URL 时,%5B之类再经%格式化会炸 —— 用urlencode,别手拼 - 渲染页码要用 0-based,且避开封面/目录(取
page_count//3起) - 中文字体路径 Windows:
C:\Windows\Fonts\msyh.ttc