--- name: member-manager description: "管理組織成員(家人、同事、親戚等)的個人資料、農曆/新曆生日及紀念日,並用 OpenClaw cron 設置自動提醒。Use when: (1) 添加/編輯成員資料, (2) 設置生日或紀念日提醒, (3) 更新農曆對應的新曆日期, (4) 查看即將到來的重要日子, (5) 管理多個組織的成員。NOT for: 一般日程安排(用 calendar skill)。" version: 1.0.1 license: MIT-0 env: OPENCLAW_SESSION_KEY: description: "當前用戶的 session 標識符(如 Telegram user ID)。用於多用戶數據隔離。" required: false default: "default" paths: config: - "~/.openclaw/session.json" data: - "~/.openclaw/workspace/users/{SESSION_KEY}/members.json" - "~/.openclaw/workspace/users/{SESSION_KEY}/reminders.json" dependencies: python: - name: lunardate version: ">=0.2.2" license: GPL-3.0 required_for: "農曆日期轉換" tools: - mcp__scheduled-tasks__create_scheduled_task - mcp__scheduled-tasks__list_scheduled_tasks - mcp__scheduled-tasks__update_scheduled_task --- # member-manager — 成員資料與生日紀念日管理 管理多個組織(家庭、同事、親戚等)中成員的個人資料、農曆/新曆生日及各類紀念日,並透過 OpenClaw scheduled-tasks 自動發送提醒。 --- ## 核心原則 1. **先讀後寫**:任何修改操作前,先讀取現有 JSON 檔案內容 2. **原子寫入**:每次寫入完整 JSON,不做部分更新 3. **用戶確認**:添加/刪除成員前向用戶確認資訊是否正確 4. **農曆自動轉換**:用戶提供農曆日期時,自動計算當年新曆日期 5. **提醒同步**:刪除成員時同步清理提醒;修改生日時同步更新提醒 --- ## 多用戶數據隔離 此 skill 支持多用戶共享同一個 OpenClaw(例如 Telegram bot 場景)。每個用戶的數據存放在以**用戶 session key 命名的獨立目錄**,互不干擾。 ### 數據路徑規則 ``` ~/.openclaw/workspace/users/{SESSION_KEY}/members.json ~/.openclaw/workspace/users/{SESSION_KEY}/reminders.json ``` `{SESSION_KEY}` 是 OpenClaw 的當前 session 標識符(Telegram DM 場景即為 Telegram user ID)。 **在使用任何成員數據前,必須先確定當前用戶的路徑。** ### 如何確定當前用戶的數據目錄 讀取環境變數或 session context 取得用戶 ID,**必須先清洗以防止目錄遍歷攻擊**,然後初始化數據目錄: ```python import os, json, re def sanitize_session_key(raw_key: str) -> str: """清洗 session key,只允許字母數字、連字號和底線,防止目錄遍歷""" sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', raw_key) if not sanitized: raise ValueError(f"Invalid session key: {raw_key!r}") return sanitized # 方法 1:從環境變數取得 raw_id = os.environ.get("OPENCLAW_SESSION_KEY", "default") # 方法 2:如果是在 Telegram bot 場景,嘗試讀取 session 檔案 session_file = os.path.expanduser("~/.openclaw/session.json") if os.path.exists(session_file): with open(session_file) as f: session = json.load(f) raw_id = session.get("user_id", session.get("session_key", "default")) user_id = sanitize_session_key(str(raw_id)) WORKSPACE_ROOT = os.path.expanduser("~/.openclaw/workspace/users") base_dir = os.path.join(WORKSPACE_ROOT, user_id) # 二次驗證:確保解析後的路徑仍在 workspace 目錄內 real_base = os.path.realpath(base_dir) real_root = os.path.realpath(WORKSPACE_ROOT) if not real_base.startswith(real_root + os.sep): raise ValueError(f"Path traversal detected: {base_dir}") members_path = os.path.join(base_dir, "members.json") reminders_path = os.path.join(base_dir, "reminders.json") # 初始化新用戶 os.makedirs(base_dir, exist_ok=True) if not os.path.exists(members_path): with open(members_path, "w") as f: json.dump([], f) if not os.path.exists(reminders_path): with open(reminders_path, "w") as f: json.dump([], f) ``` --- ## 數據結構 ### members.json ```json [ { "id": "uuid-v4", "name": "張三", "group": "家庭", "relationship": "父親", "phone": "0912345678", "birthday_solar": "1955-03-15", "birthday_lunar": { "month": 2, "day": 13, "is_leap_month": false }, "birthday_lunar_solar_this_year": "2025-03-12", "anniversaries": [ { "id": "uuid-v4", "label": "結婚紀念日", "date_solar": "1980-06-20", "recurring": true } ], "notes": "喜歡喝普洱茶", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z" } ] ``` 欄位說明: - `id`:UUID v4,成員唯一標識 - `name`:姓名(必填) - `group`:組別/組織名稱,如「家庭」「公司」「大學同學」(必填) - `relationship`:與用戶的關係,如「父親」「主管」「同學」 - `phone`:電話號碼 - `birthday_solar`:新曆生日,格式 `YYYY-MM-DD` - `birthday_lunar`:農曆生日,含 month/day/is_leap_month - `birthday_lunar_solar_this_year`:農曆生日在**當年**對應的新曆日期(每年需更新) - `anniversaries`:紀念日列表,每個紀念日有獨立 id - `notes`:備註 - `created_at` / `updated_at`:時間戳 ### reminders.json ```json [ { "id": "uuid-v4", "member_id": "對應 member 的 id", "type": "birthday_solar", "label": "張三 新曆生日", "advance_days": 3, "scheduled_task_id": "member-birthday-張三-solar", "enabled": true } ] ``` 欄位說明: - `type`:`birthday_solar` | `birthday_lunar` | `anniversary` - `advance_days`:提前幾天提醒(默認 3 天) - `scheduled_task_id`:對應 OpenClaw scheduled-task 的 ID - `enabled`:是否啟用 --- ## 農曆處理 農曆轉新曆每年都會變,因此需要**每年更新**農曆生日對應的新曆日期。 ### 農曆轉新曆 ```python # pip install lunardate from lunardate import LunarDate from datetime import date, timedelta def lunar_to_solar(year: int, lunar_month: int, lunar_day: int, is_leap: bool = False) -> date: """將農曆日期轉換為新曆日期""" try: lunar = LunarDate(year, lunar_month, lunar_day, is_leap) return lunar.toSolarDate() except ValueError: # 若該年無此農曆日期(例如閏月不存在),嘗試非閏月 if is_leap: lunar = LunarDate(year, lunar_month, lunar_day, False) return lunar.toSolarDate() raise ``` ### 計算提醒日期(正確處理跨月) ```python def calculate_reminder_date(target_date: date, advance_days: int) -> date: """計算提前提醒的日期,正確處理跨月情況""" return target_date - timedelta(days=advance_days) ``` ### 年度更新所有農曆生日 ```python def update_all_lunar_birthdays(base_dir: str, year: int): """更新所有成員農曆生日在指定年份的新曆日期""" members_path = os.path.join(base_dir, "members.json") with open(members_path) as f: members = json.load(f) updated = [] for m in members: lunar = m.get("birthday_lunar") if lunar: try: solar = lunar_to_solar( year, lunar["month"], lunar["day"], lunar.get("is_leap_month", False) ) m["birthday_lunar_solar_this_year"] = str(solar) updated.append(m["name"]) except ValueError as e: # 記錄錯誤但繼續處理其他成員 pass m["updated_at"] = datetime.utcnow().isoformat() + "Z" with open(members_path, "w") as f: json.dump(members, f, ensure_ascii=False, indent=2) return updated ``` --- ## 功能操作指南 ### 1. 添加成員 當用戶要求添加成員時: 1. 解析用戶提供的信息(姓名、組別、關係、生日等) 2. 如果用戶提供農曆生日,自動轉換為當年新曆日期 3. 向用戶確認信息後寫入 ```python import json, os, uuid from datetime import datetime def add_member(base_dir, name, group, relationship="", birthday_solar=None, birthday_lunar=None, phone="", notes=""): path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) # 檢查是否已有同名同組成員 for existing in members: if existing["name"] == name and existing["group"] == group: return None, f"已存在同名成員:{name}({group})" now = datetime.utcnow().isoformat() + "Z" member = { "id": str(uuid.uuid4()), "name": name, "group": group, "relationship": relationship, "phone": phone, "birthday_solar": birthday_solar, "birthday_lunar": birthday_lunar, "birthday_lunar_solar_this_year": None, "anniversaries": [], "notes": notes, "created_at": now, "updated_at": now } # 如果有農曆生日,計算當年新曆日期 if birthday_lunar: from lunardate import LunarDate from datetime import date try: solar = lunar_to_solar( date.today().year, birthday_lunar["month"], birthday_lunar["day"], birthday_lunar.get("is_leap_month", False) ) member["birthday_lunar_solar_this_year"] = str(solar) except ValueError: pass members.append(member) with open(path, "w") as f: json.dump(members, f, ensure_ascii=False, indent=2) return member, None ``` ### 2. 編輯成員 ```python def update_member(base_dir, member_id, **updates): """更新成員資料。支持更新任意欄位。""" path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) for m in members: if m["id"] == member_id: for key, value in updates.items(): if key in m and key not in ("id", "created_at"): m[key] = value m["updated_at"] = datetime.utcnow().isoformat() + "Z" # 如果更新了農曆生日,重新計算新曆日期 if "birthday_lunar" in updates and updates["birthday_lunar"]: from datetime import date try: lunar = updates["birthday_lunar"] solar = lunar_to_solar( date.today().year, lunar["month"], lunar["day"], lunar.get("is_leap_month", False) ) m["birthday_lunar_solar_this_year"] = str(solar) except ValueError: pass with open(path, "w") as f: json.dump(members, f, ensure_ascii=False, indent=2) return m return None ``` ### 3. 刪除成員 刪除成員時**必須同步清理提醒**: ```python def delete_member(base_dir, member_id): """刪除成員及其所有提醒""" # 刪除成員 members_path = os.path.join(base_dir, "members.json") with open(members_path) as f: members = json.load(f) member = None new_members = [] for m in members: if m["id"] == member_id: member = m else: new_members.append(m) if not member: return None, [] with open(members_path, "w") as f: json.dump(new_members, f, ensure_ascii=False, indent=2) # 清理該成員的所有提醒 reminders_path = os.path.join(base_dir, "reminders.json") with open(reminders_path) as f: reminders = json.load(f) removed_task_ids = [] new_reminders = [] for r in reminders: if r["member_id"] == member_id: removed_task_ids.append(r.get("scheduled_task_id")) else: new_reminders.append(r) with open(reminders_path, "w") as f: json.dump(new_reminders, f, ensure_ascii=False, indent=2) return member, removed_task_ids ``` **重要**:刪除成員後,需要用 `mcp__scheduled-tasks__update_scheduled_task` 停用對應的 scheduled tasks(將 `removed_task_ids` 中的 task 設為 disabled)。 ### 4. 添加紀念日 ```python def add_anniversary(base_dir, member_id, label, date_solar, recurring=True): """為成員添加紀念日""" path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) for m in members: if m["id"] == member_id: anniversary = { "id": str(uuid.uuid4()), "label": label, "date_solar": date_solar, "recurring": recurring } m["anniversaries"].append(anniversary) m["updated_at"] = datetime.utcnow().isoformat() + "Z" with open(path, "w") as f: json.dump(members, f, ensure_ascii=False, indent=2) return anniversary return None ``` ### 5. 刪除紀念日 ```python def remove_anniversary(base_dir, member_id, anniversary_id): """刪除成員的某個紀念日""" path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) for m in members: if m["id"] == member_id: m["anniversaries"] = [ a for a in m["anniversaries"] if a["id"] != anniversary_id ] m["updated_at"] = datetime.utcnow().isoformat() + "Z" with open(path, "w") as f: json.dump(members, f, ensure_ascii=False, indent=2) return True return False ``` ### 6. 查看即將到來的重要日子 ```python from datetime import date, timedelta import calendar def _safe_replace_year(d: date, year: int) -> date: """安全替換年份,處理 2/29 在非閏年的情況(fallback 到 2/28)""" if d.month == 2 and d.day == 29 and not calendar.isleap(year): return date(year, 2, 28) return d.replace(year=year) def upcoming_events(base_dir, days_ahead=30): """查詢未來 N 天內的生日和紀念日""" path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) today = date.today() end_date = today + timedelta(days=days_ahead) events = [] for m in members: # 新曆生日 if m.get("birthday_solar"): bday = date.fromisoformat(m["birthday_solar"]) this_year = _safe_replace_year(bday, today.year) # 處理跨年情況 if this_year < today: this_year = _safe_replace_year(bday, today.year + 1) if today <= this_year <= end_date: age = this_year.year - bday.year events.append({ "date": str(this_year), "name": m["name"], "group": m["group"], "type": "新曆生日", "label": f"{m['name']} 的生日({age} 歲)", "member_id": m["id"] }) # 農曆生日(使用預計算的當年新曆日期) if m.get("birthday_lunar_solar_this_year"): lunar_solar = date.fromisoformat(m["birthday_lunar_solar_this_year"]) if today <= lunar_solar <= end_date: events.append({ "date": str(lunar_solar), "name": m["name"], "group": m["group"], "type": "農曆生日", "label": f"{m['name']} 的農曆生日(農曆{m['birthday_lunar']['month']}月{m['birthday_lunar']['day']}日)", "member_id": m["id"] }) # 紀念日 for ann in m.get("anniversaries", []): if ann.get("recurring") and ann.get("date_solar"): aday = date.fromisoformat(ann["date_solar"]) this_year = _safe_replace_year(aday, today.year) if this_year < today: this_year = _safe_replace_year(aday, today.year + 1) if today <= this_year <= end_date: years = this_year.year - aday.year events.append({ "date": str(this_year), "name": m["name"], "group": m["group"], "type": "紀念日", "label": f"{m['name']} — {ann['label']}(第 {years} 年)", "member_id": m["id"] }) return sorted(events, key=lambda e: e["date"]) ``` ### 7. 搜索與篩選成員 ```python def search_members(base_dir, query=None, group=None): """搜索成員。支持按名字模糊搜索和按組別篩選。""" path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) results = members if group: results = [m for m in results if m["group"] == group] if query: query_lower = query.lower() results = [m for m in results if query_lower in m["name"].lower() or query_lower in m.get("relationship", "").lower() or query_lower in m.get("notes", "").lower()] return results def list_groups(base_dir): """列出所有組別及成員數量""" path = os.path.join(base_dir, "members.json") with open(path) as f: members = json.load(f) groups = {} for m in members: g = m["group"] groups[g] = groups.get(g, 0) + 1 return groups ``` --- ## 設置提醒(OpenClaw Scheduled Tasks) 提醒使用 OpenClaw 的 `mcp__scheduled-tasks__create_scheduled_task` 工具來創建持久化的定時任務。 ### 設置生日提醒 為成員設置生日提醒時,需要計算提醒日期並創建 scheduled task: ``` 步驟: 1. 確定生日日期(新曆或農曆轉新曆) 2. 計算提醒日期 = 生日日期 - advance_days 3. 使用 mcp__scheduled-tasks__create_scheduled_task 創建任務 4. 將提醒信息寫入 reminders.json ``` #### Scheduled Task 參數範例 **新曆生日提醒**(每年固定日期): ``` taskId: "member-birthday-{member_name}-solar" cronExpression: "0 9 {remind_day} {remind_month} *" prompt: "🎂 提醒:{member_name} 的生日在 {advance_days} 天後({birthday_month}/{birthday_day})!請準備祝福!\n\n成員資料:\n- 關係:{relationship}\n- 組別:{group}\n- 備註:{notes}" description: "{member_name} 生日提醒(提前 {advance_days} 天)" ``` **農曆生日提醒**(日期每年變化,需配合年度更新): ``` taskId: "member-birthday-{member_name}-lunar" cronExpression: "0 9 {remind_day} {remind_month} *" prompt: "🎂 提醒:{member_name} 的農曆生日在 {advance_days} 天後(農曆{lunar_month}月{lunar_day}日,今年新曆{solar_date})!\n\n成員資料:\n- 關係:{relationship}\n- 組別:{group}\n- 備註:{notes}" description: "{member_name} 農曆生日提醒(提前 {advance_days} 天)" ``` **紀念日提醒**: ``` taskId: "member-anniversary-{member_name}-{anniversary_label}" cronExpression: "0 9 {remind_day} {remind_month} *" prompt: "💝 提醒:{member_name} 的{anniversary_label}在 {advance_days} 天後({date})!\n\n成員資料:\n- 關係:{relationship}\n- 備註:{notes}" description: "{member_name} {anniversary_label}提醒" ``` ### 提醒日期計算(跨月處理) **重要**:不能簡單用 `day - advance_days`,必須用日期運算處理跨月情況: ```python from datetime import date, timedelta import calendar def get_reminder_cron(target_month, target_day, advance_days): """計算提醒的 cron expression,正確處理跨月和跨年""" today = date.today() year = today.year m, d = int(target_month), int(target_day) # 處理 2/29 非閏年 fallback if m == 2 and d == 29 and not calendar.isleap(year): d = 28 target = date(year, m, d) # 如果今年的目標日期已過,使用明年 if target < today: year += 1 if m == 2 and int(target_day) == 29 and not calendar.isleap(year): d = 28 target = date(year, m, d) remind_date = target - timedelta(days=advance_days) # 返回 cron: 分 時 日 月 星期 return f"0 9 {remind_date.day} {remind_date.month} *" ``` ### 寫入 reminders.json ```python def save_reminder(base_dir, member_id, reminder_type, label, advance_days, task_id): """保存提醒記錄""" path = os.path.join(base_dir, "reminders.json") with open(path) as f: reminders = json.load(f) reminder = { "id": str(uuid.uuid4()), "member_id": member_id, "type": reminder_type, "label": label, "advance_days": advance_days, "scheduled_task_id": task_id, "enabled": True } reminders.append(reminder) with open(path, "w") as f: json.dump(reminders, f, ensure_ascii=False, indent=2) return reminder ``` ### 年度農曆更新任務 應設置一個每年 1 月 1 日執行的 scheduled task,自動更新所有農曆生日的新曆日期: ``` taskId: "member-lunar-annual-update" cronExpression: "0 10 1 1 *" prompt: "執行 member-manager 年度農曆更新: 1. 讀取 members.json 中所有有農曆生日的成員 2. 使用 lunardate 將每個農曆生日轉換為今年的新曆日期 3. 更新 birthday_lunar_solar_this_year 欄位 4. 更新所有農曆生日提醒的 cron expression(因為新曆日期變了) 5. 回報更新結果" description: "每年自動更新農曆生日對應的新曆日期" ``` --- ## 輸出格式 ### 成員列表顯示格式 ``` 📋 {group} 成員列表(共 N 人) 1. {name}({relationship}) 🎂 生日:{birthday_solar}(新曆)/ 農曆{lunar_month}月{lunar_day}日 📞 {phone} 📝 {notes} 2. ... ``` ### 即將到來的事件顯示格式 ``` 📅 未來 {days} 天的重要日子: 🎂 {date} — {name} 的生日({age} 歲) 💝 {date} — {name} — {anniversary_label}(第 N 年) ... 共 N 個事件。 ``` ### 操作結果確認格式 ``` ✅ 已添加成員:{name}({group} / {relationship}) 生日:{birthday_info} 已設置提醒:提前 {advance_days} 天 ✅ 已刪除成員:{name} 已清理 {N} 個相關提醒 ✅ 已更新 {N} 位成員的農曆生日新曆日期 ``` --- ## 常用指令示例 以下是用戶可以對 AI 說的話,以及 skill 應如何回應: | 用戶說 | AI 應執行 | |--------|-----------| | 幫我加一個成員:媽媽,農曆三月初五生日 | add_member(group 默認「家庭」) + lunar_to_solar + 存檔 + 詢問是否設置提醒 | | 加同事小王,5月3號生日 | add_member(group「同事」)+ 存檔 | | 查看這個月有哪些生日 | upcoming_events(days_ahead=30),以表格顯示 | | 幫我提前 3 天提醒張三的生日 | 計算提醒日期 + create_scheduled_task + save_reminder | | 更新今年所有農曆生日的新曆日期 | update_all_lunar_birthdays + 更新相關提醒的 cron | | 顯示家庭組的所有成員 | search_members(group="家庭"),以列表顯示 | | 幫張三加一個結婚紀念日 6/20 | add_anniversary + 詢問是否設置提醒 | | 刪除成員李四 | 確認後 delete_member + 清理提醒 + 停用 scheduled tasks | | 修改張三的電話 | update_member(phone=...) | | 列出所有組別 | list_groups 顯示組別及人數 | | 查看所有提醒 | 讀取 reminders.json 並用 list_scheduled_tasks 顯示狀態 | | 設置每年自動更新農曆 | 創建 member-lunar-annual-update scheduled task | --- ## 互動流程 ### 添加成員的完整互動流程 ``` 用戶:幫我加一個成員,我媽媽,農曆三月初五生日 AI: 1. 解析信息:姓名=媽媽,關係=母親,農曆生日=三月初五 2. 計算今年新曆日期 3. 確認: 「確認添加以下成員: - 姓名:媽媽 - 組別:家庭 - 關係:母親 - 農曆生日:三月初五(今年新曆 {date}) 是否正確?要設置生日提醒嗎?」 4. 用戶確認後存檔 5. 如果要提醒,詢問提前幾天(默認 3 天) 6. 創建 scheduled task + 保存 reminder ``` ### 農曆日期的自然語言解析 用戶可能用多種方式表達農曆日期: - 「農曆三月初五」→ month=3, day=5 - 「陰曆八月十五」→ month=8, day=15 - 「農曆閏四月初一」→ month=4, day=1, is_leap_month=true - 「二月二」→ month=2, day=2 --- ## 安全與隱私 ### PII 數據保護 本 skill 存儲個人可識別信息(PII),包括姓名、電話、生日等。**所有數據以明文 JSON 存儲在本地磁碟**。 **必要的安全措施:** 1. **文件權限**:數據目錄應設置為僅用戶可讀(`chmod 700 ~/.openclaw/workspace/users/`) 2. **備份加密**:備份時應使用加密(如 `gpg` 或加密壓縮包) 3. **不要將數據目錄同步到雲端**(如 iCloud、Dropbox),除非雲端本身有加密 ```python # 初始化時設置目錄權限 import stat os.makedirs(base_dir, exist_ok=True) os.chmod(base_dir, stat.S_IRWXU) # 700: 僅 owner 可讀寫執行 ``` ### Session Key 安全 Session key **必須經過清洗**才能用於構建文件路徑,防止目錄遍歷攻擊(如 `../../etc/passwd`)。參見上方 `sanitize_session_key()` 函數。 ### Agent 自主存取控制 當此 skill 被 AI agent 自主調用時: - **讀取操作**(查看成員、查詢事件):可自動執行 - **寫入操作**(添加/編輯/刪除成員):**必須先向用戶確認** - **提醒設置**(創建 scheduled task):**必須先向用戶確認** - **刪除操作**(刪除成員及提醒):**必須先向用戶確認,且明確列出將被刪除的內容** ### lunardate 依賴 推薦使用 `lunardate` 套件(PyPI: `lunardate`): - **維護者**:lidaobing - **版本**:0.2.2(2023-12-07) - **許可證**:GPLv3(注意:若 skill 本身使用 MIT-0,分發時需考慮 GPL 兼容性) - **支持範圍**:1900–2099 年的農曆轉換 - **狀態**:Beta,無已知安全漏洞 - **安裝前**建議用 `pip-audit` 掃描確認 --- ## 注意事項 1. **農曆閏月**:`birthday_lunar.is_leap_month` 標記是否為閏月,轉換時需傳入 `is_leap=True`。若該年無閏月,自動 fallback 到非閏月 2. **跨年生日查詢**:查詢即將到來的事件時,如果今年的日期已過,應查看明年的日期 3. **跨用戶隔離**:永遠不要在沒有 session key 的情況下讀寫成員數據 4. **Cron 管理**:刪除成員時,必須同步刪除 reminders.json 條目並停用 scheduled tasks 5. **重複檢查**:添加成員前檢查是否已存在同名同組成員 6. **lunardate 安裝**:首次使用農曆功能時,需確認 `lunardate` 已安裝(`pip install lunardate`),並用 `pip-audit` 掃描 7. **2 月 29 日處理**:使用 `_safe_replace_year()` 自動 fallback 到 2/28(非閏年) 8. **文件權限**:確保數據目錄權限為 700,防止其他用戶讀取 PII