65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 C# 和 Selenium 将交易导入 Mint.com

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2017年4月23日

CPOL

5分钟阅读

viewsIcon

21557

downloadIcon

170

使用 Selenium 自动化 mint.com 的交易页面。

引言

本文介绍了一个使用 Selenium 自动化将交易导入 mint.com 的 C# 命令行实用程序。

在使用 iPhone 应用玩耍时,我不小心删除了我的信用卡账户,因此丢失了大约 10 年的交易记录。在网上搜索,我发现了一篇文章 http://aaronfrancis.com/2013/importing-transactions-into-mint/,以及 Intuit 的大量功能请求。我想创建一个尽可能健壮和万无一失的解决方案。

我的第一个方法是使用浏览器的开发者工具控制台中的 Javascript 来读取 csv 文件并操作 DOM。但是,这种方法遇到了一些问题:

  1. 由于浏览器限制,无法从桌面读取 csv 文件。解决方法是将数据放入一个字面字符串。
  2. 无法将 jquery 和 jquery-csv 注入页面以处理上述 csv 字符串。每当提交表单时,整个页面都会变得不稳定。解决方法是手动将 csv 转换为 json 字面字符串。
  3. 无法在进程外等待页面在提交交易后刷新。使用两个交易的测试集,只提交了最后一个交易。没有可行的解决方法。

我的第二个方法是尝试直接表单提交。我无法将页面代码逆向工程到那个级别。我查看了 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 删除了示例代码中的空白行。

 

© . All rights reserved.