""" 把 Telegram Saved Messages 裡的新訊息落地成 DragonsHoard materials/ 底下的檔案。 前置: 1. 到 my.telegram.org 申請 api_id / api_hash 2. 複製 telegram_config.example.json 為 telegram_config.json(同目錄,已在 .gitignore),填入 api_id/api_hash/phone 3. pip install telethon 4. 第一次執行會用 phone 收驗證碼登入,之後靠 session 檔案免重複登入 用法:python telegram_materials_sync.py """ import asyncio import json import re import sys from pathlib import Path from telethon import TelegramClient if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") SCRIPT_DIR = Path(__file__).resolve().parent MATERIALS_DIR = SCRIPT_DIR.parent / "materials" CONFIG_PATH = SCRIPT_DIR / "telegram_config.json" CHECKPOINT_PATH = SCRIPT_DIR / "telegram_checkpoint.json" SESSION_PATH = str(SCRIPT_DIR / "telegram_materials") ILLEGAL_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') def load_config() -> dict: if not CONFIG_PATH.exists(): raise SystemExit( f"找不到 {CONFIG_PATH}。\n" "先複製 telegram_config.example.json 為 telegram_config.json 並填入 api_id/api_hash/phone。" ) return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) def load_checkpoint() -> int: if CHECKPOINT_PATH.exists(): return json.loads(CHECKPOINT_PATH.read_text(encoding="utf-8")).get("min_id", 0) return 0 def save_checkpoint(min_id: int) -> None: CHECKPOINT_PATH.write_text(json.dumps({"min_id": min_id}), encoding="utf-8") def filename_for(text: str) -> str: first_line = text.strip().splitlines()[0] if text.strip() else "無標題" cleaned = ILLEGAL_CHARS.sub("", first_line).strip()[:60] return cleaned or "無標題" def write_material(name: str, text: str) -> bool: """寫入素材檔案;同名同內容視為其他機器已抓過的重複,跳過不寫。回傳是否真的寫了新檔。""" path = MATERIALS_DIR / f"{name}.md" if path.exists() and path.read_text(encoding="utf-8") == text: return False n = 2 while path.exists(): path = MATERIALS_DIR / f"{name} ({n}).md" n += 1 path.write_text(text, encoding="utf-8") return True def write_media(name: str, ext: str, data: bytes) -> bool: """寫入圖片素材;同名同內容(bytes 相同)視為其他機器已抓過的重複,跳過不寫。回傳是否真的寫了新檔。""" path = MATERIALS_DIR / f"{name}{ext}" if path.exists() and path.read_bytes() == data: return False n = 2 while path.exists(): path = MATERIALS_DIR / f"{name} ({n}){ext}" n += 1 path.write_bytes(data) return True async def process_photo_group(messages: list) -> int: """處理同一相簿(grouped_id 相同)或單張圖片的訊息,寫成一則 .md 筆記+對應圖片檔案(Obsidian embed 語法連結)。回傳新增檔案數。""" caption = next((m.text for m in messages if m.text), "") base = filename_for(caption) if caption else f"圖片-{messages[0].date.strftime('%Y%m%d-%H%M%S')}" written = 0 embeds = [] single = len(messages) == 1 for i, m in enumerate(messages, start=1): ext = m.file.ext if m.file and m.file.ext else ".jpg" data = await m.download_media(file=bytes) if not data: continue image_name = base if single else f"{base}-{i}" if write_media(image_name, ext, data): written += 1 embeds.append(f"![[{image_name}{ext}]]") note_body = (caption + "\n\n" if caption else "") + "\n".join(embeds) if write_material(base, note_body): written += 1 return written async def main() -> None: config = load_config() checkpoint = load_checkpoint() MATERIALS_DIR.mkdir(exist_ok=True) client = TelegramClient(SESSION_PATH, config["api_id"], config["api_hash"]) await client.start(phone=config.get("phone")) try: latest = await client.get_messages("me", limit=1) latest_id = latest[0].id if latest else 0 if latest_id <= checkpoint: print(f"目前已是最新(checkpoint={checkpoint}),沒有新素材") return new_checkpoint = checkpoint count = 0 pending_group: list = [] pending_gid = None async def flush_group(): nonlocal count, pending_group if pending_group: count += await process_photo_group(pending_group) pending_group = [] async for message in client.iter_messages("me", min_id=checkpoint, reverse=True): if message.photo: gid = message.grouped_id if gid is not None and gid == pending_gid: pending_group.append(message) else: await flush_group() pending_group = [message] pending_gid = gid else: await flush_group() pending_gid = None if message.text: if write_material(filename_for(message.text), message.text): count += 1 new_checkpoint = max(new_checkpoint, message.id) await flush_group() if new_checkpoint > checkpoint: save_checkpoint(new_checkpoint) print(f"抓到 {count} 則新素材,checkpoint 更新到 {new_checkpoint}") finally: await client.disconnect() if __name__ == "__main__": asyncio.run(main())