国番号(+)も既定地域も分からない電話番号から「どの国の番号か」をC#で自動判定したい――そんな要望は多いですが、番号だけで国を一意に決めるのは難題です。本記事ではOSSのlibphonenumber-csharpを使い、大量件数でも破綻しにくい設計と実装の落とし所を解説します。
なぜ「国番号なしの数字だけ」から国判定が難しいのか
電話番号の国判定は、見た目ほど単純ではありません。国際電話の世界では E.164 という国際標準の形式が前提で、通常は +(国番号) から始まります。ところが現実の入力は、次のような“国内形式っぽい数字列”が大量に混ざります。
- 国番号がない(例:7026881085、09012345678)
- ハイフン、空白、括弧などの装飾が混ざる(例:090-1234-5678、(702) 688-1085)
- 先頭の0(トランクプレフィックス)の扱いが国によって違う
- 同じ桁数・似た先頭パターンの国が複数存在する
- 複数国で同一の番号体系を共有する(例:北米の+1圏)
このため、「数字だけ」から国を一意に断定するのは原理的に難しく、実装で無理に決め打ちすると誤判定が増えます。特に 7026881085 のような形式は、ある国(地域)を仮定すれば“それっぽく”見える一方で、別の国の国内番号としても成立し得るため、候補が複数になりやすいのが普通です。
結論:設計として「国情報を持たせる」か「候補列挙・不明扱い」に落とす
大量件数(例:1日100万件)を低コストで処理するなら、最初にゴールを分けるのが最も重要です。国番号なしの電話番号を“国判定だけで100%当てる”ことを目標にすると破綻します。現実的には次のいずれか(または併用)が落とし所です。
| 方針 | 精度 | コスト | 実装難易度 | 向いているケース |
|---|---|---|---|---|
| 入力時に国(地域)を必須/推奨にする | 非常に高い | 低い | 低い | ユーザー登録/申込フォーム、B2C/会員制サービス |
| 既定地域(default region)を決めて解析する | 高い(対象国が偏っていれば強い) | 低い | 中 | 主な利用国がある、請求先国などが取れる |
| 複数地域で順にパースして候補列挙する | 中(断定しない前提) | 低〜中 | 中〜高 | 国情報が取れないログ/名寄せ、データクレンジング |
| 外部の有料検証APIに寄せる | 高いことが多い | 中〜高 | 低〜中 | 誤判定が致命的、コストを許容できる |
無料・ローカルで完結させるなら libphonenumber-csharp が第一候補
「有料APIは避けたい」「1日100万件をコスト効率よく回したい」という条件なら、定番は libphonenumber-csharp(GoogleのlibphonenumberのC#移植)です。ローカル実行できるため、呼び出し回数課金やレート制限に悩まされにくく、バッチ処理・ストリーム処理にも向きます。
ただし重要な注意点があります。libphonenumber系ライブラリが本領を発揮するのは、基本的に 国際形式(+国番号付き)、または 既定地域が分かるケースです。国番号なしで地域情報もないときは、ライブラリでも断定ではなく推定になります。
インストール(NuGet)
代表的には NuGet から導入し、名前空間 PhoneNumbers を利用します(パッケージ名は環境により異なる場合があります)。
dotnet add package libphonenumber-csharp
実装パターン:+国番号付きなら「番号だけ」から国(地域)を引ける
入力が +14155552671 のように国番号付きであれば、default region を知らなくても解析できます。先頭の + を見て国番号を抽出し、対応する地域コード(US/JPなど)を取得する流れです。
using PhoneNumbers;
public static string? GetRegionFromE164(string raw)
{
var util = PhoneNumberUtil.GetInstance();
// 先頭が+なら region は "ZZ"(不明)でOK。+があれば region 引数は実質無視される挙動が一般的です。
var number = util.Parse(raw, "ZZ");
// 例: +81 → "JP" / +1 → "US","CA"... のように複数候補になることもあります
var region = util.GetRegionCodeForNumber(number);
return string.IsNullOrWhiteSpace(region) ? null : region;
}
ここで注意したいのが 「国番号=国」ではない点です。たとえば +1 の国番号は北米の複数地域で共有されるため、GetRegionCodeForNumber は番号の詳細(市外局番など)まで見て地域を推定します。それでも運用上は十分な精度になることが多い一方、“国”というより“地域コード”の推定であることは理解しておくべきです。
実装パターン:国番号なしなら default region を与えて解析する
国番号がない番号を高精度に扱うには、結局のところ 既定地域(default region) が必要です。これはユーザー入力の国選択、住所(請求先/配送先)、会員の国設定などから取得するのが王道です。
using PhoneNumbers;
public static bool TryParseWithRegion(string rawNationalNumber, string region, out string e164)
{
e164 = "";
var util = PhoneNumberUtil.GetInstance();
try
{
var number = util.Parse(rawNationalNumber, region);
// まず「あり得る桁数か」→「実在する番号として妥当か」の順に絞ると効率が良い
if (!util.IsPossibleNumber(number)) return false;
if (!util.IsValidNumberForRegion(number, region)) return false;
e164 = util.Format(number, PhoneNumberFormat.E164);
return true;
}
catch (NumberParseException)
{
return false;
}
}
ポイントは、default region を“どこで決めるか”が設計の肝だということです。番号文字列だけを渡して国を当てたい、という要求はよくありますが、精度を上げる最短ルートは「番号以外の文脈」を持たせることです。
default region を推定するために使えるシグナル
| シグナル | 例 | 強さ | 実務上の注意 |
|---|---|---|---|
| ユーザーに国を選ばせる | 国選択ドロップダウン、電話番号入力欄の国フラグ | 最強 | UIの負担は増えるが、後工程のコストと誤判定が激減する |
| 請求先/配送先の国 | 決済情報、住所 | 強い | 取得できる場面が限られる。データ同期の整合性に注意 |
| アカウントの国設定 | プロフィール、利用国 | 強い | 引っ越しや海外利用でズレることがある |
| IPから推定した国 | アクセス元の国 | 中 | VPN/モバイル回線でブレる。プライバシー配慮が必要 |
| UI言語/タイムゾーン | ja-JP、Asia/Tokyo | 弱〜中 | 多言語ユーザーには弱い。単独での決め打ちは危険 |
どうしても国番号なしを推定したい:候補列挙(複数地域トライ)に落とす
「ログに残った番号を後から整理したい」「過去データの国分布をざっくり掴みたい」など、どうしても番号だけで国を推定したい場面はあります。その場合は、“当てに行く”のではなく“候補を列挙する”設計に切り替えるのが安全です。
やることはシンプルで、対象とする地域リスト(例:主要顧客がいる国だけ)を作り、順番にパースして妥当性チェックを行います。重要なのは「全世界総当たり」を避けることです。世界中の地域コードを片っ端から試すと、精度よりも先に処理コストとメンテナンスが膨らみます。
候補列挙の基本アルゴリズム
- 入力を正規化(全角→半角、記号除去、先頭の+だけ保持)
- 先頭が+なら国番号付きとして扱い、地域コードを取得
- +がない場合は「既定地域の推定」を試す(取れるならここで確定)
- それでも不明なら、対象地域リストで順にパース →
IsPossibleNumber→IsValidNumberForRegion - 候補が1つなら「推定確度高」、複数なら「複数候補」、0なら「不明/無効」
候補列挙のC#サンプル
using System.Text;
using PhoneNumbers;
public sealed class PhoneCountryGuesser
{
private readonly PhoneNumberUtil _util = PhoneNumberUtil.GetInstance();
private readonly string[] _regions; // 例: "JP","US","GB"...
public PhoneCountryGuesser(IEnumerable<string> regions)
{
_regions = regions.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
public GuessResult Guess(string raw)
{
var normalized = Normalize(raw);
// + が付いていれば国際形式として処理
if (normalized.StartsWith("+", StringComparison.Ordinal))
{
try
{
var n = _util.Parse(normalized, "ZZ");
var region = _util.GetRegionCodeForNumber(n);
// +1 のように地域が複数あり得る場合もあるので、補助的に「同国番号の地域一覧」も返せる設計にしておくと便利
return GuessResult.FromE164(normalized, region);
}
catch (NumberParseException ex)
{
return GuessResult.Invalid(normalized, ex.Message);
}
}
// 国番号なし:候補列挙
var candidates = new List<string>();
foreach (var region in _regions)
{
try
{
var n = _util.Parse(normalized, region);
// 可能性が低いものを早めに落とす
if (!_util.IsPossibleNumber(n)) continue;
if (!_util.IsValidNumberForRegion(n, region)) continue;
candidates.Add(region);
}
catch (NumberParseException)
{
// region によって解釈できないのは普通なので握りつぶす
}
}
if (candidates.Count == 1)
return GuessResult.Single(normalized, candidates[0]);
if (candidates.Count > 1)
return GuessResult.Multiple(normalized, candidates);
return GuessResult.Unknown(normalized);
}
private static string Normalize(string input)
{
if (string.IsNullOrWhiteSpace(input)) return "";
// 先頭の + だけは残し、その他の記号は除去する(簡易版)
var sb = new StringBuilder(input.Length);
foreach (var ch in input.Trim())
{
if (ch == '+' && sb.Length == 0)
{
sb.Append(ch);
continue;
}
if (char.IsDigit(ch))
{
sb.Append(ch);
continue;
}
// 全角数字の考慮(必要なら追加)
// 例: '0'〜'9' を '0'〜'9' に変換して追加するなど
}
return sb.ToString();
}
}
public record GuessResult(string Normalized, string Status, IReadOnlyList Regions, string? Note)
{
public static GuessResult FromE164(string normalized, string? region)
=> string.IsNullOrWhiteSpace(region)
? new GuessResult(normalized, "E164_BUT_REGION_UNKNOWN", Array.Empty(), null)
: new GuessResult(normalized, "E164", new[] { region }, null);
public static GuessResult Single(string normalized, string region)
=> new GuessResult(normalized, "SINGLE_CANDIDATE", new[] { region }, null);
public static GuessResult Multiple(string normalized, IReadOnlyList<string> regions)
=> new GuessResult(normalized, "MULTIPLE_CANDIDATES", regions, null);
public static GuessResult Unknown(string normalized)
=> new GuessResult(normalized, "UNKNOWN", Array.Empty<string>(), null);
public static GuessResult Invalid(string normalized, string note)
=> new GuessResult(normalized, "INVALID", Array.Empty<string>(), note);
}
このサンプルは「断定しない」を前提にしています。候補が複数なら複数のまま返す、そして上位ロジックで「国が確定しない番号は不明として扱う」ことが、誤判定による事故を避ける最短ルートです。
候補が複数になったときの“実務で使える”絞り込み方
候補列挙だけだと、国によっては候補が増えすぎることがあります。そこで、番号以外の条件も含めてスコアリングし、最終判断はアプリ側のポリシーで行うのが現実的です。
| 絞り込みルール | 例 | 効果 | 注意点 |
|---|---|---|---|
| 事業対象国だけに限定 | JP/US/GBなど上位Nか国 | 処理量と誤判定を同時に減らせる | 対象外の国は必ず「不明」になる |
| 桁数でフィルタ | 10桁/11桁/可変長 | 早期に候補を落とせる | 国によって可変長があるため過信しない |
| 先頭0の扱いを重視 | 0始まりが多い国を優先 | 国内形式の特徴を活かせる | 0を国際形式に含めない国もある |
| ユーザー文脈で加点 | UI言語、請求先国、IP推定国 | “実際の利用状況”に寄せられる | プライバシー/同意、VPNなどの例外 |
大量件数(1日100万件)での最適化ポイント
1日100万件は、秒間に直すと平均で十数件程度です。極端なピークがない限り、libphonenumber-csharp をローカルで回すこと自体は現実的です。ただし「全世界を総当たり」や「毎回同じ番号を判定」などの無駄を積むと、CPUもメモリも簡単に増えます。以下の工夫で、安定して低コスト運用できます。
| 工夫 | 狙い | 実装のコツ |
|---|---|---|
| 対象地域リストを絞る | 総当たり回避 | ビジネス上あり得る国だけ固定し、定期的に見直す |
| 正規化を先に行う | 例外・分岐を減らす | 記号除去、全角→半角、先頭+だけ保持を統一 |
| 2段階チェック | 重い処理の前に落とす | IsPossibleNumber → IsValidNumberForRegion の順 |
| キャッシュ | 同一番号の再判定削減 | 正規化後の文字列をキーに、結果(候補/不明)を短期キャッシュ |
| バッチ処理での並列化 | ピーク吸収 | 入力を分割し、スレッド数はCPUに合わせて制御(やり過ぎない) |
キャッシュ設計のポイント
- キーは「正規化後の番号」にする(ハイフンの有無で別物にしない)
- 「不明」もキャッシュする(無効番号を毎回判定しない)
- TTL(有効期限)は短めでOK(番号のルールは急に変わりにくいが、メモリは有限)
- 個人情報として扱う必要があるため、ログに残す場合はマスキング/ハッシュ化を検討
“国判定”の戻り値をどう設計するか(DB/APIの落とし穴)
国番号なしの推定は曖昧になり得るため、戻り値の設計が重要です。最初から「国コード1つだけ」を返す設計にすると、複数候補時の扱いが破綻します。おすすめは、次のようにステータスと候補一覧を持たせる形です。
| 項目 | 例 | 意味 |
|---|---|---|
| raw_input | 090-1234-5678 | 受け取ったままの文字列(必要なら別テーブルに隔離) |
| normalized | 09012345678 | 判定に使った正規化文字列 |
| status | SINGLE_CANDIDATE | E164 / SINGLE / MULTIPLE / UNKNOWN / INVALID など |
| regions | [“JP”] / [“US”,”CA”] | 推定された地域コードの配列 |
| e164 | +819012345678 | 確定できた場合のみ保存(候補が複数なら空) |
この形にしておくと、「国が確定したものだけ後続処理へ」「複数候補のものはオペレーションで確認」「不明は別キューへ」といった運用が組みやすくなります。
よくある落とし穴と対処
- 先頭の00を国際形式と誤解する:国によっては国際発信プレフィックスが00のため、
00始まりを+に置換したくなります。しかし、入力データの性質によっては国内番号の一部の可能性もあるため、置換するなら「00の後に国番号が続くときだけ」などルールを限定しましょう。 - 内線(extension)混入:
03-xxxx-xxxx ext.123のような文字列が来る場合、正規化で拡張番号が潰れてしまいます。必要なら「内線を別項目に分離」するパーサを用意します。 - 短縮番号・緊急番号:国判定の対象外として扱い、別ルールに逃がす(例:桁数が短いものは一律UNKNOWN)と事故が減ります。
- “電話番号っぽいID”が混ざる:会員IDや注文番号が電話番号欄に入っているケースがあるため、
IsPossibleNumberで早めに落とすのが有効です。
Microsoft公式の「番号だけで国を断定する専用API」は期待しない方がいい
結論から言うと、.NETやMicrosoftの標準機能として、電話番号文字列だけを渡して国を確定して返す“汎用の公開API”が用意されているケースは多くありません。現場では、OSSライブラリ(libphonenumber系)+アプリ側の入力設計(国選択・既定地域・候補扱い)で組み立てるのが一般的です。
「Microsoft製だから安心」という理由だけで解決したい気持ちは分かりますが、この問題の本質は“APIの有無”よりも、国番号なしデータの曖昧さにあります。APIを変えても曖昧さ自体は消えないため、設計で吸収するのが近道です。
まとめ:低コストで堅く運用するための最短ルート
- 国番号(+)付きなら libphonenumber-csharp で高精度に地域を取得できる
- 国番号なしなら default region をどこかで決める(国選択・住所・アカウント設定など)
- それでも不明なら 対象国を絞った候補列挙に落とし、複数候補は“複数のまま”扱う
- 大量件数では 対象国の固定、2段階チェック、キャッシュで無駄を削る
「番号だけから国を当てたい」という要望を満たすためには、実装テクニック以上に、不確実性を前提にしたデータ設計が重要です。断定できないものは断定しない。これが、結果的に最もコスト効率が良く、運用事故も少ないアプローチになります。

コメント