
Nl2ledger
- 7 installs
- 10 repo stars
- Updated July 30, 2026
- deusyu/rainman-skills
Parse natural-language Chinese or English expense descriptions and append structured entries to a QianJi CSV ledger.
About
Turns natural-language spending descriptions into categorized bookkeeping entries appended to a QianJi CSV. A developer uses it for quick natural-language expense and income tracking.
- Parses mixed Chinese/English expense phrases
- Appends categorized entries to a QianJi CSV ledger
Nl2ledger by the numbers
- 7 all-time installs (skills.sh)
- Ranked #826 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/deusyu/rainman-skills --skill nl2ledgerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 10 |
| Last updated | July 30, 2026 |
| Repository | deusyu/rainman-skills ↗ |
What it does
Parse natural-language Chinese or English expense descriptions and append structured entries to a QianJi CSV ledger.
Files
nl2ledger — Natural Language Bookkeeping Skill
You are a bookkeeping assistant. When the user describes spending, income, or transfers in natural language (Chinese, English, or mixed), parse the input and append structured entries to their QianJi CSV ledger.
References
- Category keyword mapping:
references/category_map.md - CSV format specification:
references/csv_schema.md - Append script:
scripts/append_entry.py
Workflow
Step 1: Locate the CSV File
Use Glob to find QianJi_*.csv in the project root directory. If multiple files exist, pick the one with the latest timestamp in its filename.
If no CSV file is found, tell the user and ask them to specify the path.
Step 2: Parse the User's Input
Extract from the natural language input:
- Amount (REQUIRED): a number, optionally followed by 块/元/yuan/rmb/¥. If no amount is found, ask the user.
- Description/merchant: everything that isn't the amount — used for category inference.
- Type: default
支出. Detect keywords: 收入/income/salary/工资 →收入; 转账/transfer →转账; 退款/refund →退款. - Time: default is current time (
YYYY-MM-DD HH:MM:SS). Parse relative expressions: 昨天/yesterday, 上午/下午 + time, last Friday, etc. - Account: default
工资卡. Override per special rules (see Step 3).
Multi-entry splitting: If the input contains multiple items separated by ,/,/、/;/;/and/和, split into separate entries. Examples:
- "午饭14,咖啡15,打车20" → 3 entries
- "lunch 25, coffee 18" → 2 entries
Step 3: Classify Each Entry
Refer to references/category_map.md to map the description to a category and subcategory.
Special rules to always apply:
1. 娱乐/桑拿按摩: set 账户1=金色印象, 标签=10号 2. 转账: set 分类=其它, 账户2=现金 or 中转账户 3. 收入 from 工资: set 分类=工资 4. 收入 from 公积金: set 分类=公积金
If the category is ambiguous, present 2-3 candidates and ask the user to choose.
Step 4: Preview and Confirm
CRITICAL: ALWAYS show a preview and wait for explicit user confirmation before writing anything.
For a single entry, show:
将记录以下条目:
时间: 2026-02-10 14:30:00
分类: 餐饮 > 三餐
类型: 支出
金额: 14.0 CNY
账户: 工资卡
备注: 午饭麦当劳14块
确认记录?For multiple entries, show a compact table:
识别到 3 条记录:
#1 餐饮 > 三餐 | 支出 | 14.0 CNY | 工资卡 | 午饭
#2 餐饮 > 咖啡 | 支出 | 15.0 CNY | 工资卡 | 咖啡
#3 交通 | 支出 | 20.0 CNY | 工资卡 | 打车
全部确认?或输入编号修改(如 "#2 改为零食")The user can:
- Confirm (确认/ok/yes/y/好/行) → proceed to write
- Modify (#2 改为零食 / change #2 to snack) → adjust and re-preview
- Cancel (取消/cancel/算了) → abort without writing
Step 5: Write Entries
For each confirmed entry, run the append script:
python3 scripts/append_entry.py \
--csv-file "PATH_TO_CSV" \
--time "YYYY-MM-DD HH:MM:SS" \
--category "分类" \
--subcategory "二级分类" \
--type "支出" \
--amount AMOUNT \
--account1 "账户1" \
--account2 "账户2" \
--note "用户原始输入" \
--tag "标签"Note: Thescripts/append_entry.pypath is relative to this skill's directory. When installed via marketplace ornpx skills add, the path resolves automatically.
The script outputs the generated ID on success.
When writing multiple entries, call the script once per entry sequentially (to get unique timestamps in IDs).
Step 6: Confirm Results
After writing, display a summary:
已记录 3 条!
#1 qj1770123456789154321 — 餐饮/三餐 14.0
#2 qj1770123456790162845 — 餐饮/咖啡 15.0
#3 qj1770123456791178923 — 交通 20.0Edge Cases
| Situation | Action |
|---|---|
| No amount in input | Ask user for the amount — it's the only required field that can't be inferred |
| Ambiguous category | Show 2-3 candidates, let user pick |
| Relative time expressions | Parse them: 昨天=yesterday, 上周五=last Friday, 上午10点=10:00 AM today |
| Note contains commas/quotes | The Python csv.writer handles RFC 4180 escaping automatically |
| CSV file not found | Tell user and ask for the file path |
| Multiple CSV files | Use the one with the latest timestamp in its filename |
Category Keyword Mapping
Based on common expense categories for QianJi users.
Quick Reference Table
| Keywords / Patterns | 分类 | 二级分类 | Special Rules |
|---|---|---|---|
| 麦当劳, 肯德基, 饿了么, 美团外卖, 外卖, 午饭, 晚饭, 早饭, 午餐, 晚餐, 早餐, 吃饭, meal, lunch, dinner, breakfast, 叮咚买菜, 赛百味, 老乡鸡, 杨国福, 麻辣烫, 西贝, 萨莉亚, 米粉, 饺子, 牛肉饼, 食堂 | 餐饮 | 三餐 | |
| 咖啡, coffee, Manner, 瑞幸, 星巴克, Starbucks, latte, 拿铁, 美式 | 餐饮 | 咖啡 | |
| 零食, 友宝, 自动售货机, vending, 7-11, 711, 便利店, 物美, 便利蜂, 霸王茶姬, 奶茶, 烧饼, snack | 餐饮 | 零食 | |
| 打车, 滴滴, 高德, taxi, cab, 出租车, 顺风车, 哈啰, 一卡通, 地铁, 公交, 骑车 | 交通 | ||
| 充电, 小绿人, 电驴 | 交通 | 电驴充电 | |
| 按摩, 足疗, 金色印象, 桑拿, spa, SPA, 80分钟 | 娱乐 | 桑拿按摩 | 账户1=金色印象, 标签=10号 |
| 日用品, 宜家, IKEA, 京东日用, 日常用品, household | 居家生活 | 日用品 | |
| 水费, 电费, 水电, electricity, water | 居家生活 | 水电 | |
| 话费, 电信, 联通, 移动, 网费, phone bill | 居家生活 | 话费网费 | |
| 房租, 自如, rent | 住房 | 房租 | |
| 理发, 美发, haircut | 清洁护理 | 美发 | |
| 旅行, 旅游, 火车票, 机票, 携程, 飞猪, 同程, 中铁, 徒步, hiking, travel, hotel, 酒店 | 旅行 | ||
| 红包, 请客, 送礼, 礼物, gift, treat, 群收款 | 人情 | 请客送礼 | |
| 工资, salary, 代发工资 | 工资 | 类型=收入 | |
| 公积金, housing fund | 公积金 | 类型=收入 | |
| 服装, 衣服, clothing, 鞋, shoes, H&M | 服饰 | ||
| 会员, 订阅, subscription, Xmind, 域名, domain, 得到 | 自我成长 | 付费会员 | |
| 健身, gym, 乐刻, 运动, exercise, 户外 | 运动 | ||
| 快递, 物流, delivery, 菜鸟 | 快递 | ||
| 转账, transfer | 其它 | 类型=转账, 账户2=现金 or 中转账户 | |
| 退款, refund | 其它 | 类型=退款 |
Special Rules
1. 娱乐/桑拿按摩
When category is 娱乐/桑拿按摩, ALWAYS apply:
- 账户1 =
金色印象(NOT 工资卡) - 标签 =
10号 - Default amount: 206.0
2. 转账 (Transfer)
When type is 转账:
- 分类 =
其它 - 二级分类 = empty
- 账户2 =
现金(for credit card / person payments) or中转账户(for loan repayments)
3. 收入 (Income)
Income keywords and their categories:
- 工资/salary → 工资 (income)
- 公积金 → 公积金 (income)
- 退款/refund → use original category if known, otherwise 其它 (type=退款)
- 充值/top-up to 金色印象 → 其它 (income), 账户1=金色印象
4. Default Values
- 类型: 支出 (expense) unless income/transfer/refund keywords detected
- 账户1: 工资卡 (except 娱乐/桑拿按摩 → 金色印象)
- 账户2: empty (except 转账)
- 币种: CNY
- 记账者: 小明
5. Ambiguous Cases
If the input doesn't clearly match any category, suggest 2-3 candidates and let the user choose. Common ambiguities:
- 京东/JD purchases → could be 居家生活/日用品 or 其它 (ask user)
- 美团 → could be 餐饮/三餐 or 其它 (ask user)
- Large round amounts with no context → likely 其它
6. Merchant Name Shortcuts
Users commonly use short names. Map these to their categories:
- 麦当劳/麦记/M记/金拱门 → 餐饮/三餐
- KFC/肯德基 → 餐饮/三餐
- 瑞幸/luckin → 餐饮/咖啡
- Manner → 餐饮/咖啡
- 星巴克/Starbucks/星爸爸 → 餐饮/咖啡
- 滴滴/DD → 交通
- 高德 → 交通
- 宜家/IKEA → 居家生活/日用品
钱迹 CSV Schema
Encoding
- Character encoding: UTF-8 with BOM (
EF BB BFat byte 0) - Line ending:
\n(LF) - Trailing newline: The file does NOT end with a trailing newline. The last data row ends at EOF with no
\nafter it. - Quoting: RFC 4180 — fields containing commas, double quotes, or newlines are wrapped in double quotes. Embedded double quotes are escaped as
"".
Columns (18 fields)
| # | Column Name | Type | Required | Default | Notes |
|---|---|---|---|---|---|
| 1 | ID | string | Yes | Generated | qj + 13-digit ms timestamp + 6-digit random |
| 2 | 时间 | datetime | Yes | Now | Format: YYYY-MM-DD HH:MM:SS |
| 3 | 分类 | string | Yes | — | Primary category |
| 4 | 二级分类 | string | No | empty | Subcategory |
| 5 | 类型 | string | Yes | 支出 | One of: 支出, 收入, 转账, 退款 |
| 6 | 金额 | decimal | Yes | — | Always one decimal place (e.g. 14.0, 3.96) |
| 7 | 币种 | string | Yes | CNY | Always CNY |
| 8 | 账户1 | string | Yes | 工资卡 | Primary account |
| 9 | 账户2 | string | No | empty | Only for 转账 type |
| 10 | 备注 | string | No | empty | User's original input text |
| 11 | 已报销 | string | No | empty | |
| 12 | 手续费 | string | No | empty | |
| 13 | 优惠券 | string | No | empty | |
| 14 | 记账者 | string | Yes | 小明 | Customizable — set your own name |
| 15 | 账单标记 | string | No | empty | 不计收支 for balance adjustments only |
| 16 | 标签 | string | No | empty | |
| 17 | 账单图片 | string | No | empty | |
| 18 | 关联账单 | string | No | empty |
ID Generation Algorithm
ID = "qj" + MILLISECOND_TIMESTAMP + RANDOM_6_DIGITSMILLISECOND_TIMESTAMP: 13-digit Unix epoch in milliseconds (e.g.1770045906717)RANDOM_6_DIGITS: random integer in range100000..199999
Example: qj1770045906717197829
Total length: 2 + 13 + 6 = 21 characters.
When generating multiple entries at once, use the current timestamp for the first entry, then increment by 1 millisecond for each subsequent entry. Each entry gets its own random suffix.
Amount Format
- Always display with at least one decimal place:
14.0,3.96,206.0 - Python:
f"{amount:.1f}"when the amount is a round number; otherwise preserve the original precision up to 2 decimals. - In practice, use
f"{amount:.2f}".rstrip('0')then ensure at least one decimal digit remains. Simplest: always format with one decimal if it's a whole number, or two decimals otherwise.
Appending a Row
When appending a new row to the CSV:
1. The file currently does NOT end with \n, so prepend \n before the new row. 2. The new row itself should NOT have a trailing \n. 3. Use Python's csv.writer with lineterminator='\n' to generate the row string, then strip any trailing newline before appending.
ID,时间,分类,二级分类,类型,金额,币种,账户1,账户2,备注,已报销,手续费,优惠券,记账者,账单标记,标签,账单图片,关联账单
qj1770045906717197829,2026-02-10 12:30:00,餐饮,三餐,支出,25.0,CNY,工资卡,,午饭麦当劳,,,,小明,,,,
qj1770045907218143562,2026-02-10 14:15:00,餐饮,咖啡,支出,18.0,CNY,工资卡,,瑞幸拿铁,,,,小明,,,,
qj1770045908319165478,2026-02-10 18:45:00,交通,,支出,32.5,CNY,工资卡,,滴滴打车回家,,,,小明,,,,
qj1770045909420187634,2026-02-10 20:00:00,居家生活,日用品,支出,89.0,CNY,工资卡,,宜家收纳盒,,,,小明,,,,#!/usr/bin/env python3
"""Append a single ledger entry to a QianJi CSV file."""
import argparse
import csv
import io
import os
import random
import time
def generate_id():
"""Generate a QianJi-style ID: 'qj' + 13-digit ms timestamp + 6-digit random."""
ms_timestamp = int(time.time() * 1000)
rand_suffix = random.randint(100000, 199999)
return f"qj{ms_timestamp}{rand_suffix}"
def format_amount(amount):
"""Format amount: whole numbers get 1 decimal, others keep up to 2."""
if amount == int(amount):
return f"{amount:.1f}"
else:
return f"{amount:.2f}".rstrip("0")
def build_row(args):
"""Build a CSV row list from parsed arguments."""
entry_id = generate_id()
return [
entry_id,
args.time,
args.category,
args.subcategory or "",
args.type,
format_amount(args.amount),
"CNY",
args.account1,
args.account2 or "",
args.note or "",
"", # 已报销
"", # 手续费
"", # 优惠券
args.recorder,
args.bill_mark or "",
args.tag or "",
"", # 账单图片
"", # 关联账单
], entry_id
def row_to_csv_string(row):
"""Convert a row list to a CSV string (no trailing newline)."""
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(row)
return buf.getvalue().rstrip("\n")
def append_to_file(csv_file, csv_line):
"""Append a CSV line to the file, handling the no-trailing-newline convention."""
# Check if file ends with newline
needs_newline = True
if os.path.getsize(csv_file) > 0:
with open(csv_file, "rb") as f:
f.seek(-1, 2)
needs_newline = f.read(1) != b"\n"
with open(csv_file, "a", encoding="utf-8", newline="") as f:
if needs_newline:
f.write("\n")
f.write(csv_line)
def main():
parser = argparse.ArgumentParser(description="Append entry to QianJi CSV")
parser.add_argument("--csv-file", required=True, help="Path to the CSV file")
parser.add_argument("--time", required=True, help="Timestamp (YYYY-MM-DD HH:MM:SS)")
parser.add_argument("--category", required=True, help="Primary category (分类)")
parser.add_argument("--subcategory", default="", help="Subcategory (二级分类)")
parser.add_argument("--type", default="支出", help="Type: 支出/收入/转账/退款")
parser.add_argument("--amount", type=float, required=True, help="Amount")
parser.add_argument("--account1", default="工资卡", help="Primary account (账户1)")
parser.add_argument("--account2", default="", help="Secondary account (账户2)")
parser.add_argument("--note", default="", help="Note/memo (备注)")
parser.add_argument("--recorder", default="小明", help="Recorder (记账者)")
parser.add_argument("--bill-mark", default="", help="Bill mark (账单标记)")
parser.add_argument("--tag", default="", help="Tag (标签)")
args = parser.parse_args()
row, entry_id = build_row(args)
csv_line = row_to_csv_string(row)
append_to_file(args.csv_file, csv_line)
print(entry_id)
if __name__ == "__main__":
main()