자산
238472.61CNY
부채
0
수입
-336422.75CNY
지출
132231.76CNY
자산 분포
master 자산 구성의 시각적 표현
부채 분포
master 부채 구성의 시각적 표현
Chart is empty.
현금 흐름
수입원에서 지출 및 투자로의 자금 흐름
수입 대 지출
선택한 기간의 각 구간별 총 수입과 지출을 비교하는 막대 차트.
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`,导致金额错误!

### 🛠️ 解决方案

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