wiki_spider.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """
  2. 维基百科药品数据采集器
  3. 从中文维基百科采集药品条目,输出标准 JSON 格式
  4. 特性:
  5. - 频率控制:每次请求间隔 3 秒(尊重维基百科服务器)
  6. - 批次处理:每 20 个药品暂停 60 秒
  7. - 断点续采:已采集的自动跳过
  8. - 输出符合 drug_schema.json 规范
  9. """
  10. import json
  11. import re
  12. import time
  13. import sys
  14. import os
  15. from pathlib import Path
  16. from urllib.parse import quote
  17. import httpx
  18. from bs4 import BeautifulSoup
  19. # ============================================
  20. # 配置
  21. # ============================================
  22. REQUEST_INTERVAL = 3.0 # 单次请求间隔(秒)
  23. BATCH_SIZE = 20 # 每批采集数量
  24. BATCH_PAUSE = 60 # 批次间暂停(秒)
  25. WIKI_BASE = "https://zh.wikipedia.org/wiki/"
  26. HEADERS = {
  27. "User-Agent": "PharmacopoeiaAI/1.0 (Research bot; contact@example.com) Mozilla/5.0",
  28. "Accept-Language": "zh-CN,zh;q=0.9",
  29. }
  30. # 匹配药典相关 section 标题
  31. SECTION_PATTERNS = {
  32. "性状": re.compile(r"(性状|外观|物理性质|Description|Appearance)"),
  33. "适应症": re.compile(r"(适应症|适应征|主治|功能主治|Indications)"),
  34. "用法与用量": re.compile(r"(用法|用量|剂量|给药|Dosage|Administration)"),
  35. "禁忌": re.compile(r"(禁忌|禁用|Contraindications)"),
  36. "不良反应": re.compile(r"(不良反|副作用|Side.effect|Adverse)"),
  37. "注意事项": re.compile(r"(注意|警告|谨慎|Precautions|Warnings)"),
  38. "贮藏": re.compile(r"(贮藏|储存|保存|Storage)"),
  39. "药理": re.compile(r"(药理|药效|作用机制|Pharmacology|Mechanism)"),
  40. }
  41. # 内容长度限制(避免单 section 过长超出 LLM 上下文窗口)
  42. MAX_OVERVIEW_LENGTH = 5000
  43. MAX_SECTION_LENGTH = 8000
  44. def fetch_wiki_page(drug_name: str, client: httpx.Client) -> tuple[int, str]:
  45. """获取维基百科页面"""
  46. url = WIKI_BASE + quote(drug_name)
  47. resp = client.get(url, headers=HEADERS, follow_redirects=True)
  48. # 检查是否重定向到非药品页面(如消歧义页)
  49. if resp.status_code == 200:
  50. text = resp.text
  51. if "维基百科目前还没有" in text or "Wikipedia does not have" in text:
  52. return 404, ""
  53. return resp.status_code, resp.text
  54. def parse_drug_sections(html: str, drug_name: str) -> dict:
  55. """解析维基百科页面,提取药品相关信息"""
  56. soup = BeautifulSoup(html, "lxml")
  57. # 提取基本信息
  58. infobox = {}
  59. info_table = soup.find("table", class_="infobox")
  60. if info_table:
  61. for row in info_table.find_all("tr"):
  62. th = row.find("th")
  63. td = row.find("td")
  64. if th and td:
  65. key = th.get_text(strip=True)
  66. val = td.get_text(" ", strip=True)
  67. infobox[key] = val
  68. # 提取正文内容(跳过导航、引用等)
  69. content = soup.find("div", class_="mw-parser-output")
  70. if not content:
  71. return {"sections": {}, "infobox": infobox}
  72. sections = {}
  73. current_section = "概述"
  74. current_text = []
  75. overview_text = []
  76. # 提取开头概述(第一个标题之前的内容)
  77. for elem in content.children:
  78. # 找第一个 h2 之前的段落
  79. if elem.name in ("h2", "h3", "h4"):
  80. # 保存之前的内容
  81. if overview_text:
  82. sections["概述"] = " ".join(overview_text)[:MAX_OVERVIEW_LENGTH]
  83. overview_text = []
  84. break
  85. if elem.name == "p" or elem.name == "div":
  86. t = elem.get_text(" ", strip=True)
  87. if len(t) > 20:
  88. overview_text.append(t)
  89. if overview_text:
  90. sections["概述"] = " ".join(overview_text)[:MAX_OVERVIEW_LENGTH]
  91. # 提取各 section
  92. for elem in content.children:
  93. if elem.name in ("h2", "h3", "h4"):
  94. if current_text and current_section:
  95. matched = None
  96. for key, pattern in SECTION_PATTERNS.items():
  97. if pattern.search(current_section):
  98. matched = key
  99. break
  100. if matched:
  101. sections[matched] = " ".join(current_text)[:MAX_SECTION_LENGTH]
  102. else:
  103. sections[current_section] = " ".join(current_text)[:MAX_SECTION_LENGTH]
  104. current_section = elem.get_text(strip=True).replace("[编辑]", "").strip()
  105. current_text = []
  106. continue
  107. if elem.name in ("p", "ul", "ol", "div"):
  108. t = elem.get_text(" ", strip=True)
  109. if len(t) > 10:
  110. current_text.append(t)
  111. # 保存最后一个 section
  112. if current_text and current_section:
  113. matched = None
  114. for key, pattern in SECTION_PATTERNS.items():
  115. if pattern.search(current_section):
  116. matched = key
  117. break
  118. if matched:
  119. sections[matched] = " ".join(current_text)[:MAX_SECTION_LENGTH]
  120. else:
  121. sections[current_section] = " ".join(current_text)[:MAX_SECTION_LENGTH]
  122. return {
  123. "sections": sections,
  124. "infobox": infobox,
  125. }
  126. def wiki_to_drug_entry(drug_name: str, parsed: dict) -> dict:
  127. """转换为标准 drug entry JSON"""
  128. infobox = parsed.get("infobox", {})
  129. sections = parsed.get("sections", {})
  130. return {
  131. "drug_id": f"WIKI-{drug_name[:30]}",
  132. "name": drug_name,
  133. "name_en": infobox.get("其他名称", ""),
  134. "pinyin": "",
  135. "category": infer_category(drug_name, sections),
  136. "subcategory": "",
  137. "sections": sections,
  138. "source": {
  139. "version": "维基百科",
  140. "volume": f"https://zh.wikipedia.org/wiki/{quote(drug_name)}",
  141. "page": "线上条目",
  142. },
  143. }
  144. def infer_category(name: str, sections: dict) -> str:
  145. """推断药品分类(基于名称和正文内容综合判断)"""
  146. all_text = " ".join(sections.values()) + name
  147. # 生物制品特征词(优先级最高)
  148. bio_markers = ["疫苗", "毒素", "抗血清", "免疫球蛋白", "单克隆抗体", "重组", "基因工程"]
  149. if any(k in all_text for k in bio_markers):
  150. return "生物制品"
  151. # 中药特征:多个特征同时出现才判定为中药,减少误判
  152. tcm_name_markers = ["丸", "散", "汤", "丹", "膏", "颗粒", "胶囊"]
  153. tcm_content_markers = ["中药", "本草", "性味", "归经", "炮制", "饮片"]
  154. tcm_herb_chars = ["草", "花", "叶", "根", "皮", "参", "芪", "苓", "术", "芍", "芷"]
  155. # 检查药品名是否含多个中药特征
  156. name_tcm_score = sum(1 for k in tcm_name_markers if k in name)
  157. name_tcm_score += sum(1 for c in tcm_herb_chars if c in name)
  158. # 检查正文是否含中药特征
  159. content_tcm = any(k in all_text for k in tcm_content_markers)
  160. # 名称含中药剂型 + 至少 1 个其他特征 → 中药
  161. if name_tcm_score >= 2 or (name_tcm_score >= 1 and content_tcm):
  162. return "中药"
  163. return "化学药"
  164. def main():
  165. # 读取药品索引
  166. index_path = Path(__file__).resolve().parent.parent / "data" / "drug_index.json"
  167. with open(index_path, "r") as f:
  168. drug_list = json.load(f)
  169. # 输出目录
  170. output_dir = Path(__file__).resolve().parent.parent / "data" / "wiki_drugs"
  171. output_dir.mkdir(parents=True, exist_ok=True)
  172. # 已采集的跳过(断点续采)
  173. already = set(f.stem for f in output_dir.glob("*.json"))
  174. # 只采集化学药 + 生物制品(跳过中药、通则需要更复杂的解析)
  175. candidates = [d for d in drug_list if d["volume"] in (2, 3)]
  176. print(f"📋 候选药品: {len(candidates)} 个(二部化学药 + 三部生物制品)")
  177. print(f" 已采集: {len(already)} 个")
  178. print(f" 待采集: {len(candidates) - len(already)} 个")
  179. print(f" 频率控制: {REQUEST_INTERVAL}s/次, 每{BATCH_SIZE}次暂停{BATCH_PAUSE}s")
  180. print(f" 预计耗时: ~{(len(candidates) - len(already)) * (REQUEST_INTERVAL + 1) / 60:.0f} 分钟\n")
  181. client = httpx.Client(timeout=20, follow_redirects=True)
  182. count = 0
  183. success = 0
  184. failed = 0
  185. for drug in candidates:
  186. name = drug["name"]
  187. safe_name = name.replace("/", "_").replace(":", "_")
  188. if safe_name in already:
  189. continue
  190. count += 1
  191. status, html = fetch_wiki_page(name, client)
  192. time.sleep(REQUEST_INTERVAL)
  193. if status != 200 or not html:
  194. print(f" [{count}] ❌ {name} (HTTP {status})")
  195. failed += 1
  196. else:
  197. try:
  198. parsed = parse_drug_sections(html, name)
  199. entry = wiki_to_drug_entry(name, parsed)
  200. # 至少有一个 section 才算有效
  201. sections_count = len(entry["sections"])
  202. if sections_count < 1:
  203. print(f" [{count}] ⚠️ {name} (无有效内容)")
  204. failed += 1
  205. continue
  206. out_path = output_dir / f"{safe_name}.json"
  207. with open(out_path, "w", encoding="utf-8") as f:
  208. json.dump(entry, f, ensure_ascii=False, indent=2)
  209. print(f" [{count}] ✅ {name} ({sections_count} sections)")
  210. success += 1
  211. except Exception as e:
  212. print(f" [{count}] ❌ {name} (解析失败: {str(e)[:50]})")
  213. failed += 1
  214. # 批次间暂停
  215. if count % BATCH_SIZE == 0 and count > 0:
  216. print(f"\n⏸ 已处理 {count} 个,暂停 {BATCH_PAUSE}s...\n")
  217. time.sleep(BATCH_PAUSE)
  218. client.close()
  219. print(f"\n{'='*50}")
  220. print(f"采集完成: 成功 {success}, 失败 {failed}, 总计 {count}")
  221. print(f"输出目录: {output_dir}")
  222. print(f"{'='*50}")
  223. if __name__ == "__main__":
  224. main()