extract_all.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. """
  2. PDF 健康报告 → 完整内容提取脚本
  3. ================================
  4. 提取"个体化食物推荐表"全部内容,输出:
  5. 1. 原始文本(debug用)
  6. 2. 结构化CSV(每行一个食物,10个字段)
  7. 3. 推荐指数汇总表(标准营养 + 每人推荐指数)
  8. 用法:python extract_all.py [PDF目录]
  9. 依赖:pip install PyPDF2
  10. """
  11. import sys, os, csv, re, json
  12. sys.stdout.reconfigure(encoding='utf-8')
  13. sys.stderr.reconfigure(encoding='utf-8')
  14. from PyPDF2 import PdfReader
  15. # ── 字段定义 ──
  16. COLUMNS = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
  17. '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
  18. NUM_FIELDS = ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
  19. KNOWN_CATS = [
  20. '主食', '乳制品', '干果', '坚果', '快餐', '水产品',
  21. '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料'
  22. ]
  23. # ── CJK 偏旁部首 → 标准汉字映射 ──
  24. RADICAL_MAP = {
  25. '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
  26. '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
  27. '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
  28. '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
  29. '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
  30. '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
  31. '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
  32. '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
  33. '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
  34. '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
  35. '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
  36. '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
  37. }
  38. SKIP_TEXTS = [
  39. '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
  40. '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
  41. '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
  42. '检测方法及局限性', '数据分析及模型', '结果解读及使用',
  43. '影响因素说明', '建议将检测结果', '营养建议说明',
  44. '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
  45. '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
  46. ]
  47. def normalize(text):
  48. """替换偏旁部首为标准汉字"""
  49. return ''.join(RADICAL_MAP.get(c, c) for c in text)
  50. def parse_vertical(lines):
  51. """逐行格式:名称/分类/数值/... 每9行一组"""
  52. rows = []
  53. i = 0
  54. while i + 9 < len(lines):
  55. name = lines[i].strip()
  56. cat = lines[i + 1].strip()
  57. if cat not in KNOWN_CATS:
  58. i += 1
  59. continue
  60. nums = []
  61. ok = True
  62. for j in range(2, 10):
  63. v = lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
  64. try:
  65. int(v)
  66. nums.append(v)
  67. except ValueError:
  68. ok = False
  69. break
  70. if ok and len(nums) == 8:
  71. rows.append(dict(zip(COLUMNS, [name, cat] + nums)))
  72. i += 1
  73. return rows
  74. def split_7_fields(s):
  75. """将7个连续数字串拆分为字段(回溯)"""
  76. results = []
  77. ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
  78. def backtrack(pos, idx, nums):
  79. if idx == 7:
  80. if pos == len(s):
  81. results.append(list(nums))
  82. return
  83. if pos >= len(s):
  84. return
  85. lo, hi = ranges[idx]
  86. for w in range(lo, min(hi + 1, len(s) - pos + 1)):
  87. chunk = s[pos:pos + w]
  88. if chunk.isdigit():
  89. backtrack(pos + w, idx + 1, nums + [int(chunk)])
  90. backtrack(0, 0, [])
  91. return results
  92. def decode_compressed(name, num_str, ref_vals=None):
  93. """解码压缩格式数字串"""
  94. raw = num_str.lstrip('-')
  95. has_neg = num_str.startswith('-')
  96. results = []
  97. for rec_len in range(1, 3):
  98. if rec_len > len(raw):
  99. continue
  100. rec = ('-' if has_neg else '') + raw[:rec_len]
  101. try:
  102. rec_val = int(rec)
  103. if not (-100 <= rec_val <= 100):
  104. continue
  105. except ValueError:
  106. continue
  107. remain = raw[rec_len:]
  108. candidates = split_7_fields(remain)
  109. for nums in candidates:
  110. if ref_vals:
  111. matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
  112. if matches >= 6:
  113. results.append([rec_val] + nums)
  114. else:
  115. results.append([rec_val] + nums)
  116. if not results:
  117. return None
  118. if ref_vals:
  119. results.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
  120. -len(str(abs(r[0])))), reverse=True)
  121. if sum(1 for i in range(7) if ref_vals[i] == results[0][1:][i]) < 6:
  122. return None
  123. return results[0]
  124. def parse_compressed(text, ref_lookup=None):
  125. """压缩格式:所有字段在一行无分隔"""
  126. header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
  127. rest = text
  128. if header in rest:
  129. rest = rest.replace(header, '')
  130. for kw in SKIP_TEXTS:
  131. rest = rest.replace(kw, '')
  132. rows = []
  133. while rest:
  134. best_cat, best_idx = None, len(rest)
  135. for cat in KNOWN_CATS:
  136. idx = rest.find(cat)
  137. if idx != -1 and idx < best_idx:
  138. best_idx, best_cat = idx, cat
  139. if best_cat is None:
  140. break
  141. name = rest[:best_idx]
  142. rest = rest[best_idx + len(best_cat):]
  143. num_str = ''
  144. while rest and (rest[0].isdigit() or rest[0] in '-\u2212\u2014'):
  145. c = '-' if rest[0] in '\u2212\u2014' else rest[0]
  146. num_str += c
  147. rest = rest[1:]
  148. if not name or not num_str:
  149. continue
  150. ref_vals = ref_lookup.get(name) if ref_lookup else None
  151. decoded = decode_compressed(name, num_str, ref_vals)
  152. if decoded:
  153. rows.append(dict(zip(COLUMNS, [name, best_cat] + [str(v) for v in decoded])))
  154. return rows
  155. def detect_format(text):
  156. """自动检测格式:vertical(逐行) / compressed(压缩)"""
  157. lines = [l.strip() for l in text.split('\n') if l.strip() and not re.match(r'\d+/\d+', l)]
  158. if not lines:
  159. return 'unknown'
  160. # 一行很长 + 无换行 = compressed
  161. if len(lines) <= 3 and len(lines[0]) > 200:
  162. return 'compressed'
  163. long_count = sum(1 for l in lines[:10] if len(l) > 100)
  164. if long_count >= 3:
  165. return 'compressed'
  166. cat_count = sum(1 for l in lines[:80] if l in KNOWN_CATS)
  167. if cat_count >= 5:
  168. return 'vertical'
  169. return 'unknown'
  170. def extract_food_table(pdf_path, ref_lookup=None):
  171. """从PDF提取食物推荐表,返回 (rows, fmt)"""
  172. reader = PdfReader(pdf_path)
  173. name = os.path.splitext(os.path.basename(pdf_path))[0]
  174. # 找到表格起始页
  175. food_start = None
  176. for i, page in enumerate(reader.pages):
  177. if '个体化食物推荐表' in page.extract_text():
  178. food_start = i
  179. break
  180. if food_start is None:
  181. return None, 'not_found'
  182. # 检测格式
  183. first_text = normalize(reader.pages[food_start + 1].extract_text())
  184. fmt = detect_format(first_text)
  185. rows = []
  186. if fmt == 'compressed':
  187. for i in range(food_start + 1, len(reader.pages)):
  188. text = normalize(reader.pages[i].extract_text())
  189. text = re.sub(r'\d+/\d+', '', text)
  190. rows.extend(parse_compressed(text, ref_lookup))
  191. else:
  192. all_lines = []
  193. for i in range(food_start + 1, len(reader.pages)):
  194. for line in normalize(reader.pages[i].extract_text()).split('\n'):
  195. lt = line.strip()
  196. if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS:
  197. continue
  198. if len(lt) > 60 and any(k in lt for k in SKIP_TEXTS):
  199. continue
  200. all_lines.append(lt)
  201. rows = parse_vertical(all_lines)
  202. return rows, fmt
  203. def main(pdf_dir):
  204. pdf_dir = pdf_dir or r'D:\workspace\cfc\docs\参考资料'
  205. outdir = pdf_dir
  206. all_reports = {}
  207. # ── Step 1: 提取每份PDF ──
  208. print('=' * 60)
  209. print('Step 1: 提取食物推荐表')
  210. print('=' * 60)
  211. pdf_files = sorted(f for f in os.listdir(pdf_dir) if f.lower().endswith('.pdf'))
  212. for pdf_file in pdf_files:
  213. pdf_path = os.path.join(pdf_dir, pdf_file)
  214. label = pdf_file.replace('.pdf', '')
  215. print(f'\n{label}...', end=' ')
  216. try:
  217. rows, fmt = extract_food_table(pdf_path)
  218. except Exception as e:
  219. print(f'ERROR: {e}')
  220. continue
  221. if rows is None:
  222. print('SKIP (未找到表格)')
  223. continue
  224. print(f'[{fmt}] {len(rows)} 条')
  225. all_reports[label] = rows
  226. # 保存原始文本
  227. raw_path = os.path.join(outdir, f'{label}-原始文本.txt')
  228. reader = PdfReader(pdf_path)
  229. with open(raw_path, 'w', encoding='utf-8') as f:
  230. for page in reader.pages:
  231. f.write(f'--- Page ---\n')
  232. f.write(normalize(page.extract_text()))
  233. f.write('\n')
  234. print(f' raw -> {os.path.basename(raw_path)}')
  235. # 保存CSV
  236. csv_name = f'{label}-个体化食物推荐表.csv'
  237. csv_path = os.path.join(outdir, csv_name)
  238. with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
  239. w = csv.DictWriter(f, fieldnames=COLUMNS)
  240. w.writeheader()
  241. w.writerows(rows)
  242. print(f' csv -> {csv_name}')
  243. if not all_reports:
  244. print('\n未找到任何有效PDF')
  245. return
  246. # ── Step 2: 生成推荐指数汇总表 ──
  247. print('\n' + '=' * 60)
  248. print('Step 2: 生成推荐指数汇总表')
  249. print('=' * 60)
  250. # 标准营养参考(优先用侯报告,224条最完整)
  251. ref_report = None
  252. std_label = None
  253. for pref in ['530010234-侯', '侯']:
  254. for label in all_reports:
  255. if pref in label:
  256. ref_report = all_reports[label]
  257. std_label = label
  258. break
  259. if ref_report:
  260. break
  261. if not ref_report:
  262. # 用条目最多的
  263. std_label = max(all_reports, key=lambda k: len(all_reports[k]))
  264. ref_report = all_reports[std_label]
  265. print(f'标准参考: {std_label} ({len(ref_report)} 条)')
  266. std_lookup = {}
  267. for r in ref_report:
  268. std_lookup[r['名称']] = {k: r[k] for k in ['分类'] + NUM_FIELDS}
  269. # 收集推荐指数
  270. report_labels = {}
  271. for label in all_reports:
  272. if '某人' in label:
  273. report_labels[label] = '某人'
  274. elif '侯' in label:
  275. report_labels[label] = '侯'
  276. elif '547982403' in label:
  277. report_labels[label] = '547982403'
  278. elif '儿童' in label:
  279. report_labels[label] = '儿童示例'
  280. elif '朱' in label:
  281. report_labels[label] = '朱评估报告'
  282. else:
  283. report_labels[label] = label
  284. rec_all = {}
  285. for label, short in report_labels.items():
  286. rec_all[short] = {}
  287. for r in all_reports[label]:
  288. rec_all[short][r['名称']] = r['推荐指数']
  289. sum_cols = ['名称', '分类'] + NUM_FIELDS + list(report_labels.values())
  290. sum_path = os.path.join(outdir, '推荐指数汇总.csv')
  291. with open(sum_path, 'w', newline='', encoding='utf-8-sig') as f:
  292. w = csv.DictWriter(f, fieldnames=sum_cols)
  293. w.writeheader()
  294. for r in ref_report:
  295. name = r['名称']
  296. s = std_lookup[name]
  297. row = {'名称': name, '分类': s['分类']}
  298. for k in NUM_FIELDS:
  299. row[k] = s[k]
  300. for short in report_labels.values():
  301. row[short] = rec_all.get(short, {}).get(name, '')
  302. w.writerow(row)
  303. print(f'推荐指数汇总: {len(ref_report)} 行 -> {os.path.basename(sum_path)}')
  304. for short in report_labels.values():
  305. missing = sum(1 for r in ref_report if not rec_all.get(short, {}).get(r['名称'], ''))
  306. print(f' {short}: {"完整" if missing == 0 else f"缺失 {missing} 条"}')
  307. print('\nDone!')
  308. if __name__ == '__main__':
  309. pdf_dir = sys.argv[1] if len(sys.argv) > 1 else None
  310. main(pdf_dir)