|
|
|
|
@ -1,5 +1,6 @@
|
|
|
|
|
package org.koitharu.kotatsu.parsers.site.zh
|
|
|
|
|
|
|
|
|
|
import kotlinx.coroutines.CancellationException
|
|
|
|
|
import org.json.JSONArray
|
|
|
|
|
import org.json.JSONObject
|
|
|
|
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
|
|
|
@ -26,6 +27,9 @@ import okhttp3.Interceptor
|
|
|
|
|
import okhttp3.Response
|
|
|
|
|
import okhttp3.Request
|
|
|
|
|
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")
|
|
|
|
|
internal class KomiicParser(context: MangaLoaderContext) :
|
|
|
|
|
@ -57,10 +61,13 @@ internal class KomiicParser(context: MangaLoaderContext) :
|
|
|
|
|
// 简单的第一页列表缓存,返回上次已加载的数据以避免返回时闪烁刷新
|
|
|
|
|
@Volatile
|
|
|
|
|
private var recentFirstPageCache: List<Manga>? = null
|
|
|
|
|
|
|
|
|
|
@Volatile
|
|
|
|
|
private var recentFirstPageCacheLevel: Int? = null
|
|
|
|
|
|
|
|
|
|
@Volatile
|
|
|
|
|
private var searchFirstPageCacheQuery: String? = null
|
|
|
|
|
|
|
|
|
|
@Volatile
|
|
|
|
|
private var searchFirstPageCache: List<Manga>? = null
|
|
|
|
|
|
|
|
|
|
@ -108,7 +115,7 @@ internal class KomiicParser(context: MangaLoaderContext) :
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder()
|
|
|
|
|
override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder()
|
|
|
|
|
.add("Referer", "https://$domain/")
|
|
|
|
|
.add("Origin", "https://$domain")
|
|
|
|
|
.add("Accept", "application/json, text/plain, */*")
|
|
|
|
|
@ -161,7 +168,14 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
|
|
|
|
|
} else if (filter.tags.isNotEmpty()) {
|
|
|
|
|
val categoryIds = filter.tags.map { it.key }.filter { it.matches(Regex("^\\d+")) }
|
|
|
|
|
if (categoryIds.isNotEmpty()) {
|
|
|
|
|
listByCategories(categoryIds, offset, orderByParam, statusParam, asc = false, sexyLevel = remoteSexyLevel)
|
|
|
|
|
listByCategories(
|
|
|
|
|
categoryIds,
|
|
|
|
|
offset,
|
|
|
|
|
orderByParam,
|
|
|
|
|
statusParam,
|
|
|
|
|
asc = false,
|
|
|
|
|
sexyLevel = remoteSexyLevel,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
when (orderByParam) {
|
|
|
|
|
"DATE_UPDATED" -> {
|
|
|
|
|
@ -181,6 +195,7 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
|
|
|
|
|
list
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
else -> hotComics(offset, orderByParam, statusParam, remoteSexyLevel)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -203,6 +218,7 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
|
|
|
|
|
list
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
else -> hotComics(offset, orderByParam, statusParam, remoteSexyLevel)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -210,7 +226,7 @@ override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder
|
|
|
|
|
return applyLocalFilters(base, filter)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun recentUpdate(offset: Int, status: String = "", sexyLevel: Int? = null): List<Manga> {
|
|
|
|
|
private suspend fun recentUpdate(offset: Int, status: String = "", sexyLevel: Int? = null): List<Manga> {
|
|
|
|
|
val query = """
|
|
|
|
|
query recentUpdate(${'$'}pagination: Pagination!) {
|
|
|
|
|
recentUpdate(pagination: ${'$'}pagination) {
|
|
|
|
|
@ -232,21 +248,24 @@ private suspend fun recentUpdate(offset: Int, status: String = "", sexyLevel: In
|
|
|
|
|
}
|
|
|
|
|
""".trimIndent()
|
|
|
|
|
val variables = JSONObject().apply {
|
|
|
|
|
put("pagination", JSONObject().apply {
|
|
|
|
|
put(
|
|
|
|
|
"pagination",
|
|
|
|
|
JSONObject().apply {
|
|
|
|
|
put("limit", pageSize)
|
|
|
|
|
put("offset", offset)
|
|
|
|
|
put("orderBy", "DATE_UPDATED")
|
|
|
|
|
put("status", status)
|
|
|
|
|
put("asc", true)
|
|
|
|
|
if (sexyLevel != null) put("sexyLevel", sexyLevel)
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
val data = apiCall(query, "recentUpdate", variables)
|
|
|
|
|
val arr: JSONArray = data.optJSONArray("recentUpdate") ?: JSONArray()
|
|
|
|
|
return arr.toMangaList()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun hotComics(offset: Int, orderBy: String, status: String, sexyLevel: Int? = null): List<Manga> {
|
|
|
|
|
private suspend fun hotComics(offset: Int, orderBy: String, status: String, sexyLevel: Int? = null): List<Manga> {
|
|
|
|
|
val query = """
|
|
|
|
|
query hotComics(${'$'}pagination: Pagination!) {
|
|
|
|
|
hotComics(pagination: ${'$'}pagination) {
|
|
|
|
|
@ -268,21 +287,31 @@ private suspend fun hotComics(offset: Int, orderBy: String, status: String, sexy
|
|
|
|
|
}
|
|
|
|
|
""".trimIndent()
|
|
|
|
|
val variables = JSONObject().apply {
|
|
|
|
|
put("pagination", JSONObject().apply {
|
|
|
|
|
put(
|
|
|
|
|
"pagination",
|
|
|
|
|
JSONObject().apply {
|
|
|
|
|
put("limit", pageSize)
|
|
|
|
|
put("offset", offset)
|
|
|
|
|
put("orderBy", orderBy)
|
|
|
|
|
put("status", status)
|
|
|
|
|
put("asc", true)
|
|
|
|
|
if (sexyLevel != null) put("sexyLevel", sexyLevel)
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
val data = apiCall(query, "hotComics", variables)
|
|
|
|
|
val arr: JSONArray = data.optJSONArray("hotComics") ?: JSONArray()
|
|
|
|
|
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 = """
|
|
|
|
|
query comicByCategories(${'$'}categoryId: [ID!]!, ${'$'}pagination: Pagination!) {
|
|
|
|
|
comicByCategories(categoryId: ${'$'}categoryId, pagination: ${'$'}pagination) {
|
|
|
|
|
@ -304,24 +333,30 @@ private suspend fun listByCategories(categoryIds: List<String>, offset: Int, ord
|
|
|
|
|
}
|
|
|
|
|
""".trimIndent()
|
|
|
|
|
val variables = JSONObject().apply {
|
|
|
|
|
put("categoryId", JSONArray().apply {
|
|
|
|
|
put(
|
|
|
|
|
"categoryId",
|
|
|
|
|
JSONArray().apply {
|
|
|
|
|
categoryIds.forEach { put(it) }
|
|
|
|
|
})
|
|
|
|
|
put("pagination", JSONObject().apply {
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
put(
|
|
|
|
|
"pagination",
|
|
|
|
|
JSONObject().apply {
|
|
|
|
|
put("limit", pageSize)
|
|
|
|
|
put("offset", offset)
|
|
|
|
|
put("orderBy", orderBy)
|
|
|
|
|
put("status", status)
|
|
|
|
|
put("asc", asc)
|
|
|
|
|
if (sexyLevel != null) put("sexyLevel", sexyLevel)
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
val data = apiCall(query, "comicByCategories", variables)
|
|
|
|
|
val arr: JSONArray = data.optJSONArray("comicByCategories") ?: JSONArray()
|
|
|
|
|
return arr.toMangaList()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun search(query: String): List<Manga> {
|
|
|
|
|
private suspend fun search(query: String): List<Manga> {
|
|
|
|
|
val request = """
|
|
|
|
|
query searchComicAndAuthorQuery(${'$'}keyword: String!) {
|
|
|
|
|
searchComicsAndAuthors(keyword: ${'$'}keyword) {
|
|
|
|
|
@ -351,9 +386,9 @@ private suspend fun search(query: String): List<Manga> {
|
|
|
|
|
val parent = data.optJSONObject("searchComicsAndAuthors")
|
|
|
|
|
val arr = parent?.optJSONArray("comics") ?: JSONArray()
|
|
|
|
|
return arr.toMangaList()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun JSONArray.toMangaList(): List<Manga> = mapJSON { jo ->
|
|
|
|
|
private fun JSONArray.toMangaList(): List<Manga> = mapJSON { jo ->
|
|
|
|
|
val id = jo.optString("id")
|
|
|
|
|
val title = jo.optString("title")
|
|
|
|
|
val cover = jo.optString("imageUrl", null)
|
|
|
|
|
@ -363,13 +398,18 @@ private fun JSONArray.toMangaList(): List<Manga> = mapJSON { jo ->
|
|
|
|
|
"ONGOING", "ongoing" -> MangaState.ONGOING
|
|
|
|
|
else -> null
|
|
|
|
|
}
|
|
|
|
|
val authors = jo.optJSONArray("authors")?.mapJSONNotNull { a -> a.optString("name").takeIf { it.isNotEmpty() } }
|
|
|
|
|
?.toSet().orEmpty()
|
|
|
|
|
val catNames = jo.optJSONArray("categories")?.mapJSONNotNull { c -> c.optString("name").takeIf { it.isNotEmpty() } }?.toSet().orEmpty()
|
|
|
|
|
val catIds = jo.optJSONArray("categories")?.mapJSONNotNull { c -> c.optString("id").takeIf { it.isNotEmpty() } }?.toSet().orEmpty()
|
|
|
|
|
val tags = catNames.zip(catIds.ifEmpty { catNames }).map { (n, k) ->
|
|
|
|
|
val authors = jo.optJSONArray("authors")?.mapJSONNotNullToSet { a ->
|
|
|
|
|
a.optString("name").takeIf { it.isNotEmpty() }
|
|
|
|
|
}.orEmpty()
|
|
|
|
|
val catNames = jo.optJSONArray("categories")?.mapJSONNotNullToSet { c ->
|
|
|
|
|
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)
|
|
|
|
|
}.toSet()
|
|
|
|
|
}
|
|
|
|
|
val rating = guessContentRating(catNames)
|
|
|
|
|
Manga(
|
|
|
|
|
id = generateUid(id.ifEmpty { title }),
|
|
|
|
|
@ -388,20 +428,20 @@ private fun JSONArray.toMangaList(): List<Manga> = mapJSON { jo ->
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun applyLocalFilters(list: List<Manga>, filter: MangaListFilter): List<Manga> {
|
|
|
|
|
private suspend fun applyLocalFilters(list: List<Manga>, filter: MangaListFilter): List<Manga> {
|
|
|
|
|
var result = list
|
|
|
|
|
// 仅保留状态与排除标签的本地过滤;色气程度严格依赖远端 sexyLevel
|
|
|
|
|
if (filter.states.isNotEmpty()) {
|
|
|
|
|
result = result.filter { m -> m.state != null && filter.states.contains(m.state) }
|
|
|
|
|
}
|
|
|
|
|
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) } }
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun parseSexyLevel(filter: MangaListFilter): Int? {
|
|
|
|
|
private fun parseSexyLevel(filter: MangaListFilter): Int? {
|
|
|
|
|
// 将“内容分级”映射到色气程度阈值:SAFE -> 0, SUGGESTIVE -> 1, ADULT -> 4
|
|
|
|
|
var max: Int? = null
|
|
|
|
|
if (filter.contentRating.isNotEmpty()) {
|
|
|
|
|
@ -411,14 +451,14 @@ private fun parseSexyLevel(filter: MangaListFilter): Int? {
|
|
|
|
|
ContentRating.SUGGESTIVE -> 1
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun guessContentRating(tagNames: Set<String>): ContentRating? {
|
|
|
|
|
val lower = tagNames.map { it.lowercase(Locale.ROOT) }.toSet()
|
|
|
|
|
private fun guessContentRating(tagNames: Set<String>): ContentRating? {
|
|
|
|
|
val lower = tagNames.mapToSet { it.lowercase(Locale.ROOT) }
|
|
|
|
|
val adultKeys = setOf("成人", "限制", "r18", "情色", "nsfw", "十八禁")
|
|
|
|
|
val suggestiveKeys = setOf("後宮", "福利", "性感", "誘惑", "擦邊", "肉番")
|
|
|
|
|
return when {
|
|
|
|
|
@ -426,36 +466,108 @@ private fun guessContentRating(tagNames: Set<String>): ContentRating? {
|
|
|
|
|
lower.any { s -> suggestiveKeys.any { s.contains(it) } } -> ContentRating.SUGGESTIVE
|
|
|
|
|
else -> ContentRating.SAFE
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Volatile
|
|
|
|
|
private var cachedTags: Set<MangaTag>? = null
|
|
|
|
|
@Volatile
|
|
|
|
|
private var cachedTags: Set<MangaTag>? = null
|
|
|
|
|
|
|
|
|
|
private suspend fun fetchAvailableTags(): Set<MangaTag> {
|
|
|
|
|
private fun fetchAvailableTags(): Set<MangaTag> {
|
|
|
|
|
val cached = cachedTags
|
|
|
|
|
if (cached != null) return cached
|
|
|
|
|
// 以站点固定分类为基准(来源于官方前端配置),避免采样不全
|
|
|
|
|
val names = 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
|
|
|
|
|
return set
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 已移除 lewd-* 标签定义,统一通过“内容分级”进行色气程度选择
|
|
|
|
|
// 已移除 lewd-* 标签定义,统一通过“内容分级”进行色气程度选择
|
|
|
|
|
|
|
|
|
|
@Volatile
|
|
|
|
|
private var lewdnessCache: MutableMap<String, Int> = mutableMapOf()
|
|
|
|
|
@Volatile
|
|
|
|
|
private var lewdnessCache: MutableMap<String, Int> = mutableMapOf()
|
|
|
|
|
|
|
|
|
|
private suspend fun fetchLewdnessLevel(comicId: String): Int? {
|
|
|
|
|
private suspend fun fetchLewdnessLevel(comicId: String): Int? {
|
|
|
|
|
val cached = lewdnessCache[comicId]
|
|
|
|
|
if (cached != null) return cached
|
|
|
|
|
// 优先尝试 GraphQL 详情的 sexyLevel 字段(更稳定)
|
|
|
|
|
runCatching {
|
|
|
|
|
runCatchingCancellable {
|
|
|
|
|
val q = """
|
|
|
|
|
query comicById(${'$'}id: ID!) {
|
|
|
|
|
comicById(comicId: ${'$'}id) { id sexyLevel __typename }
|
|
|
|
|
@ -472,6 +584,8 @@ private suspend fun fetchLewdnessLevel(comicId: String): Int? {
|
|
|
|
|
}.getOrElse { /* ignore and fallback to HTML */ }
|
|
|
|
|
val doc = try {
|
|
|
|
|
webClient.httpGet("https://$domain/comic/$comicId", getRequestHeaders()).parseHtml()
|
|
|
|
|
} catch (e: CancellationException) {
|
|
|
|
|
throw e
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
@ -506,9 +620,9 @@ private suspend fun fetchLewdnessLevel(comicId: String): Int? {
|
|
|
|
|
lewdnessCache[comicId] = level
|
|
|
|
|
}
|
|
|
|
|
return level
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override suspend fun getDetails(manga: Manga): Manga {
|
|
|
|
|
override suspend fun getDetails(manga: Manga): Manga {
|
|
|
|
|
val request = """
|
|
|
|
|
query comicById(${'$'}id: ID!) {
|
|
|
|
|
comicById(comicId: ${'$'}id) {
|
|
|
|
|
@ -581,13 +695,14 @@ override suspend fun getDetails(manga: Manga): Manga {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
|
|
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
|
|
|
val chapterId = chapter.url.substringAfterLast('/')
|
|
|
|
|
val comicId = chapter.branch
|
|
|
|
|
// 预热图片页面,促使站点生成必要状态/缓存,减少首次为空
|
|
|
|
|
if (!comicId.isNullOrEmpty()) {
|
|
|
|
|
runCatching {
|
|
|
|
|
webClient.httpGet("https://$domain/comic/$comicId/chapter/$chapterId/images/all", getRequestHeaders()).close()
|
|
|
|
|
runCatchingCancellable {
|
|
|
|
|
webClient.httpGet("https://$domain/comic/$comicId/chapter/$chapterId/images/all", getRequestHeaders())
|
|
|
|
|
.close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 优先使用 imagesByChapterId,若返回 kid,则拼接 /api/image/{kid}
|
|
|
|
|
@ -645,41 +760,52 @@ override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
|
|
|
for ((idx, d) in retryDelays.withIndex()) {
|
|
|
|
|
// 间隔退避
|
|
|
|
|
delay(d.toLong())
|
|
|
|
|
val ok = runCatching {
|
|
|
|
|
val ok = runCatchingCancellable {
|
|
|
|
|
webClient.httpHead(firstUrl).close()
|
|
|
|
|
true
|
|
|
|
|
}.getOrDefault(false)
|
|
|
|
|
if (ok) { ready = true; break }
|
|
|
|
|
if (ok) {
|
|
|
|
|
ready = true; break
|
|
|
|
|
}
|
|
|
|
|
// 若仍未就绪,额外触发一次图片页预热后再试(首两次)
|
|
|
|
|
if (!ready && idx < 2 && !comicId.isNullOrEmpty()) {
|
|
|
|
|
runCatching {
|
|
|
|
|
webClient.httpGet("https://$domain/comic/$comicId/chapter/$chapterId/images/all", getRequestHeaders()).close()
|
|
|
|
|
runCatchingCancellable {
|
|
|
|
|
webClient.httpGet(
|
|
|
|
|
"https://$domain/comic/$comicId/chapter/$chapterId/images/all",
|
|
|
|
|
getRequestHeaders(),
|
|
|
|
|
).close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// HEAD 在个别服务端可能不完全生效,最后补一次轻量 GET 以确认
|
|
|
|
|
if (!ready) {
|
|
|
|
|
ready = runCatching {
|
|
|
|
|
ready = runCatchingCancellable {
|
|
|
|
|
webClient.httpGet(firstUrl, getRequestHeaders()).close()
|
|
|
|
|
true
|
|
|
|
|
}.getOrDefault(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
put("operationName", operationName ?: JSONObject.NULL)
|
|
|
|
|
put("variables", variables ?: JSONObject())
|
|
|
|
|
put("query", query)
|
|
|
|
|
}
|
|
|
|
|
// 预热根域,尝试获取站点必要 Cookie
|
|
|
|
|
runCatching {
|
|
|
|
|
runCatchingCancellable {
|
|
|
|
|
webClient.httpGet("https://$domain/", getRequestHeaders()).close()
|
|
|
|
|
}
|
|
|
|
|
val response = try {
|
|
|
|
|
webClient.httpPost("https://${domain}/api/query".toHttpUrl(), body, getRequestHeaders())
|
|
|
|
|
} catch (e: CancellationException) {
|
|
|
|
|
throw e
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
throw ParseException(
|
|
|
|
|
"Komiic 接口请求失败(可能被拦截或接口错误),请在浏览器打开主页以通过挑战页,然后回到应用重试",
|
|
|
|
|
@ -721,5 +847,5 @@ private suspend fun apiCall(query: String, operationName: String? = null, variab
|
|
|
|
|
"https://${domain}/",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|