🧵
JSONに正規表現書くんだけど`\\s`とか面倒すぎる
はじめに
Blazor Web Appで家計簿のようなものを作って、ストアや決済サービスからメールで届くレシート情報を自動的に取り込むようにしています。
その際、書式変更の対応やストアの追加がビルドなしでできるように、ストア毎のメール解析器のパラメータをJSONで保持するようにしました。
パラメータに正規表現を使っているのですが、C#の逐語/生文字列リテラルに慣れた身には、\\sなどと、都度\をエスケープするのが辛いです。
そこで、以下のような感じで、""に加えて@""も任意に使えるようにしました。("""は使えません。)
[
{
"Partner":"取引先","PaymentMethod":"決済",
"MailFrom":"送信元","MailContainsRegex":"メールに含まれるキーワードなど",
"ItemDetailRegex":@"内訳を抽出",
"ExpenseAmountRegex":@"(?<=支払総額:\s*)[\d,]+(?=\s*円)",
"Replacer":{"置換前1":"置換後1"}
}
]
前提
- .NET 8
実装
Deserialize前に、@"~"に対して簡単な置換を行います。
制限事項
簡易的な処理なので、エラーがあっても検出できない場合があります。
使用例
以下の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