
简介一套使用Python实现的医学知识采集与爬虫源码整合知网、掌上高考、上海体检套餐、39net检查、薄荷网等数据源将爬取的疾病、症状、医生、机构、处方等多类信息统一保存为JSON文件适合研究多源爬虫与医学数据处理的Python开发者。压缩包共2000个文件大小约158MB主要包含大量JSON数据文件、Python脚本、txt说明、xml配置文件等目录结构清晰便于对照学习不同网站的数据解析方式。该资源已有211人学习浏览可作为从简单爬虫进阶到复杂多源采集的参考案例。代码覆盖了从URL管理、网页解析、数据清洗到JSON落盘的完整流程并体现了对robots协议和网站访问频率的考虑解压后可直接查看源码理解如何针对不同站点设计字段结构、批量存储与去重策略对构建自己的医学数据爬虫项目具有实际借鉴意义。1. 医学知识采集的正确起点先定JSON结构再谈爬取把知网、掌上高考、上海体检套餐、39net检查和薄荷网放进同一个采集项目里最容易犯错的地方不是单站解析而是没有一个能同时装下论文元数据、院校专业、体检套餐、检查项说明和食物营养数据的统一存储结构。五个站点来源不同、页面结构不同、更新频率也不同但落到本地之后必须能在同一个json文件里被查询、被去重、被重建索引。所以这个项目的真正技术难点顺序是JSON Schema设计在前站点解析在后最后才是增量更新和反爬参数的调优。适合的读者是已经写过requests加BeautifulSoup的入门爬虫想往工程化采集方向走的Python开发者下面这套方法以Python 3.10以上版本为基础所有代码都围绕可复现的最小命令展开。2. 数据模型设计与采集架构让json文件成为唯一真相2.1 三类站点形态决定采集代码的分层方式五个目标站点的页面形态可以分成三类。第一类是检索型站点知网和39net检查属于这一类它们的入口都是搜索关键词或科室导航返回的是列表页加详情页的两级结构。第二类是结构化接口站点掌上高考的数据通过页面内嵌的JSON或XHR接口返回这类站点不需要解析HTML直接请求接口效率更高。第三类是商品列表加详情页的电商式结构上海体检套餐网站和薄荷网的热量查询都属于这一类但前者的详情页是套餐内容表格后者是营养成分列表字段结构完全不同。采集代码按这个分类做三层抽象会明显降低后续维护成本。第一层是下载层只负责发起请求、处理响应、设置代理池和重试第二层是解析层把HTML或接口响应转换成统一的字典第三层是存储层负责把字典写进JSON文件并做增量合并。三层之间通过数据类传递不直接共享变量。2.2 统一JSON Schema从源头消除字段混乱五个站点如果各自存一套字段后续做交叉查询时会非常痛苦。我一般会把所有来源的数据先归一化到下面这个基础结构{ id: cnki_2024_001, source: cnki, category: paper, title: 基于深度学习的肺结节检测研究, raw_data: {}, extracted_at: 2026-01-15T10:30:0008:00, url: https://example.com/detail/12345, extra: {} }id字段用“来源缩写_年份_序号”的方式生成保证跨站不冲突。source字段标记数据来源category字段标记内容类型raw_data里保留每个站点的原始解析结果extra字段放各站的独有属性。这样做的核心价值是新增一个数据源时不需要改已有数据文件的结构只需要在extra里扩充即可。字段映射方面建议做一张全局配置表把各站的原始字段名映射到统一字段。比如知网里的“作者”映射到authors“发表时间”映射到publish_time薄荷网里的“每100g热量”映射到calories。2.3 项目骨架与Python环境准备项目目录建议按下面这个结构组织medical_spider/ ├── configs/ │ ├── sites.yaml │ └── fields_mapping.json ├── downloaders/ │ ├── base.py │ ├── requests_downloader.py │ └── retry_policy.py ├── parsers/ │ ├── cnki_parser.py │ ├── gaokao_parser.py │ ├── tijian_parser.py │ ├── 39net_parser.py │ └── boohee_parser.py ├── storage/ │ ├── json_writer.py │ └── merge.py └── main.pyconfigs/sites.yaml里保存每个站点的请求头、基础URL和请求间隔参数。parsers目录下每个站点一个解析器站点升级改版时只动对应的文件。创建虚拟环境时用python3 -m venv venv隔离依赖然后安装requests、beautifulsoup4、lxml、httpx这几个基础库就可以开工。requests_downloader.py里我保留了统一的超时和重试入口超时设置为连接5秒、读取10秒重试次数默认3次重试时采用指数退避策略。这些参数后续可以根据各站点的响应情况单独调整。2.4 数据源配置与请求参数对照在写具体解析器之前先把每个站点的规格列清楚。下面这张表是采集启动前必须确认的参数字段不完整的站点宁可不采也不要存脏数据。数据源请求方式核心字段建议请求间隔注意事项知网GET检索接口篇名、作者、来源期刊、摘要3-5秒检索结果需要分页掌上高考GET/POST接口院校名、专业名、录取分数2-3秒部分接口需要Referer头上海体检套餐GET列表页套餐名、价格、项目列表2-4秒详情页字段在页面底部39net检查GET导航页检查项目名、说明、参考值3-5秒栏目分区明显薄荷网GET搜索接口食物名、热量、营养成分2-3秒搜索接口返回JSONP需处理请求间隔只设下限不设上限遇到429响应码时动态把间隔乘以1.5倍连续失败5次就停掉该站点的抓取并写日志告警。3. 五个数据源的Python解析实现与参数说明3.1 知网检索结果的抓取与JSON构建知网检索页面的反爬主要体现在请求头和Cookie上。我用httpx构造带完整浏览器头部的GET请求先拿检索结果的HTML页面用BeautifulSoup解析出论文条目的title和href再对详情页URL做二次请求。import httpx from bs4 import BeautifulSoup def parse_cnki_search(keyword, pages2): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.cnki.net/, Accept-Language: zh-CN,zh;q0.9, } base_url https://kns.cnki.net/kns8s/defaultresult/index results [] for page in range(pages): params { kw: keyword, p: page, } # 用httpx携带headers请求超时设15秒 resp httpx.get(base_url, paramsparams, headersheaders, timeout15) soup BeautifulSoup(resp.text, lxml) for item in soup.select(table.result-table-list): title_tag item.select_one(a.fz14) if title_tag is None: continue title title_tag.get_text(stripTrue) href https://kns.cnki.net title_tag[href] entry { id: fcnki_{page}_{len(results)}, source: cnki, category: paper, title: title, url: href, raw_data: {publish_year: item.get_text()[:80]}, extracted_at: datetime.now().isoformat(), extra: {}, } results.append(entry) time.sleep(5) # 间隔时间可以交给config统一管理 return results这个函数的逻辑是循环请求检索页每个页面用CSS选择器定位论文列表提取论文标题和链接。page参数控制翻页在实际工程里不应该硬编码而是根据检索结果总数动态计算不过作为最小可运行版本这样已经足够。time.sleep(5)的5秒间隔针对知网的限流策略是安全的如果放在configs里管理不同站点可以用不同值。3.2 掌上高考的接口采集与字段重命名掌上高考的院校数据走的是接口请求响应通常是JSON格式解析负担远比HTML小。这里的核心操作是用requests直接请求接口地址把返回的院校列表映射到统一字段。import requests def fetch_gaokao_schools(province上海): api_url https://api.zjzw.cn/web/api/ payload { keyword: , page: 1, size: 20, uri: apidata/api/gk/school/lists, province_id: province, } # 注意有些接口要求signature这个值通常在页面初始化接口里返回 headers { User-Agent: Mozilla/5.0, Referer: https://www.gaokao.cn/, Origin: https://www.gaokao.cn, } resp requests.post(api_url, datapayload, headersheaders, timeout10) data resp.json() schools [] for item in data.get(data, {}).get(item, []): normalized { id: fgk_{item.get(school_id)}, source: gaokao, category: school, title: item.get(name), raw_data: {type: item.get(type_name), province: item.get(province_name)}, extracted_at: datetime.now().isoformat(), extra: {code: item.get(school_id)}, } schools.append(normalized) return schoolspayload里province_id是中文名称还是编码取决于接口实现开发时先用浏览器开发者工具抓一次真实请求看参数格式再写代码。这个站点的关键点在于Referer必须设置为官网域名否则接口大概率返回签名验证失败的错误码。3.3 上海体检套餐列表页与详情页的嵌套解析体检套餐站点的页面通常是标准的企业站结构列表页列出所有套餐每个套餐名带详情页链接。解析时先遍历列表页把套餐的名称、价格、链接存进列表再逐个请求详情页。详情页里的套餐项目可能以图片或表格形式呈现图片项目需要用OCR兜底这里只做表格的解析。import requests from bs4 import BeautifulSoup def parse_tijian_packages(list_url): resp requests.get(list_url, headers{User-Agent: Mozilla/5.0}, timeout10) soup BeautifulSoup(resp.text, lxml) packages [] for card in soup.select(div.package-item): name card.select_one(h3).get_text(stripTrue) price card.select_one(span.price).get_text(stripTrue) detail_link card.select_one(a)[href] detail fetch_tijian_detail(detail_link) packages.append({ id: ftj_{len(packages)}, source: tijian, category: package, title: name, raw_data: {price: price}, extracted_at: datetime.now().isoformat(), extra: {items: detail}, }) time.sleep(3) return packages def fetch_tijian_detail(url): resp requests.get(url, headers{User-Agent: Mozilla/5.0}, timeout10) soup BeautifulSoup(resp.text, lxml) items [] for row in soup.select(table.item-table tr): cells row.select(td) if len(cells) 2: items.append({name: cells[0].get_text(stripTrue), desc: cells[1].get_text(stripTrue)}) return items这里把详情页的套餐项目直接放进extra字段而不是展开成平铺结构是为了保留套餐项目的顺序关系。体检套餐的展示顺序是有意义的通常从上到下对应体检流程的次序展开成平铺结构会丢失这个信息。3.4 39net检查项目的栏目分页遍历39net的检查项目按科室和检查类型分类每个分类是一个独立的列表页。遍历的入口可以是一张栏目URL列表这个列表推荐手工维护在configs里因为栏目ID变动频率低手工维护比自动发现更稳定。def crawl_39net_sections(section_urls): all_items [] for section_url in section_urls: resp requests.get(section_url, headers{User-Agent: Mozilla/5.0}, timeout10) soup BeautifulSoup(resp.text, lxml) for link in soup.select(ul.check-list li a): title link.get_text(stripTrue) detail_href link[href] all_items.append({ id: f39_{abs(hash(detail_href))}, source: 39net, category: checkup, title: title, url: detail_href, raw_data: {}, extracted_at: datetime.now().isoformat(), extra: {}, }) time.sleep(4) return all_itemsid用了基于URL的hash值这个做法是可复现的前提是Python进程的hash随机化没有影响用hash()的结果在不同的进程间可能不一样更稳妥的方式是改用hashlib.md5生成固定值。实际开发中建议用md5取前12位十六进制作为id保证跨平台一致。3.5 薄荷网食物搜索接口的JSONP处理薄荷网的搜索接口比较特殊返回的是JSONP格式需要在解析时剥掉回调函数的外壳。用正则提取JSON主体是通用做法requests请求后把响应文本里的callback名和括号去掉再用json.loads解析。import re, json, requests def parse_boohee_search(food_name): search_url https://www.boohee.com/food/search params {keyword: food_name} resp requests.get(search_url, paramsparams, headers{User-Agent: Mozilla/5.0}, timeout10) # 搜索接口返回的可能是JSON或JSONP统一做一次提取 text resp.text.strip() if text.startswith(callback): json_text re.search(r\((.*)\), text, re.S).group(1) else: json_text text data json.loads(json_text) foods [] for item in data.get(foods, []): foods.append({ id: fbh_{item.get(id)}, source: boohee, category: food, title: item.get(name), raw_data: { calorie: item.get(calory), protein: item.get(protein), fat: item.get(fat), carb: item.get(carb), }, extracted_at: datetime.now().isoformat(), extra: {}, }) return foods这个解析器里raw_data保存的是薄荷网自己的字段名calory、protein、fat、carb没有在解析层做重命名原因是营养数据涉及单位换算统一映射放到数据清洗阶段更合适。抓取阶段保留源字段名可以避免因为单位理解错误导致的数据损坏。4. 增量更新、去重合并与反爬参数实战配置4.1 基于时间戳的增量合并策略全量爬取只适合第一次运行后续所有采集都应该走增量。这里的增量实现不做复杂的数据库对比直接在JSON文件层面用时间戳合并。每次抓取的数据都带上extracted_at字段合并时以id为准新数据的extracted_at晚于旧数据就覆盖否则保留。import json, os from datetime import datetime def merge_json_into_store(new_entries, store_pathdata/medical.json): if os.path.exists(store_path): with open(store_path, r, encodingutf-8) as f: old_entries json.load(f) else: old_entries [] index {item[id]: i for i, item in enumerate(old_entries)} for entry in new_entries: existing_idx index.get(entry[id]) if existing_idx is None: old_entries.append(entry) else: old_time datetime.fromisoformat(old_entries[existing_idx][extracted_at]) new_time datetime.fromisoformat(entry[extracted_at]) if new_time old_time: old_entries[existing_idx] entry with open(store_path, w, encodingutf-8) as f: json.dump(old_entries, f, ensure_asciiFalse, indent2) return len(old_entries)合并函数的核心是构建id索引字典避免每次插入都遍历整个列表。ensure_asciiFalse保证中文字符直接以UTF-8写入文件indent2让生成的文件可以用普通编辑器直接查看。增量采集的调度周期建议按站点区分知网每周一次掌上高考每月一次体检套餐每季度一次。4.2 请求频率、重试与动态间隔的参数配置反爬参数不只针对单个站点而是需要一套全局可调的机制。我通常用一个dict维护每个站点的请求参数采集时动态读取避免改参数还要改代码。SITE_CONFIG { cnki: {interval: 5.0, retry: 3, timeout: 15, max_fails: 5}, gaokao: {interval: 2.0, retry: 2, timeout: 10, max_fails: 3}, tijian: {interval: 3.0, retry: 3, timeout: 10, max_fails: 4}, 39net: {interval: 4.0, retry: 3, timeout: 10, max_fails: 4}, boohee: {interval: 2.0, retry: 2, timeout: 10, max_fails: 3}, } class RateLimiter: def __init__(self, interval): self.interval interval self.last_request 0.0 self.fail_count 0 def wait(self): elapsed time.time() - self.last_request if elapsed self.interval: time.sleep(self.interval - elapsed) def record_failure(self): self.fail_count 1 if self.fail_count SITE_CONFIG[cnki][max_fails]: raise RuntimeError(连续失败次数过多停止采集)RateLimiter类的wait方法保证同站点两个请求之间的间隔不小于配置值record_failure在连续失败时抛出异常配合外层try-except可以让采集任务中断而不是无限重试。4.3 数据清洗与字段冲突时的JSON合并规则多源数据最麻烦的问题不是格式不统一而是同一实体的字段互相冲突。比如同一个检查项目在39net里的名称是“血常规”在体检套餐站点里叫“血液分析”在薄荷网里则完全没有对应项。跨站对齐需要维护一个别名词典。ALIAS_MAP { 血常规: blood_routine, 血液分析: blood_routine, 血细胞分析: blood_routine, } def normalize_item_name(name): return ALIAS_MAP.get(name, name)清洗流程分两步先做字段级别的类型检查和字符串清理再做实体级别的合并。类型检查包括价格必须能转成float、日期必须符合ISO格式、列表字段不能是字符串。实体合并时保留最晚抓取的数据作为基底但raw_data里保留所有来源的原始值方便追溯。5. 用jq和Python双重验证JSON完整性的实用技巧5.1 用jq快速检查采集结果的结构一致性每次抓取完成后先用jq做一次快速体检。检查所有条目是否包含必须字段命令如下jq all(.[]; has(id) and has(source) and has(category)) data/medical.json返回true说明所有条目都具备三个核心字段。再检查id是否重复jq group_by(.id) | map(select(length 1)) | length data/medical.json输出0表示没有重复id。这两个命令可以在定时任务里与采集脚本串联生成不合格数据时直接告警。jq对中文字段的处理不会出问题但需要注意Windows PowerShell下的编码设置建议统一用cmd运行。5.2 Python侧的Schema校验与字段统计jq检查的是结构Python侧再做一轮类型校验防止出现某个字段值类型不稳定。import json from collections import Counter def validate_store(pathdata/medical.json): with open(path, r, encodingutf-8) as f: data json.load(f) source_counter Counter() category_counter Counter() for item in data: source_counter[item[source]] 1 category_counter[item[category]] 1 assert isinstance(item[title], str), ftitle字段异常: {item[id]} assert isinstance(item[raw_data], dict), fraw_data字段异常: {item[id]} print(来源分布:, dict(source_counter)) print(类别分布:, dict(category_counter)) return len(data)这个函数运行后能得到一个总条目数和分布统计输出格式适合直接接入日志系统。5.3 校验通过后的常规使用路径JSON文件入库后还有一个很实用的技巧用Python把嵌套的extra字段展开成一行行的CSV方便在Excel里查看或导入数据库。这个过程不改变原始JSON只是在导出时做一次路径展开。如果后续需要对这份医学知识数据做全文检索可以直接用sqlite3的FTS5虚拟表把title、raw_data里的文本字段建索引查询速度比每次全文件扫描快得多SQL语法也不用额外引入Elasticsearch这类重组件。本文还有配套的精品资源点击获取