extract_catalog.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. """
  2. 药典 PDF 目录提取工具
  3. 提取 2025 年版《中国药典》四部 PDF 的目录结构
  4. 用法:
  5. python extract_catalog.py /path/to/药典四部.pdf
  6. 输出:
  7. data-pipeline/data/catalog_volume4.json — 结构化目录 JSON
  8. data-pipeline/data/catalog_volume4.md — Markdown 目录
  9. """
  10. import sys
  11. import json
  12. import time
  13. import re
  14. from pathlib import Path
  15. import fitz # PyMuPDF
  16. # ============================================
  17. # 配置
  18. # ============================================
  19. INTERVAL = 0.2 # 每页处理间隔(秒),控制提取频率
  20. TOC_START_PAGE = 0 # 目录起始页(0-indexed),通常在前几页
  21. TOC_SCAN_PAGES = 30 # 扫描前30页找目录
  22. OUTPUT_DIR = Path(__file__).resolve().parent / "data"
  23. # ============================================
  24. # 提取逻辑
  25. # ============================================
  26. def extract_pdf_text(pdf_path: str, interval: float = INTERVAL) -> list[dict]:
  27. """逐页提取 PDF 文本,控制处理频率"""
  28. doc = fitz.open(pdf_path)
  29. total = len(doc)
  30. pages = []
  31. print(f"📄 打开 PDF: {pdf_path}")
  32. print(f" 总页数: {total}")
  33. for i in range(min(TOC_SCAN_PAGES, total)):
  34. page = doc[i]
  35. text = page.get_text("text")
  36. blocks = page.get_text("blocks") # 按段落提取
  37. pages.append({
  38. "page_num": i + 1,
  39. "text": text,
  40. "blocks": [
  41. {
  42. "text": b[4].strip(),
  43. "x0": b[0], "y0": b[1], # 坐标
  44. "x1": b[2], "y1": b[3],
  45. }
  46. for b in blocks if b[6] == 0 # 0=文本块
  47. ],
  48. })
  49. if i > 0 and i % 10 == 0:
  50. print(f" 已处理 {i}/{min(TOC_SCAN_PAGES, total)} 页")
  51. time.sleep(interval) # 控制频率
  52. doc.close()
  53. return pages
  54. def find_toc_pages(pages: list[dict]) -> tuple[int, int]:
  55. """定位目录起始和结束页"""
  56. start = 0
  57. end = TOC_SCAN_PAGES
  58. for i, p in enumerate(pages):
  59. text = p["text"]
  60. if "目" in text and "录" in text and i < 10:
  61. # 确认是目录页
  62. lines = text.strip().split("\n")
  63. if any("录" in l for l in lines[:3]):
  64. start = i
  65. else:
  66. start = i
  67. break
  68. # 找目录结束(正文开始)
  69. for i in range(start + 1, len(pages)):
  70. text = pages[i]["text"]
  71. if "凡例" in text or "通则" in text or "正文" in text:
  72. end = i
  73. break
  74. print(f" 目录定位: 第{start+1}页 ~ 第{end+1}页")
  75. return start, end
  76. def compute_level_thresholds(pages: list[dict], start: int, end: int) -> dict:
  77. """分析 block x 坐标,动态检测层级边界(替代硬编码阈值)"""
  78. x_values = []
  79. for pi in range(start, min(end + 1, len(pages))):
  80. for block in pages[pi]["blocks"]:
  81. text = block["text"].strip()
  82. if len(text) < 3:
  83. continue
  84. x_values.append(block["x0"])
  85. if not x_values:
  86. return {"part_max": 50, "chapter_max": 80}
  87. # 聚类:找自然断点作为 part/chapter/section 边界
  88. x_sorted = sorted(set(x_values))
  89. if len(x_sorted) < 3:
  90. return {"part_max": 50, "chapter_max": 80}
  91. # 找最大的两个间距作为分界
  92. gaps = [(x_sorted[i+1] - x_sorted[i], x_sorted[i], x_sorted[i+1])
  93. for i in range(len(x_sorted) - 1)]
  94. gaps.sort(reverse=True)
  95. # 用小 gap 作为 part 边界,大 gap 作为 chapter 边界
  96. small_gap, large_gap = sorted(gaps[:2], key=lambda g: g[1])
  97. part_max = small_gap[2] # 部级最大 x
  98. chapter_max = large_gap[2] # 章级最大 x
  99. # 合理性检查:如果两个阈值过于接近,回退到默认值
  100. if chapter_max - part_max < 5:
  101. print(" ⚠️ 动态阈值过于接近,使用默认值 (50, 80)")
  102. return {"part_max": 50, "chapter_max": 80}
  103. return {"part_max": part_max, "chapter_max": chapter_max}
  104. def parse_toc_structure(pages: list[dict], start: int, end: int) -> list[dict]:
  105. """解析目录层级结构(动态阈值)"""
  106. thresholds = compute_level_thresholds(pages, start, end)
  107. part_max = thresholds["part_max"]
  108. chapter_max = thresholds["chapter_max"]
  109. print(f" 动态层级阈值: 部级 x<{part_max}, 章级 {part_max}≤x<{chapter_max}, 节级 x≥{chapter_max}")
  110. toc_items = []
  111. current_part = "" # 部
  112. current_chapter = "" # 章
  113. for pi in range(start, min(end + 1, len(pages))):
  114. for block in pages[pi]["blocks"]:
  115. text = block["text"].strip()
  116. if len(text) < 3:
  117. continue
  118. x = block["x0"] # 缩进量判断层级
  119. # 部级(最左对齐)
  120. if x < part_max and ("凡例" in text or "通则" in text or "正文" in text):
  121. item = {
  122. "level": 1,
  123. "title": text,
  124. "page": pages[pi]["page_num"],
  125. "children": [],
  126. }
  127. toc_items.append(item)
  128. current_part = text
  129. current_chapter = ""
  130. print(f" 📘 {text}")
  131. # 章级(elif 确保不与部级重叠)
  132. elif part_max <= x < chapter_max and ("章" in text or len(text) > 3):
  133. item = {
  134. "level": 2,
  135. "title": text,
  136. "page": pages[pi]["page_num"],
  137. "children": [],
  138. }
  139. if toc_items:
  140. toc_items[-1]["children"].append(item)
  141. else:
  142. toc_items.append(item)
  143. current_chapter = text
  144. print(f" 📗 {text}")
  145. # 节级(elif 确保不与章级重叠)
  146. elif x >= chapter_max:
  147. item = {
  148. "level": 3,
  149. "title": text,
  150. "page": pages[pi]["page_num"],
  151. }
  152. if toc_items and toc_items[-1]["children"]:
  153. toc_items[-1]["children"][-1].setdefault("children", []).append(item)
  154. print(f" 📙 {text}" if len(text) < 50 else f" 📙 {text[:50]}...")
  155. return toc_items
  156. def export_markdown(toc: list[dict], output_path: Path):
  157. """导出为 Markdown 格式"""
  158. with open(output_path, "w", encoding="utf-8") as f:
  159. f.write("# 《中国药典》2025年版 四部 目录\n\n")
  160. for part in toc:
  161. f.write(f"## {part['title']}\n\n")
  162. for ch in part.get("children", []):
  163. f.write(f"- **{ch['title']}** ")
  164. if ch.get("page"):
  165. f.write(f"(第{ch['page']}页)")
  166. f.write("\n")
  167. for sec in ch.get("children", []):
  168. f.write(f" - {sec['title']}")
  169. if sec.get("page"):
  170. f.write(f"(第{sec['page']}页)")
  171. f.write("\n")
  172. f.write("\n")
  173. print(f"\n📝 Markdown 已保存: {output_path}")
  174. # ============================================
  175. # 主入口
  176. # ============================================
  177. def main(pdf_path: str):
  178. print("=" * 55)
  179. print(" 药典 PDF 目录提取器")
  180. print("=" * 55)
  181. print()
  182. pages = extract_pdf_text(pdf_path)
  183. print(f"\n✅ 提取完成,共处理 {len(pages)} 页\n")
  184. start, end = find_toc_pages(pages)
  185. print(f"\n📋 解析目录结构...\n")
  186. toc = parse_toc_structure(pages, start, end)
  187. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  188. json_path = OUTPUT_DIR / "catalog_volume4.json"
  189. with open(json_path, "w", encoding="utf-8") as f:
  190. json.dump(toc, f, ensure_ascii=False, indent=2)
  191. print(f"\n📦 JSON 已保存: {json_path}")
  192. md_path = OUTPUT_DIR / "catalog_volume4.md"
  193. export_markdown(toc, md_path)
  194. # 统计
  195. total = len(toc)
  196. chapters = sum(len(p.get("children", [])) for p in toc)
  197. sections = sum(
  198. len(ch.get("children", []))
  199. for p in toc
  200. for ch in p.get("children", [])
  201. )
  202. print(f"\n📊 统计: {total} 部, {chapters} 章, {sections} 节")
  203. print("=" * 55)
  204. if __name__ == "__main__":
  205. if len(sys.argv) < 2:
  206. print("用法: python extract_catalog.py <PDF文件路径>")
  207. print("示例: python extract_catalog.py ~/Downloads/CP2025_Volume4.pdf")
  208. sys.exit(1)
  209. main(sys.argv[1])