Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

// 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。
// 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。
val baseAppVersionName = "2.8.0"
val baseAppVersionName = "2.9.0"

fun String?.nonBlankOrNull(): String? =
this?.trim()?.takeIf { it.isNotBlank() }
Expand Down Expand Up @@ -62,6 +62,9 @@ android {
compose = true
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
Expand Down Expand Up @@ -98,6 +101,8 @@ android {
}

dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs.nio)

implementation(project(":ui-design"))
implementation(project(":background-task"))
implementation(project(":ui-tv"))
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ protobuf = "3.25.9"
protobuf-plugin = "0.9.6"
onnxruntime = "1.25.1"
icu4j = "78.3"
jsoup = "1.23.1"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
desugar-jdk-libs-nio = "2.0.1"
libvlc = "3.6.0"

[libraries]
Expand Down Expand Up @@ -78,6 +80,8 @@ androidx-security-crypto = { group = "androidx.security", name = "security-crypt
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
icu4j = { group = "com.ibm.icu", name = "icu4j", version.ref = "icu4j" }
jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
desugar-jdk-libs-nio = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugar-jdk-libs-nio" }

# === Coroutines ===
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.miruplay.tv.repository

import com.miruplay.tv.core.common.Result

sealed interface BangumiCommentContent {
data class Text(
val value: String,
val style: BangumiCommentTextStyle = BangumiCommentTextStyle(),
) : BangumiCommentContent
data class Image(
val url: String,
val description: String? = null,
val inline: Boolean = false,
) : BangumiCommentContent
data class Spoiler(val children: List<BangumiCommentContent>) : BangumiCommentContent
}

data class BangumiCommentTextStyle(
val bold: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false,
val strikethrough: Boolean = false,
val linkUrl: String? = null,
)

data class BangumiCommentUser(
val id: Int? = null,
val name: String,
val avatarUrl: String? = null,
)

data class BangumiEpisodeComment(
val id: Int,
val user: BangumiCommentUser,
val content: List<BangumiCommentContent>,
val createdAt: String? = null,
val replies: List<BangumiEpisodeComment> = emptyList(),
)

interface BangumiEpisodeCommentsService {
suspend fun getEpisodeComments(episodeId: Int): Result<List<BangumiEpisodeComment>>
}
1 change: 1 addition & 0 deletions scraper-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies {
api(project(":core:common"))
api(project(":repository-api"))
implementation(libs.icu4j)
implementation(libs.jsoup)
implementation(libs.okhttp)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import com.miruplay.tv.repository.BangumiApiPayloads
import com.miruplay.tv.repository.BangumiCollectionService
import com.miruplay.tv.repository.BangumiEpisodeCollection
import com.miruplay.tv.repository.BangumiEpisodeCollectionType
import com.miruplay.tv.repository.BangumiEpisodeCommentsService
import com.miruplay.tv.repository.BangumiEpisodeComment
import com.miruplay.tv.repository.BangumiEpisodeMetadata
import com.miruplay.tv.repository.BangumiJsonMapper
import com.miruplay.tv.repository.BangumiSubjectCollection
Expand All @@ -36,12 +38,14 @@ import java.util.concurrent.TimeUnit

class BangumiApiClient(
baseUrl: String = DEFAULT_BASE_URL,
websiteBaseUrl: String = DEFAULT_WEBSITE_BASE_URL,
private val tokenProvider: () -> String? = { null },
private val userAgent: String = DEFAULT_USER_AGENT,
private val normalizeQuery: (String) -> String = { it },
private val archiveSearch: BangumiArchiveSubjectSearch? = null,
) : BangumiCollectionService {
) : BangumiCollectionService, BangumiEpisodeCommentsService {
private val baseHttpUrl: HttpUrl = baseUrl.toHttpUrl()
private val websiteHttpUrl: HttpUrl = websiteBaseUrl.toHttpUrl()
private val client = BangumiProxyAwareOkHttpClient(defaultClient())
private val json = Json { ignoreUnknownKeys = true }
private val mediaType = "application/json".toMediaType()
Expand Down Expand Up @@ -216,6 +220,38 @@ class BangumiApiClient(
}
}

override suspend fun getEpisodeComments(
episodeId: Int,
): Result<List<BangumiEpisodeComment>> = PerformanceLog.measureSuspendResult(
tag = PERFORMANCE_TAG,
operation = "bangumi.episode_comments",
attributes = mapOf("episode_id" to episodeId.toString()),
) {
withContext(Dispatchers.IO) {
try {
val url = websiteHttpUrl.newBuilder().encodedPath("/ep/$episodeId").build()
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", userAgent)
.addHeader("Accept", "text/html,application/xhtml+xml")
.get()
.build()
Result.success(
BangumiEpisodeCommentHtmlParser.parse(
html = executeText(request),
baseUrl = url.toString(),
),
)
} catch (error: Exception) {
Result.failure(
AppError.ScrapingError.ApiError(
SOURCE_NAME,
error.message ?: "Failed to read episode comments",
),
)
}
}
}
override suspend fun getCurrentUser(): Result<BangumiUser> = withContext(Dispatchers.IO) {
try {
requireToken()
Expand Down Expand Up @@ -342,6 +378,21 @@ class BangumiApiClient(
}
}

private fun executeText(request: Request): String = PerformanceLog.measure(
tag = PERFORMANCE_TAG,
operation = "bangumi.http_text",
attributes = request.performanceAttributes(),
) {
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw IllegalStateException("HTTP ${response.code}: ${body.ifBlank { response.message }}")
}
if (body.isBlank()) throw IllegalStateException("Empty response")
body
}
}

private fun executeJson(request: Request): JsonElement = PerformanceLog.measure(
tag = PERFORMANCE_TAG,
operation = "bangumi.http_json",
Expand Down Expand Up @@ -381,6 +432,7 @@ class BangumiApiClient(

companion object {
const val DEFAULT_BASE_URL = "https://api.bgm.tv"
const val DEFAULT_WEBSITE_BASE_URL = "https://bgm.tv"
const val DEFAULT_USER_AGENT = "ModerRAS/MiruPlay/0.1.0 (https://github.com/ModerRAS/MiruPlay)"
const val SOURCE_NAME = "Bangumi"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.miruplay.tv.scraper.core

import com.miruplay.tv.repository.BangumiCommentContent
import com.miruplay.tv.repository.BangumiCommentTextStyle
import com.miruplay.tv.repository.BangumiCommentUser
import com.miruplay.tv.repository.BangumiEpisodeComment
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode

internal object BangumiEpisodeCommentHtmlParser {
fun parse(html: String, baseUrl: String): List<BangumiEpisodeComment> {
val document = Jsoup.parse(html, baseUrl)
val comments = document.select(
"#comment_list > .row_reply, .singleCommentList .commentList > .row_reply",
).distinctBy(Element::id).mapNotNull(::parseTopLevelComment)
val declaredCount = document.selectFirst(".singleCommentList h2.subtitle .tip")
?.text()
?.trim()
?.toIntOrNull()
if (declaredCount != null && declaredCount > 0 && comments.isEmpty()) {
throw IllegalStateException("Bangumi episode comment markup is unsupported")
}
return comments
}

private fun parseTopLevelComment(element: Element): BangumiEpisodeComment? {
val comment = parseComment(element) ?: return null
val replies = element.children().firstOrNull { it.hasClass("topic_sub_reply") }
?.children()
?.filter { it.hasClass("sub_reply_bg") }
?.mapNotNull(::parseComment)
.orEmpty()
return comment.copy(replies = replies)
}

private fun parseComment(element: Element): BangumiEpisodeComment? {
val id = element.id().removePrefix("post_").toIntOrNull() ?: return null
val inner = element.children().firstOrNull { it.hasClass("inner") }
val author = inner?.selectFirst("strong > a") ?: inner?.selectFirst("a.l")
val username = element.attr("data-item-user").ifBlank {
author?.attr("href")?.substringAfterLast('/').orEmpty()
}
val name = author?.text()?.trim().orEmpty().ifBlank {
username.ifBlank { "Bangumi 用户" }
}
val avatar = element.children().firstOrNull { it.tagName() == "a" && it.hasClass("avatar") }
?.selectFirst("[style*=background-image]")
?.attr("style")
?.substringAfter("url(", "")
?.substringBeforeLast(')')
?.trim(' ', '\'', '"')
?.let { resolveUrl(it, element.baseUri()) }
val time = element.children().firstOrNull { it.hasClass("post_actions") }
?.selectFirst("small")
?.text()
?.substringAfter(" - ", "")
?.trim()
?.ifBlank { null }
val body = inner?.children()?.firstOrNull { it.hasClass("reply_content") }
?.selectFirst(".message")
?: return null
return BangumiEpisodeComment(
id = id,
user = BangumiCommentUser(name = name, avatarUrl = avatar),
content = parseContent(body),
createdAt = time,
)
}

private fun parseContent(root: Element): List<BangumiCommentContent> =
parseNodes(root.childNodes(), BangumiCommentTextStyle(), root.baseUri()).trimBoundaryWhitespace()

private fun parseNodes(
nodes: List<Node>,
style: BangumiCommentTextStyle,
baseUrl: String,
): List<BangumiCommentContent> {
val result = mutableListOf<BangumiCommentContent>()
nodes.forEach { node ->
when (node) {
is TextNode -> appendText(result, node.wholeText, style)
is Element -> when {
node.hasClass("text_mask") -> result += BangumiCommentContent.Spoiler(
parseNodes(node.childNodes(), style, baseUrl),
)
node.tagName() == "img" -> {
val url = resolveUrl(node.attr("src"), baseUrl)
if (url != null) {
result += BangumiCommentContent.Image(
url = url,
description = node.attr("alt").ifBlank { null },
inline = node.hasClass("smile"),
)
} else {
appendText(result, node.attr("alt"), style)
}
}
node.tagName() == "br" -> appendText(result, "\n", style)
else -> result += parseNodes(
node.childNodes(),
styleForElement(style, node, baseUrl),
baseUrl,
)
}
}
}
return result
}

private fun styleForElement(
style: BangumiCommentTextStyle,
element: Element,
baseUrl: String,
): BangumiCommentTextStyle = when (element.tagName()) {
"b", "strong" -> style.copy(bold = true)
"i", "em" -> style.copy(italic = true)
"u" -> style.copy(underline = true)
"s", "del", "strike" -> style.copy(strikethrough = true)
"a" -> style.copy(linkUrl = resolveUrl(element.attr("href"), baseUrl))
else -> style
}

private fun appendText(
result: MutableList<BangumiCommentContent>,
text: String,
style: BangumiCommentTextStyle,
) {
if (text.isEmpty()) return
val previous = result.lastOrNull() as? BangumiCommentContent.Text
if (previous?.style == style) {
result[result.lastIndex] = previous.copy(value = previous.value + text)
} else {
result += BangumiCommentContent.Text(text, style)
}
}

private fun List<BangumiCommentContent>.trimBoundaryWhitespace(): List<BangumiCommentContent> {
val result = toMutableList()
val first = result.firstOrNull() as? BangumiCommentContent.Text
if (first != null) {
val value = first.value.trimStart()
if (value.isEmpty()) result.removeAt(0) else result[0] = first.copy(value = value)
}
val last = result.lastOrNull() as? BangumiCommentContent.Text
if (last != null) {
val value = last.value.trimEnd()
if (value.isEmpty()) result.removeAt(result.lastIndex) else result[result.lastIndex] = last.copy(value = value)
}
return result
}

private fun resolveUrl(value: String, baseUrl: String): String? {
val normalized = when {
value.startsWith("//") -> "https:$value"
value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true) -> value
value.startsWith('/') -> Jsoup.parse("<a href=\"$value\"></a>", baseUrl).selectFirst("a")?.absUrl("href")
else -> null
}
return normalized?.takeIf {
it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true)
}
}
}
Loading
Loading