diff --git a/servers/rpi.md b/servers/rpi.md index cc68c27..a4b4282 100644 --- a/servers/rpi.md +++ b/servers/rpi.md @@ -297,3 +297,8 @@ Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian ст - Отдельный системный пользователь `tgexport` (без шелла): сессия `/var/lib/tg-export/tg.session` (600) = полный доступ к аккаунту — Astra её не видит; ключи приложения `/etc/tg-export/env` (root:tgexport 640, те же `api_id`/`api_hash`, что на Mac). Вход — владелец: `ssh -t rpi sudo -u tgexport tg-export login`. - Мониторинг — `backup-metrics backup_tg_export` → `BackupFailed`/`BackupStale` (>30 ч). ⚠️ Первый прогон может идти дольше суток из-за FloodWait. - Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта. + +## Читалка books → Obsidian (с 2026-10-18) + +- Заметки, рекапы и диалоги с ИИ из books.ruzzy.dev → vault `Книги/` (файл на книгу) раз в 10 мин: `books-sync.timer` (astra) тянет `GET /api/export` с токеном `export:obsidian-rpi` (`/etc/books-sync/token`, root:astra 640; на fr1 только хэш). Export-токены читалка пускает только на `/api/export`. Подробно — [`stacks/rpi-books-sync/`](../stacks/rpi-books-sync/README.md). +- Односторонняя: выше `## Мои мысли` перезаписывается, раздел владельца сохраняется. Первая синхронизация 2026-10-18 00:38 — «Efficient Go», «Observability Engineering». diff --git a/stacks/rpi-books-sync/README.md b/stacks/rpi-books-sync/README.md new file mode 100644 index 0000000..180547f --- /dev/null +++ b/stacks/rpi-books-sync/README.md @@ -0,0 +1,23 @@ +# Читалка books → Obsidian (rpi) + +С 2026-10-18: заметки, рекапы и диалоги с ИИ из читалки books.ruzzy.dev (fr1) раз в 10 минут попадают в vault +владельца, папка **`Книги/`** — файл на книгу, главы разделами. Дальше их разносит Obsidian Sync (Mac, iPhone, Boox). + +- **Доставка — pull с малины по HTTPS** (fr1 до малины за NAT не достучится): `GET https://books.ruzzy.dev/api/export?v=<версия>` + с Bearer-токеном **`export:obsidian-rpi`**. Токены с префиксом `export:` читалка пускает **только** на `/api/export` + (MCP и остальной API — 401). Ответ без изменений — `{"changed": false}`, данные не передаются. +- Токен — `/etc/books-sync/token` (root:astra 640), в читалке — только хэш (`api_token`). Отозвать: + `docker compose exec books python3 books_mcp.py revoke export:obsidian-rpi` на fr1; выдать новый — `add-token` (stdin). +- `/usr/local/bin/books-sync` (stdlib), `books-sync.timer` (каждые 10 мин после окончания прогона, юзер astra), + состояние `/var/lib/books-sync/state.json` (версия, book_id → файл). +- **Односторонняя:** всё выше `## Мои мысли` перезаписывается из читалки; сам раздел и всё ниже — не трогается + (свои мысли писать туда). Правки заметок — в читалке. Книгу переименовали — файл переименовывается вместе с «Моими мыслями». +- Файлы — только для книг, где есть заметки/рекапы/диалоги. Длинные цитаты обрезаются до 1500 знаков. + Удалённая из читалки книга — файл остаётся. + +```sh +sudo install -m 755 books-sync /usr/local/bin/ && sudo install -d -o astra -g astra -m 750 /var/lib/books-sync +sudo install -m 644 systemd/books-sync.* /etc/systemd/system/ && sudo systemctl daemon-reload +sudo systemctl enable --now books-sync.timer +sudo -u astra books-sync --force # переписать файлы принудительно +``` diff --git a/stacks/rpi-books-sync/books-sync b/stacks/rpi-books-sync/books-sync new file mode 100755 index 0000000..5545f07 --- /dev/null +++ b/stacks/rpi-books-sync/books-sync @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""books-sync — заметки, рекапы и диалоги читалки books.ruzzy.dev → Obsidian-vault владельца (папка «Книги/»). + +Раз в 10 мин (books-sync.timer, юзер astra): GET /api/export с токеном «export:obsidian-rpi» (/etc/books-sync/token); +если версия не изменилась — ничего не делаем. Файл на книгу: «Книги/<название>.md», главы — разделами (рекап, заметки, +диалоги). Всё выше заголовка «## Мои мысли» перезаписывается из читалки, сам раздел и всё ниже — не трогаем. +Синхронизация односторонняя: правки заметок — в читалке. Только stdlib. + + books-sync синхронизировать (таймер) + books-sync --force переписать файлы, даже если версия та же +""" +import datetime as dt +import json +import os +import re +import sys +import urllib.request +from pathlib import Path +from zoneinfo import ZoneInfo + +URL = os.environ.get("BOOKS_URL", "https://books.ruzzy.dev") +TOKEN = Path(os.environ.get("BOOKS_SYNC_TOKEN", "/etc/books-sync/token")) +STATE = Path(os.environ.get("BOOKS_SYNC_STATE", "/var/lib/books-sync/state.json")) +VAULT = Path(os.environ.get("ASTRA_VAULT", "/home/astra/vault")) +FOLDER = "Книги" +MINE = "## Мои мысли" +QUOTE_MAX = 1500 +TZ = ZoneInfo("Asia/Nicosia") +KIND = {"note": "📝 Заметка", "mine": "✎ Стикер", "later": "⏳ Разобрать позже"} + + +def fetch(version): + req = urllib.request.Request(f"{URL}/api/export?v={version}", + headers={"Authorization": "Bearer " + TOKEN.read_text().strip()}) + with urllib.request.urlopen(req, timeout=60) as r: + return json.load(r) + + +def safe(name, n=90): + name = re.split(r"\s+--\s+", name or "")[0] # «Название -- Автор -- 2024 -- …» из имени файла + name = re.sub(r'[\\/:*?"<>|#^\[\]\x00-\x1f]', " ", name).strip(" .") + return re.sub(r"\s+", " ", name)[:n].rstrip(" .") or "Без названия" + + +def quote(text): + text = (text or "").strip() + if len(text) > QUOTE_MAX: + text = text[:QUOTE_MAX].rstrip() + " …" + return "\n".join("> " + l if l.strip() else ">" for l in text.splitlines()) + + +def demote(md): + """Заголовки внутри рекапа/ответа ИИ — ниже уровня главы, чтобы не ломать структуру файла.""" + return re.sub(r"(?m)^(#{1,4}) ", lambda m: "#" * min(6, len(m.group(1)) + 3) + " ", (md or "").strip()) + + +def day(ts): + try: + return dt.datetime.fromisoformat(ts).strftime("%d.%m.%Y") + except (TypeError, ValueError): + return "" + + +def render(site, b, chapters, notes, recaps, dialogs): + url = f"{site}/book/{b['id']}" + title = re.split(r"\s+--\s+", b["title"])[0] + out = ["---", f"книга: {json.dumps(title, ensure_ascii=False)}"] + if b.get("author"): + out.append(f"автор: {json.dumps(b['author'], ensure_ascii=False)}") + out += [f"читалка: {url}", "источник: books-sync — всё выше «Мои мысли» перезаписывается из читалки", + "tags: [книга]", "---", "", f"# {title}", ""] + out.append(" · ".join(x for x in (b.get("author") or "", f"[Открыть в читалке]({url})") if x)) + idxs = sorted({x["chapter_idx"] for x in notes + recaps + dialogs}) + for idx in idxs: + ch = chapters.get(idx, {"title": f"Глава {idx}"}) + out += ["", f"## {ch['title']}", f"[в читалке]({url}/ch/{idx})"] + r = [x for x in recaps if x["chapter_idx"] == idx] + if r: + out += ["", "### Рекап", "", demote(r[0]["md"])] + ns = [x for x in notes if x["chapter_idx"] == idx] + if ns: + out += ["", "### Заметки"] + for n in ns: + meta = " · ".join(x for x in (KIND.get(n["kind"], n["kind"]), f"с. {n['page']}" if n.get("page") else "", + day(n.get("updated") or n.get("created")), + "✓ разобрано" if n.get("status") == "done" else "") if x) + out += ["", f"**{meta}**"] + if n["kind"] != "note" and n.get("quote"): # у «заметки» цитата уже в тексте (как в читалке) + out.append(quote(n["quote"])) + if (n.get("body") or "").strip(): + out += ["", n["body"].strip()] + ds = [x for x in dialogs if x["chapter_idx"] == idx] + if ds: + out += ["", "### Диалоги с ИИ"] + for d in ds: + head = re.sub(r"\s+", " ", d.get("quote") or "").strip() + head = head[:70].rstrip() + ("…" if len(head) > 70 else "") + out += ["", f"#### «{head}» · {day(d.get('created'))}", quote(d.get("quote"))] + for m in d["msgs"]: + who = "**Я:**" if m["role"] == "user" else "**ИИ:**" + out += ["", who, demote(m["text"])] + return "\n".join(out).rstrip() + "\n" + + +def write_book(path, generated): + """Сгенерированная часть + раздел «Мои мысли» из прежнего файла (или пустой).""" + mine = f"\n{MINE}\n\n" + if path.exists(): + old = path.read_text(encoding="utf-8") + i = old.find("\n" + MINE) + if i >= 0: + mine = old[i:] + text = generated + mine + if path.exists() and path.read_text(encoding="utf-8") == text: + return False + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name("." + path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, path) + return True + + +def main(): + force = "--force" in sys.argv + state = json.loads(STATE.read_text()) if STATE.exists() else {} + data = fetch("" if force else state.get("version", "")) + if not data.get("changed"): + return + chapters = {} + for ch in data["chapters"]: + chapters.setdefault(ch["book_id"], {})[ch["idx"]] = ch + files = state.get("files", {}) + changed = [] + for b in data["books"]: + sel = lambda xs: [x for x in xs if x["book_id"] == b["id"]] + notes, recaps, dialogs = sel(data["notes"]), sel(data["recaps"]), sel(data["dialogs"]) + if not (notes or recaps or dialogs): + continue + rel = f"{FOLDER}/{safe(b['title'])}.md" + old = files.get(str(b["id"])) + if old and old != rel and (VAULT / old).exists() and not (VAULT / rel).exists(): + os.replace(VAULT / old, VAULT / rel) # книгу переименовали — файл вместе с «Моими мыслями» + if write_book(VAULT / rel, render(data["site"], b, chapters.get(b["id"], {}), notes, recaps, dialogs)): + changed.append(rel) + files[str(b["id"])] = rel + STATE.parent.mkdir(parents=True, exist_ok=True) + STATE.write_text(json.dumps({"version": data["version"], "files": files, + "synced": dt.datetime.now(TZ).isoformat(timespec="seconds")}, ensure_ascii=False)) + for rel in changed: + print("обновлено:", rel) + + +if __name__ == "__main__": + main() diff --git a/stacks/rpi-books-sync/systemd/books-sync.service b/stacks/rpi-books-sync/systemd/books-sync.service new file mode 100644 index 0000000..171029a --- /dev/null +++ b/stacks/rpi-books-sync/systemd/books-sync.service @@ -0,0 +1,11 @@ +[Unit] +Description=Читалка books → Obsidian: заметки, рекапы, диалоги в «Книги/» +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=astra +Group=astra +ExecStart=/usr/local/bin/books-sync +TimeoutStartSec=5min diff --git a/stacks/rpi-books-sync/systemd/books-sync.timer b/stacks/rpi-books-sync/systemd/books-sync.timer new file mode 100644 index 0000000..2536470 --- /dev/null +++ b/stacks/rpi-books-sync/systemd/books-sync.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Читалка books → Obsidian каждые 10 минут + +[Timer] +OnBootSec=5min +OnUnitInactiveSec=10min + +[Install] +WantedBy=timers.target