做爬虫这些年我越来越确认一件事爬虫最耗时的环节从来不是写请求和解析选择器而是拿到数据后的清洗。你费劲把网页上的信息抓下来结果日期一会儿是2024年12月2日一会儿是12/02/2024金额带着货币符号和千分位空值更是形态各异。这篇文章分享的就是我沉淀下来的一套通用清洗工具包专门对付日期、金额、单位、空值这四类高频脏数据代码可复用、可扩展零基础也能直接用。核心思路很简单把变化多端的字符串变成格式统一的结构化数据后续入库、分析和可视化才算真正开始。1. 爬虫数据为什么总是一团乱麻1.1 脏数据的真实来源抓回来只是第一步爬虫从网页上拿到的数据本质上只是网页源码的切片。网页本身是给人看的不是给程序用的所以格式混乱是常态。我从实际项目中归纳了三类最常见的问题来源。第一类是HTML结构嵌套带来的串位。很多网站用表格或div嵌套展示数据解析时如果selector写得不够精确很容易把相邻字段混进来。比如某电商平台的价格字段页面源码里可能同时存在促销价和划线价一不小心就把划线价当成真实价格抓下来了这种数据其实是假脏数据它格式正常但语义错了清洗阶段要格外小心。第二类是页面渲染导致的缺失。现在越来越多的网站是前后端分离架构数据由JavaScript动态加载直接用requests抓到的HTML里根本没有这些字段。即便你用Selenium或者Playwright渲染之后再抓由于网络延迟、懒加载策略也会出现某些字段抓不到的情况。这种缺失在数据里就直接表现为空值、None、null字符串甚至是一个孤零零的--。第三类是编码和字符问题。中文网页常见GBK/GB2312编码英文网页常用UTF-8偶尔还有BOM头、乱码字符混进来。金额符号可能是全角也可能是半角日期里的分隔符可能是/、-、年月日空格和换行也可能混在其中。这些看似琐碎的问题如果不在清洗阶段统一处理到了分析和建模阶段会逐一爆炸。1.2 清洗在爬虫链路中的位置咽喉要道一个完整的爬虫项目链路大致是采集、解析、清洗、存储、分析。很多人把精力狂热地投入到前两步觉得抓到页面、提取字段就完事了结果数据一入库就发现问题日期字段有的是字符串有的是datetime有的是时间戳金额字段有的带万字有的带千分位空值满天飞写SQL都难受。清洗环节恰恰是这条链路的咽喉。它做的是把从网页提取出来的、格式各异的原始字符串转换成结构明确、类型统一、语义清晰的数据下游不管是存进MySQL、SQLite还是交给pandas做分析都能省一大堆事。特别提一句清洗和解析要分开。解析是把字段从HTML里抠出来清洗是把抠出来的字段变成靠谱的数据。两个环节分开写代码清晰测试也方便。我见过不少新手把正则和replace乱掺在一起字段也抠了、格式也改了但代码完全没法复用换一个网站就重写一遍非常痛苦。这也是我想把清洗工具独立出来的初衷。2. 工具包设计思路一个类解决一类问题2.1 为什么不做成散装函数写清洗工具很多人第一反应是写几个函数不就行了。确实clean_date()、clean_amount()几个函数摆在那里看起来也能用。但真进了实战项目你会发现散装函数有几个很难受的问题。第一是参数无处收敛。清洗规则是需要配置的比方说空值用什么策略处理、日期解析失败返回什么兜底值、金额单位如何换算这些参数如果散落在各个函数里每个调用处都要传一遍传着传着就有人漏传导致同一个字段在不同地方清洗行为不一致。第二是没有状态和复用能力。项目里经常需要针对同一个网站的多个页面、甚至多个网站的数据统一清洗如果清洗逻辑是散装的你得在每一个爬虫脚本里复制粘贴一份后续规则一改全项目都要跟着改。第三是测试困难。散装函数没有统一的入口想写单元测试就得分别import各个函数测试代码比业务代码还长。所以我把清洗逻辑封装成一个UniversalCleaner类。类的优势在于构造时统一配置方法按职责划分调用链清晰还能挂载日志、统计、异常处理这些辅助能力。它像一个数据清洗加工厂你往里扔原始字符串它吐出来干净的Python对象。2.2 关键设计决策兜底值、空值策略与调用链我这个工具包有三个关键设计决策直接影响复用体验。第一个是兜底值机制。清洗类方法统一带default参数任何解析失败、空值、类型不符的情况都不抛异常而是返回兜底值。为什么因为爬虫面对的是非受控数据源一个字段格式不对就中断整个爬虫代价太高。兜底值可以让你在数据量大的场景下先跑通再修正配合日志记录后续再去分析哪些字段触发了兜底。第二个是空值策略统一配置。null_strategy参数在初始化时定好fill表示填充drop表示删除mark表示标记保留。这个不单单是某个字段的处理而是针对整批数据的统一策略。比如写数据库之前你想填充默认值分析前你想删除全空行改一个参数就行不用动业务代码。第三个是方法返回类型确定。清洗器的每个方法都返回确定类型的结果日期返回datetime金额返回float空值判断返回bool这就让清洗可以像流水线一样串联。先判断空值再清洗日期再清洗金额每一步的输出都是下一步的输入逻辑非常顺畅。3. 四大清洗模块详解与实现3.1 日期清洗把花式日期统一成datetime日期是爬虫数据里脏得最离谱的字段。同一个2024年12月2日在不同网站里可能是下面这些形态原始字符串说明2024-12-02最标准的ISO格式2024/12/02斜杠分隔2024年12月2日中文格式12月02日缺少年份02/12/2024日/月/年欧美习惯昨天相对时间3小时前相对时间评论区常见Dec 2, 2024英文缩写月如果还有时间部分比如2024-12-02 14:30:00处理逻辑又要多一截。我的clean_date方法分三步处理第一步识别空值和非法输入直接返回兜底值 第二步处理相对时间关键词比如刚刚x分钟前x小时前昨天x天前这类数据在评论区、公告、订单记录里特别常见 第三步依次尝试多种日期格式模板解析匹配成功就转成datetime全部失败就返回兜底值。代码实现如下基于常见实践封装可直接复用import re from datetime import datetime, timedelta def clean_date(raw, defaultNone, nowNone): 把花式日期字符串统一成 datetime解析失败返回 default if now is None: now datetime.now() if raw is None: return default text str(raw).strip() # 1. 空值判断 lower text.lower() if not text or lower in (null, none, nan, n/a, na): return default # 2. 相对时间 if text in (刚刚, 现在, just now): return now match re.match(r(\d)\s*(分钟|小时|天)前, text) if match: num, unit int(match.group(1)), match.group(2) if unit 分钟: return now - timedelta(minutesnum) if unit 小时: return now - timedelta(hoursnum) if unit 天: return now - timedelta(daysnum) if text 昨天: return now - timedelta(days1) if text 前天: return now - timedelta(days2) # 3. 完整日期格式按优先级依次尝试 formats [ (%Y-%m-%d %H:%M:%S, r\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}), (%Y/%m/%d %H:%M:%S, r\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}), (%Y-%m-%d, r\d{4}-\d{2}-\d{2}), (%Y/%m/%d, r\d{4}/\d{2}/\d{2}), (%Y年%m月%d日, r\d{4}年\d{1,2}月\d{1,2}日), (%Y.%m.%d, r\d{4}\.\d{2}\.\d{2}), (%m月%d日, r\d{1,2}月\d{1,2}日), ] for fmt, pattern in formats: if re.fullmatch(pattern, text): try: parsed datetime.strptime(text, fmt) # 缺少年份时补当前年份 if fmt %m月%d日: parsed parsed.replace(yearnow.year) return parsed except ValueError: continue # 英文月份Dec 2, 2024 match re.fullmatch(r([A-Za-z]{3})\.?\s(\d{1,2}),?\s(\d{4}), text) if match: month_map {Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6, Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12} mon month_map.get(match.group(1).capitalize()) if mon: try: return datetime(int(match.group(3)), mon, int(match.group(2))) except ValueError: pass return default这段代码看起来不短但逻辑是线性的先判空再处理相对时间再按格式模板匹配。日常爬虫数据九成以上都能覆盖。真正要注意的是优先级带时间的完整格式要放在不带时间的前面否则2024-12-02 14:30:00会被%Y-%m-%d匹配到只剩日期时间部分丢了。我在实际项目里还踩过一个坑有些网站日期里混进全角字符比如看起来一模一样但strptime不认。遇到这种情况有两种处理思路要么先做全角转半角要么用正则把数字部分提取出来再拼标准格式。我建议在清洗器里面加一个全角转半角的小工具函数一劳永逸。3.2 金额清洗符号、千分位、单位一网打尽金额字段的脏主要体现在三个方面货币符号、¥、$、千分位逗号、单位词万、亿、元。此外还有全角数字、空格、多余文字混入的情况。我的思路是只提取数值主干再根据单位词做数量级换算最终统一输出float类型。def clean_amount(raw, default0.0): 金额字符串转 float支持万/亿/千等中文数量级 if raw is None: return default text str(raw).replace(,, ).replace(, ).replace( , ).strip() if not text or text.lower() in (null, none, n/a, nan, --, -): return default # 提取数字部分支持负号和小数 match re.search(r-?\d(?:\.\d)?, text) if not match: return default num float(match.group()) if 亿 in text: num * 100000000 elif 万 in text: num * 10000 elif 千 in text or re.search(r\d\s*k\b, text, re.IGNORECASE): num * 1000 return round(num, 4)这个版本把¥1,234.56转成1234.56把1.2万转成12000.0把-3,500元转成-3500.0覆盖了绝大多数中文站点的金额形态。需要注意的几个边界一是负数的处理。有些爬虫抓到的价格可能带负号比如优惠券抵扣后的金额正则里要保留负号否则-3,500会被提取成3500正负语义就错了。二是元要不要累乘。如果单位是元本身就是基础单位无需换算但有些网站写万元比如某房产网站挂牌价350万元这里的万已经是数量级需要乘10000而元只是本位词不能重复乘。所以单位换算只看数量级词万、亿、千不要看本位词元、块、RMB。三是精度问题。金额涉及万换算时可能出现浮点误差比如0.1万算出1000.0000000000001所以我在返回前统一round到4位小数。如果项目对精度要求极高比如金融数据建议后面用Decimal或者直接把数量级换算保留成整数运算。我不建议在这种通用工具里做太复杂的货币汇率转换爬虫抓到的金额通常已经是目标网页展示的最终面值直接归一化即可。如果确实需要处理多币种那就得额外加币种识别和汇率参数这属于业务级功能不应该放进通用工具包。3.3 单位清洗不只是万和亿标题里说的单位其实涵盖两类一是数值自带的单位词万、亿、千二是字段本身的度量单位重量kg、长度米、面积平米等。第一类我在金额清洗里已经顺手处理了这一节重点说第二类因为它在爬虫数据里同样高频出现比如商品详情页的2.5kg、500g房产页的120㎡、85平米。单位归一化的核心思路是先定义一张单位换算表把各种写法映射到基准单位再用统一公式换算。我习惯用每类基准单位一张表的方式组织UNIT_WEIGHT { kg: 1.0, 千克: 1.0, 公斤: 1.0, g: 0.001, 克: 0.001, 斤: 0.5, 两: 0.05, t: 1000.0, 吨: 1000.0, lbs: 0.45359237, 磅: 0.45359237, } UNIT_LENGTH { m: 1.0, 米: 1.0, cm: 0.01, 厘米: 0.01, mm: 0.001, 毫米: 0.001, km: 1000.0, 公里: 1000.0, 英尺: 0.3048, ft: 0.3048, 英寸: 0.0254, in: 0.0254, } def clean_unit(value_text, unit_mapNone, defaultNone): 把带单位的字符串拆成数值统一换算到基准单位 if unit_map is None: unit_map UNIT_WEIGHT if value_text is None: return default text str(value_text).strip().lower() match re.search(r(-?\d(?:\.\d)?)\s*([a-z\u4e00-\u9fa5]), text) if not match: return default num float(match.group(1)) raw_unit match.group(2) if raw_unit in unit_map: return round(num * unit_map[raw_unit], 4) return default实际使用时你可以根据字段语义选择不同的单位表。比如商品重量抓到的原始字段2.5公斤调用clean_unit(2.5公斤, UNIT_WEIGHT)直接得到2.5基准kg。如果和你下游要求的单位一致就不用再二次处理了。单位清洗的坑主要在中文和英文单位的混合写法以及单位词和数字之间有没有空格。比如2.5 kg和2.5kg都能匹配但2.5KG如果不先lower()就会漏掉。再比如1000克和1千克结果都是1kg但如果不做换算下游就乱了。多说一句单位清洗不建议在爬虫解析阶段做因为你可能同时抓多个列表页和详情页字段的单位表可能不同统一在清洗阶段处理更安全。3.4 空值清洗识别、填充、删除、标记空值是爬虫数据里最隐蔽的坑。为什么说隐蔽因为空值不只是None和空字符串它有一大堆形态原始值常见来源None / null / NULLJSON、数据库NaN / nanpandas、numpy / HTML空标签null / none / N/A / n/a / NA网页模板占位-- / - / —前端占位符\N / / (null)数据导入导出待定 / 暂无 / 未知业务占位语义我的is_null方法会把上面所有这些形态统一识别成空然后根据策略统一处理。字段级判断代码import numpy as np def is_null(value): 识别各种形态的空值 if value is None: return True if isinstance(value, float) and np.isnan(value): return True if isinstance(value, str): text value.strip() return text in (, null, None, NULL, none, nan, NaN, N/A, n/a, NA, --, -, —, \\N, null, (null), 待定, 暂无, 未知, 无) return False def apply_null_strategy(value, strategyfill, fill_valueNone): 按策略处理空值返回清洗后的值 if not is_null(value): return value if strategy fill: return fill_value elif strategy drop: return np.nan # 标记为需要删除的行 return __MISSING__ # mark 策略保留标记让后续业务判断这里有三件事要特别提醒。第一is_null必须是宽识别因为爬虫拿到的空值形态远比你想象的丰富。我见过有网站用nbsp;非换行空格填充空字段字符串strip后还有内容肉眼看着是空的程序却识别不出来。这种情况建议把\u00a0、\u200b这类特殊空白符也加进替换逻辑。第二apply_null_strategy的返回值语义要清晰。填充策略直接返回填充值删除策略我返回np.nan意思是这一行请干掉标记策略返回一个特定字符串让下游能感知这里原本是空的。三种策略对应三种下游场景别混着用。第三空值清洗要和日期、金额清洗串起来。我的经验是先做空值判断再做类型转换。否则一个None丢进clean_amount虽然方法内部也会判空但每个方法都判空会导致代码重复串起来更优雅# 串行调用示例 if is_null(raw_date): date_obj default_date else: date_obj clean_date(raw_date)4. 把清洗工具包接入爬虫主流程4.1 在解析循环中调用清洗方法工具包写好了怎么用这是零基础读者最关心的问题。最理想的方式是在爬虫的解析环节结束、构造结构化数据字典之前把每个字段交给对应的清洗方法这样items列表里装的就是清洗后的干净数据后续无论转DataFrame还是直接入库都不用再反复处理。我以一个商品列表爬虫为例假设解析页面的item字典长这样items [] for item_node in page_soup.select(.item-list .item): raw { title: item_node.select_one(.title).text.strip(), price: item_node.select_one(.price).text, publish_time: item_node.select_one(.time).text, weight: item_node.select_one(.weight).text, sold_count: item_node.select_one(.sold).text, } cleaned { title: apply_null_strategy(raw[title]), price: clean_amount(raw[price]), publish_time: clean_date(raw[publish_time]), weight: clean_unit(raw[weight]), sold_count: clean_amount(raw[sold_count]), } items.append(cleaned)注意几个细节title这类文本字段我用了apply_null_strategy而不是简单的strip()因为空值策略要全局统一不能某个字段特殊处理。sold_count字段虽然看起来是整数但网页里可能显示1.2万所以复用了金额清洗方法因为它本质上也是数字数量级词的组合。如果某个清洗方法返回兜底值一定要记录日志我习惯在方法里挂一个回调或者logger记录触发兜底的原因和原始值。这样后续可以回溯到底是网站改版了还是解析规则出了问题。4.2 批量清洗当DataFrame遇上清洗器很多时候爬虫不是一条一条处理数据而是攒一批一起入库。这种情况下我会先把解析结果转成pandas DataFrame然后利用Series的map方法对每一列批量调用清洗器比for循环快得多代码也简洁import pandas as pd df pd.DataFrame(items) # 批量处理 df[price] df[price].map(clean_amount) df[publish_time] df[publish_time].map(lambda x: clean_date(x)) df[weight] df[weight].map(lambda x: clean_unit(x, UNIT_WEIGHT)) # 删除空值行 df df.dropna(subset[price, publish_time]).reset_index(dropTrue)map方法会把列里每个值逐个放进清洗方法结果以Series返回。需要注意的是如果你的清洗方法是类方法且带默认参数直接df[col].map(cleaner.clean_amount)可能报错因为map只会传一个位置参数进去。解决办法是包一层lambda或者用functools.partial固定默认参数。这一点是我实际踩过的坑报错信息还不直观。批量场景下性能也值得关注。如果你有几十万条数据逐行调Python循环基本等不起。建议优先用pandas的内置方法如pd.to_datetime、str.replace做初步清洗只把清洗器用在真正需要复杂逻辑的列上。两者结合速度和复用性都能兼顾。5. 常见问题与排查技巧实录5.1 日期解析失败的6种典型场景场景原始值原因处理建议英文月份缩写Mar 2, 2024格式不在模板列表加英文月份映射全角数字--编码未转换先全角转半角日期时间混排2024-12-02T14:30:00ISO8601的T分隔符加%Y-%m-%dT%H:%M:%S模板时间戳1704009600数字字符串被当日期先判断是否为纯数字按13位毫秒/10位秒分别处理相对时间2024-12-02 更新于12-05字符串夹杂无关文字用正则提取其中的日期子串缺失日期无页面本身没展示日期返回兜底值并记录日志这里补充一个时间戳的坑有的网站直接给毫秒级时间戳13位有的给秒级10位千万别统一除以1000否则日期全错了。我的习惯是先判断字符串长度13位是毫秒10位是秒再分别处理。5.2 数据库写入时空值覆盖的惨痛教训这个坑我必须单独拎出来讲。我早期做爬虫时每天晚上定时把当日数据全量更新到线上数据库用的SQL是傻瓜式的UPDATE 表 SET 字段值 WHERE id某值。看起来没问题直到某天线上某个字段突然变成空值排查半天才发现爬虫当天那个字段没抓到空值策略是drop结果写入时字段值变成了空字符串直接把数据库里原本正确的内容覆盖了。所以后来我给自己定了一条铁律爬虫数据入库前空值字段必须明确处理绝不允许空值悄悄覆盖旧值。具体做法有三层第一层上游拦截。清洗阶段把空值统一处理成填充值或标记值不让原生的None流向数据库。第二层SQL防御。如果用的是UPDATE操作强烈建议加WHERE条件确保只有新值非空时才执行更新避免空值覆盖——这一点和很多后端实践里提到的UPDATE加WHERE EXISTS条件是同一个思路。第三层对比校验。如果是全量更新入库前先对比旧数据和新数据的非空字段数量差异过大说明爬虫可能大面积漏抓先报警再说。这个坑在爬虫项目的后期最容易暴雷因为数据量大了以后单个字段的空值很难用肉眼发现等下游分析发现异常时数据已经被覆盖了好几轮。宁可清洗阶段多花点功夫也不要省这个保险。5.3 单位换算带来精度损失单位清洗在浮点数换算上有一个隐藏问题它不是精确运算。比如1斤换算成0.5kg0.5 * 1.0还好但3两换算成3 * 0.05结果是0.15000000000000002如果下游做精确比较就会出问题。我的处理办法是一切换算结果统一round(…, 4)同时把换算表里的系数尽量写成整数比值比如斤系数写成1/2而不是0.5这样在部分场景下能减少浮点误差。如果项目要精确到厘0.01元这种级别建议直接换成decimal.Decimal。另外一个常见问题是字段语义混淆。有些网页把价格和销量放在同一个带单位的字符串里比如¥99 已售1.2万件如果你直接扔进clean_amount正则会把第一个数字99抓出来当价格后面的1.2万就丢了。这种时候必须先在解析阶段把字段拆开各走各的清洗方法不能让清洗器背锅。6. 工具包的扩展方向与实践建议6.1 让清洗工具包支持业务自定义规则通用工具包的价值在于通用但真正跑进生产环境每个业务都有自己的特殊规则。比如某些平台的价格带括号说明含税某些日期后面跟着补发这种状态词。我在设计类的时候预留了一个自定义规则入口你可以传入一个custom_rules字典或回调函数cleaner UniversalCleaner( null_strategyfill, null_fill_value未知, custom_rules{ price: lambda x: x.replace(含税, ).strip(), publish_time: lambda x: x.split()[0].strip(), } )原则是通用逻辑写在工具包里业务差异写在规则里两者分离。这样清洗器本身只做格式归一化不掺入任何业务判断下一个项目还能继续用。6.2 实测下来最省心的组合方式根据我个人实际操作经验爬虫数据清洗最省心的组合是解析阶段拿原始字符串 → 用本工具包做字段级清洗 → 用pandas做列级批量处理 → 入库前再做一次空值总检查。三层防护基本上能把爬虫数据90%以上的脏问题挡在门外。这套流程我用了好几年稳定性明显比早期抓到什么存什么要高。尤其是空值总检查这一步我习惯在入库前跑一行df.isnull().sum()看一眼各字段空值数量有没有突变。如果昨晚还是0今天变成5000那大概率是目标网站改版了爬虫解析规则需要调整。这种问题越早发现损失越小。最后再分享一个小技巧清洗工具包里一定要加日志记录哪怕是简单的print或logging。特别是兜底值触发时一定要打印原始值和触发原因。我早期没有这个习惯数据出问题全靠猜加完日志之后问题定位速度提升了一个量级。这一点对任何规模的爬虫项目都适用。