Skill: baoxinwen/spending-log (version 1.0.0)

--- name: spending-log description: 个人支出记账。当用户提到记账、花了、消费、支出、账单、开销、统计消费、月度报表时触发。支持自然语言记账、自动分类、月度统计、预算提醒和月度报表生成。 version: 1.0.0 --- # 消费记录 个人支出记账技能，数据存储在 `data/expenses.json`。 ## 数据文件 - `data/expenses.json` — 所有支出记录，格式如下： ```json [ { "id": "becd186675a34bb0", "amount": 35.0, "category": "餐饮", "description": "午饭", "date": "2026-04-08", "timestamp": 1772952046769 } ] ``` - `data/config.json` — 配置（月预算等）： ```json { "monthly_budget": 2000 } ``` ## 分类 餐饮、交通、购物、娱乐、医疗、房租、日用、社交、其他 根据用户输入内容智能归类，无法判断时归为"其他"并标注。 ## 操作 ### 记账 用户用自然语言说，例如： - "午饭35" "今天打车花了25" "昨天买日用品60" "充话费50" 解析规则： 1. **金额**：从文本中提取数字，默认单位为元。支持大写数字（壹/贰/叁等）和口语化表达（"一块五" "两块钱"），自动转换为小数 2. **日期**：今天/昨天/前天/具体日期，省略则默认今天 3. **分类**：根据描述关键词自动归类 4. **描述**：提取金额和日期后的剩余内容作为描述 5. **多金额歧义**：文本中出现多个数字时，优先取最大值作为金额并标注；若明显不可能是金额（如日期中的数字）则跳过 解析后追加到 `data/expenses.json`，回复确认信息：金额、分类、描述、日期。 ### 删除记录 "删除刚才那条" / "删掉午餐那条" — 找到最近匹配的记录删除，确认后执行。 ### 修改记录 通过 `scripts/crud.py edit` 命令修改已有记录的金额、分类、描述或日期： ```bash python3 scripts/crud.py edit --id <记录ID> --amount 40 --description "午饭加菜" ``` ### 月度统计 "这个月花了多少" / "4月餐饮花了多少" / "本月交通支出" **必须使用 `scripts/query.py` 脚本计算，禁止心算。** ```bash # 查某分类当月支出 python3 scripts/query.py --category 餐饮 # 查当月总支出（省略分类） python3 scripts/query.py # 查指定月份 python3 scripts/query.py --month 2026-03 # 最近7天详细明细 python3 scripts/query.py --days 7 --format detail # 指定日期范围（场景：出差/旅行期间的花费） python3 scripts/query.py --from 2026-04-10 --to 2026-04-15 # 查全量数据 python3 scripts/query.py --all ``` 默认按日期倒序排列。`--days` 使用精确的日期边界（当天 00:00 起），避免模糊区间。`--month` 和 `--days` 同时传时优先 `--month`。 脚本会返回：笔数、合计金额、逐笔明细。直接将结果呈现给用户即可。 ### 预算检查 每次记账后自动检查：**当月累计支出是否超过月预算（默认2000元）**。超过时主动提醒用户。 用户也可主动问"预算还剩多少"来查询。 ### 月度报表 用户说"月度报表" / "生成报表" / "本月账单"时，使用 `scripts/report.py` 生成 HTML 报表。 ## 报表生成 ```bash python3 scripts/report.py --month 2026-04 python3 scripts/report.py --month 2026-04 --force # 强制重新生成 python3 scripts/report.py --month 2026-04 --csv ``` 将生成的 HTML 通过消息发送给用户（使用 `<qqmedia>` 标签发送文件）。同时备份到 [REDACTED_SECRET] 目录。 **禁止使用 canvas 展示报表。** 生成的报表包含： - 月度总支出和预算环形进度对比 - 分类支出 SVG 交互饼图（悬浮/点击可查看分类详情） - 分类详情卡片（带进度条，点击可联动筛选明细） - 每日支出 SVG 柱状趋势图（悬浮/点击显示日期金额，点击联动筛选明细） - 消费明细列表（支持按分类+日期交叉筛选，超过5笔自动折叠，可展开/收起） - 环比对比（有上月数据时自动显示，含各分类涨跌变化） - 月份快速导航（顶部箭头切换相邻月份报表） - 饼图/图例/趋势柱/分类卡片之间联动交互：点击任一分类元素自动筛选明细并滚动定位 报表支持 PC/手机自适应布局，自动为其他月份生成报表文件。 ## 月底提醒 通过 `openclaw cron` 设置每月1日早上8点自动生成上月报表并发送给用户。首次设置记账后触发配置。

path: README.md
content: # spending-log

个人消费记录 OpenClaw Skill。支持自然语言记账、自动分类、月度统计、预算提醒和精美 HTML 报表生成。

## 功能

- **自然语言记账** — "午饭35"、"昨天打车25"、"买书128"
- **自动分类** — 餐饮、交通、购物、娱乐、医疗、房租、日用、社交、其他
- **月度统计** — 按分类、日期、金额等多维度查询
- **预算提醒** — 每次记账自动检查，超支主动提醒
- **HTML 报表** — 交互式 SVG 饼图/柱状图，交叉筛选，环比对比，PC/手机自适应
- **CSV 导出** — 一键导出当月明细

## 安装

### 方式一：ClawHub（推荐）

```bash
openclaw skills install spending-log
```

### 方式二：手动安装

1. 克隆仓库到 OpenClaw skills 目录：

```bash
cd ~/.openclaw/skills
git clone https://github.com/baoxinwen/spending-log.git
```

2. 确保 Python 3 可用（脚本无需额外依赖）

3. 重启 OpenClaw 或执行 `openclaw skills reload`

## 使用

对 OpenClaw 说以下任意指令即可触发：

| 说法 | 动作 |
|------|------|
| 午饭35 | 记一笔餐饮支出 |
| 昨天打车花了25 | 记一笔昨天的交通支出 |
| 这个月花了多少 | 查看当月统计 |
| 4月餐饮花了多少 | 按分类查询 |
| 预算还剩多少 | 查看预算余额 |
| 生成报表 / 月度报表 | 生成 HTML 报表 |
| 删除刚才那条 | 删除最近一条记录 |
| 把午饭改成40 | 修改记录 |

## 项目结构

```
spending-log/
├── SKILL.md              # Skill 定义（OpenClaw 入口）
├── scripts/
│   ├── module.py         # 共享配置和数据层
│   ├── crud.py           # 增删改查操作
│   ├── query.py          # 统计查询
│   └── report.py         # HTML/CSV 报表生成
└── data/                 # 用户数据（运行时生成）
    ├── expenses.json     # 消费记录
    └── config.json       # 配置（月预算）
```

## 配置

月预算默认 2000 元，可通过修改 `data/config.json` 调整：

```json
{ "monthly_budget": 3000 }
```

## 报表预览

生成的 HTML 报表包含：

- **月度总览** — 总支出、预算环形进度、日均支出、最高单笔/最高消费日
- **支出分布** — SVG 交互饼图，hover/点击查看详情
- **分类详情** — 各分类金额、占比、进度条
- **每日趋势** — SVG 柱状图 + 日均参考线
- **消费明细** — 支持分类+日期交叉筛选，联动饼图/分类卡片/趋势图
- **环比对比** — 与上月各分类涨跌对比
- **月份导航** — 顶部箭头快速切换月份

## License

MIT


path: scripts/crud.py
content: #!/usr/bin/env python3
"""支出记录数据操作（增删改查）"""

import json
import argparse
import os
import time
import uuid
try:
    import fcntl
except ImportError:
    fcntl = None
from datetime import datetime

from module import CATEGORIES, load_data


def save_data(filepath, expenses):
    """写入临时文件再 rename，带文件锁防止并发写入"""
    lock_path = filepath + '.lock'
    lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
    try:
        if fcntl:
            fcntl.flock(lock_fd, fcntl.LOCK_EX)
        tmp_path = filepath + '.tmp'
        with open(tmp_path, 'w') as f:
            json.dump(expenses, f, ensure_ascii=False, indent=2)
        os.replace(tmp_path, filepath)
    finally:
        if fcntl:
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
        os.close(lock_fd)
        try:
            os.unlink(lock_path)
        except OSError:
            pass


def add_record(filepath, amount, category, description, date=None):
    """新增记录"""
    # 校验
    if float(amount) <= 0:
        print(json.dumps({'error': '金额必须为正数'}, ensure_ascii=False))
        return None
    if category not in CATEGORIES:
        print(json.dumps({'error': f'分类无效，可选: {", ".join(CATEGORIES)}'}, ensure_ascii=False))
        return None
    if date:
        try:
            datetime.strptime(date, '%Y-%m-%d')
        except ValueError:
            print(json.dumps({'error': '日期格式应为 YYYY-MM-DD'}, ensure_ascii=False))
            return None

    expenses = load_data(filepath)
    if date is None:
        date = datetime.now().strftime('%Y-%m-%d')

    ts = int(time.time() * 1000)
    record = {
        'id': uuid.uuid4().hex[:16],
        'amount': round(float(amount), 2),
        'category': category,
        'description': description,
        'date': date,
        'timestamp': ts
    }
    expenses.append(record)
    save_data(filepath, expenses)
    print(json.dumps(record, ensure_ascii=False))
    return record


def find_records(filepath, id=None, description=None, category=None, date=None, amount=None, recent=None):
    """查找记录，返回JSON列表"""
    expenses = load_data(filepath)
    results = []

    if id:
        results = [e for e in expenses if e['id'] == id]
    else:
        for e in expenses:
            match = True
            if description and description not in e.get('description', ''):
                match = False
            if category and e.get('category') != category:
                match = False
            if date and e.get('date') != date:
                match = False
            if amount is not None and abs(e.get('amount', 0) - float(amount)) > 0.001:
                match = False
            if match:
                results.append(e)
        # 默认按时间倒序
        results = sorted(results, key=lambda x: x['timestamp'], reverse=True)
        if recent:
            results = results[:recent]

    print(json.dumps(results, ensure_ascii=False))
    return results


def edit_record(filepath, record_id, amount=None, category=None, description=None, date=None):
    """修改记录"""
    expenses = load_data(filepath)

    found = None
    for e in expenses:
        if e['id'] == record_id:
            found = e
            break

    if not found:
        print(json.dumps({'error': f'未找到ID为 {record_id} 的记录'}, ensure_ascii=False))
        return None

    old = dict(found)
    errors = []
    
    if amount is not None:
        if float(amount) <= 0:
            errors.append('金额必须为正数')
        else:
            found['amount'] = round(float(amount), 2)
    if category is not None:
        if category and category not in CATEGORIES:
            errors.append(f'分类无效，可选: {", ".join(CATEGORIES)}')
        elif category:
            found['category'] = category
    if description is not None:
        found['description'] = description
    if date is not None:
        try:
            datetime.strptime(date, '%Y-%m-%d')
            found['date'] = date
        except ValueError:
            errors.append('日期格式应为 YYYY-MM-DD')
    
    if errors:
        # 回滚：整体替换而非逐 key 恢复
        found.clear()
        found.update(old)
        print(json.dumps({'error': '; '.join(errors)}, ensure_ascii=False))
        return None

    if found == old:
        print(json.dumps({'error': '没有做任何修改'}, ensure_ascii=False))
        return None

    save_data(filepath, expenses)
    print(json.dumps({'old': old, 'new': found}, ensure_ascii=False))
    return found


def delete_record(filepath, record_id):
    """删除记录"""
    expenses = load_data(filepath)
    before = len(expenses)
    expenses = [e for e in expenses if e['id'] != record_id]
    after = len(expenses)

    if before == after:
        print(json.dumps({'error': f'未找到ID为 {record_id} 的记录'}, ensure_ascii=False))
        return False

    save_data(filepath, expenses)
    print(json.dumps({'deleted': record_id}, ensure_ascii=False))
    return True


def main():
    parser = argparse.ArgumentParser(description='支出记录数据操作（增删改查）')
    parser.add_argument('--file', default='data/expenses.json', help='数据文件路径')

    sub = parser.add_subparsers(dest='command', required=True)

    # 新增
    add_p = sub.add_parser('add', help='新增记录')
    add_p.add_argument('--amount', type=float, required=True, help='金额')
    add_p.add_argument('--category', required=True, help='分类')
    add_p.add_argument('--description', required=True, help='描述')
    add_p.add_argument('--date', help='日期 YYYY-MM-DD（默认今天）')

    # 查找
    find_p = sub.add_parser('find', help='查找记录')
    find_p.add_argument('--id', help='按ID精确匹配')
    find_p.add_argument('--description', help='按描述模糊匹配')
    find_p.add_argument('--category', help='按分类精确匹配')
    find_p.add_argument('--date', help='按日期精确匹配')
    find_p.add_argument('--amount', type=float, help='按金额精确匹配')
    find_p.add_argument('--recent', type=int, help='最近N条（可与其它条件组合）')

    # 修改
    edit_p = sub.add_parser('edit', help='修改记录')
    edit_p.add_argument('--id', required=True, help='记录ID')
    edit_p.add_argument('--amount', type=float, help='新金额')
    edit_p.add_argument('--category', help='新分类')
    edit_p.add_argument('--description', help='新描述')
    edit_p.add_argument('--date', help='新日期 YYYY-MM-DD')

    # 删除
    del_p = sub.add_parser('delete', help='删除记录')
    del_p.add_argument('--id', required=True, help='记录ID')

    args = parser.parse_args()

    if args.command == 'add':
        add_record(args.file, args.amount, args.category, args.description, args.date)
    elif args.command == 'find':
        find_records(args.file, id=args.id, description=args.description,
                     category=args.category, date=args.date, amount=args.amount, recent=args.recent)
    elif args.command == 'edit':
        edit_record(args.file, args.id, amount=args.amount, category=args.category,
                    description=args.description, date=args.date)
    elif args.command == 'delete':
        delete_record(args.file, args.id)


if __name__ == '__main__':
    main()


path: scripts/module.py
content: """共享配置和工具函数，供 crud.py / query.py / report.py 统一引用"""

import json
import os
from collections import defaultdict

# ── 分类配置（统一 emoji / 颜色） ──────────────────────────────
CATEGORIES = ['餐饮', '交通', '购物', '娱乐', '医疗', '房租', '日用', '社交', '其他']

CAT_CONFIG = {
    '餐饮': {'emoji': '🍜', 'color': '#d4845a', 'bg': '#fdf5ee'},
    '交通': {'emoji': '🚗', 'color': '#7ba699', 'bg': '#eef5f2'},
    '购物': {'emoji': '🛒', 'color': '#c4899e', 'bg': '#faf0f3'},
    '娱乐': {'emoji': '🎮', 'color': '#d4a04a', 'bg': '#fdf8ed'},
    '医疗': {'emoji': '💊', 'color': '#8aaa7b', 'bg': '#f0f5ee'},
    '房租': {'emoji': '🏠', 'color': '#9b8ec4', 'bg': '#f3f1f8'},
    '日用': {'emoji': '🧴', 'color': '#6ba3a8', 'bg': '#edf5f5'},
    '社交': {'emoji': '🤝', 'color': '#c97070', 'bg': '#faf0f0'},
    '其他': {'emoji': '📌', 'color': '#a0978e', 'bg': '#f5f3f1'},
}

# ── Schema 校验字段 ──────────────────────────────────────────
REQUIRED_FIELDS = ('id', 'amount', 'category', 'description', 'date', 'timestamp')

# ── 数据加载 ──────────────────────────────────────────────────

def load_data(filepath):
    """加载 JSON 数据，带 schema 校验"""
    if not os.path.exists(filepath):
        return []
    with open(filepath, 'r') as f:
        data = json.load(f)
    if not isinstance(data, list):
        data = [data]
    # 校验每条记录
    for i, rec in enumerate(data):
        if not isinstance(rec, dict):
            raise ValueError(f"第 {i+1} 条记录不是对象: {rec!r}")
        for field in REQUIRED_FIELDS:
            if field not in rec:
                raise ValueError(f"第 {i+1} 条记录缺少字段 '{field}': {rec!r}")
    return data


# （统计工具已内联到 query.py / report.py 中，此处不再单独导出）


path: scripts/query.py
content: #!/usr/bin/env python3
"""支出统计计算脚本，读取数据做统计分析"""

import json
import argparse
import os
from collections import defaultdict
from datetime import datetime, timedelta, time as dt_time

from module import load_data


def filter_by_date(expenses, args):
    """按日期条件筛选"""
    if args.days:
        cutoff = datetime.combine(datetime.now() - timedelta(days=args.days - 1), dt_time.min).strftime('%Y-%m-%d')
        expenses = [e for e in expenses if e['date'] >= cutoff]
    elif args.from_date or args.to_date:
        if args.from_date:
            expenses = [e for e in expenses if e['date'] >= args.from_date]
        if args.to_date:
            expenses = [e for e in expenses if e['date'] <= args.to_date]
    elif not args.all:
        # 默认取最新月份
        latest = max(e['date'][:7] for e in expenses)
        expenses = [e for e in expenses if e['date'].startswith(latest)]
    return expenses


def filter_by_category(expenses, category):
    if category:
        expenses = [e for e in expenses if e['category'] == category]
    return expenses


def sort_expenses(expenses, sort_key):
    if sort_key == 'date':
        expenses = sorted(expenses, key=lambda x: x['date'])
    elif sort_key == 'date_desc':
        expenses = sorted(expenses, key=lambda x: x['date'], reverse=True)
    elif sort_key == 'amount':
        expenses = sorted(expenses, key=lambda x: x['amount'])
    elif sort_key == 'amount_desc':
        expenses = sorted(expenses, key=lambda x: x['amount'], reverse=True)
    return expenses


def get_date_range(expenses):
    """从记录中提取日期范围（不受排序影响）"""
    dates = sorted(set(e['date'] for e in expenses))
    if len(dates) > 1:
        return f"{dates[0]} ~ {dates[-1]}"
    return dates[0] if dates else ""


def format_summary(expenses, category=None, month=None):
    """简洁汇总模式"""
    if not expenses:
        print("没有匹配的记录")
        return

    total = sum(e['amount'] for e in expenses)
    by_cat = defaultdict(float)
    for e in expenses:
        by_cat[e['category']] += e['amount']

    date_range = get_date_range(expenses)
    label = category or '全部'
    month_label = month or expenses[0]['date'][:7]

    print(f"=== {month_label} {label}支出 ({date_range}) ===")
    print(f"共 {len(expenses)} 笔，合计 ¥{total:.2f}")

    if not category:
        print()
        for cat, amount in sorted(by_cat.items(), key=lambda x: x[1], reverse=True):
            pct = amount / total * 100 if total > 0 else 0
            print(f"  {cat}: ¥{amount:.2f} ({pct:.1f}%)")


def format_detail(expenses, category=None, month=None):
    """详细明细模式"""
    if not expenses:
        print("没有匹配的记录")
        return

    total = sum(e['amount'] for e in expenses)
    date_range = get_date_range(expenses)
    label = category or '全部'
    month_label = month or expenses[0]['date'][:7]

    print(f"=== {month_label} {label}支出 ({date_range}) ===")
    print(f"共 {len(expenses)} 笔，合计 ¥{total:.2f}")
    print()

    by_date = defaultdict(list)
    for e in expenses:
        by_date[e['date']].append(e)

    for date in sorted(by_date.keys()):
        day_total = sum(e['amount'] for e in by_date[date])
        print(f"📅 {date}  小计 ¥{day_total:.2f}")
        for e in by_date[date]:
            print(f"    {e['category']}  {e.get('description', '')}  ¥{e['amount']:.2f}")

    print()
    print(f"💰 合计: ¥{total:.2f}")


def format_json(expenses):
    """JSON输出模式"""
    print(json.dumps(expenses, ensure_ascii=False, indent=2))


def main():
    parser = argparse.ArgumentParser(description='支出统计计算')
    parser.add_argument('--file', default='data/expenses.json', help='数据文件路径')
    parser.add_argument('--all', action='store_true', help='查询全量数据（不限月份）')
    parser.add_argument('--month', help='指定月份，格式 YYYY-MM')
    parser.add_argument('--from', dest='from_date', help='起始日期 YYYY-MM-DD')
    parser.add_argument('--to', dest='to_date', help='结束日期 YYYY-MM-DD')
    parser.add_argument('--days', type=int, help='最近N天')
    parser.add_argument('--category', help='分类名称（精确匹配）')
    parser.add_argument('--limit', type=int, help='返回记录条数')
    parser.add_argument('--sort', choices=['date', 'date_desc', 'amount', 'amount_desc'], default='date_desc', help='排序方式（默认按日期倒序）')
    parser.add_argument('--format', choices=['summary', 'detail', 'json'], default='summary', help='输出格式')

    args = parser.parse_args()

    expenses = load_data(args.file)
    if not expenses:
        print("暂无数据")
        return

    if args.month and args.days:
        import warnings
        warnings.warn('--month 和 --days 同时指定，将优先使用 --month', stacklevel=1)
    if args.month:
        try:
            datetime.strptime(args.month, '%Y-%m')
        except ValueError:
            print("月份格式应为 YYYY-MM")
            return
        expenses = [e for e in expenses if e['date'].startswith(args.month)]
    elif not args.all:
        expenses = filter_by_date(expenses, args)
    expenses = filter_by_category(expenses, args.category)
    expenses = sort_expenses(expenses, args.sort)

    if args.limit:
        expenses = expenses[:args.limit]

    if args.format == 'summary':
        format_summary(expenses, args.category, args.month)
    elif args.format == 'detail':
        format_detail(expenses, args.category, args.month)
    elif args.format == 'json':
        format_json(expenses)


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)