価格を複数の国から収集すると、同じ数値が十通りもの書き方で登場することになる。ドイツのサイトでは1.234,56 €と表示される。フランスのサイトでは1 234,56 €と表示され、この空白は目で見分けがつかないこともある。スイスのサイトではCHF 1’234.50と表示される。インドのサイトでは桁を12,34,567のようにグループ化する。そして03/04/2026のような日付は、アメリカ合衆国では3月4日を意味し、それ以外のほぼすべての地域では4月3日を意味する。
これらを誤ると、そのエラーはめったに一目ではわからない。価格が1000倍ずれる、日付が1か月ずれる、通貨が同じ記号を共有する別の通貨と混同される、といった具合だ。このガイドでは、出会うことになるフォーマット、それを決めるルール、そして値が適合しないときに推測を拒否する解析コードを扱う。
主なポイント
- すべての値は、その値が由来したページのロケールに従って解析すること。同じ文字列でも、ロケールが異なれば意味する数値も異なる。
- 区切り文字を取り除いて何とかなることを期待してはいけない。どの記号が小数点区切りかはロケールから決定し、適合しない値は拒否すること。
- 通貨記号は曖昧なものとして扱うこと。「$10.00」は、カナダのページではカナダドル、メキシコのページではペソ、オーストラリアのページではオーストラリアドルを意味する。ISO 4217コードで保存すること。
- 金額は正しい精度で補助単位として保存すること。円には小数点以下の桁がなく、バーレーンディナールとクウェートディナールには3桁ある。
- 数字だけの日付にはロケールが必要であり、できれば機械可読なソースの方が望ましい。ページが提供している場合は、構造化データ由来のISO 8601形式を優先すること。
ルールの出典
ロケールの慣習は俗説ではない。それらはUnicode Common Locale Data Repository、略してCLDRとして公開されており、オペレーティングシステム、ブラウザ、そしてほとんどの国際化ライブラリがこれを利用している。以下の例は、Pythonのライブラリであるbabelを通じて、数値1234567.891と1,234.50ユーロという金額をフォーマットした、CLDRのデータそのものである。
| ロケール | 小数点 | 桁区切り | 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 |
3つの点でつまずきやすい。フランス語は千の位を狭い不改行スペースで区切るが、これは見た目はスペースに見えて実際はスペースではない文字だ。スイスドイツ語はアポストロフィのような文字を使う。そしてインド英語は最初の3桁をまとめ、その後は2桁ずつ区切るため、1,234,567は12,34,567と書かれる。
ロケールに従って解析し、適合しない値は拒否する
誘惑に駆られるやり方は、数字と区切り文字1つを残して他をすべて取り除き、どちらの区切り文字が小数点なのかを推測することだ。これは「1.234」という文字列に出会うまではうまくいく。この文字列は、ドイツでは千二百三十四を意味し、アメリカ合衆国では1点234を意味する。どれだけ工夫を凝らしても、文字列だけから正しい答えを導き出すことはできない。ロケールならそれができる。
import re
from babel.dates import parse_date
from babel.numbers import NumberFormatError, get_currency_precision, get_group_symbol, parse_decimal
# 数字、区切り文字、そしてサイトが千の位の間に使う各種スペースを残す。
NUMBER_CHARS = re.compile("[^0-9.,'\u2019 \u00a0\u2009\u202f-]")
SPACES = re.compile("[ \u00a0\u2009\u202f]")
def parse_amount(text, locale):
"""ページのロケールを使って表示された金額を解析する。ロケールに適合しない入力は拒否する。"""
number = NUMBER_CHARS.sub("", text).strip()
group = get_group_symbol(locale)
if SPACES.fullmatch(group):
# サイトは通常のスペース、ノーブレークスペース、狭いノーブレークスペースを混在させるため、ロケール自身の区切り文字を使う。
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):
"""金額を通貨の最小単位(セント、円、フィルスなど)の整数として保存する。"""
return int((amount * 10 ** get_currency_precision(currency)).to_integral_value())
def parse_display_date(text, locale):
"""数字だけの日付はロケールなしでは曖昧である: 03/04/2026は3月か4月かわからない。"""
return parse_date(text.strip(), locale=locale)
実際の書式でテストした結果:
| 表示 | ロケール | 解析結果 |
|---|---|---|
| $1,234.56 | 英語、アメリカ合衆国 | 1234.56 |
| 1.234,56 € | ドイツ語、ドイツ | 1234.56 |
| 1 234,56 € (3種類のスペース文字のいずれか) | フランス語、フランス | 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 | 英語、アメリカ合衆国 | 拒否 |
最後の2行が重要だ。アメリカのページだと思っていたページにドイツ式でフォーマットされた価格が現れたら、それは何かがおかしいという合図だ。誤ったロケールのバリエーションが配信された、ページが変わった、あるいは自分の設定が間違っている、といった可能性がある。目に見えるエラーの方が、価格がひっそりと1000倍ずれているよりずっとよい。
スペースの扱いは、テストを通じてその価値が証明された。最初のバージョンでは、通常のスペースで書かれたフランスの価格を拒否してしまっていた。CLDRのフランス語の区切り文字は狭い不改行スペースだからだ。実際のサイトは3種類のスペース文字をすべて混在させるため、現在のコードは解析前にそれらすべてをロケール自身の区切り文字に変換する。
通貨:記号だけを信用しない
多くの通貨が記号を共有している。CLDRは、現地通貨の10単位を、カナダ、メキシコ、オーストラリアのいずれでも同じように「$10.00」とフォーマットし、カナダのページでは米ドルを「US$10.00」と表示する。「$」をUSDにマッピングするスクレイパーは、世界の多くのサイトで誤った結果になる。
通貨は、あいまいさのない何かから取得すること。ページの構造化データ内のISO 4217コード、通貨セレクターの値、あるいは収集元のサイトと市場の既知のデフォルト通貨などだ。すべての価格に3文字のコードを保存し、他に証拠のない記号だけの価格は、推測するのではなく不完全なものとして扱うこと。
補助単位と精度
解析後は、金額を通貨の最小単位の整数として保存すること。金額に対する浮動小数点演算は丸め誤差を蓄積するからだ。小数点以下の桁数は必ずしも2桁とは限らない。
| 通貨 | 小数点以下の桁数 | 表示上の1,234は次のようになる |
|---|---|---|
| ユーロ、米ドル | 2 | 123400 |
| 日本円、韓国ウォン、チリペソ | 0 | 1234 |
| バーレーンディナール、クウェートディナール | 3 | 1234000 |
値と一緒に単位を記録すること。スクレイピングデータにおけるスキーマドリフトで説明されているように、$100の製品の価格が10000として届くなど、主単位と補助単位が混在するパイプラインは、まったくもっともらしく見えるちょうど100倍のずれを生み出す。
日付:機械可読なソースを優先する
同じ数字だけの日付でも、ロケールによって解析結果は異なる。
| 表示 | ロケール | 解析結果 |
|---|---|---|
| 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形式で保存し、ロケールを確認していないサイトについては、日と月の両方が12以下である値にフラグを立てること。
そもそもロケールを正しく把握する
上記のルールはすべて、ページがどのロケールを使っていたかを知っていることに依存しており、それは一部、収集時の判断でもある。同じ商品ページでも、訪問者の国によって言語、通貨、数値フォーマットが異なって描画されることがある。だからこそ、ドイツの出口経由で収集した価格とアメリカ合衆国の出口経由で収集した価格は、両方を正規化するまでは直接比較できない。各値がどの市場から収集されたかを記録し、レジデンシャルプロキシのジオ、タイムゾーン、ロケールを一致させるで扱っているように、セッション内で言語と地域を一貫させること。
結論
数値、価格、日付は普遍的に見えるが、そうではない。同じ文字が異なるロケールでは異なる値を意味し、記号は複数の通貨にマッピングされ、精度は通貨によって異なり、数字だけの日付はもともと曖昧なものだ。
すべての値は、それが書かれたロケールに従って解析し、推測するのではなく適合しない値は拒否し、通貨はISOコードとして、金額は正しい精度の補助単位として保存し、ページが提供している場合は機械可読な日付を優先すること。これらのルールのそれぞれが、もっともらしく見える静かなエラーを、目に見えるエラーへと変える。
出典と参考資料
- Unicode Consortium、Common Locale Data Repository (CLDR)。
- Babel、Python国際化ライブラリ、バージョン2.18.0、上記の例で使用。
- ISO 4217通貨コードおよびISO 8601日付時刻フォーマット。