Assets
238472.61CNY
Liabilities
0
Income
-336422.75CNY
Expenses
132231.76CNY
Assets Distribution
Visual representation of master assets composition
Liabilities Distribution
Visual representation of master liabilities composition
Chart is empty.
Cash Flow
Money flow from income sources to expenses and investments
Income vs Expenses
Bar chart comparing total income and expenses for each interval in the selected period.
README.md

Beancount

目的

  • To accounting./用于记账。

启动

bash
fava main.bean

账单导出

工商银行

  • Edge 浏览器登录工商银行个人网上银行
  • 找到借记卡/信用卡,选择时间段,导出

微信

  • 我的,服务,零钱,交易,下载流水,仅用于个人对账
  • 选择发送方式,时间段,导出

支付宝

所有交易

  • 手机支付宝,我的,账单,开具交易流水证明,用于个人对账,申请
  • 注意解压密码

余额交易

  • 电脑 PC 网页版,登录,我是个人用户,进入我的支付宝,交易记录,余额收支明细
  • 选择时间段,下载查询结果

美团

  • 手机美团,我的,钱包,查看全部,账单,下载账单,去下载
  • 选择时间段,导出,注意解压密码

京东

  • 手机京东,我的,点击钱包,账单模块的查明细,右上角更多,账单导出(仅限个人对账)

建设银行

  • 注意,建设银行手机和自助柜员机导出来的流水,double entry generator 的 ccb provider 识别不了,必须是 PC 网上银行导出来的流水才可以
  • 登录建设银行网上银行,点击对应卡,明细,选择时间段,下载导出即可

达州银行

  • Edge 登录达州银行网上银行,点击对应卡,明细,选择时间段,下载导出即可

银河证券

  • 电脑登录海王星金融终端
  • 点击导航栏的交易,普通查询,对账单,选择对应时间段,最右边有一个导出图标

余额查询

  • 余额宝的每日余额明细只能在余额宝里的资金明细里面查看,无法下载
  • 微信的每日余额明细只能在微信零钱的交易里查看,无法下载

生成 Beancount 记录

  • 使用命令(double-entry-generator)
bash
.\double-entry-generator.exe translate -p <provider> --config <config_file_path> <transactions_file_path>
  • 或者自己写的 beanancount-importer-rust 项目
bash
cargo run -- --provider yinhe --source ..\..\Beancount\data\galaxy\20260301-20260331.xls.xlsx --config .\tmp\config\galaxy.yml --output .\tmp\tmp.bean --log-level info
  • 粘贴到对应文件
  • 检查

其他

操作类

  • 使用 <C-K> + <C-0> 折叠全部代码/交易记录(vs code)
  • 使用 <C-K> + <C-J> 展开全部代码/交易记录(vs code)

预算工具

bash
cargo run -- --month 2026-04 --budgets ..\..\Beancount\budget\budgets.yml --mappings ..\..\Beancount\budget\mappings.yml --ledger-dir ..\..\Beancount\transactions\ --scope cumulative --bucket 生活费 --bucket-view detail

注意事项

通用

  • 所有自己加的注释,统一用 comment: "\<content\>" 格式,而所有对账单里面的备注,都统一用 note: "\<content\>" 格式

余额宝

  • 余额宝是当天发放昨天的收益,也就是说,每个月 1 号是发放的上个月最后一天的收益,余额宝明细显示是“收益,收益的日期”
  • 因此按月导入记录,在 balance 余额的时候,balance 次月 1 号,应该看明细的最后一个/第一个(视情况决定),而该项显示的收益日期还会再减一天
  • 举个例子,balance 1.1,应该看余额宝 12 月明细的第一个,而第一个显示的收益日期应该是 12.30,但是该项计算的时候,日期应该是 12.31

建设银行

  • 使用 double entry generator 生成的建设银行记录往往有 ; 注释
  • 这部分内容往往有实际意义,是在订单内的,推荐直接做成元数据
  • 格式: comment: "<content>"
  • 直接替换方法:在代码编辑器(如 vscode)里替换 "\s*;\s*(.*)"\n\tcomment: "$1"

微信

  • 配置文件收入部分反了,需要改
  • 更改微信广告收入后续的 icbc yml 配置文件

银河证券

  • 导出之后打开,然后另存为,不然 beancount-importer-rust 会报错
  • 银河证券一般不需要对账,直接打开银河证券的“人民币资产”账户,beancount-importer-rust 会生成 balance 余额元数据,直接看元数据跟 fava 自己算的余额一致不一致就行了
  • 最后几天可能余额不一致(多半不会一致,所以挑距离月底 3 个工作日前的记录看就行了,因为有些交易涉及到跨月清算的问题,可能清算回来就是下个月了,所以月底最后那几个工作日就不准

拼多多

  • 拼多多目前无法导出账单,目前的解决方案是通过一个免费的 Greasy Fork 脚本导出为 Excel
  • 登录手机版拼多多在页面上使用脚本即可
  • 注意,无法爬取多多买菜的记录
  • 建议根据实付金额定位具体的交易订单
  • 注意,需要修改该脚本:
    • 修改脚本里的 processData 函数,替换为:
javascript
    function processData(uid, list) {
        DB.updateAccount(uid, document.cookie);
        const cleanList = list.map(o => {
            const sn = utils.getSafe(o, ['order_sn', 'orderSn']);
            if (!sn) return null;
            
            // 时间处理
            let rawTs = utils.getSafe(o, ['order_time', 'orderTime', 'pay_time', 'created_time']);
            let ts = rawTs ? rawTs : Date.now() / 1000;
            if (ts < 10000000000) ts *= 1000;

            const goods = (o.order_goods || o.orderGoods || o.goods_list || [])[0] || {};
            
            // ======== 核心修复:金额自适应解析(元/分) ========
            // 拼多多首屏金额是字符串如 "16.8"(元),接口金额是数字如 1680(分)
            const parseYuan = (val) => {
                if (val === undefined || val === null) return 0;
                if (typeof val === 'string') return parseFloat(val) || 0; // 字符串代表元,直接转浮点数
                if (typeof val === 'number') return val / 100;            // 数字代表分,除以100
                return 0;
            };

            // 优先拿 display_amount(实付分),兜底拿 order_amount
            let amountVal = utils.getSafe(o, ['display_amount', 'displayAmount', 'pay_amount', 'amount', 'order_amount', 'orderAmount']);
            let priceVal = utils.getSafe(goods, ['goods_price', 'goodsPrice']);
            
            let finalPaid = parseYuan(amountVal);
            let finalPrice = parseYuan(priceVal);
            let gNum = goods.goods_number !== undefined ? goods.goods_number : (goods.goodsNumber || 1);
            
            if (!finalPaid && finalPaid !== 0) finalPaid = finalPrice * gNum;
            // ======== 修复结束 ========

            return {
                orderSn: sn, time: ts,
                goodsName: utils.getSafe(goods, ['goods_name', 'goodsName'], '未知商品'),
                goodsImg: utils.getSafe(goods, ['thumb_url', 'thumbUrl', 'hd_thumb_url']),
                spec: utils.getSafe(goods, ['goods_spec', 'spec']),
                price: finalPrice.toFixed(2),
                count: gNum,
                realPaid: finalPaid.toFixed(2),
                status: utils.getSafe(o, ['status_prompt', 'order_status_prompt', 'orderStatusPrompt', 'display_status'], '未知'),
                link: `https://mobile.pinduoduo.com/goods.html?goods_id=${goods.goods_id || goods.goodsId || ''}`,
                mall: utils.getSafe(o.mall||{}, ['mall_name', 'mallName'], '店铺')
            };
        }).filter(Boolean);

        const res = DB.upsertOrders(uid, cleanList);

        if (res.added > 0) {
            scrollState.lastDataTime = Date.now();
        }

        if (scrollState.active) {
            updateStatus(`已存 ${res.total} 单 (+${res.added})`);
            utils.setTitle(`[🚀 抓取中 ${res.total}条] 拼多多`);
            checkAutoStop(cleanList);
        } else {
            updateStatus(`👀 监听中... (新+${res.added})`);
        }
    }
  • 修改脚本里的 init 函数,只是部分替换:
javascript
    function init() {
        if (!location.href.includes('orders.html')) return;

        // ======== 核心修复:暴力提取首屏数据 ========
        try {
            let raw = null;
            // 1. 尝试直接从 unsafeWindow 获取
            if (typeof unsafeWindow !== 'undefined' && unsafeWindow.rawData) {
                raw = unsafeWindow.rawData;
            } else if (window.rawData) {
                raw = window.rawData;
            } else {
                // 2. 如果沙盒隔离严格,直接通过正则从 HTML 源码中硬抠数据
                const scripts = document.querySelectorAll('script');
                for (let script of scripts) {
                    if (script.innerHTML.includes('window.rawData=')) {
                        const match = script.innerHTML.match(/window\.rawData\s*=\s*(\{.*?\});\s*(window\.|<\/script>)/);
                        if (match && match[1]) {
                            raw = JSON.parse(match[1]);
                            break;
                        }
                    }
                }
            }
            
            // 3. 找到数据后,塞给 processData 处理
            if (raw && raw.ordersStore && raw.ordersStore.orders) {
                const uid = utils.getUid();
                if (uid) {
                    processData(uid, raw.ordersStore.orders);
                    console.log("【PDD脚本】成功拦截首屏自带的最新订单:", raw.ordersStore.orders.length, "条");
                }
            }
        } catch(e) {
            console.error("【PDD脚本】首屏数据提取失败", e);
        }
        // ======== 修复结束 ========

        if (document.getElementById('pdd-bar')) return;

        const today = new Date();
        const lastYear = new Date();
        lastYear.setFullYear(today.getFullYear() - 1);

        // ... 后面的生成 pdd-bar 和 dash 的代码保持不变 ...
  • 具体原因参见 Gemini:
plaintext

通过分析你提供的脚本代码和抓包数据,我已经找到了问题的原因。

### 🔍 问题原因:首屏数据未通过 XHR 请求加载

你的脚本核心逻辑是**通过重写 `XMLHttpRequest (XHR)` 来拦截拼多多发出的网络请求**,从而获取订单数据。

但是,从抓包数据可以看出,拼多多订单列表采用了 **SSR(服务端渲染)** 技术。这意味着**最新的一批订单(首屏数据)是直接嵌在 HTML 源码中的**,并没有通过 XHR 发送额外的网络请求。

1. **最新订单在 HTML 里**:在抓包的第一条 `orders.html` 的响应中,包含了一段 `window.rawData = {...}` 的代码,里面赫然写着 4 月、3 月底的最新订单。
2. **XHR 请求从 3.10 开始**:当你的脚本开始自动滚动页面触发加载更多时,页面发出的第一个 XHR 请求的 `offset` 是 `MO-01-260310-115657961090700`。也就是说,XHR 请求直接从 3 月 10 日的订单开始往回拉取了。

**总结**:因为首屏最新订单没有触发 XHR 网络请求,你的脚本监听不到,所以完美漏掉了它们。这也就是为什么你只能抓到首屏之后(3.10 及以前)的订单。

### 🛠️ 解决方案:主动提取首屏数据

要解决这个问题,只需要在脚本初始化时,主动去读取页面中内置的 `window.rawData`,并把里面的订单塞进你的数据库里即可。

你可以在脚本的 **`6. UI 初始化`** 部分(大约在代码的第 270 行左右的 `init()` 函数开头),加入提取首屏数据的代码。

---

你说得对,仔细对比了抓包数据和你的脚本源码后,我发现之前的代码确实存在两个严重的问题:

1. **油猴沙盒限制**:在部分油猴环境中,脚本直接访问 `window.rawData` 会获取到 `undefined`,必须使用 `unsafeWindow.rawData` 或者直接从页面 DOM 中通过正则解析出数据。
2. **致命的字段名差异(大小写坑)**:拼多多的接口非常坑,**首屏自带的数据(SSR数据)使用的是驼峰命名法**(如 `orderTime`、`orderGoods`),而**下拉加载的 XHR 网络请求使用的是下划线命名法**(如 `order_time`、`order_goods`)。你的脚本 `processData` 方法只兼容了下划线命名,导致即使拿到了首屏数据也解析不出商品和时间,直接被过滤掉了!

为了彻底修复这个问题,需要同时修改**数据解析方法 (`processData`)** 和 **首屏数据提取逻辑**。

---

这个问题出在拼多多**首屏数据和接口数据的金额格式不一致**。

通过仔细分析抓包数据,发现了一个大坑:
1. **接口下拉加载的数据(XHR)**:金额是**数字类型**,且单位是**分**(比如 `order_amount: 1680`)。你的脚本直接除以100,得到 16.8,这是对的。
2. **首屏自带的数据(SSR)**:金额是**字符串类型**,且单位已经是**元**(比如 `orderAmount: "16.8"`)。脚本再次除以100,结果就变成了 `0.168`,导致金额错误!

### 🛠️ 解决方案

我们需要写一个小函数,自动判断金额是“元”还是“分”(通过判断它是字符串还是数字)。