|
|
@@ -2,21 +2,27 @@
|
|
|
菌群报告 PDF 解析器
|
|
|
从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
|
|
|
"""
|
|
|
+import os
|
|
|
import re
|
|
|
from PyPDF2 import PdfReader
|
|
|
|
|
|
# === 常量 ===
|
|
|
+# Kangxi 部首 → CJK 统一汉字(与 extract_full_report_v5.py 完全一致)
|
|
|
RADICAL_MAP = {
|
|
|
- '\u2f52': '\u6c0f', '\u2f51': '\u6bcd', '\u2f59': '\u6b6f',
|
|
|
- '\u2f04': '\u4e59', '\u2f20': '\u4e00', '\u2f21': '\u4e28',
|
|
|
- '\u2f22': '\u4e3f', '\u2f23': '\u4e39', '\u2f2b': '\u5c38',
|
|
|
- '\u2f2d': '\u5c71', '\u2f44': '\u4e59', '\u2f53': '\u6c14',
|
|
|
- '\u2f55': '\u6c34', '\u2f5c': '\u725b', '\u2f5f': '\u7389',
|
|
|
- '\u2f7a': '\u7f8a', '\u2f81': '\u8089', '\u2f83': '\u81ea',
|
|
|
- '\u2f8a': '\u8272', '\u2f8e': '\u8840', '\u2f95': '\u79be',
|
|
|
- '\u2f96': '\u8c46', '\u2faf': '\u9762', '\u2fb9': '\u9999',
|
|
|
- '\u2fca': '\u9ed1', '\u2ec9': '\u8d1d', '\u2edd': '\u98df',
|
|
|
- '\u2ee2': '\u9a6c', '\u2ee9': '\u9ec4',
|
|
|
+ '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
|
|
|
+ '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
|
|
|
+ '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
|
|
|
+ '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
|
|
|
+ '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
|
|
|
+ '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
|
|
|
+ '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
|
|
|
+ '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
|
|
|
+ '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
|
|
|
+ '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
|
|
|
+ '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
|
|
|
+ '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
|
|
|
+ # 氏 radical U+2F52 → U+6C0F
|
|
|
+ '\u2f52': '氏',
|
|
|
}
|
|
|
|
|
|
KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
|
|
|
@@ -42,6 +48,39 @@ KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类',
|
|
|
'喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
|
|
|
KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
|
|
|
|
|
|
+# === 菌群表标题(完整版 extract_full_report_v5.py 移植) ===
|
|
|
+BACTERIA_TABLE_TITLES = [
|
|
|
+ '核心菌属构成表', '益生菌', '有害菌属构成表',
|
|
|
+ '其它重要菌属构成表', '病原菌属构成表',
|
|
|
+]
|
|
|
+PHYLUM_TABLE_TITLES = ['菌门构成表', '菌群门水平构成表', '门水平菌群构成']
|
|
|
+CLASS_TABLE_TITLES = ['菌纲构成表', '菌群纲水平构成表', '纲水平菌群构成']
|
|
|
+ORDER_TABLE_TITLES = ['菌目构成表', '菌群目水平构成表', '目水平菌群构成']
|
|
|
+FAMILY_TABLE_TITLES = ['菌科构成表', '菌群科水平构成表', '科水平菌群构成']
|
|
|
+GENUS_TABLE_TITLES = ['菌属构成表', '菌群属水平构成表', '属水平菌群构成']
|
|
|
+SPECIES_TABLE_TITLES = ['菌种构成表', '菌群种水平构成表', '种水平菌群构成']
|
|
|
+DISEASE_BACTERIA_TITLES = [
|
|
|
+ '肥胖相关菌', '便秘相关菌', '抑郁相关菌', '过敏相关菌',
|
|
|
+ '腹胀相关菌', '失眠相关菌', '肠道健康相关菌',
|
|
|
+ '多动症相关菌', '自闭症相关菌',
|
|
|
+]
|
|
|
+
|
|
|
+# === 食物推荐表(完整版 extract_full_report_v5.py 移植) ===
|
|
|
+COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
|
|
|
+ '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
|
|
|
+KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
|
|
|
+ '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
|
|
|
+FOOD_SKIP_TEXTS = [
|
|
|
+ '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
|
|
|
+ '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
|
|
|
+ '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
|
|
|
+ '检测方法及局限性', '数据分析及模型', '结果解读及使用',
|
|
|
+ '影响因素说明', '建议将检测结果', '营养建议说明',
|
|
|
+ '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
|
|
|
+ '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
|
|
|
+ '募极生物',
|
|
|
+]
|
|
|
+
|
|
|
|
|
|
def norm(s):
|
|
|
return ''.join(RADICAL_MAP.get(c, c) for c in s)
|
|
|
@@ -153,6 +192,18 @@ def parse_report_pdf(file_path: str) -> dict:
|
|
|
result['trace_elements'] = [r for r in vits if '维生素' not in r['name']]
|
|
|
break
|
|
|
|
|
|
+ # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
|
|
|
+ bacteria_tables = _extract_bacteria_tables(file_path)
|
|
|
+ result['菌群检出详细列表'] = bacteria_tables
|
|
|
+
|
|
|
+ # 个体化食物推荐表(保留原始键名与结构)
|
|
|
+ food_rows, food_fmt = _extract_food_rows(file_path)
|
|
|
+ result['个体化食物推荐表'] = {
|
|
|
+ '格式': food_fmt,
|
|
|
+ '条目数': len(food_rows),
|
|
|
+ '数据': food_rows
|
|
|
+ }
|
|
|
+
|
|
|
return result
|
|
|
|
|
|
|
|
|
@@ -164,3 +215,539 @@ def parse_report_pdf_with_fallback(file_path: str) -> dict:
|
|
|
not result.get('overview', {}).get('健康总分'):
|
|
|
result['_parse_incomplete'] = True
|
|
|
return result
|
|
|
+
|
|
|
+
|
|
|
+def _parse_bacteria_table(reader, pages_text, full_text, title, fmt, skip_header=False):
|
|
|
+ """从PDF中解析一个菌群表格(移植自 extract_full_report_v5.py)"""
|
|
|
+ results = []
|
|
|
+ sidx = full_text.find(title)
|
|
|
+ if sidx == -1:
|
|
|
+ return results
|
|
|
+
|
|
|
+ # 病原菌检出表特殊处理:找"仅列出检出的病原菌"(跳过前面的说明文字中的"病原菌")
|
|
|
+ if title == '病原菌':
|
|
|
+ better_sidx = full_text.find('仅列出检出的病原菌')
|
|
|
+ if better_sidx != -1:
|
|
|
+ sidx = better_sidx
|
|
|
+
|
|
|
+ # 找表格结束位置(下一个标题或页尾)
|
|
|
+ end_pos = len(full_text)
|
|
|
+ for t in (BACTERIA_TABLE_TITLES + PHYLUM_TABLE_TITLES + CLASS_TABLE_TITLES
|
|
|
+ + ORDER_TABLE_TITLES + FAMILY_TABLE_TITLES + GENUS_TABLE_TITLES
|
|
|
+ + SPECIES_TABLE_TITLES + DISEASE_BACTERIA_TITLES
|
|
|
+ + ['指标范围', '个体化食物推荐表', '报告总结', '健康总分']):
|
|
|
+ if t == title:
|
|
|
+ continue
|
|
|
+ ei = full_text.find(t, sidx + len(title))
|
|
|
+ if ei != -1 and ei < end_pos:
|
|
|
+ end_pos = ei
|
|
|
+
|
|
|
+ region = full_text[sidx:end_pos]
|
|
|
+
|
|
|
+ # 检测区域实际格式:如果换行数很少(<3)则是inline格式,即使全局fmt=triplet
|
|
|
+ lines_from_region = [l.strip() for l in region.split('\n') if l.strip()]
|
|
|
+ actual_fmt = fmt
|
|
|
+ if fmt == 'triplet' and len(lines_from_region) <= 5:
|
|
|
+ actual_fmt = 'inline'
|
|
|
+
|
|
|
+ if actual_fmt == 'triplet':
|
|
|
+ # 三元组格式:每个字段单独一行
|
|
|
+ lines = [l.strip() for l in region.split('\n') if l.strip()]
|
|
|
+ start = 0
|
|
|
+ for i, line in enumerate(lines):
|
|
|
+ if line == '名称':
|
|
|
+ start = i + 1
|
|
|
+ break
|
|
|
+ if line.startswith('名称'):
|
|
|
+ if skip_header:
|
|
|
+ start = i + 1
|
|
|
+ break
|
|
|
+
|
|
|
+ i = start
|
|
|
+ while i < len(lines):
|
|
|
+ name = lines[i]
|
|
|
+ if not name or len(name) <= 1 or name in ['说明', '检测结果', '结果解释', '建议']:
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ if name.startswith('说明:') or name.startswith('改善方式'):
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ if len(name) > 80:
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 找丰度%
|
|
|
+ if i + 1 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$|^ND$', lines[i + 1]):
|
|
|
+ pct = lines[i + 1]
|
|
|
+ normal_range = ''
|
|
|
+ pop_level = ''
|
|
|
+ detection_rate = ''
|
|
|
+ desc = ''
|
|
|
+ j = i + 2
|
|
|
+ # 正常范围 (允许小数,如 0.06-6.96, 0.03-3.07)
|
|
|
+ if j < len(lines) and re.match(r'^[\d]+\.?[\d]*-[\d]+\.?[\d]*$', lines[j]):
|
|
|
+ normal_range = lines[j]
|
|
|
+ j += 1
|
|
|
+ # 人群水平% (允许小数,如 15.66%)
|
|
|
+ if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
|
|
|
+ pop_level = lines[j]
|
|
|
+ j += 1
|
|
|
+ # 检出率%
|
|
|
+ if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
|
|
|
+ detection_rate = lines[j]
|
|
|
+ j += 1
|
|
|
+ # 说明
|
|
|
+ if j < len(lines) and lines[j].startswith('说明'):
|
|
|
+ desc = lines[j]
|
|
|
+ j += 1
|
|
|
+ # 改善方式
|
|
|
+ if j < len(lines) and lines[j].startswith('改善方式'):
|
|
|
+ if desc:
|
|
|
+ desc += ' | ' + lines[j]
|
|
|
+ else:
|
|
|
+ desc = lines[j]
|
|
|
+ j += 1
|
|
|
+
|
|
|
+ entry = {'名称': name, '丰度%': pct}
|
|
|
+ if normal_range:
|
|
|
+ entry['正常范围%'] = normal_range
|
|
|
+ if pop_level:
|
|
|
+ entry['人群水平%'] = pop_level
|
|
|
+ if detection_rate:
|
|
|
+ entry['检出率%'] = detection_rate
|
|
|
+ if desc:
|
|
|
+ entry['说明'] = desc
|
|
|
+ results.append(entry)
|
|
|
+ i = j
|
|
|
+ else:
|
|
|
+ i += 1
|
|
|
+ else:
|
|
|
+ # inline 格式
|
|
|
+ sample = region[:500]
|
|
|
+ truly_compressed = bool(re.search(r'[a-z]\d', sample, re.IGNORECASE))
|
|
|
+ if not truly_compressed:
|
|
|
+ # 有空格分隔的inline格式,使用re.finditer
|
|
|
+ region_clean = region
|
|
|
+ for hdr in ['名称', '丰度%', '正常范围%', '处于人群%水平', '%正常人有检出',
|
|
|
+ '人群水平%', '%人检出']:
|
|
|
+ region_clean = region_clean.replace(hdr, '')
|
|
|
+ region_clean = re.sub(
|
|
|
+ r'说明:[\u4e00-\u9fff\s,。、;:,.;:()()、/a-zA-Z0-9\-]{10,}?(?=[\u4e00-\u9fff]|$)',
|
|
|
+ '', region_clean)
|
|
|
+ # 扫描所有匹配的数据行
|
|
|
+ for m in re.finditer(
|
|
|
+ r'([\u4e00-\u9fff]{2,12}(?:[((][\u4e00-\u9fff\w]+[))])?)\s+' # 中文名
|
|
|
+ r'(?:[A-Z][a-z]+(?:\s[A-Z][a-z]+)*\s+)?' # 可选英文名
|
|
|
+ r'(ND|[\d]+\.?[\d]*%?)\s+' # 丰度
|
|
|
+ r'([\d]+\.?[\d]*-[\d]+\.?[\d]*)?\s*' # 可选正常范围
|
|
|
+ r'(\d+\.?\d*%?)\s+' # 人群水平%
|
|
|
+ r'(\d+\.?\d*%)', # 检出率%
|
|
|
+ region_clean):
|
|
|
+ name = m.group(1).strip()
|
|
|
+ pct = m.group(2)
|
|
|
+ if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
|
|
|
+ continue
|
|
|
+ normal_range = m.group(3) or ''
|
|
|
+ pop_level = m.group(4)
|
|
|
+ if not pop_level.endswith('%'):
|
|
|
+ pop_level += '%'
|
|
|
+ detection_rate = m.group(5)
|
|
|
+ entry = {'名称': name, '丰度%': pct}
|
|
|
+ if normal_range:
|
|
|
+ entry['正常范围%'] = normal_range
|
|
|
+ entry['人群水平%'] = pop_level
|
|
|
+ entry['检出率%'] = detection_rate
|
|
|
+ results.append(entry)
|
|
|
+ # 模式1没有匹配时:仅中文名+丰度+人群水平(+检出率)
|
|
|
+ if not results:
|
|
|
+ for m in re.finditer(
|
|
|
+ r'([\u4e00-\u9fff]{2,10}[\u4e00-\u9fff]?)\s+'
|
|
|
+ r'(ND|[\d]+\.?[\d]*%?)\s+'
|
|
|
+ r'(\d+\.?\d*%)\s+'
|
|
|
+ r'(\d+\.?\d*%)?',
|
|
|
+ region_clean):
|
|
|
+ name = m.group(1).strip()
|
|
|
+ pct = m.group(2)
|
|
|
+ if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
|
|
|
+ continue
|
|
|
+ pop_level = m.group(3)
|
|
|
+ detection_rate = m.group(4) or ''
|
|
|
+ entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
|
|
|
+ if detection_rate:
|
|
|
+ entry['检出率%'] = detection_rate
|
|
|
+ results.append(entry)
|
|
|
+
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def _parse_phylum_tables(reader, full_text, fmt, title_list=None):
|
|
|
+ """提取菌群层级构成表(门/纲/目/科/属/种 level)(移植自 extract_full_report_v5.py)"""
|
|
|
+ if title_list is None:
|
|
|
+ title_list = PHYLUM_TABLE_TITLES
|
|
|
+ results = []
|
|
|
+ for phylum_title in title_list:
|
|
|
+ rows = _parse_bacteria_table(reader, None, full_text, phylum_title, fmt)
|
|
|
+ results.extend(rows)
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def _parse_taxonomy_levels(reader, full_text, fmt):
|
|
|
+ """从"菌群检出详细列表"中提取纲目科属种各级数据(移植自 extract_full_report_v5.py)"""
|
|
|
+ results = {}
|
|
|
+ sidx = full_text.find('菌群检出详细列表')
|
|
|
+ if sidx == -1:
|
|
|
+ return results
|
|
|
+
|
|
|
+ end_pos = len(full_text)
|
|
|
+ for t in ['个体化食物推荐表', '报告总结', '健康总分']:
|
|
|
+ ei = full_text.find(t, sidx)
|
|
|
+ if ei != -1 and ei < end_pos:
|
|
|
+ end_pos = ei
|
|
|
+
|
|
|
+ section = full_text[sidx:end_pos]
|
|
|
+
|
|
|
+ for level, level_name in [('纲', '菌纲构成'), ('目', '菌目构成'),
|
|
|
+ ('科', '菌科构成'), ('属', '菌属构成'),
|
|
|
+ ('种', '菌种构成')]:
|
|
|
+ marker = f'\n{level}\n名称\n丰度%'
|
|
|
+ marker2 = f'{level} 名称 丰度%'
|
|
|
+ li = section.find(marker)
|
|
|
+ level_start = None
|
|
|
+ if li == -1:
|
|
|
+ li2 = section.find(marker2)
|
|
|
+ if li2 != -1:
|
|
|
+ li = li2
|
|
|
+ level_start = li2 + len(marker2)
|
|
|
+ else:
|
|
|
+ compressed_marker = f'{level}名称丰度%人群水平%%人检出'
|
|
|
+ cli = section.find(compressed_marker)
|
|
|
+ if cli == -1:
|
|
|
+ continue
|
|
|
+ # 压缩格式解析:用正则提取数据
|
|
|
+ level_start = cli + len(compressed_marker)
|
|
|
+ level_end = len(section)
|
|
|
+ for next_level in ['目', '科', '属', '种']:
|
|
|
+ if next_level == level:
|
|
|
+ continue
|
|
|
+ ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
|
|
|
+ if ni != -1 and ni < level_end:
|
|
|
+ level_end = ni
|
|
|
+ break
|
|
|
+ level_region = section[level_start:level_end]
|
|
|
+ compressed_pattern = re.compile(
|
|
|
+ r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?' # 可选中文名
|
|
|
+ r'([A-Za-z][A-Za-z\s.\-]*?)' # 拉丁名(可能含空格)
|
|
|
+ r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)' # 三连百分比
|
|
|
+ )
|
|
|
+ rows = []
|
|
|
+ for m in compressed_pattern.finditer(level_region):
|
|
|
+ cn_name = (m.group(1) or '').strip()
|
|
|
+ latin_name = m.group(2).strip()
|
|
|
+ pct = m.group(3)
|
|
|
+ pop_level = m.group(4)
|
|
|
+ detection = m.group(5)
|
|
|
+ name = cn_name if cn_name else latin_name
|
|
|
+ entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
|
|
|
+ rows.append(entry)
|
|
|
+ if rows:
|
|
|
+ results[level_name] = rows
|
|
|
+ continue
|
|
|
+ else:
|
|
|
+ level_start = li + len(marker)
|
|
|
+
|
|
|
+ level_end = len(section)
|
|
|
+ for next_level in ['纲', '目', '科', '属', '种']:
|
|
|
+ if next_level == level:
|
|
|
+ continue
|
|
|
+ ni = section.find(f'\n{next_level}\n名称', level_start)
|
|
|
+ if ni != -1 and ni < level_end:
|
|
|
+ level_end = ni
|
|
|
+ break
|
|
|
+
|
|
|
+ level_region = section[level_start:level_end]
|
|
|
+ lines = [l.strip() for l in level_region.split('\n') if l.strip()]
|
|
|
+ rows = []
|
|
|
+ i = 0
|
|
|
+ while i < len(lines):
|
|
|
+ if lines[i] in ['名称', '丰度%', '人群水平%', '%人检出']:
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ name = lines[i]
|
|
|
+ if i + 2 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$', lines[i + 1]):
|
|
|
+ pct = lines[i + 1]
|
|
|
+ pop_level = lines[i + 2] if i + 2 < len(lines) else ''
|
|
|
+ detection = lines[i + 3] if i + 3 < len(lines) and re.match(r'^[\d.]+%$', lines[i + 3]) else ''
|
|
|
+ entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
|
|
|
+ if detection:
|
|
|
+ entry['检出率%'] = detection
|
|
|
+ rows.append(entry)
|
|
|
+ i += 4 if detection else 3
|
|
|
+ else:
|
|
|
+ i += 1
|
|
|
+
|
|
|
+ if rows:
|
|
|
+ results[level_name] = rows
|
|
|
+
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def _extract_bacteria_tables(pdf_path):
|
|
|
+ """提取菌群检出详细列表,返回 {中文分组名: [行]}(移植自 extract_full_report_v5.py)
|
|
|
+
|
|
|
+ 与原始脚本保持一致的文本构造与格式检测:full_text 使用
|
|
|
+ '\\n'.join(norm(p.extract_text()) for p in reader.pages)(不 strip、不过滤空行),
|
|
|
+ fmt 使用原脚本 L672-683 的独立检测逻辑。
|
|
|
+ """
|
|
|
+ reader = PdfReader(pdf_path)
|
|
|
+ full_text = '\n'.join(norm(p.extract_text()) for p in reader.pages)
|
|
|
+
|
|
|
+ # 原脚本 extract_bacteria_tables 的格式检测(L674-683)
|
|
|
+ fmt = 'triplet' if '指标范围' in full_text and '疾病风险评估' in full_text else 'inline'
|
|
|
+ for pt in [p.extract_text() for p in reader.pages]:
|
|
|
+ t = norm(pt)
|
|
|
+ if '疾病风险评估' in t and '指标范围' in t:
|
|
|
+ for line in t.split('\n'):
|
|
|
+ if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', line.strip()):
|
|
|
+ fmt = 'inline'
|
|
|
+ break
|
|
|
+ break
|
|
|
+
|
|
|
+ all_tables = {}
|
|
|
+
|
|
|
+ # 核心菌属构成表1-3
|
|
|
+ core_genus = []
|
|
|
+ for i in range(1, 4):
|
|
|
+ title = f'核心菌属构成表{i}'
|
|
|
+ rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
|
|
|
+ core_genus.extend(rows)
|
|
|
+ all_tables['核心菌属'] = core_genus
|
|
|
+
|
|
|
+ # 益生菌(使用更精确的表头定位,跳过前面的说明文字)
|
|
|
+ prob_marker = '仅列出丰度前22的益生菌种'
|
|
|
+ prob_sidx = full_text.find(prob_marker)
|
|
|
+ if prob_sidx != -1:
|
|
|
+ prob_rows = _parse_bacteria_table(reader, None, full_text, prob_marker, fmt, skip_header=True)
|
|
|
+ else:
|
|
|
+ prob_rows = _parse_bacteria_table(reader, None, full_text, '益生菌', fmt, skip_header=True)
|
|
|
+ all_tables['益生菌'] = [r for r in prob_rows if r.get('名称') and r['名称'] not in
|
|
|
+ ['我的益生菌都为ND', '仅列出丰度前22的益生菌种。']]
|
|
|
+
|
|
|
+ # 有害菌属构成表1-2
|
|
|
+ harmful = []
|
|
|
+ for i in range(1, 3):
|
|
|
+ title = f'有害菌属构成表{i}'
|
|
|
+ rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
|
|
|
+ harmful.extend(rows)
|
|
|
+ all_tables['有害菌属'] = harmful
|
|
|
+
|
|
|
+ # 其它重要菌属
|
|
|
+ other_rows = _parse_bacteria_table(reader, None, full_text, '其它重要菌属构成表', fmt)
|
|
|
+ all_tables['其它重要菌属'] = other_rows
|
|
|
+
|
|
|
+ # 病原菌属构成表
|
|
|
+ patho_genus = _parse_bacteria_table(reader, None, full_text, '病原菌属构成表', fmt)
|
|
|
+ all_tables['病原菌属'] = patho_genus
|
|
|
+
|
|
|
+ # 病原菌(检出列表)
|
|
|
+ patho_rows = _parse_bacteria_table(reader, None, full_text, '病原菌', fmt, skip_header=True)
|
|
|
+ all_tables['病原菌检出'] = [r for r in patho_rows if r.get('名称') and len(r['名称']) >= 2
|
|
|
+ and '仅列出' not in r['名称'] and '说明' not in r['名称']]
|
|
|
+
|
|
|
+ # 菌门构成表(phylum level)
|
|
|
+ phylum_rows = _parse_phylum_tables(reader, full_text, fmt)
|
|
|
+ if phylum_rows:
|
|
|
+ all_tables['菌门构成'] = phylum_rows
|
|
|
+
|
|
|
+ # 菌纲构成表
|
|
|
+ class_rows = _parse_phylum_tables(reader, full_text, fmt, CLASS_TABLE_TITLES)
|
|
|
+ if class_rows:
|
|
|
+ all_tables['菌纲构成'] = class_rows
|
|
|
+
|
|
|
+ # 菌目构成表
|
|
|
+ order_rows = _parse_phylum_tables(reader, full_text, fmt, ORDER_TABLE_TITLES)
|
|
|
+ if order_rows:
|
|
|
+ all_tables['菌目构成'] = order_rows
|
|
|
+
|
|
|
+ # 菌科构成表
|
|
|
+ family_rows = _parse_phylum_tables(reader, full_text, fmt, FAMILY_TABLE_TITLES)
|
|
|
+ if family_rows:
|
|
|
+ all_tables['菌科构成'] = family_rows
|
|
|
+
|
|
|
+ # 菌属构成表
|
|
|
+ genus_rows = _parse_phylum_tables(reader, full_text, fmt, GENUS_TABLE_TITLES)
|
|
|
+ if genus_rows:
|
|
|
+ all_tables['菌属构成'] = genus_rows
|
|
|
+
|
|
|
+ # 菌种构成表
|
|
|
+ species_rows = _parse_phylum_tables(reader, full_text, fmt, SPECIES_TABLE_TITLES)
|
|
|
+ if species_rows:
|
|
|
+ all_tables['菌种构成'] = species_rows
|
|
|
+
|
|
|
+ # 菌群层级(纲目科属种)- 从"菌群检出详细列表"统一入口提取
|
|
|
+ taxonomy_rows = _parse_taxonomy_levels(reader, full_text, fmt)
|
|
|
+ for key, rows in taxonomy_rows.items():
|
|
|
+ if rows:
|
|
|
+ all_tables[key] = rows
|
|
|
+
|
|
|
+ return all_tables
|
|
|
+
|
|
|
+
|
|
|
+def split_7_fields(s):
|
|
|
+ """将压缩数字串切分为 7 个字段(移植自 extract_full_report_v5.py)"""
|
|
|
+ results = []
|
|
|
+ ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
|
|
|
+
|
|
|
+ def backtrack(pos, idx, nums):
|
|
|
+ if idx == 7:
|
|
|
+ if pos == len(s):
|
|
|
+ results.append(list(nums))
|
|
|
+ return
|
|
|
+ if pos >= len(s):
|
|
|
+ return
|
|
|
+ lo, hi = ranges[idx]
|
|
|
+ for w in range(lo, min(hi + 1, len(s) - pos + 1)):
|
|
|
+ chunk = s[pos:pos + w]
|
|
|
+ if chunk.isdigit():
|
|
|
+ backtrack(pos + w, idx + 1, nums + [int(chunk)])
|
|
|
+
|
|
|
+ backtrack(0, 0, [])
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def decode_compressed(name, num_str, ref_vals=None):
|
|
|
+ """解码压缩食物数字串(移植自 extract_full_report_v5.py)"""
|
|
|
+ raw = num_str.lstrip('-')
|
|
|
+ has_neg = num_str.startswith('-')
|
|
|
+ candidates = []
|
|
|
+ for rec_len in range(1, 3):
|
|
|
+ if rec_len > len(raw):
|
|
|
+ continue
|
|
|
+ rec = ('-' if has_neg else '') + raw[:rec_len]
|
|
|
+ try:
|
|
|
+ rec_val = int(rec)
|
|
|
+ if not (-100 <= rec_val <= 100):
|
|
|
+ continue
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ remain = raw[rec_len:]
|
|
|
+ for nums in split_7_fields(remain):
|
|
|
+ if ref_vals:
|
|
|
+ matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
|
|
|
+ if matches >= 6:
|
|
|
+ candidates.append([rec_val] + nums)
|
|
|
+ else:
|
|
|
+ candidates.append([rec_val] + nums)
|
|
|
+ if not candidates:
|
|
|
+ return None
|
|
|
+ if ref_vals:
|
|
|
+ candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
|
|
|
+ -len(str(abs(r[0])))), reverse=True)
|
|
|
+ if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
|
|
|
+ return None
|
|
|
+ else:
|
|
|
+ candidates.sort(key=lambda r: (len(str(abs(r[0]))),
|
|
|
+ -sum(1 for i in range(7) if r[1:][i] == 0)))
|
|
|
+ return candidates[0]
|
|
|
+
|
|
|
+
|
|
|
+def extract_food_table(pdf_path, ref_lookup=None):
|
|
|
+ """提取个体化食物推荐表(移植自 extract_full_report_v5.py)"""
|
|
|
+ reader = PdfReader(pdf_path)
|
|
|
+ food_start = None
|
|
|
+ for i, page in enumerate(reader.pages):
|
|
|
+ if '个体化食物推荐表' in page.extract_text():
|
|
|
+ food_start = i
|
|
|
+ break
|
|
|
+ if food_start is None:
|
|
|
+ return [], 'not_found'
|
|
|
+
|
|
|
+ first_text = norm(reader.pages[food_start + 1].extract_text())
|
|
|
+ lines = [l.strip() for l in first_text.split('\n')
|
|
|
+ if l.strip() and not re.match(r'\d+/\d+', l)]
|
|
|
+ # 判断压缩格式:任何一行超过100字符(单行密集格式),或前10行中超过3行长行
|
|
|
+ is_compressed = (len(lines) >= 1 and any(len(l) > 100 for l in lines[:10])) or \
|
|
|
+ sum(1 for l in lines[:10] if len(l) > 100) >= 2
|
|
|
+
|
|
|
+ rows = []
|
|
|
+ if is_compressed:
|
|
|
+ fmt = 'compressed'
|
|
|
+ for i in range(food_start + 1, len(reader.pages)):
|
|
|
+ text = norm(reader.pages[i].extract_text())
|
|
|
+ text = re.sub(r'\d+/\d+', '', text)
|
|
|
+ header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
|
|
|
+ text = text.replace(header, '')
|
|
|
+ for kw in FOOD_SKIP_TEXTS:
|
|
|
+ text = text.replace(kw, '')
|
|
|
+ while text:
|
|
|
+ best_cat, best_idx = None, len(text)
|
|
|
+ for cat in KNOWN_CATS:
|
|
|
+ idx = text.find(cat)
|
|
|
+ if idx != -1 and idx < best_idx:
|
|
|
+ best_idx, best_cat = idx, cat
|
|
|
+ if best_cat is None:
|
|
|
+ break
|
|
|
+ name = text[:best_idx]
|
|
|
+ text = text[best_idx + len(best_cat):]
|
|
|
+ num_str = ''
|
|
|
+ while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
|
|
|
+ c = '-' if text[0] in '\u2212\u2014' else text[0]
|
|
|
+ num_str += c
|
|
|
+ text = text[1:]
|
|
|
+ if not name or not num_str:
|
|
|
+ continue
|
|
|
+ ref_vals = ref_lookup.get(name) if ref_lookup else None
|
|
|
+ decoded = decode_compressed(name, num_str, ref_vals)
|
|
|
+ if decoded:
|
|
|
+ rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
|
|
|
+ else:
|
|
|
+ fmt = 'vertical'
|
|
|
+ all_lines = []
|
|
|
+ for i in range(food_start + 1, len(reader.pages)):
|
|
|
+ for line in norm(reader.pages[i].extract_text()).split('\n'):
|
|
|
+ lt = line.strip()
|
|
|
+ if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
|
|
|
+ continue
|
|
|
+ if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
|
|
|
+ continue
|
|
|
+ all_lines.append(lt)
|
|
|
+ i = 0
|
|
|
+ while i + 9 < len(all_lines):
|
|
|
+ name = all_lines[i].strip()
|
|
|
+ cat = all_lines[i + 1].strip()
|
|
|
+ if cat not in KNOWN_CATS:
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ nums = []
|
|
|
+ ok = True
|
|
|
+ for j in range(2, 10):
|
|
|
+ v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
|
|
|
+ try:
|
|
|
+ int(v)
|
|
|
+ nums.append(v)
|
|
|
+ except Exception:
|
|
|
+ ok = False
|
|
|
+ break
|
|
|
+ if ok and len(nums) == 8:
|
|
|
+ rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
|
|
|
+ i += 1
|
|
|
+ return rows, fmt
|
|
|
+
|
|
|
+
|
|
|
+def _extract_food_rows(pdf_path):
|
|
|
+ """食物推荐表解析:优先复用同目录其他报告的参考营养表做压缩格式解码"""
|
|
|
+ ref_nutrition = {}
|
|
|
+ base_dir = os.path.dirname(pdf_path) or '.'
|
|
|
+ for fname in sorted(os.listdir(base_dir)):
|
|
|
+ if fname.lower().endswith('.pdf') and fname != os.path.basename(pdf_path):
|
|
|
+ try:
|
|
|
+ tr, _ = extract_food_table(os.path.join(base_dir, fname))
|
|
|
+ if len(tr) > 100:
|
|
|
+ for r in tr:
|
|
|
+ ref_nutrition[r['名称']] = [int(r[k]) for k in
|
|
|
+ ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
|
|
|
+ '总膳食纤维g', '胆固醇mg']]
|
|
|
+ break
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ food_rows, food_fmt = extract_food_table(pdf_path, ref_nutrition or None)
|
|
|
+ return food_rows, food_fmt
|