1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
| """ 微信支付账单 XLSX → Apple ICS 日历转换脚本 将每天的收支汇总为日历事件,导入到 macOS/iOS 日历中显示。 """
import argparse import datetime import uuid from collections import defaultdict from pathlib import Path
try: import openpyxl except ImportError: print("缺少 openpyxl 库,请运行: pip install openpyxl") exit(1)
def parse_xlsx(path: str) -> str: """读取 XLSX 文件,返回 (昵称, 收入总笔数/金额, 支出总笔数/金额, 按日期汇总的收支记录)""" wb = openpyxl.load_workbook(path, read_only=True, data_only=True) ws = wb.active
rows = list(ws.iter_rows(values_only=True))
nick_name = None for r in rows[:6]: if r[0] and isinstance(r[0], str) and r[0].startswith("微信昵称"): nick_name = r[0].replace("微信昵称:", "").replace("[", "").replace("]", "")
header_row = rows[17] col_idx = { "time": 0, "type": 1, "income_expense": 4, "amount": 5, }
daily: dict[str, dict] = defaultdict(lambda: {"income": 0.0, "expense": 0.0, "count_income": 0, "count_expense": 0})
for row in rows[18:]: dt = row[col_idx["time"]] io = row[col_idx["income_expense"]] amt = row[col_idx["amount"]]
if dt is None: continue if not isinstance(dt, datetime.datetime): continue
date_str = dt.strftime("%Y-%m-%d")
if io and isinstance(io, str): try: amount = float(amt) if amt is not None else 0.0 except (ValueError, TypeError): amount = 0.0
if io == "收入": daily[date_str]["income"] += amount daily[date_str]["count_income"] += 1 elif io == "支出": daily[date_str]["expense"] += amount daily[date_str]["count_expense"] += 1
wb.close() return nick_name, daily
def escape_ics(text: str) -> str: """转义 ICS 文本字段的特殊字符""" text = text.replace("\\", "\\\\") text = text.replace(";", "\\;") text = text.replace(",", "\\,") text = text.replace("\n", "\\n") return text
def build_ics(nick_name: str, daily: dict) -> str: """生成 ICS 日历内容。每天一个全天事件,标题包含当天收支汇总。""" now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))) now_utc = now.astimezone(datetime.timezone.utc) timestamp = now_utc.strftime("%Y%m%dT%H%M%SZ")
lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//xlsx2ics//微信账单日历//CN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "X-WR-CALNAME:支出", f"X-WR-CALDESC:{escape_ics(nick_name or '我的')}的微信支付每日收支。", ]
if daily: first_date = min(daily.keys()) last_date = max(daily.keys()) lines.append(f"X-WR-TIMEZONE:Asia/Shanghai")
for date_str in sorted(daily.keys()): d = daily[date_str] income = d["income"] expense = d["expense"] net = income - expense
parts = [] if income > 0: parts.append(f"收入 ¥{income:.2f}") if expense > 0: parts.append(f"支出 ¥{expense:.2f}") if income > 0 and expense > 0: if net >= 0: parts.append(f"净收入 ¥{net:.2f}") else: parts.append(f"净支出 ¥{abs(net):.2f}") summary = " | ".join(parts) if not summary: continue
detail_lines = [ f"日期: {date_str}", ] if income > 0: detail_lines.append(f"收入: ¥{income:.2f} ({d['count_income']}笔)") if expense > 0: detail_lines.append(f"支出: ¥{expense:.2f} ({d['count_expense']}笔)") detail = "\\n".join(detail_lines)
uid = str(uuid.uuid4()) date_compact = date_str.replace("-", "")
lines.extend([ "BEGIN:VEVENT", f"UID:{uid}", f"DTSTART;VALUE=DATE:{date_compact}", f"DTEND;VALUE=DATE:{date_compact}", f"SUMMARY:{escape_ics(summary)}", f"DESCRIPTION:{escape_ics(detail)}", f"DTSTAMP:{timestamp}", "TRANSP:TRANSPARENT", "END:VEVENT", ])
lines.append("END:VCALENDAR") return "\r\n".join(lines) + "\r\n"
def main(): parser = argparse.ArgumentParser(description="微信支付 XLSX 账单 → ICS 日历") parser.add_argument("xlsx", help="微信支付账单 XLSX 文件路径") parser.add_argument("-o", "--output", help="输出 ICS 文件路径(默认: 输入文件名.ics)") args = parser.parse_args()
xlsx_path = Path(args.xlsx) if not xlsx_path.exists(): print(f"❌ 文件不存在: {xlsx_path}") exit(1)
print(f"📖 读取: {xlsx_path}") nick_name, daily = parse_xlsx(str(xlsx_path))
if not daily: print("❌ 未找到有效的交易记录") exit(1)
total_income = sum(v["income"] for v in daily.values()) total_expense = sum(v["expense"] for v in daily.values()) total_net = total_income - total_expense print(f"👤 昵称: {nick_name or '未知'}") print(f"📅 日期范围: {min(daily.keys())} ~ {max(daily.keys())} ({len(daily)}天)") print(f"💰 总收入: ¥{total_income:.2f}") print(f"💸 总支出: ¥{total_expense:.2f}") print(f"📊 净收支: ¥{total_net:.2f}")
ics_content = build_ics(nick_name, daily)
output_path = args.output or xlsx_path.with_suffix(".ics") with open(output_path, "w", encoding="utf-8") as f: f.write(ics_content)
print(f"✅ 已导出: {output_path}") print(f" {len(daily)} 个日历事件") print() print("📌 双击 .ics 文件即可导入到 macOS/iOS 日历。")
if __name__ == "__main__": main()
|