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`.
|
- Отдельный системный пользователь `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.
|
- Мониторинг — `backup-metrics backup_tg_export` → `BackupFailed`/`BackupStale` (>30 ч). ⚠️ Первый прогон может идти дольше суток из-за FloodWait.
|
||||||
- Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта.
|
- Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `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