using HtmlAgilityPack; using Service.AI; // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract // ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract namespace Service.EmailServices.EmailParse; [UsedImplicitly] public partial class EmailParseFormCcsvc( ILogger logger, ISmartHandleService smartHandleService ) : EmailParseServicesBase(logger, smartHandleService) { [GeneratedRegex("<.*?>")] private static partial Regex HtmlRegex(); public override bool CanParse(string from, string subject, string body) { if (!from.Contains("ccsvc@message.cmbchina.com")) { return false; } if (!subject.Contains("每日信用管家")) { return false; } // 必须包含HTML标签 return HtmlRegex().IsMatch(body); } public override async Task<( string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt )[]> ParseEmailContentAsync(string emailContent) { var doc = new HtmlDocument(); doc.LoadHtml(emailContent); var result = new List<(string, string, decimal, decimal, TransactionType, DateTime?)>(); // 1. Get Date var dateNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '您的消费明细如下')]"); if (dateNode == null) { logger.LogWarning("Date node not found"); return []; } var dateText = dateNode.InnerText.Trim(); // "2025/12/21 您的消费明细如下:" var dateMatch = Regex.Match(dateText, @"\d{4}/\d{1,2}/\d{1,2}"); if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date)) { logger.LogWarning("Failed to parse date from: {DateText}", dateText); return []; } // 2. Get Balance (Available Limit) decimal balance = 0; // Find "可用额度" label var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]"); if (limitLabelNode != null) { // Go up to TR var tr = limitLabelNode.Ancestors("tr").FirstOrDefault(); if (tr != null) { var prevTr = tr.PreviousSibling; while (prevTr != null && prevTr.Name != "tr") prevTr = prevTr.PreviousSibling; if (prevTr != null) { var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]"); if (balanceNode != null) { var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim(); decimal.TryParse(balanceStr, out balance); } } } } // 3. Get Transactions var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']"); if (transactionNodes != null) { foreach (var node in transactionNodes) { var card = ""; try { // Time var timeNode = node.SelectSingleNode(".//span[@id='fixBand5']//font"); var timeText = timeNode?.InnerText.Trim(); // "10:13:43" DateTime? occurredAt = date; if (!string.IsNullOrEmpty(timeText) && DateTime.TryParse($"{date:yyyy-MM-dd} {timeText}", out var dt)) { occurredAt = dt; } // Info Block var infoNode = node.SelectSingleNode(".//span[@id='fixBand12']"); if (infoNode == null) continue; // Amount var amountNode = infoNode.SelectSingleNode(".//font[contains(text(), 'CNY')]"); var amountText = amountNode?.InnerText.Replace("CNY", "").Replace(" ", "").Trim(); if (!decimal.TryParse(amountText, out var amount)) { continue; } // Description var descNode = infoNode.SelectSingleNode(".//tr[2]//font"); var descText = descNode?.InnerText ?? ""; // Replace   and non-breaking space (\u00A0) with normal space descText = descText.Replace(" ", " "); descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim(); // Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡" var parts = descText.Split([' '], StringSplitOptions.RemoveEmptyEntries); var reason = descText; TransactionType type; if (parts.Length > 0 && parts[0].StartsWith("尾号")) { card = parts[0].Replace("尾号", ""); } if (parts.Length > 2) { reason = string.Join(" ", parts.Skip(2)); } // 招商信用卡特殊,消费金额为正数,退款为负数 if (amount > 0) { type = TransactionType.Expense; } else { type = TransactionType.Income; amount = Math.Abs(amount); } result.Add((card, reason, amount, balance, type, occurredAt)); } catch (Exception ex) { logger.LogError(ex, "Error parsing transaction node"); } } } return await Task.FromResult(result.ToArray()); } }