Skill: dyby99-gif/smart-ledger (version 1.0.0)

--- name: smart-ledger version: 1.0.0 description: > 智能收支追踪器 — 通过自然语言快速记账，自动分类，生成日/周/月报告和趋势分析。 支持中文自然语言输入("今天午饭花了35元")，数据本地JSON存储保护隐私。 适用于个人财务管理、预算追踪和消费分析场景。 emoji: 💰 category: finance tags: - finance - expense-tracker - budget - chinese - natural-language requires: python: ">=3.9" os: - macos - linux - windows author: dyby99 license: MIT --- # Smart Expense Tracker - 智能收支追踪器 通过自然语言快速记账，自动生成财务报告的智能工具。 ## 功能特性 - 📝 **自然语言记账**: "今天午饭花了35元"、"收到工资8000" - 🏷️ **智能自动分类**: 自动识别餐饮、交通、购物等分类 - 📊 **多维度报告**: 日报、周报、月报、趋势分析 - 🔒 **本地数据存储**: JSON文件存储，保护隐私 - 🌐 **中英文支持**: 支持中英文自然语言输入 - ⚠️ **消费异常检测**: 自动识别异常消费模式 ## 快速开始 ### 添加收支记录 ```bash # 记录支出 python scripts/expense-tracker.py add "今天午饭花了35元" python scripts/expense-tracker.py add "昨天打车去公司28块" python scripts/expense-tracker.py add "买衣服花了299元" # 记录收入 python scripts/expense-tracker.py add "今天发工资8000元" python scripts/expense-tracker.py add "收到红包200" ``` ### 查看记录 ```bash # 列出最近7天记录 python scripts/expense-tracker.py list # 列出最近30天记录 python scripts/expense-tracker.py list 30 # 只显示支出 python scripts/expense-tracker.py list 7 expense ``` ### 生成报告 ```bash # 日报 python scripts/report-generator.py daily python scripts/report-generator.py daily 2024-01-15 # 周报 python scripts/report-generator.py weekly python scripts/report-generator.py weekly 1 # 上周 # 月报 python scripts/report-generator.py monthly python scripts/report-generator.py monthly 1 # 上月 # 趋势分析 python scripts/report-generator.py trend 30 ``` ### 查看统计 ```bash # 最近30天收支摘要 python scripts/expense-tracker.py summary # 最近90天摘要 python scripts/expense-tracker.py summary 90 # 查看分类列表 python scripts/expense-tracker.py categories ``` ## 项目结构 ``` smart-ledger/ ├── SKILL.md # 本文件 ├── scripts/ │ ├── expense-tracker.py # 核心记账逻辑 │ └── report-generator.py # 报告生成器 └── assets/ └── categories.json # 分类配置 ``` ## 数据存储 - **路径**: `~/.openclaw/workspace/data/expenses/expenses.json` - **格式**: JSON - **隐私**: 纯本地存储，不上传云端 数据结构: ```json { "expenses": [ { "id": "a1b2c3d4", "date": "2024-01-15", "type": "expense", "amount": 35.00, "category": "餐饮", "note": "午饭", "raw_text": "今天午饭花了35元", "created_at": "2024-01-15T12:30:00" } ], "categories": { "income": ["工资", "奖金", "投资", ...], "expense": ["餐饮", "交通", "购物", ...] } } ``` ## 自然语言支持示例 ### 时间表达 - "今天..." - 今天 - "昨天..." - 昨天 - "前天..." - 前天 - "2024-01-15..." - 指定日期 ### 金额表达 - "花了35元" - "花了35块" - "35元" - "收入5000" ### 自动分类关键词 | 分类 | 触发关键词 | |------|-----------| | 餐饮 | 吃饭、午饭、晚饭、外卖、奶茶、咖啡、火锅 | | 交通 | 地铁、公交、打车、滴滴、加油、停车、高铁 | | 购物 | 购物、买衣服、鞋子、包包、化妆品、淘宝、京东 | | 娱乐 | 电影、游戏、充值、会员、旅游、KTV | | 居住 | 房租、房贷、水电、物业费、装修 | | 工资 | 工资、薪水、发工资 | | 奖金 | 奖金、年终奖、分红 | ## 分类配置 分类配置存储在 `assets/categories.json`，包含: - **收入分类**: 工资、奖金、投资、兼职、红包、退款、其他收入 - **支出分类**: 餐饮、交通、购物、娱乐、居住、医疗、教育、通讯、人情、其他支出 - **预算预警**: 每个分类可设置预算阈值 - **关键词映射**: 自定义分类识别关键词 ### 自定义分类 编辑 `assets/categories.json` 添加自定义分类和关键词。 ## 报告类型 ### 日报 - 当日收支总览 - 分类明细 - 交易记录列表 ### 周报 - 本周收支总览 - 日均支出 - 每日明细 - TOP支出分类 ### 月报 - 月度收支总览 - 预算执行情况 - 周度趋势 - 分类统计 - 日均支出 ### 趋势分析 - 消费趋势（上升/下降/稳定） - 异常消费检测（超过均值2倍） - 环比变化分析 - 消费建议 ## 高级用法 ### 删除记录 ```bash # 删除指定ID的记录 python scripts/expense-tracker.py delete a1b2c3d4 ``` ### JSON格式输出 ```bash # 报告输出JSON格式（便于程序处理） python scripts/report-generator.py monthly 0 json ``` ### 批量导入 可通过直接编辑 `expenses.json` 文件实现批量导入。 ## 使用建议 1. **每日记账**: 养成每天记录的习惯，可以在每天晚上花2分钟回顾当天支出 2. **及时分类**: 如果自动分类不准确，可以在备注中添加更具体的关键词 3. **定期复盘**: 每周查看周报，每月查看月报，了解自己的消费模式 4. **设置预算**: 在 `categories.json` 中为各分类设置预算预警值 ## 故障排除 ### 无法识别金额 - 确保金额格式正确，如 "35元"、"35块" - 尝试明确表达："花了35元" ### 分类不准确 - 在描述中添加更多关键词，如 "淘宝买衣服" 会比 "买衣服" 更准确 - 可在 `categories.json` 中添加自定义关键词 ### 数据文件损坏 - 数据文件是纯JSON，可用文本编辑器修复 - 建议定期备份 `~/.openclaw/workspace/data/expenses/expenses.json` ## 扩展开发 ### 添加新的自动分类规则 编辑 `scripts/expense-tracker.py` 中的 `KEYWORD_MAPPING` 字典: ```python KEYWORD_MAPPING = { # 现有规则... "新关键词": "目标分类", " another keyword": "Category Name" } ``` ### 集成其他工具 `expense-tracker.py` 可作为模块导入: ```python from scripts.expense_tracker import ExpenseTracker tracker = ExpenseTracker() record = tracker.add("今天买书花了50元") print(record) ``` ## 技术规格 - **Python版本**: 3.9+ - **依赖**: 无外部依赖，仅使用标准库 - **编码**: UTF-8 - **数据格式**: JSON ## 许可证 MIT License

path: assets/categories.json
content: {
  "income": {
    "categories": [
      {
        "name": "工资",
        "keywords": ["工资", "薪水", "月薪", "年薪", "发钱", " paycheck"],
        "icon": "💰",
        "color": "#52c41a"
      },
      {
        "name": "奖金",
        "keywords": ["奖金", "年终奖", "分红", "提成", "绩效", " incentive"],
        "icon": "🎁",
        "color": "#73d13d"
      },
      {
        "name": "投资",
        "keywords": ["投资", "股票", "基金", "理财", "利息", "股息", " investment"],
        "icon": "📈",
        "color": "#389e0d"
      },
      {
        "name": "兼职",
        "keywords": ["兼职", "副业", "外快", " freelance", " side job"],
        "icon": "💼",
        "color": "#237804"
      },
      {
        "name": "红包",
        "keywords": ["红包", "转账", "礼金", "红包", " red packet"],
        "icon": "🧧",
        "color": "#f5222d"
      },
      {
        "name": "退款",
        "keywords": ["退款", "退货", "返现", " rebate", " refund"],
        "icon": "↩️",
        "color": "#faad14"
      },
      {
        "name": "其他收入",
        "keywords": ["其他", "misc"],
        "icon": "💵",
        "color": "#8c8c8c"
      }
    ],
    "default": "其他收入"
  },
  "expense": {
    "categories": [
      {
        "name": "餐饮",
        "keywords": ["吃饭", "午饭", "晚饭", "早餐", "宵夜", "餐厅", "外卖", "奶茶", "咖啡", "火锅", "烧烤", "超市", "水果", "买菜", "food", "eat", "lunch", "dinner", "breakfast"],
        "icon": "🍔",
        "color": "#fa8c16",
        "budget_warning": 2000
      },
      {
        "name": "交通",
        "keywords": ["地铁", "公交", "打车", "滴滴", "出租车", "加油", "停车", "高铁", "火车", "飞机", "修车", "保养", "车险", "transport", "subway", "bus", "taxi"],
        "icon": "🚗",
        "color": "#1890ff",
        "budget_warning": 800
      },
      {
        "name": "购物",
        "keywords": ["购物", "买衣服", "鞋子", "包包", "化妆品", "护肤品", "数码", "手机", "电脑", "家电", "淘宝", "京东", "拼多多", "天猫", "shopping", "buy", "clothes", "shoes"],
        "icon": "🛍️",
        "color": "#eb2f96",
        "budget_warning": 1500
      },
      {
        "name": "娱乐",
        "keywords": ["电影", "游戏", "充值", "会员", "视频", "音乐", "KTV", "旅游", "旅行", "酒店", "entertainment", "movie", "game", "travel"],
        "icon": "🎮",
        "color": "#722ed1",
        "budget_warning": 500
      },
      {
        "name": "居住",
        "keywords": ["房租", "房贷", "水电", "物业费", "装修", "家具", "维修", "rent", "utilities"],
        "icon": "🏠",
        "color": "#13c2c2",
        "budget_warning": 3000
      },
      {
        "name": "医疗",
        "keywords": ["医院", "看病", "买药", "体检", "保险", "medical", "hospital", "medicine"],
        "icon": "🏥",
        "color": "#cf1322",
        "budget_warning": 500
      },
      {
        "name": "教育",
        "keywords": ["学费", "培训", "课程", "书本", "考试", "education", "course", "book"],
        "icon": "📚",
        "color": "#096dd9",
        "budget_warning": 1000
      },
      {
        "name": "通讯",
        "keywords": ["话费", "宽带", "流量", "phone", "internet", "communication"],
        "icon": "📱",
        "color": "#531dab",
        "budget_warning": 200
      },
      {
        "name": "人情",
        "keywords": ["礼物", "请客", "份子钱", "聚餐", "gift", "treat", "party"],
        "icon": "🎁",
        "color": "#c41d7f",
        "budget_warning": 500
      },
      {
        "name": "其他支出",
        "keywords": ["其他", "misc", "other"],
        "icon": "📝",
        "color": "#8c8c8c"
      }
    ],
    "default": "其他支出"
  },
  "settings": {
    "currency": "CNY",
    "currency_symbol": "¥",
    "date_format": "%Y-%m-%d",
    "monthly_budget": 5000,
    "enable_notifications": true,
    "notification_threshold": 0.8
  }
}


path: scripts/expense-tracker.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
智能收支追踪器 - 核心逻辑
支持自然语言记账、自动分类、数据持久化
"""

import json
import os
import re
import sys
import tempfile
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple

# 数据存储路径
DATA_DIR = Path(os.path.expanduser("~/.openclaw/workspace/data/expenses"))
DATA_FILE = DATA_DIR / "expenses.json"

# 隐私保护：确保数据文件权限仅限当前用户
_PRIVATE_PERMS = 0o600  # rw-------
_PRIVATE_DIR_PERMS = 0o700  # rwx------

# 默认分类配置
DEFAULT_CATEGORIES = {
    "income": ["工资", "奖金", "投资", "兼职", "红包", "退款", "其他收入"],
    "expense": ["餐饮", "交通", "购物", "娱乐", "居住", "医疗", "教育", "通讯", "人情", "其他支出"]
}

# 关键词映射到分类
KEYWORD_MAPPING = {
    # 收入
    "工资": "工资", "薪水": "工资", "发钱": "工资",
    "奖金": "奖金", "年终奖": "奖金", "分红": "奖金",
    "投资": "投资", "股票": "投资", "基金": "投资", "理财": "投资", "利息": "投资",
    "兼职": "兼职", "副业": "兼职", "外快": "兼职",
    "红包": "红包", "转账": "红包",
    "退款": "退款", "退货": "退款",
    
    # 餐饮
    "吃饭": "餐饮", "午饭": "餐饮", "晚饭": "餐饮", "早餐": "餐饮", "宵夜": "餐饮",
    "餐厅": "餐饮", "外卖": "餐饮", "奶茶": "餐饮", "咖啡": "餐饮", "火锅": "餐饮",
    "烧烤": "餐饮", "超市": "餐饮", "水果": "餐饮", "买菜": "餐饮",
    
    # 交通
    "地铁": "交通", "公交": "交通", "打车": "交通", "滴滴": "交通", "出租车": "交通",
    "加油": "交通", "停车": "交通", "高铁": "交通", "火车": "交通", "飞机": "交通",
    "修车": "交通", "保养": "交通", "车险": "交通",
    
    # 购物
    "购物": "购物", "买衣服": "购物", "鞋子": "购物", "包包": "购物", "化妆品": "购物",
    "护肤品": "购物", "数码": "购物", "手机": "购物", "电脑": "购物", "家电": "购物",
    "淘宝": "购物", "京东": "购物", "拼多多": "购物", "天猫": "购物",
    
    # 娱乐
    "电影": "娱乐", "游戏": "娱乐", "充值": "娱乐", "会员": "娱乐", "视频": "娱乐",
    "音乐": "娱乐", "KTV": "娱乐", "旅游": "娱乐", "旅行": "娱乐", "酒店": "娱乐",
    
    # 居住
    "房租": "居住", "房贷": "居住", "水电": "居住", "物业费": "居住", "装修": "居住",
    "家具": "居住", "维修": "居住",
    
    # 医疗
    "医院": "医疗", "看病": "医疗", "买药": "医疗", "体检": "医疗", "保险": "医疗",
    
    # 教育
    "学费": "教育", "培训": "教育", "课程": "教育", "书本": "教育", "买书": "教育",
    "考试": "教育", "书": "教育",
    
    # 通讯
    "话费": "通讯", "宽带": "通讯", "流量": "通讯",
    
    # 人情
    "礼物": "人情", "请客": "人情", "份子钱": "人情", "聚餐": "人情"
}


class ExpenseTracker:
    """收支追踪器主类"""
    
    def __init__(self):
        self._ensure_data_dir()
        self.data = self._load_data()
    
    def _ensure_data_dir(self):
        """确保数据目录存在，并设置严格权限（仅当前用户可访问）"""
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        try:
            os.chmod(str(DATA_DIR), _PRIVATE_DIR_PERMS)
        except OSError:
            pass  # Windows 等不支持 chmod 的系统跳过
    
    def _load_data(self) -> Dict:
        """加载数据文件"""
        if DATA_FILE.exists():
            try:
                with open(DATA_FILE, 'r', encoding='utf-8') as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError) as e:
                print(f"⚠️ 数据文件读取失败，创建新文件: {e}")
        return {"expenses": [], "categories": DEFAULT_CATEGORIES}
    
    def _save_data(self):
        """原子保存数据到文件（先写临时文件再rename，防止写入中断导致数据丢失）"""
        try:
            fd, tmp_path = tempfile.mkstemp(
                dir=str(DATA_DIR), suffix='.tmp', prefix='expenses_'
            )
            try:
                with os.fdopen(fd, 'w', encoding='utf-8') as f:
                    json.dump(self.data, f, ensure_ascii=False, indent=2)
                os.replace(tmp_path, str(DATA_FILE))
                # 隐私保护：确保数据文件仅当前用户可读写
                try:
                    os.chmod(str(DATA_FILE), _PRIVATE_PERMS)
                except OSError:
                    pass
            except Exception:
                # 清理临时文件
                if os.path.exists(tmp_path):
                    os.unlink(tmp_path)
                raise
        except IOError as e:
            print(f"❌ 数据保存失败: {e}")
            sys.exit(1)
    
    def _parse_amount(self, text: str) -> Optional[float]:
        """从文本中提取金额"""
        # 先移除日期部分，避免误匹配日期中的数字
        cleaned = re.sub(r'\d{4}-\d{1,2}-\d{1,2}', '', text)
        cleaned = re.sub(r'\d{1,2}月\d{1,2}[日号]', '', cleaned)

        patterns = [
            # 优先匹配带单位的：35元、35块、35.5元
            r'(\d+\.?\d*)\s*[元块]',
            # 匹配"花了/收入了 + 数字"的模式（数字必须跟在动词后面）
            r'(?:花了|收入了|赚了|收到|花|赚)\s*(\d+\.?\d*)',
        ]
        for pattern in patterns:
            match = re.search(pattern, cleaned)
            if match:
                try:
                    amount = float(match.group(1))
                    if amount > 0:
                        return amount
                except ValueError:
                    continue
        return None
    
    def _detect_type(self, text: str) -> str:
        """检测收支类型（收入/支出）"""
        income_keywords = ['收入', '赚到', '收到', '发工资', '奖金', '退款', '红包', '赚了']
        expense_keywords = ['花', '买', '吃', '付', '消费', '支出', '花了', '买了', '吃了', '付了']
        
        text_lower = text.lower()
        
        for kw in income_keywords:
            if kw in text_lower:
                return 'income'
        
        for kw in expense_keywords:
            if kw in text_lower:
                return 'expense'
        
        # 默认支出
        return 'expense'
    
    def _auto_categorize(self, text: str, trans_type: str) -> str:
        """自动分类"""
        # 先尝试关键词匹配
        for keyword, category in KEYWORD_MAPPING.items():
            if keyword in text:
                return category
        
        # 默认分类
        if trans_type == 'income':
            return "其他收入"
        return "其他支出"
    
    def _parse_date(self, text: str) -> str:
        """解析日期，默认为今天"""
        today = datetime.now()
        
        if '昨天' in text:
            date = today - timedelta(days=1)
        elif '前天' in text:
            date = today - timedelta(days=2)
        elif '今天' in text or '刚才' in text or '刚刚' in text:
            date = today
        else:
            # 尝试匹配 YYYY-MM-DD
            match = re.search(r'(\d{4})-(\d{1,2})-(\d{1,2})', text)
            if match:
                year = match.group(1)
                month = match.group(2)
                day = match.group(3)
                try:
                    # 验证日期合法性
                    datetime(int(year), int(month), int(day))
                    return f"{year}-{int(month):02d}-{int(day):02d}"
                except ValueError:
                    pass

            # 尝试匹配 MM-DD（无年份）
            match = re.search(r'(?<!\d)(\d{1,2})-(\d{1,2})(?!\d)', text)
            if match:
                month = match.group(1)
                day = match.group(2)
                try:
                    datetime(today.year, int(month), int(day))
                    return f"{today.year}-{int(month):02d}-{int(day):02d}"
                except ValueError:
                    pass
            date = today
        
        return date.strftime("%Y-%m-%d")
    
    def _extract_note(self, text: str, amount: float, category: str) -> str:
        """提取备注信息"""
        note = text
        # 移除日期格式
        note = re.sub(r'\d{4}-\d{1,2}-\d{1,2}', '', note)
        note = re.sub(r'\d{1,2}月\d{1,2}[日号]', '', note)
        # 移除金额相关文本
        note = re.sub(r'\d+\.?\d*\s*[元块]', '', note)
        # 移除常见时间词
        note = re.sub(r'(今天|昨天|前天|刚才|刚刚)', '', note)
        # 移除常见动词
        note = re.sub(r'(花了|收入了|赚了|收到了?)', '', note)
        # 清理多余空格和标点
        note = re.sub(r'\s+', ' ', note).strip()

        if not note:
            note = category

        return note[:50]  # 限制长度
    
    def add(self, text: str) -> Dict:
        """
        添加一笔收支记录
        
        Args:
            text: 自然语言描述，如"今天午饭花了35元"
        
        Returns:
            记录详情字典
        """
        # 解析金额
        amount = self._parse_amount(text)
        if amount is None:
            raise ValueError("无法识别金额，请使用格式如：花了35元")
        if amount > 9999999:
            raise ValueError("金额不能超过9,999,999")
        if amount <= 0:
            raise ValueError("金额必须大于0")
        
        # 检测类型
        trans_type = self._detect_type(text)
        
        # 自动分类
        category = self._auto_categorize(text, trans_type)
        
        # 解析日期
        date = self._parse_date(text)
        
        # 提取备注
        note = self._extract_note(text, amount, category)
        
        # 创建记录 — 使用UUID短ID，避免删除后ID冲突
        record = {
            "id": uuid.uuid4().hex[:8],
            "date": date,
            "type": trans_type,
            "amount": round(amount, 2),
            "category": category,
            "note": note,
            "raw_text": text,
            "created_at": datetime.now().isoformat()
        }
        
        # 添加到数据
        self.data["expenses"].append(record)
        self._save_data()
        
        return record
    
    def list(self, days: int = 7, trans_type: Optional[str] = None) -> List[Dict]:
        """
        列出收支记录
        
        Args:
            days: 最近多少天，默认7天
            trans_type: 类型筛选，'income'/'expense'/None
        
        Returns:
            记录列表
        """
        cutoff_date = datetime.now() - timedelta(days=days)
        cutoff_str = cutoff_date.strftime("%Y-%m-%d")
        
        records = []
        for r in self.data["expenses"]:
            if r["date"] >= cutoff_str:
                if trans_type is None or r["type"] == trans_type:
                    records.append(r)
        
        # 按日期倒序
        records.sort(key=lambda x: x["date"], reverse=True)
        return records
    
    def delete(self, record_id: str) -> bool:
        """删除指定记录"""
        for i, r in enumerate(self.data["expenses"]):
            if str(r["id"]) == str(record_id):
                del self.data["expenses"][i]
                self._save_data()
                return True
        return False
    
    def get_summary(self, days: int = 30) -> Dict:
        """获取收支摘要"""
        records = self.list(days=days)
        
        income_total = sum(r["amount"] for r in records if r["type"] == "income")
        expense_total = sum(r["amount"] for r in records if r["type"] == "expense")
        
        # 分类统计
        category_stats = {}
        for r in records:
            cat = r["category"]
            if cat not in category_stats:
                category_stats[cat] = {"income": 0, "expense": 0}
            category_stats[cat][r["type"]] += r["amount"]
        
        return {
            "period_days": days,
            "income": round(income_total, 2),
            "expense": round(expense_total, 2),
            "balance": round(income_total - expense_total, 2),
            "record_count": len(records),
            "category_breakdown": category_stats
        }
    
    def get_categories(self) -> Dict:
        """获取分类列表"""
        return self.data.get("categories", DEFAULT_CATEGORIES)
    
    def add_category(self, trans_type: str, category: str):
        """添加自定义分类"""
        if trans_type not in ["income", "expense"]:
            raise ValueError("类型必须是 income 或 expense")
        
        if "categories" not in self.data:
            self.data["categories"] = DEFAULT_CATEGORIES.copy()
        
        if category not in self.data["categories"][trans_type]:
            self.data["categories"][trans_type].append(category)
            self._save_data()


def main():
    """命令行入口"""
    tracker = ExpenseTracker()
    
    if len(sys.argv) < 2:
        print("智能收支追踪器")
        print("用法: python expense-tracker.py <命令> [参数]")
        print("")
        print("命令:")
        print("  add <描述>     - 添加记录，如: add '今天午饭花了35元'")
        print("  list [天数]    - 列出记录，默认最近7天")
        print("  summary [天数] - 收支摘要，默认最近30天")
        print("  delete <ID>    - 删除指定记录")
        print("  categories     - 显示分类列表")
        print("")
        sys.exit(0)
    
    command = sys.argv[1]
    
    try:
        if command == "add":
            if len(sys.argv) < 3:
                print("❌ 请提供描述，如: python expense-tracker.py add '今天午饭35元'")
                sys.exit(1)
            
            text = " ".join(sys.argv[2:])
            record = tracker.add(text)
            type_emoji = "💰" if record["type"] == "income" else "💸"
            type_text = "收入" if record["type"] == "income" else "支出"
            print(f"✅ 已记录{type_emoji} {type_text}")
            print(f"   金额: ¥{record['amount']}")
            print(f"   分类: {record['category']}")
            print(f"   日期: {record['date']}")
            print(f"   备注: {record['note']}")
        
        elif command == "list":
            days = int(sys.argv[2]) if len(sys.argv) > 2 else 7
            records = tracker.list(days=days)
            
            if not records:
                print(f"📭 最近{days}天没有记录")
                return
            
            print(f"📋 最近{days}天收支记录 ({len(records)}条)")
            print("-" * 60)
            
            current_date = ""
            for r in records:
                if r["date"] != current_date:
                    current_date = r["date"]
                    print(f"\n📅 {current_date}")
                
                type_emoji = "💰" if r["type"] == "income" else "💸"
                print(f"  [{r['id']}] {type_emoji} ¥{r['amount']:8.2f}  {r['category']:8s}  {r['note']}")
        
        elif command == "summary":
            days = int(sys.argv[2]) if len(sys.argv) > 2 else 30
            summary = tracker.get_summary(days=days)
            
            print(f"📊 最近{days}天收支摘要")
            print("=" * 50)
            print(f"💰 总收入: ¥{summary['income']:.2f}")
            print(f"💸 总支出: ¥{summary['expense']:.2f}")
            balance_emoji = "📈" if summary['balance'] >= 0 else "📉"
            print(f"{balance_emoji} 净余额: ¥{summary['balance']:.2f}")
            print(f"📝 记录数: {summary['record_count']} 条")
            
            if summary['category_breakdown']:
                print("\n📁 分类统计:")
                for cat, amounts in sorted(summary['category_breakdown'].items()):
                    if amounts['expense'] > 0:
                        print(f"  💸 {cat}: ¥{amounts['expense']:.2f}")
                    if amounts['income'] > 0:
                        print(f"  💰 {cat}: ¥{amounts['income']:.2f}")
        
        elif command == "delete":
            if len(sys.argv) < 3:
                print("❌ 请提供记录ID")
                sys.exit(1)
            
            record_id = sys.argv[2]
            if tracker.delete(record_id):
                print(f"✅ 已删除记录 #{record_id}")
            else:
                print(f"❌ 未找到记录 #{record_id}")
        
        elif command == "categories":
            cats = tracker.get_categories()
            print("📁 分类列表")
            print("\n收入分类:")
            for c in cats["income"]:
                print(f"  💰 {c}")
            print("\n支出分类:")
            for c in cats["expense"]:
                print(f"  💸 {c}")
        
        else:
            print(f"❌ 未知命令: {command}")
            print("可用命令: add, list, summary, delete, categories")
    
    except ValueError as e:
        print(f"❌ 错误: {e}")
        sys.exit(1)
    except Exception as e:
        print(f"❌ 发生错误: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/report-generator.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
智能收支报告生成器
支持日/周/月/年报告，趋势分析，异常检测
"""

import json
import os
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple

# 数据路径
DATA_DIR = Path(os.path.expanduser("~/.openclaw/workspace/data/expenses"))
DATA_FILE = DATA_DIR / "expenses.json"


class ReportGenerator:
    """报告生成器"""
    
    def __init__(self):
        self.data = self._load_data()
    
    def _load_data(self) -> Dict:
        """加载数据"""
        if DATA_FILE.exists():
            try:
                with open(DATA_FILE, 'r', encoding='utf-8') as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass
        return {"expenses": []}
    
    def _get_records_in_range(self, start_date: str, end_date: str) -> List[Dict]:
        """获取日期范围内的记录"""
        records = []
        for r in self.data.get("expenses", []):
            if start_date <= r["date"] <= end_date:
                records.append(r)
        return records
    
    def _get_date_range(self, period: str, offset: int = 0) -> Tuple[str, str]:
        """
        获取日期范围
        
        Args:
            period: 'day', 'week', 'month', 'year'
            offset: 偏移量，0表示当前周期，1表示上一个周期
        """
        today = datetime.now()
        
        if period == 'day':
            target = today - timedelta(days=offset)
            start = end = target.strftime("%Y-%m-%d")
        
        elif period == 'week':
            # 获取本周一
            monday = today - timedelta(days=today.weekday(), weeks=offset)
            sunday = monday + timedelta(days=6)
            start = monday.strftime("%Y-%m-%d")
            end = sunday.strftime("%Y-%m-%d")
        
        elif period == 'month':
            # 获取目标月
            year = today.year
            month = today.month - offset
            while month <= 0:
                year -= 1
                month += 12
            
            start = f"{year}-{month:02d}-01"
            # 计算月末
            if month == 12:
                next_month = datetime(year + 1, 1, 1)
            else:
                next_month = datetime(year, month + 1, 1)
            last_day = (next_month - timedelta(days=1)).day
            end = f"{year}-{month:02d}-{last_day}"
        
        elif period == 'year':
            year = today.year - offset
            start = f"{year}-01-01"
            end = f"{year}-12-31"
        
        else:
            raise ValueError(f"不支持的周期类型: {period}")
        
        return start, end
    
    def generate_daily_report(self, date: Optional[str] = None) -> Dict:
        """生成日报"""
        if date is None:
            date = datetime.now().strftime("%Y-%m-%d")
        
        records = self._get_records_in_range(date, date)
        
        income = sum(r["amount"] for r in records if r["type"] == "income")
        expense = sum(r["amount"] for r in records if r["type"] == "expense")
        
        # 分类统计
        category_expense = defaultdict(float)
        category_income = defaultdict(float)
        
        for r in records:
            if r["type"] == "expense":
                category_expense[r["category"]] += r["amount"]
            else:
                category_income[r["category"]] += r["amount"]
        
        return {
            "type": "daily",
            "date": date,
            "income": round(income, 2),
            "expense": round(expense, 2),
            "balance": round(income - expense, 2),
            "record_count": len(records),
            "top_expenses": sorted(category_expense.items(), key=lambda x: x[1], reverse=True)[:5],
            "top_incomes": sorted(category_income.items(), key=lambda x: x[1], reverse=True)[:3],
            "records": records
        }
    
    def generate_weekly_report(self, offset: int = 0) -> Dict:
        """生成周报"""
        start, end = self._get_date_range('week', offset)
        records = self._get_records_in_range(start, end)
        
        # 按天统计
        daily_data = defaultdict(lambda: {"income": 0, "expense": 0})
        category_expense = defaultdict(float)
        category_income = defaultdict(float)
        
        for r in records:
            daily_data[r["date"]][r["type"]] += r["amount"]
            if r["type"] == "expense":
                category_expense[r["category"]] += r["amount"]
            else:
                category_income[r["category"]] += r["amount"]
        
        income = sum(r["amount"] for r in records if r["type"] == "income")
        expense = sum(r["amount"] for r in records if r["type"] == "expense")
        
        return {
            "type": "weekly",
            "start_date": start,
            "end_date": end,
            "income": round(income, 2),
            "expense": round(expense, 2),
            "balance": round(income - expense, 2),
            "record_count": len(records),
            "daily_breakdown": dict(daily_data),
            "top_expenses": sorted(category_expense.items(), key=lambda x: x[1], reverse=True)[:5],
            "top_incomes": sorted(category_income.items(), key=lambda x: x[1], reverse=True)[:3]
        }
    
    def generate_monthly_report(self, offset: int = 0) -> Dict:
        """生成月报"""
        start, end = self._get_date_range('month', offset)
        records = self._get_records_in_range(start, end)
        
        income = sum(r["amount"] for r in records if r["type"] == "income")
        expense = sum(r["amount"] for r in records if r["type"] == "expense")
        
        # 按周统计
        weekly_data = defaultdict(lambda: {"income": 0, "expense": 0})
        category_expense = defaultdict(float)
        category_income = defaultdict(float)
        
        for r in records:
            date = datetime.strptime(r["date"], "%Y-%m-%d")
            week_num = date.isocalendar()[1]
            weekly_data[f"第{week_num}周"][r["type"]] += r["amount"]
            
            if r["type"] == "expense":
                category_expense[r["category"]] += r["amount"]
            else:
                category_income[r["category"]] += r["amount"]
        
        # 计算日均支出
        days_in_month = int(end.split("-")[2])
        avg_daily_expense = expense / days_in_month if days_in_month > 0 else 0
        
        return {
            "type": "monthly",
            "start_date": start,
            "end_date": end,
            "income": round(income, 2),
            "expense": round(expense, 2),
            "balance": round(income - expense, 2),
            "record_count": len(records),
            "avg_daily_expense": round(avg_daily_expense, 2),
            "weekly_breakdown": dict(weekly_data),
            "top_expenses": sorted(category_expense.items(), key=lambda x: x[1], reverse=True)[:5],
            "top_incomes": sorted(category_income.items(), key=lambda x: x[1], reverse=True)[:3]
        }
    
    def generate_trend_analysis(self, days: int = 30) -> Dict:
        """
        生成趋势分析报告
        
        包含:
        - 支出趋势（上升/下降）
        - 异常消费检测
        - 消费建议
        """
        end_date = datetime.now()
        start_date = end_date - timedelta(days=days)
        
        records = self._get_records_in_range(
            start_date.strftime("%Y-%m-%d"),
            end_date.strftime("%Y-%m-%d")
        )
        
        # 按周分组
        weekly_expense = defaultdict(float)
        weekly_income = defaultdict(float)
        
        for r in records:
            date = datetime.strptime(r["date"], "%Y-%m-%d")
            week_key = date.strftime("%Y-W%W")
            if r["type"] == "expense":
                weekly_expense[week_key] += r["amount"]
            else:
                weekly_income[week_key] += r["amount"]
        
        # 计算周环比变化
        weeks = sorted(weekly_expense.keys())
        trend = "stable"
        if len(weeks) >= 2:
            recent = weekly_expense[weeks[-1]]
            previous = weekly_expense[weeks[-2]]
            if previous > 0:
                change_pct = (recent - previous) / previous * 100
                if change_pct > 20:
                    trend = "increasing"
                elif change_pct < -20:
                    trend = "decreasing"
        
        # 异常检测（超过平均值2倍）
        if weekly_expense:
            avg_weekly = sum(weekly_expense.values()) / len(weekly_expense)
            anomalies = [
                {"week": w, "amount": a, "deviation": round((a - avg_weekly) / avg_weekly * 100, 1)}
                for w, a in weekly_expense.items() if a > avg_weekly * 2
            ]
        else:
            anomalies = []
        
        return {
            "type": "trend",
            "period_days": days,
            "expense_trend": trend,
            "weekly_expenses": dict(weekly_expense),
            "weekly_incomes": dict(weekly_income),
            "anomalies": anomalies,
            "avg_weekly_expense": round(sum(weekly_expense.values()) / len(weekly_expense), 2) if weekly_expense else 0
        }
    
    def format_report(self, report: Dict, format_type: str = "text") -> str:
        """格式化报告为可读文本"""
        if format_type == "json":
            return json.dumps(report, ensure_ascii=False, indent=2)
        
        lines = []
        
        # 标题
        if report["type"] == "daily":
            lines.append(f"📅 收支日报 ({report['date']})")
        elif report["type"] == "weekly":
            lines.append(f"📊 收支周报 ({report['start_date']} ~ {report['end_date']})")
        elif report["type"] == "monthly":
            lines.append(f"📈 收支月报 ({report['start_date'][:7]})")
        elif report["type"] == "trend":
            lines.append(f"📉 消费趋势分析 (近{report['period_days']}天)")
        
        lines.append("=" * 50)
        
        # 收支概览
        if "income" in report:
            balance = report.get('balance', report['income'] - report['expense'])
            balance_emoji = "📈" if balance >= 0 else "📉"
            
            lines.append(f"💰 总收入: ¥{report['income']:.2f}")
            lines.append(f"💸 总支出: ¥{report['expense']:.2f}")
            lines.append(f"{balance_emoji} 净余额: ¥{balance:.2f}")
            lines.append(f"📝 记录数: {report.get('record_count', 0)} 条")
            
            if "avg_daily_expense" in report:
                lines.append(f"📊 日均支出: ¥{report['avg_daily_expense']:.2f}")
        
        # 趋势信息
        if report["type"] == "trend":
            trend_text = {
                "increasing": "⚠️ 支出呈上升趋势",
                "decreasing": "✅ 支出呈下降趋势",
                "stable": "➡️ 支出保持稳定"
            }
            lines.append(f"\n{trend_text.get(report['expense_trend'], '')}")
            
            if report.get("anomalies"):
                lines.append("\n⚠️ 异常消费周:")
                for a in report["anomalies"]:
                    lines.append(f"  {a['week']}: ¥{a['amount']:.2f} (+{a['deviation']}%)")
        
        # 分类详情
        if "top_expenses" in report and report["top_expenses"]:
            lines.append("\n🏆 支出TOP分类:")
            for cat, amount in report["top_expenses"]:
                lines.append(f"  💸 {cat}: ¥{amount:.2f}")
        
        if "top_incomes" in report and report["top_incomes"]:
            lines.append("\n💰 收入TOP分类:")
            for cat, amount in report["top_incomes"]:
                lines.append(f"  💰 {cat}: ¥{amount:.2f}")
        
        return "\n".join(lines)


def main():
    """命令行入口"""
    generator = ReportGenerator()
    
    if len(sys.argv) < 2:
        print("智能收支报告生成器")
        print("用法: python report-generator.py <报告类型> [参数]")
        print("")
        print("报告类型:")
        print("  daily [日期]        - 日报，如: daily 2024-01-15")
        print("  weekly [周偏移]     - 周报，0=本周, 1=上周")
        print("  monthly [月偏移]    - 月报，0=本月, 1=上月")
        print("  trend [天数]        - 趋势分析，默认30天")
        print("")
        sys.exit(0)
    
    command = sys.argv[1]
    
    try:
        if command == "daily":
            date = sys.argv[2] if len(sys.argv) > 2 else None
            report = generator.generate_daily_report(date)
        
        elif command == "weekly":
            offset = int(sys.argv[2]) if len(sys.argv) > 2 else 0
            report = generator.generate_weekly_report(offset)
        
        elif command == "monthly":
            offset = int(sys.argv[2]) if len(sys.argv) > 2 else 0
            report = generator.generate_monthly_report(offset)
        
        elif command == "trend":
            days = int(sys.argv[2]) if len(sys.argv) > 2 else 30
            report = generator.generate_trend_analysis(days)
        
        else:
            print(f"❌ 未知报告类型: {command}")
            print("可用类型: daily, weekly, monthly, trend")
            sys.exit(1)
        
        # 输出报告
        format_type = sys.argv[-1] if sys.argv[-1] in ["text", "json"] else "text"
        print(generator.format_report(report, format_type))
    
    except Exception as e:
        print(f"❌ 生成报告失败: {e}")
        import traceback
        traceback.print_exc()
        sys.exit(1)


if __name__ == "__main__":
    main()


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