news 2026/9/16 14:38:57

多源医学数据采集实战:统一JSON结构设计与Python爬虫工程化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多源医学数据采集实战:统一JSON结构设计与Python爬虫工程化

简介:一套使用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:00+08: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.py

configs/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, pages=2): 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;q=0.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, params=params, headers=headers, timeout=15) 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(strip=True) href = "https://kns.cnki.net" + title_tag["href"] entry = { "id": f"cnki_{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, data=payload, headers=headers, timeout=10) data = resp.json() schools = [] for item in data.get("data", {}).get("item", []): normalized = { "id": f"gk_{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 schools

payload里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"}, timeout=10) soup = BeautifulSoup(resp.text, "lxml") packages = [] for card in soup.select("div.package-item"): name = card.select_one("h3").get_text(strip=True) price = card.select_one("span.price").get_text(strip=True) detail_link = card.select_one("a")["href"] detail = fetch_tijian_detail(detail_link) packages.append({ "id": f"tj_{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"}, timeout=10) 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(strip=True), "desc": cells[1].get_text(strip=True)}) 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"}, timeout=10) soup = BeautifulSoup(resp.text, "lxml") for link in soup.select("ul.check-list li a"): title = link.get_text(strip=True) detail_href = link["href"] all_items.append({ "id": f"39_{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_items

id用了基于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, params=params, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) # 搜索接口返回的可能是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": f"bh_{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_path="data/medical.json"): if os.path.exists(store_path): with open(store_path, "r", encoding="utf-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", encoding="utf-8") as f: json.dump(old_entries, f, ensure_ascii=False, indent=2) return len(old_entries)

合并函数的核心是构建id索引字典,避免每次插入都遍历整个列表。ensure_ascii=False保证中文字符直接以UTF-8写入文件,indent=2让生成的文件可以用普通编辑器直接查看。增量采集的调度周期建议按站点区分,知网每周一次,掌上高考每月一次,体检套餐每季度一次。

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(path="data/medical.json"): with open(path, "r", encoding="utf-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), f"title字段异常: {item['id']}" assert isinstance(item["raw_data"], dict), f"raw_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这类重组件。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 14:38:00

Android财务系统毕业设计:登录、账单与统计全模块解析

简介&#xff1a;面向毕业设计场景的Android财务系统完整项目资源包&#xff0c;适合计算机相关专业学生与需要快速搭建财务类App的开发者。资源共1226个文件&#xff0c;压缩包仅10.86MB&#xff0c;以Java源码、XML布局、PNG图片、JAR依赖库等类型为主&#xff0c;包含完整的…

作者头像 李华
网站建设 2026/9/16 14:34:56

SpringBoot智慧校园系统源码跑通实战指南

简介&#xff1a;本资源是一套基于SpringBoot开发的智慧校园管理系统完整源码&#xff0c;面向Java全栈初学者与高校课程设计者&#xff0c;提供可运行、可二次开发的B/S架构校园信息化解决方案。系统涵盖用户管理、图片与视频素材库等核心模块&#xff0c;技术栈包括Java 1.8、…

作者头像 李华
网站建设 2026/9/16 14:34:36

双通道语音增强:基于NLMS/DNLMS的自适应滤波实现与Matlab解析

简介&#xff1a;这是一份面向Matlab语音处理学习与研发人员的双通道语音增强算法源码包&#xff0c;针对嘈杂环境下的语音通信场景&#xff0c;采用归一化最小均方&#xff08;NLMS&#xff09;自适应滤波方法&#xff0c;可有效消除环境噪声并保持语音可懂度&#xff0c;实测…

作者头像 李华
网站建设 2026/9/16 14:32:59

51单片机电子密码锁系统设计:矩阵键盘与EEPROM存储详解

简介&#xff1a;面向电子类专业课程设计与毕业设计的电子密码锁仿真资源包&#xff0c;基于51单片机完成软硬件设计&#xff0c;适合学习矩阵键盘、LCD显示、密码校验与报警逻辑等知识点的读者参考。包内共18个文件&#xff0c;以C语言源程序、Proteus仿真工程&#xff08;DSN…

作者头像 李华