Add Ravensburger search/download API client

Scrapes the Ravensburger site for .gme download links (barcode or
free-text query), decodes HTML entities, and orchestrates
download/library/sync operations against the native Tiptoi plugin.
This commit is contained in:
2026-08-12 11:54:39 +02:00
parent 4c8e0d010b
commit 49786ac54f
+204
View File
@@ -0,0 +1,204 @@
import { Capacitor, CapacitorHttp } from '@capacitor/core'
import { Tiptoi } from './native/TiptoiPlugin'
// ── HTML-Entity-Dekodierung ─────────────────────────────────────────────────
function decodeHtmlEntities(str: string): string {
return str
.replace(/&/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&szlig;/g, 'ß')
.replace(/&auml;/g, 'ä').replace(/&Auml;/g, 'Ä')
.replace(/&ouml;/g, 'ö').replace(/&Ouml;/g, 'Ö')
.replace(/&uuml;/g, 'ü').replace(/&Uuml;/g, 'Ü')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
}
// ── Typen ────────────────────────────────────────────────────────────────────
export interface GmeEntry {
url: string
label: string
imageUrl: string | null
}
export interface SearchResult {
title?: string
gmeUrls?: GmeEntry[]
error?: string
}
export interface Book {
filename: string
coverUrl: string | null
size: number | null
}
export interface PenInfo {
uri: string
device: string
label: string | null
candidates: PenInfo[]
}
// ── Suche ────────────────────────────────────────────────────────────────────
export async function searchBook(q: string): Promise<SearchResult> {
let query = q
const isBarcode = /^\d{8,}$/.test(q)
if (isBarcode) query = q.slice(q.length - 6, q.length - 1)
try {
const apiRes = await CapacitorHttp.get({
url: `https://service.ravensburger.de/@api/deki/site/query?dream.out.format=json&limit=1&q=${encodeURIComponent(query)}&recommendations=true&sortBy=-rank&types=wiki&notrack=false&parser=bestguess`,
})
const data = apiRes.data as { result?: { uri?: string; title?: string } }
const pageUri = data.result?.uri
if (!pageUri) return { error: 'Kein Buch gefunden' }
const title = decodeHtmlEntities(data.result?.title ?? '')
if (isBarcode && !title.includes(query)) return { error: 'Kein Buch gefunden' }
const pageRes = await CapacitorHttp.get({ url: pageUri, responseType: 'text' })
const html = pageRes.data as string
const anchorRe = /<a\s[^>]*href="([^"]+\.gme)"[^>]*>([\s\S]*?)<\/a>/gi
const srcRe = /src="([^"]+)"/i
const altRe = /alt="([^"]*)"/i
const seen = new Set<string>()
const gmeUrls: GmeEntry[] = []
let am
while ((am = anchorRe.exec(html)) !== null) {
const url = decodeHtmlEntities(am[1])
if (seen.has(url)) continue
seen.add(url)
const content = am[2]
const srcMatch = srcRe.exec(content)
const altMatch = altRe.exec(content)
const imageUrl = srcMatch ? decodeHtmlEntities(srcMatch[1]) : null
const altText = altMatch ? decodeHtmlEntities(altMatch[1]) : ''
const numMatch = altText.match(/\b(\d{4,6})\b/)
const label = numMatch ? numMatch[1] : url.split('/').pop()!.replace(/\.gme$/i, '')
gmeUrls.push({ url, label, imageUrl })
}
if (gmeUrls.length === 0) {
for (const fm of html.matchAll(/href="([^"]+\.gme)"/gi)) {
const url = decodeHtmlEntities(fm[1])
if (!seen.has(url)) {
seen.add(url)
gmeUrls.push({ url, label: url.split('/').pop()!.replace(/\.gme$/i, ''), imageUrl: null })
}
}
}
if (gmeUrls.length === 0) return { error: 'Keine GME-Datei gefunden' }
return { title, gmeUrls }
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) }
}
}
// ── Download ─────────────────────────────────────────────────────────────────
export async function downloadBook(
gmeUrl: string,
imageUrl?: string,
): Promise<{ message: string; filename?: string }> {
const urlPath = new URL(gmeUrl).pathname
const base = decodeURIComponent(urlPath.split('/').pop()!).replace(/\.gme$/i, '')
const filename = `${base}.gme`
await Tiptoi.downloadGme({ url: gmeUrl, filename })
if (imageUrl) {
try {
const ext = new URL(imageUrl).pathname.match(/\.[^.]+$/)?.[0] ?? '.png'
await Tiptoi.downloadCover({ url: imageUrl, filename: `${base}${ext}` })
} catch { /* Cover ist optional */ }
}
return { message: `${filename} heruntergeladen`, filename }
}
// ── Bibliothek ───────────────────────────────────────────────────────────────
export async function listDownloads(): Promise<Book[]> {
const { books } = await Tiptoi.listDownloads()
return books.map(b => ({
filename: b.filename,
coverUrl: b.coverPath ? Capacitor.convertFileSrc(b.coverPath) : null,
size: b.size,
}))
}
// ── Stift-Erkennung ──────────────────────────────────────────────────────────
export async function detectPen(): Promise<PenInfo | null> {
const { uri } = await Tiptoi.getPenUri()
if (!uri) return null
return { uri, device: '', label: null, candidates: [] }
}
export async function requestPenAccess(): Promise<PenInfo | null> {
const { uri } = await Tiptoi.requestPenAccess()
return { uri, device: '', label: null, candidates: [] }
}
// ── Stift-Dateien ────────────────────────────────────────────────────────────
export async function listPenFiles(uri: string): Promise<string[]> {
const { files } = await Tiptoi.listPenFiles({ uri })
return files
}
export async function syncFile(
uri: string,
filename: string,
onProgress?: (fraction: number) => void,
): Promise<void> {
let handle: import('@capacitor/core').PluginListenerHandle | undefined
if (onProgress) {
handle = await Tiptoi.addListener('copyProgress', (e) => {
if (e.filename === filename && e.total > 0) {
onProgress(e.bytes / e.total)
}
})
}
try {
await Tiptoi.copyToPen({ uri, filename })
onProgress?.(1)
} finally {
await handle?.remove()
}
}
export async function syncAll(
uri: string,
onProgress?: (filename: string, fraction: number) => void,
): Promise<void> {
const [downloads, penFiles] = await Promise.all([listDownloads(), listPenFiles(uri)])
const onPen = new Set(penFiles)
for (const book of downloads) {
if (!onPen.has(book.filename)) {
await syncFile(uri, book.filename, onProgress ? (f) => onProgress(book.filename, f) : undefined)
}
}
}
export async function deleteDownload(filename: string): Promise<void> {
await Tiptoi.deleteDownload({ filename })
}
export async function deletePenFile(uri: string, filename: string): Promise<void> {
await Tiptoi.deletePenFile({ uri, filename })
}
export async function ejectPen(_uri: string, _device: string): Promise<{ success: boolean; message: string }> {
// Android: OS übernimmt das physische Trennen
return { success: true, message: 'Stift kann sicher abgezogen werden' }
}