从多个国家收集价格,你会遇到同一个数字用十几种方式书写的情况。德国网站显示 1.234,56 €。法国网站显示 1 234,56 €,中间是一个你可能看不出来的空格。瑞士网站显示 CHF 1’234.50。印度网站将数字分组为 12,34,567。而像 03/04/2026 这样的日期,在美国意味着 3 月 4 日,在几乎其他所有地方都意味着 4 月 3 日。
只要弄错其中任何一个,错误往往并不明显:价格可能相差一千倍,日期可能偏差一个月,货币可能与另一种共享符号的货币混淆。本指南将介绍你会遇到的各种格式、决定这些格式的规则,以及在数值不匹配时拒绝猜测的解析代码。
关键要点
- 用页面所属的区域设置(locale)来解析每个数值。相同的字符在不同的区域设置中代表不同的数字。
- 不要仅仅去掉分隔符然后碰运气。要根据区域设置判断哪个符号是小数分隔符,并拒绝不符合规则的数值。
- 将货币符号视为存在歧义。“$10.00” 在加拿大页面上是加元,在墨西哥页面上是比索,在澳大利亚页面上是澳元。请存储 ISO 4217 代码。
- 以正确的精度存储最小货币单位的金额。日元没有小数位;巴林第纳尔和科威特第纳尔则有三位小数。
- 数字型日期需要区域设置,或者更好的是,需要机器可读的数据来源。如果页面提供了结构化数据,优先使用其中的 ISO 8601 格式。
这些规则从何而来
区域设置的惯例并非民间传说。它们发布在 Unicode 通用区域数据仓库(Common Locale Data Repository,CLDR)中,操作系统、浏览器以及大多数国际化库都使用这一仓库。以下示例直接来自 CLDR 数据,通过 Python 库 Babel 生成,格式化的对象是数字 1234567.891 和金额 1,234.50 欧元:
| 区域设置 | 小数点 | 分组符 | 1234567.891 | €1,234.50 |
|---|---|---|---|---|
| 英语,美国 | . | , | 1,234,567.891 | €1,234.50 |
| 德语,德国 | , | . | 1.234.567,891 | 1.234,50 € |
| 法语,法国 | , | 窄不换行空格 | 1 234 567,891 | 1 234,50 € |
| 德语,瑞士 | . | ’ | 1’234’567.891 | EUR 1’234.50 |
| 英语,印度 | . | , | 12,34,567.891 | €1,234.50 |
| 葡萄牙语,巴西 | , | . | 1.234.567,891 | € 1.234,50 |
有三个细节容易让人出错。法语用窄不换行空格来分组千位,这个字符看起来像空格,实际上却不是。瑞士德语使用一种类似撇号的字符。而印度英语先对前三位数字分组,之后每两位一组,因此 1,234,567 写作 12,34,567。
用区域设置解析,并拒绝不匹配的内容
一种诱人的做法是去掉除数字和一个分隔符之外的所有字符,然后猜测哪个分隔符是小数点。这种方法在遇到 “1.234” 之前都还管用,但这个字符串在德国代表一千二百三十四,在美国代表一点二三四。无论多么巧妙的处理都无法仅凭字符串本身还原出正确答案。区域设置才能做到这一点。
import re
from babel.dates import parse_date
from babel.numbers import NumberFormatError, get_currency_precision, get_group_symbol, parse_decimal
# Keep digits, separators and the space variants sites use between thousands.
NUMBER_CHARS = re.compile("[^0-9.,'\u2019 \u00a0\u2009\u202f-]")
SPACES = re.compile("[ \u00a0\u2009\u202f]")
def parse_amount(text, locale):
"""Parse a displayed amount using the page's locale. Refuses input that does not fit it."""
number = NUMBER_CHARS.sub("", text).strip()
group = get_group_symbol(locale)
if SPACES.fullmatch(group):
# Sites mix ordinary, no-break and narrow no-break spaces; use the locale's own.
number = SPACES.sub(group, number)
try:
return parse_decimal(number, locale=locale, strict=True)
except NumberFormatError as e:
raise ValueError(f"{text!r} does not parse as a {locale} number: {e}") from None
def to_minor_units(amount, currency):
"""Store money as an integer in the currency's smallest unit (cents, yen, fils)."""
return int((amount * 10 ** get_currency_precision(currency)).to_integral_value())
def parse_display_date(text, locale):
"""Numeric dates are ambiguous without a locale: 03/04/2026 is March or April."""
return parse_date(text.strip(), locale=locale)
在真实世界的格式上进行了测试:
| 显示内容 | 区域设置 | 解析结果 |
|---|---|---|
| $1,234.56 | 英语,美国 | 1234.56 |
| 1.234,56 € | 德语,德国 | 1234.56 |
| 1 234,56 €(三种空格字符中的任意一种) | 法语,法国 | 1234.56 |
| CHF 1’234.50 | 德语,瑞士 | 1234.50 |
| ₹12,34,567.00 | 英语,印度 | 1234567.00 |
| R$ 1.234,56 | 葡萄牙语,巴西 | 1234.56 |
| 1.234,56 € | 英语,美国 | 拒绝 |
| 9,99 | 英语,美国 | 拒绝 |
最后两行才是重点。如果一个你原本认为是美国页面的页面上出现了德式格式的价格,这本身就是一个信号,说明有什么地方出了问题:提供了错误的区域变体、页面发生了变化,或者你的配置有误。一个可见的错误胜过一个悄悄相差一千倍却毫无察觉的价格。
空格处理这部分是在测试中才确定下来的。我们的第一个版本拒绝了用普通空格书写的法语价格,因为 CLDR 中法语的分组符是窄不换行空格。而真实网站会混用这三种空格字符,因此代码现在会在解析前将其中任意一种都映射为该区域设置自身的分组符。
货币:不要仅凭符号来判断
许多货币共享同一个符号。CLDR 将当地货币的十个单位在加拿大、墨西哥和澳大利亚都格式化为 “$10.00”,而在加拿大页面上美元则显示为 “US$10.00”。如果一个抓取程序把 ”$” 直接映射为 USD,那么在全球相当大比例的网站上都会出错。
应该从明确无歧义的来源获取货币信息:页面结构化数据中的 ISO 4217 代码、货币选择器的取值,或者你所收集的站点和市场的已知默认货币。将三字母代码与每个价格一同存储,对于只有符号而没有其他证据的价格,应视为信息不完整,而不是去猜测。
最小货币单位与精度
解析之后,应以货币最小单位的整数形式存储金额,因为对金钱进行浮点运算会累积舍入误差。小数位数并不总是两位:
| 货币 | 小数位数 | 显示为 1,234 时的存储值 |
|---|---|---|
| 欧元、美元 | 2 | 123400 |
| 日元、韩元、智利比索 | 0 | 1234 |
| 巴林第纳尔、科威特第纳尔 | 3 | 1234000 |
请将单位与数值一同记录。一个混用主单位和最小单位的处理流程,就像抓取数据中的模式漂移一文中提到的、一件售价 $100 的商品价格却以 10000 的形式到达的情况,会产生恰好相差一百倍、却看起来完全合理的错误。
日期:优先使用机器可读的数据来源
同一个数字型日期在不同区域设置下会解析出不同结果:
| 显示内容 | 区域设置 | 解析结果 |
|---|---|---|
| 03/04/2026 | 英语,美国 | 2026 年 3 月 4 日 |
| 03/04/2026 | 英语,英国 | 2026 年 4 月 3 日 |
| 03/04/2026 | 德语,德国 | 2026 年 4 月 3 日 |
只要有可能,就应该完全避免解析显示出来的日期。许多页面会在结构化数据中,或者在 <time> 元素的 datetime 属性中,发布机器可读的日期,通常采用没有歧义的 ISO 8601 格式;提取这些数据的方法在停止解析 HTML一文中有所介绍。当你不得不解析显示出来的日期时,请明确使用该页面的区域设置,将结果以带时区的 ISO 8601 格式存储,并对那些日和月都小于等于十二、而你尚未确认其区域设置的站点数据加以标记。
首先要弄清区域设置
上述所有规则都取决于你是否知道某个页面使用的是哪种区域设置,而这在一定程度上是一个采集层面的决策。同一个商品页面会根据访问者所在国家,以不同的语言、货币和数字格式呈现,这正是为什么通过德国出口节点采集的价格和通过美国出口节点采集的价格,在两者都经过归一化处理之前并不能直接比较。请记录每个数值的采集市场,并保持会话在语言和地区上的一致性,相关内容在匹配住宅代理的地理位置、时区与区域设置一文中有所介绍。
结论
数字、价格和日期看起来是通用的,实际上并非如此。相同的字符在不同的区域设置中代表不同的数值,符号可以对应多种货币,精度因货币而异,数字型日期天生就存在歧义。
用数值所写入时的区域设置来解析每一个数值,遇到不匹配的情况就拒绝而不是猜测,以 ISO 代码存储货币、以正确精度的最小单位存储金额,并在页面提供机器可读日期时优先使用它。这些规则中的每一条,都能把一个悄无声息、看似合理的错误变成一个可见的错误。
来源与参考资料
- Unicode Consortium,通用区域数据仓库(Common Locale Data Repository,CLDR)。
- Babel,Python 国际化库,版本 2.18.0,用于生成上述示例。
- ISO 4217 货币代码与 ISO 8601 日期时间格式。