之前用过一些记账 APP,但 APP 有很多缺点无法解决:
- 开屏广告/动画无法跳过,每次记账都要看好几秒动画,很影响体验
- 有可能泄露隐私,尤其是国产 APP,谁知道它拿着你的数据干啥了呢
- 有数据丢失风险,数据都在人家服务器上,他们跑路了就完犊子了
为此,我经过研究,决定使用 telegram bot + beancount + fava 进行记账。只需要在 telegram 上对机器人说句话,就能自动记录到自己的服务器上,并且随时可以在网页端查看统计图:
便捷操作,自己搭建,高效安全,功能完整!
注意:如果你在服务器上使用 telegram bot,需要确保你的服务器能翻墙!
Beancount 安装与配置
简介
Beancount 是个很强大的复式记账工具,使用纯文本记录并通过 python 生成报表,非常自由。它的消费记录长这样:
2022-07-04 * "午饭"
Expenses:Food:吃饭:午饭 20 CNY
Assets:现金
不仅可以记录何时花了多少钱,还可以分类(比如这里就是 Food 大类下的吃饭子类下的午饭类),还知道钱是从哪出的(现金、支付宝、银行卡等)。
这篇主要讲如何配置,就不详细介绍 beancount 的语法了,可以在这里查看官方使用教程 ,或者在这里看中文版教程 。
安装
它是个 python 库,通过 pip 安装:
pip install beancount
pip install fava
如果报错 fatal error: Python.h: No such file or directory
,则需要安装 python-dev:
apt-get install python3-dev
# 如果还无法解决,可以根据python版本指定python-dev的版本,比如你用python3.9,就是 apt-get install python3.9-dev
使用
找个地方新建一个文件,比如叫 account.beancount
,在里面写东西就行了。初始化可以这样:
;; -*- mode: beancount; coding: utf-8; fill-column: 400; -*-
option "title" "记叭账!"
option "operating_currency" "CNY"
option "operating_currency" "USD"
;初始化账户
1970-01-01 open Assets:Cash CNY
1970-01-01 open Equity:Init CNY
1970-01-01 * "账户初始化"
Assets:Cash 200 CNY
Equity:Init
2022-07-04 * "午饭"
Expenses:Food:吃饭:午饭 20 CNY
Assets:Cash
第一行指定了 utf-8 编码,似乎可有可无。后面几个 option 指定了标题和使用的币种。下一段初始化,开设了一个叫 Cash 的账户,表示现在我有 200 元现金。最后三行是一次消费记录 —— 午饭花了 20 元,现金支付。
然后打开可视化界面:
fava account.beancount
默认端口在 5000,只要打开 localhost:5000
就可以看到网页了。
以后记账则要修改 account.beancount
文件(也可以在网页左侧点击“编辑器”编辑这个文件)。这样很麻烦,所以我们要用 telegram bot 简化这一操作。
使用 telegram bot 实现随时记账
首先点击这里
添加 BotFather
为好友,然后对它发送 /newbot
,之后按提示操作便可得到一个 bot。记录下它给的 HTTP API 并妥善保管,这是操作 bot 的唯一凭证!
可以参考我的脚本,也可以自己写。就是把 telegram 上收到的消息 parse 成 beancount 的格式。只有一小段代码,就直接放在文末了。
现在,只要对 telegram bot 说 午饭 20 现金
,就可以自动转换成下面这一串,然后写入账本:
2022-07-04 * "午饭"
Expenses:Food:吃饭:午饭 20 CNY
Assets:Cash
可以在手机主屏幕添加一个 telegram 的小工具,更方便。
登录认证
我有一个域名,所以就把 localhost:5000
映射到公网了,可以随时查看报表。但财务状况涉及隐私,我们不希望别人能看见,于是想用 nginx 的 auth_basic 设置一个密码。
安装:
apt-get install apache2-utils
在要存放密码文件的地方生成密码(不要放在 root 目录下!否则 nginx 没有权限读取,会一直 500):
htpasswd -c /etc/nginx/auth_basic/passwdfile {username}
#执行上命令后会要求输入两次密码,./passwdfile 是在当前目录下创建密码文件passwdfile ,username即为需要设置的账号
改nginx配置,增加一个 server 进行映射,并启用验证(自己替换 []
中的内容):
server {
listen 443 ssl;
server_name [your url];
auth_basic "Please type your account name and password";
auth_basic_user_file [location of the passwdfile];
# 建议使用https,不然数据会在网上裸奔,也不安全
ssl_certificate /etc/letsencrypt/live/shadiao.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shadiao.online/privkey.pem;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4; #加密算法 ssl_protocols TLSv1 TLSv1.1 TLSv1.2; #安全链接可选的加密协议
ssl_prefer_server_ciphers on; #使用服务器端的首选算法
location / {
proxy_pass http://127.0.0.1:5000;
}
}
service nginx restart
大功告成!
附录
HTTP API 放在同一目录下的 password.py
中,token = 'xxxx'
。
Python 代码:
from telegram import Update
from telegram.ext import CallbackContext
from telegram.ext import Updater
from telegram.ext import CommandHandler
from telegram.ext import MessageHandler, Filters
import logging
from datetime import datetime
from urwid import str_util # 计算字符宽度
# 在telegram上输入'早饭',会自动转换成'Expenses:Food:吃饭:早饭'
# 这个表可以自己维护
translate = {
'早饭': 'Expenses:Food:吃饭:早饭',
'午饭': 'Expenses:Food:吃饭:午饭',
'晚饭': 'Expenses:Food:吃饭:晚饭',
'夜宵': 'Expenses:Food:吃饭:夜宵',
'a': 'Assets:Bank:OCBC-Frank',
'frank': 'Assets:Bank:OCBC-Frank',
'b': 'Assets:Cash-SGD',
'现金': 'Assets:Cash-SGD',
}
## utils
# 字符串的显示宽度
def str_width(text):
return sum([str_util.get_width(ord(c)) for c in text])
# 输入文本,返回beancount格式的字符串
# 第一个返回值是返回给用户的提示语,第二个返回值是beancount,要在外部写入文件
'''
2022-07-04 * "午饭"
Expenses:Food:吃饭:午饭 9.1 SGD
Assets:Bank:OCBC-Frank
'''
def parse_bill(text):
align = 32 # 钱数在32个字符处对齐
ingredients = text.split(' ')
if len(ingredients) == 3:
title, money, account = ingredients # 干了啥,多少钱,从哪出的
for i in [title, account]:
if i not in translate:
reply = '错误!“%s”不在翻译表里!' % i
return reply, ''
date = datetime.today().strftime('%Y-%m-%d')
line1 = f'{date} * "{title}"'
line2 = f' {translate[title]}'
width2 = str_width(line2)
padding2 = 32 - width2 # 空格补齐
while padding2 <= 0:
padding2 += 4 # 超长就多补齐4个
line2 = line2 + ' ' * padding2 + money + ' SGD'
line3 = f' {translate[account]}'
'''
# 只从一个账户支付,后面可以不写,beancount会自动计算
width3 = str_width(line3)
padding3 = 32 - width3 # 空格补齐
while padding3 <= 0:
padding3 += 4 # 超长就多补齐4个
line3 = line3 + ' -' * padding3 + money + ' SGD'
'''
parsed = line1 + '\n' + line2 + '\n' + line3
return '', parsed
else:
# todo
reply = '错误!ingredients的长度需要是3,可你是%d!' % len(ingredients)
return reply, ''
# text message
# 目前输入格式:项目 金额 账户,例如“午饭 10 现金”
def bill(update: Update, context: CallbackContext):
text = update.message.text # 早饭 6 frank
reply, parsed = parse_bill(text)
if parsed:
# 成了!
# 一定要用a,不然内容没了
with open('./account.beancount', 'a', encoding='utf-8') as file:
# 前后加换行
file.write('\n' + parsed + '\n')
reply = '成功写入以下内容:\n\n' + parsed
context.bot.send_message(chat_id=update.effective_chat.id, text=reply)
context.bot.send_message(chat_id=update.effective_chat.id, text=str(translate))
# the bot
import password
updater = Updater(token=password.token) # 在这里指定token,也可改成命令行输入
dispatcher = updater.dispatcher
# log
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO)
# turn functions to handlers here
start_handler = CommandHandler('start', start)
echo_handler = MessageHandler(Filters.text & (~Filters.command), bill)
# add handlers
dispatcher.add_handler(start_handler)
dispatcher.add_handler(echo_handler)
# start the bot
updater.start_polling()