C#で国番号なし電話番号から国判定する方法|libphonenumber-csharpで大量件数対応

国番号(+)も既定地域も分からない電話番号から「どの国の番号か」を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弱〜中多言語ユーザーには弱い。単独での決め打ちは危険

どうしても国番号なしを推定したい:候補列挙(複数地域トライ)に落とす

「ログに残った番号を後から整理したい」「過去データの国分布をざっくり掴みたい」など、どうしても番号だけで国を推定したい場面はあります。その場合は、“当てに行く”のではなく“候補を列挙する”設計に切り替えるのが安全です。

やることはシンプルで、対象とする地域リスト(例:主要顧客がいる国だけ)を作り、順番にパースして妥当性チェックを行います。重要なのは「全世界総当たり」を避けることです。世界中の地域コードを片っ端から試すと、精度よりも先に処理コストとメンテナンスが膨らみます。

候補列挙の基本アルゴリズム

  1. 入力を正規化(全角→半角、記号除去、先頭の+だけ保持)
  2. 先頭が+なら国番号付きとして扱い、地域コードを取得
  3. +がない場合は「既定地域の推定」を試す(取れるならここで確定)
  4. それでも不明なら、対象地域リストで順にパース → IsPossibleNumber → IsValidNumberForRegion
  5. 候補が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_input090-1234-5678受け取ったままの文字列(必要なら別テーブルに隔離)
normalized09012345678判定に使った正規化文字列
statusSINGLE_CANDIDATEE164 / 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段階チェック、キャッシュで無駄を削る

「番号だけから国を当てたい」という要望を満たすためには、実装テクニック以上に、不確実性を前提にしたデータ設計が重要です。断定できないものは断定しない。これが、結果的に最もコスト効率が良く、運用事故も少ないアプローチになります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次