| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957 |
- """
- 菌群报告 PDF 解析器
- 从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
- """
- import os
- import re
- from PyPDF2 import PdfReader
- # === 常量 ===
- # Kangxi 部首 → CJK 统一汉字(与 extract_full_report_v5.py 完全一致)
- RADICAL_MAP = {
- '\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': '氏',
- # 以下为本项目报告 PDF 中实际出现的部首字形(U+2F00-Kangxi / U+2E80-CJK Radicals)
- '\u2f00': '一', '\u2f04': '乙', '\u2f06': '二', '\u2f08': '人',
- '\u2f09': '儿', '\u2f0a': '入', '\u2f0f': '几', '\u2f12': '力',
- '\u2f17': '十', '\u2f1c': '又', '\u2f1d': '口', '\u2f20': '士',
- '\u2f25': '女', '\u2f2b': '尸', '\u2f2f': '工', '\u2f34': '广',
- '\u2f38': '弓', '\u2f3f': '手', '\u2f40': '支', '\u2f45': '方',
- '\u2f47': '日', '\u2f49': '月', '\u2f4c': '止', '\u2f51': '毛',
- '\u2f53': '气', '\u2f5a': '片', '\u2f5b': '牙', '\u2f5d': '犬',
- '\u2f70': '示', '\u2f74': '立', '\u2f79': '网', '\u2f7d': '而',
- '\u2f83': '自', '\u2f84': '至', '\u2f86': '舌', '\u2f8a': '色',
- '\u2f8d': '虫', '\u2f8e': '血', '\u2f8f': '行', '\u2f94': '言',
- '\u2f9b': '走', '\u2f9c': '足', '\u2f9f': '辛', '\u2fa5': '里',
- '\u2fae': '非', '\u2fb0': '革', '\u2fb3': '音', '\u2fb8': '首',
- '\u2fbc': '高', '\u2fcf': '鼠', '\u2fd0': '鼻',
- '\u2e9f': '母', '\u2ec5': '见', '\u2ec6': '角', '\u2ed3': '长',
- '\u2ed4': '门', '\u2ed8': '青', '\u2ed9': '韦', '\u2edb': '风',
- '\u2ee3': '骨', '\u2eec': '齐',
- }
- KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
- KNOWN_AMINO = ['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸', '胱氨酸',
- '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸', '甘氨酸',
- '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺', '谷氨酰胺',
- '精氨酸', '色氨酸']
- KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
- '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
- KNOWN_TRACE = ['铁', '锌']
- KNOWN_DISEASE_RISKS = ['炎症性肠炎', '肠易激综合征', '感染性腹泻', '自闭症',
- '抑郁症', '甲状腺疾病', '肺部感染或疾病', '自体免疫病', '结直肠癌',
- '肥胖', '便秘', '过敏', '失眠', '肝病', '肾病', '胃病', '胆病',
- '心脑血管疾病', 'II型糖尿病']
- KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
- '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺', '硫化氢', '尸胺']
- KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)', '异戊酸盐(Isovaleric)']
- KNOWN_NEURO = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)', '谷氨酸(Glutamate)',
- '色氨酸(Tryptophan)', 'DOPAC', '多巴胺', '组胺(Histamine)', '一氧化氮',
- '喹啉(Quinolinic)', '维生素K2', '肌醇(Inositol)', '肾上腺素',
- '去甲肾上腺素', '乙酰胆碱', '皮质醇']
- 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)
- def detect_format(pages_text):
- """检测 triplet / inline 格式(v5 detect_format 移植,页面级判定)
- 仅当同一页同时含'疾病风险评估'+'指标范围'且存在内嵌数字的行时才算 inline,
- 否则按 triplet 处理(标准模板报告为 triplet,报告类 PDF 为 inline)。
- """
- for pt in pages_text:
- t = norm(pt)
- if '疾病风险评估' in t and '指标范围' in t:
- for line in t.split('\n'):
- ls = line.strip()
- if not ls:
- continue
- if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', ls):
- return 'inline'
- return 'triplet'
- return 'triplet'
- def extract_text(file_path):
- """读取 PDF 并提取文本"""
- reader = PdfReader(file_path)
- lines = []
- for page in reader.pages:
- text = norm(page.extract_text() or '')
- for line in text.split('\n'):
- ls = line.strip()
- if ls:
- lines.append(ls)
- return lines
- def parse_overview(lines):
- """提取报告概述"""
- text = '\n'.join(lines)
- r = {}
- m = re.search(r'编号[::\s]*(\d+)', text)
- if m: r['report_number'] = m.group(1)
- m = re.search(r'姓名[::\s]*([\u4e00-\u9fff]{2,10})', text)
- if m: r['person_name'] = re.sub(r'(编号|年龄|性别|备注|肠道).*', '', m.group(1))[:4]
- m = re.search(r'年龄[::\s]*(\d+)', text)
- if m: r['age'] = int(m.group(1))
- m = re.search(r'性别[::\s]*([\u4e00-\u9fff])', text)
- if m: r['gender'] = 'male' if m.group(1) == '男' else 'female'
- for kw in ['健康总分', '菌群健康', '慢病控制', '营养均衡', '肠道菌群平衡',
- '菌群多样性', '有益菌', '有害菌', '核心菌属']:
- m = re.search(rf'{kw}\s*(\d+)', text)
- if m: r[kw] = int(m.group(1))
- m = re.search(r'肠道预测年龄[::\s]*([\d.]+)', text)
- if m: r['gut_age'] = m.group(1)
- m = re.search(r'肠型[::\s]*(\S+)', text)
- if m: r['gut_type'] = m.group(1)
- return r
- def parse_triplet_until(lines, stop_markers):
- """三元组解析:3行一组 名称/数值/状态"""
- results = []
- i = 0
- while i < len(lines):
- if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
- break
- if lines[i] in ('指标范围', '名称', '丰度', '评估'):
- i += 1
- continue
- name = lines[i]
- if i + 2 >= len(lines): break
- val = lines[i + 1]
- status = lines[i + 2]
- if re.match(r'^-?\d+\.?\d*$', val):
- results.append({'name': name, 'value': val, 'status': status})
- i += 3
- else:
- i += 1
- return results
- # ==========================================
- # 完整分区提取(移植自 extract_full_report_v5.py,修复分区错位)
- # 原问题:疾病风险 stop 标记缺'营养状况评估'导致吞并营养/氨基酸;
- # 维生素/微量元素标题在数据后(Case B)导致为空;屏障/SCFA/神经递质/病原菌/抗生素缺失
- # ==========================================
- def _extract_all_triplet(all_lines):
- """按'指标范围'数据页标记分段提取(v5 extract_all_triplet 移植)
- 数据页索引约定(v5 实证):
- data_pages[0] = 疾病风险页('疾病风险评估'标题前也有'指标范围')
- data_pages[1] = 营养状况评估页(含前5个氨基酸:苏氨酸/异亮氨酸/亮氨酸/赖氨酸/蛋氨酸)
- data_pages[2] = 剩余氨基酸页
- data_pages[3] = 维生素评估页(含微量元素)
- """
- data_pages = []
- for i, line in enumerate(all_lines):
- if line == '指标范围':
- data_pages.append(i)
- result = {}
- # 数据页1: 疾病风险评估(按已知疾病名过滤在调用方做)
- if len(data_pages) >= 1:
- i = data_pages[0] + 1
- if i < len(all_lines) and all_lines[i] == '疾病风险评估':
- i += 1
- rows = parse_triplet_until(all_lines[i:],
- ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估',
- '主要消化道致病菌', '抗生素风险评估', '抗生素耐药风险'])
- result['disease_risks'] = [r for r in rows if '注:' not in r['name'] and '注:' not in r['name']]
- # 数据页2: 营养状况评估(含前5个氨基酸)
- if len(data_pages) >= 2:
- i = data_pages[1] + 1
- if i < len(all_lines) and all_lines[i] == '营养状况评估':
- i += 1
- rows = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估'])
- result['nutrition_rows'] = rows[:5]
- result['amino_p2'] = rows[5:10] if len(rows) > 5 else []
- # 数据页3: 剩余氨基酸
- if len(data_pages) >= 3:
- i = data_pages[2] + 1
- if i < len(all_lines) and '氨基酸' in all_lines[i]:
- while i < len(all_lines) and '氨基酸' in all_lines[i]:
- i += 1
- remaining = parse_triplet_until(all_lines[i:], ['氨基酸评估'])
- result['amino_p3'] = remaining
- # 数据页4: 维生素评估 + 微量元素
- if len(data_pages) >= 4:
- i = data_pages[3] + 1
- if i < len(all_lines) and all_lines[i] == '维生素评估':
- i += 1
- vit_rows = parse_triplet_until(all_lines[i:], ['微量元素评估', '主要消化道致病菌'])
- vit, trace = [], []
- for r in vit_rows:
- if '维生素' in r['name']:
- vit.append(r)
- else:
- trace.append(r)
- result['vitamin_rows'] = vit
- result['trace_rows'] = trace
- # 主要消化道致病菌(特殊格式:致病菌/丰度%/评估)
- for i, line in enumerate(all_lines):
- if line == '主要消化道致病菌':
- j = i + 1
- while j < len(all_lines) and all_lines[j] in ('致病菌', '丰度', '评估'):
- j += 1
- path_rows = []
- while j < len(all_lines) and not all_lines[j].startswith('肠道屏障'):
- name = all_lines[j]
- if j + 2 < len(all_lines) and re.match(r'^\d+%$', all_lines[j+1]):
- path_rows.append({'name': name, 'value': all_lines[j+1], 'status': all_lines[j+2]})
- j += 3
- else:
- j += 1
- result['pathogens'] = path_rows
- break
- # 抗生素风险评估(去重:同名只保留第一条)
- for i, line in enumerate(all_lines):
- if line == '抗生素风险评估':
- abx_rows = parse_triplet_until(all_lines[i+1:], ['抗生素耐药风险', '个体化食物推荐表'])
- seen = set()
- deduped = []
- for r in abx_rows:
- n = r.get('name', '')
- if n and n not in seen:
- seen.add(n)
- deduped.append(r)
- result['antibiotics'] = deduped
- break
- return result
- def _extract_barrier_and_scfa_triplet(all_lines):
- """肠道屏障及代谢物/短链脂肪酸/神经递质及激素 - 已知指标名精确匹配(v5 移植)"""
- results = {'barrier': [], 'scfa': [], 'neurotransmitter': []}
- current_section = None
- i = 0
- while i < len(all_lines):
- line = all_lines[i]
- if line == '肠道屏障及菌群代谢物':
- current_section = 'barrier'
- i += 1
- continue
- if '短链脂肪酸' in line:
- current_section = 'scfa'
- i += 1
- continue
- if '神经递质' in line:
- current_section = 'neurotransmitter'
- i += 1
- continue
- if current_section is None:
- i += 1
- continue
- # 跳过头行
- if line in ('名称', '评估值', '正常范围', '过量', '缺乏', '相关症状') or \
- '过量 /' in line or '缺乏' in line:
- i += 1
- continue
- # 提前终止
- if current_section == 'barrier' and line in ('短链脂肪酸', '抗生素风险', '抗生素耐药风险'):
- break
- if current_section == 'scfa' and line in ('神经递质', '神经递质及激素指标', '神经递质及激素', '抗生素风险'):
- break
- if current_section == 'neurotransmitter' and line in ('抗生素风险', '个体化食物推荐表'):
- break
- # 只处理已知指标名
- known = {'barrier': KNOWN_BARRIER, 'scfa': KNOWN_SCFA, 'neurotransmitter': KNOWN_NEURO}
- known_list = known[current_section]
- matched_name = None
- for kn in sorted(known_list, key=len, reverse=True):
- if line.startswith(kn) or line == kn:
- matched_name = kn
- break
- if matched_name is None:
- i += 1
- continue
- # 模式1:整行解析 名称+数值+状态+范围(inline 单行)
- parts = line.split()
- if len(parts) >= 2:
- vi = None
- for pi, p in enumerate(parts):
- if re.match(r'^\d+$', p) and pi >= 1:
- vi = pi
- break
- if vi:
- val = parts[vi]
- status = ''
- range_ = ''
- for rp in parts[vi+1:]:
- if rp in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
- status = rp
- elif re.match(r'^\d+-\d+$', rp):
- range_ = rp
- results[current_section].append({
- 'name': matched_name, 'value': val, 'status': status, 'refRange': range_
- })
- i += 1
- continue
- # 模式2:下一行是数值(三元组)
- if i + 1 < len(all_lines) and re.match(r'^\d+$', all_lines[i+1]):
- val = all_lines[i+1]
- status = ''
- range_ = ''
- for j in range(2, min(6, len(all_lines)-i)):
- if all_lines[i+j] in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
- status = all_lines[i+j]
- elif re.match(r'^\d+-\d+$', all_lines[i+j]):
- range_ = all_lines[i+j]
- results[current_section].append({
- 'name': matched_name, 'value': val, 'status': status, 'refRange': range_
- })
- i += 2
- continue
- i += 1
- # 去重(每个 section 内每个指标名只保留一条)
- for section in ('barrier', 'scfa', 'neurotransmitter'):
- seen = set()
- deduped = []
- for item in results[section]:
- n = item.get('name', '')
- if n and n not in seen:
- seen.add(n)
- deduped.append(item)
- results[section] = deduped
- return results
- def _extract_names_from_region(region_str, known_names, end_pos):
- """从区域文本中提取已知名称+数值+状态(v5 移植)"""
- for h in ['肠道屏障及菌群代谢物名称评估值正常范围过量 / 缺乏相关症状',
- '名称评估值正常范围过量 / 缺乏相关症状',
- '短链脂肪酸评估值正常范围过量 / 缺乏相关症状',
- '神经递质及激素评估值正常范围过量 / 缺乏相关症状',
- '过量 / 缺乏相关症状']:
- region_str = region_str.replace(h, '')
- positions = []
- for kn in known_names:
- # 找所有匹配位置,选第一个后面有数字(允许空格)且不是头部标记的
- search_start = 0
- while True:
- ii = region_str.find(kn, search_start)
- if ii == -1:
- break
- after = region_str[ii+len(kn):ii+len(kn)+10].lstrip()
- if re.match(r'\d', after):
- positions.append((ii, kn))
- break # 找到第一个有效匹配
- search_start = ii + 1
- if not positions:
- return []
- # 去重:同名先后取前面(较精准的),不同名重叠取较长的
- positions.sort()
- filtered = []
- for i, (pos, name) in enumerate(positions):
- has_longer = False
- for j, (pos2, name2) in enumerate(positions):
- if i != j and pos2 <= pos and pos + len(name) <= pos2 + len(name2) and len(name2) > len(name):
- has_longer = True
- break
- if not has_longer:
- filtered.append((pos, name))
- positions = filtered
- seen_names = {}
- for pos, name in positions:
- if name not in seen_names:
- seen_names[name] = pos
- positions = [(pos, name) for name, pos in seen_names.items()]
- positions.sort()
- local = []
- for i, (ii, kn) in enumerate(positions):
- start_after = ii + len(kn)
- after_text = region_str[start_after:].lstrip()
- vm = re.match(r'(\d+(?:\.\d+)?)', after_text)
- if not vm:
- continue
- val = vm.group(1)
- whitespace_skipped = len(region_str[start_after:]) - len(after_text)
- val_start = start_after + whitespace_skipped
- val_end = val_start + len(val)
- if i + 1 < len(positions):
- next_idx = positions[i + 1][0]
- status = region_str[val_end:next_idx].strip()
- else:
- status = region_str[val_end:end_pos].strip()
- # 清理状态中的残留标记
- status = re.sub(
- r'注意大环内酯类|注意呋喃类|注意氯霉素类|注意喹诺酮类|注意磺胺类|'
- r'注意甲氧苄啶类|注意四环素类|正常氨基酸评估|正常维生素评估|'
- r'正常微量元素评估|指标范围|疾病风险评估|营养状况评估|'
- r'抗生素风险评估|抗生素耐药风险|主要消化道致病菌',
- '', status).strip()
- # 截断:状态中出现'注'(如'注:本检测仅用于...')只取之前部分
- status = re.split(r'注[::]', status)[0].strip()
- if len(status) > 20:
- status = status[:20]
- local.append({'name': kn, 'value': val, 'status': status})
- return local
- def _extract_inline_module(all_text, start_marker, end_markers, known_names):
- """从 inline 文本中提取已知名称的指标(v5 移植)
- 支持两种布局:
- A) 标题在前、数据在后(标准)
- B) 标题在后、数据在前('指标范围'与标题之间)
- """
- sm_idx = all_text.find(start_marker)
- if sm_idx == -1:
- return []
- end_pos = len(all_text)
- for em in end_markers:
- ei = all_text.find(em, sm_idx + len(start_marker))
- if ei != -1 and ei < end_pos:
- end_pos = ei
- # Case A: 标题在前,数据在标题和 end_marker 之间
- region_a = all_text[sm_idx:end_pos]
- result_a = _extract_names_from_region(region_a, known_names, len(region_a))
- # Case B: 标题在后,数据在'指标范围'和标题之间
- zb_idx = all_text.rfind('指标范围', 0, sm_idx)
- if zb_idx == -1:
- zb_idx = max(0, sm_idx - 500)
- region_b = all_text[zb_idx:sm_idx]
- result_b = _extract_names_from_region(region_b, known_names, len(region_b))
- # 取匹配多的那个
- if len(result_a) >= len(result_b):
- return result_a
- return result_b
- def _extract_pathogens_inline(text):
- """提取 inline 格式中主要消化道致病菌数据(幽门螺杆菌0%未检出...)"""
- results = []
- sidx = text.find('主要消化道致病菌')
- if sidx == -1:
- return results
- eidx = text.find('肠道屏障', sidx)
- if eidx == -1:
- eidx = len(text)
- region = text[sidx:eidx]
- for name in KNOWN_PATHOGENS:
- idx = region.find(name)
- if idx == -1:
- continue
- after = region[idx + len(name):]
- m = re.match(r'\s*(\d+%)', after)
- if not m:
- continue
- abundance = m.group(1)
- assessment_start = idx + len(name) + m.end()
- assessment_end = len(region)
- for next_name in KNOWN_PATHOGENS:
- if next_name == name:
- continue
- ni = region.find(next_name, assessment_start)
- if ni != -1 and ni < assessment_end:
- assessment_end = ni
- assessment = region[assessment_start:assessment_end].strip()
- results.append({'name': name, 'value': abundance, 'status': assessment})
- return results
- def _final_fallback_scan(full_text, result, section_known_map):
- """最终的全文正则扫描——弥补所有提取器漏掉的已知指标项(v5 移植)"""
- for section, known_list in section_known_map.items():
- existing = {r['name'] for r in result.get(section, [])}
- for name in known_list:
- if name in existing:
- continue
- # 搜索多个变体:完整名、去掉括号的后缀
- search_names = [name]
- paren = name.find('(')
- if paren != -1:
- search_names.append(name[:paren])
- found = False
- for sname in search_names:
- idx = full_text.find(sname)
- if idx == -1:
- continue
- # 跳过名称之后紧跟着的重复名称(正字+部首两次出现的情况)
- skip = idx + len(sname)
- while skip < len(full_text):
- ch = full_text[skip]
- if ch == '\n' or ch == ' ':
- skip += 1
- elif '\u4e00' <= ch <= '\u9fff' or '\u2f00' <= ch <= '\u2fdf':
- skip += 1
- else:
- break
- after = full_text[skip:].lstrip()
- m = re.match(r'(\d+(?:\.\d+)?)', after)
- if not m:
- continue
- val = m.group(1)
- val_end_in_after = m.end()
- status_raw = after[val_end_in_after:].lstrip()
- status_m = re.search(r'([\u4e00-\u9fff/]+)', status_raw)
- status = status_m.group(1).strip() if status_m else status_raw[:20]
- if len(status) > 20:
- status = status[:20]
- result.setdefault(section, []).append({'name': name, 'value': val, 'status': status})
- found = True
- break
- return result
- def parse_report_pdf(file_path: str) -> dict:
- """主函数:解析 PDF 返回结构化数据(v5 完整提取逻辑移植)"""
- reader = PdfReader(file_path)
- pages_text = [p.extract_text() or '' for p in reader.pages]
- lines = extract_text(file_path)
- full_text = '\n'.join(lines)
- # 指纹检测:北京菌群报告
- if detect_beijing_format(full_text):
- return _parse_beijing_report(file_path)
- fmt = detect_format(pages_text)
- result = {'format': fmt, 'overview': parse_overview(lines)}
- _SECTION_KNOWN_MAP = {
- 'disease_risks': KNOWN_DISEASE_RISKS,
- 'nutrition': KNOWN_MACRO,
- 'amino_acids': KNOWN_AMINO,
- 'vitamins': KNOWN_VITAMINS,
- 'trace_elements': KNOWN_TRACE,
- '抗生素风险评估': KNOWN_ANTIBIOTICS,
- '肠道屏障及代谢物': KNOWN_BARRIER,
- '短链脂肪酸': KNOWN_SCFA,
- '神经递质及激素': KNOWN_NEURO,
- }
- if fmt == 'triplet':
- parsed = _extract_all_triplet(lines)
- # 疾病风险评估(已知疾病名词表过滤)
- disease_set = set(KNOWN_DISEASE_RISKS)
- result['disease_risks'] = [r for r in parsed.get('disease_risks', []) if r['name'] in disease_set]
- # 兜底:inline 方式补充 triplet 漏掉的项目
- for r in _extract_inline_module(full_text, '疾病风险评估',
- ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS):
- if r['name'] not in {x['name'] for x in result['disease_risks']}:
- result['disease_risks'].append(r)
- # 主要营养评估
- nutri_set = set(KNOWN_MACRO)
- result['nutrition'] = [r for r in parsed.get('nutrition_rows', []) if r['name'] in nutri_set]
- for r in _extract_inline_module(full_text, '营养状况评估',
- ['主要营养评估', '氨基酸评估'], KNOWN_MACRO):
- if r['name'] not in {x['name'] for x in result['nutrition']}:
- result['nutrition'].append(r)
- # 氨基酸评估(营养页内嵌 p2 + 独立页 p3 合并 + inline 兜底)
- amino_rows = parsed.get('amino_p2', []) + parsed.get('amino_p3', [])
- amino_set = set(KNOWN_AMINO)
- result['amino_acids'] = [r for r in amino_rows if r['name'] in amino_set]
- for r in _extract_inline_module(full_text, '氨基酸评估',
- ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO):
- if r['name'] not in {x['name'] for x in result['amino_acids']}:
- result['amino_acids'].append(r)
- # 维生素评估 / 微量元素评估
- vit_set = set(KNOWN_VITAMINS)
- trace_set = set(KNOWN_TRACE)
- result['vitamins'] = [r for r in parsed.get('vitamin_rows', []) if r['name'] in vit_set]
- result['trace_elements'] = [r for r in parsed.get('trace_rows', []) if r['name'] in trace_set]
- for r in _extract_inline_module(full_text, '维生素评估',
- ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS):
- if r['name'] not in {x['name'] for x in result['vitamins']}:
- result['vitamins'].append(r)
- for r in _extract_inline_module(full_text, '微量元素评估',
- ['主要消化道致病菌'], KNOWN_TRACE):
- if r['name'] not in {x['name'] for x in result['trace_elements']}:
- result['trace_elements'].append(r)
- # 主要消化道致病菌
- result['主要消化道致病菌'] = parsed.get('pathogens', [])
- # 抗生素风险评估(已知抗生素词表过滤)
- abx_set = set(KNOWN_ANTIBIOTICS)
- result['抗生素风险评估'] = [r for r in parsed.get('antibiotics', []) if r['name'] in abx_set]
- # 肠道屏障 + 短链脂肪酸 + 神经递质(已知指标名精确匹配)
- bs = _extract_barrier_and_scfa_triplet(lines)
- result['肠道屏障及代谢物'] = bs['barrier']
- result['短链脂肪酸'] = bs['scfa']
- result['神经递质及激素'] = bs['neurotransmitter']
- # inline 兜底补充
- for section_key, start_mk, end_mks, known_list in [
- ('肠道屏障及代谢物', '肠道屏障及菌群代谢物',
- ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER),
- ('短链脂肪酸', '短链脂肪酸',
- ['神经递质', '抗生素风险'], KNOWN_SCFA),
- ('神经递质及激素', '神经递质',
- ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO),
- ]:
- existing = {r['name'] for r in result.get(section_key, [])}
- for r in _extract_inline_module(full_text, start_mk, end_mks, known_list):
- if r['name'] not in existing:
- result.setdefault(section_key, []).append(r)
- existing.add(r['name'])
- # 最终兜底:全文正则扫描所有已知指标名
- _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
- else:
- # inline 格式(v5 extract_pdf_to_json inline 分支移植)
- result['disease_risks'] = _extract_inline_module(full_text, '疾病风险评估',
- ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS)
- result['nutrition'] = _extract_inline_module(full_text, '营养状况评估',
- ['主要营养评估', '氨基酸评估'], KNOWN_MACRO)
- result['amino_acids'] = _extract_inline_module(full_text, '营养状况评估',
- ['主要营养评估', '氨基酸评估'], KNOWN_AMINO)
- amino_p2 = _extract_inline_module(full_text, '氨基酸评估',
- ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO)
- for a in amino_p2:
- if a['name'] not in {x['name'] for x in result['amino_acids']}:
- result['amino_acids'].append(a)
- result['vitamins'] = _extract_inline_module(full_text, '维生素评估',
- ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS)
- result['trace_elements'] = _extract_inline_module(full_text, '微量元素评估',
- ['主要消化道致病菌'], KNOWN_TRACE)
- result['主要消化道致病菌'] = _extract_pathogens_inline(full_text)
- result['抗生素风险评估'] = _extract_inline_module(full_text, '抗生素风险评估',
- ['抗生素耐药风险', '个体化食物推荐表'], KNOWN_ANTIBIOTICS)
- result['肠道屏障及代谢物'] = _extract_inline_module(full_text, '肠道屏障及菌群代谢物',
- ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER)
- result['短链脂肪酸'] = _extract_inline_module(full_text, '短链脂肪酸',
- ['神经递质', '抗生素风险'], KNOWN_SCFA)
- result['神经递质及激素'] = _extract_inline_module(full_text, '神经递质',
- ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO)
- _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
- # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
- 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
- }
- # 统一结构:北京报告特有字段(标准报告为空)
- result['肠道微生物健康指数'] = {}
- result['菌群多样性'] = {}
- result['肠道黏膜屏障'] = {}
- result['菌群表型评估'] = {}
- result['短链脂肪酸合成能力'] = {}
- result['肠道菌群精准分布'] = []
- result['营养物质及营养素代谢评估'] = {}
- result.setdefault('抗生素风险评估', {})
- result['毒性物质清除能力评估'] = {}
- result['趣味肠菌评估'] = {}
- result['胃肠道感染病原体'] = []
- result['健康整体评估'] = {}
- result['肠道菌群主要检测结果'] = {}
- return result
- def parse_report_pdf_with_fallback(file_path: str) -> dict:
- """算法解析 + 简单校验,返回结构化数据"""
- result = parse_report_pdf(file_path)
- # 简单校验:如果关键字段缺失,标记为解析不完整
- if not result.get('overview', {}).get('overallScore') and \
- 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
- # ============================================================
- # 北京菌群报告 — 指纹检测
- # ============================================================
- FINGERPRINTS_BEIJING = [
- "肠道微生物健康指数",
- "GMHI",
- "肠道菌群主要检测结果",
- "核心菌属综合评分",
- "有益菌综合评分",
- "有害菌综合评分",
- "肠道菌群精准分布",
- "营养物质及营养素代谢评估",
- "趣味肠菌评估",
- "高通量测序",
- ]
- def detect_beijing_format(text):
- """检测是否为北京菌群报告格式"""
- beijing_matches = sum(1 for f in FINGERPRINTS_BEIJING if f in text)
- if beijing_matches >= 3:
- return True
- return False
- # ============================================================
- # 北京菌群报告 — 章节提取(PyPDF2 版)
- # ============================================================
- # 疾病风险关键词(北京报告特有)
- DISEASE_KEYWORDS_BJ = [
- "溃疡性结肠炎", "克罗恩病", "腹泻型肠易激综合征",
- "硬化性胆管炎", "非酒精性脂肪性肝病", "肝硬化",
- "便秘", "IgA肾病", "肾结石", "格雷夫斯病",
- "慢性淋巴细胞性甲状腺炎", "强直性脊柱炎",
- "精神分裂症", "痛经",
- "结直肠癌", "胃癌", "甲状腺癌", "肺癌", "乳腺癌",
- "高血压", "冠心病", "2型糖尿病", "高脂血症",
- "食管癌", "胆结石", "哮喘", "心脏病",
- "膜性肾病", "帕金森病", "类风湿关节炎",
- ]
- # 抗生素分类(北京报告格式)
- ANTIBIOTIC_CATEGORIES_BJ = [
- "β内酰胺类", "氨基糖苷类", "大环内酯类", "四环素类",
- "氯霉素类", "磺酰胺类", "喹诺酮类", "万古霉素类",
- "头孢菌素类", "磷霉素类", "甲氧苄氨嘧啶类",
- "N-乙酰基转移酶类", "O-磷酸转移酶类", "16S甲基转移酶",
- ]
- # 核心菌属 22 种(北京报告)
- CORE_GENERA_BJ = [
- ("阿克曼菌属", "Akkermansia"), ("另枝菌属", "Alistipes"),
- ("拟杆菌属", "Bacteroides"), ("双歧杆菌属", "Bifidobacterium"),
- ("布劳特氏菌属", "Blautia"), ("梭菌属", "Clostridium"),
- ("粪球菌属", "Coprococcus"), ("戴阿利斯特杆菌属", "Dialister"),
- ("多尔氏菌属", "Dorea"), ("真杆菌属", "Eubacterium"),
- ("粪杆菌属", "Faecalibacterium"), ("Lachnoclostridium属", "Lachnoclostridium"),
- ("毛螺菌属", "Lachnospira"), ("乳杆菌属", "Lactobacillus"),
- ("巨单胞菌属", "Megamonas"), ("颤螺旋菌属", "Oscillospira"),
- ("副拟杆菌属", "Parabacteroides"), ("考拉杆菌属", "Phascolarctobacterium"),
- ("普雷沃氏菌属", "Prevotella"), ("罗氏菌属", "Roseburia"),
- ("瘤胃球菌属", "Ruminococcus"), ("萨特氏菌属", "Sutterella"),
- ]
- # 病原体列表(北京报告)
- PATHOGEN_LIST_BJ = [
- "气单胞菌属", "蜡样芽孢杆菌", "唐菖蒲伯克霍尔德氏菌",
- "弯曲杆菌属", "艰难梭菌", "肉毒梭菌", "产气荚膜梭菌",
- "幽门螺杆菌", "肺炎克雷伯菌", "类志贺邻单胞菌",
- "沙门氏菌属", "志贺氏菌属", "金黄色葡萄球菌",
- "肺炎链球菌", "霍乱弧菌", "拟态弧菌", "副溶血弧菌",
- "小肠结肠炎耶尔森氏菌", "假结核耶尔森氏菌",
- ]
- def _find_content_after_toc(text):
- """找到北京报告中 TOC 之后的内容起始位置"""
- # 找"第一部分 健康整体评估"后的内容,或者"健康整体评估"正文
- markers = ['第一部分 健康整体评估', '菌群得分总览', '您属于肠']
- for m in markers:
- idx = text.find(m)
- if idx != -1:
- return idx
- return 0
- def _find_last_section(text, section_title):
- """找到章节标题的最后一次出现(实际内容,非目录)"""
- last = -1
- start = 0
- while True:
- idx = text.find(section_title, start)
- if idx == -1:
- break
- last = idx
- start = idx + 1
- return last
- def _parse_bj_gmhi(text):
- """提取 GMHI 肠道微生物健康指数(从内容区提取)"""
- gmhi = {}
- # 找最后一次出现的 GMHI 值(内容区,非目录)
- content_start = _find_content_after_toc(text)
- content = text[content_start:]
- m = re.search(r'GMHI[)\)]?[为是](\d+)', content)
- if m: gmhi['GMHI'] = float(m.group(1))
- m = re.search(r'肠道微生物健康指数[为](\d+)', content)
- if m: gmhi['肠道微生物健康指数'] = float(m.group(1))
- m = re.search(r'肠道菌群状态[为::\s]*(\S+?)[。,\n]', content)
- if m: gmhi['肠道菌群状态'] = m.group(1)
- # 健康状态推断
- if gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI'):
- score = gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI', 0)
- if score >= 90: gmhi['健康状态'] = '健康'
- elif score >= 80: gmhi['健康状态'] = '健康倾向'
- elif score >= 40: gmhi['健康状态'] = '亚健康'
- elif score >= 20: gmhi['健康状态'] = '不健康倾向'
- else: gmhi['健康状态'] = '不健康'
- return gmhi
- def _parse_bj_gut_type(text):
- """提取肠型"""
- content_start = _find_content_after_toc(text)
- content = text[content_start:]
- m = re.search(r'属于\s*肠[型道]\s*([IVX]+)', content)
- if m: return f'肠型{m.group(1)}'
- m = re.search(r'属于\s*肠[型道]\s*(\S+?)[,。,.\n]', content)
- if m: return m.group(1).strip()
- return None
- def _parse_bj_balance(text):
- """提取肠道菌群平衡评估"""
- m = re.search(r'(I{1,3}度失衡)', text)
- if m: return m.group(1)
- return None
- def _parse_bj_barrier(text):
- """提取肠道黏膜屏障/BE比值"""
- barrier = {}
- # 在内容区找表格
- content_start = _find_content_after_toc(text)
- content = text[content_start:]
- # 找 BE比值 表格行
- m = re.search(r'BE比[值例][::\s]*(\d+\.?\d*)', text)
- if m: barrier['BE比值'] = float(m.group(1))
- m = re.search(r'双歧杆菌[属菌][::\s]*(\d+\.?\d*)', text)
- if m: barrier['双歧杆菌属'] = float(m.group(1))
- m = re.search(r'肠杆菌[科属][::\s]*(\d+\.?\d*)', text)
- if m: barrier['肠杆菌科'] = float(m.group(1))
- # 综合评估在"BE比值"附近找
- be_idx = content.find('BE比')
- if be_idx != -1:
- chunk = content[be_idx:be_idx + 300]
- m = re.search(r'(正常|轻度损伤|中度损伤|重度损伤|损伤)', chunk)
- if m: barrier['综合评估'] = m.group(1)
- return barrier
- def _parse_bj_diversity(text):
- """提取菌群多样性"""
- div = {}
- content_start = _find_content_after_toc(text)
- content = text[content_start:]
- m = re.search(r'多样性指数[为](\d+\.?\d*)', content)
- if m: div['Shannon指数'] = float(m.group(1))
- m = re.search(r'高于[::\s]*(\d+)%', content)
- if m: div['人群百分位'] = f'高于{m.group(1)}%'
- m = re.search(r'菌种总数[::\s]*(\d+)', content)
- if m: div['菌种总数'] = int(m.group(1))
- return div
- def _parse_bj_phenotype(text):
- """提取菌群表型评估"""
- pheno = {}
- # 找表型评估表格区域
- last_idx = _find_last_section(text, '菌群表型评估')
- if last_idx == -1:
- last_idx = _find_last_section(text, '肠道菌群表型评估')
- if last_idx == -1:
- return pheno
- # 取表格区域
- region = text[last_idx:last_idx + 1500]
- # 解析表格行: 指标名 评估值 结果
- for label, kw in [('革兰氏阳性菌', '阳性菌'), ('革兰氏阴性菌', '阴性菌'),
- ('生物膜合成', '生物膜'), ('好氧菌', '好氧'),
- ('厌氧菌', '厌氧'), ('兼性厌氧菌', '兼性'),
- ('氧化胁迫耐受', '氧化'), ('致病潜力', '致病')]:
- # 找该行: 值在指标名附近
- idx = region.find(kw)
- if idx == -1:
- continue
- chunk = region[idx:idx + 100]
- val_m = re.search(r'(\d+\.?\d*)', chunk)
- if val_m:
- entry = {'评估值': float(val_m.group(1))}
- for status in ['正常', '异常']:
- if status in chunk:
- entry['结果'] = status
- break
- pheno[label] = entry
- return pheno
- def _parse_bj_scfa(text):
- """提取短链脂肪酸合成能力"""
- scfa = {}
- # 找 SCFA 表格区域(最后一次出现)
- last_idx = _find_last_section(text, '短链脂肪酸合成能力')
- if last_idx == -1:
- last_idx = _find_last_section(text, '短链脂肪酸')
- if last_idx == -1:
- return scfa
- region = text[last_idx:last_idx + 800]
- for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
- m = re.search(rf'{acid}[^a-zA-Z]*?(\d+)', region)
- if m:
- scfa[acid] = int(m.group(1))
- return scfa
- def _parse_bj_core_bacteria(text):
- """提取核心菌属精准分布"""
- bacteria = []
- for cn_name, lat_name in CORE_GENERA_BJ:
- idx = text.find(lat_name)
- if idx == -1: idx = text.find(cn_name)
- if idx == -1: continue
- chunk = text[max(0, idx - 50):idx + 200]
- val_m = re.search(r'(\d+\.?\d*)', chunk)
- status_m = re.search(r'(缺失|严重超标|超标|偏低|正常)', chunk)
- pct_m = re.search(r'(\d+\.?\d*)%', chunk)
- range_m = re.search(r'([\d.]+-[\d.]+)', chunk)
- entry = {
- '拉丁名': lat_name,
- '中文名': cn_name,
- '检测结果': float(val_m.group(1)) if val_m else None,
- '状态': status_m.group(1) if status_m else '未知',
- '人群百分位': float(pct_m.group(1)) if pct_m else None,
- '参考范围': range_m.group(1) if range_m else None,
- }
- bacteria.append(entry)
- return bacteria
- def _parse_bj_disease_risks(text):
- """提取疾病风险评估"""
- diseases = []
- for keyword in DISEASE_KEYWORDS_BJ:
- idx = text.find(keyword)
- if idx == -1: continue
- chunk = text[idx:idx + 200]
- val_m = re.search(r'(\d+\.\d+)', chunk)
- level_m = re.search(r'(低风险|较低风险|中度风险|较高风险|高风险)', chunk)
- if val_m:
- diseases.append({
- '疾病': keyword,
- '风险指数': float(val_m.group(1)),
- '风险等级': level_m.group(1) if level_m else '',
- })
- return diseases
- def _parse_bj_nutrient_metabolism(text):
- """提取营养物质及营养素代谢评估"""
- metabolism = {}
- # 主要营养物质
- for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
- m = re.search(rf'{n}[^估]*?(\d+)', text)
- if m:
- metabolism.setdefault('主要营养物质代谢', {})[n] = int(m.group(1))
- # 糖类
- for item in ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
- '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']:
- m = re.search(rf'{item[:4]}.*?(\d+)', text)
- if m:
- metabolism.setdefault('糖类代谢', {})[item] = int(m.group(1))
- # 脂类
- for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
- '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
- 'Omega-3', 'Omega-6']:
- m = re.search(rf'{item}.*?(\d+)', text)
- if m:
- metabolism.setdefault('脂类代谢', {})[item] = int(m.group(1))
- # 维生素
- for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
- '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
- '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
- m = re.search(rf'{item}.*?(\d+)', text)
- if m:
- metabolism.setdefault('维生素', {})[item] = int(m.group(1))
- # 微量元素
- for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
- m = re.search(rf'{item}[^估]*?(\d+)', text)
- if m:
- metabolism.setdefault('微量元素', {})[item] = int(m.group(1))
- # 氨基酸
- for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
- '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
- '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
- '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
- m = re.search(rf'{item}[^估]*?(\d+)', text)
- if m:
- metabolism.setdefault('氨基酸', {})[item] = int(m.group(1))
- # 神经递质
- for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
- m = re.search(rf'{item}.*?(\d+)', text)
- if m:
- metabolism.setdefault('神经递质', {})[item] = int(m.group(1))
- # 其他单项
- for pattern, key in [
- (r'嘌呤代谢.*?(\d+)', '嘌呤代谢'),
- (r'谷胱甘肽.*?(\d+)', '三肽(谷胱甘肽)'),
- (r'胆汁酸.*?(\d+)', '胆汁酸代谢'),
- (r'硫辛酸.*?(\d+)', '抗自由基(硫辛酸)'),
- (r'辅酶Q.*?(\d+)', '抗自由基(辅酶Q)'),
- ]:
- m = re.search(pattern, text)
- if m:
- metabolism[key] = int(m.group(1))
- return metabolism
- def _parse_bj_antibiotics(text):
- """提取抗生素风险评估"""
- ab = {}
- for cat in ANTIBIOTIC_CATEGORIES_BJ:
- m = re.search(rf'{cat}[::\s]*(\d+)', text)
- if m:
- ab[cat] = int(m.group(1))
- return ab
- def _parse_bj_toxins(text):
- """提取毒性物质清除能力评估"""
- toxins = {}
- toxin_items = [
- "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
- "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
- "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
- "阿特拉津", "己内酰胺", "双酚", "二恶英",
- "萘", "多环芳烃",
- ]
- for item in toxin_items:
- m = re.search(rf'{item}.*?(\d+)', text)
- if m:
- toxins[item] = int(m.group(1))
- return toxins
- def _parse_bj_pathogens(text):
- """提取胃肠道感染病原体评估"""
- pathogens = []
- for p in PATHOGEN_LIST_BJ:
- idx = text.find(p)
- if idx == -1: continue
- chunk = text[idx:idx + 150]
- val_m = re.search(r'(\d+\.?\d*)', chunk)
- status_m = re.search(r'(检出|超标|未检出|正常)', chunk)
- if val_m:
- entry = {'名称': p, '检测值': float(val_m.group(1)) if '.' in val_m.group(1) else int(val_m.group(1))}
- if status_m: entry['状态'] = status_m.group(1)
- pathogens.append(entry)
- return pathogens
- def _parse_bj_psych_eval(text, find_table_val=None):
- """提取趣味肠菌评估"""
- psych = {}
- m = re.search(r'最高概率[::\s]*(\S+)', text)
- if m: psych['人格特征'] = {'最高概率': m.group(1)}
- # 认知功能(从表格)
- if find_table_val:
- cog = find_table_val('认知能力', val_col=1)
- if cog:
- psych['认知功能'] = {'认知能力评分': int(cog)}
- return psych
- def _parse_bj_scfa_from_tables(find_table_rows):
- """从表格提取短链脂肪酸合成能力"""
- scfa = {}
- for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
- rows = find_table_rows(acid)
- for name, val in rows:
- if acid in name:
- scfa[acid] = int(val)
- break
- return scfa
- def _parse_bj_core_bacteria_from_tables(all_tables):
- """从表格提取核心菌属精准分布"""
- bacteria = []
- for cn_name, lat_name in CORE_GENERA_BJ:
- for table in all_tables:
- for row in table:
- row_str = ' '.join(str(c) for c in row if c)
- if lat_name in row_str or cn_name in row_str:
- vals = [str(c).strip() for c in row if c and str(c).strip()]
- entry = {'拉丁名': lat_name, '中文名': cn_name}
- for v in vals:
- v_clean = v.replace('"', '').replace('#', '').strip()
- # 判断是否为范围值(如 0.0041-0.0339)
- if '-' in v_clean and v_clean.replace('.','').replace('-',' ').replace(' ','').isdigit():
- entry['参考范围'] = v_clean
- elif v_clean == 'ND':
- if '检测结果' not in entry:
- entry['检测结果'] = 0.0
- elif v_clean.replace('.','').replace('-','').isdigit() and v_clean != '':
- val = float(v_clean)
- if '检测结果' not in entry:
- entry['检测结果'] = val
- elif '人群百分位' not in entry:
- entry['人群百分位'] = val
- if '检测结果' in entry:
- bacteria.append(entry)
- break
- if bacteria and bacteria[-1].get('拉丁名') == lat_name:
- break
- return bacteria
- def _parse_bj_nutrient_from_tables(find_table_rows, find_table_val):
- """从表格提取营养物质及营养素代谢评估"""
- metabolism = {}
- # 主要营养物质
- for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
- rows = find_table_rows(n)
- if rows:
- for name, val in rows:
- if n in name:
- metabolism.setdefault('主要营养物质代谢', {})[n] = int(val)
- # 糖类
- sugar_items = ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
- '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']
- for item in sugar_items:
- rows = find_table_rows(item[:3])
- if rows:
- for name, val in rows:
- metabolism.setdefault('糖类代谢', {})[item] = int(val)
- # 脂类
- for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
- '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
- 'Omega-3', 'Omega-6']:
- rows = find_table_rows(item[:3])
- if rows:
- for name, val in rows:
- metabolism.setdefault('脂类代谢', {})[item] = int(val)
- # 维生素
- for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
- '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
- '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
- rows = find_table_rows(item)
- if rows:
- for name, val in rows:
- metabolism.setdefault('维生素', {})[item] = int(val)
- # 微量元素
- for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
- rows = find_table_rows(item)
- if rows:
- for name, val in rows:
- if name == item:
- metabolism.setdefault('微量元素', {})[item] = int(val)
- # 氨基酸
- for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
- '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
- '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
- '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
- rows = find_table_rows(item[:2])
- if rows:
- for name, val in rows:
- metabolism.setdefault('氨基酸', {})[item] = int(val)
- # 神经递质
- for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
- rows = find_table_rows(item[:3])
- if rows:
- for name, val in rows:
- metabolism.setdefault('神经递质', {})[item] = int(val)
- # 其他单项
- for item, key in [('嘌呤代谢', '嘌呤代谢'), ('谷胱甘肽', '三肽(谷胱甘肽)'),
- ('胆汁酸', '胆汁酸代谢'), ('硫辛酸', '抗自由基(硫辛酸)'),
- ('辅酶Q', '抗自由基(辅酶Q)')]:
- v = find_table_val(item)
- if v: metabolism[key] = int(v)
- return metabolism
- def _parse_bj_antibiotics_from_tables(find_table_rows):
- """从表格提取抗生素风险评估"""
- ab = {}
- for cat in ANTIBIOTIC_CATEGORIES_BJ:
- rows = find_table_rows(cat, val_col=2)
- for name, val in rows:
- ab[cat] = int(val)
- return ab
- def _parse_bj_toxins_from_tables(all_tables, find_table_rows):
- """从表格提取毒性物质清除能力评估"""
- toxins = {}
- toxin_items = [
- "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
- "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
- "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
- "阿特拉津", "己内酰胺", "双酚", "二恶英",
- "萘", "多环芳烃",
- ]
- for item in toxin_items:
- rows = find_table_rows(item[:3], name_col=1, val_col=2)
- for name, val in rows:
- toxins[item] = int(val)
- return toxins
- def _parse_bj_overview(text):
- """提取北京报告概述(统一到标准报告 overview 结构)"""
- r = {}
- m = re.search(r'检测编号[::\s]*(\S+)', text)
- if m: r['report_number'] = m.group(1)
- m = re.search(r'姓\s*名[::\s]*(\S+)', text)
- if m: r['person_name'] = m.group(1)
- m = re.search(r'年\s*龄[::\s]*(\S+)', text)
- if m: r['age'] = m.group(1)
- m = re.search(r'性\s*别[::\s]*(\S+)', text)
- if m: r['gender'] = 'male' if m.group(1) in ('男', 'M') else 'female'
- # 肠型
- gut_type = _parse_bj_gut_type(text)
- if gut_type: r['gut_type'] = gut_type
- # GMHI → 健康总分
- m = re.search(r'肠道微生物健康指数[::\s]*(\d+)', text)
- if m: r['overallScore'] = int(m.group(1))
- return r
- def _parse_beijing_report(file_path: str) -> dict:
- """解析北京菌群报告 PDF,输出统一结构(pdfplumber 表格+文本混合提取)"""
- try:
- import pdfplumber
- with pdfplumber.open(file_path) as pdf:
- text = '\n'.join(page.extract_text() or '' for page in pdf.pages)
- # 提取所有表格
- all_tables = []
- for page in pdf.pages:
- tables = page.extract_tables()
- for table in tables:
- if table and len(table) >= 2:
- all_tables.append(table)
- except Exception as e:
- from PyPDF2 import PdfReader
- reader = PdfReader(file_path)
- text = '\n'.join((page.extract_text() or '') for page in reader.pages)
- all_tables = []
- def find_table_rows(keyword, val_col=1, name_col=0, try_all=False):
- """从表格中找指定关键词的数据行"""
- rows = []
- for table in all_tables:
- for row in table:
- row_str = ' '.join(str(c) for c in row if c)
- if keyword in row_str:
- if name_col < len(row) and val_col < len(row):
- name = str(row[name_col] or '').strip()
- val = str(row[val_col] or '').strip()
- if name and val and val.replace('.','').replace('-','').isdigit():
- rows.append((name, val))
- if rows and not try_all:
- break
- return rows
- def find_table_val(keyword, val_col=1, name_col=0):
- """从表格中找单个值"""
- for table in all_tables:
- for row in table:
- row_str = ' '.join(str(c) for c in row if c)
- if keyword in row_str:
- if val_col < len(row):
- val = str(row[val_col] or '').strip()
- if val and val.replace('.','').isdigit():
- return val
- return None
- result = {
- 'format': 'beijing',
- 'overview': _parse_bj_overview(text),
- 'disease_risks': [],
- 'nutrition': [],
- 'amino_acids': [],
- 'vitamins': [],
- 'trace_elements': [],
- '菌群检出详细列表': {},
- '个体化食物推荐表': {'格式': 'not_found', '条目数': 0, '数据': []},
- # 北京报告特有指标
- '肠道微生物健康指数': _parse_bj_gmhi(text),
- '菌群多样性': _parse_bj_diversity(text),
- '肠道黏膜屏障': _parse_bj_barrier(text),
- '菌群表型评估': _parse_bj_phenotype(text),
- '短链脂肪酸合成能力': _parse_bj_scfa_from_tables(find_table_rows),
- '肠道菌群精准分布': _parse_bj_core_bacteria_from_tables(all_tables),
- '营养物质及营养素代谢评估': _parse_bj_nutrient_from_tables(find_table_rows, find_table_val),
- '抗生素风险评估': _parse_bj_antibiotics_from_tables(find_table_rows),
- '毒性物质清除能力评估': _parse_bj_toxins_from_tables(all_tables, find_table_rows),
- '趣味肠菌评估': _parse_bj_psych_eval(text, find_table_val),
- '胃肠道感染病原体': [],
- '健康整体评估': {},
- '肠道菌群主要检测结果': {},
- }
- # 从表格更新 GMHI/抗炎/免疫/纤维/多样性/屏障
- gmhi_val = find_table_val('肠道微生物健康指数', val_col=1)
- if gmhi_val:
- result['肠道微生物健康指数']['肠道微生物健康指数'] = float(gmhi_val)
- score = float(gmhi_val)
- if score >= 90: result['肠道微生物健康指数']['健康状态'] = '健康'
- elif score >= 80: result['肠道微生物健康指数']['健康状态'] = '健康倾向'
- elif score >= 40: result['肠道微生物健康指数']['健康状态'] = '亚健康'
- elif score >= 20: result['肠道微生物健康指数']['健康状态'] = '不健康倾向'
- else: result['肠道微生物健康指数']['健康状态'] = '不健康'
- for attr, kw in [('肠道抗炎能力', '抗炎能力'), ('肠道免疫力', '免疫力'),
- ('肠道膳食纤维需求', '膳食纤维需求')]:
- v = find_table_val(kw)
- if v:
- result.setdefault('肠道菌群主要检测结果', {})[attr] = int(v)
- if '抗炎' in kw: result['overview']['inflammationScore'] = int(v)
- elif '免疫' in kw: result['overview']['immunityScore'] = int(v)
- # 多样性
- div_val = find_table_val('肠道微生物多样性', val_col=1)
- if div_val:
- result['菌群多样性']['Shannon指数'] = float(div_val)
- # BE比值
- be_val = find_table_val('B/E比值', val_col=1)
- if be_val:
- result['肠道黏膜屏障']['BE比值'] = float(be_val)
- bi_val = find_table_val('双歧杆菌属', val_col=1)
- if bi_val:
- result['肠道黏膜屏障']['双歧杆菌属'] = float(bi_val)
- ent_val = find_table_val('肠杆菌科', val_col=1)
- if ent_val:
- result['肠道黏膜屏障']['肠杆菌科'] = float(ent_val)
- # 肠型
- gut_type = _parse_bj_gut_type(text)
- balance = _parse_bj_balance(text)
- if gut_type or balance:
- result['健康整体评估'] = {}
- if gut_type: result['健康整体评估']['肠型'] = gut_type
- if balance: result['健康整体评估']['菌群平衡评估'] = balance
- # 疾病风险评估(文本提取)
- disease_risks = _parse_bj_disease_risks(text)
- if disease_risks:
- result['disease_risks'] = disease_risks
- # 病原体
- pathogens = _parse_bj_pathogens(text)
- if pathogens:
- result['胃肠道感染病原体'] = pathogens
- # 营养物质 → 标准字段映射
- metabolism = result.get('营养物质及营养素代谢评估', {})
- if metabolism.get('主要营养物质代谢'):
- result['nutrition'] = [
- {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
- for k, v in metabolism['主要营养物质代谢'].items()
- ]
- if metabolism.get('氨基酸'):
- result['amino_acids'] = [
- {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
- for k, v in metabolism['氨基酸'].items()
- ]
- if metabolism.get('维生素'):
- result['vitamins'] = [
- {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
- for k, v in metabolism['维生素'].items()
- ]
- if metabolism.get('微量元素'):
- result['trace_elements'] = [
- {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
- for k, v in metabolism['微量元素'].items()
- ]
- return result
|