← 返回博客首页

文本处理实战:不可见字符、CSV 解析与正则边界的三类静默故障

文本处理的三类故障有一个共同点:它们都不报错。字符串比较不相等、正则匹配不上、CSV 解析后字段错位——程序照常运行、退出码是 0,只有在结果被用到时问题才显现。

本文覆盖三类高频静默故障,工具支持见站内 文本处理速查。

第一类:不可见字符

这是最消耗排查时间的一类,因为它违反直觉 —— 你看到的字符集不等于实际存在的字符集。

常见的「隐形」字符:

字符 码位 从哪来
零宽空格 U+200B 网页   之外的隐藏分隔
零宽连接符 U+200D emoji 组合、波斯语等
不换行空格 U+00A0  、Word 自动排版
BOM U+FEFF UTF-8 文件头
各种引号 U+2018/2019/201C/201D Word 自动替换

它们的共同特征是不占位、不显示、但参与运算。于是:

  • trim() 之后字符串还有内容(U+00A0 不是空白字符给 JS 的 trim)
  • ^abc$ 匹配不上,因为实际是 \u200Babc
  • 两个文件名看起来一致但不相等
  • 数据库 unique 约束莫名冲突

排查的唯一可靠方法是打印字符码,不要靠肉眼。 本站的 文本统计工具 会列出每个字符的码位,肉眼看不出来的差异在这里一目了然。

预防是在入口做规范化,而不是在每个比较点打补丁:

const normalize = s => s
  .replace(/[\u200B-\u200D\uFEFF]/g, '')   // 零宽字符与 BOM
  .replace(/\u00A0/g, ' ')                // 不换行空格 → 普通空格
  .replace(/[\u2018\u2019]/g, "'")        // 中文引号 → ASCII
  .normalize('NFKC')                       // 全角 → 半角、兼容字符归一
  .replace(/\r\n/g, '\n')                  // 换行统一
  .trim()

NFKC 归一化尤其值得强调:它会把全角字母数字转成半角(ABC → ABC)、把兼容字符折叠(① → 1),这在处理用户从各种渠道粘贴来的文本时非常关键。

第二类:CSV 解析

「按行 split 处理 CSV」是一个看起来无害实则错误的写法。

RFC 4180 明确规定:字段内可以包含换行,只要用双引号包裹。于是这个内容:

a,b
"line1
line2",c

有三行文本,但只有两条记录。按 \n split 会得到 3 段,其中第 2 段是 line2",c —— 后续所有处理都建立在错误的记录数上。

更麻烦的是它不报错。split 总是成功返回数组,你会以为自己拿到了数据。

正确做法是用真正的 CSV 解析器。它们会处理:

  • 引号转义(字段内的 " 写成 "")
  • 字段内换行
  • 分隔符转义
  • 可选的 BOM 处理

Python 用 csv 模块,JavaScript 用 papaparse 或 ExcelJS。本站的 Excel 转 CSV 与 Excel 转 JSON 在导出时也遵循同样的规则。

什么时候可以手动 split?只有一个条件:你确认文件里没有引号包裹的字段。另外,注意 split 应该按分隔符(逗号/分号/制表符)而不是按行 —— 这是两个正交的操作,很多代码里混在一起就出错了。

第三类:正则替换的边界

正则有两个容易误解的性质。

第一,点号不是字面的点。 a.c 匹配 abc、a1c、a-c——任何首尾是 a、c 的三字符串。把它当分隔符会大面积误伤。

第二,全局替换的连带影响。 考虑这个需求:「把所有点号换成逗号」(做欧洲数字格式转换)。直接影响是期望的,但连带影响包括:

  • 小数点:3.14 → 3,14(某些欧洲格式确实要这样,但你要确认)
  • 版本号:1.2.3 → 1,2,3(一定是错的)
  • 域名:example.com → example,com(一定是错的)
  • IP 地址:192.168.1.1 → 192,168,1,1(一定是错的)

正确做法分三步:

  1. 先统计,不替换。 str.match(re)?.length 拿到命中数,如果预期 5 处却统计到 500 处,说明模式写错了。

  2. 用字符类限定范围。 /\d+\.\d+/g 只匹配「数字.数字」形态的小数,不会碰版本号与 IP。

  3. 用 \b 卡边界。 需要匹配完整单词时,\bword\b 不会命中 wordsmith。

const text = '1.5 and 1.2.3 and v1.0 and 2.25'
console.log((text.match(/\./g) || []).length)          // 4 —— 所有点
console.log((text.match(/\d+\.\d+/g) || []).length)    // 2 —— 仅小数
text.replace(/\d+\.\d+/g, m => m.replace('.', ','))
// 1,5 and 1.2.3 and v1.0 and 2,25  ← 版本号与 v1.0 未被改动

替换前先统计这一步几乎总被跳过,但它能在几秒内发现模式写错的绝大多数情况。本站的 文本统计工具 也能帮你快速确认。

一个额外的坑:去重要按业务键

顺便提一个常见问题:文本去重时按整行比对。

b,2
a,1
b,2

按整行去重后得到 2 行,但如果 b,2 的重复是因为「同一个用户 ID 出现多次」,那真正的业务问题不是重复行,而是数据本身有问题(要么该聚合,要么该报错)。

去重的正确姿势是明确业务键:按 ID 去重(保留第一条还是最后一条要说明),还是按整行去重。这两个语义完全不同。

自查清单

  • [ ] 文本比对失败时打印字符码,不靠肉眼找差异
  • [ ] 在数据入口做 NFKC 规范化并去零宽字符
  • [ ] 处理 CSV 用真正的解析器,不按行 split
  • [ ] 正则替换前先统计命中数
  • [ ] 用 \d+\. 这类字符类限定边界,不用裸点号
  • [ ] 明确去重是按整行还是按业务键

可复现的实测结果

把同一段文本分别从记事本、Word、网页复制三份,粘进本站的 文本统计工具,会看到三份的「不可见字符数」不同——Word 复制的那份通常带 U+00A0 与弯引号,网页那份可能带 U+200B。这解释了为什么「从 Word 粘来的数据就是匹配不上」。用 文本替换工具 试一次点号替换,也能直观看到不限定边界时版本号被改坏的后果。


广告

常见问题

两段看起来一样的文本为什么比较不相等?

**几乎总是因为看不见的字符。** 从网页、Word、PDF 复制的文本常混入零宽空格(U+200B)、零宽连接符(U+200D)、不换行空格(U+00A0)、字节顺序标记(BOM),以及各种引号变体(' ' 对 ' ')。这些字符不占位、不显示,但在字符串比较、正则匹配、trim 之后都还在。典型症状:trim() 之后还有内容、`^abc$` 匹配不上、文件名对不上但肉眼完全一致。**排查方法不要靠肉眼**——把字符码打印出来(本站的 [文本统计工具](/text-stats.html) 会显示每个字符的码位)。**预防方法是在入口做规范化**:去零宽字符、U+00A0 转普通空格、NFKC 归一化。

CSV 能不能用 split('\n') 处理?

**不能,除非你确认文件里没有引号包裹的字段。** RFC 4180 明确允许字段内包含换行,只要用双引号包裹。所以一行 `a,b` 后面跟着 `"line1 line2",c` 的文件,按行 split 会把一条记录拆成两半,后续处理全错,而且不报错。正确做法是用真正的 CSV 解析器(Python 的 `csv` 模块、papaparse、ExcelJS 等),它们会处理引号转义、字段内换行与分隔符转义。手动 split 只能用于「确认无引号无换行」的简单格式,或者用分隔符(逗号/分号/制表符)切分而不是按行。

正则里用 . 匹配分隔符有什么问题?

**点号在正则里匹配任意字符,不是字面的点。** `a.c` 会匹配 `abc`、`a1c`、`a-c`,任何长度为 3 且首尾是 a、c 的字符串。所以用它做分隔符转换会大面积误伤。另外一个更隐蔽的问题是**全局替换的连带影响**:把所有点号换成逗号,会同时改掉小数点、版本号(1.2.3)、域名、IP 地址。正确做法:① 替换前先统计命中数(`str.match(re)` 取长度),确认范围;② 用字符类限定边界,比如 `/\d+\.\d+/g` 只匹配小数点而不动版本号;③ 用 `\b` 卡词边界。工具选择上,[文本统计工具](/text-stats.html) 可以快速统计命中数量,替换前务必先做这一步。

← 返回博客首页