[Komiic] Refactor and fix formatting

master
Koitharu 6 months ago
parent 8effefcd50
commit df1cab3f9d
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.parsers.site.zh package org.koitharu.kotatsu.parsers.site.zh
import kotlinx.coroutines.CancellationException
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@ -26,6 +27,9 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.Request import okhttp3.Request
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@MangaSourceParser("KOMIIC", "Komiic", "zh") @MangaSourceParser("KOMIIC", "Komiic", "zh")
internal class KomiicParser(context: MangaLoaderContext) : internal class KomiicParser(context: MangaLoaderContext) :
@ -57,10 +61,13 @@ internal class KomiicParser(context: MangaLoaderContext) :
// 简单的第一页列表缓存,返回上次已加载的数据以避免返回时闪烁刷新 // 简单的第一页列表缓存,返回上次已加载的数据以避免返回时闪烁刷新
@Volatile @Volatile
private var recentFirstPageCache: List<Manga>? = null private var recentFirstPageCache: List<Manga>? = null
@Volatile @Volatile
private var recentFirstPageCacheLevel: Int? = null private var recentFirstPageCacheLevel: Int? = null
@Volatile @Volatile
private var searchFirstPageCacheQuery: String? = null private var searchFirstPageCacheQuery: String? = null
@Volatile @Volatile
private var searchFirstPageCache: List<Manga>? = null private var searchFirstPageCache: List<Manga>? = null
@ -161,7 +168,14 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
} else if (filter.tags.isNotEmpty()) { } else if (filter.tags.isNotEmpty()) {
val categoryIds = filter.tags.map { it.key }.filter { it.matches(Regex("^\\d+")) } val categoryIds = filter.tags.map { it.key }.filter { it.matches(Regex("^\\d+")) }
if (categoryIds.isNotEmpty()) { if (categoryIds.isNotEmpty()) {
listByCategories(categoryIds, offset, orderByParam, statusParam, asc = false, sexyLevel = remoteSexyLevel) listByCategories(
categoryIds,
offset,
orderByParam,
statusParam,
asc = false,
sexyLevel = remoteSexyLevel,
)
} else { } else {
when (orderByParam) { when (orderByParam) {
"DATE_UPDATED" -> { "DATE_UPDATED" -> {
@ -181,6 +195,7 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
list list
} }
} }
else -> hotComics(offset, orderByParam, statusParam, remoteSexyLevel) else -> hotComics(offset, orderByParam, statusParam, remoteSexyLevel)
} }
} }
@ -203,6 +218,7 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
list list
} }
} }
else -> hotComics(offset, orderByParam, statusParam, remoteSexyLevel) else -> hotComics(offset, orderByParam, statusParam, remoteSexyLevel)
} }
} }
@ -232,14 +248,17 @@ private suspend fun recentUpdate(offset: Int, status: String = "", sexyLevel: In
} }
""".trimIndent() """.trimIndent()
val variables = JSONObject().apply { val variables = JSONObject().apply {
put("pagination", JSONObject().apply { put(
"pagination",
JSONObject().apply {
put("limit", pageSize) put("limit", pageSize)
put("offset", offset) put("offset", offset)
put("orderBy", "DATE_UPDATED") put("orderBy", "DATE_UPDATED")
put("status", status) put("status", status)
put("asc", true) put("asc", true)
if (sexyLevel != null) put("sexyLevel", sexyLevel) if (sexyLevel != null) put("sexyLevel", sexyLevel)
}) },
)
} }
val data = apiCall(query, "recentUpdate", variables) val data = apiCall(query, "recentUpdate", variables)
val arr: JSONArray = data.optJSONArray("recentUpdate") ?: JSONArray() val arr: JSONArray = data.optJSONArray("recentUpdate") ?: JSONArray()
@ -268,21 +287,31 @@ private suspend fun hotComics(offset: Int, orderBy: String, status: String, sexy
} }
""".trimIndent() """.trimIndent()
val variables = JSONObject().apply { val variables = JSONObject().apply {
put("pagination", JSONObject().apply { put(
"pagination",
JSONObject().apply {
put("limit", pageSize) put("limit", pageSize)
put("offset", offset) put("offset", offset)
put("orderBy", orderBy) put("orderBy", orderBy)
put("status", status) put("status", status)
put("asc", true) put("asc", true)
if (sexyLevel != null) put("sexyLevel", sexyLevel) if (sexyLevel != null) put("sexyLevel", sexyLevel)
}) },
)
} }
val data = apiCall(query, "hotComics", variables) val data = apiCall(query, "hotComics", variables)
val arr: JSONArray = data.optJSONArray("hotComics") ?: JSONArray() val arr: JSONArray = data.optJSONArray("hotComics") ?: JSONArray()
return arr.toMangaList() return arr.toMangaList()
} }
private suspend fun listByCategories(categoryIds: List<String>, offset: Int, orderBy: String, status: String, asc: Boolean, sexyLevel: Int? = null): List<Manga> { private suspend fun listByCategories(
categoryIds: List<String>,
offset: Int,
orderBy: String,
status: String,
asc: Boolean,
sexyLevel: Int? = null,
): List<Manga> {
val query = """ val query = """
query comicByCategories(${'$'}categoryId: [ID!]!, ${'$'}pagination: Pagination!) { query comicByCategories(${'$'}categoryId: [ID!]!, ${'$'}pagination: Pagination!) {
comicByCategories(categoryId: ${'$'}categoryId, pagination: ${'$'}pagination) { comicByCategories(categoryId: ${'$'}categoryId, pagination: ${'$'}pagination) {
@ -304,17 +333,23 @@ private suspend fun listByCategories(categoryIds: List<String>, offset: Int, ord
} }
""".trimIndent() """.trimIndent()
val variables = JSONObject().apply { val variables = JSONObject().apply {
put("categoryId", JSONArray().apply { put(
"categoryId",
JSONArray().apply {
categoryIds.forEach { put(it) } categoryIds.forEach { put(it) }
}) },
put("pagination", JSONObject().apply { )
put(
"pagination",
JSONObject().apply {
put("limit", pageSize) put("limit", pageSize)
put("offset", offset) put("offset", offset)
put("orderBy", orderBy) put("orderBy", orderBy)
put("status", status) put("status", status)
put("asc", asc) put("asc", asc)
if (sexyLevel != null) put("sexyLevel", sexyLevel) if (sexyLevel != null) put("sexyLevel", sexyLevel)
}) },
)
} }
val data = apiCall(query, "comicByCategories", variables) val data = apiCall(query, "comicByCategories", variables)
val arr: JSONArray = data.optJSONArray("comicByCategories") ?: JSONArray() val arr: JSONArray = data.optJSONArray("comicByCategories") ?: JSONArray()
@ -363,13 +398,18 @@ private fun JSONArray.toMangaList(): List<Manga> = mapJSON { jo ->
"ONGOING", "ongoing" -> MangaState.ONGOING "ONGOING", "ongoing" -> MangaState.ONGOING
else -> null else -> null
} }
val authors = jo.optJSONArray("authors")?.mapJSONNotNull { a -> a.optString("name").takeIf { it.isNotEmpty() } } val authors = jo.optJSONArray("authors")?.mapJSONNotNullToSet { a ->
?.toSet().orEmpty() a.optString("name").takeIf { it.isNotEmpty() }
val catNames = jo.optJSONArray("categories")?.mapJSONNotNull { c -> c.optString("name").takeIf { it.isNotEmpty() } }?.toSet().orEmpty() }.orEmpty()
val catIds = jo.optJSONArray("categories")?.mapJSONNotNull { c -> c.optString("id").takeIf { it.isNotEmpty() } }?.toSet().orEmpty() val catNames = jo.optJSONArray("categories")?.mapJSONNotNullToSet { c ->
val tags = catNames.zip(catIds.ifEmpty { catNames }).map { (n, k) -> c.optString("name").takeIf { it.isNotEmpty() }
}.orEmpty()
val catIds = jo.optJSONArray("categories")?.mapJSONNotNullToSet { c ->
c.optString("id").takeIf { it.isNotEmpty() }
}.orEmpty()
val tags = catNames.zip(catIds.ifEmpty { catNames }).mapToSet { (n, k) ->
MangaTag(title = n, key = k, source = source) MangaTag(title = n, key = k, source = source)
}.toSet() }
val rating = guessContentRating(catNames) val rating = guessContentRating(catNames)
Manga( Manga(
id = generateUid(id.ifEmpty { title }), id = generateUid(id.ifEmpty { title }),
@ -395,7 +435,7 @@ private suspend fun applyLocalFilters(list: List<Manga>, filter: MangaListFilter
result = result.filter { m -> m.state != null && filter.states.contains(m.state) } result = result.filter { m -> m.state != null && filter.states.contains(m.state) }
} }
if (filter.tagsExclude.isNotEmpty()) { if (filter.tagsExclude.isNotEmpty()) {
val excludeKeys = filter.tagsExclude.map { it.key }.toSet() val excludeKeys = filter.tagsExclude.mapToSet { it.key }
result = result.filter { m -> m.tags.none { t -> excludeKeys.contains(t.key) } } result = result.filter { m -> m.tags.none { t -> excludeKeys.contains(t.key) } }
} }
return result return result
@ -411,14 +451,14 @@ private fun parseSexyLevel(filter: MangaListFilter): Int? {
ContentRating.SUGGESTIVE -> 1 ContentRating.SUGGESTIVE -> 1
ContentRating.ADULT -> 4 ContentRating.ADULT -> 4
} }
max = if (max == null) v else kotlin.math.max(max!!, v) max = if (max == null) v else kotlin.math.max(max, v)
} }
} }
return max return max
} }
private fun guessContentRating(tagNames: Set<String>): ContentRating? { private fun guessContentRating(tagNames: Set<String>): ContentRating? {
val lower = tagNames.map { it.lowercase(Locale.ROOT) }.toSet() val lower = tagNames.mapToSet { it.lowercase(Locale.ROOT) }
val adultKeys = setOf("成人", "限制", "r18", "情色", "nsfw", "十八禁") val adultKeys = setOf("成人", "限制", "r18", "情色", "nsfw", "十八禁")
val suggestiveKeys = setOf("後宮", "福利", "性感", "誘惑", "擦邊", "肉番") val suggestiveKeys = setOf("後宮", "福利", "性感", "誘惑", "擦邊", "肉番")
return when { return when {
@ -431,17 +471,89 @@ private fun guessContentRating(tagNames: Set<String>): ContentRating? {
@Volatile @Volatile
private var cachedTags: Set<MangaTag>? = null private var cachedTags: Set<MangaTag>? = null
private suspend fun fetchAvailableTags(): Set<MangaTag> { private fun fetchAvailableTags(): Set<MangaTag> {
val cached = cachedTags val cached = cachedTags
if (cached != null) return cached if (cached != null) return cached
// 以站点固定分类为基准(来源于官方前端配置),避免采样不全 // 以站点固定分类为基准(来源于官方前端配置),避免采样不全
val names = arrayOf( val names = arrayOf(
"愛情","神鬼","校園","搞笑","生活","懸疑","冒險","職場","魔幻","後宮","魔法","格鬥","宅男","勵志","耽美","科幻","百合","治癒","萌系","熱血","競技","推理","雜誌","偵探","偽娘","美食","恐怖","四格","社會","歷史","戰爭","舞蹈","武俠","機戰","音樂","體育","黑道" "愛情",
"神鬼",
"校園",
"搞笑",
"生活",
"懸疑",
"冒險",
"職場",
"魔幻",
"後宮",
"魔法",
"格鬥",
"宅男",
"勵志",
"耽美",
"科幻",
"百合",
"治癒",
"萌系",
"熱血",
"競技",
"推理",
"雜誌",
"偵探",
"偽娘",
"美食",
"恐怖",
"四格",
"社會",
"歷史",
"戰爭",
"舞蹈",
"武俠",
"機戰",
"音樂",
"體育",
"黑道",
) )
val ids = arrayOf( val ids = arrayOf(
"1","3","4","5","6","7","8","10","11","2","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","9","28","31","32","33","34","35","36","37","40","42" "1",
"3",
"4",
"5",
"6",
"7",
"8",
"10",
"11",
"2",
"12",
"13",
"14",
"15",
"16",
"17",
"18",
"19",
"20",
"21",
"22",
"23",
"24",
"25",
"26",
"27",
"9",
"28",
"31",
"32",
"33",
"34",
"35",
"36",
"37",
"40",
"42",
) )
val set = names.zip(ids).map { (n, k) -> MangaTag(title = n, key = k, source = source) }.toSet() val set = names.zip(ids).mapToSet { (n, k) -> MangaTag(title = n, key = k, source = source) }
cachedTags = set cachedTags = set
return set return set
} }
@ -455,7 +567,7 @@ private suspend fun fetchLewdnessLevel(comicId: String): Int? {
val cached = lewdnessCache[comicId] val cached = lewdnessCache[comicId]
if (cached != null) return cached if (cached != null) return cached
// 优先尝试 GraphQL 详情的 sexyLevel 字段(更稳定) // 优先尝试 GraphQL 详情的 sexyLevel 字段(更稳定)
runCatching { runCatchingCancellable {
val q = """ val q = """
query comicById(${'$'}id: ID!) { query comicById(${'$'}id: ID!) {
comicById(comicId: ${'$'}id) { id sexyLevel __typename } comicById(comicId: ${'$'}id) { id sexyLevel __typename }
@ -472,6 +584,8 @@ private suspend fun fetchLewdnessLevel(comicId: String): Int? {
}.getOrElse { /* ignore and fallback to HTML */ } }.getOrElse { /* ignore and fallback to HTML */ }
val doc = try { val doc = try {
webClient.httpGet("https://$domain/comic/$comicId", getRequestHeaders()).parseHtml() webClient.httpGet("https://$domain/comic/$comicId", getRequestHeaders()).parseHtml()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
return null return null
} }
@ -586,8 +700,9 @@ override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val comicId = chapter.branch val comicId = chapter.branch
// 预热图片页面,促使站点生成必要状态/缓存,减少首次为空 // 预热图片页面,促使站点生成必要状态/缓存,减少首次为空
if (!comicId.isNullOrEmpty()) { if (!comicId.isNullOrEmpty()) {
runCatching { runCatchingCancellable {
webClient.httpGet("https://$domain/comic/$comicId/chapter/$chapterId/images/all", getRequestHeaders()).close() webClient.httpGet("https://$domain/comic/$comicId/chapter/$chapterId/images/all", getRequestHeaders())
.close()
} }
} }
// 优先使用 imagesByChapterId若返回 kid则拼接 /api/image/{kid} // 优先使用 imagesByChapterId若返回 kid则拼接 /api/image/{kid}
@ -645,21 +760,26 @@ override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
for ((idx, d) in retryDelays.withIndex()) { for ((idx, d) in retryDelays.withIndex()) {
// 间隔退避 // 间隔退避
delay(d.toLong()) delay(d.toLong())
val ok = runCatching { val ok = runCatchingCancellable {
webClient.httpHead(firstUrl).close() webClient.httpHead(firstUrl).close()
true true
}.getOrDefault(false) }.getOrDefault(false)
if (ok) { ready = true; break } if (ok) {
ready = true; break
}
// 若仍未就绪,额外触发一次图片页预热后再试(首两次) // 若仍未就绪,额外触发一次图片页预热后再试(首两次)
if (!ready && idx < 2 && !comicId.isNullOrEmpty()) { if (!ready && idx < 2 && !comicId.isNullOrEmpty()) {
runCatching { runCatchingCancellable {
webClient.httpGet("https://$domain/comic/$comicId/chapter/$chapterId/images/all", getRequestHeaders()).close() webClient.httpGet(
"https://$domain/comic/$comicId/chapter/$chapterId/images/all",
getRequestHeaders(),
).close()
} }
} }
} }
// HEAD 在个别服务端可能不完全生效,最后补一次轻量 GET 以确认 // HEAD 在个别服务端可能不完全生效,最后补一次轻量 GET 以确认
if (!ready) { if (!ready) {
ready = runCatching { ready = runCatchingCancellable {
webClient.httpGet(firstUrl, getRequestHeaders()).close() webClient.httpGet(firstUrl, getRequestHeaders()).close()
true true
}.getOrDefault(false) }.getOrDefault(false)
@ -668,18 +788,24 @@ override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return pages return pages
} }
private suspend fun apiCall(query: String, operationName: String? = null, variables: JSONObject? = null): JSONObject { private suspend fun apiCall(
query: String,
operationName: String? = null,
variables: JSONObject? = null,
): JSONObject {
val body = JSONObject().apply { val body = JSONObject().apply {
put("operationName", operationName ?: JSONObject.NULL) put("operationName", operationName ?: JSONObject.NULL)
put("variables", variables ?: JSONObject()) put("variables", variables ?: JSONObject())
put("query", query) put("query", query)
} }
// 预热根域,尝试获取站点必要 Cookie // 预热根域,尝试获取站点必要 Cookie
runCatching { runCatchingCancellable {
webClient.httpGet("https://$domain/", getRequestHeaders()).close() webClient.httpGet("https://$domain/", getRequestHeaders()).close()
} }
val response = try { val response = try {
webClient.httpPost("https://${domain}/api/query".toHttpUrl(), body, getRequestHeaders()) webClient.httpPost("https://${domain}/api/query".toHttpUrl(), body, getRequestHeaders())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw ParseException( throw ParseException(
"Komiic 接口请求失败(可能被拦截或接口错误),请在浏览器打开主页以通过挑战页,然后回到应用重试", "Komiic 接口请求失败(可能被拦截或接口错误),请在浏览器打开主页以通过挑战页,然后回到应用重试",

Loading…
Cancel
Save