2026-02-10 17:49:19 +08:00
|
|
|
using HtmlAgilityPack;
|
2026-01-28 11:19:23 +08:00
|
|
|
using Service.AI;
|
2026-01-19 13:39:59 +08:00
|
|
|
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
|
|
|
|
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
|
|
|
|
|
|
|
|
|
namespace Service.EmailServices.EmailParse;
|
|
|
|
|
|
|
|
|
|
[UsedImplicitly]
|
|
|
|
|
public partial class EmailParseFormCcsvc(
|
|
|
|
|
ILogger<EmailParseFormCcsvc> logger,
|
2026-02-10 17:49:19 +08:00
|
|
|
ISmartHandleService smartHandleService
|
|
|
|
|
) : EmailParseServicesBase(logger, smartHandleService)
|
2026-01-19 13:39:59 +08:00
|
|
|
{
|
|
|
|
|
[GeneratedRegex("<.*?>")]
|
|
|
|
|
private static partial Regex HtmlRegex();
|
2026-01-30 10:41:19 +08:00
|
|
|
|
2026-01-19 13:39:59 +08:00
|
|
|
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)
|
|
|
|
|
{
|
2026-01-28 17:00:58 +08:00
|
|
|
var card = "";
|
2026-01-19 13:39:59 +08:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 招商信用卡特殊,消费金额为正数,退款为负数
|
2026-01-30 10:41:19 +08:00
|
|
|
if (amount > 0)
|
2026-01-19 13:39:59 +08:00
|
|
|
{
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|