194 lines
5.9 KiB
Python
Executable File
194 lines
5.9 KiB
Python
Executable File
#!/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()
|