wiki_spider.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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. def fetch_wiki_page(drug_name: str, client: httpx.Client) -> tuple[int, str]:
  42. """获取维基百科页面"""
  43. url = WIKI_BASE + quote(drug_name)
  44. resp = client.get(url, headers=HEADERS, follow_redirects=True)
  45. # 检查是否重定向到非药品页面(如消歧义页)
  46. if resp.status_code == 200:
  47. text = resp.text
  48. if "维基百科目前还没有" in text or "Wikipedia does not have" in text:
  49. return 404, ""
  50. return resp.status_code, resp.text
  51. def parse_drug_sections(html: str, drug_name: str) -> dict:
  52. """解析维基百科页面,提取药品相关信息"""
  53. soup = BeautifulSoup(html, "lxml")
  54. # 提取基本信息
  55. infobox = {}
  56. info_table = soup.find("table", class_="infobox")
  57. if info_table:
  58. for row in info_table.find_all("tr"):
  59. th = row.find("th")
  60. td = row.find("td")
  61. if th and td:
  62. key = th.get_text(strip=True)
  63. val = td.get_text(" ", strip=True)
  64. infobox[key] = val
  65. # 提取正文内容(跳过导航、引用等)
  66. content = soup.find("div", class_="mw-parser-output")
  67. if not content:
  68. return {"sections": {}, "infobox": infobox}
  69. sections = {}
  70. current_section = "概述"
  71. current_text = []
  72. overview_text = []
  73. # 提取开头概述(第一个标题之前的内容)
  74. for elem in content.children:
  75. # 找第一个 h2 之前的段落
  76. if elem.name in ("h2", "h3", "h4"):
  77. # 保存之前的内容
  78. if overview_text:
  79. sections["概述"] = " ".join(overview_text)[:2000]
  80. overview_text = []
  81. break
  82. if elem.name == "p" or elem.name == "div":
  83. t = elem.get_text(" ", strip=True)
  84. if len(t) > 20:
  85. overview_text.append(t)
  86. if overview_text:
  87. sections["概述"] = " ".join(overview_text)[:2000]
  88. # 提取各 section
  89. for elem in content.children:
  90. if elem.name in ("h2", "h3", "h4"):
  91. if current_text and current_section:
  92. matched = None
  93. for key, pattern in SECTION_PATTERNS.items():
  94. if pattern.search(current_section):
  95. matched = key
  96. break
  97. if matched:
  98. sections[matched] = " ".join(current_text)[:3000]
  99. else:
  100. sections[current_section] = " ".join(current_text)[:3000]
  101. current_section = elem.get_text(strip=True).replace("[编辑]", "").strip()
  102. current_text = []
  103. continue
  104. if elem.name in ("p", "ul", "ol", "div"):
  105. t = elem.get_text(" ", strip=True)
  106. if len(t) > 10:
  107. current_text.append(t)
  108. # 保存最后一个 section
  109. if current_text and current_section:
  110. matched = None
  111. for key, pattern in SECTION_PATTERNS.items():
  112. if pattern.search(current_section):
  113. matched = key
  114. break
  115. if matched:
  116. sections[matched] = " ".join(current_text)[:3000]
  117. else:
  118. sections[current_section] = " ".join(current_text)[:3000]
  119. return {
  120. "sections": sections,
  121. "infobox": infobox,
  122. }
  123. def wiki_to_drug_entry(drug_name: str, parsed: dict) -> dict:
  124. """转换为标准 drug entry JSON"""
  125. infobox = parsed.get("infobox", {})
  126. sections = parsed.get("sections", {})
  127. return {
  128. "drug_id": f"WIKI-{drug_name[:30]}",
  129. "name": drug_name,
  130. "name_en": infobox.get("其他名称", ""),
  131. "pinyin": "",
  132. "category": infer_category(drug_name, sections),
  133. "subcategory": "",
  134. "sections": sections,
  135. "source": {
  136. "version": "维基百科",
  137. "volume": f"https://zh.wikipedia.org/wiki/{quote(drug_name)}",
  138. "page": "线上条目",
  139. },
  140. }
  141. def infer_category(name: str, sections: dict) -> str:
  142. """推断药品分类"""
  143. all_text = " ".join(sections.values()) + name
  144. if any(k in all_text for k in ["疫苗", "毒素", "抗血清", "免疫球蛋白"]):
  145. return "生物制品"
  146. if any(k in name for k in ["草", "花", "叶", "根", "皮", "丸", "散", "汤", "丹"]):
  147. return "中药"
  148. return "化学药"
  149. def main():
  150. # 读取药品索引
  151. index_path = Path(__file__).resolve().parent.parent / "data" / "drug_index.json"
  152. with open(index_path, "r") as f:
  153. drug_list = json.load(f)
  154. # 输出目录
  155. output_dir = Path(__file__).resolve().parent.parent / "data" / "wiki_drugs"
  156. output_dir.mkdir(parents=True, exist_ok=True)
  157. # 已采集的跳过(断点续采)
  158. already = set(f.stem for f in output_dir.glob("*.json"))
  159. # 只采集化学药 + 生物制品(跳过中药、通则需要更复杂的解析)
  160. candidates = [d for d in drug_list if d["volume"] in (2, 3)]
  161. print(f"📋 候选药品: {len(candidates)} 个(二部化学药 + 三部生物制品)")
  162. print(f" 已采集: {len(already)} 个")
  163. print(f" 待采集: {len(candidates) - len(already)} 个")
  164. print(f" 频率控制: {REQUEST_INTERVAL}s/次, 每{BATCH_SIZE}次暂停{BATCH_PAUSE}s")
  165. print(f" 预计耗时: ~{(len(candidates) - len(already)) * (REQUEST_INTERVAL + 1) / 60:.0f} 分钟\n")
  166. client = httpx.Client(timeout=20, follow_redirects=True)
  167. count = 0
  168. success = 0
  169. failed = 0
  170. for drug in candidates:
  171. name = drug["name"]
  172. safe_name = name.replace("/", "_").replace(":", "_")
  173. if safe_name in already:
  174. continue
  175. count += 1
  176. status, html = fetch_wiki_page(name, client)
  177. time.sleep(REQUEST_INTERVAL)
  178. if status != 200 or not html:
  179. print(f" [{count}] ❌ {name} (HTTP {status})")
  180. failed += 1
  181. else:
  182. try:
  183. parsed = parse_drug_sections(html, name)
  184. entry = wiki_to_drug_entry(name, parsed)
  185. # 至少有一个 section 才算有效
  186. sections_count = len(entry["sections"])
  187. if sections_count < 1:
  188. print(f" [{count}] ⚠️ {name} (无有效内容)")
  189. failed += 1
  190. continue
  191. out_path = output_dir / f"{safe_name}.json"
  192. with open(out_path, "w", encoding="utf-8") as f:
  193. json.dump(entry, f, ensure_ascii=False, indent=2)
  194. print(f" [{count}] ✅ {name} ({sections_count} sections)")
  195. success += 1
  196. except Exception as e:
  197. print(f" [{count}] ❌ {name} (解析失败: {str(e)[:50]})")
  198. failed += 1
  199. # 批次间暂停
  200. if count % BATCH_SIZE == 0 and count > 0:
  201. print(f"\n⏸ 已处理 {count} 个,暂停 {BATCH_PAUSE}s...\n")
  202. time.sleep(BATCH_PAUSE)
  203. client.close()
  204. print(f"\n{'='*50}")
  205. print(f"采集完成: 成功 {success}, 失败 {failed}, 总计 {count}")
  206. print(f"输出目录: {output_dir}")
  207. print(f"{'='*50}")
  208. if __name__ == "__main__":
  209. main()