把微信 / 支付宝 / 招商银行 / 中国银行信用卡四类账单解析后幂等入库 SQLite,剔除内部流转、 跨账单去重,并提供一个常驻 web 站点用来看消费明细和资产负债。
- 消费明细:收入/支出流水,按任意字段排序过滤,支出类型占比,人工分类与剔除/恢复。
- 资产负债:手工登记各项资产/负债,按日期取最新值算净值曲线。
- 邮件监控(可选):后台轮询邮箱,发现账单邮件后一键下载解析入库。依赖一个不在本仓库内的 外部模块,见下方「邮件监控」一节。
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# 把导出的账单原始文件放进 raw/(见下方「目录」),然后:
export LEDGERLITE_CMB_PASSWORD=xxxxxx # 招行账单 zip 的解压密码,登录招行 App 查看
cd scripts && python combine.py # 首次批量灌库,产出 data/bill.db
cd ../web && python app.py --port 5057 # 启动网站,浏览器打开 http://localhost:5057无测试、无 lint 配置。验证靠重跑 combine.py 后核对网页展示 / data/meta.json 的 warnings。
raw/ 原始账单文件(.gitignore,需自己放进去)
scripts/ 解析/入库/合并/邮件监控脚本(核心)
data/ bill.db(SQLite,唯一账单存储)+ meta.json + 人工覆盖 JSON(.gitignore)
web/ Flask 常驻站点
raw/ 下期望的文件(combine.py 用 glob 按这些模式查找,找不到会报错提示):
| 渠道 | 期望路径/模式 | 说明 |
|---|---|---|
| 支付宝 | raw/ex_alipay/*.csv |
支付宝导出的交易明细 CSV(GBK 编码) |
| 微信 | raw/ex_wechat*/*.xlsx |
微信导出的账单 xlsx;可放多份不同时间范围的,自动选覆盖最宽的一份 |
| 招行 | raw/*招商银行*.zip |
招行 App 导出的交易流水 zip(内含加密 PDF) |
| 中行 | raw/*中国银行*.PDF |
中行信用卡账单 PDF |
全部可不设,用合理默认值;需要自定义部署路径/接入自己的账单细节时按需覆盖(见 scripts/paths.py):
| 变量 | 用途 | 默认值 |
|---|---|---|
LEDGERLITE_HOME |
项目根目录 | 代码所在目录的上一级 |
LEDGERLITE_RAW_DIR |
原始账单目录 | <HOME>/raw |
LEDGERLITE_DATA_DIR |
数据目录 | <HOME>/data |
LEDGERLITE_CMB_PASSWORD |
招行账单 zip 解压密码 | 无默认值,combine.py 批量灌库必须设置 |
LEDGERLITE_OWNER_NAME |
你在账单里显示的姓名,用于识别"转给自己另一账户"的自转账 | 空(不做这项剔除) |
LEDGERLITE_CMB_CARD_TAG |
支付宝/微信"支付方式"字段里银行卡的文本,用于识别哪些支出会在招行流水里重复出现 | 招商银行(只写银行名,不含卡号;多卡场景可设成含卡号尾号的完整文本以精确匹配) |
LEDGERLITE_URL_PREFIX |
整站挂载的子路径(反向代理场景),如 /ledgerlite |
空(直接挂根路径) |
OUTLOOK_SKILL_DIR |
邮件监控功能依赖的外部模块所在目录 | 空(不设则邮件监控功能报错,不影响解析本地文件这条主流程) |
data/bill.db:
transactions:每条成交记录一行,主键natural_key(渠道|时间|金额|对方|单号|备注;招行同日同额同对方同余额的真重复加#n后缀,既不误并又对重复导入幂等)。raw 列由解析器落库,派生列(spend_category/amount_cny/excluded/exclude_reason/dup_of)由combine.derive()对全量重算后回写——故跨源覆盖与去重与入库顺序无关。detected_bills:邮件监控发现的账单邮件(主键 message_id,幂等),状态 detected/ingested/error/ignored。kv:扫描状态(last_scan_at / last_scan_error)等。
data/snapshot.db(资产负债,独立于账单库):asset/debt 两张"更新式"表,每条记录
(date, item, amount, liquid) 表示在某日把某项资产/负债更新为某值;当前持仓 = 按 item 取
日期最大的一条。
scripts/parsers.py—— 四类异构账单 → 统一 schema(UNIFIED_COLUMNS)。- 支付宝:GBK CSV,表头以"交易时间"开头。
- 微信:xlsx,表头在数据区前若干行。
- 招行:约百页文字版 PDF,按词坐标(extract_words)分列 + 最近锚点重建。对手信息单元格围绕金额行垂直居中、换行片段在金额行上一行/下一行,且摘要列长文本会串列,故不能按 extract_text 逐行拼;按 x 分列(对手列 x≥412)、把无日期片段按 top 距离归到最近交易锚点,能正确拼回换行对手名。
- 中行:信用卡 PDF,
extract_tables(),分人民币段(CNY)/日元段(JPY)。
scripts/db.py—— SQLite 存储层。upsert_raw(INSERT OR IGNORE on natural_key,幂等)、load_raw/write_derived、load_all(供 web)、detected_bills/kvCRUD。assign_natural_keys给招行同键真重复加#n后缀。scripts/combine.py——derive(df):对一份原始记录做全量派生(剔除标注→跨源去重→个人转账镜像去重→主观分类→折算),无窗口、纯函数、入库后重算用。rebuild_derived():读 DB 全量→derive→回写+刷新 meta。main():批量灌库(解析四源→窗口过滤→upsert→rebuild)。- 剔除口径(每条都带
exclude_reason,写入 transactions 表可审计):支付宝/微信"不计收支"(退款/理财/提现)、转给本人自己的转账(账户互转,需设LEDGERLITE_OWNER_NAME);招行 朝朝宝转入转出 / 赎回 / 申购 / 银证转账 / 转账汇款(本人) / 信用卡自动还款 / ATM取现 / 购付汇 / 快捷退款冲正 / 贷款;中行 还款/购汇。转给同事/家人/朋友的个人转账保留为真实收支,不再一刀切剔除;其经招行卡产生的"微信转账"镜像由去重剔除,保证每笔只计一次。 - 跨账单去重:支付宝/微信里走银行卡(
LEDGERLITE_CMB_CARD_TAG)的支出,会在招行"快捷支付/银联快捷支付"里重复出现;按金额+日期(±3天,见DEDUP_DAYS)匹配,保留平台明细、剔除招行重复条(招行条dup_of指回平台行)。
- 剔除口径(每条都带
scripts/overrides.py+data/category_overrides.json+data/manual_exclusions.json—— 人工覆盖层(按"自然键 source|trade_time|amount|counterparty|order_id|note"持久化,combine 与 web 都套用、跨重跑保留):- 分类覆盖:网页"分类"彩色 badge 点击弹单选下拉改分类 → POST
/api/category(badge 带 ✎ 标记)。 - 手动剔除/恢复:每行"剔除"按钮 + 表格上方"查看已剔除"切换里每行"恢复"按钮 → POST
/api/exclude {action:exclude|restore}。effective_excluded(auto, manual):manual=exclude→剔除,=include→保留,否则按自动。
- 分类覆盖:网页"分类"彩色 badge 点击弹单选下拉改分类 → POST
scripts/categorize.py—— 把每笔支出映射到主观分类 餐饮/出行/数码/日用/娱乐/运动/居住/医疗/礼物/美容/服饰/其他(收入标"收入"),写入spend_category列。- 判定模型:一张有序过滤器表
FILTERS,每个过滤器是谓词(rec)->bool(rec=整条成交记录,可看 counterparty/description/category/note 等任意字段);拿每条记录从上往下依次匹配,第一个命中的过滤器决定分类(first-match-wins)。仓库里这份是通用示例规则,真正好用的规则(家人朋友姓名、常去的本地商户等私有信息)建议放进scripts/categorize_local.py(已.gitignore,定义EXTRA_FILTERS列表,会被自动加载并排在最前面优先命中),具体格式见categorize.py顶部注释。
- 判定模型:一张有序过滤器表
scripts/fx.py—— JPY→CNY 汇率,三源互备(frankfurter / er-api / jsdelivr)。全部失败抛FxUnavailableError,不回退常量;由 combine 捕获并写入 meta 告警。combine.py产出data/meta.json:记录汇率状态(fx.ok/rate)、时间窗口、未折算外币笔数、warnings。汇率全失败时该币种amount_cny留空(NaN)。scripts/mail_monitor.py—— 邮件监控。scan_inbox(lookback_hours)取近 N 小时邮件、按发件人特征(SENDER_TO_SOURCE)识别四类账单、登记进 detected_bills(幂等),刷新 kv 扫描状态。scan_safe捕获异常写 last_scan_error(后台线程不崩)。scripts/ingest.py—— 账单入库。ingest_message(message_id, password):从邮箱下载附件(微信无附件→正文下载链接抓 zip)→ 按来源解压/解析 →upsert_raw→combine.rebuild_derived()→ 更新 detected_bills 状态。ingest_file供本地补录/测试。失败标 error 并抛错(接口回 400+原因)。web/app.py+web/static/app.js+web/templates/index.html—— 常驻站点。- 消费明细:读 bill.db + meta.json,前端过滤/排序/占比(无外部 CDN);占比饼图按
spend_category;汇率失败顶部黄条告警;amount_cny 为 NaN 时 API 输出 null。 - 资产负债:
GET/POST /api/networth*,手工登记 → 净值曲线。 - 邮件监控:
GET /api/mail/status(扫描状态+已发现邮件列表)、POST /api/mail/scan(立即扫描,可带 hours)、POST /api/mail/ingest {message_id,password}(入库)。启动时拉起后台扫描线程(每 600s,回看 24h);加密账单在行内填解压密码。 app.py --no-scan关后台线程;deploy/ledgerlite-web.service是 systemd 常驻单元模板(路径需按你的部署改)。
- 消费明细:读 bill.db + meta.json,前端过滤/排序/占比(无外部 CDN);占比饼图按
自动发现/下载账单邮件依赖一个外部模块 outlook_graph(本仓库不包含实现),需要你自己提供并
通过环境变量 OUTLOOK_SKILL_DIR 指向它所在目录。该模块需暴露以下接口(对照微软 Graph API 的
邮件读取 + MSAL 静默令牌续期):
def load_credentials(): ... # 返回给 acquire_token_silent 用的凭据对象
def acquire_token_silent(creds) -> str | None: ... # 静默换取访问令牌,无缓存/过期返回 None
def list_recent_messages(token: str, lookback_hours: int) -> list[dict]: ...
# 返回最近 N 小时的邮件列表,每条至少含 id / from.emailAddress.address /
# receivedDateTime / hasAttachments / subject
def save_attachments(token: str, message_id: str, out_dir: str) -> list[str]: ...
# 下载某封邮件的附件到 out_dir,返回保存的文件路径列表不需要这个功能(比如只想手动导出账单跑 combine.py)可以完全忽略这一节;OUTLOOK_SKILL_DIR
不设置时,网站其余功能(消费明细/资产负债)不受影响,只有点"邮件监控"相关操作会报错提示。
- 招行/中行账单只有交易日期、没有具体时间(导出口径如此);支付宝/微信有完整时间。
- 招行对手名过长换行时只取首行(尾部可能截断),但不会被相邻行文本污染。
- 退款一律按"不计收支"剔除(与支付宝/微信平台口径一致),不做与原支出的轧差。
- 招行直接刷卡消费(未经支付宝/微信)
category原始只有交易摘要(如"快捷支付"),但已由spend_category按对方商户名重新归类,占比图用后者。 - 微信红包/转账/群收款这类对外转账:转给同事/家人/朋友的保留为真实收支并按对方名归类(需要在
categorize_local.py里配置这些人名对应的分类,否则归"其他");只有转给本人自己(LEDGERLITE_OWNER_NAME)的按账户互转剔除。 - 房租:招行"转账汇款"给房屋租赁/物业公司保留为支出归"居住";给本人/他人的大额转账仍按账户间转账剔除。
- 跨账单去重保留支付宝/微信那条,因平台记录带完整交易时间(招行/中行只有日期)。
- 时间窗口默认只在首次批量灌库时限制(
combine.py --start/--end);DB 长期累积,之后的邮件入库不再受窗口限制。
MIT,见 LICENSE。