3

随着近些年消费结构的改变,抵制消费主义的行为越来越流行,越来越多的年轻人开始意识到个人财务管理的重要性。与此同时,年轻人也越来越意识到,如果他们想要实现自己的财务目标,如购买房屋、投资、旅游等,就需要掌握有效的财务管理技能。因此,记账已经成为了日常生活中不可或缺的一部分。

但于此同时,真正尝试过记账的小伙伴很多,能年复一年坚持下来的却很少。原因在于记账是一个太过机械性的行为,且很容易因为或主观或客观的因素忘记漏记。这样记账变得枯燥乏味的同时,数据还不够可靠,无法准确地记录消费趋势,从而导致记账的动力不足。

那么,此时如果有一个能够完全自动化的记账系统,能够完全不需要你手动录入每一笔交易,全自动完成所有的数据清洗和分类,并自动地分析消费趋势,最终以图表等形式生成报表,直观地展示出来,你会不会觉得很方便,记账的动力也会大大增强呢?这就是我近些年来一直在思考和优化的财务管理目标。

记账模板展示

先展示成果,以增强大家的记账动力。我使用了腾讯文档作为所有账单汇总的数据源。由于它支持多人协作,且可以随时随地在任何设备上面修改,且 UI 设计出色,非常适合作为小数据量的账单数据库使用。

以下在不同时期根据自身需求优化出来的财务报表,从上面可以很清晰直观地看到自己的月度消费趋势,以及各个消费类别在每个月的占比,均摊及统计变化趋势。

20230725161330

甚至根据自身定制的需要,还可以展示使用的支付方式占比、每年各类别均摊支出及变化趋势等,都能很直观的以图表的形式展示出来。

20230725161559

当然只展示整体的显示效果,具体的数据涉及个人隐私都打了厚码,影响美观见谅。

那么接下来就开始从零开始逐个讲解如何设计一个这样的自动记账系统了。

一个完全自动化的记账系统,大体由三个重要部分组成:数据获取、数据清洗和数据分析。我将根据这三个部分依次介绍。

数据获取

数据获取是一个完全自动化记账系统的第一步,也是最重要的一步。交易数据的准确性以及信息丰富度直接决定了我们后续的数据清洗和数据分析的效果。因此,我们需要尽可能选择信息量更大的数据源,以获取更多的每条交易更多的上下文信息,从而减少进一步人工标注的干预。

对中国大陆来说,微信和支付宝就是国民级的支付软件,它足够普及和便利,几乎不需要大多数人改变交易习惯。此外,它们的交易数据也是最丰富的。因此,我们的数据获取就以微信和支付宝为主。

但很不幸的是,与国外一些信用卡每月会定期发送账单邮件不同,微信和支付宝并不会主动推送交易账单,并且没有开放获取交易数据的 API,且安全性校验很复杂,也无法使用无头的爬虫来获取数据。因此我们需要通过自行操作来获取交易账单。(当然,如果由愿意折腾的小伙伴也可以考虑模拟用户的行为获取账单)

微信支付账单获取

微信支付账单的获取需要在移动端完成(毕竟小而美)。需要在自己的微信 APP 中,依次点击 => 服务 => 钱包 => 账单 => 常见问题 => 下载账单 => 用于个人对账,输入账单时间邮箱地址,即可将账单发送到你的邮箱中。下载到本地后,输入发送到微信的密码,即可获取微信支付账单。

88ca321689b9f4f2310ec102d37132a9

但是,微信支付的账单只能获取最近三个月的数据。因此,我们最多每三个月就需要更新下载账单。

支付宝账单获取

支付宝账单的获取需要在 PC 端支付宝官网完成。登录后选择 我是个人用户 => 我的支付宝 => 交易记录,就可以选择对应的交易时间段,选择下载账单为 Excel 格式 ,即可获取到支付宝账单。

20230725155847

其他数据源

当然,如果微信和支付宝不是你主要使用的交易工具。其实所有的银行 APP 都提供打印流水服务,完全可以使用你主要使用的交易工具来作为数据源。当然具体入口可能会有区别,且单条交易记录的信息丰富度可能不足,需要手动补充,在此就不加赘述了。

数据清洗

数据清洗是一个记账系统很重要的一步。因为这种自动化导出的账单必定是非常杂乱的,不同的数据源,其交易记录的格式都不一样,且信息丰富度也不一样。因此,我们需要对不同的数据源进行不同的数据清洗。

数据清洗的目的,首先是统一好来自不同数据源的格式以方便接下来的综合分析,其次是剔除掉一些我们不关心的噪声,例如我只想记录 100 元以上的大宗交易,或者我不希望个人不同账户直接的转账被计入进来等等,这部分也完全可以根据自身的需求进行定制。

观前重要提醒:

如果有读者完全没有编程基础也完全不用害怕,可以在 Excel 中自行操作出一模一样的格式,再合并数据即可,使用代码只是为了提升自动化效率。当然,我之后也将开源所有脱敏的预处理与训练代码,有兴趣的小伙伴可以自行尝试。

以下的脚本都以 typescript 为例,也完全可以使用任何你所熟悉的语言。

下面仍然以微信和支付宝为例,来介绍如何进行数据清洗。

首先是统一各个数据源的账单格式。如果我们打开了在上一步中获取的微信与支付宝的账单文件,会发现它们之间其实有很多信息都很相似,可以经过一定的规则合并起来。例如,它们都有交易时间、交易类型、交易对方、交易金额等信息。因此,我们可以将它们统一为一个格式:

interface IBookKeepingRow {
  交易时间:Date;
  类型:RecordType | "";
  "金额(元)": string;
  "收/支": "支出" | "收入" | "/";
  支付方式:string;
  交易对方:string;
  商品名称:string;
  备注:string;
}

找到了目标格式,接下来我们来看看如何将数据源进行转换。

微信支付账单清洗

使用记事本打开微信支付的账单 csv,会发现它的格式类似于:

微信支付账单明细,,,,,,,,
微信昵称:[Duang],,,,,,,,
起始时间:[2023-06-04 00:00:00] 终止时间:[2023-07-04 00:00:00],,,,,,,,
导出类型:[全部],,,,,,,,
导出时间:[2023-07-25 18:08:39],,,,,,,,
,,,,,,,,
共 44 笔记录,,,,,,,,
收入:3 笔 1145.14 元,,,,,,,,
支出:40 笔 2333.33 元,,,,,,,,
中性交易:1 笔 888.00 元,,,,,,,,
注:,,,,,,,,
1. 充值/提现/理财通购买/零钱通存取/信用卡还款等交易,将计入中性交易,,,,,,,,
2. 本明细仅展示当前账单中的交易,不包括已删除的记录,,,,,,,,
3. 本明细仅供个人对账使用,,,,,,,,
,,,,,,,,
----------------------微信支付账单明细列表--------------------,,,,,,,,
交易时间,交易类型,交易对方,商品,收/支,金额(元), 支付方式,当前状态,交易单号,商户单号,备注
2023-07-04 12:05:22, 商户消费,甜甜花酿鸡,"xxx", 支出,¥6.00, 零钱通,支付成功,xxx,xxx,"/"
2023-07-02 19:42:54, 扫二维码付款,愚人众,"收款方备注:二维码收款", 支出,¥15.00, 零钱通,已转账,xxx,xxx,"/"

...

我们可以看出,微信支付的账单中,第一行到第 14 行都是无用的信息,我们需要跳过这些行,从第 15 行开始读取数据。同时,我们还需要将 交易时间交易类型交易对方商品收/支金额(元)支付方式备注 这些信息去除空格后提取出来,其余的信息如交易单号等都可以根据需要忽略掉。

我给大家一个我自己常用的模板列头供大家参考:

20230821162810

接下来我们就可以来看看如何处理数据使得账单的排列符合我们自己设置的模板。

下面我通过 fast-csv 库读取 csv 文件并筛选相关数据,最终返回一个 IBookKeepingRow[] 数组。

export async function wechatPayFormatter(
  sourceFilePath: string
): Promise<IBookKeepingRow[]> {
  const csvStream = fs.createReadStream(sourceFilePath);

  return new Promise((resolve, reject) => {
    const bookKeepingRows: IBookKeepingRow[] = [];
    parseStream<IWechatBillRow, IBookKeepingRow>(csvStream, {
      headers: true,
      ignoreEmpty: true,
      skipLines: 14,
      trim: true,
    })
      .transform((row: IWechatBillRow) => {
        const bookKeepingRow: IBookKeepingRow = {
          交易时间:new Date(row. 交易时间),
          类型:"",
          "金额(元)": row["金额(元)"].replace(/¥/g, ""),
          "收/支": row["收/支"],
          支付方式:row. 支付方式 === "亲属卡" ? "亲属卡" : "微信支付",
          交易对方:row. 交易对方,
          商品名称:row. 商品,
          备注:row. 备注 === "/" ? "" : row. 备注,
        };
        return bookKeepingRow;
      })
      .on("data", (row: IBookKeepingRow) => {
        bookKeepingRows.push(row);
      })
      .on("end", () => {
        resolve(bookKeepingRows);
      })
      .on("error", (error) => reject(error));
  });
}

支付宝账单清洗

对支付宝账单的清洗也采用类似的逻辑。

不过这里需要注意是,支付宝账单导出的 csv 是 gbk 格式的,并不能被包括 fast-csv 在内的许多 csv 解析库处理,因此不能使用 stream 的方式直接读取,需要先做一个转码的操作。

另外,支付宝除了上面几行记录了账单总览信息外,最下面几行也加了几行统计信息,因此在清洗时也需要一并剔除。

export async function aliPayFormatter(
  sourceFilePath: string
): Promise<IBookKeepingRow[]> {
  const csvBuffer = fs.readFileSync(sourceFilePath);

  // 将 GBK 编码转换为 UTF-8 编码
  const utf8Csv = iconv.decode(csvBuffer, "gbk");
  const lines = utf8Csv.split("\n");

  // 截取以"--------"开头的行中间的所有行
  let flag = false;
  const csvTableString = lines
    .filter((line: string) => {
      const isDividerLine = line.startsWith("--------");
      if (!flag) {
        if (isDividerLine) {
          flag = true;
        }
        return false;
      }
      if (isDividerLine) {
        flag = false;
        return false;
      }
      return true;
    })
    .map((line: string) => line.replace(/[\s]+,/g, ","))
    .join("");

  return new Promise((resolve, reject) => {
    const bookKeepingRows: IBookKeepingRow[] = [];
    parseString<IAlipayBillRow, IBookKeepingRow>(csvTableString, {
      headers: true,
      ignoreEmpty: true,
    })
      .transform((row: IAlipayBillRow) => {
        const bookKeepingRow: IBookKeepingRow = {
          交易时间:new Date(row. 交易创建时间),
          类型:"",
          "金额(元)": row["金额(元)"],
          "收/支": row["收/支"] === "不计收支" ? "/" : row["收/支"],
          支付方式:"支付宝",
          交易对方:row. 交易对方,
          商品名称:row. 商品名称,
          备注:row. 备注,
        };
        return bookKeepingRow;
      })
      .on("data", (row: IBookKeepingRow) => {
        bookKeepingRows.push(row);
      })
      .on("end", () => {
        resolve(bookKeepingRows);
      })
      .on("error", (error) => reject(error));
  });
}

这样,将以上两个 IBookKeepingRow 数组合并起来,并按照交易时间排序,就可以得到一个完整的交易账单记录了。

  const bookKeepRecords = [...aliPayRecords, ...wechatPayRecords].sort(
    (a, b) => {
      return a. 交易时间。getTime() - b. 交易时间。getTime();
    }
  );

数据降噪处理

合并账单只是数据清洗的第一步,我们还需要对数据进行一些预处理,清洗掉一些无关噪声,便于后续的数据分析。

不过由于这一部分的定制需求非常高,每个人都有自己的理由为自己的账单定制筛选逻辑,去除一部分数据,保留一部分数据。因此这一章节,我将不进行过多的代码讲解,只是抛砖引玉,简单介绍一下我自己的数据预处理逻辑的思路。

首先是金融相关收支的分类。例如很多人在支付宝或微信中买了理财产品(余额宝这种也算),还有银行间的转入转出,以及理财收益等,这些都是金融相关的收支,它们的收益也会体现在导出的账单中,我们需要将他筛选出来。

我的观察是,这些金融相关的收支,其 收/支 一栏都会显示为 /不计收支。因此,我们可以通过这个特征来筛选出来。

20230821161700

这里需要注意的是,这里会有一些 Corner Case 需要处理,由于涉及到个人消费偏好,每个人的设置都会需要微调一下。例如在微信支付或者支付宝的账单中,有一些退款的记录,也会显示为 /。但是这些退款的记录,其 商品名称 一栏都会包含 退款 字样,因此我们可以通过这个特征来反向筛选出来。

  // 优先去掉非收支部分
  const filteredBookKeepRecords = bookKeepRecords.filter((row) => {
    return !(row["收/支"] === "/" && !row["商品名称"].includes("退款"));
  });

同理,我们也可以同样筛选出来一些不需要的数据,例如我不想记录某个个人账户之间的转账,那么我们可以通过 交易对方 这一栏来筛选出来舍弃掉。又例如,我只想记录 100 元以上的大宗交易,那么我们可以通过 金额(元) 这一栏来筛选出来。

通过这样的方式,我们完成了最重要的一步,数据清洗,使得账面整洁且清晰了起来。

20230821163315

拥有了统一且规范化的数据格式,接下来我们就能安心地在类型下填入该笔消费的类型,对整个账单数据进行分类,从而得知自己的各项消费趋势了。

小结

到这里,我们已经完成了一个完整的数据清洗流程,将来自不同数据源的账单数据统一为一个格式,且去除了一些我们不关心的噪声,使得数据更加干净整洁,便于后续的更进阶的数据分析了。

什么?你说我是标题党?这还是需要我自己手动一个个进行分类?

没错。但本篇只是一个基础篇,它帮助你解决了账单数据源从无到有,从杂乱到规范的过程,这一过程通常是最繁琐的,本篇将这一流程实现了自动化处理。能为有需要的同学省下了许多时间。

但如果你问,有没有更智能化的方式,能将账单分类的过程也给我省略了,不需要分类操作,躺着就能自动更新获取我近年来所有的消费趋势。我会说,有的。我们可以通过收集一些历史消费数据,通过建立机器学习模型来完成预测。

接下来的数据分析,就是一个非常有趣的过程了,我们将在下一篇中进行讲解。


Duang
91 声望321 粉丝

Learn Better, Live Better.