#!/usr/bin/env python3 """ sync_cards.py — Memos 卡片备份脚本(基于 uid 去重) 每30分钟由 cron 触发,将当前用户的所有 Memos 卡片备份到 cards/ 目录, 并推送到 Gitea inspiration-collector 仓库。 去重方式:按 memo.uid 比对,不重复写入。 """ import os import sys import re import subprocess from datetime import datetime, timezone, timedelta import requests # ── 配置 ── BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CARDS_DIR = os.path.join(BASE_DIR, "cards") GIT_DIR = BASE_DIR MEMOS_URL = os.environ.get("MEMOS_URL", "http://localhost:5230") MEMOS_TOKEN = os.environ.get("MEMOS_ACCESS_TOKEN", "") TZ_BEIJING = timezone(timedelta(hours=8)) KNOWN_TAGS = {"#功能完善", "#90后四级副职培训班", "#灵感收集器"} class MemosClient: def __init__(self, base_url, token): self.base_url = base_url.rstrip("/") self.session = requests.Session() self.session.headers.update({ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }) def list_all_memos(self): all_memos = [] page_token = "" while True: params = {"pageSize": 100} if page_token: params["pageToken"] = page_token resp = self.session.post( f"{self.base_url}/memos.api.v1.MemoService/ListMemos", json=params, timeout=15 ) resp.raise_for_status() data = resp.json() all_memos.extend(data.get("memos", [])) next_token = data.get("nextPageToken", "") if not next_token: break page_token = next_token return all_memos def sanitize_filename(text): text = re.sub(r'[<>:"/\\|?*]', "", text) text = re.sub(r'\s+', "_", text.strip()) return text[:80] or "memo" def extract_tags(content): """提取内容中的 #标签 """ return [w for w in content.split() if w.startswith("#") and w in KNOWN_TAGS] def load_existing_uids(cards_dir): """扫描现有卡片文件,收集所有 uid + 文件名作为去重依据""" uids = set() fnames = set() if not os.path.isdir(cards_dir): return uids, fnames for fname in os.listdir(cards_dir): if not fname.endswith(".md"): continue fnames.add(fname) fpath = os.path.join(cards_dir, fname) try: with open(fpath, "r", encoding="utf-8") as f: head = f.read(500) # 匹配 uid: "xxx" 或 id: xxx 或 uid: xxx(无引号) m = re.search(r'^(?:uid|id):\s*"?([^"\n]+)"?', head, re.MULTILINE) if m: val = m.group(1).strip() if val: uids.add(val) except Exception: continue return uids, fnames def memo_to_markdown(memo): uid = memo.get("uid", "") content = memo.get("content", "") create_time = memo.get("createTime", "") try: dt = datetime.fromisoformat(create_time.replace("Z", "+00:00")) dt_bj = dt.astimezone(TZ_BEIJING) time_str = dt_bj.strftime("%Y-%m-%dT%H:%M:%S+08:00") file_ts = dt_bj.strftime("%Y%m%d%H%M%S") except Exception: time_str = create_time file_ts = uid[:14] if uid else "unknown" first_line = content.strip().split("\n")[0][:60] filename = f"{file_ts}_{sanitize_filename(first_line)}.md" filepath = os.path.join(CARDS_DIR, filename) md = f"""--- title: "{first_line}" date: {time_str} uid: "{uid}" source: "memos" --- {content} """ return filepath, md def git_push(directory, message): try: subprocess.run(["git", "add", "-A"], cwd=directory, check=True, capture_output=True, timeout=30) result = subprocess.run( ["git", "diff", "--cached", "--quiet"], cwd=directory, capture_output=True, timeout=15 ) if result.returncode == 0: return subprocess.run( ["git", "commit", "-m", message], cwd=directory, check=True, capture_output=True, timeout=30 ) subprocess.run( ["git", "push", "origin", "main"], cwd=directory, check=True, capture_output=True, timeout=60 ) print(f"[OK] 已推送: {message}") except subprocess.CalledProcessError as e: print(f"[WARN] git 操作失败: {e.stderr.decode()[:200]}") except Exception as e: print(f"[WARN] git 异常: {e}") def main(): if not MEMOS_TOKEN: print("[ERROR] MEMOS_ACCESS_TOKEN 未设置") sys.exit(1) os.makedirs(CARDS_DIR, exist_ok=True) # 加载已有 uid + 文件名 existing_uids, existing_fnames = load_existing_uids(CARDS_DIR) print(f"[INFO] 已有 {len(existing_fnames)} 个文件,{len(existing_uids)} 个 uid") client = MemosClient(MEMOS_URL, MEMOS_TOKEN) print(f"[INFO] 正在从 Memos 拉取...") all_memos = client.list_all_memos() print(f"[INFO] 共获取 {len(all_memos)} 条卡片") new_count = 0 for memo in all_memos: uid = memo.get("uid", "") if uid and uid in existing_uids: continue # uid 已存在,跳过 filepath, md = memo_to_markdown(memo) fname = os.path.basename(filepath) if fname in existing_fnames: continue # 文件名已存在,跳过(兼容旧版无 uid 的卡片) with open(filepath, "w", encoding="utf-8") as f: f.write(md) new_count += 1 print(f" [+] {fname}") if uid: existing_uids.add(uid) existing_fnames.add(fname) if new_count > 0: print(f"[INFO] 新增 {new_count} 条卡片,推送至 Gitea...") git_push(GIT_DIR, f"cards: 备份 {new_count} 条 Memos 卡片") else: print(f"[INFO] 无新增卡片") if __name__ == "__main__": main()