ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

爬虫数据清洗四坑:编码乱码、时区错位、空值与脏字符实战

爬虫数据清洗四坑:编码乱码、时区错位、空值与脏字符实战 1. 第一个坑编码乱码不是你代码写错了是requests猜错了先讲个最典型的场景。你刚装好环境写了人生第一个爬虫requests.get一个网页然后print(response.text)。一运行控制台吐出一堆“锟斤拷”“烫烫烫”或者干脆是各种问号。绝大多数新手的第一个反应是我是不是代码写错了查了半天最后发现问题根本不在你写的逻辑里而是requests在帮你解码网页内容时猜错了编码。很多人写爬虫时拿到response就直接.text完全不管底层发生了什么。实际上response.text是requests帮你做了一步“解码”操作它把服务器返回的原始字节response.content用某种字符集转成了Python字符串。关键问题是它怎么知道用哪种字符集规则很简单——先看HTTP响应头里的Content-Type字段比如text/html; charsetutf-8如果有charset就用它如果没写requests就默认用ISO-8859-1也叫latin-1来解码。这个编码集在中文网页里几乎必乱因为中文在UTF-8里是3个字节在GBK里是2个字节硬要塞进latin-1的单字节空间根本对不上。这就是绝大多数爬虫新手第一次见乱码的真正原因不是网站在“加密”不是你的代码“有bug”而是requests解码时用了错误的字符集。1.1 requests是怎么猜编码的先看一个最直接的实验。随便找一个页面打印response.encoding和response.apparent_encoding你会发现两者的结果经常不一样import requests url https://example.com/some-page r requests.get(url, timeout10) print(headers里的charset:, r.encoding) print(requests猜出来的编码:, r.apparent_encoding)r.encoding来自响应头的Content-Type如果服务器没写charset它的值就是ISO-8859-1这时直接.text必出乱码。r.apparent_encoding是requests基于网页内容字节统计来推断的编码用的是chardet库这个在后端返回不规范的老网站上存活率很高。但注意apparent_encoding也不是万能的。它依赖统计学猜测对内容太短的页面、或者两种编码字节分布很接近的页面照样会猜错。我自己遇到过好几次一个GBK页面里恰好英文比例极高chardet就给你猜成ascii结果还是乱。所以正确的做法不是“无脑用apparent_encoding”而是“按优先级取编码”。我的习惯是这样import requests import re def get_page_text(response): # 1. 优先用响应头里的charset if response.encoding and response.encoding.lower() ! iso-8859-1: return response.text # 2. 从html源码的meta标签里找charset meta_charset re.search( rmeta[^]charset[\]?([\w-]), response.text[:5000], re.I ) if meta_charset: response.encoding meta_charset.group(1) return response.text # 3. 实在找不到就靠requests的统计推断 response.encoding response.apparent_encoding return response.text这段代码的核心逻辑是先相信服务器自己声明的编码服务器没声明就去看HTML里的meta标签两个都没有才用统计猜测。实际用下来我靠这个函数解决了大量老系统、政府网站、中小型网站的乱码问题。1.2 控制台乱码又是一回事还有一种“乱码”跟前端解析无关纯粹是控制台显示问题。代码在Linux服务器上跑得好好的拿到Windows上一运行print中文直接报UnicodeEncodeError或者全是乱码。因为Windows控制台默认编码是GBK中文版而Python 3的字符串是Unicode对象你在print时它得转成GBK输出一旦字符串里有GBK表示不了的字符比如特殊符号、生僻字直接抛异常。最省事的处理方式是在代码开头强制标准输出走UTF-8import sys sys.stdout.reconfigure(encodingutf-8)如果你用的Python版本比较老没有reconfigure那就用sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8)。再补充一个容易被忽略的点写文件时不管是open(data.json, w, encodingutf-8)还是爬到的二进制content直接写文件encoding参数一定要显式写明白。别图省事默认编码在不同操作系统上不一致你今天在自己电脑上存的是GBK明天换个环境读取就变乱码这坑我踩过不止一次。1.3 那些“表面上是编码问题其实不是”的场景有些时候你看到的乱码不是解码错误而是“叠了一层皮”。比如某些网站为了防爬把真实数据用base64包了一层再以看似正常的编码展示出来。这种在小程序接口里特别常见响应里是一串base64字符串你直接当成普通文本去解析怎么parse都是乱。base64本质上不是编码而是一种“二进制数据的文本表示法”它把任意字节流映射成64个可见字符。遇到这种情况正确的处理顺序是先解base64拿到真正的字节流再判断编码。import base64 # 假设从接口拿到了疑似base64的字段 raw 5L2g5aW977yM5LiW55WM try: decoded_bytes base64.b64decode(raw) text decoded_bytes.decode(utf-8) print(text) except Exception: print(不是base64或不是utf-8)再说一种URL编码。爬虫里经常要处理%20、%E4%B8%AD%E6%96%87这种百分号编码它是URL传输层的东西跟字符集解码不是一个层次但新手常常混在一起。用urllib.parse.unquote()可以解from urllib.parse import unquote print(unquote(%E4%B8%AD%E6%96%87)) # 输出中文记住一个判断顺序URL编码 → base64 → 字符集解码。这三层剥开之后才是你真正要的字符串数据。先剥哪一层脱错了后面全乱而且根本看不出是哪里出的问题。2. 第二个坑8小时时差服务器时区在背后捣鬼时区问题在爬虫界属于“不炸则已一炸就炸得特别隐蔽”的坑。编码乱码你一眼就能看出来但时间错了8小时如果你不仔细比对数据看着完全正常——日期格式、时间顺序都对就是数值跟你预期对不上。这种问题在数据分析、报表统计场景里后果非常严重。我印象最深的一次同事爬了一个电商平台的订单接口里面每条订单都有created_at字段值是2024-06-01T08:00:00Z这种带Z后缀的UTC时间字符串。他直接把字符串存进了MySQL的datetime字段压根没做任何时区转换。到了月底统计“6月1日订单量”时发现6月1日的数据比实际少了整整一批。因为UTC的6月1日08:00换算成北京时间已经是6月1日16:00了但到6月1日24:00之前UTC时间还没到6月2日——时间就“错位”了。2.1 为什么你说“服务器时间”都不靠谱爬虫拿到的网页或接口数据时间来源通常有三种UTC时间字符串末尾带Z或00:00或者干脆是10位/13位的Unix时间戳这种时间是绝对的不随地区和季节变化。服务器本地时间后端部署在哪台机器、机器设的什么时区它就返回什么时间。这种数据最坑因为“服务器本地时间”这个概念在分布式系统里基本等于“薛定谔的时间”——同一套接口部署在不同区域的服务器上返回的时间可能完全不一样而且你没任何可靠信息能分辨它究竟算哪个时区。前端格式化后的时间JavaScript在浏览器端生成的2024/06/01 08:00:00它用的往往是用户本机时区。爬虫如果直接抓这种渲染后的数据时间含义完全取决于访问者是谁。所以你用爬虫拿到的任何字符串时间第一件要做的事不是“转格式”而是先确认它的时区属性。怎么确认最靠谱的途径有两个看接口文档里有没有明确标注时区看时间字符串本身带不带时区后缀。如果两者都没有这时间就是“裸时间”你在处理时必须自己选一个默认时区并且记住这个选择后面全流程统一用它——否则今天按北京时间存明天按UTC存你的数据就是一笔烂账。2.2 时间戳转换你以为很简单Unix时间戳是爬虫接口里出场率最高的时间格式。它本身是绝对的跟时区无关——1717246800在全世界任何地方都是同一个瞬间。但问题出在“把时间戳格式化为人话”这一步。来看这个操作from datetime import datetime ts 1717246800 print(datetime.fromtimestamp(ts)) # 输出依赖当前系统时区 print(datetime.utcfromtimestamp(ts)) # 输出UTC时间推荐datetime.fromtimestamp()会使用当前操作系统的时区设置来格式化。你的电脑设的是北京时间它就输出北京时间你的服务器设的是UTC它就输出UTC。同一个时间戳在不同机器上跑出的字符串不一样。如果你直接用这个字符串去入库、去跟其他按小时分区的表做join比对分分钟出偏差。我的方案是统一用带时区的时间对象处理存储一律用UTC展示层再按需转本地时间。具体代码长这样from datetime import datetime, timezone from zoneinfo import ZoneInfo # 1. 时间戳 → 带时区的UTC时间对象 utc_dt datetime.fromtimestamp(1717246800, tztimezone.utc) # 2. UTC → 北京时间存储和展示都明确标注 bj_dt utc_dt.astimezone(ZoneInfo(Asia/Shanghai)) # 3. 存储统一存成带时区的字符串 store_str bj_dt.isoformat() # 2024-06-01T16:00:0008:002.3 不规范化时间处理的“连环翻车”时区处理不规范短期内看不出问题一旦数据累积到一定程度你会遇到一堆“跟人解释不清”的怪象凌晨0点跑批的任务统计出来的“今天”数据总比别人少一段两个爬虫抓同一个平台的数据一个抓到的订单时间是UTC8另一个是UTC入库后同一笔订单出现在两个日期里做时间序列分析时明明应该是平滑曲线结果每到早上8点出现一个断崖——因为那恰好是UTC日界线的交界。这些问题的根因都是一样的没有在数据入口处把时间统一成同一个时区体系。所以在自己的项目里我定了一条铁规矩凡是爬虫入库的时间字段统一转成带时区标记的UTC字符串或者北京时间字符串二选一绝不允许存“裸时间”。若上游只给了裸时间字符串我按约定默认为北京时间若上游给的是时间戳我用带tz参数的fromtimestamp转换成北京时间若上游给的是ISO8601带偏移的字符串直接用zoneinfo处理。3. 第三个坑字段一缺失整行数据就废了空值问题是所有爬虫工程师每天都要面对的事。它不像编码乱码那么刺眼也不像时区错位那么隐蔽它就是“润物细无声”地让程序崩溃、让数据库里出现一堆NULL、让算出来的报表数字对不上。而最让新手崩溃的是本机测试跑得好好的一旦换个列表页、换个商品分类突然就报KeyError或者TypeError程序当场中断。本质原因很简单网页和接口是后端程序员写的字段结构取决于后端返回逻辑它不会为了你的爬虫保持字段永远完整。商品无货时可能没有price字段个人简介为空时可能丢掉bio列表页某些卡片缺少title——这些都是常态。3.1 解析JSON时的防御姿势很多新手一上来就写item[price]这是最脆弱的写法。如果键不存在直接抛KeyError如果键存在但值是None虽然不报错但后续做price * 0.8之类的运算时必然报TypeError: unsupported operand type(s)。正确姿势是把“访问键”和“处理空值”当成两件事看。访问键用.get()方法处理空值用条件判断或or表达式item {name: iPhone 15, price: None} # 错误姿势key找不到直接崩溃 # price item[price] # 正确姿势先取默认值再判断 price item.get(price) or 0 name item.get(name) or 未知商品 # 但如果字段可能合法地是0、空字符串、False # 用 or 就有风险更适合用 is None 判断 price item.get(price) if price is None: price 0or的坑在于它会把0、、False、空列表这些“假值”一起替换掉。如果某个字段合法值就可能是0那你用or会把合法0也吞掉。所以在爬虫数据处理里我倾向于两种写法并行字段允许“空”且为空时要默认值用or快速兜底字段合法值可能为0或空字符串时必须用is None做精确判断。还有一个实战里经常出现的场景从HTML里用XPath或CSS选择器取数据元素不存在时返回的是空列表或None。此时很多人会直接对空结果做.text或.strip()一运行就抛AttributeError。这种问题统一用一个小函数包一层就行def safe_find(dom, selector, default): node dom.select_one(selector) if node is None: return default return node.get_text(stripTrue) or default3.2 数据库里NULL和空字符串是两个物种很多爬虫新手是在把数据写进数据库后才开始感受到空值的魔力的。最常见的一个认知误区认为NULL和空字符串差不多。实际上在SQL的语义里这两者完全不同NULL表示“值未知”它不参与任何计算NULL NULL的结果不是TRUE也不是FALSE而是NULL表示“我知道这是个空字符串”它是一个具体的值。怎么感受区别跑一条查询最能说明问题SELECT COUNT(*), COUNT(price), SUM(price) FROM products;如果price字段里有NULLCOUNT(price)会直接跳过NULL行SUM(price)也会忽略NULL结果就是你看着总共有100条记录但COUNT(price)只有80SUM(price)算出来的总和比实际“感觉上”少了20条记录的贡献。这在报表场景里很容易被误读为“数据缺失”。更麻烦的还有排序。MySQL里NULL默认在升序时排最前降序时排最后跟你直觉里“空值排在末尾”完全反过来。如果你没意识到字段里有NULL排序结果就会出现诡异的“空值跑最前面”现象。所以爬虫入库前我建议你对所有字段做一个统一策略能明确为0或空字符串的就规整成0或真正“查不到”的才留NULL。换句话说用显式的空值代替隐式的NULL能少踩一大半SQL层面的坑。3.3 UPDATE操作如何避免把好数据洗成空值这是我在实际项目中见过最隐蔽、后果最严重的一类问题。场景是这样的你有一个商品表里面已经积累了昨天的价格数据。今天你重新爬了一遍但这次爬虫在某个商品上拿不到价格——也许是页面结构变了也许是商品下架了、字段直接缺失。然后你写了个定时更新任务把今天爬到的数据全部UPDATE一遍逻辑很简单有数据就更新没数据就没得更新。但如果代码写成下面这样sql UPDATE products SET price %s, stock %s WHERE sku %s cursor.execute(sql, (price, stock, sku))当今天的price或stock解析出来是None时这条语句会直接把数据库里原来有值的字段覆盖成NULL。等你过了几天想做个价格走势图才发现历史数据被自己洗掉了想恢复只有干瞪眼。这种“空值更新”造成的破坏往往比爬不到数据严重得多。网上流行的一个思路是给UPDATE加WHERE条件来避免空值更新比如这样UPDATE products SET price %s WHERE sku %s AND %s IS NOT NULL;但这条语句在MySQL里有个隐性bug需要绕一下AND %s IS NOT NULL如果参数传的是Python的None在驱动层面会被转成NULL IS NOT NULL这个条件的判断结果是FALSE看起来是“防住了”但SQL语义上其实并不直观不同数据库驱动对参数类型处理还不完全一样。更稳妥的做法是在代码层直接判断只有字段值不为空时才拼进SET子句。updates {} if price is not None: updates[price] price if stock is not None: updates[stock] stock if updates: set_clause , .join(f{k}%s for k in updates) sql fUPDATE products SET {set_clause} WHERE sku%s params list(updates.values()) [sku] cursor.execute(sql, params)这样写哪些字段有值就更新哪些字段没有值的字段保留原值不会发生“一次无心UPDATE洗掉整列历史数据”的惨案。这个模式一定要刻在脑子里凡是做增量更新、按天重爬的爬虫项目都应该默认采用。4. 第四个坑看不见的脏字符数据清洗的“隐形雷区”如果说编码、时区、空值还都能用“逻辑”推理出来那脏数据就是纯粹“折磨人”的东西。它们藏在你看得见的内容里用一系列肉眼几乎无法察觉的字符让你的数据格式混乱、比对失败、正则失效。4.1 最经典的脏数据案例“¥1,299.00”我在带新手做爬虫项目时特别喜欢用一个经典的脏数据案例从网页上抓一个商品价格页面显示“¥1,299.00”你以为自己抓到的就是1299.00结果打印出来一看——字符串是¥1,299.00中间还夹着一个不间断空格\xa0。于是你想直接float(price)转数字结果抛异常。你想用正则\d\.\d{2}去匹配结果因为中间有全角字符、有逗号、有不间断空格匹配出来要么是299.00丢了前面的1要么干脆匹配不成功。这类问题在商品、金额、日期、号码等字段上极为常见。我从实际项目中总结了一套相对通用的“清洗链”import re import unicodedata import html def clean_price(value): if value is None: return None if isinstance(value, (int, float)): return float(value) # 1. HTML实体解码nbsp; → 普通空格 text html.unescape(str(value)) # 2. Unicode规范化全角字符转半角 text unicodedata.normalize(NFKC, text) # 3. 去掉货币符号、千分位逗号、空白 text text.replace(¥, ).replace(, ).replace(,, ) text re.sub(r\s, , text) # 4. 统一英文句点 text text.replace(, .).replace(。, .) try: return float(text) except ValueError: return None先html.unescape把nbsp;还原成真正的空格再用unicodedata.normalize(NFKC)做全角到半角的统一接着去掉货币符号和千分位逗号最后才转float。这套流程跑下来¥1,299.00、1,29900、\u00a01299\u00a0元这些形态都能被清洗成1299.0。4.2 全角空格、零宽字符与不可见字符脏数据里最让人血压飙升的是不可见字符。你在浏览器里看页面一切正常但你把这行文本拷贝出来放进Python里一查长度——啊怎么比看到的多出来几个字符我实际遇到过的不可见字符有几种\xa0不间断空格HTML里的nbsp;它是合法字符但在字符串比对时跟普通空格\x20不相等肉眼完全看不出区别\u3000全角空格中文排版里常见的“大空格”长度跟普通空格不同\u200b零宽空格。这个名字就很贴切——宽度为零的空格它存在于文本中但你看不见它复制粘贴时特别容易带进来而且它会直接导致字符串相等判断失败、正则匹配不到边界\ufeff字节序标记BOM常见于从Excel或带BOM的TXT文件里复制出来的文本会莫名其妙多出这个不可见前缀。排查不可见字符我一般这样快速定位s 你的字符串 for i, ch in enumerate(s): if ch.isspace() or ord(ch) 0xFFFF or ord(ch) in (0x200B, 0xFEFF, 0x00A0): print(i, hex(ord(ch)), repr(ch))清洗时直接用正则把它们全部替换掉import re def remove_invisible_chars(text): # 保留常见空格半角全角去掉零宽字符、BOM、不间断空格 text text.replace(\u00a0, ).replace(\u3000, ) text text.replace(\u200b, ).replace(\ufeff, ) text re.sub(r[\x00-\x1f\x7f], , text) # 去掉控制字符 return text.strip()这里要特别提醒一点不要无脑把所有\xa0都删掉因为它在某些场合是“有意义的内容”——比如中文排版里\xa0常被用来做缩进或分隔。正确做法是转成普通空格而不是直接删除。4.3 数据清洗的三个核心思路吃够了脏数据的亏之后我总结出三条经验算是在数据清洗这个方向上的核心心法清洗必须发生在入库前而不是查询时。很多人喜欢在SQL查询时用REPLACE()、TRIM()来处理数据这属于“亡羊补牢”。爬虫项目的数据入口只有一个你在入口处一次性清洗干净后续所有下游报表、分析、API都不用再操心格式问题。如果只是偶尔查询时清洗每次写SQL都要重复一堆函数嵌套累死人还容易漏。清洗规则要写成“幂等”的。所谓幂等就是同样的清洗函数对同一份数据跑一次跟跑一百次结果完全一样。这样能保证数据重复入库时不会叠出新的脏数据。这点特别容易在字符串合并场景翻车某次清洗规则是把\xa0替换成空格但如果下次运行时又把空格替换成了别的就叠出问题了。清洗失败时宁可留NULL也不要留半吊子数据。有些爬虫为了不让程序中断会设置try...except后直接返回原始字符串结果脏数据照样进了库。我的原则是如果清洗函数不能确定清洗结果是对的那这个字段就返回None并写一条日志。入库后再排查总比入库一个“看着像对的实际是错的”数据要强。配合这套思路日常爬虫里常用的“总清洗入口”可以是这样的def clean_field(value, field_typetext): value html.unescape(str(value)) if value is not None else value unicodedata.normalize(NFKC, value) value remove_invisible_chars(value) if field_type price: return clean_price(value) if field_type int: return parse_int(value) if field_type datetime: return parse_dt(value) return value.strip()所有字段统一经过clean_field清洗字段类型用field_type区分规则集中在同一个模块里。这样新人接手代码时不需要满项目找清洗逻辑全在一个地方既好维护又不容易漏。5. 把这四个坑一次填平设计一个统一的数据清洗模块讲完了四个坑各自是什么、怎么产生的、怎么填接下来就是工程化的问题了。在实际爬虫项目里这四个坑往往不是独立出现的接口返回的文本带乱码时间字段藏在JSON里是裸时间某个键为空导致整条记录清洗流程中断然后你会发现网站上同类页面居然有五六种价格写法……如果你每处理一个页面就写一堆散落的判断逻辑代码很快会变成一坨没法维护的“屎山”。所以我倾向于在项目早期就建一个统一的数据清洗模块把编码、时区、空值、脏数据的处理全部收编进去。以下是我的实践方案你可以直接拿去用然后根据自己的项目微调。5.1 清洗模块的六个核心能力一个“能打”的清洗模块至少得有六项能力输入标准化无论上游给的是bytes还是str先统一转成str并明确编码转不了就抛异常或返回None字段访问安全化所有字典访问走safe_get系列函数杜绝KeyError和AttributeError时间处理统一化所有时间字符串、时间戳最终统一输出成带时区的ISO8601格式字符串或UTC时间对象空值策略统一化根据字段类型对None、、NaN做策略性替换并保证入库前所有字段都有显式值或合法的NULL标记字符清洗规则化把HTML实体还原、全半角统一、不可见字符清理、两端去空白等步骤编成固定的调用顺序不允许到处自由发挥清洗审计日志化清洗前后的值、被替换掉的异常值、清洗失败的原因都要有日志可查否则出了问题完全无从下手。把这六项能力融进一个类里大概长这样import re import html import unicodedata from datetime import datetime, timezone from zoneinfo import ZoneInfo from urllib.parse import unquote class CrawlerDataCleaner: def __init__(self, default_timezoneAsia/Shanghai): self.tz ZoneInfo(default_timezone) def decode_content(self, content, declared_encodingNone): 字节流 - 字符串带编码探测 if isinstance(content, str): return content if not isinstance(content, (bytes, bytearray)): return if declared_encoding: try: return bytes(content).decode(declared_encoding, errorsreplace) except LookupError: pass try: return bytes(content).decode(utf-8, errorsreplace) except UnicodeDecodeError: return bytes(content).decode(gbk, errorsreplace) def safe_get(self, obj, key, default): if isinstance(obj, dict): value obj.get(key) if value is None: return default return value return default def normalize_datetime(self, value, defaultNone): 统一时间字符串、时间戳为带时区的datetime if value is None or value : return default if isinstance(value, (int, float)): return datetime.fromtimestamp(value, tzself.tz) if isinstance(value, datetime): if value.tzinfo is None: return value.replace(tzinfoself.tz) return value.astimezone(self.tz) # 处理带Z的UTC字符串 text str(value).strip() if text.endswith(Z) or re.search(r[-]\d{2}:\d{2}$, text): try: dt datetime.fromisoformat(text.replace(Z, 00:00)) return dt.astimezone(self.tz) except ValueError: pass # 处理裸时间字符串默认按北京时间解析 for fmt in (%Y-%m-%d %H:%M:%S, %Y-%m-%d, %Y/%m/%d %H:%M:%S): try: dt datetime.strptime(text, fmt) return dt.replace(tzinfoself.tz) except ValueError: continue return default def clean_text(self, text): if text is None: return text html.unescape(str(text)) text unicodedata.normalize(NFKC, text) text text.replace(\u00a0, ).replace(\ufeff, ) text re.sub(r[\x00-\x1f\x7f\u200b\u200c\u200d], , text) return text.strip() def clean_price(self, value): if value is None: return None if isinstance(value, (int, float)) and not isinstance(value, bool): return float(value) text self.clean_text(value) text text.replace(¥, ).replace(, ).replace($, ) text re.sub(r[,\s], , text) text text.replace(, .).replace(。, .) try: return float(text) except ValueError: return None这个类里decode_content把编码问题挡在最前面safe_get把空值访问挡在解析层normalize_datetime把时区统一收编clean_text和clean_price把脏字符包装成两条固定清洗链。你在写爬虫的时候只需要记住这一个入口不再需要到处写一堆try/except和正则。5.2 用示例数据跑通全流程光有模块不跑一遍就是纸上谈兵。下面给出一组我特意设计的“毒样数据”涵盖了编码、时区、空值、脏数据四个坑的典型形态cleaner CrawlerDataCleaner() # 模拟从接口拿到的原始数据 raw_items [ { name: Apple\u200b iPhone 15, price: ¥1,299.00, created_at: 2024-06-01T08:00:00Z, }, { name: \u3000小米 14\u3000, price: None, created_at: 1717246800, }, { name: HUAWEI Mate 60, price: 1,29900, created_at: 2024/06/01 16:00:00, }, ] cleaned [] for item in raw_items: cleaned.append({ name: cleaner.clean_text(cleaner.safe_get(item, name)), price: cleaner.clean_price(cleaner.safe_get(item, price)), created_at: cleaner.normalize_datetime( cleaner.safe_get(item, created_at), defaultNone ).isoformat() if cleaner.normalize_datetime( cleaner.safe_get(item, created_at) ) else None, }) for item in cleaned: print(item)输出结果应该统一成{name: Apple iPhone 15, price: 1299.0, created_at: 2024-06-01T16:00:0008:00} {name: 小米 14, price: None, created_at: 2024-06-02T00:00:0008:00} {name: HUAWEI Mate 60, price: 1299.0, created_at: 2024-06-01T16:00:0008:00}看到没有零宽空格被清掉了全角空格被转成普通空格再strip价格统一成了float时间无论带Z、时间戳、还是裸时间字符串全部统一成带08:00偏移的ISO8601字符串。这才是能直接入库的干净数据。5.3 清洗模块上线前的自检清单最后分享一张我每次写完一个爬虫项目后都会对照检查的自检清单。这四类问题如果不做一轮系统检查上线后早晚出事检查维度具体检查项通过标准编码是否显式指定了所有读写文件的encoding参数没有一处依赖系统默认编码编码是否处理了response.text解码乱码的可能性取页面文本时经过编码探测或meta charset判断时区所有时间字段是否都有统一的时区策略入库时间全部带时区偏移或统一为UTC时区时间戳是否都用带tz参数的fromtimestamp代码中不出现无参数的datetime.fromtimestamp空值所有dict访问是否都用safe_get或get不存在裸的item[key]空值UPDATE语句是否会覆盖已有数据的场景动态SQL跳过空值字段不会洗掉原值脏数据是否处理了HTML实体与全角半角清洗链包含html.unescape和NFKC脏数据是否清理了零宽字符、BOM、不间断空格清洗链对不可见字符有明确处理这套清单我每次项目收尾都会过一遍。说句实话爬虫本身写起来不难难就难在把“脏乱差”的网页数据整理成“干净可用”的结构化数据。编码、时区、空值、脏数据这四个坑你早一天系统掌握项目里就能少熬好几天的夜。根据我个人的经验新手阶段最容易高估自己的“抗坑能力”看完教程觉得懂了一跑真实项目就翻车。所以建议你动手练习时刻意给自己找几个“不那么友好”的网站没写charset的、返回裸时间的、价格格式五花八门的、字段时有时无的。只有在脏数据里摸爬滚打过一遍你才算真正迈过了爬虫入门那道坎。把这篇文章里的清洗模块做成自己项目里的标配以后写新爬虫时直接复用效率能翻好几倍。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进