LernApp/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt
Oliver Wallisch b082df962f
All checks were successful
CI Pipeline / lint-and-test (pull_request) Successful in 2m38s
fix: fixed h5p integration and api call for the age
2026-09-15 11:38:36 +02:00

515 lines
20 KiB
Kotlin

package com.oliver.kidio.backend.ingestion
import kotlinx.serialization.json.*
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
data class ZumOerItem(
val id: String,
val title: String,
val subject: String,
val grade: Int? = null,
val downloadUrl: String,
val license: String = "CC BY-SA 4.0",
val library: String? = null,
val rawJsonContent: String? = null,
)
class ZumApiClient(
private val httpClient: HttpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build(),
) {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
companion object {
fun mapSubject(raw: String): String {
val lower = raw.trim().lowercase()
return when {
lower.contains("mathe") -> "Mathematik"
lower.contains("deutsch") || lower.contains("german") -> "Deutsch"
lower.contains("sach") || lower.contains("hsu") || lower.contains("natur") || lower.contains("heimat") -> "Sachunterricht"
lower.contains("englisch") || lower.contains("english") -> "Englisch"
lower.contains("bio") -> "Biologie"
lower.contains("geo") || lower.contains("erdkunde") -> "Geographie"
lower.contains("gesch") -> "Geschichte"
lower.contains("chemie") -> "Chemie"
lower.contains("physik") -> "Physik"
lower.contains("musik") -> "Musik"
lower.contains("kunst") -> "Kunst"
lower.contains("informatik") -> "Informatik"
else -> raw.trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
}
fun mapGradeToAgeRange(grade: Int?): Pair<Int, Int> {
return when (grade) {
1 -> 6 to 7
2 -> 7 to 8
3 -> 8 to 9
4 -> 9 to 10
5 -> 10 to 11
6 -> 11 to 12
7 -> 12 to 13
8 -> 13 to 14
9 -> 14 to 15
10 -> 15 to 16
11, 12, 13 -> 16 to 18
else -> 6 to 12
}
}
}
/**
* Downloads an .h5p package file from a remote URL.
*/
fun downloadH5P(downloadUrl: String): ByteArray? {
return try {
val request =
HttpRequest.newBuilder()
.uri(URI.create(downloadUrl))
.timeout(Duration.ofSeconds(15))
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray())
if (response.statusCode() == 200) {
response.body()
} else {
null
}
} catch (e: Exception) {
println("WARNUNG: Download fehlgeschlagen für $downloadUrl: ${e.message}")
null
}
}
/**
* Fetches live OER items from apps.zum.de via search and Drupal settings extraction.
* Skips items whose IDs are already present in existingIds.
* Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items.
*/
fun fetchLiveOerItems(
subject: String? = null,
limit: Int = 50,
maxPages: Int = 15,
existingIds: Set<String> = emptySet(),
): List<ZumOerItem> {
val results = mutableListOf<ZumOerItem>()
val seenAppIds = mutableSetOf<String>()
println("[ZUM-Apps Live] Starte Live-Abfrage auf apps.zum.de (Fach: ${subject ?: "Alle Fächer"}, Limit: $limit)...")
for (page in 0 until maxPages) {
if (results.size >= limit) break
val searchUrl =
if (subject.isNullOrBlank()) {
"https://apps.zum.de/apps?page=$page"
} else {
val encoded = URLEncoder.encode(subject, "UTF-8")
"https://apps.zum.de/apps?keys=$encoded&page=$page"
}
val html = fetchString(searchUrl)
if (html == null) {
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
continue
}
// Filtert bereits verarbeitete oder in DB vorhandene Apps heraus
val appIds =
Regex("""href=["']/apps/(\d+)["']""")
.findAll(html)
.map { it.groupValues[1] }
.distinct()
.filter { appId ->
val fullId = "zum-$appId"
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
}
.toList()
if (appIds.isEmpty()) {
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
continue
}
for (appId in appIds) {
if (results.size >= limit) break
seenAppIds.add(appId)
val item = fetchAppDetails(appId, subject)
if (item != null) {
results.add(item)
val lib = item.library ?: "H5P"
val gr = item.grade ?: "k.A."
println(
"[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]",
)
}
}
}
if (results.isEmpty()) {
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
return getCuratedOerItems(subject = subject, limit = limit)
}
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
return results
}
private fun fetchAppDetails(
appId: String,
fallbackSubject: String?,
): ZumOerItem? {
val appUrl = "https://apps.zum.de/apps/$appId"
val html = fetchString(appUrl) ?: return null
val settingsMatch = Regex("""data-drupal-selector="drupal-settings-json">([^<]+)<""").find(html) ?: return null
val jsonStr = settingsMatch.groupValues[1]
val drupalSettings =
try {
json.parseToJsonElement(jsonStr).jsonObject
} catch (_: Exception) {
return null
}
val h5pContents =
drupalSettings["h5p"]?.jsonObject
?.get("H5PIntegration")?.jsonObject
?.get("contents")?.jsonObject
?: return null
val contentObj =
h5pContents["cid-$appId"]?.jsonObject
?: h5pContents.values.firstOrNull()?.jsonObject
?: return null
val library = contentObj["library"]?.jsonPrimitive?.contentOrNull ?: ""
if (!library.contains("MultiChoice", ignoreCase = true) &&
!library.contains("TrueFalse", ignoreCase = true) &&
!library.contains("QuestionSet", ignoreCase = true)
) {
return null
}
val jsonContent = contentObj["jsonContent"]?.jsonPrimitive?.contentOrNull ?: return null
val metadata = contentObj["metadata"]?.jsonObject
val title =
metadata?.get("title")?.jsonPrimitive?.contentOrNull
?: Regex("""<title>(.*?)(?:\|.*)?</title>""").find(html)?.groupValues?.get(1)?.trim()
?: "ZUM App #$appId"
val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull
val license =
when {
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> "CC BY-SA 4.0"
rawLicense.contains("CC", ignoreCase = true) -> rawLicense
else -> "CC BY-SA 4.0 ($rawLicense)"
}
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
// Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags)
val tags =
Regex("""<a[^>]*>([^<]+)</a>""")
.findAll(html)
.map { it.groupValues[1].trim() }
.filter { it.isNotBlank() && it.length < 60 }
.toList()
val subject = inferSubject(title, tags, fallbackSubject)
val grade = inferGrade(title, html, tags)
return ZumOerItem(
id = "zum-$appId",
title = title,
subject = subject,
grade = grade,
downloadUrl = exportUrl,
license = license,
library = library,
rawJsonContent = jsonContent,
)
}
private fun inferSubject(
title: String,
tags: List<String>,
fallbackSubject: String?,
): String {
for (tag in tags) {
val mapped = mapSubject(tag)
if (mapped != tag ||
tag.equals("Mathematik", ignoreCase = true) ||
tag.equals("Deutsch", ignoreCase = true) ||
tag.equals("Biologie", ignoreCase = true) ||
tag.equals("Englisch", ignoreCase = true)
) {
return mapped
}
}
if (!fallbackSubject.isNullOrBlank()) {
return mapSubject(fallbackSubject)
}
return mapSubject(title)
}
private fun inferGrade(
title: String,
html: String,
tags: List<String>,
): Int? {
val primaryText = (tags + title).joinToString(" ")
// 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl."
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3"
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 3. Muster: "Klasse 3-4", "Klasse 3/4", "3.-4. Klasse"
Regex("""\b(?:Klasse|Kl\.)?\s*(\d+)\s*[-/]\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 4. Fallback: Gesamten HTML-Text der Seite nach Klassenangaben absuchen
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 5. Fallback: Schulstufen-Schlüsselwörter
val combinedLower = (primaryText + " " + html).lowercase()
if (combinedLower.contains("primarstufe") || combinedLower.contains("grundschule")) return 3
val isSek1 =
combinedLower.contains("sekundarstufe i") ||
combinedLower.contains("sek i") ||
combinedLower.contains("realschule") ||
combinedLower.contains("hauptschule")
if (isSek1) return 6
val isSek2 =
combinedLower.contains("sekundarstufe ii") ||
combinedLower.contains("sek ii") ||
combinedLower.contains("gymnasium") ||
combinedLower.contains("abitur")
if (isSek2) return 10
return null
}
private fun fetchString(url: String): String? {
return try {
val request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(12))
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "de-DE,de;q=0.9,en;q=0.8")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
if (response.statusCode() == 200) {
response.body()
} else {
null
}
} catch (e: Exception) {
null
}
}
/**
* Returns curated ZUM/OER sample metadata items, filterable by subject.
*/
fun getCuratedOerItems(
subject: String? = null,
limit: Int = 20,
): List<ZumOerItem> {
val allItems =
listOf(
ZumOerItem(
id = "zum-math-001",
title = "Grundrechenarten: Multiplikation 3. Klasse",
subject = "Mathematik",
grade = 3,
downloadUrl = "https://apps.zum.de/api/h5p/math-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Was ist das Ergebnis von <strong>6 &times; 8</strong>?</p>",
"answers": [
{"text": "42", "correct": false},
{"text": "48", "correct": true},
{"text": "54", "correct": false},
{"text": "56", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-math-002",
title = "Zahlenraum bis 100: Addition",
subject = "Mathematik",
grade = 2,
downloadUrl = "https://apps.zum.de/api/h5p/math-002.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Berechne: 34 + 28</p>",
"answers": [
{"text": "52", "correct": false},
{"text": "62", "correct": true},
{"text": "64", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-math-003",
title = "Geometrie: Formen erkennen",
subject = "Mathematik",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/math-003.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.</p>",
"correct": true
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-de-001",
title = "Wortarten: Nomen erkennen",
subject = "Deutsch",
grade = 2,
downloadUrl = "https://apps.zum.de/api/h5p/de-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Welches der folgenden W&ouml;rter ist ein <strong>Nomen (Namenwort)</strong>?</p>",
"answers": [
{"text": "schnell", "correct": false},
{"text": "laufen", "correct": false},
{"text": "Schule", "correct": true},
{"text": "sch&ouml;n", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-de-002",
title = "Rechtschreibung: Doppel-s oder ss",
subject = "Deutsch",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/de-002.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Wie wird das Wort richtig geschrieben? &bdquo;Der Flu... ist tief.&ldquo;</p>",
"answers": [
{"text": "Flus", "correct": false},
{"text": "Fluss", "correct": true},
{"text": "Fluß", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-sach-001",
title = "Natur & Tiere: Winterschlaf",
subject = "Sachunterricht",
grade = 3,
downloadUrl = "https://apps.zum.de/api/h5p/sach-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Welches dieser Tiere h&auml;lt einen echten <strong>Winterschlaf</strong>?</p>",
"answers": [
{"text": "Igel", "correct": true},
{"text": "Eichh&ouml;rnchen", "correct": false},
{"text": "Amsel", "correct": false},
{"text": "Fuchs", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-sach-002",
title = "Planetensystem: Sonnennähe",
subject = "Sachunterricht",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/sach-002.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>Welcher Planet ist der Sonne am n&auml;chsten?</p>",
"answers": [
{"text": "Venus", "correct": false},
{"text": "Mars", "correct": false},
{"text": "Merkur", "correct": true},
{"text": "Jupiter", "correct": false}
]
}
""".trimIndent(),
),
ZumOerItem(
id = "zum-eng-001",
title = "English Vocabulary: Colors & Animals",
subject = "Englisch",
grade = 4,
downloadUrl = "https://apps.zum.de/api/h5p/eng-001.h5p",
license = "CC BY-SA 4.0",
rawJsonContent =
"""
{
"question": "<p>What is the English word for &bdquo;Hund&ldquo;?</p>",
"answers": [
{"text": "Cat", "correct": false},
{"text": "Dog", "correct": true},
{"text": "Bird", "correct": false},
{"text": "Rabbit", "correct": false}
]
}
""".trimIndent(),
),
)
val normalizedSubject = subject?.let { mapSubject(it) }
val filtered =
if (normalizedSubject.isNullOrBlank()) {
allItems
} else {
allItems.filter { it.subject.equals(normalizedSubject, ignoreCase = true) }
}
return filtered.take(limit)
}
}