使用 C# 和 Selenium 将交易导入 Mint.com
使用 Selenium 自动化 mint.com 的交易页面。
引言
本文介绍了一个使用 Selenium 自动化将交易导入 mint.com 的 C# 命令行实用程序。
在使用 iPhone 应用玩耍时,我不小心删除了我的信用卡账户,因此丢失了大约 10 年的交易记录。在网上搜索,我发现了一篇文章 http://aaronfrancis.com/2013/importing-transactions-into-mint/,以及 Intuit 的大量功能请求。我想创建一个尽可能健壮和万无一失的解决方案。
我的第一个方法是使用浏览器的开发者工具控制台中的 Javascript 来读取 csv 文件并操作 DOM。但是,这种方法遇到了一些问题:
- 由于浏览器限制,无法从桌面读取 csv 文件。解决方法是将数据放入一个字面字符串。
- 无法将 jquery 和 jquery-csv 注入页面以处理上述 csv 字符串。每当提交表单时,整个页面都会变得不稳定。解决方法是手动将 csv 转换为 json 字面字符串。
- 无法在进程外等待页面在提交交易后刷新。使用两个交易的测试集,只提交了最后一个交易。没有可行的解决方法。
我的第二个方法是尝试直接表单提交。我无法将页面代码逆向工程到那个级别。我查看了 HTTP 数据包,但怀疑即使我可以手动创建它们,身份验证仍然会是一个障碍。
最后,我选择了 C# 加 Selenium。C# 将为我提供一个简单的命令行实用程序,丰富的库支持来处理 csv 文件,并且是进程外的。Selenium 用于“为测试目的自动化 Web 应用程序”。虽然这个项目不是测试,但它正是 Web 应用程序自动化。通过 NuGet 包,Selenium 可以非常轻松地集成到 C# 解决方案中。
背景
基本工作流程很简单:从银行或信用卡网站获取交易的 csv 文件,然后自动化 mint.com 的“添加交易”对话框。
CSV 文件
下载信用卡交易很简单。重构 CSV 为 [日期, 商户, 类别, 金额],清理数据,并应用类别,虽然繁琐,但也简单明了。
但是,请记住以下几点:
- 某些类别将不起作用,例如“转账”和“信用卡还款”。请勿导入“从 XXX 转账”项目,因为它们将被标记为支出,这将破坏各种趋势图。
- 注意非标准的单引号。
- 确保“负数”交易以减号开头,而不是用括号括起来。
- 文件末尾不要有任何空白行。
一个示例输入文件
Date,Merchant,Category,Amount 01/01/2016,TestA,Financial,$1.00 01/01/2016,TestB,Financial,-$1.00 01/02/2016,TestC,Financial,-$1.00 01/02/2016,TestD,Financial,$1.00 01/03/2016,TestE,Financial,$1.00
Mint.com 网页元素
使用 Chrome 开发者工具和 DOMListener 插件,可以找到所有相关的元素,并在添加交易时查看其变化。
登录页面
注意:登录后,会有一个进度页面,然后显示主页面。
主导航栏
注意:点击“交易”后,会有一个非常短暂的进度页面,然后新页面会被渲染,但会有些“闪烁”,直到完全重新渲染。
交易按钮
注意:点击“+ 交易”按钮后,“交易”表单会显示出来,覆盖在交易列表的第一个条目上。
交易表单
注意:提交交易后,页面会再次有些“闪烁”,然后重新渲染。
注意:表单及其字段始终存在于 DOM 中。似乎改变的是表单的类,从“expense hide”变为“expense”或“income”,然后再变回。
注意:交易以“现金交易”的形式添加。它们不与任何特定账户相关联。但它们是可删除的。
使用代码
该解决方案创建了一个简单的控制台应用程序,它接受以下键值对参数。参数可以以 / 或 -- 开头,并且可以使用 = 或 : 来分隔键和值,例如“--transactionfile=c:\temp\testdata.csv”。
键 | 值 |
---|---|
transactionfile | .csv 文件的路径。可选,默认为 .\transactions.csv。 |
名称 | Mint.com 账户名。必需,如果未指定,将提示用户。 |
密码 | Mint.com 账户密码。必需,如果未指定,将提示用户,但不会明文显示密码。 |
logfile | 生成的日志文件的路径。可选,默认为 .\UpdateMint-DATE.log。 |
whatif | 可选,无需值。如果指定,则会取消交易表单对话框,而不是提交。这是测试代码的好方法。 |
// VS 2015 Community Edition
// NuGet: "Selenium.WebDriver", "Selenium.Support", and "Selenium.WebDriver.ChromeDriver"
using System;
using System.Linq;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
namespace Project
{
public static class Extensions
{
// REFERENCE <a href="http://stackoverflow.com/questions/6229769/execute-javascript-using-selenium-webdriver-in-c-sharp">http://stackoverflow.com/questions/6229769/execute-javascript-using-selenium-webdriver-in-c-sharp</a>
public static IJavaScriptExecutor Scripts(this IWebDriver driver)
{
return (IJavaScriptExecutor)driver;
}
}
public class App
{
//// PROPERTIES
public static string Date = System.DateTime.Now.ToString("yyyyMMddHHmm");
public static string Self = System.Reflection.Assembly.GetEntryAssembly().Location;
public static string Root = <a href="http://system.io/">System.IO</a>.Path.GetDirectoryName(Self);
public static ArgumentTable Arguments = new ArgumentTable();
public static LogFile Log = new LogFile();
public static int ExitCode = 0;
//// METHODS
public static int Main(string[] args)
{
try
{
// start
if (!Arguments.ContainsKey("transactionfile")) Arguments["transactionfile"] = <a href="http://system.io/">System.IO</a>.Path.Combine(Root, "transactions.csv"); // default to .\transactions.csv
if (!Arguments.ContainsKey("name")) { Console.Write("Enter email or user id: "); Arguments["name"] = Console.ReadLine(); } // required
if (!Arguments.ContainsKey("password")) { Console.Write("Enter password: "); Arguments["password"] = ReadPassword(); } // required
if (!Arguments.ContainsKey("logfile")) Arguments["logfile"] = <a href="http://system.io/">System.IO</a>.Path.Combine(Root, String.Format("{0}-{1}.log",<a href="http://system.io/">System.IO</a>.Path.GetFileNameWithoutExtension(Self), Date));
Log.Open(Arguments["logfile"]);
Log.Message("Start");
Log.Debug(Arguments.ToString());
// 1. load csv data into an array of objects
Log.Trace("Loading CSV data...");
System.Collections.Generic.List<Transaction> Transactions = <a href="http://system.io/">System.IO</a>.File.ReadAllLines(Arguments["transactionfile"]).Skip(1).Select(v => Transaction.FromCsv(v)).ToList();
using (IWebDriver driver = new OpenQA.Selenium.Chrome.ChromeDriver())
{
var wait = new OpenQA.Selenium.Support.UI.WebDriverWait(driver, TimeSpan.FromSeconds(10));
// 2. open <a href="http://mint.com/">mint.com</a>
Log.Trace("Opening website...");
driver.Url ="<a href="https://mint.intuit.com/login.event?referrer=direct&soc=&utm=">https://mint.intuit.com/login.event?referrer=direct&soc=&utm=</a>";
// 3. login
Log.Trace("Logging in...");
wait.Until(ExpectedConditions.ElementIsVisible(By.Id("ius-userid")));
wait.Until(ExpectedConditions.ElementIsVisible(By.Id("ius-password")));
wait.Until(ExpectedConditions.ElementIsVisible(By.Id("ius-sign-in-submit-btn")));
driver.FindElement(By.Id("ius-userid")).SendKeys(Arguments["name"]);
driver.FindElement(By.Id("ius-password")).SendKeys(Arguments["password"]);
driver.FindElement(By.Id("ius-sign-in-submit-btn")).Submit();
// 4. navigate to transactions page
Log.Trace("Navigating to transaction page...");
wait.Until(ExpectedConditions.ElementToBeClickable(By.CssSelector("a[href*='transaction.event']")));
driver.FindElement(By.CssSelector("a[href*='transaction.event']")).Click();
System.Threading.Thread.Sleep(3000); // MAGIC, let the new page load; sometimes the first transaction fails because the form is add-cash but the fields are an existing transaction and not "Enter Description"
// 5. import transactions
Log.Trace("Importing transactions...");
foreach (var Transaction in Transactions)
{
Log.Debug("Found {0}", Transaction.ToString());
// a. open form
Log.Trace("Opening form..");
wait.Until(ExpectedConditions.ElementExists(By.Id("txnEdit")));
wait.Until(ExpectedConditions.ElementExists(By.Id("txnEdit-form")));
wait.Until(ExpectedConditions.ElementToBeClickable(By.Id("controls-add")));
Log.Debug("#txnEdit class = {0}", driver.FindElement(By.Id("txnEdit")).GetAttribute("class"));
wait.Until(d => d.FindElement(By.Id("txnEdit")).GetAttribute("class") == "single regular");
Log.Debug("#txnEdit-form class = {0}", driver.FindElement(By.Id("txnEdit-form")).GetAttribute("class"));
wait.Until(d => d.FindElement(By.Id("txnEdit-form")).GetAttribute("class").Contains("hide") == true);
driver.Scripts().ExecuteScript("document.getElementById('controls-add').click()"); // driver...Click() sometimes failed
// b. enter values
Log.Trace("Entering values..");
Log.Debug("#txnEdit class = {0}", driver.FindElement(By.Id("txnEdit")).GetAttribute("class"));
wait.Until(d => d.FindElement(By.Id("txnEdit")).GetAttribute("class") == "add cash");
Log.Debug("#txnEdit-form class = {0}", driver.FindElement(By.Id("txnEdit-form")).GetAttribute("class"));
wait.Until(d => d.FindElement(By.Id("txnEdit-form")).GetAttribute("class").Contains("hide") == false);
wait.Until(ExpectedConditions.ElementToBeClickable(By.Id("txnEdit-date-input")));
wait.Until(ExpectedConditions.ElementToBeClickable(By.Id("txnEdit-merchant_input")));
wait.Until(ExpectedConditions.ElementToBeClickable(By.Id("txnEdit-category_input")));
wait.Until(ExpectedConditions.ElementToBeClickable(By.Id("txnEdit-amount_input")));
Log.Debug("#txnEdit-merchant_input value = {0}", (string)driver.Scripts().ExecuteScript("return document.getElementById('txnEdit-merchant_input').value"));
wait.Until(d => (string)d.Scripts().ExecuteScript("return document.getElementById('txnEdit-merchant_input').value") == "Enter Description"); // the most important safety check, otherwise you might override existing data
driver.Scripts().ExecuteScript("document.getElementById('txnEdit-date-input').value = arguments[0]", Transaction.Date); // .SendKeys doesn't work for this field
driver.FindElement(By.Id("txnEdit-merchant_input")).SendKeys(Transaction.Merchant);
driver.FindElement(By.Id("txnEdit-category_input")).SendKeys(Transaction.Category);
driver.FindElement(By.Id("txnEdit-amount_input")).SendKeys(Transaction.Amount);
if (Transaction.Type == TransactionType.Expense)
{
driver.FindElement(By.Id("txnEdit-mt-expense")).Click();
if (driver.FindElement(By.Id("txnEdit-mt-cash-split")).Selected) driver.FindElement(By.Id("txnEdit-mt-cash-split")).Click();
}
else
{
driver.FindElement(By.Id("txnEdit-mt-income")).Click();
}
driver.FindElement(By.Id("txnEdit-note")).SendKeys("Imported transaction.");
// c. submit form
Log.Trace("Submitting form..");
if (!Arguments.ContainsKey("whatif")) // submit
{
driver.FindElement(By.Id("txnEdit-submit")).Click();
}
else // pretend
{
driver.FindElement(By.Id("txnEdit-cancel")).Click();
}
Log.Message("Imported {0}", Transaction);
System.Threading.Thread.Sleep(3000); // MAGIC, safety net, let the submit cook
}
}
}
catch (Exception ex)
{
Log.Exception(ex.Message);
ExitCode = 255;
}
finally
{
// finish
Log.Message("Finished [{0}]", ExitCode);
Log.Close();
}
return ExitCode;
}
public static string ReadPassword()
{
string Password = "";
ConsoleKeyInfo KeyInfo = Console.ReadKey(true);
while (KeyInfo.Key != ConsoleKey.Enter)
{
if (KeyInfo.Key != ConsoleKey.Backspace)
{
Password += KeyInfo.KeyChar;
Console.Write("*");
}
else if (Password.Length > 0)
{
Password = Password.Substring(0, (Password.Length - 1));
Console.Write("\b \b");
}
KeyInfo = Console.ReadKey(true);
}
Console.WriteLine();
return Password;
}
} // class
} // namespace
附加详情
- iMac, 27英寸, 2013年末
- [Bootcamp] Windows 10 (已完全打补丁)
- Visual Studio 2015 Community Edition
- NuGet 包:Selenium.Support, Selenium.WebDriver, Selenium.WebDriver.ChromeDriver
- Chrome
关注点
最困难的部分是确定何时可以安全地操作 DOM。如上所述,许多字段始终存在,问题在于它们的“状态”。此外,根据我观察到的行为,我怀疑页面代码是基于 Ajax 的,这使得更难知道何时准备就绪或完成。我采用了一种模式,使用 Wait.Until() 语句作为即将操作的字段的先决条件,并使用 Thread.Sleep() 来等待页面加载和表单提交。显然,使用 Thread.Sleep() 不是一个好的做法。但在这种情况下,务实且安全的方法是使用它们。超时值是通过反复试验计算出来的,并向上取整。这些值将是 mint.com 服务性能、网络延迟和本地 CPU 性能的函数。您可能需要根据您的情况进行调整。
我最终一次导入了一个月的交易,大约 100 笔。在 24 次导入中,除了“错误数据”之外,由于时序问题,大约有 4 次失败。当发生故障时,通过查看 mint.com 和日志文件,很容易就能确定最后一次成功的交易,然后从 csv 文件中截断它及其前驱项,然后重新尝试导入。
LogFile 和 ArgumentTable 工具类是我多年前编写的代码,并且一直重复使用。
历史
2017-04-23 原始草稿。
2017-04-24 删除了示例代码中的空白行。