Files
Pascal MichelandClaude Opus 5.5 fa1c26b407 Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:27:59 +02:00

143 lines
5.5 KiB
Python

"""Schmaler HTTP-Client für die BookStack-API — Standardbibliothek, kein requests."""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
import uuid
from collections.abc import Callable
SEITENGROESSE = 500
WIEDERHOLUNGEN_BEI_429 = 2
# Ohne eigene Kennung schickt urllib "Python-urllib/3.x". Cloudflare und ähnliche
# Schutzschichten vor BookStack-Instanzen beantworten das mit 403, noch bevor die
# API das Token überhaupt sieht. Gefunden beim ersten Lauf gegen die echte Instanz.
KENNUNG = "bookstack-upload/1.0 (Claude Code Skill)"
class ApiFehler(Exception):
def __init__(self, status: int, meldung: str, pfad: str) -> None:
super().__init__(f"BookStack antwortete {status} auf {pfad}: {meldung}")
self.status = status
self.meldung = meldung
self.pfad = pfad
def _baue_multipart(
felder: dict[str, str], datei: tuple[str, str, bytes, str]
) -> tuple[bytes, str]:
"""Baut einen multipart/form-data-Körper von Hand — die Standardbibliothek kann das nicht."""
grenze = "----bookstackupload" + uuid.uuid4().hex
teile: list[bytes] = []
for name, wert in felder.items():
teile.append(
f'--{grenze}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{wert}\r\n'.encode(
"utf-8"
)
)
feldname, dateiname, inhalt, mime = datei
teile.append(
(
f"--{grenze}\r\n"
f'Content-Disposition: form-data; name="{feldname}"; filename="{dateiname}"\r\n'
f"Content-Type: {mime}\r\n\r\n"
).encode("utf-8")
+ inhalt
+ b"\r\n"
)
teile.append(f"--{grenze}--\r\n".encode("utf-8"))
return b"".join(teile), f"multipart/form-data; boundary={grenze}"
class BookStackApi:
def __init__(
self,
basis_url: str,
token_id: str,
token_secret: str,
*,
mindestabstand: float = 0.34,
schlaefer: Callable[[float], None] = time.sleep,
uhr: Callable[[], float] = time.monotonic,
) -> None:
self.basis_url = basis_url.rstrip("/")
self._autorisierung = f"Token {token_id}:{token_secret}"
self._mindestabstand = mindestabstand
self._schlaefer = schlaefer
self._uhr = uhr
self._letzter_aufruf: float | None = None
def _drossle(self) -> None:
jetzt = self._uhr()
if self._letzter_aufruf is not None:
rest = self._mindestabstand - (jetzt - self._letzter_aufruf)
if rest > 0:
self._schlaefer(rest)
self._letzter_aufruf = self._uhr()
def _einmal(self, methode: str, pfad: str, koerper: bytes | None, inhaltstyp: str | None) -> dict:
self._drossle()
anfrage = urllib.request.Request(self.basis_url + pfad, data=koerper, method=methode)
anfrage.add_header("Authorization", self._autorisierung)
anfrage.add_header("Accept", "application/json")
anfrage.add_header("User-Agent", KENNUNG)
if inhaltstyp is not None:
anfrage.add_header("Content-Type", inhaltstyp)
try:
with urllib.request.urlopen(anfrage, timeout=60) as antwort:
roh = antwort.read()
except urllib.error.HTTPError as fehler:
roh_fehler = fehler.read()
try:
meldung = json.loads(roh_fehler)["error"]["message"]
except (ValueError, KeyError, TypeError):
meldung = str(fehler.reason)
raise ApiFehler(fehler.code, meldung, pfad) from None
except urllib.error.URLError as fehler:
raise ApiFehler(0, f"nicht erreichbar ({fehler.reason})", pfad) from None
return json.loads(roh) if roh else {}
def _mit_wiederholung(
self, methode: str, pfad: str, koerper: bytes | None, inhaltstyp: str | None
) -> dict:
for versuch in range(WIEDERHOLUNGEN_BEI_429 + 1):
try:
return self._einmal(methode, pfad, koerper, inhaltstyp)
except ApiFehler as fehler:
if fehler.status != 429 or versuch == WIEDERHOLUNGEN_BEI_429:
raise
self._schlaefer(2.0 * (versuch + 1))
raise AssertionError("unerreichbar")
def anfrage(self, methode: str, pfad: str, daten: dict | None = None) -> dict:
"""Ein JSON-API-Aufruf. Bei 429 wird bis zu zweimal nachgefasst."""
if daten is None:
return self._mit_wiederholung(methode, pfad, None, None)
return self._mit_wiederholung(
methode, pfad, json.dumps(daten).encode("utf-8"), "application/json"
)
def sende_formular(
self, pfad: str, felder: dict[str, str], datei: tuple[str, str, bytes, str]
) -> dict:
"""POST als multipart/form-data — nötig für die Bildergalerie."""
koerper, inhaltstyp = _baue_multipart(felder, datei)
return self._mit_wiederholung("POST", pfad, koerper, inhaltstyp)
def liste(self, pfad: str) -> list[dict]:
"""Holt alle Seiten einer Sammlung und gibt sie als eine Liste zurück."""
eintraege: list[dict] = []
versatz = 0
trenner = "&" if "?" in pfad else "?"
while True:
antwort = self.anfrage(
"GET", f"{pfad}{trenner}count={SEITENGROESSE}&offset={versatz}"
)
teil = antwort.get("data", [])
eintraege.extend(teil)
versatz += len(teil)
if not teil or versatz >= int(antwort.get("total", len(eintraege))):
return eintraege