pdf_parser.py 86 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036
  1. """
  2. 菌群报告 PDF 解析器
  3. 从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
  4. """
  5. import os
  6. import re
  7. from PyPDF2 import PdfReader
  8. # === 常量 ===
  9. # Kangxi 部首 → CJK 统一汉字(与 extract_full_report_v5.py 完全一致)
  10. RADICAL_MAP = {
  11. '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
  12. '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
  13. '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
  14. '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
  15. '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
  16. '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
  17. '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
  18. '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
  19. '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
  20. '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
  21. '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
  22. '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
  23. # 氏 radical U+2F52 → U+6C0F
  24. '\u2f52': '氏',
  25. # 以下为本项目报告 PDF 中实际出现的部首字形(U+2F00-Kangxi / U+2E80-CJK Radicals)
  26. '\u2f00': '一', '\u2f04': '乙', '\u2f06': '二', '\u2f08': '人',
  27. '\u2f09': '儿', '\u2f0a': '入', '\u2f0f': '几', '\u2f12': '力',
  28. '\u2f17': '十', '\u2f1c': '又', '\u2f1d': '口', '\u2f20': '士',
  29. '\u2f25': '女', '\u2f2b': '尸', '\u2f2f': '工', '\u2f34': '广',
  30. '\u2f38': '弓', '\u2f3f': '手', '\u2f40': '支', '\u2f45': '方',
  31. '\u2f47': '日', '\u2f49': '月', '\u2f4c': '止', '\u2f51': '毛',
  32. '\u2f53': '气', '\u2f5a': '片', '\u2f5b': '牙', '\u2f5d': '犬',
  33. '\u2f70': '示', '\u2f74': '立', '\u2f79': '网', '\u2f7d': '而',
  34. '\u2f83': '自', '\u2f84': '至', '\u2f86': '舌', '\u2f8a': '色',
  35. '\u2f8d': '虫', '\u2f8e': '血', '\u2f8f': '行', '\u2f94': '言',
  36. '\u2f9b': '走', '\u2f9c': '足', '\u2f9f': '辛', '\u2fa5': '里',
  37. '\u2fae': '非', '\u2fb0': '革', '\u2fb3': '音', '\u2fb8': '首',
  38. '\u2fbc': '高', '\u2fcf': '鼠', '\u2fd0': '鼻',
  39. '\u2e9f': '母', '\u2ec5': '见', '\u2ec6': '角', '\u2ed3': '长',
  40. '\u2ed4': '门', '\u2ed8': '青', '\u2ed9': '韦', '\u2edb': '风',
  41. '\u2ee3': '骨', '\u2eec': '齐',
  42. }
  43. KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
  44. KNOWN_AMINO = ['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸', '胱氨酸',
  45. '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸', '甘氨酸',
  46. '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺', '谷氨酰胺',
  47. '精氨酸', '色氨酸']
  48. KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
  49. '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
  50. KNOWN_TRACE = ['铁', '锌']
  51. KNOWN_DISEASE_RISKS = ['炎症性肠炎', '肠易激综合征', '感染性腹泻', '自闭症',
  52. '抑郁症', '甲状腺疾病', '肺部感染或疾病', '自体免疫病', '结直肠癌',
  53. '肥胖', '便秘', '过敏', '失眠', '肝病', '肾病', '胃病', '胆病',
  54. '心脑血管疾病', 'II型糖尿病']
  55. KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
  56. '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺', '硫化氢', '尸胺']
  57. KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)', '异戊酸盐(Isovaleric)']
  58. KNOWN_NEURO = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)', '谷氨酸(Glutamate)',
  59. '色氨酸(Tryptophan)', 'DOPAC', '多巴胺', '组胺(Histamine)', '一氧化氮',
  60. '喹啉(Quinolinic)', '维生素K2', '肌醇(Inositol)', '肾上腺素',
  61. '去甲肾上腺素', '乙酰胆碱', '皮质醇']
  62. KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类', '呋喃类',
  63. '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
  64. KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
  65. # === 菌群表标题(完整版 extract_full_report_v5.py 移植) ===
  66. BACTERIA_TABLE_TITLES = [
  67. '核心菌属构成表', '益生菌', '有害菌属构成表',
  68. '其它重要菌属构成表', '病原菌属构成表',
  69. ]
  70. PHYLUM_TABLE_TITLES = ['菌门构成表', '菌群门水平构成表', '门水平菌群构成']
  71. CLASS_TABLE_TITLES = ['菌纲构成表', '菌群纲水平构成表', '纲水平菌群构成']
  72. ORDER_TABLE_TITLES = ['菌目构成表', '菌群目水平构成表', '目水平菌群构成']
  73. FAMILY_TABLE_TITLES = ['菌科构成表', '菌群科水平构成表', '科水平菌群构成']
  74. GENUS_TABLE_TITLES = ['菌属构成表', '菌群属水平构成表', '属水平菌群构成']
  75. SPECIES_TABLE_TITLES = ['菌种构成表', '菌群种水平构成表', '种水平菌群构成']
  76. DISEASE_BACTERIA_TITLES = [
  77. '肥胖相关菌', '便秘相关菌', '抑郁相关菌', '过敏相关菌',
  78. '腹胀相关菌', '失眠相关菌', '肠道健康相关菌',
  79. '多动症相关菌', '自闭症相关菌',
  80. ]
  81. # === 食物推荐表(完整版 extract_full_report_v5.py 移植) ===
  82. COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
  83. '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
  84. KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
  85. '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
  86. FOOD_SKIP_TEXTS = [
  87. '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
  88. '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
  89. '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
  90. '检测方法及局限性', '数据分析及模型', '结果解读及使用',
  91. '影响因素说明', '建议将检测结果', '营养建议说明',
  92. '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
  93. '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
  94. '募极生物',
  95. ]
  96. def norm(s):
  97. return ''.join(RADICAL_MAP.get(c, c) for c in s)
  98. def detect_format(pages_text):
  99. """检测 triplet / inline 格式(v5 detect_format 移植,页面级判定)
  100. 仅当同一页同时含'疾病风险评估'+'指标范围'且存在内嵌数字的行时才算 inline,
  101. 否则按 triplet 处理(标准模板报告为 triplet,报告类 PDF 为 inline)。
  102. """
  103. for pt in pages_text:
  104. t = norm(pt)
  105. if '疾病风险评估' in t and '指标范围' in t:
  106. for line in t.split('\n'):
  107. ls = line.strip()
  108. if not ls:
  109. continue
  110. if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', ls):
  111. return 'inline'
  112. return 'triplet'
  113. return 'triplet'
  114. def extract_text(file_path):
  115. """读取 PDF 并提取文本"""
  116. reader = PdfReader(file_path)
  117. lines = []
  118. for page in reader.pages:
  119. text = norm(page.extract_text() or '')
  120. for line in text.split('\n'):
  121. ls = line.strip()
  122. if ls:
  123. lines.append(ls)
  124. return lines
  125. def _normalize_report_date(raw):
  126. """将常见中文/数字日期字符串归一为 YYYY-MM-DD;无法识别返回空串。"""
  127. raw = (raw or '').strip()
  128. m = re.search(r'(\d{4})\s*[-/年.]\s*(\d{1,2})\s*[-/月.]\s*(\d{1,2})', raw)
  129. if m:
  130. return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
  131. return ''
  132. def _extract_report_date(text):
  133. """从报告文本提取报告日期/采样日期/检测日期等,归一为 YYYY-MM-DD。
  134. 命中失败返回空串(保持 overview.report_date=null,不造假值)。
  135. """
  136. for kw in ('报告出具日期', '采样日期', '检测日期', '报告日期',
  137. '采样时间', '检测时间', '报告时间'):
  138. m = re.search(rf'{kw}\s*[::]?\s*(\d{{4}}\s*[-/年.]\s*\d{{1,2}}\s*[-/月.]\s*\d{{1,2}})', text)
  139. if m:
  140. d = _normalize_report_date(m.group(1))
  141. if d:
  142. return d
  143. return ''
  144. def parse_overview(lines):
  145. """提取报告概述"""
  146. text = '\n'.join(lines)
  147. r = {}
  148. m = re.search(r'编号[::\s]*(\d+)', text)
  149. if m: r['report_number'] = m.group(1)
  150. m = re.search(r'姓名[::\s]*([\u4e00-\u9fff]{2,10})', text)
  151. if m: r['person_name'] = re.sub(r'(编号|年龄|性别|备注|肠道).*', '', m.group(1))[:4]
  152. m = re.search(r'年龄[::\s]*(\d+)', text)
  153. if m: r['age'] = int(m.group(1))
  154. m = re.search(r'性别[::\s]*([\u4e00-\u9fff])', text)
  155. if m: r['gender'] = 'male' if m.group(1) == '男' else 'female'
  156. for kw in ['健康总分', '菌群健康', '慢病控制', '营养均衡', '肠道菌群平衡',
  157. '菌群多样性', '有益菌', '有害菌', '核心菌属']:
  158. m = re.search(rf'{kw}\s*(\d+)', text)
  159. if m: r[kw] = int(m.group(1))
  160. m = re.search(r'肠道预测年龄[::\s]*([\d.]+)', text)
  161. if m: r['gut_age'] = m.group(1)
  162. m = re.search(r'肠型[::\s]*(\S+)', text)
  163. if m: r['gut_type'] = m.group(1)
  164. # V-02 fix: 补 report_date 提取(报告/采样/检测日期常见形态);无则保持 null
  165. report_date = _extract_report_date(text)
  166. if report_date:
  167. r['report_date'] = report_date
  168. return r
  169. def parse_triplet_until(lines, stop_markers):
  170. """三元组解析:3行一组 名称/数值/状态"""
  171. results = []
  172. i = 0
  173. while i < len(lines):
  174. if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
  175. break
  176. if lines[i] in ('指标范围', '名称', '丰度', '评估'):
  177. i += 1
  178. continue
  179. name = lines[i]
  180. if i + 2 >= len(lines): break
  181. val = lines[i + 1]
  182. status = lines[i + 2]
  183. if re.match(r'^-?\d+\.?\d*$', val):
  184. results.append({'name': name, 'value': val, 'status': status})
  185. i += 3
  186. else:
  187. i += 1
  188. return results
  189. # ==========================================
  190. # 完整分区提取(移植自 extract_full_report_v5.py,修复分区错位)
  191. # 原问题:疾病风险 stop 标记缺'营养状况评估'导致吞并营养/氨基酸;
  192. # 维生素/微量元素标题在数据后(Case B)导致为空;屏障/SCFA/神经递质/病原菌/抗生素缺失
  193. # ==========================================
  194. def _extract_all_triplet(all_lines):
  195. """按'指标范围'数据页标记分段提取(v5 extract_all_triplet 移植)
  196. 数据页索引约定(v5 实证):
  197. data_pages[0] = 疾病风险页('疾病风险评估'标题前也有'指标范围')
  198. data_pages[1] = 营养状况评估页(含前5个氨基酸:苏氨酸/异亮氨酸/亮氨酸/赖氨酸/蛋氨酸)
  199. data_pages[2] = 剩余氨基酸页
  200. data_pages[3] = 维生素评估页(含微量元素)
  201. """
  202. data_pages = []
  203. for i, line in enumerate(all_lines):
  204. if line == '指标范围':
  205. data_pages.append(i)
  206. result = {}
  207. # 数据页1: 疾病风险评估(按已知疾病名过滤在调用方做)
  208. if len(data_pages) >= 1:
  209. i = data_pages[0] + 1
  210. if i < len(all_lines) and all_lines[i] == '疾病风险评估':
  211. i += 1
  212. rows = parse_triplet_until(all_lines[i:],
  213. ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估',
  214. '主要消化道致病菌', '抗生素风险评估', '抗生素耐药风险'])
  215. result['disease_risks'] = [r for r in rows if '注:' not in r['name'] and '注:' not in r['name']]
  216. # 数据页2: 营养状况评估(含前5个氨基酸)
  217. if len(data_pages) >= 2:
  218. i = data_pages[1] + 1
  219. if i < len(all_lines) and all_lines[i] == '营养状况评估':
  220. i += 1
  221. rows = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估'])
  222. result['nutrition_rows'] = rows[:5]
  223. result['amino_p2'] = rows[5:10] if len(rows) > 5 else []
  224. # 数据页3: 剩余氨基酸
  225. if len(data_pages) >= 3:
  226. i = data_pages[2] + 1
  227. if i < len(all_lines) and '氨基酸' in all_lines[i]:
  228. while i < len(all_lines) and '氨基酸' in all_lines[i]:
  229. i += 1
  230. remaining = parse_triplet_until(all_lines[i:], ['氨基酸评估'])
  231. result['amino_p3'] = remaining
  232. # 数据页4: 维生素评估 + 微量元素
  233. if len(data_pages) >= 4:
  234. i = data_pages[3] + 1
  235. if i < len(all_lines) and all_lines[i] == '维生素评估':
  236. i += 1
  237. vit_rows = parse_triplet_until(all_lines[i:], ['微量元素评估', '主要消化道致病菌'])
  238. vit, trace = [], []
  239. for r in vit_rows:
  240. if '维生素' in r['name']:
  241. vit.append(r)
  242. else:
  243. trace.append(r)
  244. result['vitamin_rows'] = vit
  245. result['trace_rows'] = trace
  246. # 主要消化道致病菌(特殊格式:致病菌/丰度%/评估)
  247. for i, line in enumerate(all_lines):
  248. if line == '主要消化道致病菌':
  249. j = i + 1
  250. while j < len(all_lines) and all_lines[j] in ('致病菌', '丰度', '评估'):
  251. j += 1
  252. path_rows = []
  253. while j < len(all_lines) and not all_lines[j].startswith('肠道屏障'):
  254. name = all_lines[j]
  255. if j + 2 < len(all_lines) and re.match(r'^\d+%$', all_lines[j+1]):
  256. path_rows.append({'name': name, 'value': all_lines[j+1], 'status': all_lines[j+2]})
  257. j += 3
  258. else:
  259. j += 1
  260. result['pathogens'] = path_rows
  261. break
  262. # 抗生素风险评估(去重:同名只保留第一条)
  263. for i, line in enumerate(all_lines):
  264. if line == '抗生素风险评估':
  265. abx_rows = parse_triplet_until(all_lines[i+1:], ['抗生素耐药风险', '个体化食物推荐表'])
  266. seen = set()
  267. deduped = []
  268. for r in abx_rows:
  269. n = r.get('name', '')
  270. if n and n not in seen:
  271. seen.add(n)
  272. deduped.append(r)
  273. result['antibiotics'] = deduped
  274. break
  275. return result
  276. def _extract_barrier_and_scfa_triplet(all_lines):
  277. """肠道屏障及代谢物/短链脂肪酸/神经递质及激素 - 已知指标名精确匹配(v5 移植)"""
  278. results = {'barrier': [], 'scfa': [], 'neurotransmitter': []}
  279. current_section = None
  280. i = 0
  281. while i < len(all_lines):
  282. line = all_lines[i]
  283. if line == '肠道屏障及菌群代谢物':
  284. current_section = 'barrier'
  285. i += 1
  286. continue
  287. if '短链脂肪酸' in line:
  288. current_section = 'scfa'
  289. i += 1
  290. continue
  291. if '神经递质' in line:
  292. current_section = 'neurotransmitter'
  293. i += 1
  294. continue
  295. if current_section is None:
  296. i += 1
  297. continue
  298. # 跳过头行
  299. if line in ('名称', '评估值', '正常范围', '过量', '缺乏', '相关症状') or \
  300. '过量 /' in line or '缺乏' in line:
  301. i += 1
  302. continue
  303. # 提前终止
  304. if current_section == 'barrier' and line in ('短链脂肪酸', '抗生素风险', '抗生素耐药风险'):
  305. break
  306. if current_section == 'scfa' and line in ('神经递质', '神经递质及激素指标', '神经递质及激素', '抗生素风险'):
  307. break
  308. if current_section == 'neurotransmitter' and line in ('抗生素风险', '个体化食物推荐表'):
  309. break
  310. # 只处理已知指标名
  311. known = {'barrier': KNOWN_BARRIER, 'scfa': KNOWN_SCFA, 'neurotransmitter': KNOWN_NEURO}
  312. known_list = known[current_section]
  313. matched_name = None
  314. for kn in sorted(known_list, key=len, reverse=True):
  315. if line.startswith(kn) or line == kn:
  316. matched_name = kn
  317. break
  318. if matched_name is None:
  319. i += 1
  320. continue
  321. # 模式1:整行解析 名称+数值+状态+范围(inline 单行)
  322. parts = line.split()
  323. if len(parts) >= 2:
  324. vi = None
  325. for pi, p in enumerate(parts):
  326. if re.match(r'^\d+$', p) and pi >= 1:
  327. vi = pi
  328. break
  329. if vi:
  330. val = parts[vi]
  331. status = ''
  332. range_ = ''
  333. for rp in parts[vi+1:]:
  334. if rp in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
  335. status = rp
  336. elif re.match(r'^\d+-\d+$', rp):
  337. range_ = rp
  338. results[current_section].append({
  339. 'name': matched_name, 'value': val, 'status': status, 'refRange': range_
  340. })
  341. i += 1
  342. continue
  343. # 模式2:下一行是数值(三元组)
  344. if i + 1 < len(all_lines) and re.match(r'^\d+$', all_lines[i+1]):
  345. val = all_lines[i+1]
  346. status = ''
  347. range_ = ''
  348. for j in range(2, min(6, len(all_lines)-i)):
  349. if all_lines[i+j] in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
  350. status = all_lines[i+j]
  351. elif re.match(r'^\d+-\d+$', all_lines[i+j]):
  352. range_ = all_lines[i+j]
  353. results[current_section].append({
  354. 'name': matched_name, 'value': val, 'status': status, 'refRange': range_
  355. })
  356. i += 2
  357. continue
  358. i += 1
  359. # 去重(每个 section 内每个指标名只保留一条)
  360. for section in ('barrier', 'scfa', 'neurotransmitter'):
  361. seen = set()
  362. deduped = []
  363. for item in results[section]:
  364. n = item.get('name', '')
  365. if n and n not in seen:
  366. seen.add(n)
  367. deduped.append(item)
  368. results[section] = deduped
  369. return results
  370. def _extract_names_from_region(region_str, known_names, end_pos):
  371. """从区域文本中提取已知名称+数值+状态(v5 移植)"""
  372. for h in ['肠道屏障及菌群代谢物名称评估值正常范围过量 / 缺乏相关症状',
  373. '名称评估值正常范围过量 / 缺乏相关症状',
  374. '短链脂肪酸评估值正常范围过量 / 缺乏相关症状',
  375. '神经递质及激素评估值正常范围过量 / 缺乏相关症状',
  376. '过量 / 缺乏相关症状']:
  377. region_str = region_str.replace(h, '')
  378. positions = []
  379. for kn in known_names:
  380. # 找所有匹配位置,选第一个后面有数字(允许空格)且不是头部标记的
  381. search_start = 0
  382. while True:
  383. ii = region_str.find(kn, search_start)
  384. if ii == -1:
  385. break
  386. after = region_str[ii+len(kn):ii+len(kn)+10].lstrip()
  387. if re.match(r'\d', after):
  388. positions.append((ii, kn))
  389. break # 找到第一个有效匹配
  390. search_start = ii + 1
  391. if not positions:
  392. return []
  393. # 去重:同名先后取前面(较精准的),不同名重叠取较长的
  394. positions.sort()
  395. filtered = []
  396. for i, (pos, name) in enumerate(positions):
  397. has_longer = False
  398. for j, (pos2, name2) in enumerate(positions):
  399. if i != j and pos2 <= pos and pos + len(name) <= pos2 + len(name2) and len(name2) > len(name):
  400. has_longer = True
  401. break
  402. if not has_longer:
  403. filtered.append((pos, name))
  404. positions = filtered
  405. seen_names = {}
  406. for pos, name in positions:
  407. if name not in seen_names:
  408. seen_names[name] = pos
  409. positions = [(pos, name) for name, pos in seen_names.items()]
  410. positions.sort()
  411. local = []
  412. for i, (ii, kn) in enumerate(positions):
  413. start_after = ii + len(kn)
  414. after_text = region_str[start_after:].lstrip()
  415. vm = re.match(r'(\d+(?:\.\d+)?)', after_text)
  416. if not vm:
  417. continue
  418. val = vm.group(1)
  419. whitespace_skipped = len(region_str[start_after:]) - len(after_text)
  420. val_start = start_after + whitespace_skipped
  421. val_end = val_start + len(val)
  422. if i + 1 < len(positions):
  423. next_idx = positions[i + 1][0]
  424. status = region_str[val_end:next_idx].strip()
  425. else:
  426. status = region_str[val_end:end_pos].strip()
  427. # 清理状态中的残留标记
  428. status = re.sub(
  429. r'注意大环内酯类|注意呋喃类|注意氯霉素类|注意喹诺酮类|注意磺胺类|'
  430. r'注意甲氧苄啶类|注意四环素类|正常氨基酸评估|正常维生素评估|'
  431. r'正常微量元素评估|指标范围|疾病风险评估|营养状况评估|'
  432. r'抗生素风险评估|抗生素耐药风险|主要消化道致病菌',
  433. '', status).strip()
  434. # 截断:状态中出现'注'(如'注:本检测仅用于...')只取之前部分
  435. status = re.split(r'注[::]', status)[0].strip()
  436. if len(status) > 20:
  437. status = status[:20]
  438. local.append({'name': kn, 'value': val, 'status': status})
  439. return local
  440. def _extract_inline_module(all_text, start_marker, end_markers, known_names):
  441. """从 inline 文本中提取已知名称的指标(v5 移植)
  442. 支持两种布局:
  443. A) 标题在前、数据在后(标准)
  444. B) 标题在后、数据在前('指标范围'与标题之间)
  445. """
  446. sm_idx = all_text.find(start_marker)
  447. if sm_idx == -1:
  448. return []
  449. end_pos = len(all_text)
  450. for em in end_markers:
  451. ei = all_text.find(em, sm_idx + len(start_marker))
  452. if ei != -1 and ei < end_pos:
  453. end_pos = ei
  454. # Case A: 标题在前,数据在标题和 end_marker 之间
  455. region_a = all_text[sm_idx:end_pos]
  456. result_a = _extract_names_from_region(region_a, known_names, len(region_a))
  457. # Case B: 标题在后,数据在'指标范围'和标题之间
  458. zb_idx = all_text.rfind('指标范围', 0, sm_idx)
  459. if zb_idx == -1:
  460. zb_idx = max(0, sm_idx - 500)
  461. region_b = all_text[zb_idx:sm_idx]
  462. result_b = _extract_names_from_region(region_b, known_names, len(region_b))
  463. # 取匹配多的那个
  464. if len(result_a) >= len(result_b):
  465. return result_a
  466. return result_b
  467. def _extract_pathogens_inline(text):
  468. """提取 inline 格式中主要消化道致病菌数据(幽门螺杆菌0%未检出...)"""
  469. results = []
  470. sidx = text.find('主要消化道致病菌')
  471. if sidx == -1:
  472. return results
  473. eidx = text.find('肠道屏障', sidx)
  474. if eidx == -1:
  475. eidx = len(text)
  476. region = text[sidx:eidx]
  477. for name in KNOWN_PATHOGENS:
  478. idx = region.find(name)
  479. if idx == -1:
  480. continue
  481. after = region[idx + len(name):]
  482. m = re.match(r'\s*(\d+%)', after)
  483. if not m:
  484. continue
  485. abundance = m.group(1)
  486. assessment_start = idx + len(name) + m.end()
  487. assessment_end = len(region)
  488. for next_name in KNOWN_PATHOGENS:
  489. if next_name == name:
  490. continue
  491. ni = region.find(next_name, assessment_start)
  492. if ni != -1 and ni < assessment_end:
  493. assessment_end = ni
  494. assessment = region[assessment_start:assessment_end].strip()
  495. results.append({'name': name, 'value': abundance, 'status': assessment})
  496. return results
  497. def _final_fallback_scan(full_text, result, section_known_map):
  498. """最终的全文正则扫描——弥补所有提取器漏掉的已知指标项(v5 移植)"""
  499. for section, known_list in section_known_map.items():
  500. existing = {r['name'] for r in result.get(section, [])}
  501. for name in known_list:
  502. if name in existing:
  503. continue
  504. # 搜索多个变体:完整名、去掉括号的后缀
  505. search_names = [name]
  506. paren = name.find('(')
  507. if paren != -1:
  508. search_names.append(name[:paren])
  509. found = False
  510. for sname in search_names:
  511. idx = full_text.find(sname)
  512. if idx == -1:
  513. continue
  514. # 跳过名称之后紧跟着的重复名称(正字+部首两次出现的情况)
  515. skip = idx + len(sname)
  516. while skip < len(full_text):
  517. ch = full_text[skip]
  518. if ch == '\n' or ch == ' ':
  519. skip += 1
  520. elif '\u4e00' <= ch <= '\u9fff' or '\u2f00' <= ch <= '\u2fdf':
  521. skip += 1
  522. else:
  523. break
  524. after = full_text[skip:].lstrip()
  525. m = re.match(r'(\d+(?:\.\d+)?)', after)
  526. if not m:
  527. continue
  528. val = m.group(1)
  529. val_end_in_after = m.end()
  530. status_raw = after[val_end_in_after:].lstrip()
  531. status_m = re.search(r'([\u4e00-\u9fff/]+)', status_raw)
  532. status = status_m.group(1).strip() if status_m else status_raw[:20]
  533. if len(status) > 20:
  534. status = status[:20]
  535. result.setdefault(section, []).append({'name': name, 'value': val, 'status': status})
  536. found = True
  537. break
  538. return result
  539. def parse_report_pdf(file_path: str) -> dict:
  540. """主函数:解析 PDF 返回结构化数据(v5 完整提取逻辑移植)"""
  541. reader = PdfReader(file_path)
  542. pages_text = [p.extract_text() or '' for p in reader.pages]
  543. lines = extract_text(file_path)
  544. full_text = '\n'.join(lines)
  545. # 指纹检测:北京菌群报告
  546. if detect_beijing_format(full_text):
  547. return _parse_beijing_report(file_path)
  548. fmt = detect_format(pages_text)
  549. result = {'format': fmt, 'overview': parse_overview(lines)}
  550. _SECTION_KNOWN_MAP = {
  551. 'disease_risks': KNOWN_DISEASE_RISKS,
  552. 'nutrition': KNOWN_MACRO,
  553. 'amino_acids': KNOWN_AMINO,
  554. 'vitamins': KNOWN_VITAMINS,
  555. 'trace_elements': KNOWN_TRACE,
  556. '抗生素风险评估': KNOWN_ANTIBIOTICS,
  557. '肠道屏障及代谢物': KNOWN_BARRIER,
  558. '短链脂肪酸': KNOWN_SCFA,
  559. '神经递质及激素': KNOWN_NEURO,
  560. }
  561. if fmt == 'triplet':
  562. parsed = _extract_all_triplet(lines)
  563. # 疾病风险评估(已知疾病名词表过滤)
  564. disease_set = set(KNOWN_DISEASE_RISKS)
  565. result['disease_risks'] = [r for r in parsed.get('disease_risks', []) if r['name'] in disease_set]
  566. # 兜底:inline 方式补充 triplet 漏掉的项目
  567. for r in _extract_inline_module(full_text, '疾病风险评估',
  568. ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS):
  569. if r['name'] not in {x['name'] for x in result['disease_risks']}:
  570. result['disease_risks'].append(r)
  571. # 主要营养评估
  572. nutri_set = set(KNOWN_MACRO)
  573. result['nutrition'] = [r for r in parsed.get('nutrition_rows', []) if r['name'] in nutri_set]
  574. for r in _extract_inline_module(full_text, '营养状况评估',
  575. ['主要营养评估', '氨基酸评估'], KNOWN_MACRO):
  576. if r['name'] not in {x['name'] for x in result['nutrition']}:
  577. result['nutrition'].append(r)
  578. # 氨基酸评估(营养页内嵌 p2 + 独立页 p3 合并 + inline 兜底)
  579. amino_rows = parsed.get('amino_p2', []) + parsed.get('amino_p3', [])
  580. amino_set = set(KNOWN_AMINO)
  581. result['amino_acids'] = [r for r in amino_rows if r['name'] in amino_set]
  582. for r in _extract_inline_module(full_text, '氨基酸评估',
  583. ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO):
  584. if r['name'] not in {x['name'] for x in result['amino_acids']}:
  585. result['amino_acids'].append(r)
  586. # 维生素评估 / 微量元素评估
  587. vit_set = set(KNOWN_VITAMINS)
  588. trace_set = set(KNOWN_TRACE)
  589. result['vitamins'] = [r for r in parsed.get('vitamin_rows', []) if r['name'] in vit_set]
  590. result['trace_elements'] = [r for r in parsed.get('trace_rows', []) if r['name'] in trace_set]
  591. for r in _extract_inline_module(full_text, '维生素评估',
  592. ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS):
  593. if r['name'] not in {x['name'] for x in result['vitamins']}:
  594. result['vitamins'].append(r)
  595. for r in _extract_inline_module(full_text, '微量元素评估',
  596. ['主要消化道致病菌'], KNOWN_TRACE):
  597. if r['name'] not in {x['name'] for x in result['trace_elements']}:
  598. result['trace_elements'].append(r)
  599. # 主要消化道致病菌
  600. result['主要消化道致病菌'] = parsed.get('pathogens', [])
  601. # 抗生素风险评估(已知抗生素词表过滤)
  602. abx_set = set(KNOWN_ANTIBIOTICS)
  603. result['抗生素风险评估'] = [r for r in parsed.get('antibiotics', []) if r['name'] in abx_set]
  604. # 肠道屏障 + 短链脂肪酸 + 神经递质(已知指标名精确匹配)
  605. bs = _extract_barrier_and_scfa_triplet(lines)
  606. result['肠道屏障及代谢物'] = bs['barrier']
  607. result['短链脂肪酸'] = bs['scfa']
  608. result['神经递质及激素'] = bs['neurotransmitter']
  609. # inline 兜底补充
  610. for section_key, start_mk, end_mks, known_list in [
  611. ('肠道屏障及代谢物', '肠道屏障及菌群代谢物',
  612. ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER),
  613. ('短链脂肪酸', '短链脂肪酸',
  614. ['神经递质', '抗生素风险'], KNOWN_SCFA),
  615. ('神经递质及激素', '神经递质',
  616. ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO),
  617. ]:
  618. existing = {r['name'] for r in result.get(section_key, [])}
  619. for r in _extract_inline_module(full_text, start_mk, end_mks, known_list):
  620. if r['name'] not in existing:
  621. result.setdefault(section_key, []).append(r)
  622. existing.add(r['name'])
  623. # 最终兜底:全文正则扫描所有已知指标名
  624. _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
  625. else:
  626. # inline 格式(v5 extract_pdf_to_json inline 分支移植)
  627. result['disease_risks'] = _extract_inline_module(full_text, '疾病风险评估',
  628. ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS)
  629. result['nutrition'] = _extract_inline_module(full_text, '营养状况评估',
  630. ['主要营养评估', '氨基酸评估'], KNOWN_MACRO)
  631. result['amino_acids'] = _extract_inline_module(full_text, '营养状况评估',
  632. ['主要营养评估', '氨基酸评估'], KNOWN_AMINO)
  633. amino_p2 = _extract_inline_module(full_text, '氨基酸评估',
  634. ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO)
  635. for a in amino_p2:
  636. if a['name'] not in {x['name'] for x in result['amino_acids']}:
  637. result['amino_acids'].append(a)
  638. result['vitamins'] = _extract_inline_module(full_text, '维生素评估',
  639. ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS)
  640. result['trace_elements'] = _extract_inline_module(full_text, '微量元素评估',
  641. ['主要消化道致病菌'], KNOWN_TRACE)
  642. result['主要消化道致病菌'] = _extract_pathogens_inline(full_text)
  643. result['抗生素风险评估'] = _extract_inline_module(full_text, '抗生素风险评估',
  644. ['抗生素耐药风险', '个体化食物推荐表'], KNOWN_ANTIBIOTICS)
  645. result['肠道屏障及代谢物'] = _extract_inline_module(full_text, '肠道屏障及菌群代谢物',
  646. ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER)
  647. result['短链脂肪酸'] = _extract_inline_module(full_text, '短链脂肪酸',
  648. ['神经递质', '抗生素风险'], KNOWN_SCFA)
  649. result['神经递质及激素'] = _extract_inline_module(full_text, '神经递质',
  650. ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO)
  651. _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
  652. # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
  653. bacteria_tables = _extract_bacteria_tables(file_path)
  654. result['菌群检出详细列表'] = bacteria_tables
  655. # 个体化食物推荐表(保留原始键名与结构)
  656. food_rows, food_fmt = _extract_food_rows(file_path)
  657. result['个体化食物推荐表'] = {
  658. '格式': food_fmt,
  659. '条目数': len(food_rows),
  660. '数据': food_rows
  661. }
  662. # 统一结构:北京报告特有字段(标准报告为空)
  663. result['肠道微生物健康指数'] = {}
  664. result['菌群多样性'] = {}
  665. result['肠道黏膜屏障'] = {}
  666. result['菌群表型评估'] = {}
  667. result['短链脂肪酸合成能力'] = {}
  668. result['肠道菌群精准分布'] = []
  669. result['营养物质及营养素代谢评估'] = {}
  670. result.setdefault('抗生素风险评估', {})
  671. result['毒性物质清除能力评估'] = {}
  672. result['趣味肠菌评估'] = {}
  673. result['胃肠道感染病原体'] = []
  674. result['健康整体评估'] = {}
  675. result['肠道菌群主要检测结果'] = {}
  676. return result
  677. def parse_report_pdf_with_fallback(file_path: str) -> dict:
  678. """算法解析 + 简单校验,返回结构化数据"""
  679. result = parse_report_pdf(file_path)
  680. # 简单校验:如果关键字段缺失,标记为解析不完整
  681. if not result.get('overview', {}).get('overallScore') and \
  682. not result.get('overview', {}).get('健康总分'):
  683. result['_parse_incomplete'] = True
  684. return result
  685. def _parse_bacteria_table(reader, pages_text, full_text, title, fmt, skip_header=False):
  686. """从PDF中解析一个菌群表格(移植自 extract_full_report_v5.py)"""
  687. results = []
  688. sidx = full_text.find(title)
  689. if sidx == -1:
  690. return results
  691. # 病原菌检出表特殊处理:找"仅列出检出的病原菌"(跳过前面的说明文字中的"病原菌")
  692. if title == '病原菌':
  693. better_sidx = full_text.find('仅列出检出的病原菌')
  694. if better_sidx != -1:
  695. sidx = better_sidx
  696. # 找表格结束位置(下一个标题或页尾)
  697. end_pos = len(full_text)
  698. for t in (BACTERIA_TABLE_TITLES + PHYLUM_TABLE_TITLES + CLASS_TABLE_TITLES
  699. + ORDER_TABLE_TITLES + FAMILY_TABLE_TITLES + GENUS_TABLE_TITLES
  700. + SPECIES_TABLE_TITLES + DISEASE_BACTERIA_TITLES
  701. + ['指标范围', '个体化食物推荐表', '报告总结', '健康总分']):
  702. if t == title:
  703. continue
  704. ei = full_text.find(t, sidx + len(title))
  705. if ei != -1 and ei < end_pos:
  706. end_pos = ei
  707. region = full_text[sidx:end_pos]
  708. # 检测区域实际格式:如果换行数很少(<3)则是inline格式,即使全局fmt=triplet
  709. lines_from_region = [l.strip() for l in region.split('\n') if l.strip()]
  710. actual_fmt = fmt
  711. if fmt == 'triplet' and len(lines_from_region) <= 5:
  712. actual_fmt = 'inline'
  713. if actual_fmt == 'triplet':
  714. # 三元组格式:每个字段单独一行
  715. lines = [l.strip() for l in region.split('\n') if l.strip()]
  716. start = 0
  717. for i, line in enumerate(lines):
  718. if line == '名称':
  719. start = i + 1
  720. break
  721. if line.startswith('名称'):
  722. if skip_header:
  723. start = i + 1
  724. break
  725. i = start
  726. while i < len(lines):
  727. name = lines[i]
  728. if not name or len(name) <= 1 or name in ['说明', '检测结果', '结果解释', '建议']:
  729. i += 1
  730. continue
  731. if name.startswith('说明:') or name.startswith('改善方式'):
  732. i += 1
  733. continue
  734. if len(name) > 80:
  735. i += 1
  736. continue
  737. # 找丰度%
  738. if i + 1 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$|^ND$', lines[i + 1]):
  739. pct = lines[i + 1]
  740. normal_range = ''
  741. pop_level = ''
  742. detection_rate = ''
  743. desc = ''
  744. j = i + 2
  745. # 正常范围 (允许小数,如 0.06-6.96, 0.03-3.07)
  746. if j < len(lines) and re.match(r'^[\d]+\.?[\d]*-[\d]+\.?[\d]*$', lines[j]):
  747. normal_range = lines[j]
  748. j += 1
  749. # 人群水平% (允许小数,如 15.66%)
  750. if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
  751. pop_level = lines[j]
  752. j += 1
  753. # 检出率%
  754. if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
  755. detection_rate = lines[j]
  756. j += 1
  757. # 说明
  758. if j < len(lines) and lines[j].startswith('说明'):
  759. desc = lines[j]
  760. j += 1
  761. # 改善方式
  762. if j < len(lines) and lines[j].startswith('改善方式'):
  763. if desc:
  764. desc += ' | ' + lines[j]
  765. else:
  766. desc = lines[j]
  767. j += 1
  768. entry = {'名称': name, '丰度%': pct}
  769. if normal_range:
  770. entry['正常范围%'] = normal_range
  771. if pop_level:
  772. entry['人群水平%'] = pop_level
  773. if detection_rate:
  774. entry['检出率%'] = detection_rate
  775. if desc:
  776. entry['说明'] = desc
  777. results.append(entry)
  778. i = j
  779. else:
  780. i += 1
  781. else:
  782. # inline 格式
  783. sample = region[:500]
  784. truly_compressed = bool(re.search(r'[a-z]\d', sample, re.IGNORECASE))
  785. if not truly_compressed:
  786. # 有空格分隔的inline格式,使用re.finditer
  787. region_clean = region
  788. for hdr in ['名称', '丰度%', '正常范围%', '处于人群%水平', '%正常人有检出',
  789. '人群水平%', '%人检出']:
  790. region_clean = region_clean.replace(hdr, '')
  791. region_clean = re.sub(
  792. r'说明:[\u4e00-\u9fff\s,。、;:,.;:()()、/a-zA-Z0-9\-]{10,}?(?=[\u4e00-\u9fff]|$)',
  793. '', region_clean)
  794. # 扫描所有匹配的数据行
  795. for m in re.finditer(
  796. r'([\u4e00-\u9fff]{2,12}(?:[((][\u4e00-\u9fff\w]+[))])?)\s+' # 中文名
  797. r'(?:[A-Z][a-z]+(?:\s[A-Z][a-z]+)*\s+)?' # 可选英文名
  798. r'(ND|[\d]+\.?[\d]*%?)\s+' # 丰度
  799. r'([\d]+\.?[\d]*-[\d]+\.?[\d]*)?\s*' # 可选正常范围
  800. r'(\d+\.?\d*%?)\s+' # 人群水平%
  801. r'(\d+\.?\d*%)', # 检出率%
  802. region_clean):
  803. name = m.group(1).strip()
  804. pct = m.group(2)
  805. if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
  806. continue
  807. normal_range = m.group(3) or ''
  808. pop_level = m.group(4)
  809. if not pop_level.endswith('%'):
  810. pop_level += '%'
  811. detection_rate = m.group(5)
  812. entry = {'名称': name, '丰度%': pct}
  813. if normal_range:
  814. entry['正常范围%'] = normal_range
  815. entry['人群水平%'] = pop_level
  816. entry['检出率%'] = detection_rate
  817. results.append(entry)
  818. # 模式1没有匹配时:仅中文名+丰度+人群水平(+检出率)
  819. if not results:
  820. for m in re.finditer(
  821. r'([\u4e00-\u9fff]{2,10}[\u4e00-\u9fff]?)\s+'
  822. r'(ND|[\d]+\.?[\d]*%?)\s+'
  823. r'(\d+\.?\d*%)\s+'
  824. r'(\d+\.?\d*%)?',
  825. region_clean):
  826. name = m.group(1).strip()
  827. pct = m.group(2)
  828. if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
  829. continue
  830. pop_level = m.group(3)
  831. detection_rate = m.group(4) or ''
  832. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
  833. if detection_rate:
  834. entry['检出率%'] = detection_rate
  835. results.append(entry)
  836. return results
  837. def _parse_phylum_tables(reader, full_text, fmt, title_list=None):
  838. """提取菌群层级构成表(门/纲/目/科/属/种 level)(移植自 extract_full_report_v5.py)"""
  839. if title_list is None:
  840. title_list = PHYLUM_TABLE_TITLES
  841. results = []
  842. for phylum_title in title_list:
  843. rows = _parse_bacteria_table(reader, None, full_text, phylum_title, fmt)
  844. results.extend(rows)
  845. return results
  846. def _parse_taxonomy_levels(reader, full_text, fmt):
  847. """从"菌群检出详细列表"中提取纲目科属种各级数据(移植自 extract_full_report_v5.py)"""
  848. results = {}
  849. sidx = full_text.find('菌群检出详细列表')
  850. if sidx == -1:
  851. return results
  852. end_pos = len(full_text)
  853. for t in ['个体化食物推荐表', '报告总结', '健康总分']:
  854. ei = full_text.find(t, sidx)
  855. if ei != -1 and ei < end_pos:
  856. end_pos = ei
  857. section = full_text[sidx:end_pos]
  858. for level, level_name in [('纲', '菌纲构成'), ('目', '菌目构成'),
  859. ('科', '菌科构成'), ('属', '菌属构成'),
  860. ('种', '菌种构成')]:
  861. marker = f'\n{level}\n名称\n丰度%'
  862. marker2 = f'{level} 名称 丰度%'
  863. li = section.find(marker)
  864. level_start = None
  865. if li == -1:
  866. li2 = section.find(marker2)
  867. if li2 != -1:
  868. li = li2
  869. level_start = li2 + len(marker2)
  870. else:
  871. compressed_marker = f'{level}名称丰度%人群水平%%人检出'
  872. cli = section.find(compressed_marker)
  873. if cli == -1:
  874. continue
  875. # 压缩格式解析:用正则提取数据
  876. level_start = cli + len(compressed_marker)
  877. level_end = len(section)
  878. for next_level in ['目', '科', '属', '种']:
  879. if next_level == level:
  880. continue
  881. ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
  882. if ni != -1 and ni < level_end:
  883. level_end = ni
  884. break
  885. level_region = section[level_start:level_end]
  886. compressed_pattern = re.compile(
  887. r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?' # 可选中文名
  888. r'([A-Za-z][A-Za-z\s.\-]*?)' # 拉丁名(可能含空格)
  889. r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)' # 三连百分比
  890. )
  891. rows = []
  892. for m in compressed_pattern.finditer(level_region):
  893. cn_name = (m.group(1) or '').strip()
  894. latin_name = m.group(2).strip()
  895. pct = m.group(3)
  896. pop_level = m.group(4)
  897. detection = m.group(5)
  898. name = cn_name if cn_name else latin_name
  899. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
  900. rows.append(entry)
  901. if rows:
  902. results[level_name] = rows
  903. continue
  904. else:
  905. level_start = li + len(marker)
  906. level_end = len(section)
  907. for next_level in ['纲', '目', '科', '属', '种']:
  908. if next_level == level:
  909. continue
  910. ni = section.find(f'\n{next_level}\n名称', level_start)
  911. if ni != -1 and ni < level_end:
  912. level_end = ni
  913. break
  914. level_region = section[level_start:level_end]
  915. lines = [l.strip() for l in level_region.split('\n') if l.strip()]
  916. rows = []
  917. i = 0
  918. while i < len(lines):
  919. if lines[i] in ['名称', '丰度%', '人群水平%', '%人检出']:
  920. i += 1
  921. continue
  922. name = lines[i]
  923. if i + 2 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$', lines[i + 1]):
  924. pct = lines[i + 1]
  925. pop_level = lines[i + 2] if i + 2 < len(lines) else ''
  926. detection = lines[i + 3] if i + 3 < len(lines) and re.match(r'^[\d.]+%$', lines[i + 3]) else ''
  927. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
  928. if detection:
  929. entry['检出率%'] = detection
  930. rows.append(entry)
  931. i += 4 if detection else 3
  932. else:
  933. i += 1
  934. if rows:
  935. results[level_name] = rows
  936. return results
  937. def _extract_bacteria_tables(pdf_path):
  938. """提取菌群检出详细列表,返回 {中文分组名: [行]}(移植自 extract_full_report_v5.py)
  939. 与原始脚本保持一致的文本构造与格式检测:full_text 使用
  940. '\\n'.join(norm(p.extract_text()) for p in reader.pages)(不 strip、不过滤空行),
  941. fmt 使用原脚本 L672-683 的独立检测逻辑。
  942. """
  943. reader = PdfReader(pdf_path)
  944. full_text = '\n'.join(norm(p.extract_text()) for p in reader.pages)
  945. # 原脚本 extract_bacteria_tables 的格式检测(L674-683)
  946. fmt = 'triplet' if '指标范围' in full_text and '疾病风险评估' in full_text else 'inline'
  947. for pt in [p.extract_text() for p in reader.pages]:
  948. t = norm(pt)
  949. if '疾病风险评估' in t and '指标范围' in t:
  950. for line in t.split('\n'):
  951. if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', line.strip()):
  952. fmt = 'inline'
  953. break
  954. break
  955. all_tables = {}
  956. # 核心菌属构成表1-3
  957. core_genus = []
  958. for i in range(1, 4):
  959. title = f'核心菌属构成表{i}'
  960. rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
  961. core_genus.extend(rows)
  962. all_tables['核心菌属'] = core_genus
  963. # 益生菌(使用更精确的表头定位,跳过前面的说明文字)
  964. prob_marker = '仅列出丰度前22的益生菌种'
  965. prob_sidx = full_text.find(prob_marker)
  966. if prob_sidx != -1:
  967. prob_rows = _parse_bacteria_table(reader, None, full_text, prob_marker, fmt, skip_header=True)
  968. else:
  969. prob_rows = _parse_bacteria_table(reader, None, full_text, '益生菌', fmt, skip_header=True)
  970. all_tables['益生菌'] = [r for r in prob_rows if r.get('名称') and r['名称'] not in
  971. ['我的益生菌都为ND', '仅列出丰度前22的益生菌种。']]
  972. # 有害菌属构成表1-2
  973. harmful = []
  974. for i in range(1, 3):
  975. title = f'有害菌属构成表{i}'
  976. rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
  977. harmful.extend(rows)
  978. all_tables['有害菌属'] = harmful
  979. # 其它重要菌属
  980. other_rows = _parse_bacteria_table(reader, None, full_text, '其它重要菌属构成表', fmt)
  981. all_tables['其它重要菌属'] = other_rows
  982. # 病原菌属构成表
  983. patho_genus = _parse_bacteria_table(reader, None, full_text, '病原菌属构成表', fmt)
  984. all_tables['病原菌属'] = patho_genus
  985. # 病原菌(检出列表)
  986. patho_rows = _parse_bacteria_table(reader, None, full_text, '病原菌', fmt, skip_header=True)
  987. all_tables['病原菌检出'] = [r for r in patho_rows if r.get('名称') and len(r['名称']) >= 2
  988. and '仅列出' not in r['名称'] and '说明' not in r['名称']]
  989. # 菌门构成表(phylum level)
  990. phylum_rows = _parse_phylum_tables(reader, full_text, fmt)
  991. if phylum_rows:
  992. all_tables['菌门构成'] = phylum_rows
  993. # 菌纲构成表
  994. class_rows = _parse_phylum_tables(reader, full_text, fmt, CLASS_TABLE_TITLES)
  995. if class_rows:
  996. all_tables['菌纲构成'] = class_rows
  997. # 菌目构成表
  998. order_rows = _parse_phylum_tables(reader, full_text, fmt, ORDER_TABLE_TITLES)
  999. if order_rows:
  1000. all_tables['菌目构成'] = order_rows
  1001. # 菌科构成表
  1002. family_rows = _parse_phylum_tables(reader, full_text, fmt, FAMILY_TABLE_TITLES)
  1003. if family_rows:
  1004. all_tables['菌科构成'] = family_rows
  1005. # 菌属构成表
  1006. genus_rows = _parse_phylum_tables(reader, full_text, fmt, GENUS_TABLE_TITLES)
  1007. if genus_rows:
  1008. all_tables['菌属构成'] = genus_rows
  1009. # 菌种构成表
  1010. species_rows = _parse_phylum_tables(reader, full_text, fmt, SPECIES_TABLE_TITLES)
  1011. if species_rows:
  1012. all_tables['菌种构成'] = species_rows
  1013. # 菌群层级(纲目科属种)- 从"菌群检出详细列表"统一入口提取
  1014. taxonomy_rows = _parse_taxonomy_levels(reader, full_text, fmt)
  1015. for key, rows in taxonomy_rows.items():
  1016. if rows:
  1017. all_tables[key] = rows
  1018. return all_tables
  1019. def split_7_fields(s):
  1020. """将压缩数字串切分为 7 个字段(移植自 extract_full_report_v5.py)"""
  1021. results = []
  1022. ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
  1023. def backtrack(pos, idx, nums):
  1024. if idx == 7:
  1025. if pos == len(s):
  1026. results.append(list(nums))
  1027. return
  1028. if pos >= len(s):
  1029. return
  1030. lo, hi = ranges[idx]
  1031. for w in range(lo, min(hi + 1, len(s) - pos + 1)):
  1032. chunk = s[pos:pos + w]
  1033. if chunk.isdigit():
  1034. backtrack(pos + w, idx + 1, nums + [int(chunk)])
  1035. backtrack(0, 0, [])
  1036. return results
  1037. def decode_compressed(name, num_str, ref_vals=None):
  1038. """解码压缩食物数字串(移植自 extract_full_report_v5.py)"""
  1039. raw = num_str.lstrip('-')
  1040. has_neg = num_str.startswith('-')
  1041. candidates = []
  1042. for rec_len in range(1, 3):
  1043. if rec_len > len(raw):
  1044. continue
  1045. rec = ('-' if has_neg else '') + raw[:rec_len]
  1046. try:
  1047. rec_val = int(rec)
  1048. if not (-100 <= rec_val <= 100):
  1049. continue
  1050. except Exception:
  1051. continue
  1052. remain = raw[rec_len:]
  1053. for nums in split_7_fields(remain):
  1054. if ref_vals:
  1055. matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
  1056. if matches >= 6:
  1057. candidates.append([rec_val] + nums)
  1058. else:
  1059. candidates.append([rec_val] + nums)
  1060. if not candidates:
  1061. return None
  1062. if ref_vals:
  1063. candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
  1064. -len(str(abs(r[0])))), reverse=True)
  1065. if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
  1066. return None
  1067. else:
  1068. candidates.sort(key=lambda r: (len(str(abs(r[0]))),
  1069. -sum(1 for i in range(7) if r[1:][i] == 0)))
  1070. return candidates[0]
  1071. def extract_food_table(pdf_path, ref_lookup=None):
  1072. """提取个体化食物推荐表(移植自 extract_full_report_v5.py)"""
  1073. reader = PdfReader(pdf_path)
  1074. food_start = None
  1075. for i, page in enumerate(reader.pages):
  1076. if '个体化食物推荐表' in page.extract_text():
  1077. food_start = i
  1078. break
  1079. if food_start is None:
  1080. return [], 'not_found'
  1081. first_text = norm(reader.pages[food_start + 1].extract_text())
  1082. lines = [l.strip() for l in first_text.split('\n')
  1083. if l.strip() and not re.match(r'\d+/\d+', l)]
  1084. # 判断压缩格式:任何一行超过100字符(单行密集格式),或前10行中超过3行长行
  1085. is_compressed = (len(lines) >= 1 and any(len(l) > 100 for l in lines[:10])) or \
  1086. sum(1 for l in lines[:10] if len(l) > 100) >= 2
  1087. rows = []
  1088. if is_compressed:
  1089. fmt = 'compressed'
  1090. for i in range(food_start + 1, len(reader.pages)):
  1091. text = norm(reader.pages[i].extract_text())
  1092. text = re.sub(r'\d+/\d+', '', text)
  1093. header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
  1094. text = text.replace(header, '')
  1095. for kw in FOOD_SKIP_TEXTS:
  1096. text = text.replace(kw, '')
  1097. while text:
  1098. best_cat, best_idx = None, len(text)
  1099. for cat in KNOWN_CATS:
  1100. idx = text.find(cat)
  1101. if idx != -1 and idx < best_idx:
  1102. best_idx, best_cat = idx, cat
  1103. if best_cat is None:
  1104. break
  1105. name = text[:best_idx]
  1106. text = text[best_idx + len(best_cat):]
  1107. num_str = ''
  1108. while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
  1109. c = '-' if text[0] in '\u2212\u2014' else text[0]
  1110. num_str += c
  1111. text = text[1:]
  1112. if not name or not num_str:
  1113. continue
  1114. ref_vals = ref_lookup.get(name) if ref_lookup else None
  1115. decoded = decode_compressed(name, num_str, ref_vals)
  1116. if decoded:
  1117. rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
  1118. else:
  1119. fmt = 'vertical'
  1120. all_lines = []
  1121. for i in range(food_start + 1, len(reader.pages)):
  1122. for line in norm(reader.pages[i].extract_text()).split('\n'):
  1123. lt = line.strip()
  1124. if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
  1125. continue
  1126. if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
  1127. continue
  1128. all_lines.append(lt)
  1129. i = 0
  1130. while i + 9 < len(all_lines):
  1131. name = all_lines[i].strip()
  1132. cat = all_lines[i + 1].strip()
  1133. if cat not in KNOWN_CATS:
  1134. i += 1
  1135. continue
  1136. nums = []
  1137. ok = True
  1138. for j in range(2, 10):
  1139. v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
  1140. try:
  1141. int(v)
  1142. nums.append(v)
  1143. except Exception:
  1144. ok = False
  1145. break
  1146. if ok and len(nums) == 8:
  1147. rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
  1148. i += 1
  1149. return rows, fmt
  1150. def _extract_food_rows(pdf_path):
  1151. """食物推荐表解析:优先复用同目录其他报告的参考营养表做压缩格式解码"""
  1152. ref_nutrition = {}
  1153. base_dir = os.path.dirname(pdf_path) or '.'
  1154. for fname in sorted(os.listdir(base_dir)):
  1155. if fname.lower().endswith('.pdf') and fname != os.path.basename(pdf_path):
  1156. try:
  1157. tr, _ = extract_food_table(os.path.join(base_dir, fname))
  1158. if len(tr) > 100:
  1159. for r in tr:
  1160. ref_nutrition[r['名称']] = [int(r[k]) for k in
  1161. ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
  1162. '总膳食纤维g', '胆固醇mg']]
  1163. break
  1164. except Exception:
  1165. continue
  1166. food_rows, food_fmt = extract_food_table(pdf_path, ref_nutrition or None)
  1167. return food_rows, food_fmt
  1168. # ============================================================
  1169. # 北京菌群报告 — 指纹检测
  1170. # ============================================================
  1171. FINGERPRINTS_BEIJING = [
  1172. "肠道微生物健康指数",
  1173. "GMHI",
  1174. "肠道菌群主要检测结果",
  1175. "核心菌属综合评分",
  1176. "有益菌综合评分",
  1177. "有害菌综合评分",
  1178. "肠道菌群精准分布",
  1179. "营养物质及营养素代谢评估",
  1180. "趣味肠菌评估",
  1181. "高通量测序",
  1182. ]
  1183. def detect_beijing_format(text):
  1184. """检测是否为北京菌群报告格式"""
  1185. beijing_matches = sum(1 for f in FINGERPRINTS_BEIJING if f in text)
  1186. if beijing_matches >= 3:
  1187. return True
  1188. return False
  1189. # ============================================================
  1190. # 北京菌群报告 — 章节提取(PyPDF2 版)
  1191. # ============================================================
  1192. # 疾病风险关键词(北京报告特有)
  1193. DISEASE_KEYWORDS_BJ = [
  1194. "溃疡性结肠炎", "克罗恩病", "腹泻型肠易激综合征",
  1195. "硬化性胆管炎", "非酒精性脂肪性肝病", "肝硬化",
  1196. "便秘", "IgA肾病", "肾结石", "格雷夫斯病",
  1197. "慢性淋巴细胞性甲状腺炎", "强直性脊柱炎",
  1198. "精神分裂症", "痛经",
  1199. "结直肠癌", "胃癌", "甲状腺癌", "肺癌", "乳腺癌",
  1200. "高血压", "冠心病", "2型糖尿病", "高脂血症",
  1201. "食管癌", "胆结石", "哮喘", "心脏病",
  1202. "膜性肾病", "帕金森病", "类风湿关节炎",
  1203. ]
  1204. # 抗生素分类(北京报告格式)
  1205. ANTIBIOTIC_CATEGORIES_BJ = [
  1206. "β内酰胺类", "氨基糖苷类", "大环内酯类", "四环素类",
  1207. "氯霉素类", "磺酰胺类", "喹诺酮类", "万古霉素类",
  1208. "头孢菌素类", "磷霉素类", "甲氧苄氨嘧啶类",
  1209. "N-乙酰基转移酶类", "O-磷酸转移酶类", "16S甲基转移酶",
  1210. ]
  1211. # 核心菌属 22 种(北京报告)
  1212. CORE_GENERA_BJ = [
  1213. ("阿克曼菌属", "Akkermansia"), ("另枝菌属", "Alistipes"),
  1214. ("拟杆菌属", "Bacteroides"), ("双歧杆菌属", "Bifidobacterium"),
  1215. ("布劳特氏菌属", "Blautia"), ("梭菌属", "Clostridium"),
  1216. ("粪球菌属", "Coprococcus"), ("戴阿利斯特杆菌属", "Dialister"),
  1217. ("多尔氏菌属", "Dorea"), ("真杆菌属", "Eubacterium"),
  1218. ("粪杆菌属", "Faecalibacterium"), ("Lachnoclostridium属", "Lachnoclostridium"),
  1219. ("毛螺菌属", "Lachnospira"), ("乳杆菌属", "Lactobacillus"),
  1220. ("巨单胞菌属", "Megamonas"), ("颤螺旋菌属", "Oscillospira"),
  1221. ("副拟杆菌属", "Parabacteroides"), ("考拉杆菌属", "Phascolarctobacterium"),
  1222. ("普雷沃氏菌属", "Prevotella"), ("罗氏菌属", "Roseburia"),
  1223. ("瘤胃球菌属", "Ruminococcus"), ("萨特氏菌属", "Sutterella"),
  1224. ]
  1225. # 病原体列表(北京报告)
  1226. PATHOGEN_LIST_BJ = [
  1227. "气单胞菌属", "蜡样芽孢杆菌", "唐菖蒲伯克霍尔德氏菌",
  1228. "弯曲杆菌属", "艰难梭菌", "肉毒梭菌", "产气荚膜梭菌",
  1229. "幽门螺杆菌", "肺炎克雷伯菌", "类志贺邻单胞菌",
  1230. "沙门氏菌属", "志贺氏菌属", "金黄色葡萄球菌",
  1231. "肺炎链球菌", "霍乱弧菌", "拟态弧菌", "副溶血弧菌",
  1232. "小肠结肠炎耶尔森氏菌", "假结核耶尔森氏菌",
  1233. ]
  1234. def _find_content_after_toc(text):
  1235. """找到北京报告中 TOC 之后的内容起始位置"""
  1236. # 找"第一部分 健康整体评估"后的内容,或者"健康整体评估"正文
  1237. markers = ['第一部分 健康整体评估', '菌群得分总览', '您属于肠']
  1238. for m in markers:
  1239. idx = text.find(m)
  1240. if idx != -1:
  1241. return idx
  1242. return 0
  1243. def _find_last_section(text, section_title):
  1244. """找到章节标题的最后一次出现(实际内容,非目录)"""
  1245. last = -1
  1246. start = 0
  1247. while True:
  1248. idx = text.find(section_title, start)
  1249. if idx == -1:
  1250. break
  1251. last = idx
  1252. start = idx + 1
  1253. return last
  1254. def _parse_bj_gmhi(text):
  1255. """提取 GMHI 肠道微生物健康指数(从内容区提取)"""
  1256. gmhi = {}
  1257. # 找最后一次出现的 GMHI 值(内容区,非目录)
  1258. content_start = _find_content_after_toc(text)
  1259. content = text[content_start:]
  1260. m = re.search(r'GMHI[)\)]?[为是](\d+)', content)
  1261. if m: gmhi['GMHI'] = float(m.group(1))
  1262. m = re.search(r'肠道微生物健康指数[为](\d+)', content)
  1263. if m: gmhi['肠道微生物健康指数'] = float(m.group(1))
  1264. m = re.search(r'肠道菌群状态[为::\s]*(\S+?)[。,\n]', content)
  1265. if m: gmhi['肠道菌群状态'] = m.group(1)
  1266. # 健康状态推断
  1267. if gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI'):
  1268. score = gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI', 0)
  1269. if score >= 90: gmhi['健康状态'] = '健康'
  1270. elif score >= 80: gmhi['健康状态'] = '健康倾向'
  1271. elif score >= 40: gmhi['健康状态'] = '亚健康'
  1272. elif score >= 20: gmhi['健康状态'] = '不健康倾向'
  1273. else: gmhi['健康状态'] = '不健康'
  1274. return gmhi
  1275. def _parse_bj_gut_type(text):
  1276. """提取肠型"""
  1277. content_start = _find_content_after_toc(text)
  1278. content = text[content_start:]
  1279. m = re.search(r'属于\s*肠[型道]\s*([IVX]+)', content)
  1280. if m: return f'肠型{m.group(1)}'
  1281. m = re.search(r'属于\s*肠[型道]\s*(\S+?)[,。,.\n]', content)
  1282. if m: return m.group(1).strip()
  1283. return None
  1284. def _parse_bj_balance(text):
  1285. """提取肠道菌群平衡评估"""
  1286. m = re.search(r'(I{1,3}度失衡)', text)
  1287. if m: return m.group(1)
  1288. return None
  1289. def _parse_bj_barrier(text):
  1290. """提取肠道黏膜屏障/BE比值"""
  1291. barrier = {}
  1292. # 在内容区找表格
  1293. content_start = _find_content_after_toc(text)
  1294. content = text[content_start:]
  1295. # 找 BE比值 表格行
  1296. m = re.search(r'BE比[值例][::\s]*(\d+\.?\d*)', text)
  1297. if m: barrier['BE比值'] = float(m.group(1))
  1298. m = re.search(r'双歧杆菌[属菌][::\s]*(\d+\.?\d*)', text)
  1299. if m: barrier['双歧杆菌属'] = float(m.group(1))
  1300. m = re.search(r'肠杆菌[科属][::\s]*(\d+\.?\d*)', text)
  1301. if m: barrier['肠杆菌科'] = float(m.group(1))
  1302. # 综合评估在"BE比值"附近找
  1303. be_idx = content.find('BE比')
  1304. if be_idx != -1:
  1305. chunk = content[be_idx:be_idx + 300]
  1306. m = re.search(r'(正常|轻度损伤|中度损伤|重度损伤|损伤)', chunk)
  1307. if m: barrier['综合评估'] = m.group(1)
  1308. return barrier
  1309. def _parse_bj_diversity(text):
  1310. """提取菌群多样性"""
  1311. div = {}
  1312. content_start = _find_content_after_toc(text)
  1313. content = text[content_start:]
  1314. m = re.search(r'多样性指数[为](\d+\.?\d*)', content)
  1315. if m: div['Shannon指数'] = float(m.group(1))
  1316. m = re.search(r'高于[::\s]*(\d+)%', content)
  1317. if m: div['人群百分位'] = f'高于{m.group(1)}%'
  1318. m = re.search(r'菌种总数[::\s]*(\d+)', content)
  1319. if m: div['菌种总数'] = int(m.group(1))
  1320. return div
  1321. def _parse_bj_phenotype(text):
  1322. """提取菌群表型评估"""
  1323. pheno = {}
  1324. # 找表型评估表格区域
  1325. last_idx = _find_last_section(text, '菌群表型评估')
  1326. if last_idx == -1:
  1327. last_idx = _find_last_section(text, '肠道菌群表型评估')
  1328. if last_idx == -1:
  1329. return pheno
  1330. # 取表格区域
  1331. region = text[last_idx:last_idx + 1500]
  1332. # 解析表格行: 指标名 评估值 结果
  1333. for label, kw in [('革兰氏阳性菌', '阳性菌'), ('革兰氏阴性菌', '阴性菌'),
  1334. ('生物膜合成', '生物膜'), ('好氧菌', '好氧'),
  1335. ('厌氧菌', '厌氧'), ('兼性厌氧菌', '兼性'),
  1336. ('氧化胁迫耐受', '氧化'), ('致病潜力', '致病')]:
  1337. # 找该行: 值在指标名附近
  1338. idx = region.find(kw)
  1339. if idx == -1:
  1340. continue
  1341. chunk = region[idx:idx + 100]
  1342. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1343. if val_m:
  1344. entry = {'评估值': float(val_m.group(1))}
  1345. for status in ['正常', '异常']:
  1346. if status in chunk:
  1347. entry['结果'] = status
  1348. break
  1349. pheno[label] = entry
  1350. return pheno
  1351. def _parse_bj_scfa(text):
  1352. """提取短链脂肪酸合成能力"""
  1353. scfa = {}
  1354. # 找 SCFA 表格区域(最后一次出现)
  1355. last_idx = _find_last_section(text, '短链脂肪酸合成能力')
  1356. if last_idx == -1:
  1357. last_idx = _find_last_section(text, '短链脂肪酸')
  1358. if last_idx == -1:
  1359. return scfa
  1360. region = text[last_idx:last_idx + 800]
  1361. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  1362. m = re.search(rf'{acid}[^a-zA-Z]*?(\d+)', region)
  1363. if m:
  1364. scfa[acid] = int(m.group(1))
  1365. return scfa
  1366. def _parse_bj_core_bacteria(text):
  1367. """提取核心菌属精准分布"""
  1368. bacteria = []
  1369. for cn_name, lat_name in CORE_GENERA_BJ:
  1370. idx = text.find(lat_name)
  1371. if idx == -1: idx = text.find(cn_name)
  1372. if idx == -1: continue
  1373. chunk = text[max(0, idx - 50):idx + 200]
  1374. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1375. status_m = re.search(r'(缺失|严重超标|超标|偏低|正常)', chunk)
  1376. pct_m = re.search(r'(\d+\.?\d*)%', chunk)
  1377. range_m = re.search(r'([\d.]+-[\d.]+)', chunk)
  1378. entry = {
  1379. '拉丁名': lat_name,
  1380. '中文名': cn_name,
  1381. '检测结果': float(val_m.group(1)) if val_m else None,
  1382. '状态': status_m.group(1) if status_m else '未知',
  1383. '人群百分位': float(pct_m.group(1)) if pct_m else None,
  1384. '参考范围': range_m.group(1) if range_m else None,
  1385. }
  1386. bacteria.append(entry)
  1387. return bacteria
  1388. def _parse_related_bacteria_in_chunk(chunk):
  1389. """从文本片段提取关联菌列表(如:乳杆菌属(缺乏)、另枝菌属(超标))"""
  1390. bacteria = []
  1391. for bm in re.finditer(
  1392. r'([\u4e00-\u9fa5]{2,10}?(?:菌|属|科|杆菌|球菌|梭菌))[((]\s*(超标|缺乏|偏高|偏低|异常|正常)\s*[))]',
  1393. chunk):
  1394. bacteria.append({
  1395. 'bacteria': bm.group(1),
  1396. 'correlation': bm.group(2),
  1397. 'value': '',
  1398. 'status': '',
  1399. })
  1400. return bacteria
  1401. def _parse_bj_disease_risks(text):
  1402. """提取疾病风险评估(含关联菌 P0)"""
  1403. diseases = []
  1404. for keyword in DISEASE_KEYWORDS_BJ:
  1405. idx = text.find(keyword)
  1406. if idx == -1: continue
  1407. chunk = text[idx:idx + 200]
  1408. val_m = re.search(r'(\d+\.\d+)', chunk)
  1409. level_m = re.search(r'(低风险|较低风险|中度风险|较高风险|高风险)', chunk)
  1410. if val_m:
  1411. entry = {
  1412. '疾病': keyword,
  1413. '风险指数': float(val_m.group(1)),
  1414. '风险等级': level_m.group(1) if level_m else '',
  1415. }
  1416. bacteria = _parse_related_bacteria_in_chunk(chunk)
  1417. if bacteria:
  1418. entry['related_bacteria'] = bacteria
  1419. diseases.append(entry)
  1420. # 补充:从「疾病肠菌评估(异常项)」章节提取关联菌,按疾病名匹配补挂
  1421. ab_idx = text.find('疾病肠菌评估')
  1422. if ab_idx != -1:
  1423. ab_sec = text[ab_idx:ab_idx + 1800]
  1424. for entry in diseases:
  1425. if entry.get('related_bacteria'):
  1426. continue
  1427. kw_idx = ab_sec.find(entry['疾病'])
  1428. if kw_idx == -1:
  1429. continue
  1430. # 截断到下一个疾病关键字,避免跨疾病串扰
  1431. end = len(ab_sec)
  1432. for other in DISEASE_KEYWORDS_BJ:
  1433. if other == entry['疾病']:
  1434. continue
  1435. oi = ab_sec.find(other, kw_idx + len(entry['疾病']))
  1436. if oi != -1 and oi < end:
  1437. end = oi
  1438. chunk = ab_sec[kw_idx:end]
  1439. bacteria = _parse_related_bacteria_in_chunk(chunk)
  1440. if bacteria:
  1441. entry['related_bacteria'] = bacteria
  1442. return diseases
  1443. def _parse_bj_nutrient_metabolism(text):
  1444. """提取营养物质及营养素代谢评估"""
  1445. metabolism = {}
  1446. # 主要营养物质
  1447. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1448. m = re.search(rf'{n}[^估]*?(\d+)', text)
  1449. if m:
  1450. metabolism.setdefault('主要营养物质代谢', {})[n] = int(m.group(1))
  1451. # 糖类
  1452. for item in ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1453. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']:
  1454. m = re.search(rf'{item[:4]}.*?(\d+)', text)
  1455. if m:
  1456. metabolism.setdefault('糖类代谢', {})[item] = int(m.group(1))
  1457. # 脂类
  1458. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1459. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1460. 'Omega-3', 'Omega-6']:
  1461. m = re.search(rf'{item}.*?(\d+)', text)
  1462. if m:
  1463. metabolism.setdefault('脂类代谢', {})[item] = int(m.group(1))
  1464. # 维生素
  1465. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1466. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1467. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1468. m = re.search(rf'{item}.*?(\d+)', text)
  1469. if m:
  1470. metabolism.setdefault('维生素', {})[item] = int(m.group(1))
  1471. # 微量元素
  1472. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1473. m = re.search(rf'{item}[^估]*?(\d+)', text)
  1474. if m:
  1475. metabolism.setdefault('微量元素', {})[item] = int(m.group(1))
  1476. # 氨基酸
  1477. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1478. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1479. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1480. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1481. m = re.search(rf'{item}[^估]*?(\d+)', text)
  1482. if m:
  1483. metabolism.setdefault('氨基酸', {})[item] = int(m.group(1))
  1484. # 神经递质
  1485. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1486. m = re.search(rf'{item}.*?(\d+)', text)
  1487. if m:
  1488. metabolism.setdefault('神经递质', {})[item] = int(m.group(1))
  1489. # 其他单项
  1490. for pattern, key in [
  1491. (r'嘌呤代谢.*?(\d+)', '嘌呤代谢'),
  1492. (r'谷胱甘肽.*?(\d+)', '三肽(谷胱甘肽)'),
  1493. (r'胆汁酸.*?(\d+)', '胆汁酸代谢'),
  1494. (r'硫辛酸.*?(\d+)', '抗自由基(硫辛酸)'),
  1495. (r'辅酶Q.*?(\d+)', '抗自由基(辅酶Q)'),
  1496. ]:
  1497. m = re.search(pattern, text)
  1498. if m:
  1499. metabolism[key] = int(m.group(1))
  1500. return metabolism
  1501. def _parse_bj_antibiotics(text):
  1502. """提取抗生素风险评估"""
  1503. ab = {}
  1504. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1505. m = re.search(rf'{cat}[::\s]*(\d+)', text)
  1506. if m:
  1507. ab[cat] = int(m.group(1))
  1508. return ab
  1509. def _parse_bj_toxins(text):
  1510. """提取毒性物质清除能力评估"""
  1511. toxins = {}
  1512. toxin_items = [
  1513. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1514. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1515. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1516. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1517. "萘", "多环芳烃",
  1518. ]
  1519. for item in toxin_items:
  1520. m = re.search(rf'{item}.*?(\d+)', text)
  1521. if m:
  1522. toxins[item] = int(m.group(1))
  1523. return toxins
  1524. def _parse_bj_pathogens(text):
  1525. """提取胃肠道感染病原体评估"""
  1526. pathogens = []
  1527. for p in PATHOGEN_LIST_BJ:
  1528. idx = text.find(p)
  1529. if idx == -1: continue
  1530. chunk = text[idx:idx + 150]
  1531. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1532. status_m = re.search(r'(检出|超标|未检出|正常)', chunk)
  1533. if val_m:
  1534. entry = {'名称': p, '检测值': float(val_m.group(1)) if '.' in val_m.group(1) else int(val_m.group(1))}
  1535. if status_m: entry['状态'] = status_m.group(1)
  1536. pathogens.append(entry)
  1537. return pathogens
  1538. def _parse_bj_psych_eval(text, find_table_val=None):
  1539. """提取趣味肠菌评估"""
  1540. psych = {}
  1541. m = re.search(r'最高概率[::\s]*(\S+)', text)
  1542. if m: psych['人格特征'] = {'最高概率': m.group(1)}
  1543. # 认知功能(从表格)
  1544. if find_table_val:
  1545. cog = find_table_val('认知能力', val_col=1)
  1546. if cog:
  1547. psych['认知功能'] = {'认知能力评分': int(cog)}
  1548. return psych
  1549. def _parse_bj_scfa_from_tables(find_table_rows):
  1550. """从表格提取短链脂肪酸合成能力"""
  1551. scfa = {}
  1552. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  1553. rows = find_table_rows(acid)
  1554. for name, val in rows:
  1555. if acid in name:
  1556. scfa[acid] = int(val)
  1557. break
  1558. return scfa
  1559. def _parse_bj_core_bacteria_from_tables(all_tables):
  1560. """从表格提取核心菌属精准分布"""
  1561. bacteria = []
  1562. for cn_name, lat_name in CORE_GENERA_BJ:
  1563. for table in all_tables:
  1564. for row in table:
  1565. row_str = ' '.join(str(c) for c in row if c)
  1566. if lat_name in row_str or cn_name in row_str:
  1567. vals = [str(c).strip() for c in row if c and str(c).strip()]
  1568. entry = {'拉丁名': lat_name, '中文名': cn_name}
  1569. for v in vals:
  1570. v_clean = v.replace('"', '').replace('#', '').strip()
  1571. # 判断是否为范围值(如 0.0041-0.0339)
  1572. if '-' in v_clean and v_clean.replace('.','').replace('-',' ').replace(' ','').isdigit():
  1573. entry['参考范围'] = v_clean
  1574. elif v_clean == 'ND':
  1575. if '检测结果' not in entry:
  1576. entry['检测结果'] = 0.0
  1577. elif v_clean.replace('.','').replace('-','').isdigit() and v_clean != '':
  1578. val = float(v_clean)
  1579. if '检测结果' not in entry:
  1580. entry['检测结果'] = val
  1581. elif '人群百分位' not in entry:
  1582. entry['人群百分位'] = val
  1583. if '检测结果' in entry:
  1584. bacteria.append(entry)
  1585. break
  1586. if bacteria and bacteria[-1].get('拉丁名') == lat_name:
  1587. break
  1588. return bacteria
  1589. def _parse_bj_nutrient_from_tables(find_table_rows, find_table_val):
  1590. """从表格提取营养物质及营养素代谢评估"""
  1591. metabolism = {}
  1592. # 主要营养物质
  1593. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1594. rows = find_table_rows(n)
  1595. if rows:
  1596. for name, val in rows:
  1597. if n in name:
  1598. metabolism.setdefault('主要营养物质代谢', {})[n] = int(val)
  1599. # 糖类
  1600. sugar_items = ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1601. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']
  1602. for item in sugar_items:
  1603. rows = find_table_rows(item[:3])
  1604. if rows:
  1605. for name, val in rows:
  1606. metabolism.setdefault('糖类代谢', {})[item] = int(val)
  1607. # 脂类
  1608. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1609. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1610. 'Omega-3', 'Omega-6']:
  1611. rows = find_table_rows(item[:3])
  1612. if rows:
  1613. for name, val in rows:
  1614. metabolism.setdefault('脂类代谢', {})[item] = int(val)
  1615. # 维生素
  1616. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1617. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1618. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1619. rows = find_table_rows(item)
  1620. if rows:
  1621. for name, val in rows:
  1622. metabolism.setdefault('维生素', {})[item] = int(val)
  1623. # 微量元素
  1624. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1625. rows = find_table_rows(item)
  1626. if rows:
  1627. for name, val in rows:
  1628. if name == item:
  1629. metabolism.setdefault('微量元素', {})[item] = int(val)
  1630. # 氨基酸
  1631. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1632. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1633. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1634. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1635. rows = find_table_rows(item[:2])
  1636. if rows:
  1637. for name, val in rows:
  1638. metabolism.setdefault('氨基酸', {})[item] = int(val)
  1639. # 神经递质
  1640. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1641. rows = find_table_rows(item[:3])
  1642. if rows:
  1643. for name, val in rows:
  1644. metabolism.setdefault('神经递质', {})[item] = int(val)
  1645. # 其他单项
  1646. for item, key in [('嘌呤代谢', '嘌呤代谢'), ('谷胱甘肽', '三肽(谷胱甘肽)'),
  1647. ('胆汁酸', '胆汁酸代谢'), ('硫辛酸', '抗自由基(硫辛酸)'),
  1648. ('辅酶Q', '抗自由基(辅酶Q)')]:
  1649. v = find_table_val(item)
  1650. if v: metabolism[key] = int(v)
  1651. return metabolism
  1652. def _parse_bj_antibiotics_from_tables(find_table_rows):
  1653. """从表格提取抗生素风险评估"""
  1654. ab = {}
  1655. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1656. rows = find_table_rows(cat, val_col=2)
  1657. for name, val in rows:
  1658. ab[cat] = int(val)
  1659. return ab
  1660. def _parse_bj_toxins_from_tables(all_tables, find_table_rows):
  1661. """从表格提取毒性物质清除能力评估"""
  1662. toxins = {}
  1663. toxin_items = [
  1664. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1665. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1666. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1667. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1668. "萘", "多环芳烃",
  1669. ]
  1670. for item in toxin_items:
  1671. rows = find_table_rows(item[:3], name_col=1, val_col=2)
  1672. for name, val in rows:
  1673. toxins[item] = int(val)
  1674. return toxins
  1675. def _parse_bj_overview(text):
  1676. """提取北京报告概述(统一到标准报告 overview 结构)"""
  1677. r = {}
  1678. m = re.search(r'检测编号[::\s]*(\S+)', text)
  1679. if m: r['report_number'] = m.group(1)
  1680. m = re.search(r'姓\s*名[::\s]*(\S+)', text)
  1681. if m: r['person_name'] = m.group(1)
  1682. m = re.search(r'年\s*龄[::\s]*(\S+)', text)
  1683. if m: r['age'] = m.group(1)
  1684. m = re.search(r'性\s*别[::\s]*(\S+)', text)
  1685. if m: r['gender'] = 'male' if m.group(1) in ('男', 'M') else 'female'
  1686. # V-02 fix: 补 report_date 提取;无则保持 null(不造假值)
  1687. report_date = _extract_report_date(text)
  1688. if report_date:
  1689. r['report_date'] = report_date
  1690. # 肠型
  1691. gut_type = _parse_bj_gut_type(text)
  1692. if gut_type: r['gut_type'] = gut_type
  1693. # GMHI → 健康总分
  1694. m = re.search(r'肠道微生物健康指数[::\s]*(\d+)', text)
  1695. if m: r['overallScore'] = int(m.group(1))
  1696. return r
  1697. def _parse_beijing_report(file_path: str) -> dict:
  1698. """解析北京菌群报告 PDF,输出统一结构(pdfplumber 表格+文本混合提取)"""
  1699. try:
  1700. import pdfplumber
  1701. with pdfplumber.open(file_path) as pdf:
  1702. text = '\n'.join(page.extract_text() or '' for page in pdf.pages)
  1703. # 提取所有表格
  1704. all_tables = []
  1705. for page in pdf.pages:
  1706. tables = page.extract_tables()
  1707. for table in tables:
  1708. if table and len(table) >= 2:
  1709. all_tables.append(table)
  1710. except Exception as e:
  1711. from PyPDF2 import PdfReader
  1712. reader = PdfReader(file_path)
  1713. text = '\n'.join((page.extract_text() or '') for page in reader.pages)
  1714. all_tables = []
  1715. def find_table_rows(keyword, val_col=1, name_col=0, try_all=False):
  1716. """从表格中找指定关键词的数据行"""
  1717. rows = []
  1718. for table in all_tables:
  1719. for row in table:
  1720. row_str = ' '.join(str(c) for c in row if c)
  1721. if keyword in row_str:
  1722. if name_col < len(row) and val_col < len(row):
  1723. name = str(row[name_col] or '').strip()
  1724. val = str(row[val_col] or '').strip()
  1725. if name and val and val.replace('.','').replace('-','').isdigit():
  1726. rows.append((name, val))
  1727. if rows and not try_all:
  1728. break
  1729. return rows
  1730. def find_table_val(keyword, val_col=1, name_col=0):
  1731. """从表格中找单个值"""
  1732. for table in all_tables:
  1733. for row in table:
  1734. row_str = ' '.join(str(c) for c in row if c)
  1735. if keyword in row_str:
  1736. if val_col < len(row):
  1737. val = str(row[val_col] or '').strip()
  1738. if val and val.replace('.','').isdigit():
  1739. return val
  1740. return None
  1741. result = {
  1742. 'format': 'beijing',
  1743. 'overview': _parse_bj_overview(text),
  1744. 'disease_risks': [],
  1745. 'nutrition': [],
  1746. 'amino_acids': [],
  1747. 'vitamins': [],
  1748. 'trace_elements': [],
  1749. '菌群检出详细列表': {},
  1750. '个体化食物推荐表': {'格式': 'not_found', '条目数': 0, '数据': []},
  1751. # 北京报告特有指标
  1752. '肠道微生物健康指数': _parse_bj_gmhi(text),
  1753. '菌群多样性': _parse_bj_diversity(text),
  1754. '肠道黏膜屏障': _parse_bj_barrier(text),
  1755. '菌群表型评估': _parse_bj_phenotype(text),
  1756. '短链脂肪酸合成能力': _parse_bj_scfa_from_tables(find_table_rows),
  1757. '肠道菌群精准分布': _parse_bj_core_bacteria_from_tables(all_tables),
  1758. '营养物质及营养素代谢评估': _parse_bj_nutrient_from_tables(find_table_rows, find_table_val),
  1759. '抗生素风险评估': _parse_bj_antibiotics_from_tables(find_table_rows),
  1760. '毒性物质清除能力评估': _parse_bj_toxins_from_tables(all_tables, find_table_rows),
  1761. '趣味肠菌评估': _parse_bj_psych_eval(text, find_table_val),
  1762. '胃肠道感染病原体': [],
  1763. '健康整体评估': {},
  1764. '肠道菌群主要检测结果': {},
  1765. }
  1766. # 从表格更新 GMHI/抗炎/免疫/纤维/多样性/屏障
  1767. gmhi_val = find_table_val('肠道微生物健康指数', val_col=1)
  1768. if gmhi_val:
  1769. result['肠道微生物健康指数']['肠道微生物健康指数'] = float(gmhi_val)
  1770. score = float(gmhi_val)
  1771. if score >= 90: result['肠道微生物健康指数']['健康状态'] = '健康'
  1772. elif score >= 80: result['肠道微生物健康指数']['健康状态'] = '健康倾向'
  1773. elif score >= 40: result['肠道微生物健康指数']['健康状态'] = '亚健康'
  1774. elif score >= 20: result['肠道微生物健康指数']['健康状态'] = '不健康倾向'
  1775. else: result['肠道微生物健康指数']['健康状态'] = '不健康'
  1776. for attr, kw in [('肠道抗炎能力', '抗炎能力'), ('肠道免疫力', '免疫力'),
  1777. ('肠道膳食纤维需求', '膳食纤维需求')]:
  1778. v = find_table_val(kw)
  1779. if v:
  1780. result.setdefault('肠道菌群主要检测结果', {})[attr] = int(v)
  1781. if '抗炎' in kw: result['overview']['inflammationScore'] = int(v)
  1782. elif '免疫' in kw: result['overview']['immunityScore'] = int(v)
  1783. # 多样性
  1784. div_val = find_table_val('肠道微生物多样性', val_col=1)
  1785. if div_val:
  1786. result['菌群多样性']['Shannon指数'] = float(div_val)
  1787. # BE比值
  1788. be_val = find_table_val('B/E比值', val_col=1)
  1789. if be_val:
  1790. result['肠道黏膜屏障']['BE比值'] = float(be_val)
  1791. bi_val = find_table_val('双歧杆菌属', val_col=1)
  1792. if bi_val:
  1793. result['肠道黏膜屏障']['双歧杆菌属'] = float(bi_val)
  1794. ent_val = find_table_val('肠杆菌科', val_col=1)
  1795. if ent_val:
  1796. result['肠道黏膜屏障']['肠杆菌科'] = float(ent_val)
  1797. # 肠型
  1798. gut_type = _parse_bj_gut_type(text)
  1799. balance = _parse_bj_balance(text)
  1800. if gut_type or balance:
  1801. result['健康整体评估'] = {}
  1802. if gut_type: result['健康整体评估']['肠型'] = gut_type
  1803. if balance: result['健康整体评估']['菌群平衡评估'] = balance
  1804. # 疾病风险评估(文本提取)
  1805. disease_risks = _parse_bj_disease_risks(text)
  1806. if disease_risks:
  1807. result['disease_risks'] = disease_risks
  1808. # 病原体
  1809. pathogens = _parse_bj_pathogens(text)
  1810. if pathogens:
  1811. result['胃肠道感染病原体'] = pathogens
  1812. # 肠道菌群状态 → overview.gutStatusLevel(P1)
  1813. status = result.get('肠道微生物健康指数', {}).get('肠道菌群状态')
  1814. if status:
  1815. result['overview']['gutStatusLevel'] = status
  1816. # 营养物质 → 标准字段映射
  1817. metabolism = result.get('营养物质及营养素代谢评估', {})
  1818. if metabolism.get('主要营养物质代谢'):
  1819. result['nutrition'] = [
  1820. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1821. for k, v in metabolism['主要营养物质代谢'].items()
  1822. ]
  1823. if metabolism.get('氨基酸'):
  1824. result['amino_acids'] = [
  1825. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1826. for k, v in metabolism['氨基酸'].items()
  1827. ]
  1828. if metabolism.get('维生素'):
  1829. result['vitamins'] = [
  1830. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1831. for k, v in metabolism['维生素'].items()
  1832. ]
  1833. if metabolism.get('微量元素'):
  1834. result['trace_elements'] = [
  1835. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1836. for k, v in metabolism['微量元素'].items()
  1837. ]
  1838. return result