十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

医疗器械分类目录PDF解析:抽取、清洗与判定接口

医疗器械分类目录PDF解析:抽取、清洗与判定接口 简介这份《一类二类三类医疗器械分类目录--政府版.pdf》面向医疗器械研发、注册申报、生产经营及临床采购等从业者用于快速对照器械的类别归属判断产品是按一类、二类还是三类管理。资源以政府版分类目录为底稿按基础外科、显微外科、神经外科、眼科、耳鼻喉科、口腔科、胸腔心血管外科、腹部外科、泌尿肛肠外科、矫形骨科外科等手术器械大类逐项展开每项列出名称、品名举例与对应类别序号如医用缝合针、基础外科用剪钳镊钩、脑膜刀、角膜剪、拔牙钳、胸骨刀、椎板咬骨钳等均可在表中定位。压缩包内共1个PDF文件约416KB篇幅集中、检索方便适合打印或离线查阅。目前已有544人浏览学习。读者可借此掌握各类器械的典型品名与风险等级划分逻辑为产品注册分类界定、经营许可范围核对及医院器械选型提供直接依据。1. 医疗器械分类目录 PDF 的价值不在 PDF而在它的字段能不能被程序读到做医疗器械注册申报系统、器械 ERP 或者平台类目审核的人几乎都会卡在同一个地方手上有一份《医疗器械分类目录》政府版 PDF业务方要的却是一句「输入产品名告诉我它属于一类、二类还是三类」。这份目录的正文里同时塞着分类编码、产品描述、预期用途、品名举例和管理类别品名举例一格经常并列十几个名称用顿号、分号混着隔开表格跨页时表头不重复管理类别还常常是合并单元格。于是 CtrlF 找不到东西复制出来是断行的。这条链路上真正要写的代码只有三段把 PDF 版面还原成行记录、把字段清洗成一张可索引的表、再包一层先精确后模糊的判定服务。目标读者是做医疗 SaaS 后端、器械合规风控和类目运营工具的工程师也包括需要自己造一份查询底表的注册与合规同学。2. 拆解分类目录版面编码层级、管理类别列与品名举例的字段逻辑2.1 一级、二级、三级产品类别的编码树长什么样目录的主体是按子目录组织的树形结构一级产品类别用两位数字表示二级产品类别再加两位三级产品类别有些版本叫三级序号再加两位拼起来就是06-01-03这种六位三段形态。不同批次的排版差异很大有的把三段拆成三列独立列出有的只在首行写完整编码、后续行留空。抽取时最容易踩的坑是一级产品类别编码只在子目录开篇出现一次正文页里根本不再重复如果不做向下填充抽出来的记录会大面积缺一级归属。编码段位数含义抽取时的坑第 1 段2一级产品类别如 06 医用成像器械仅子目录首页出现正文页不重复第 2 段2二级产品类别缺一级则无法还原完整编码第 3 段2三级产品类别 / 序号同一二级下从 01 递增跨页易被误判为重置理解这棵树的意义在于后面对外提供的所有查询能力本质上都在编码前缀上做文章。业务问「骨科器械都有哪些三类产品」落到 SQL 上就是从某个一级编码区间里筛managed_class III。2.2 政府版 PDF 的三种典型版面与对应的抽取策略同一份文件在不同年份、不同来源下会呈现完全不同的版面特征先探测再决定策略比直接写死一套参数稳妥得多。常见的是三类带完整边框的真表格、只有横线没有竖线的伪表格、以及扫描件。前两类可以做纯文本层抽取第三类必须先过 OCR。判断方法很直接用 pdfplumber 读一页看能拿到多少字符对象和线段对象import pdfplumber def probe_page(page): 探测单页版面类型决定后续用哪种抽取策略 n_chars len(page.chars) # 有字符对象说明带文本层 h_lines sum(1 for l in page.lines if abs(l[y0] - l[y1]) 1) # 水平线 v_lines sum(1 for l in page.lines if abs(l[x0] - l[x1]) 1) # 垂直线 return {chars: n_chars, h: h_lines, v: v_lines}n_chars接近 0 说明是扫描图只能走 OCRh和v都很大说明是完整边框表格用 lines 策略最省事只有h大而v接近 0说明是仅横线的排版必须退到 text 策略靠列坐标切分。版面特征判断依据推荐策略完整边框h、v 均较大vertical/horizontal_strategy lines仅横线h 大、v 接近 0text策略 列 x 坐标切分纯图像chars 接近 0先 OCR再按坐标重构列混合排版部分页有文本层按页分别探测不要全局统一参数提示同一份 PDF 里子目录封面页和正文页的版面往往不同封面页通常是通栏文字。按页探测比按文档统一设置准确。2.3 pdfplumber 抽取表格的最小可用参数组合参数不是越多越好下面这套是针对中文政府文档调过的常用组合重点是三个容差TABLE_SETTINGS { vertical_strategy: lines, # 用垂直线定位列边界 horizontal_strategy: lines, # 用水平线定位行边界 snap_tolerance: 3, # 3pt 内的线段吸附成一条抗排版错位 join_tolerance: 3, # 把断开的线段接起来 intersection_tolerance: 5, # 交叉点判定容差处理线头没对齐 edge_min_length: 10, # 过滤页眉页脚里的短横线 } def extract_all_tables(pdf_path): rows [] with pdfplumber.open(pdf_path) as pdf: for pno, page in enumerate(pdf.pages, start1): for table in page.extract_tables(TABLE_SETTINGS): for r in table: # 单元格里的换行去掉页码单独存一列便于回查原文 rows.append([pno] [(c or ).replace(\n, ) for c in r]) return rowssnap_tolerance是最关键的一个。政府版 PDF 多数由排版软件导出表格线经常有零点几磅的错位取值太小会把一行拆成两行太大又会把相邻两行粘在一起3 是个比较稳的起点。edge_min_length用来过滤装饰性短线否则页眉页码位置会凭空多出一批假表格。抽取结果一定要保留来源页码pno后面做人工复核和版本比对时没有页码几乎没法定位问题。3. 把目录落成结构化数据清洗规则、管理类别归一化与建库3.1 跨页表格合并与一级类别向下填充抽取出来的原始行是「不完整」的合并单元格只有首行有值跨页之后表头消失一级编码也不会重复。所以第二步必须是补全。填充要按子目录分组进行否则上一节的编码会串到下一节。import re CODE_RE re.compile(r^(\d{2})-(\d{2})-(\d{2})$) def fill_down(records, key): 合并单元格只在首行出现值后续空行需要继承上一行的值 last None for rec in records: v (rec.get(key) or ).strip() if v: last v else: rec[key] last or return records def parse_code(raw): 拆六位三段编码返回 (一级, 二级, 三级)格式不对返回 None m CODE_RE.match((raw or ).strip()) return m.groups() if m else Nonefill_down的设计要点是「有值就更新游标没值就继承」这是处理合并单元格最通用的写法。parse_code用严格正则而不是粗暴 split是因为正文里会混进06-01这种二级编码引用宽松匹配会把它们误当成有效行。真正的过滤动作放在调用侧parse_code返回 None 的行直接丢弃同时把丢弃行数打到日志里后面校验章节要用。3.2 管理类别归一化一类、第一类、I、Ⅰ 的收敛管理类别这一列是整份数据里最脏的字段同一份 PDF 里就可能出现「第一类」和「Ⅰ」两套写法扫描件 OCR 之后还会把罗马数字I认成小写l或者数字1。归一化必须收敛到一个枚举值上否则后面按类别筛选会漏数据。原文写法归一化结果说明第一类 / 一类I口语化写法常出现在注释里Ⅰ / I / l / 1I全角罗马数字与 OCR 误识别第二类 / 二类II注意与 III 区分长度第三类 / 三类III最长容易与 II 混淆CN2ROMAN {一: I, 二: II, 三: III} OCR_FIX {l: I, 1: I, |: I} # OCR 常见误识别映射 def norm_managed_class(raw: str) - str: s (raw or ).strip() if not s: return s s.translate(str.maketrans(ⅠⅡⅢ, III())) # 全角转半角 if 类 in s: ch s.replace(第, ).replace(类, ).strip() return CN2ROMAN.get(ch, ) s .join(OCR_FIX.get(c, c) for c in s.upper() if c.strip()) return s if re.fullmatch(rI{1,3}, s) else # 只接受 1~3 个 I返回空字符串而不是抛异常是为了让清洗流程能跑完并汇总出「未识别行清单」。这份清单必须人工过一遍因为漏掉的往往是排版异常的那几行恰好也是最容易出合规问题的行。返回值的字符集限定在I{1,3}能挡住Il这类噪声。3.3 品名举例拆行与 SQLite 建表品名举例是最有查询价值的一列因为业务方通常只知道产品叫什么不知道它属于哪个二级类别。一格塞十几个品名直接存成一列字符串检索只能全表扫。做法是拆成独立行。import re SPLIT_RE re.compile(r[、;,\s]) def split_examples(cell: str): 拆分品名举例先剔除括号补充说明再按多种分隔符切分 cell re.sub(r[(][^)]*[)], , cell or ) return [x for x in SPLIT_RE.split(cell) if len(x) 2]剔除括号内容的目的是避免把「超声诊断仪不含探头」这类带排除说明的短语切碎。长度小于 2 的结果直接丢弃防止「等」「及」这类连接词单独成行。分隔符集合里同时放顿号、全角分号、半角分号、逗号和空白是因为不同批次排版用的符号并不统一。CREATE TABLE device_category ( full_code TEXT PRIMARY KEY, -- 06-01-03 top_code TEXT NOT NULL, -- 一级编码两位 mid_code TEXT NOT NULL, -- 二级编码两位 leaf_code TEXT NOT NULL, -- 三级编码两位 leaf_name TEXT, managed_class TEXT NOT NULL, -- I / II / III description TEXT, -- 产品描述 intended_use TEXT, -- 预期用途 src_page INTEGER -- 来源页码便于回查原文 ); CREATE INDEX idx_top_code ON device_category(top_code); CREATE INDEX idx_class ON device_category(managed_class); CREATE TABLE device_example ( full_code TEXT NOT NULL, example_name TEXT NOT NULL, FOREIGN KEY(full_code) REFERENCES device_category(full_code) ); CREATE INDEX idx_example_name ON device_example(example_name);把full_code做定长补零是整套设计的关键前提只有这样字符串比较才能等价于区间比较索引才用得上。src_page看着多余实际是排查数据问题时最常被用到的列。4. 面向业务的分类查询编码前缀检索、品名模糊匹配与判定接口4.1 编码前缀检索为什么用区间比用 LIKE 更可控编码定长之后一级类别过滤可以写成区间条件执行计划能稳定走到 B-Tree 索引上-- 查 06 医用成像器械下的全部条目按编码排序 SELECT full_code, leaf_name, managed_class FROM device_category WHERE full_code 06-00-00 AND full_code 06-99-99 ORDER BY full_code; -- 查某个二级类别下的全部三类产品 SELECT full_code, leaf_name FROM device_category WHERE full_code LIKE 06-01-% AND managed_class III;区间写法不依赖数据库对 LIKE 前缀的优化程度跨 SQLite、MySQL、PostgreSQL 行为一致。而一旦模式串以%开头索引立刻失效。查询意图SQL 写法是否走索引按一级类别full_code BETWEEN 06-00-00 AND 06-99-99走按二级前缀full_code LIKE 06-01-%走前缀按中间段full_code LIKE %06%不走品名精确example_name ?走品名模糊example_name LIKE %X%不走注意不要把top_code、mid_code当成可以省掉的冗余列。它们存在的意义就是让区间条件不必反复做字符串截取截取会让索引失效。4.2 品名举例的字级倒排与排序整个目录的品名举例条目数在万级这个量级下 LIKE 其实也够快但对中文短词来说模糊匹配的召回质量比速度更值得优化。SQLite 的 FTS5 用 unicode61 分词器时对中文按字切分相当于字级别的倒排索引短词匹配的召回比LIKE %词%更稳。CREATE VIRTUAL TABLE device_fts USING fts5( example_name, full_code UNINDEXED, -- 只做返回用不进索引 managed_class UNINDEXED, tokenize unicode61 ); -- 把拆分结果灌进倒排表 INSERT INTO device_fts(example_name, full_code, managed_class) SELECT example_name, full_code, managed_class FROM device_example; -- 检索按相关度排序rank 由 FTS5 自动计算 SELECT full_code, example_name, managed_class FROM device_fts WHERE device_fts MATCH 超声 ORDER BY rank LIMIT 20;UNINDEXED标记的列不会被写入倒排结构纯粹作为返回字段携带能省下不少索引体积。如果后续想按词而不是按字切分就在写入前先做一遍分词把结果用空格连起来再灌进 FTS 表。4.3 一个先精确后模糊的判定接口业务方要的不是搜索框是一个「给名字返回类别」的接口。两段式匹配能把准确率和响应时间同时控住from fastapi import FastAPI, Query import sqlite3 app FastAPI() DB device.db app.get(/classify) def classify(name: str Query(..., min_length2), limit: int 10): conn sqlite3.connect(DB) conn.row_factory sqlite3.Row # 第一段品名举例精确命中命中即可直接返回类别 exact conn.execute( SELECT full_code, example_name, managed_class FROM device_example WHERE example_name ?, (name,) ).fetchall() if exact: return {hit: exact, items: [dict(r) for r in exact]} # 第二段退化为包含匹配短名字优先limit 兜底防止大结果集拖慢接口 fuzzy conn.execute( SELECT full_code, example_name, managed_class FROM device_example WHERE example_name LIKE ? ORDER BY length(example_name) LIMIT ?, (f%{name}%, limit) ).fetchall() return {hit: fuzzy, items: [dict(r) for r in fuzzy]}ORDER BY length(example_name)让短名称排在前面直觉上短名字更接近通用品名长名字多是带规格后缀的具体型号。min_length2挡住单字查询单字在字级倒排里命中会泛滥成灾。limit是必要的兜底任何模糊匹配接口都要有返回上限。参数建议值原因min_length2单字命中过泛人工无法确认limit10返回给人工挑选的合理上限相似度阈值0.6 起调低于该值不如直接让人工从候选里选FTS 与 LIKE万级数据 LIKE 足够数据量小时别过度设计返回体里给出hit字段exact / fuzzy很重要前端可以据此决定是直接展示类别还是提示用户从候选列表里确认。把「系统判定」和「人工确认」在接口层就区分开能省掉后面大量的责任界定扯皮。5. 校验与版本比对怎么证明解析没漏行抽取类项目最大的风险是静默漏行——流程跑通了数据少了几百条没人发现。三道校验能拦住绝大多数问题。第一道是编码连续性同一二级类别下三级序号应当从 01 连续递增出现断号基本就是漏抽了。-- 找出同一二级类别下序号断档的条目 WITH seq AS ( SELECT full_code, mid_code, CAST(SUBSTR(full_code, 7, 2) AS INTEGER) AS leaf_seq, LAG(CAST(SUBSTR(full_code, 7, 2) AS INTEGER)) OVER (PARTITION BY mid_code ORDER BY full_code) AS prev_seq FROM device_category ) SELECT * FROM seq WHERE leaf_seq - prev_seq 1;第二道是行数守恒。抽取出的三级条目数要和 PDF 尾页的最大序号、或者每个子目录的条目数对得上对不上就回到src_page定位。第三道是抽样断言挑几条众所周知的三类产品写进测试用例用 pytest 固定住行为防止后续改动参数时把清洗逻辑改坏。版本比对是这份数据真正的长期价值所在。目录会动态调整新旧两版按full_code做全外连接一次分出新增、删除和类别变更三类差异-- PostgreSQL 或 SQLite 3.39 支持 FULL OUTER JOIN SELECT COALESCE(n.full_code, o.full_code) AS full_code, o.managed_class AS old_class, n.managed_class AS new_class, CASE WHEN o.full_code IS NULL THEN 新增 WHEN n.full_code IS NULL THEN 删除 WHEN o.managed_class n.managed_class THEN 类别变更 ELSE 未变 END AS diff_type FROM new_cat n FULL OUTER JOIN old_cat o USING (full_code) WHERE o.managed_class IS DISTINCT FROM n.managed_class OR o.full_code IS NULL OR n.full_code IS NULL;三类差异里只有「类别变更」必须逐条回看原文页因为它会直接改变产品的注册路径一类走备案、二类和三类分别对应不同的注册层级接口返回的这个字段一旦错了下游的合规判断全错。把 diff 结果导成 CSV 交给合规同事逐条签字确认比在系统里弹一个变更通知有用得多。有意思的是实际跑下来最常见的 diff 类型不是新增而是品名举例里的措辞微调——这类改动不影响判定但在文本比对时会全部冒出来所以比对一定要落在full_code和managed_class两个键上别拿整行做哈希。本文还有配套的精品资源点击获取
返回列表