🧵

JSONに正規表現書くんだけど`\\s`とか面倒すぎる

に公開

はじめに

Blazor Web Appで家計簿のようなものを作って、ストアや決済サービスからメールで届くレシート情報を自動的に取り込むようにしています。
その際、書式変更の対応やストアの追加がビルドなしでできるように、ストア毎のメール解析器のパラメータをJSONで保持するようにしました。
パラメータに正規表現を使っているのですが、C#​の逐語/生文字列リテラルに慣れた身には、\\sなどと、都度\をエスケープするのが辛いです。
そこで、以下のような感じで、""に加えて@""も任意に使えるようにしました。("""は使えません。)

[
  {
    "Partner":"取引先","PaymentMethod":"決済",
    "MailFrom":"送信元","MailContainsRegex":"メールに含まれるキーワードなど",
    "ItemDetailRegex":@"内訳を抽出",
    "ExpenseAmountRegex":@"(?<=支払総額:\s*)[\d,]+(?=\s*円)",
    "Replacer":{"置換前1":"置換後1"}
  }
]

前提

  • .NET 8

実装

Deserialize前に、@"~"に対して簡単な置換を行います。

https://github.com/tetr4lab/Tetr4labNugetPackages/blob/e407b7189999f6e7b731bacb3094a58586193b9f/Tetr4lab/Scripts/JsonSerializerEx.cs#L12-L16

制限事項

簡易的な処理なので、エラーがあっても検出できない場合があります。

使用例

以下のFromVerbatimJsonは、生成されたインスタンスと(あれば)エラー文言を返します。

MailParser.cs
public class MailParser {
    public string Partner { get; set; } = "";
    public string PaymentMethod { get; set; } = "";
    public string MailFrom { get; set; } = "";
    public string MailContainsRegex { get; set; } = "";
    public string ItemDetailRegex { get; set; } = "";
    public string ExpenseAmountRegex { get; set; } = "";
    public Dictionary<string, string>? Replacer { get; set; } = null;

    public static (List<MailParser>? list, string error) FromVerbatimJson (string atJson) {
        var error = string.Empty;
        try {
            var list = JsonSerializerEx.Deserialize<List<MailParser>> (atJson);
            if (list is not null) {
                return (list, string.Empty);
            }
            error = "unknown error";
        }
        catch (Exception ex) {
            error = ex.Message;
        }
        return (null, error);
    }
}

以下は、(MudBlazorの)検証付きの編集用フィールドで、エラーがあれば先の文言が表示されます。

SettingDialog.razor
    <MudTextField Validation="@(new Func<string, string> (ParsersJsonValidate))" HelperTextOnFocus
        Label="@($"{Setting.Label [nameof (Setting.ParsersJson)]} ({Item.Parsers.Count}件)")"
        @bind-Value="Item.ParsersJson" Lines="5" AutoGrow="@_isAutoGrow" />
SettingDialog.razor.cs
    protected string ParsersJsonValidate (string json)
        => string.IsNullOrEmpty (json) ? string.Empty : MailParser.FromVerbatimJson (json).error;
蛇足 (モデル)

格納先では、初期値の構文ハイライトを設定しています。

Setting.cs
    [Column ("parsers"), StringSyntax (StringSyntaxAttribute.Json)] public string _parsersJson { get; set; } = """
        [
          {
            "Partner":"取引先","PaymentMethod":"決済手段",
            "MailFrom":"送信元[,送信元...](いずれかを含む)","MailContainsRegex":"含まれる語句など(正規表現)",
            "ItemDetailRegex":@"詳細内訳(正規表現)",
            "ExpenseAmountRegex":@"出金額(正規表現)",
            "Replacer":{"置換前1":"置換後1","置換前2":"置換後2"}
          },  {
            "Partner":"","PaymentMethod":"",
            "MailFrom":"","MailContainsRegex":"",
            "ItemDetailRegex":@"",
            "ExpenseAmountRegex":@"",
            "Replacer":{"":""}
          }
        ]
        """;

    public string ParsersJson {
        get => _parsersJson;
        set {
            _parsersJson = value;
            __parsers = null;
        }
    }

以下のコードは、MimeKitで受け取ったメール(MimeMessage)を分析し、MailParserの設定に基づいて取引データを抽出するロジックを実装しています。

Transaction.cs
    /// <summary>メッセージの解析</summary>
    /// <param name="message">メッセージ</param>
    protected void ParseMessage (MimeMessage message) {
        if (DataSet is CashbookDataSet dataSet) {
            var text = $"{message.Subject}\n{message.TextBody}";
            var from = message.From.ToString ();
            foreach (var parser in dataSet.Setting.Parsers) {
                try {
                    // MailFromは必須、`,`区切りでOR扱い
                    if (!string.IsNullOrEmpty (parser.MailFrom) && parser.MailFrom.Split (',').Any (x => from.Contains (x.Trim ()))
                        // MailContainsRegexは未記載/空でも可、大小文字を区別せず
                        && (string.IsNullOrEmpty (parser.MailContainsRegex) || new Regex (parser.MailContainsRegex, RegexOptions.Singleline | RegexOptions.IgnoreCase, RegexTimeOut).IsMatch (text))) {
                        // ExpenseAmountRegexは、一致した全体を数値化、`,`を許容
                        var subTotalMatch = string.IsNullOrEmpty (parser.ExpenseAmountRegex) ? null : new Regex (parser.ExpenseAmountRegex, RegexOptions.Singleline, RegexTimeOut).Match (text);
                        if (subTotalMatch?.Success == true && decimal.TryParse (subTotalMatch.Value, out var expenseAmount)) {
                            ExpenseAmount = expenseAmount;
                        }
                        // ExpenseAmountRegexは、捕捉部分を連結、任意回数適合し各1行とする
                        var summaryMatches = string.IsNullOrEmpty (parser.ItemDetailRegex) ? null : new Regex (parser.ItemDetailRegex, RegexOptions.Singleline, RegexTimeOut).Matches (text);
                        if (summaryMatches?.Count > 0) {
                            var items = new List<string> ();
                            foreach (Match summaryMatch in summaryMatches) {
                                if (summaryMatch.Groups.Count > 0) {
                                    items.Add (string.Join ("", summaryMatch.Groups.Values.Skip (1)));
                                }
                            }
                            var detail = string.Join ('\n', items);
                            // Replacerによる文字列置換
                            if (parser.Replacer is not null) {
                                foreach (var key in parser.Replacer.Keys) {
                                    detail = detail.Replace (key, parser.Replacer [key]);
                                }
                            }
                            ItemDetail = detail;
                        }
                        // 出金か概要があれば有効
                        if (ExpenseAmount is not null || !string.IsNullOrEmpty (ItemDetail)) {
                            // 指定があれば支払先を設定
                            if (!string.IsNullOrEmpty (parser.Partner)) {
                                Partner = parser.Partner;
                            }
                            // 指定があれば決済種別を設定
                            if (!string.IsNullOrEmpty (parser.PaymentMethod)) {
                                PaymentMethod = parser.PaymentMethod;
                            }
                            // 受信日を取引日とする
                            TransactionDate = TimeZoneInfo.ConvertTime (message.Date, TimeZoneInfo.Local).DateTime;
                            return;
                        }
                    }
                }
                catch (Exception ex) {
                    ItemDetail = $"Exception: {ex.Message}\n{ex.StackTrace}";
                    return;
                }
            }
        }
        // 汎用のメール解析
        TransactionDate = TimeZoneInfo.ConvertTime (message.Date, TimeZoneInfo.Local).DateTime;
        Partner = null;
        PaymentMethod = string.Empty;
        ItemDetail = WholeTextBody;
        ExpenseAmount = null;
    }

この使用例のプロジェクトは(未だ)公開されていません。

おわりに

最後までお読みいただきありがとうございました。
何かお気づきの際は、是非コメントなどでご指摘ください。
あるいは、「それでも解らない」、「自分はこう捉えている」などといった、ご意見、ご感想も歓迎いたします。

Discussion