refactor: 重构项目结构
- 将 Python 代码移至 analyzer/ 目录(含 venv) - 拆分 Go 服务器代码为模块化结构: - config/: 配置加载 - model/: 请求/响应模型 - service/: 业务逻辑 - handler/: API处理器 - 添加 .gitignore 文件 - 删除旧的独立脚本文件
This commit is contained in:
168
analyzer/category.py
Normal file
168
analyzer/category.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
交易分类配置和推断逻辑
|
||||
|
||||
配置文件: config/category.yaml
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# =============================================================================
|
||||
# 加载 YAML 配置
|
||||
# =============================================================================
|
||||
|
||||
CONFIG_FILE = Path(__file__).parent / "config" / "category.yaml"
|
||||
|
||||
def load_config():
|
||||
"""加载分类配置"""
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def reload_config():
|
||||
"""重新加载配置(配置文件修改后调用)"""
|
||||
global _config
|
||||
_config = load_config()
|
||||
|
||||
# 初始化加载
|
||||
_config = load_config()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 配置访问函数
|
||||
# =============================================================================
|
||||
|
||||
def get_platform_merchants() -> list[str]:
|
||||
"""获取需要重新分类的平台型商家列表"""
|
||||
return _config.get("平台商家", [])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 分类推断函数
|
||||
# =============================================================================
|
||||
|
||||
def infer_category(merchant: str, product: str, income_expense: str) -> tuple[str, bool]:
|
||||
"""
|
||||
根据商户名称和商品信息推断交易分类
|
||||
|
||||
Args:
|
||||
merchant: 交易对方/商户名称
|
||||
product: 商品说明
|
||||
income_expense: 收/支 ("收入" 或 "支出")
|
||||
|
||||
Returns:
|
||||
(分类名称, 是否确定) - 如果无法确定分类,第二个值为 False
|
||||
"""
|
||||
merchant_lower = merchant.lower()
|
||||
product_lower = product.lower()
|
||||
combined = merchant_lower + " " + product_lower
|
||||
|
||||
# 收入类型处理
|
||||
if income_expense == "收入":
|
||||
for category, keywords in _config["收入分类"].items():
|
||||
for kw in keywords:
|
||||
if kw.lower() in combined:
|
||||
return category, True
|
||||
return _config["默认分类"]["收入"], False # 无法确定
|
||||
|
||||
# 支出类型处理 - 按优先级顺序匹配
|
||||
for category, keywords in _config["支出分类"].items():
|
||||
for kw in keywords:
|
||||
if kw.lower() in combined:
|
||||
return category, True
|
||||
|
||||
# 转账类特殊处理
|
||||
if "转账" in combined:
|
||||
return _config["默认分类"]["支出"], False # 转账无法确定具体分类
|
||||
|
||||
return _config["默认分类"]["支出"], False # 无法确定
|
||||
|
||||
|
||||
def get_all_categories() -> list:
|
||||
"""获取所有支出分类列表"""
|
||||
return list(_config["支出分类"].keys()) + [_config["默认分类"]["支出"]]
|
||||
|
||||
|
||||
def get_all_income_categories() -> list:
|
||||
"""获取所有收入分类列表"""
|
||||
return list(_config["收入分类"].keys())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 分类重推断(用于修正原始分类错误)
|
||||
# =============================================================================
|
||||
|
||||
def reclassify_if_needed(original_category: str, merchant: str, product: str,
|
||||
income_expense: str) -> tuple[str, bool, int]:
|
||||
"""
|
||||
检查是否需要重新分类,如果推断出更准确的分类则返回新分类
|
||||
|
||||
Args:
|
||||
original_category: 原始分类
|
||||
merchant: 交易对方
|
||||
product: 商品说明
|
||||
income_expense: 收/支
|
||||
|
||||
Returns:
|
||||
(分类名称, 是否已修改, 复核等级)
|
||||
|
||||
复核等级:
|
||||
0 = 无需复核(分类确定或原分类可信)
|
||||
1 = 低优先级复核(分类被调整,需确认调整是否正确)
|
||||
2 = 高优先级复核(完全无法判断,原分类也是默认分类)
|
||||
"""
|
||||
new_category, is_certain = infer_category(merchant, product, income_expense)
|
||||
|
||||
default_category = _config["默认分类"].get(
|
||||
"支出" if income_expense == "支出" else "收入"
|
||||
)
|
||||
|
||||
# 判断原分类是否为默认/笼统分类
|
||||
is_original_default = (
|
||||
original_category == default_category or
|
||||
original_category in ["其他", "其他支出", "其他收入", ""]
|
||||
)
|
||||
|
||||
# 情况1:推断出具体分类,且与原分类不同 → 调整分类,低优先级复核
|
||||
if new_category != default_category and new_category != original_category:
|
||||
return new_category, True, 1 # 低优先级复核
|
||||
|
||||
# 情况2:推断失败,但原分类是具体分类 → 信任原分类,无需复核
|
||||
if not is_certain and not is_original_default:
|
||||
return original_category, False, 0 # 信任支付宝原分类
|
||||
|
||||
# 情况3:推断失败,原分类也是默认分类 → 高优先级复核
|
||||
if not is_certain and is_original_default:
|
||||
return new_category, False, 2 # 高优先级复核
|
||||
|
||||
# 情况4:分类确定且未调整 → 无需复核
|
||||
return original_category, False, 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 调试工具
|
||||
# =============================================================================
|
||||
|
||||
def test_category(merchant: str, product: str = "", income_expense: str = "支出") -> None:
|
||||
"""测试分类推断结果"""
|
||||
category, is_certain = infer_category(merchant, product, income_expense)
|
||||
review_level = "无" if is_certain else "高 ⚠️"
|
||||
print(f"商户: {merchant}")
|
||||
print(f"商品: {product}")
|
||||
print(f"收支: {income_expense}")
|
||||
print(f"分类: {category}")
|
||||
print(f"复核: {review_level}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== 分类测试 ===\n")
|
||||
|
||||
test_cases = [
|
||||
("luckin coffee", "订单付款", "支出"),
|
||||
("美团", "万达影城-美团App", "支出"),
|
||||
("美团", "茶百道-美团App", "支出"),
|
||||
("美团", "美宜佳-美团App", "支出"),
|
||||
]
|
||||
|
||||
for merchant, product, ie in test_cases:
|
||||
test_category(merchant, product, ie)
|
||||
|
||||
104
analyzer/clean_bill.py
Normal file
104
analyzer/clean_bill.py
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
账单清理统一入口
|
||||
自动识别微信或支付宝账单,调用对应的清理逻辑
|
||||
|
||||
用法: python clean_bill.py <输入文件> [输出文件] [日期筛选选项]
|
||||
示例:
|
||||
python clean_bill.py 账单.csv --year 2026
|
||||
python clean_bill.py 账单.csv --year 2026 --month 1
|
||||
python clean_bill.py 账单.csv --start 2026-01-01 --end 2026-01-15
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cleaners.base import create_arg_parser, compute_date_range
|
||||
from cleaners import AlipayCleaner, WechatCleaner
|
||||
|
||||
|
||||
def detect_bill_type(filepath: str) -> str | None:
|
||||
"""
|
||||
检测账单类型
|
||||
|
||||
Returns:
|
||||
'alipay' | 'wechat' | None
|
||||
"""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for _ in range(20):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
# 支付宝特征:表头包含"交易分类"和"对方账号"
|
||||
if "交易分类" in line and "对方账号" in line:
|
||||
return "alipay"
|
||||
|
||||
# 微信特征:表头包含"交易类型"和"金额(元)"
|
||||
if "交易类型" in line and "金额(元)" in line:
|
||||
return "wechat"
|
||||
|
||||
# 数据行特征
|
||||
if line.startswith("202"):
|
||||
if "¥" in line:
|
||||
return "wechat"
|
||||
if "@" in line:
|
||||
return "alipay"
|
||||
|
||||
except Exception as e:
|
||||
print(f"读取文件失败: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = create_arg_parser("账单清理统一入口 - 自动识别微信/支付宝账单")
|
||||
parser.add_argument(
|
||||
"--type", "-t",
|
||||
choices=["alipay", "wechat", "auto"],
|
||||
default="auto",
|
||||
help="手动指定账单类型(默认自动检测)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
input_file = args.input_file
|
||||
|
||||
# 检查文件是否存在
|
||||
if not Path(input_file).exists():
|
||||
print(f"❌ 错误:文件不存在 - {input_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 检测账单类型
|
||||
if args.type == "auto":
|
||||
bill_type = detect_bill_type(input_file)
|
||||
if bill_type is None:
|
||||
print("❌ 无法识别账单类型,请使用 --type 参数手动指定", file=sys.stderr)
|
||||
print(" 支持: --type alipay (支付宝) 或 --type wechat (微信)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
bill_type = args.type
|
||||
|
||||
# 显示检测结果
|
||||
type_names = {"alipay": "支付宝", "wechat": "微信"}
|
||||
print(f"📋 检测到账单类型: {type_names[bill_type]}")
|
||||
print()
|
||||
|
||||
# 计算日期范围
|
||||
start_date, end_date = compute_date_range(args)
|
||||
|
||||
# 获取输出格式
|
||||
output_format = getattr(args, 'format', 'csv')
|
||||
|
||||
# 创建对应的清理器
|
||||
if bill_type == "alipay":
|
||||
cleaner = AlipayCleaner(args.input_file, args.output_file, output_format)
|
||||
else:
|
||||
cleaner = WechatCleaner(args.input_file, args.output_file, output_format)
|
||||
|
||||
cleaner.set_date_range(start_date, end_date)
|
||||
cleaner.clean()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
analyzer/cleaners/__init__.py
Normal file
9
analyzer/cleaners/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
账单清理模块
|
||||
"""
|
||||
from .base import BaseCleaner
|
||||
from .alipay import AlipayCleaner
|
||||
from .wechat import WechatCleaner
|
||||
|
||||
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner']
|
||||
|
||||
231
analyzer/cleaners/alipay.py
Normal file
231
analyzer/cleaners/alipay.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
支付宝账单清理模块
|
||||
"""
|
||||
import csv
|
||||
from decimal import Decimal
|
||||
|
||||
from .base import (
|
||||
BaseCleaner, parse_amount, format_amount,
|
||||
is_in_date_range, create_arg_parser
|
||||
)
|
||||
from category import reclassify_if_needed, get_platform_merchants
|
||||
|
||||
|
||||
class AlipayCleaner(BaseCleaner):
|
||||
"""支付宝账单清理器"""
|
||||
|
||||
def clean(self) -> None:
|
||||
"""执行清理"""
|
||||
self.print_header()
|
||||
|
||||
# 读取数据
|
||||
with open(self.input_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
header = next(reader)
|
||||
rows = list(reader)
|
||||
|
||||
self.stats["original_count"] = len(rows)
|
||||
print(f"原始数据行数: {len(rows)}")
|
||||
|
||||
# 第一步:按日期范围筛选
|
||||
rows_filtered = [
|
||||
row for row in rows
|
||||
if row and is_in_date_range(row[0], self.start_date, self.end_date)
|
||||
]
|
||||
self.stats["filtered_count"] = len(rows_filtered)
|
||||
|
||||
date_desc = f"{self.start_date} ~ {self.end_date}" if self.start_date or self.end_date else "全部"
|
||||
print(f"筛选后数据行数: {len(rows_filtered)} ({date_desc})")
|
||||
|
||||
# 第二步:分离退款和非退款条目
|
||||
refund_rows = []
|
||||
expense_rows = []
|
||||
|
||||
for row in rows_filtered:
|
||||
if len(row) > 1 and row[1] == "退款":
|
||||
refund_rows.append(row)
|
||||
else:
|
||||
expense_rows.append(row)
|
||||
|
||||
print(f"退款条目数: {len(refund_rows)}")
|
||||
print(f"非退款条目数: {len(expense_rows)}")
|
||||
|
||||
# 第三步:处理退款
|
||||
order_refunds = self._aggregate_refunds(refund_rows)
|
||||
print(f"有退款的订单数: {len(order_refunds)}")
|
||||
|
||||
# 第四步:处理每笔支出
|
||||
final_rows = self._process_expenses(expense_rows, order_refunds)
|
||||
|
||||
print(f"\n处理结果:")
|
||||
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
||||
print(f" 最终保留行数: {len(final_rows)}")
|
||||
|
||||
# 第五步:重新分类并添加"需复核"标注
|
||||
final_rows = self.reclassify(final_rows, header)
|
||||
|
||||
if self.stats["category_adjusted"] > 0:
|
||||
print(f" 分类调整: {self.stats['category_adjusted']} 条")
|
||||
|
||||
self.stats["final_count"] = len(final_rows)
|
||||
|
||||
# 写入文件
|
||||
self.write_output(header, final_rows)
|
||||
|
||||
print(f"\n清理后的数据已保存到: {self.output_file}")
|
||||
|
||||
def _aggregate_refunds(self, refund_rows: list) -> dict:
|
||||
"""聚合退款金额"""
|
||||
order_refunds = {}
|
||||
|
||||
for row in refund_rows:
|
||||
if len(row) >= 11:
|
||||
refund_order_no = row[9].strip()
|
||||
refund_merchant_no = row[10].strip()
|
||||
refund_amount = parse_amount(row[6])
|
||||
|
||||
original_order = refund_order_no.split("_")[0] if "_" in refund_order_no else refund_order_no
|
||||
key = original_order if original_order else refund_merchant_no
|
||||
|
||||
if key:
|
||||
if key not in order_refunds:
|
||||
order_refunds[key] = Decimal("0")
|
||||
order_refunds[key] += refund_amount
|
||||
print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}元")
|
||||
|
||||
return order_refunds
|
||||
|
||||
def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list:
|
||||
"""处理支出记录"""
|
||||
final_rows = []
|
||||
|
||||
for row in expense_rows:
|
||||
if len(row) >= 12:
|
||||
order_no = row[9].strip()
|
||||
merchant_no = row[10].strip()
|
||||
expense_amount = parse_amount(row[6])
|
||||
|
||||
# 查找对应的退款
|
||||
refund_amount = Decimal("0")
|
||||
matched_key = None
|
||||
|
||||
for key, amount in order_refunds.items():
|
||||
if key and (order_no == key or merchant_no == key or order_no.startswith(key)):
|
||||
refund_amount = amount
|
||||
matched_key = key
|
||||
break
|
||||
|
||||
if matched_key:
|
||||
if refund_amount >= expense_amount:
|
||||
# 全额退款,删除
|
||||
self.stats["fully_refunded"] += 1
|
||||
print(f" 全额退款删除: {row[0]} | {row[2]} | {row[4][:25]}... | 原{expense_amount}元")
|
||||
else:
|
||||
# 部分退款,保留差额
|
||||
remaining = expense_amount - refund_amount
|
||||
new_row = row.copy()
|
||||
new_row[6] = format_amount(remaining)
|
||||
|
||||
original_remark = new_row[11] if len(new_row) > 11 else ""
|
||||
new_row[11] = f"原金额{expense_amount}元,退款{refund_amount}元{';' + original_remark if original_remark else ''}"
|
||||
|
||||
final_rows.append(new_row)
|
||||
self.stats["partially_refunded"] += 1
|
||||
print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}元")
|
||||
else:
|
||||
final_rows.append(row)
|
||||
else:
|
||||
final_rows.append(row)
|
||||
|
||||
return final_rows
|
||||
|
||||
def _is_platform_merchant(self, merchant: str) -> bool:
|
||||
"""判断是否为平台型商家(从配置文件读取)"""
|
||||
platform_merchants = get_platform_merchants()
|
||||
return any(platform in merchant for platform in platform_merchants)
|
||||
|
||||
def reclassify(self, rows: list, header: list) -> list:
|
||||
"""
|
||||
重新分类支付宝账单,并添加"复核等级"标注字段
|
||||
|
||||
只对平台型商家(美团、京东、抖音等)进行分类调整,
|
||||
其他商家直接信任支付宝原分类。
|
||||
|
||||
复核等级:
|
||||
空 = 无需复核
|
||||
低 = 分类被调整,需确认调整是否正确
|
||||
高 = 完全无法判断,需人工分类
|
||||
|
||||
字段索引:
|
||||
0: 交易时间
|
||||
1: 交易分类
|
||||
2: 交易对方
|
||||
4: 商品说明
|
||||
5: 收/支
|
||||
"""
|
||||
# 添加"复核等级"字段到表头
|
||||
if "复核等级" not in header:
|
||||
header.append("复核等级")
|
||||
|
||||
review_low_count = 0
|
||||
review_high_count = 0
|
||||
|
||||
for row in rows:
|
||||
if len(row) >= 6:
|
||||
original_category = row[1]
|
||||
merchant = row[2]
|
||||
product = row[4]
|
||||
income_expense = row[5]
|
||||
|
||||
review_mark = ""
|
||||
|
||||
# 只对平台型商家进行重新分类
|
||||
if self._is_platform_merchant(merchant):
|
||||
new_category, changed, review_level = reclassify_if_needed(
|
||||
original_category, merchant, product, income_expense
|
||||
)
|
||||
|
||||
if changed:
|
||||
row[1] = new_category
|
||||
self.stats["category_adjusted"] += 1
|
||||
print(f" 分类调整: {merchant[:15]}... | {original_category} -> {new_category}")
|
||||
|
||||
# 添加复核等级标注
|
||||
if review_level == 1:
|
||||
review_mark = "LOW"
|
||||
review_low_count += 1
|
||||
elif review_level == 2:
|
||||
review_mark = "HIGH"
|
||||
review_high_count += 1
|
||||
# 非平台商家:直接信任支付宝原分类,无需复核
|
||||
|
||||
# 确保行长度足够
|
||||
while len(row) < len(header) - 1:
|
||||
row.append("")
|
||||
row.append(review_mark)
|
||||
|
||||
if review_high_count > 0:
|
||||
print(f" 高优先级复核: {review_high_count} 条(无法判断)")
|
||||
if review_low_count > 0:
|
||||
print(f" 低优先级复核: {review_low_count} 条(分类已调整)")
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
parser = create_arg_parser("清理支付宝交易明细数据")
|
||||
args = parser.parse_args()
|
||||
|
||||
from .base import get_output_file, compute_date_range
|
||||
|
||||
cleaner = AlipayCleaner(args.input_file, args.output_file)
|
||||
start_date, end_date = compute_date_range(args)
|
||||
cleaner.set_date_range(start_date, end_date)
|
||||
cleaner.clean()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
240
analyzer/cleaners/base.py
Normal file
240
analyzer/cleaners/base.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
账单清理基类和公共工具函数
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 公共工具函数
|
||||
# =============================================================================
|
||||
|
||||
def parse_date(date_str: str) -> date:
|
||||
"""解析日期字符串,支持 YYYY-MM-DD 或 YYYY/MM/DD 格式"""
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"无法解析日期: {date_str},请使用 YYYY-MM-DD 格式")
|
||||
|
||||
|
||||
def parse_amount(amount_str: str) -> Decimal:
|
||||
"""解析金额字符串为Decimal(去掉¥符号)"""
|
||||
try:
|
||||
clean = amount_str.replace("¥", "").replace(" ", "").strip()
|
||||
return Decimal(clean)
|
||||
except:
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def format_amount(amount: Decimal) -> str:
|
||||
"""格式化金额为字符串(保留两位小数)"""
|
||||
return str(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def compute_date_range(args) -> tuple[date | None, date | None]:
|
||||
"""
|
||||
根据参数计算最终的日期范围
|
||||
多重指定时取交集(最小范围)
|
||||
|
||||
Returns:
|
||||
(start_date, end_date) 或 (None, None) 表示不筛选
|
||||
"""
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
# 1. 根据年份设置范围
|
||||
if args.year:
|
||||
year = int(args.year)
|
||||
start_date = date(year, 1, 1)
|
||||
end_date = date(year, 12, 31)
|
||||
|
||||
# 2. 根据月份进一步收窄
|
||||
if args.month:
|
||||
month = int(args.month)
|
||||
year = int(args.year) if args.year else datetime.now().year
|
||||
|
||||
if not start_date:
|
||||
start_date = date(year, 1, 1)
|
||||
end_date = date(year, 12, 31)
|
||||
|
||||
month_start = date(year, month, 1)
|
||||
if month == 12:
|
||||
month_end = date(year, 12, 31)
|
||||
else:
|
||||
month_end = date(year, month + 1, 1) - timedelta(days=1)
|
||||
|
||||
start_date = max(start_date, month_start) if start_date else month_start
|
||||
end_date = min(end_date, month_end) if end_date else month_end
|
||||
|
||||
# 3. 根据 start/end 参数进一步收窄
|
||||
if args.start:
|
||||
custom_start = parse_date(args.start)
|
||||
start_date = max(start_date, custom_start) if start_date else custom_start
|
||||
|
||||
if args.end:
|
||||
custom_end = parse_date(args.end)
|
||||
end_date = min(end_date, custom_end) if end_date else custom_end
|
||||
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def is_in_date_range(date_str: str, start_date: date | None, end_date: date | None) -> bool:
|
||||
"""检查日期字符串是否在指定范围内"""
|
||||
if start_date is None and end_date is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
row_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if start_date and row_date < start_date:
|
||||
return False
|
||||
if end_date and row_date > end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def create_arg_parser(description: str) -> argparse.ArgumentParser:
|
||||
"""创建通用的命令行参数解析器"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=description,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
日期筛选说明:
|
||||
--year 指定年份(如 2026)
|
||||
--month 指定月份(1-12)
|
||||
--start 起始日期(YYYY-MM-DD)
|
||||
--end 结束日期(YYYY-MM-DD)
|
||||
|
||||
多个条件同时指定时,取交集(最小日期范围)
|
||||
|
||||
输出格式:
|
||||
--format 输出格式:csv(默认)或 json
|
||||
"""
|
||||
)
|
||||
parser.add_argument("input_file", help="输入的账单CSV文件")
|
||||
parser.add_argument("output_file", nargs="?", default=None,
|
||||
help="输出文件(默认为 输入文件名_cleaned.csv/json)")
|
||||
parser.add_argument("--year", "-y", type=str, default=None,
|
||||
help="保留的年份(如 2026)")
|
||||
parser.add_argument("--month", "-m", type=int, choices=range(1, 13),
|
||||
metavar="1-12", help="保留的月份(1-12)")
|
||||
parser.add_argument("--start", "-s", type=str, help="起始日期(YYYY-MM-DD)")
|
||||
parser.add_argument("--end", "-e", type=str, help="结束日期(YYYY-MM-DD)")
|
||||
parser.add_argument("--format", "-f", choices=["csv", "json"], default="csv",
|
||||
help="输出格式:csv(默认)或 json")
|
||||
return parser
|
||||
|
||||
|
||||
def get_output_file(input_file: str, output_file: str | None, output_format: str = "csv") -> str:
|
||||
"""获取输出文件路径"""
|
||||
if output_file:
|
||||
return output_file
|
||||
import os
|
||||
base_name = os.path.splitext(input_file)[0]
|
||||
ext = "json" if output_format == "json" else "csv"
|
||||
return f"{base_name}_cleaned.{ext}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 账单清理基类
|
||||
# =============================================================================
|
||||
|
||||
class BaseCleaner(ABC):
|
||||
"""账单清理基类"""
|
||||
|
||||
def __init__(self, input_file: str, output_file: str | None = None, output_format: str = "csv"):
|
||||
self.input_file = input_file
|
||||
self.output_format = output_format
|
||||
self.output_file = get_output_file(input_file, output_file, output_format)
|
||||
self.start_date: date | None = None
|
||||
self.end_date: date | None = None
|
||||
|
||||
# 统计信息
|
||||
self.stats = {
|
||||
"original_count": 0,
|
||||
"filtered_count": 0,
|
||||
"fully_refunded": 0,
|
||||
"partially_refunded": 0,
|
||||
"category_adjusted": 0,
|
||||
"final_count": 0,
|
||||
}
|
||||
|
||||
def set_date_range(self, start_date: date | None, end_date: date | None):
|
||||
"""设置日期筛选范围"""
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
|
||||
def print_header(self):
|
||||
"""打印处理头信息"""
|
||||
print(f"输入文件: {self.input_file}")
|
||||
print(f"输出文件: {self.output_file}")
|
||||
print(f"输出格式: {self.output_format.upper()}")
|
||||
if self.start_date or self.end_date:
|
||||
print(f"日期范围: {self.start_date or '不限'} ~ {self.end_date or '不限'}")
|
||||
else:
|
||||
print("日期范围: 全部")
|
||||
print()
|
||||
|
||||
def write_output(self, header: list, rows: list):
|
||||
"""
|
||||
写入输出文件(支持 CSV 和 JSON 格式)
|
||||
|
||||
Args:
|
||||
header: 表头列表
|
||||
rows: 数据行列表
|
||||
"""
|
||||
if self.output_format == "json":
|
||||
self._write_json(header, rows)
|
||||
else:
|
||||
self._write_csv(header, rows)
|
||||
|
||||
def _write_csv(self, header: list, rows: list):
|
||||
"""写入 CSV 格式"""
|
||||
with open(self.output_file, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
|
||||
def _write_json(self, header: list, rows: list):
|
||||
"""写入 JSON 格式"""
|
||||
# 将每行转换为字典
|
||||
data = []
|
||||
for row in rows:
|
||||
record = {}
|
||||
for i, col in enumerate(header):
|
||||
if i < len(row):
|
||||
record[col] = row[i]
|
||||
else:
|
||||
record[col] = ""
|
||||
data.append(record)
|
||||
|
||||
with open(self.output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@abstractmethod
|
||||
def clean(self) -> None:
|
||||
"""执行清理,子类实现"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reclassify(self, rows: list) -> list:
|
||||
"""
|
||||
重新分类(子类实现)
|
||||
|
||||
Args:
|
||||
rows: 待处理的数据行
|
||||
|
||||
Returns:
|
||||
处理后的数据行
|
||||
"""
|
||||
pass
|
||||
|
||||
288
analyzer/cleaners/wechat.py
Normal file
288
analyzer/cleaners/wechat.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
微信账单清理模块
|
||||
"""
|
||||
import csv
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from .base import (
|
||||
BaseCleaner, parse_amount, format_amount,
|
||||
is_in_date_range, create_arg_parser
|
||||
)
|
||||
from category import infer_category
|
||||
|
||||
|
||||
# 与支付宝对齐的表头(包含"复核等级"字段)
|
||||
ALIGNED_HEADER = [
|
||||
"交易时间", "交易分类", "交易对方", "对方账号", "商品说明",
|
||||
"收/支", "金额", "收/付款方式", "交易状态", "交易订单号", "商家订单号", "备注", "复核等级"
|
||||
]
|
||||
|
||||
|
||||
class WechatCleaner(BaseCleaner):
|
||||
"""微信账单清理器"""
|
||||
|
||||
def clean(self) -> None:
|
||||
"""执行清理"""
|
||||
self.print_header()
|
||||
|
||||
# 读取数据
|
||||
with open(self.input_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
header = next(reader)
|
||||
rows = list(reader)
|
||||
|
||||
self.stats["original_count"] = len(rows)
|
||||
print(f"原始数据行数: {len(rows)}")
|
||||
|
||||
# 第一步:按日期范围筛选
|
||||
rows_filtered = [
|
||||
row for row in rows
|
||||
if row and is_in_date_range(row[0], self.start_date, self.end_date)
|
||||
]
|
||||
self.stats["filtered_count"] = len(rows_filtered)
|
||||
|
||||
date_desc = f"{self.start_date} ~ {self.end_date}" if self.start_date or self.end_date else "全部"
|
||||
print(f"筛选后数据行数: {len(rows_filtered)} ({date_desc})")
|
||||
|
||||
# 第二步:分离退款、支出、收入
|
||||
refund_rows, expense_rows, income_rows = self._separate_rows(rows_filtered)
|
||||
|
||||
print(f"退款条目数: {len(refund_rows)}")
|
||||
print(f"支出条目数: {len(expense_rows)}")
|
||||
print(f"其他收入条目数: {len(income_rows)}")
|
||||
|
||||
# 第三步:处理退款(包括转账退款)
|
||||
final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows)
|
||||
|
||||
print(f"\n处理结果:")
|
||||
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
||||
print(f" 保留支出条目: {len(final_expense_rows)} 条")
|
||||
print(f" 保留收入条目: {len(income_rows)} 条")
|
||||
|
||||
# 第四步:转换为对齐格式并重新分类
|
||||
aligned_expense = [self._convert_and_reclassify(r, remark) for r, remark in final_expense_rows]
|
||||
aligned_income = [self._convert_and_reclassify((r, None), None) for r in income_rows]
|
||||
|
||||
# 合并并排序
|
||||
final_rows = aligned_expense + aligned_income
|
||||
final_rows.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# 统计复核数量
|
||||
review_high_count = sum(1 for row in final_rows if row[-1] == "HIGH")
|
||||
|
||||
self.stats["final_count"] = len(final_rows)
|
||||
print(f" 最终保留行数: {len(final_rows)}")
|
||||
if review_high_count > 0:
|
||||
print(f" 高优先级复核: {review_high_count} 条(无法判断)")
|
||||
|
||||
# 写入文件
|
||||
self.write_output(ALIGNED_HEADER, final_rows)
|
||||
|
||||
print(f"\n清理后的数据已保存到: {self.output_file}")
|
||||
|
||||
# 统计支出
|
||||
self._print_expense_summary(aligned_expense)
|
||||
|
||||
def _separate_rows(self, rows: list) -> tuple[list, list, list]:
|
||||
"""分离退款、支出、收入记录"""
|
||||
refund_rows = []
|
||||
expense_rows = []
|
||||
income_rows = []
|
||||
|
||||
for row in rows:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
|
||||
transaction_type = row[1]
|
||||
income_expense = row[4]
|
||||
|
||||
if "-退款" in transaction_type:
|
||||
refund_rows.append(row)
|
||||
elif income_expense == "支出":
|
||||
expense_rows.append(row)
|
||||
elif income_expense == "收入" and "-退款" not in transaction_type:
|
||||
income_rows.append(row)
|
||||
|
||||
return refund_rows, expense_rows, income_rows
|
||||
|
||||
def _process_refunds(self, expense_rows: list, income_rows: list) -> tuple[list, list]:
|
||||
"""
|
||||
处理退款(包括转账退款)
|
||||
|
||||
微信的退款有两种形式:
|
||||
1. 状态标注:支出记录的"当前状态"列标注"已退款"
|
||||
2. 转账退款:同一交易对方有收入记录(转账退回)
|
||||
"""
|
||||
# 3.1 识别转账退款
|
||||
transfer_refunds = {}
|
||||
transfer_refund_rows = []
|
||||
|
||||
for row in income_rows:
|
||||
merchant = row[2].strip()
|
||||
amount = parse_amount(row[5])
|
||||
|
||||
# 检查是否有对应的支出记录
|
||||
has_matching_expense = any(exp[2].strip() == merchant for exp in expense_rows)
|
||||
|
||||
if has_matching_expense:
|
||||
if merchant not in transfer_refunds:
|
||||
transfer_refunds[merchant] = Decimal("0")
|
||||
transfer_refunds[merchant] += amount
|
||||
transfer_refund_rows.append(row)
|
||||
|
||||
# 从收入中移除已识别的转账退款
|
||||
for row in transfer_refund_rows:
|
||||
income_rows.remove(row)
|
||||
|
||||
if transfer_refunds:
|
||||
print(f" 识别到转账退款: {len(transfer_refunds)} 笔")
|
||||
|
||||
# 3.2 处理支出记录
|
||||
final_expense_rows = []
|
||||
|
||||
for row in expense_rows:
|
||||
status = row[7]
|
||||
merchant = row[2].strip()
|
||||
original_amount = parse_amount(row[5])
|
||||
|
||||
# 计算总退款金额
|
||||
status_refund = Decimal("0")
|
||||
transfer_refund = transfer_refunds.get(merchant, Decimal("0"))
|
||||
|
||||
if "已全额退款" in status:
|
||||
self.stats["fully_refunded"] += 1
|
||||
print(f" 全额退款删除: {row[0]} | {row[2]} | {row[3][:25]}... | {row[5]}")
|
||||
continue
|
||||
elif "已退款" in status:
|
||||
status_refund = self._extract_refund_amount(status) or Decimal("0")
|
||||
|
||||
total_refund = status_refund + transfer_refund
|
||||
|
||||
if total_refund > 0:
|
||||
if total_refund >= original_amount:
|
||||
self.stats["fully_refunded"] += 1
|
||||
print(f" 全额退款删除: {row[0]} | {row[2]} | {row[3][:25]}... | {row[5]}")
|
||||
else:
|
||||
remaining = original_amount - total_refund
|
||||
new_row = row.copy()
|
||||
new_row[5] = f"¥{format_amount(remaining)}"
|
||||
remark = f"原金额{row[5]},退款¥{total_refund}"
|
||||
|
||||
final_expense_rows.append((new_row, remark))
|
||||
self.stats["partially_refunded"] += 1
|
||||
print(f" 部分退款: {row[0]} | {row[2]} | 原{row[5]} -> ¥{format_amount(remaining)}")
|
||||
|
||||
if merchant in transfer_refunds:
|
||||
del transfer_refunds[merchant]
|
||||
else:
|
||||
final_expense_rows.append((row, None))
|
||||
|
||||
return final_expense_rows, income_rows
|
||||
|
||||
def _extract_refund_amount(self, status: str) -> Decimal | None:
|
||||
"""从状态中提取已退款金额"""
|
||||
match = re.search(r'已退款[((]?¥?([\d.]+)[))]?', status)
|
||||
if match:
|
||||
return Decimal(match.group(1))
|
||||
if "已全额退款" in status:
|
||||
return None
|
||||
return Decimal("0")
|
||||
|
||||
def _convert_and_reclassify(self, row_tuple: tuple, remark_override: str | None) -> list:
|
||||
"""
|
||||
转换为对齐格式并重新分类
|
||||
|
||||
微信原始字段:
|
||||
0: 交易时间, 1: 交易类型, 2: 交易对方, 3: 商品,
|
||||
4: 收/支, 5: 金额(元), 6: 支付方式, 7: 当前状态,
|
||||
8: 交易单号, 9: 商户单号, 10: 备注
|
||||
|
||||
对齐后字段:
|
||||
交易时间, 交易分类, 交易对方, 对方账号, 商品说明,
|
||||
收/支, 金额, 收/付款方式, 交易状态, 交易订单号, 商家订单号, 备注, 需复核
|
||||
"""
|
||||
if isinstance(row_tuple, tuple):
|
||||
row, remark = row_tuple
|
||||
else:
|
||||
row, remark = row_tuple, None
|
||||
|
||||
remark = remark_override if remark_override else remark
|
||||
|
||||
transaction_time = row[0]
|
||||
merchant = row[2]
|
||||
product = row[3]
|
||||
income_expense = row[4]
|
||||
amount = parse_amount(row[5])
|
||||
payment_method = row[6]
|
||||
status = row[7]
|
||||
order_no = row[8]
|
||||
merchant_order_no = row[9] if len(row) > 9 else ""
|
||||
final_remark = remark if remark else (row[10] if len(row) > 10 else "/")
|
||||
|
||||
# 重新分类(微信原始的"交易类型"太笼统)
|
||||
category, is_certain = infer_category(merchant, product, income_expense)
|
||||
|
||||
# 复核等级: 空=无需复核, HIGH=无法判断
|
||||
review_mark = "" if is_certain else "HIGH"
|
||||
|
||||
return [
|
||||
transaction_time,
|
||||
category,
|
||||
merchant,
|
||||
"/", # 对方账号(微信无此字段)
|
||||
product,
|
||||
income_expense,
|
||||
format_amount(amount),
|
||||
payment_method,
|
||||
status,
|
||||
order_no,
|
||||
merchant_order_no,
|
||||
final_remark,
|
||||
review_mark
|
||||
]
|
||||
|
||||
def reclassify(self, rows: list) -> list:
|
||||
"""
|
||||
重新分类微信账单
|
||||
|
||||
微信账单在 _convert_and_reclassify 中已完成分类
|
||||
此方法为接口兼容保留
|
||||
"""
|
||||
return rows
|
||||
|
||||
def _print_expense_summary(self, expense_rows: list):
|
||||
"""打印支出统计"""
|
||||
total = Decimal("0")
|
||||
categories = {}
|
||||
|
||||
for row in expense_rows:
|
||||
if row[5] == "支出":
|
||||
amt = Decimal(row[6])
|
||||
total += amt
|
||||
cat = row[1]
|
||||
categories[cat] = categories.get(cat, Decimal("0")) + amt
|
||||
|
||||
print(f"清理后支出总额: ¥{total}")
|
||||
print("\n=== 按分类统计 ===")
|
||||
for cat, amt in sorted(categories.items(), key=lambda x: -x[1]):
|
||||
print(f" {cat}: ¥{amt}")
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
parser = create_arg_parser("清理微信支付账单数据")
|
||||
args = parser.parse_args()
|
||||
|
||||
from .base import compute_date_range
|
||||
|
||||
cleaner = WechatCleaner(args.input_file, args.output_file)
|
||||
start_date, end_date = compute_date_range(args)
|
||||
cleaner.set_date_range(start_date, end_date)
|
||||
cleaner.clean()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
237
analyzer/config/category.yaml
Normal file
237
analyzer/config/category.yaml
Normal file
@@ -0,0 +1,237 @@
|
||||
# =============================================================================
|
||||
# 交易分类关键词配置
|
||||
# 按优先级从上到下匹配,排在前面的分类优先级更高
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# 需要重新分类的平台型商家
|
||||
# 这些平台的交易类型多样(外卖、电影、酒店、打车等),支付宝原分类可能不准确
|
||||
# 只有匹配到以下商家时才会触发分类调整逻辑
|
||||
# =============================================================================
|
||||
平台商家:
|
||||
- 美团
|
||||
- 京东
|
||||
- 抖音
|
||||
- 饿了么
|
||||
- 拼多多
|
||||
- 携程
|
||||
- 金山便利店
|
||||
|
||||
# =============================================================================
|
||||
# 支出分类关键词
|
||||
# =============================================================================
|
||||
支出分类:
|
||||
# 医疗健康 - 最高优先级
|
||||
医疗健康:
|
||||
- 医院
|
||||
- 药房
|
||||
- 药店
|
||||
- 诊所
|
||||
- 健康
|
||||
- 皮肤
|
||||
- 医疗
|
||||
- 体检
|
||||
- 口腔
|
||||
- 牙科
|
||||
- 眼科
|
||||
- 中医
|
||||
- 西医
|
||||
- 门诊
|
||||
- 挂号
|
||||
|
||||
# 交通出行
|
||||
交通出行:
|
||||
- 出行
|
||||
- 打车
|
||||
- 单车
|
||||
- 骑行
|
||||
- 骑安 # 广州骑安共享单车
|
||||
- 滴滴
|
||||
- 高德
|
||||
- 班车
|
||||
- 通勤
|
||||
- 公交
|
||||
- 地铁
|
||||
- 火车
|
||||
- 机票
|
||||
- 航空
|
||||
- 共享
|
||||
- 京庐 # 京庐出行
|
||||
- 哈啰
|
||||
- 美团单车
|
||||
- 青桔
|
||||
- 摩拜
|
||||
- 加油
|
||||
- 停车
|
||||
- 高速
|
||||
- 过路费
|
||||
- "12306"
|
||||
- 携程
|
||||
- 飞猪
|
||||
|
||||
# 充值缴费
|
||||
充值缴费:
|
||||
- 充值
|
||||
- 缴费
|
||||
- 水费
|
||||
- 电费
|
||||
- 燃气
|
||||
- 话费
|
||||
- 流量
|
||||
- 宽带
|
||||
- 物业
|
||||
- 房租
|
||||
- 供暖
|
||||
- 暖气
|
||||
|
||||
# 运动健身
|
||||
运动健身:
|
||||
- 健身
|
||||
- 健身房
|
||||
- 运动
|
||||
- 滑雪
|
||||
- 冰雪
|
||||
- 游泳
|
||||
- 瑜伽
|
||||
- 跑步
|
||||
- 球场
|
||||
- 篮球
|
||||
- 足球
|
||||
- 羽毛球
|
||||
- 乒乓
|
||||
- 网球
|
||||
- 众雪 # 众雪滑雪用品店
|
||||
- 雪具
|
||||
- 滑板
|
||||
- 攀岩
|
||||
- 骑行装备
|
||||
|
||||
# 文化休闲(优先级高于日用百货,避免万达影城被识别为万达商场)
|
||||
文化休闲:
|
||||
- 影城 # 电影院
|
||||
- 影院
|
||||
- 电影
|
||||
- 游戏
|
||||
- 娱乐
|
||||
- 旅游
|
||||
- 景区
|
||||
- 门票
|
||||
- 会员
|
||||
- 视频
|
||||
- 音乐
|
||||
- KTV
|
||||
- 网吧
|
||||
- 桌游
|
||||
- 剧本杀
|
||||
- 密室
|
||||
- 酒吧
|
||||
- 演出
|
||||
- 演唱会
|
||||
- 酒店 # 美团酒店
|
||||
- 如家
|
||||
- 汉庭
|
||||
- 7天
|
||||
- 民宿
|
||||
|
||||
# 日用百货 - 盒马放在前面,避免被餐饮匹配
|
||||
日用百货:
|
||||
- 京邦达 # 京东物流
|
||||
- 快递
|
||||
- 拼多多
|
||||
- 便利店
|
||||
- 超市
|
||||
- 商场
|
||||
- 购物
|
||||
- 永辉
|
||||
- 华润
|
||||
- 万达
|
||||
- 苏宁
|
||||
- 国美
|
||||
- 得物
|
||||
- 唯品会
|
||||
- 可多 # Codow可多便利店
|
||||
- 全家
|
||||
- "7-11"
|
||||
- 罗森
|
||||
- 美宜佳
|
||||
- 妍丽 # AFIONA妍丽美妆店
|
||||
- 屈臣氏
|
||||
- 丝芙兰
|
||||
|
||||
# 餐饮美食
|
||||
餐饮美食:
|
||||
- coffee
|
||||
- 咖啡
|
||||
- luckin
|
||||
- 瑞幸
|
||||
- 星巴克
|
||||
- starbucks
|
||||
- 食堂
|
||||
- 订餐
|
||||
- 餐厅
|
||||
- 饭店
|
||||
- 饮品
|
||||
- 美食
|
||||
- 烧烤
|
||||
- 火锅
|
||||
- 面馆
|
||||
- 粥
|
||||
- 小吃
|
||||
- 甜品
|
||||
- 蛋糕
|
||||
- 麦当劳
|
||||
- 肯德基
|
||||
- 必胜客
|
||||
- 汉堡王
|
||||
- 德克士
|
||||
- 奶茶
|
||||
- 茶饮
|
||||
- 喜茶
|
||||
- 奈雪
|
||||
- 蜜雪
|
||||
- 茶百道
|
||||
- 书亦
|
||||
- 外卖
|
||||
- 饿了么
|
||||
- 美团外卖
|
||||
- 半秋山 # 西餐厅
|
||||
- 西餐
|
||||
- 中餐
|
||||
- 日料
|
||||
- 韩餐
|
||||
- 川菜
|
||||
- 湘菜
|
||||
- 酸汤
|
||||
- 黔有财 # 贵州酸汤火锅
|
||||
- 烤鸭
|
||||
- 臭豆腐
|
||||
- 炸鸡
|
||||
- 披萨
|
||||
- 寿司
|
||||
- 拉面
|
||||
- 米线
|
||||
- 螺蛳粉
|
||||
- 串串
|
||||
- 冒菜
|
||||
- 麻辣烫
|
||||
- 黄焖鸡
|
||||
- 煲仔饭
|
||||
- 蛙来哒 # 牛蛙餐厅
|
||||
- 粒上皇 # 炒货零食店
|
||||
|
||||
# 转账红包
|
||||
转账红包:
|
||||
- 红包
|
||||
- 转账给
|
||||
|
||||
|
||||
收入分类:
|
||||
退款:
|
||||
- 退款
|
||||
其他收入: []
|
||||
|
||||
|
||||
默认分类:
|
||||
支出: 其他支出
|
||||
收入: 其他收入
|
||||
|
||||
2
analyzer/requirements.txt
Normal file
2
analyzer/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pyyaml>=6.0
|
||||
|
||||
Reference in New Issue
Block a user