rpi: books-sync — заметки читалки в Obsidian «Книги/» (pull по export-токену)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8a06244ef4
commit
71486960c5
@@ -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».
|
||||
|
||||
@@ -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 # переписать файлы принудительно
|
||||
```
|
||||
Executable
+154
@@ -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
|
||||
Reference in New Issue
Block a user