做能效管理系统那阵子最让我头疼的不是业务逻辑而是数据。判断一台空调是不是符合某个能效等级需要去翻标准文件、查公示数据能效比、输入功率、制冷量这些参数散落在不同页面里程序根本没法直接用。后来我花了两天时间把某能效公示平台上的公开标准数据全部抓下来整理成一个结构化的能效等级标准字典库业务系统只要一行 import 就能查。这篇文章就把这个过程的完整思路和代码写出来——从数据源分析、字段建模到爬虫实现、数据校验再到最终如何让字典库稳定地服务业务。这个需求其实很常见。大到能效检测机构做产品比对小到电商平台给商品打能效标签都需要一份机器可读的标准数据。人工去翻网页太慢一次性整理成文档又没法自动更新爬虫 结构化字典库的组合就是目前性价比最高的解法。1. 整体设计思路为什么是“字典库”而不是“数据库表”1.1 从业务侧反推数据形态先别急着写爬虫先想清楚业务到底怎么用这份数据。我当时接到的需求是这样的某产品线要做能效自查输入设备的型号和参数系统自动判断它属于几级能效、是否满足某条采购标准的下限要求。这意味着系统要高频读取“能效等级对应的参数阈值”但数据本身又是低频更新的——能效标准一般按季度甚至年度修订每次变更的条目通常只有几十条。这种“读多写少、总量不大”的数据用字典库比用关系型数据库舒服得多。能效等级标准总共几千条规则加载到内存里也就几MB直接以 dict 嵌套结构组织后业务侧一次 json.load 就完事不需要连数据库、不需要写SQL、不需要维护连接池。相比之下MySQL 方案要建表、写查询接口、考虑连接超时为了几千条规则引入这么大一坨基础设施成本完全不成比例。1.2 字典库相比关系表的优势你可以把字典库理解为一份“直接塞进代码里的规则快照”。它和传统数据库表相比核心差异在访问方式和版本管理上我用一张表对比过对比维度关系型数据库表结构化字典库数据存储独立部署的数据库服务JSON / YAML 文件或 Python 模块访问方式通过网络协议远程查询本地内存直接读取部署依赖需要数据库账号、权限、网络连通性一个文件随代码包发布即可版本追溯依赖数据库变更记录天然支持 Git 版本管理字段变更需要 ALTER TABLE 迁移改 JSON 结构后同步升级 schema_version适合场景数据量大、并发写、事务强一致规则类、配置类、读多写少的数据我这么说不是否定数据库而是想强调一个原则工具选型要跟数据特征匹配。能效标准数据具备“总量小、更新慢、读密集”三个特征字典库刚好全部命中。如果是爬几百万条商品数据做分析那肯定得上数据库或数据仓库方案是随需求走的。1.3 工业级标准的三层要求标题里写了“工业级”这个词不是噱头。我理解的工业级至少包含三层意思第一是健壮性。爬虫跑的时候不能因为某个页面结构微调就整个崩溃解析逻辑要写容错分支数据入库前要有校验字段缺失要及时发现。第二是可追溯性。每条数据要能说清楚“从哪来、什么时候抓的、依据哪个标准条文”出问题的时候能回溯。第三是可维护性。字段必须有约定、有注释目录结构要清楚换一个人接手也能快速看懂并扩展。这三点决定了整个项目的实现方式。比如字段里必须包含来源URL和抓取时间解析函数里必须处理“字段不存在”的分支输出字典里必须带 schema_version。这些看起来不起眼的细节才是“工业级”和“写着玩”的分水岭。2. 数据源分析与字段模型设计2.1 怎么挑一个合适的数据源数据源选得好项目就成功了一半。我当时对比了几个备选平台最终选定某能效标识公示平台作为主数据源原因很朴素它的栏目结构固定、列表页和详情页都是服务端渲染的静态HTML、公开栏目无需登录即可访问而且站点在 robots 协议里明确允许普通用户访问公开内容。判断一个数据源能不能爬我一般看四个维度公开性、结构化程度、更新规律、访问限制。公开性指的是信息本身是面向公众的不涉及隐私或付费内容结构化程度指的是页面里是否能用选择器稳定定位到目标字段更新规律决定了我是否需要做增量爬取访问限制则决定了请求频率的上限。这四个维度里任何一个不满足我都建议换一个源不要跟某个站点死磕。这里多说一句合规是底线。只爬公开信息控制请求频率不绕过访问限制爬下来的数据仅用于内部业务分析。这不是场面话是行业里吃过亏之后的共识。2.2 字段设计要从使用场景反推字段设计不能拍脑袋要从“业务查询时到底需要什么”倒推。我梳理业务侧最常见的几个查询场景发现无非是三类查某个设备的能效等级门槛、查某个标准条文的参数限值、查某个等级下的能耗上限。围绕这三个场景我设计了以下字段字段名含义示例值是否必填device_category设备大类空调是device_subcategory设备子类分体式空调是energy_level能效等级1级是param_name参数名称能效比(EER)是param_value参数数值4.05是param_unit参数单位W/W是compare_type比较类型ge (大于等于)是standard_code标准编号GB-XXXXX-2020是effective_date生效日期2021-01-01是source_url来源URLhttps://example.com/detail/1001是crawled_at抓取时间2025-01-12 10:00:00是其中 compare_type 很多人会忽略但它恰恰是字典库能否被业务直接使用的关键。标准条文里的限值有两种表述方式有的规定“能效比不应低于 X”有的规定“待机功率不应高于 Y”如果不把比较类型记录下来业务侧每个字段都要单独写判断逻辑麻烦且容易出错。把它结构化之后业务代码只需要一个通用的比对函数就能处理全部规则。2.3 字典库嵌套结构设计字段决定表格表格决定字典结构。我最终采用的是“设备大类 → 设备子类 → 能效等级规则”的三层嵌套结构示例大概长这样{ schema_version: 2.1.0, updated_at: 2025-01-12T10:23:4508:00, data: { 空调: { 分体式空调: { 1级: [ { param_name: 能效比(EER), param_value: 4.05, param_unit: W/W, compare_type: ge, standard_code: GB-XXXXX-2020, effective_date: 2021-01-01, source_url: https://example.com/detail/1001, crawled_at: 2025-01-12 10:00:00 } ], 2级: [], 3级: [] } } } }之所以按“大类 → 子类 → 等级”组织而不是拍平成一张大表是为了配合实际查询路径。业务侧拿到一台设备第一步就是知道它是“空调 / 分体式空调”接下来要查“属于哪个等级”天然就是逐层下钻的过程。嵌套 dict 的访问路径和业务思路完全一致代码里写起来非常顺手。当然这个结构也不是死板的。如果某个子类下有多条规则比如能效比和制冷量都要满足才算合格我就在等级节点下挂一个列表每条规则独立存放。这样既保留了嵌套结构的查询效率又能容纳复杂判断逻辑后续如果要生成报表或者对接规则引擎结构上也说得通。3. 爬虫实现从 HTML 到结构化字典3.1 请求策略先模拟浏览器再去想别的数据源是静态HTML页面所以请求层不需要上 Selenium 或 Playwright用 requests 就够。但“够用”不代表可以裸奔请求头还是得好好写否则很容易被服务端拒之门外。我习惯用 requests.Session() 创建一个会话然后统一设置 User-Agent、Accept、Accept-Language 这些头。User-Agent 不要每次都用同一个准备一个小的列表轮换能减少被识别为脚本的概率。另外每两个请求之间加一个随机延时我用的是 1 到 2 秒之间的随机值既不太慢也不至于给对方服务器造成压力。import requests import random import time USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36..., Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36..., Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36... ] session requests.Session() session.headers.update({ Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8 }) def fetch_page(url): session.headers[User-Agent] random.choice(USER_AGENTS) resp session.get(url, timeout15) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text def fetch_with_delay(url, delay_range(1, 2)): time.sleep(random.uniform(*delay_range)) return fetch_page(url)这里有几个细节timeout 必须设置不然某个请求卡住会导致整个爬虫停摆resp.encoding 要手动设置成 apparent_encoding否则中文页面可能乱码raise_for_status() 要调用让 HTTP 错误直接抛异常方便后续统一重试。这些都是踩过坑之后养成的习惯。3.2 页面解析选对解析器等于成功一半页面解析这一步我用了 BeautifulSoup lxml 解析器。BeautifulSoup 的 API 对新手友好调试起来直观lxml 的解析速度又比 html.parser 快不少两者搭配正好。解析流程分两步先抓列表页拿到每个详情页的链接和基础信息再进详情页抓完整的标准参数。列表页里每条记录都在一个带固定 class 的里链接则在 标签的 href 属性中解析逻辑非常规律from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, lxml) items [] for node in soup.select(div.standard-card): link_tag node.select_one(a.standard-link) title_tag node.select_one(span.standard-title) if not link_tag or not title_tag: continue items.append({ title: title_tag.get_text(stripTrue), detail_url: link_tag.get(href) }) return items详情页的解析稍微复杂一点因为标准参数的字段名和取值在不同设备类别下略有差异。我采用的方法是先定义一个字段选择器配置表然后遍历配置表去取值取不到的字段返回 None 并记录日志。这样的写法比硬编码几百个字段更抗页面结构变化。FIELD_SELECTORS { energy_level: (span, {class: energy-level}), param_name: (span, {class: param-name}), param_value: (span, {class: param-value}), param_unit: (span, {class: param-unit}), standard_code: (span, {class: std-code}), effective_date: (span, {class: eff-date}) } def parse_detail_page(html): soup BeautifulSoup(html, lxml) field_values {} for field, (tag, attrs) in FIELD_SELECTORS.items(): node soup.find(tag, attrsattrs) if attrs else soup.find(tag) if node: field_values[field] node.get_text(stripTrue) else: field_values[field] None return field_values这种做法说到底就是把“页面结构调整”这件事的破坏力降到最低。工业级爬虫不怕数据格式变怕的是定位逻辑写死一旦某个字段的标签属性变了整个解析链就崩。3.3 数据清洗单位统一和缺失值处理是重头戏页面抓下来的原始文本不能直接用。标准参数页面上呈现的是“能效比 4.05(W/W)”这种混合文本业务侧比价的时候需要的是“数值 单位”分离。我用正则表达式从文本中抽取数字部分和单位部分import re def extract_numeric_and_unit(text): text text.strip() match re.search(r([-]?\d\.?\d*)\s*([%A-Za-z/℃°])?, text) if not match: return None, None value float(match.group(1)) unit match.group(2) or None return value, unit抽出来之后还要做单位统一。能效参数里最常见的是功率有的标准写“kW”有的写“W”业务侧统一用“W”做基准存储。换算规则不复杂遇到 kW 就乘 1000遇到 MW 就乘 1000000。百分比类型的参数比如效率值 85%要统一转成小数 0.85这样后续做阈值比较时不会被单位坑到。缺失值的处理同样关键。标准参数里偶尔会有个别字段没有公示比如某个新增子类还没来得及标注生效日期。我的原则是数值类字段缺席时存 None但必须在最后校验阶段上报不允许用 0 或空字符串占位因为 0 在能效比较里是有实际含义的占位会导致误判。清洗完的数据在进字典之前还要做一轮类型检查param_value 必须是 floatenergy_level 必须匹配“X级”格式非法数据直接拦截。4. 稳定运行的关键环节4.1 反爬现象不是玄学大多是请求行为问题跑爬虫的人多少都遇到过 403、连接重置、返回验证页这几件事。我早年一遇到 403 就觉得是对方上了多狠的反爬手段后来把请求日志拉出来一看绝大多数情况是自己请求频率太高、头信息不完整或者缺少某个必要的 Cookie。常见的几个状态码和应对思路我整理了一张速查表现象常见原因优先排查方向HTTP 403请求头不完整或频率过高补 User-Agent、Referer降低请求频率连接重置连接数过多或 IP 被临时限制改用单线程串行请求增加延时返回统一提示页命中服务端反爬策略检查是不是同一个 UA 高频访问换 UA 轮换策略部分页面超时目标站点响应慢增加 timeout配合重试机制我从来不去碰那些需要绕验证码或者破解加密参数的路子因为那已经超出了合理抓取公开数据的边界。工业级的思路是跟目标站点保持一个礼貌的访问节奏通过延时、重试、轮换 UA 让请求看起来更像正常用户就够了。4.2 重试机制与日志系统让脚本自己干活爬虫跑起来之后人不可能一直守在终端前。为了让脚本自己处理偶发故障我写了一个简单的重试装饰器遇到请求异常自动重试三次间隔时间指数递增import time import logging from functools import wraps logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) def retry(max_retries3, base_delay2): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_retries 1): try: return func(*args, **kwargs) except Exception as e: logger.warning(第 %s 次请求失败: %s, attempt, e) if attempt max_retries: raise time.sleep(base_delay * attempt) return wrapper return decorator retry(max_retries3, base_delay2) def fetch_with_retry(url): return fetch_with_delay(url)日志是重试机制的好搭档。我会在每个关键节点打日志开始爬取某个分类、解析了多少条、失败了多少条、清洗阶段剔除了多少异常数据。事后排查问题的时候日志就是唯一的线索来源。我还会用 logging.FileHandler 把日志持久化到文件而不是只输出到终端否则脚本一关历史记录就没了。4.3 数据一致性校验进字典前最后一道闸爬取完成不等于数据可用。我在生成最终 JSON 之前会跑一轮校验脚本主要查三件事条数是否在预期范围、必填字段是否都有值、数值字段是否在合理区间。比如能效比不可能小于 1制冷量的数值范围也有常识边界超出这些范围的数据一定有 bug要么是解析错了要么是源数据本身格式异常。校验时我会顺带计算整份数据的哈希值。后续增量更新时先比对哈希值如果数据没有变化就直接跳过写入避免无意义地刷新文件时间戳。哈希一致说明源站内容没改哈希不一致再做差异比对这样可以保证字典库的更新是有据可依的。5. 从一次性脚本到可持续数据资产5.1 输出与版本管理让字典库可追溯、可回滚清洗校验完的数据最终会输出成一个 JSON 文件。文件命名里要带日期和版本号比如 energy_standard_dict_20250112.json文件内部也要有 schema_version 字段。我通常在 schema_version 上遵循语义化版本规则主版本号表示字典结构变更次版本号表示新增了设备类别或字段修订号表示同一结构下的数据修正。版本管理用 Git 就够了。每次数据更新提交一次 commit打一个 tag改坏了可以随时回滚。如果项目内部把这份字典作为公共依赖还可以发布成 Python 包或独立的 JSON 资源文件其他业务模块通过 pip 或内部仓库引用。这样字典库就从“我本地的一个爬虫产物”变成了“团队共享的一份数据资产”。5.2 业务侧怎么用这份字典字典库最终是要给业务代码调用的。我提供了一个很薄的读取层把 JSON 加载和查询封装成函数业务侧调用起来非常简洁import json def load_energy_dict(pathenergy_standard_dict.json): with open(path, r, encodingutf-8) as f: return json.load(f) def get_energy_rule(dict_data, category, subcategory, level): category_data dict_data[data].get(category) if not category_data: return None sub_data category_data.get(subcategory) if not sub_data: return None rules sub_data.get(level) return rules if rules else None实际业务里拿到规则列表后就是一个简单的遍历比对。先看规则里的 compare_type如果是 ge 就判断传入参数是否大于等于 param_value如果是 le 就判断是否小于等于。所有规则都满足设备才算达标。这个通用逻辑不受具体设备类别影响后续新增设备只需要在字典库里加数据业务代码一行都不用改。5.3 后续扩展增量更新、接口化、可视化这套字典库跑起来之后可以往几个方向继续扩展。第一个是增量更新每天定时跑一次增量爬虫只抓详情页哈希有变化的数据配合告警通知。第二个是接口化把查询逻辑封装成内部 HTTP API其他系统通过接口访问避免每个系统各自加载 JSON 文件导致版本不一致。第三个是可视化基于字典库数据构建一个简单的检索界面非技术人员也能自助查询某个能效等级对应的参数限值减轻人工答疑负担。不过扩展方向要按需选不要一上来全做。我见过不少项目死在一开始就想搞“大而全”的平台架构结果核心数据还没整明白先把四周的壳搭了一堆。我的建议是先把字典库本身维护好扩展功能等有真实需求再逐项排期。这套字典库的爬虫部分只占整个项目三成工作量剩下七成都在字段建模、清洗规则和校验逻辑上。我个人的体会是动手写爬虫之前一定先花时间把业务查询场景梳理清楚字段和结构都从场景里长出来而不是从页面里抄出来。页面是容易变的业务需求是相对稳定的顺着后者设计数据结构后面无论页面怎么改你都只需要改一小块解析配置字典库整体不会伤筋动骨。另外一个小技巧每爬完一轮手动抽查几条数据跟源页面比对一下。自动化校验能抓格式问题但抓不住语义问题人工抽检虽然原始却往往是发现隐蔽 bug 最快的方式。我习惯每批次抽 10 到 20 条花不了几分钟换来的是对整份数据质量的信心。
阅读完成 · 觉得有帮助?