爬虫写到第二天,十有八九会撞上同一堵墙:明明请求发出去了,返回的数据也拿到了,可一拿去比较、计算、存库就各种报错。要么是'100' > 20这种比较结果莫名其妙,要么是价格字段带着货币符号算不了账,要么是时间字符串排序排得乱七八糟。这些问题的根子几乎都指向同一件事——类型转换没处理好。爬虫这个活儿,数据从网页到你手里,全程都是字符串形态在流转,而你要做的判断、统计、入库,全都要求正确的数据类型。类型转换就是这条流水线上最不起眼、却最容易翻车的一环。
这篇内容面向的是刚学爬虫、已经能跑通第一个请求的新手,也适合写了一阵子爬虫但总在数据清洗环节卡壳的朋友。我会把类型转换这件事从“为什么必须做”讲到“怎么做才稳”,覆盖字符串与数字互转、布尔值的坑、时间格式处理、容器类型转换、编码相关的隐式转换,以及批量数据清洗时的转换策略。所有示例都基于标准库和爬虫场景里最常见的第三方库,代码可以直接拿去改改就用。文中提到的项目、数据一律为模拟场景,不涉及任何真实站点信息。
1. 类型转换在爬虫里到底解决什么问题
1.1 从一次“比较失灵”说起
先还原一个特别典型的现场。你抓了一个商品列表,页面上写着“库存 100 件”,你顺手把文本取出来存进变量,然后写了个判断:库存大于 20 就标记为充足。代码跑完,所有商品全被标成充足,包括那个明明写着“库存 5 件”的。你打印出来一看,'5' > 20的结果是True——因为字符串比较是按字符编码逐位比的,'5'的编码大于'2',比较在第一位就结束了,后面的0根本没参与。
这就是类型转换存在的第一层意义:让数据具备正确的语义。字符串'5'和整数5在计算机里是完全不同的东西,前者是一串字符,后者是一个可参与算术运算的数值。爬虫从HTML里抠出来的内容,默认全是字符串(或者更准确地说,是str类型),你不主动转换,它就永远只是个“长得像数字的文本”。
第二层意义是让数据能参与运算和聚合。你想算平均价格、求库存总和、按销量排序,这些操作都要求数值类型。字符串做加法是拼接,'100' + '200'得到'100200'而不是300,这个坑新手几乎人人踩过。
第三层意义是让数据能正确入库和序列化。数据库的数值字段、日期字段对类型有严格要求,JSON序列化时True和'True'也是两回事。类型不对,轻则存进去的数据没法查询,重则直接抛异常中断整个抓取流程。
1.2 爬虫数据流的类型地图
把一次完整的抓取拆开看,数据类型的流转路径大致是这样的:HTTP响应回来是bytes,解码后变成str,解析HTML后提取出的文本节点还是str,经过清洗和类型转换变成int/float/datetime/bool等目标类型,最后入库或序列化。这条链上每一步都可能出问题,而类型转换处在“原始文本”到“可用数据”的关口,是承上启下的关键。
我用一张表把常见字段和它们的目标类型对应起来,方便你建立直觉:
| 页面字段示例 | 抓取到的原始形态 | 目标类型 | 转换要点 |
|---|---|---|---|
| 库存 100 件 | '100' | int | 剥离中文和空格 |
| 价格 ¥1,299.00 | '¥1,299.00' | float | 去符号、去千分位 |
| 是否包邮:是 | '是' | bool | 映射规则要明确 |
| 发布时间 2024-03-15 | '2024-03-15' | datetime | 格式串要对齐 |
| 销量 1.2万 | '1.2万' | int | 单位换算 |
| 标签 手机,数码 | '手机,数码' | list | 分隔符切分 |
这张表不是让你背,而是让你在写解析逻辑之前先想清楚:这个字段最终要变成什么类型,中间需要剥掉哪些“噪音”。想清楚这一步,后面写转换代码就是顺水推舟。
1.3 为什么不能“边抓边转”图省事
有人会想,既然迟早要转,那我在提取的时候顺手转了不就行了。这个想法在小规模脚本里没问题,但在稍微正经一点的爬虫里会埋雷。原因有三:一是提取和转换耦合在一起,转换失败时你分不清是页面结构变了还是数据格式变了,排查成本翻倍;二是有些字段需要先做整体校验再转换,比如先确认这一页是不是正常页面,再批量转;三是转换逻辑往往需要复用和单独测试,混在解析函数里没法独立验证。
我的习惯是把流程切成三段:提取层只负责拿到原始字符串,清洗层负责剥离噪音,转换层负责类型落地。三层之间用明确的中间数据结构传递,比如一个字典列表,每个字典的键是字段名,值是清洗后的字符串。这样任何一层出问题,都能快速定位。这个分层思路在后面讲批量处理时还会用到。
2. 字符串与数字互转的核心细节
2.1 int 和 float 的转换边界
int()和float()看着简单,实际用起来边界情况一大堆。先说int(),它接受字符串、浮点数、布尔值,但字符串必须是“纯整数形态”,带小数点、带空格、带符号位置不对都会抛ValueError。int('100')没问题,int('100.0')直接报错,int(' 100 ')反而可以——因为int()会自动去掉首尾空白。这个细节很多人不知道,导致白白多写一层strip()。
float()宽松一些,float('100')、float('100.0')、float('1e3')都能过,float(' 100 ')也行。但float('1,000')会报错,千分位逗号它不认。所以处理价格字段时,去逗号是必须的前置步骤。
这里有个容易忽略的点:浮点数的精度问题。float('0.1') + float('0.2')不等于0.3,而是0.30000000000000004。爬虫里如果涉及金额累加,用float会累积误差。金额场景我建议用decimal.Decimal,转换时直接Decimal('0.1'),注意要传字符串而不是浮点数,传浮点数会把误差带进去。下面这段是价格字段的稳妥转换写法:
from decimal import Decimal, InvalidOperation def parse_price(raw): if not raw: return None cleaned = raw.replace('¥', '').replace(',', '').replace('元', '').strip() try: return Decimal(cleaned) except InvalidOperation: return None注意InvalidOperation这个异常,Decimal转换失败抛的是它而不是ValueError,这是很多人第一次用Decimal时踩的坑。
2.2 带单位的数字怎么转
电商页面特别喜欢用“1.2万”“3.5k”这种缩写。这类字段不能直接int(),得先做单位识别和换算。我的做法是写一个映射表加正则提取,把数字部分和单位部分分开处理:
import re UNIT_MAP = {'万': 10000, 'w': 10000, 'k': 1000, '千': 1000} def parse_count(raw): if not raw: return 0 match = re.search(r'([\d.]+)\s*([万w千k]?)', raw, re.IGNORECASE) if not match: return 0 num, unit = match.group(1), match.group(2).lower() value = float(num) if unit in UNIT_MAP: value *= UNIT_MAP[unit] return int(value)这段代码有几个设计考量值得说。用re.search而不是re.match,是因为原始文本前面可能带“销量”之类的字样;单位映射用小写键配合re.IGNORECASE,兼容大小写混写;返回值统一int,因为销量这类字段不需要小数精度。实测下来,这套逻辑能覆盖绝大多数电商和内容平台的计数展示格式。
注意:单位换算后取整会丢精度,如果业务上需要保留小数(比如“1.25万”要精确到12500),把最后的
int()换成round()并保留合适位数。
2.3 数字转字符串的格式化陷阱
反方向的转换也有讲究。str(100)得到'100'没问题,但str(0.1 + 0.2)会得到一长串小数,直接拼进URL或存库都难看。格式化输出用f-string的格式说明符更可控:f'{value:.2f}'保留两位小数,f'{value:,}'加千分位。爬虫里拼分页URL、生成文件名、写日志都会用到。
还有一个隐蔽的坑:科学计数法。str(1e20)得到'1e+20',如果你拿这个字符串去拼查询参数,服务端可能不认。需要普通十进制表示时用f'{value:.0f}'。这个细节在处理大ID、时间戳时特别容易翻车,我见过有人把毫秒时间戳转成字符串后变成科学计数法,接口直接返回参数错误。
3. 布尔值与容器的转换逻辑
3.1 布尔转换的“真值”规则
Python里bool()的转换规则是:空字符串、0、None、空列表、空字典、空元组都是False,其余都是True。这个规则本身简单,但爬虫场景里有个大坑:字符串'False'是True。因为它是非空字符串。所以你不能指望bool('False')得到False,必须自己写映射。
页面上的布尔字段形态五花八门:“是/否”“有/无”“支持/不支持”“Y/N”“1/0”“true/false”。稳妥的做法是维护一个真值集合和一个假值集合,不在集合里的走默认值并记日志:
TRUE_SET = {'是', '有', '支持', 'y', 'yes', 'true', '1', '包邮'} FALSE_SET = {'否', '无', '不支持', 'n', 'no', 'false', '0', '不包邮'} def parse_bool(raw, default=False): if raw is None: return default text = str(raw).strip().lower() if text in TRUE_SET: return True if text in FALSE_SET: return False return default这里把raw先str()再lower(),是为了兼容上游可能传进来的非字符串类型。默认值参数的存在是为了让调用方决定“认不出来时算真还是算假”,比如“是否包邮”认不出来时默认False更保守,“是否上架”认不出来时可能默认True更合理。这个设计比硬编码一个默认值灵活得多。
3.2 字符串与列表的互转
标签、分类、关键词这类字段,页面上常以“手机,数码,配件”或“手机 数码 配件”的形式出现,目标类型是列表。str.split()是主力工具,但分隔符要按实际情况选,逗号、中文逗号、顿号、空格、竖线都可能出现。我的经验是先统一替换成一种分隔符再切:
def parse_tags(raw): if not raw: return [] for sep in [',', '、', '|', ';', ';']: raw = raw.replace(sep, ',') return [item.strip() for item in raw.split(',') if item.strip()]列表转字符串用join,注意元素必须全是字符串,混了数字要先map(str, ...)。这个方向在拼请求参数、生成存储字段时常用。
字典和字符串的互转主要涉及JSON。json.dumps把字典转字符串,json.loads反过来。这里的关键是编码参数:json.dumps(obj, ensure_ascii=False)才能让中文正常显示,否则会变成\uXXXX转义。存库和写文件时这个参数几乎必加,不然数据可读性极差。
3.3 None 与空值的处理策略
爬虫里None和空字符串''经常混着出现,转换时要区分对待。int(None)会抛TypeError,int('')会抛ValueError,异常类型都不一样。统一处理的办法是在转换函数入口先判空:
def safe_int(raw, default=0): if raw is None or raw == '': return default try: return int(float(str(raw).strip())) except (ValueError, TypeError): return default注意这里先float再int,是为了兼容'100.0'这种形态。直接int('100.0')会炸,绕一道float就稳了。这个技巧在处理从JSON里取出来的数字时特别有用,因为JSON的数字有时会被解析成浮点。
4. 时间与编码相关的转换实战
4.1 时间字符串的解析与格式化
时间字段是类型转换的重灾区。页面上的时间格式千奇百怪:“2024-03-15”“2024/03/15”“03-15 14:30”“3分钟前”“昨天”“2024年3月15日”。前几种用datetime.strptime配合格式串能搞定,后几种相对时间需要额外逻辑。
strptime的格式串要严格对齐,%Y-%m-%d对应2024-03-15,%Y/%m/%d对应斜杠版。一个格式串只能匹配一种形态,所以实际项目里我会准备一个格式列表,逐个尝试:
from datetime import datetime FORMATS = ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d', '%Y/%m/%d', '%Y年%m月%d日'] def parse_time(raw): if not raw: return None text = raw.strip() for fmt in FORMATS: try: return datetime.strptime(text, fmt) except ValueError: continue return None逐个尝试的性能开销可以接受,因为格式列表通常就几种。如果数据量大,可以先根据字符串长度或特征字符做一次预判,减少尝试次数。
相对时间(“3分钟前”“昨天”)需要结合抓取时刻计算。我的做法是解析出数值和单位,用timedelta往前推。这里要注意“昨天”这种没有具体时刻的,通常按当天零点处理,业务上如果需要精确到时分,得看页面有没有额外信息。
时间转字符串用strftime,格式串按入库要求定。存数据库一般用%Y-%m-%d %H:%M:%S,展示用可以更友好。还有一个高频需求是时间戳互转:datetime.timestamp()拿秒级时间戳,datetime.fromtimestamp()反过来。毫秒时间戳记得乘除1000。
4.2 编码转换:bytes 与 str 的边界
爬虫里bytes和str的转换是绕不开的。requests返回的response.content是bytes,response.text是解码后的str。解码用的编码如果猜错,中文就变乱码。response.encoding有时不准,稳妥做法是从响应头或HTML的meta里找charset,找不到再用apparent_encoding兜底。
手动转换用bytes.decode('utf-8')和str.encode('utf-8')。这里的关键认知是:编码错误分两种,UnicodeDecodeError是解码时字节序列不合法,UnicodeEncodeError是编码时字符超出目标编码范围。处理前者可以用errors='ignore'或errors='replace'跳过坏字节,但这是有损的,只适合对完整性要求不高的场景。我一般先定位是哪个字段出的问题,而不是无脑加errors参数,因为乱码往往意味着上游解析逻辑有误。
提示:写文件时
open(path, 'w', encoding='utf-8')的encoding参数一定要显式指定,依赖系统默认编码在不同环境下行为不一致,是跨平台脚本的经典坑。
4.3 批量数据的转换流水线
单个字段的转换写好了,接下来要面对的是成百上千条记录的批量处理。我的做法是定义一个字段转换配置,用统一的流水线跑:
FIELD_RULES = { 'price': parse_price, 'stock': safe_int, 'sales': parse_count, 'free_shipping': parse_bool, 'publish_time': parse_time, 'tags': parse_tags, } def transform_record(record): result = {} for key, value in record.items(): converter = FIELD_RULES.get(key) result[key] = converter(value) if converter else value return result这个设计的价值在于:转换规则集中管理,加字段只改配置;单条记录转换失败不影响其他字段(因为每个转换函数内部都做了异常兜底);方便单独对某个转换函数写测试。批量跑的时候再套一层列表推导或map,配合日志记录失败条数。
流水线跑完建议做一次类型校验,确认每个字段的实际类型符合预期。可以用一个简单的断言表,或者用pydantic这类库做模型校验。校验这一步能拦住很多“转换函数返回了None但下游没处理”的隐性bug。
5. 常见问题排查与避坑清单
5.1 高频报错速查表
下面这张表是我在实际项目里整理的高频问题,按报错信息或现象归类,方便对照排查:
| 现象/报错 | 根因 | 解决方向 |
|---|---|---|
ValueError: invalid literal for int() | 字符串含非数字字符 | 先清洗再转,或走safe_int |
TypeError: int() argument must be... | 传了None或列表 | 入口判空,确认上游类型 |
| 比较结果反直觉 | 字符串按字典序比较 | 比较前统一转数值 |
| 金额累加有尾差 | float精度问题 | 改用Decimal |
中文变\uXXXX | json.dumps默认转义 | 加ensure_ascii=False |
| 时间解析全失败 | 格式串与文本不匹配 | 打印原文核对格式 |
| 大数字变科学计数法 | str(float)的默认行为 | 用f'{v:.0f}'格式化 |
| 布尔判断恒为真 | bool('False')是True | 用显式映射集合 |
5.2 几个反直觉的坑
第一个坑:int()对浮点字符串的拒绝。前面提过,int('100.0')报错,但int(100.0)是合法的,得到100(截断而非四舍五入)。这个不对称性让很多人困惑。记住规则:字符串转int要求纯整数形态,数值转int是截断。
第二个坑:round()的银行家舍入。round(2.5)得到2而不是3,因为Python用的是“四舍六入五成双”。金额场景如果需要传统四舍五入,用Decimal配合ROUND_HALF_UP。这个细节在算折扣、分摊费用时会导致对不上账。
第三个坑:split()和split(' ')的区别。不带参数的split()会把连续空白当一个分隔符并去掉首尾空白,带参数的split(' ')遇到连续空格会产生空字符串元素。清洗文本时前者通常更符合预期。
第四个坑:隐式转换的静默失败。有些库在类型不对时会悄悄转成字符串而不是报错,导致问题延后暴露。比如某些ORM在赋值时做宽松转换,存进去才发现类型错了。对策是在数据进入存储层之前做一次显式类型断言。
5.3 我的转换函数设计原则
踩了这么多坑之后,我给自己定了三条规矩。第一,每个转换函数必须有默认值和异常兜底,绝不让单条脏数据中断整批任务。第二,转换失败要留痕,至少记一条带原始值的日志,方便回溯是哪条数据、哪个字段出的问题。第三,转换逻辑和业务逻辑分离,转换函数只负责类型落地,不做业务判断,这样函数可复用、可测试。
还有一条经验:先采样再批量。拿到一批数据后,先抽几十条跑一遍转换,人工核对结果,确认规则没问题再全量跑。这个习惯帮我省下过好几次因为格式没覆盖全而重跑整批任务的时间。
6. 把类型转换做成可复用的工具模块
6.1 模块划分与接口约定
零散的转换函数攒多了,就该收拢成一个模块。我的划分方式是按目标类型分文件:converters/number.py放数值相关,converters/datetime.py放时间相关,converters/text.py放字符串清洗,converters/container.py放列表字典相关。每个函数遵循统一约定:第一个参数是原始值,第二个参数是默认值(可选),内部捕获所有转换异常,返回目标类型或默认值。
这个约定的好处是调用方不用关心异常,拿到的永远是可用值。代价是可能掩盖数据质量问题,所以配套的日志不能省。我在每个函数的异常分支里都会logger.warning一条,带上原始值和字段名,跑完批量任务后统计告警数量,超过阈值就说明页面结构可能变了。
6.2 用配置驱动字段映射
字段和转换函数的对应关系用配置管理,前面展示过FIELD_RULES的写法。进一步可以把默认值也放进配置:
FIELD_RULES = { 'price': (parse_price, None), 'stock': (safe_int, 0), 'free_shipping': (parse_bool, False), }这样转换流水线在调用时把默认值透传进去,配置一处改、全局生效。字段多的时候这份配置本身就是一份数据字典,新人接手看一遍就知道每个字段的目标类型和兜底策略。
6.3 测试与回归
转换函数是最值得写单元测试的部分,因为它们纯函数性质强、边界情况多。测试用例不用追求覆盖率,重点覆盖:正常值、空值、None、格式异常值、边界值(如超大数、负数、零)。每个函数写五六个用例,跑起来很快,但能在改代码时第一时间发现回归。
我还会维护一份“脏数据样本”,把线上遇到过的奇葩格式收集起来,作为测试输入。这份样本库随着项目推进不断扩充,是比任何文档都宝贵的资产。新写一个转换函数,先拿样本库跑一遍,能过再上线,心里踏实。
类型转换这件事,单看每个函数都不复杂,难的是覆盖全、兜得住、可维护。爬虫的数据质量很大程度上取决于这一层的扎实程度。我个人的体会是,与其在数据出错后回头排查,不如在转换层多花半天时间把规则和兜底做扎实,后面能省下好几天的调试时间。最后分享一个小习惯:每次新增字段,先在纸上写下它的原始形态和目标类型,再动手写转换函数,这个几秒钟的动作能避免大部分想当然的错误。