rpi: books-sync — заметки читалки в Obsidian «Книги/» (pull по export-токену)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gilfanovr
2026-10-18 00:39:22 +03:00
co-authored by Claude Opus 5.5
parent 8a06244ef4
commit 71486960c5
5 changed files with 202 additions and 0 deletions
+5
View File
@@ -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».
+23
View File
@@ -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 # переписать файлы принудительно
```
+154
View File
@@ -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()
@@ -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
@@ -0,0 +1,9 @@
[Unit]
Description=Читалка books → Obsidian каждые 10 минут
[Timer]
OnBootSec=5min
OnUnitInactiveSec=10min
[Install]
WantedBy=timers.target