引言
第五章到这里,我们已经学完了两件武器:字符串方法、切片与格式化(第 1、2、7 篇),以及编码与正则(第 3 至 6 篇);第 8 篇用它们解析了 nginx 访问日志。本篇把这些技能收拢成一套完整的”文本清洗工具链”(text cleaning pipeline)。它是本章的第 9 篇——进入第 10 篇综合测验之前的最后一次实战。
我们面对的真实问题是:从网页复制粘贴、爬虫抓取、Excel 导出、用户手工填表等渠道拿到的文本,从来不会干干净净。常见脏数据包括:行首行尾与单词之间夹着看不见的空白;全角标点与半角标点混用(“电话:138…”与”电话:138…”并存);文本里残留 <p> 这类 HTML 标签和 这类实体;电话号码带着连字符;数字带着”1,234,567”这样的千分位。如果直接把这样的字符串送进后续分析代码——比如按逗号 split、按数字比较大小——结果必然出错。
本篇的做法是:先看一组取自真实场景的脏数据;然后把它拆成若干”一层只做一件事”的清洗层,每层写出可独立运行、可单独验证的函数;最后组装成一条”清洗 → 提取 → 统计 → 结构化输出”的完整管线,并附上实战中最高发的四个坑。读完本篇,你应该能独立写出自己的清洗管线。前置要求:第 1 篇的常用字符串方法、第 2 篇的切片、第 4 至 6 篇的正则(含分组与非贪婪)、第 7 篇的格式化语法。
概念与原理
什么是数据清洗,为什么不能只写一次性脚本
数据清洗(data cleaning)指:把”格式混乱、含杂质”的原始文本,转换成”结构一致、字段明确”的干净文本或结构化记录。它和数据解析(parsing)的区别在于:解析是从已有固定规则的结构里取字段(比如第 8 篇 nginx 日志的行格式),清洗则要先处理”没有规则可言”的那部分——杂质多种多样,字段可能缺失也可能重复。
面对一批脏数据,初学者最常犯的错误是写一个”大而全”的函数,用十几个 replace 加一堆 if 把所有情况一次性处理完。这种脚本有四个致命问题:其一,不可复用——换一批数据、多一类杂质,就要重写;其二,不可验证——字段丢了,你不知道是哪个环节弄坏的;其三,顺序没有设计——先做哪步、后做哪步全凭感觉,而清洗的先后顺序恰恰是最容易出错的设计点;其四,不可维护——三个月后你自己也看不懂它为什么要这样拼接。工程上的正确做法是”管线”(pipeline):把清洗过程拆成若干处理层(layer),数据像流水线上的工件一样,依次流过每一层,每一层只负责一类杂质。
分层设计与顺序为什么重要
本篇管线按职责分为五层:
- 空白归一化:去首尾空白,压缩内部连续空白;
- 网页残留清理:移除 HTML 标签,还原常见实体;
- 标点规范化:把全角标点映射为半角标点;
- 数值还原:抽掉千分位逗号,恢复纯数字;
- 字段提取:从清洗后的文本中取出邮箱与电话,并统一大小写、去重。
层与层之间”只依赖上一层的输出,不互相调用”,这就是单一职责(single responsibility)原则在数据清洗上的体现:任何一层出错,单独运行它就能定位。顺序同样关键——例如手机号”159-1234-5678”带着连字符,如果先提取字段再做连字符归一化,提取必然失败;先归一化再提取就能成功。这也是为什么提取被放在清洗之后,而”顺序反了会怎样”专门留到了易错点部分讨论。
背后的机制
- strip 与空白的定义:
str.strip()默认去除字符串首尾的空白字符,这里的”空白”遵循str.isspace()的定义,不只是空格与\t、\n,还包括不间断空格\u00a0、全角空格\u3000等 Unicode 空白。正则的\s在字符串模式下匹配同样的字符集合(本机 Python 3.12 实测与isspace()一致)。因此用re.sub(r'\s+', ' ', text)压缩空白是可靠的;而str.replace(' ', '')只认 ASCII 空格,会漏掉上面这些”看不见的空白”。 - translate 与查表替换:
str.maketrans(a, b)生成”字符到字符”的映射表,要求 a、b 等长;str.translate(table)用这张表做一次从左到右的查表替换,复杂度 O(n)。它比连续调用多个str.replace更清晰、更快——一张表说清楚全部映射,这正是第 1 篇字符串方法的综合应用。 - 正则的查找-替换模型:
re.sub(pattern, repl, text)从左到右扫描 text,每遇一个匹配就替换,返回新字符串;替换串中的\1、\2引用第 1、2 个捕获组,是第 5 篇”分组”知识的直接应用。需要强调:一次sub扫描会替换”所有匹配”,但这不等于”替换到不能再替换”——易错点 2 会展示两者的区别,这正是”循环到字符串不再变化”(不动点,fixed point)这一写法的由来。 - 提取与规整:
findall负责批量抓取全部候选;抓到的邮箱统一lower()并放入集合去重——邮箱地址不区分大小写,ZhangSan@Example.COM和zhangsan@example.com是同一个地址;电话则保留原文形态,因为它不是用于比较的键,后续通常还要按它的格式做分组。
操作与实现:逐层构建工具链
第 0 步:观察真实的脏数据
先准备一组足够”脏”的样例(dirty data)。它们混合了首尾与内部多余空白、全角标点、HTML 残留、千分位数字、大小写不一以及带连字符的电话号码,模拟从网页复制、或由不同的人手工填表汇总后的真实形态:
import re
dirty_lines = [
' 张三 ,zhangsan@example.com ,电话:13812345678 ',
'李四 <lisi@test.org> 手机:159-1234-5678',
'王五 <p>Email: wang.wu@mail.example.com ¥12,345.67</p>',
'赵六 (010)88886666 转 102 邮箱:ZHAOLIU@EXAMPLE.NET',
'销量 1,234,567 件,退款率 3.5%,联系 021-55551234',
]
def strip_whitespace(text):
# 第 1 层:去首尾空白,并把中间连续的空白压缩成单个空格
return re.sub(r'\s+', ' ', text).strip()
for line in dirty_lines[:3]:
print(repr(line))
print(' ->', repr(strip_whitespace(line)))
运行结果:
' 张三 ,zhangsan@example.com ,电话:13812345678 '
-> '张三 ,zhangsan@example.com ,电话:13812345678'
'李四 <lisi@test.org> 手机:159-1234-5678'
-> '李四 <lisi@test.org> 手机:159-1234-5678'
'王五 <p>Email: wang.wu@mail.example.com ¥12,345.67</p>'
-> '王五 <p>Email: wang.wu@mail.example.com ¥12,345.67</p>'
用 repr() 打印是为了让看不见的空白现出原形:可见首尾的空格被剥掉、双空格被压成单空格。注意第 2 行第 3 行看起来很”干净”的问题并没有被这一层解决——标签、标点、千分位都还在,这正是后面几层的工作。
第 2 层:清理网页残留
import re
def clean_html(text):
# 第 2 层:去掉 HTML 标签,还原常见实体
text = re.sub(r'<[^>]+>', '', text)
for old, new in {' ': ' ', '&': '&', '<': '<', '>': '>'}.items():
text = text.replace(old, new)
return text
sample = '<p>售价 & 折扣,详见<说明>页面</p>'
print(clean_html(sample))
运行结果:
售价 & 折扣,详见<说明>页面
这里用 <[^>]+> 而不是 <.+>,正是第 5 篇”贪婪与非贪婪”的实战:<.+> 是贪婪的,会从第一个 < 一路吃到最后一个 >,把多个标签连同中间的正文一起吞掉;而 [^>]+> 表示”一个或多个非 > 的字符”,保证每个匹配都止步于最近的闭角括号,这样才能逐个删除每个标签。实体 (不间断空格)、&(与号)、</>(尖括号)则用字典逐个 replace 还原成真正的内容。
第 3 层:全角标点转半角
FULL = ',。;:?!()“”‘’'
HALF = ',.;:?!()""\'\''
PUNCT_TABLE = str.maketrans(FULL, HALF)
def normalize_punct(text):
# 第 3 层:全角标点 -> 半角标点(查表替换)
return text.translate(PUNCT_TABLE)
print(normalize_punct('电话:13812345678;邮箱:a@b.com(重要)'))
print(normalize_punct('他问:“现在几点了?”'))
运行结果:
电话:13812345678;邮箱:a@b.com(重要)
他问:"现在几点了?"
两张等长的字符串 FULL 与 HALF 逐字符对应:全角逗号变半角逗号、全角问号变半角问号……str.maketrans 建表、translate 一次扫描完成。为什么值得做这一步?因为后续的 split(',')、正则里的 , 都只认半角——不统一标点,同一个字段就会在”有逗号分隔”和”没有逗号”两个宇宙里各出现一次,统计时必然对不上。
第 4、5 层:提取邮箱电话与还原千分位
import re
EMAIL_RE = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')
PHONE_RE = re.compile(r'(?:\(\d{3,4}\)|\d{3,4}-)\d{7,8}|1[3-9]\d{9}')
text = '张三 zhangsan@example.com 电话 13812345678;李四 lisi@test.org 座机 (010)88886666'
print('邮箱:', EMAIL_RE.findall(text))
print('电话:', PHONE_RE.findall(text))
def restore_thousands(text):
# 第 5 层:千分位还原,必须循环直到无变化
while True:
new = re.sub(r'(\d),(\d)', r'\1\2', text)
if new == text:
return text
text = new
for s in ['销量 1,234,567 件', '金额 ¥12,345.67', '连续逗号 1,2,3,456']:
print(s, '->', restore_thousands(s))
运行结果:
邮箱: ['zhangsan@example.com', 'lisi@test.org']
电话: ['13812345678', '(010)88886666']
销量 1,234,567 件 -> 销量 1234567 件
金额 ¥12,345.67 -> 金额 ¥12345.67
连续逗号 1,2,3,456 -> 连续逗号 123456
邮箱模式 [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,} 是工程上的常用保守写法:允许英文、数字、点、下划线等常见字符,域名部分至少两点分隔、后缀至少两个字母——既不会被”@”前后的中文误伤,也不会漏掉 a.b@sub.example.com 这样的合法地址。电话号码用交替(alternation)处理两类:(?:\(\d{3,4}\)|\d{3,4}-)\d{7,8} 匹配带区号的座机((010)88886666 或 021-55551234),1[3-9]\d{9} 匹配 11 位手机号(第二位 3 至 9)。千分位还原用 (\d),(\d) 捕获”数字-逗号-数字”,反向引用 \1\2 抽掉逗号;为什么要 while 循环见易错点 2。
组装管线:清洗 → 提取 → 结构化输出
把以上各层串起来,再加上批处理与统计,就是一条可复用的完整工具链:
import re
EMAIL_RE = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')
PHONE_RE = re.compile(r'(?:\(\d{3,4}\)|\d{3,4}-)\d{7,8}|1[3-9]\d{9}')
FULL = ',。;:?!()“”‘’'
HALF = ',.;:?!()""\'\''
PUNCT_TABLE = str.maketrans(FULL, HALF)
def strip_whitespace(text):
return re.sub(r'\s+', ' ', text).strip()
def clean_html(text):
text = re.sub(r'<[^>]+>', '', text)
for old, new in {' ': ' ', '&': '&', '<': '<', '>': '>'}.items():
text = text.replace(old, new)
return text
def normalize_punct(text):
return text.translate(PUNCT_TABLE)
def restore_thousands(text):
while True:
new = re.sub(r'(\d),(\d)', r'\1\2', text)
if new == text:
return text
text = new
def clean(text):
# 组装清洗管线:顺序即设计,各层互不依赖
for layer in (strip_whitespace, clean_html, normalize_punct, restore_thousands):
text = layer(text)
return text
def extract(text):
# 提取并规范化字段:邮箱统一小写、去重
emails = {e.lower() for e in EMAIL_RE.findall(text)}
phones = PHONE_RE.findall(text)
return sorted(emails), phones
def process_batch(lines):
# 批量处理,产出结构化结果
results = []
for raw in lines:
cleaned = clean(raw)
emails, phones = extract(cleaned)
results.append({'原始': raw, '清洗后': cleaned, '邮箱': emails, '电话': phones})
return results
dirty_lines = [
' 张三 ,zhangsan@example.com ,电话:13812345678 ',
'李四 <lisi@test.org> 手机:159-1234-5678',
'王五 <p>Email: wang.wu@mail.example.com ¥12,345.67</p>',
'赵六 (010)88886666 转 102 邮箱:ZHAOLIU@EXAMPLE.NET',
'销量 1,234,567 件,退款率 3.5%,联系 021-55551234',
]
report = process_batch(dirty_lines)
print(report[0])
print()
for i, row in enumerate(report, 1):
print(f'第 {i} 条: {row["清洗后"]}')
print(f' 邮箱 {len(row["邮箱"])} 个: {row["邮箱"]}')
print(f' 电话 {len(row["电话"])} 个: {row["电话"]}')
total_emails = sum(len(r['邮箱']) for r in report)
total_phones = sum(len(r['电话']) for r in report)
print(f'\n共处理 {len(report)} 条记录, 提取邮箱 {total_emails} 个, 电话 {total_phones} 个')
运行结果:
{'原始': ' 张三 ,zhangsan@example.com ,电话:13812345678 ', '清洗后': '张三 ,zhangsan@example.com ,电话:13812345678', '邮箱': ['zhangsan@example.com'], '电话': ['13812345678']}
第 1 条: 张三 ,zhangsan@example.com ,电话:13812345678
邮箱 1 个: ['zhangsan@example.com']
电话 1 个: ['13812345678']
第 2 条: 李四 手机:159-1234-5678
邮箱 0 个: []
电话 0 个: []
第 3 条: 王五 Email: wang.wu@mail.example.com ¥12345.67
邮箱 1 个: ['wang.wu@mail.example.com']
电话 0 个: []
第 4 条: 赵六 (010)88886666 转 102 邮箱:ZHAOLIU@EXAMPLE.NET
邮箱 1 个: ['zhaoliu@example.net']
电话 1 个: ['(010)88886666']
第 5 条: 销量 1234567 件,退款率 3.5%,联系 021-55551234
邮箱 0 个: []
电话 1 个: ['021-55551234']
共处理 5 条记录, 提取邮箱 3 个, 电话 3 个
请仔细观察第 2 条——李四的邮箱和电话都丢了:<lisi@test.org> 被第 2 层当成 HTML 标签整个删掉了,159-1234-5678 又因为连字符不被手机号模式识别。这就是真实数据给我们的下马威:管线跑通了,但”跑通”不等于”跑对”。原因与修复见易错点 3。另一方面,report[0] 展示了结构化结果:每条记录是含”原始、清洗后、邮箱、电话”四个键的字典,可以直接交给统计或入库逻辑;末尾的汇总行则是第 7 篇格式化语法的应用。
易错点与陷阱
陷阱 1:空格不止一种
用 replace(' ', '') 清理空白,会漏掉不间断空格 \u00a0 和全角空格 \u3000——它们分别来自网页上的 实体与中文排版。实测如下:
import re
# 陷阱 1:replace(' ', '') 漏掉不间断空格与全角空格
s = 'a\u00a0b\u3000c'
print('replace 空格:', repr(s.replace(' ', '')))
print('regex \\s+ :', repr(re.sub(r'\s+', '', s)))
运行结果:
replace 空格: 'a\xa0b\u3000c'
regex \s+ : 'abc'
相同的数据,\s 一步清干净,replace(' ', '') 则纹丝不动(\xa0 即 \u00a0)。规则:只要目标是”所有空白”,就用 \s、strip() 或 split()/join(),不要手写空格列表。
陷阱 2:千分位还原”一次 sub”不够
re.sub 一次扫描会替换所有匹配,但 1,2,3,456 里的逗号两两配对、位置交错,一次扫描结束后还会残留新”配对”出的逗号。实测:
import re
# 陷阱 2:千分位一次替换不够
t = '1,2,3,456'
print('一次替换:', re.sub(r'(\d),(\d)', r'\1\2', t))
while True:
new = re.sub(r'(\d),(\d)', r'\1\2', t)
if new == t:
break
t = new
print('循环后 :', t)
运行结果:
一次替换: 12,3456
循环后 : 123456
一次替换后 1,2 变 12、3,4 变 34,但中间的逗号被”夹”在了新数字中间,变成 12,3456;只有反复替换到字符串不再变化(不动点),才能彻底还原。另外注意细节:1,234.56 这种”千分位 + 小数点”混合写法不会被误伤,因为模式要求逗号两侧都是数字。
陷阱 3:尖括号陷阱与连字符手机号
回到管线输出第 2 条:<lisi@test.org> 被 <[^>]+> 当成标签删除,导致邮箱丢失;159-1234-5678 又不匹配 1[3-9]\d{9},导致电话丢失。两条都是真实案例级别的坑。修复思路:其一,标签删除改用”白名单”——只删已知的常见标签名,而不是看到尖括号就删;其二,新增一层”连字符手机号归一化”,把 3-4-4 的连字符分段拼回 11 位纯数字。
import re
# 修复 1:标签白名单,只删"真标签";修复 2:手机号连字符归一化
TAG_RE = re.compile(r'</?(?:p|div|span|b|i|em|strong|a|br|li|ul|table|tr|td)\b[^>]*>', re.I)
EMAIL_RE = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')
PHONE_RE = re.compile(r'(?:\(\d{3,4}\)|\d{3,4}-)\d{7,8}|1[3-9]\d{9}')
def clean_html(text):
text = TAG_RE.sub('', text) # 白名单替换原来的 <[^>]+>
for old, new in {' ': ' ', '&': '&', '<': '<', '>': '>'}.items():
text = text.replace(old, new)
return text
def normalize_hyphens(text): # 新增:连字符手机号 -> 纯数字
return re.sub(r'(\d{3})-(\d{4})-(\d{4})', r'\1\2\3', text)
lines = [
'李四 <lisi@test.org> 手机:159-1234-5678',
'赵六 (010)88886666 转 102 邮箱:ZHAOLIU@EXAMPLE.NET',
]
for raw in lines:
t = re.sub(r'\s+', ' ', raw).strip()
t = clean_html(t)
t = normalize_hyphens(t)
print(t)
print(' 邮箱:', EMAIL_RE.findall(t), ' 电话:', PHONE_RE.findall(t))
运行结果:
李四 <lisi@test.org> 手机:15912345678
邮箱: ['lisi@test.org'] 电话: ['15912345678']
赵六 (010)88886666 转 102 邮箱:ZHAOLIU@EXAMPLE.NET
邮箱: ['zhaoliu@example.net'] 电话: ['(010)88886666']
\blisi 之类不被白名单收录,<lisi@test.org> 因此得以保留;完整五条数据跑修复后的管线,合计能提取到 4 个邮箱、4 个电话(本机实测)。这个例子说明:清洗管线必须针对你的真实数据反复”打补丁”,每加一层的规则都要用真实样例回归验证。不存在一劳永逸的万能清洗器,但”分层 + 每层可独立验证”让打补丁变得安全可控。
陷阱 4:中文宽度与 format 对齐
想用第 7 篇的对齐语法排出表格,却总对不齐——因为格式化的宽度按”字符个数”计算,而中文在等宽字体下显示宽度约为两个英文字符:
print(f'{"姓名":<6}|{"邮箱":<10}')
print(f'{"张三":<6}|{"zs@qq.com":<10}')
print(f'{"李四光":<6}|{"lsg@163.com":<10}')
运行结果:
姓名 |邮箱
张三 |zs@qq.com
李四光 |lsg@163.com
张三 两字符加四个空格,与 姓名 逐字符对齐;但 李四光 三字符在显示器上占六个格子,比 张三 多出两个格子的宽度,竖线因此错位。实践中要么把中文列单独处理(按显示宽度补齐),要么像本篇汇总行那样只对纯 ASCII 字段(数字、域名)做对齐,中文直接输出。
小结
本篇把第五章前八篇的技能收拢成了一条可复用的文本清洗管线:空白归一化、HTML 残留清理、全角转半角、千分位还原、邮箱与电话提取,最后输出结构化记录并统计汇总。四个陷阱说明三件事:第一,空白不止一种,面向”所有空白”要交给 \s;第二,“替换所有匹配”不等于”替换到不动点”,千分位需要循环;第三,正则的边界永远以真实数据为准,标签要用白名单、格式要先归一化;第四,中文字符宽度与格式化对齐需要单独处理。带着这四个经验,去完成第 10 篇的综合测验;下一篇,把这套能力用在系统化的检测上。
练习与思考题
- 给定一段含
\t与空行的文本,用splitlines()与split()统计”非空行数”和”每行单词数”,并说明为什么先strip()再数词更稳妥。 - 为管线新增一层
extract_urls:用r'https?://\S+'提取 URL 并去重,思考它应该放在第 4、5 层(提取)之前还是之后,给出理由。 - 把
restore_thousands应用于异常数据'1,23,4567',检查结果是否可信;如果不可信,讨论应该在管线的哪一层、用什么手段(比如再次校验字段长度)拦截这类坏数据。 - 回答陷阱 3 中的顺序问题:如果先把
159-1234-5678做连字符归一化,再被手机号正则提取,结果如何?如果顺序反过来呢?用一行代码验证你的判断。