parent
07c3f22f75
commit
c09d484a14
@ -0,0 +1,408 @@
|
||||
package org.koitharu.kotatsu.parsers.site.en
|
||||
|
||||
import okhttp3.Headers
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.util.*
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||
import org.koitharu.kotatsu.parsers.util.json.toJSONList
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@MangaSourceParser("LINEWEBTOONS", "Line Webtoons", "en", type = ContentType.MANGA)
|
||||
class LineWebtoonsParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.LINEWEBTOONS) {
|
||||
private val signer = WebtoonsUrlSigner("gUtPzJFZch4ZyAGviiyH94P99lQ3pFdRTwpJWDlSGFfwgpr6ses5ALOxWHOIT7R1")
|
||||
|
||||
override val configKeyDomain
|
||||
get() = ConfigKey.Domain("webtoons.com")
|
||||
private val configKeyApiDomain
|
||||
get() = ConfigKey.Domain("global.apis.naver.com")
|
||||
private val configKeyStaticDomain
|
||||
get() = ConfigKey.Domain("webtoon-phinf.pstatic.net")
|
||||
|
||||
private val apiDomain
|
||||
get() = config[configKeyApiDomain]
|
||||
private val staticDomain
|
||||
get() = config[configKeyStaticDomain]
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.POPULARITY,
|
||||
// doesn't actually sort by rating, but by likes
|
||||
// this should be fine though
|
||||
SortOrder.RATING,
|
||||
)
|
||||
override val headers: Headers
|
||||
get() = Headers.Builder()
|
||||
.add("User-Agent", "nApps (Android 12;; linewebtoon; 3.1.0)")
|
||||
.build()
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String {
|
||||
return page.url
|
||||
}
|
||||
|
||||
private suspend fun getChapters(titleNo: Long): List<MangaChapter> {
|
||||
val firstResult = makeRequest("/lineWebtoon/webtoon/challengeEpisodeList.json?v=2&titleNo=$titleNo&startIndex=0&pageSize=30")
|
||||
|
||||
val totalEpisodeCount = firstResult
|
||||
.getJSONObject("episodeList")
|
||||
.getInt("totalServiceEpisodeCount")
|
||||
|
||||
val episodes = firstResult
|
||||
.getJSONObject("episodeList")
|
||||
.getJSONArray("episode")
|
||||
.toJSONList()
|
||||
.toMutableList()
|
||||
|
||||
while (episodes.count() < totalEpisodeCount) {
|
||||
val page = makeRequest("/lineWebtoon/webtoon/challengeEpisodeList.json?v=2&titleNo=$titleNo&startIndex=${episodes.count()}&pageSize=30")
|
||||
.getJSONObject("episodeList")
|
||||
.getJSONArray("episode")
|
||||
.toJSONList()
|
||||
|
||||
episodes.addAll(page)
|
||||
}
|
||||
|
||||
return episodes.mapIndexed { i, jo ->
|
||||
MangaChapter(
|
||||
id = generateUid("$titleNo-$i"),
|
||||
name = jo.getString("episodeTitle"),
|
||||
number = jo.getInt("episodeSeq"),
|
||||
url = "$titleNo-${jo.getString("episodeNo")}",
|
||||
uploadDate = jo.getLong("modifyYmdt"),
|
||||
// do we want to use it for anything?
|
||||
branch = null,
|
||||
scanlator = null,
|
||||
source = source,
|
||||
)
|
||||
}.sortedBy { it.number }
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val titleNo = manga.url.toLong()
|
||||
|
||||
return makeRequest("/lineWebtoon/webtoon/challengeTitleInfo.json?v=2&titleNo=${titleNo}")
|
||||
.getJSONObject("titleInfo")
|
||||
.let { jo ->
|
||||
Manga(
|
||||
id = generateUid(titleNo),
|
||||
title = jo.getString("title"),
|
||||
altTitle = null,
|
||||
url = "$titleNo",
|
||||
publicUrl = "https://${domain}/en/canvas/a/list?title_no=${titleNo}",
|
||||
rating = jo.getDouble("starScoreAverage").toFloat() / 10f,
|
||||
isNsfw = jo.getBoolean("ageGradeNotice"),
|
||||
coverUrl = "https://$staticDomain${jo.getString("thumbnail")}",
|
||||
largeCoverUrl = if (jo.has("thumbnailVertical")) {
|
||||
"https://$staticDomain${jo.getString("thumbnailVertical")}"
|
||||
} else { null },
|
||||
tags = setOf(parseTag(jo.getJSONObject("genreInfo"))),
|
||||
author = jo.getString("writingAuthorName"),
|
||||
description = jo.getString("synopsis"),
|
||||
// I don't think the API provides this info
|
||||
state = null,
|
||||
chapters = getChapters(titleNo),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder,
|
||||
): List<Manga> {
|
||||
val genre = tags.oneOrThrowIfMany()?.key ?: "ALL"
|
||||
|
||||
val sortOrderStr = when (sortOrder) {
|
||||
SortOrder.UPDATED -> "UPDATE"
|
||||
SortOrder.POPULARITY -> "READ_COUNT"
|
||||
SortOrder.RATING -> "LIKEIT"
|
||||
else -> {
|
||||
throw Exception("Unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
val manga = if (query != null) {
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
throw IllegalArgumentException("This source does not support search with tags")
|
||||
}
|
||||
|
||||
makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${query.urlEncoded()}&startIndex=${offset+1}&pageSize=20")
|
||||
.getJSONObject("challengeSearch")
|
||||
.getJSONArray("titleList")
|
||||
.mapJSON { jo ->
|
||||
val titleNo = jo.getLong("titleNo")
|
||||
|
||||
Manga(
|
||||
id = generateUid(titleNo),
|
||||
title = jo.getString("title"),
|
||||
altTitle = null,
|
||||
url = "$titleNo",
|
||||
publicUrl = "https://${domain}/en/canvas/a/list?title_no=${titleNo}",
|
||||
rating = RATING_UNKNOWN,
|
||||
isNsfw = false,
|
||||
coverUrl = "https://$staticDomain${jo.getString("thumbnail")}",
|
||||
largeCoverUrl = null,
|
||||
tags = setOf(),
|
||||
author = jo.getString("writingAuthorName"),
|
||||
description = null,
|
||||
state = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val result = makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=${genre}&sortOrder=${sortOrderStr}&startIndex=${offset+1}&pageSize=20")
|
||||
|
||||
val genres = result.getJSONObject("genreList")
|
||||
.getJSONArray("challengeGenres")
|
||||
.mapJSON { jo -> parseTag(jo) }
|
||||
.associateBy { tag -> tag.key }
|
||||
|
||||
result
|
||||
.getJSONObject("titleList")
|
||||
.getJSONArray("titles")
|
||||
.mapJSON { jo ->
|
||||
val titleNo = jo.getLong("titleNo")
|
||||
|
||||
Manga(
|
||||
id = generateUid(titleNo),
|
||||
title = jo.getString("title"),
|
||||
altTitle = null,
|
||||
url = "$titleNo",
|
||||
publicUrl = "https://${domain}/en/canvas/a/list?title_no=${titleNo}",
|
||||
rating = jo.getDouble("starScoreAverage").toFloat() / 10f,
|
||||
isNsfw = jo.getBoolean("ageGradeNotice"),
|
||||
coverUrl = "https://$staticDomain${jo.getString("thumbnail")}",
|
||||
largeCoverUrl = if (jo.has("thumbnailVertical")) {
|
||||
"https://$staticDomain${jo.getString("thumbnailVertical")}"
|
||||
} else { null },
|
||||
tags = setOf(genres[jo.getString("representGenre")]!!),
|
||||
author = jo.getString("writingAuthorName"),
|
||||
description = jo.getString("synopsis"),
|
||||
// I don't think the API provides this info
|
||||
state = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val (titleNo, episodeNo) = chapter.url.splitTwoParts('-')!!
|
||||
|
||||
return makeRequest("/lineWebtoon/webtoon/challengeEpisodeInfo.json?v=2&titleNo=${titleNo}&episodeNo=${episodeNo}")
|
||||
.getJSONObject("episodeInfo")
|
||||
.getJSONArray("imageInfo")
|
||||
.mapJSONIndexed() { i, jo ->
|
||||
MangaPage(
|
||||
id = generateUid("$titleNo-$episodeNo-$i"),
|
||||
url = "https://$staticDomain${jo.getString("url")}",
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTag(jo: JSONObject): MangaTag {
|
||||
return MangaTag(
|
||||
title = jo.getString("name"),
|
||||
key = jo.getString("code"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
return makeRequest("/lineWebtoon/webtoon/challengeGenreList.json")
|
||||
.getJSONObject("genreList")
|
||||
.getJSONArray("challengeGenres")
|
||||
.mapJSONToSet { jo -> parseTag(jo) }
|
||||
}
|
||||
|
||||
private suspend fun makeRequest(url: String): JSONObject {
|
||||
val resp = webClient.httpGet(finalizeUrl(url))
|
||||
val message = resp.parseJson().getJSONObject("message")
|
||||
if (resp.isSuccessful) {
|
||||
return message.getJSONObject("result")
|
||||
} else {
|
||||
// TODO: handle 404 and some other error codes
|
||||
val code = message.getInt("code")
|
||||
val errorMessage = message.getString("message")
|
||||
throw ParseException("Api error (code=$code): $errorMessage", url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeUrl(url: String): String {
|
||||
val urlWithHost = "https://${apiDomain}$url"
|
||||
val uri = URI(urlWithHost)
|
||||
val hasVersion = (uri.rawQuery ?: "").split("&").any { it.startsWith("v=") }
|
||||
val hasQuery = uri.rawQuery != null
|
||||
val language = "en"
|
||||
|
||||
val urlWithParams = urlWithHost + if (hasQuery) {
|
||||
"&"
|
||||
} else {
|
||||
"?"
|
||||
} + "serviceZone=GLOBAL&" + if (!hasVersion) {
|
||||
"v=1"
|
||||
} else { "" } + "&language=${language}&locale=${language}&platform=APP_ANDROID"
|
||||
|
||||
val result = signer.makeEncryptUrl(urlWithParams);
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class WebtoonsUrlSigner(val secret: String) {
|
||||
private val mac = Mac.getInstance("HmacSHA1").apply {
|
||||
this.init(SecretKeySpec(secret.encodeToByteArray(), "HmacSHA1"))
|
||||
}
|
||||
|
||||
private fun getMessage(url: String, msgpad: String): String {
|
||||
return url.substring(0, 0xFF.coerceAtMost(url.length)) + msgpad
|
||||
}
|
||||
|
||||
private fun getMessageDigest(s: String): String {
|
||||
var signedMessage: ByteArray
|
||||
synchronized(mac) { signedMessage = mac.doFinal(s.toByteArray()) }
|
||||
|
||||
// we don't use the context.encodeBase64 here because it adds newlines and doesn't add padding
|
||||
// we, however, need padding and no newlines
|
||||
return encodeBase64(signedMessage)
|
||||
}
|
||||
|
||||
|
||||
fun makeEncryptUrl(s: String): String {
|
||||
return makeEncryptUrlCore(
|
||||
s,
|
||||
java.lang.String.valueOf(Calendar.getInstance().timeInMillis),
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeEncryptUrlCore(url: String, msgpad: String): String {
|
||||
val digest = URLEncoder.encode(getMessageDigest(getMessage(url, msgpad)), "utf-8")
|
||||
return url + if (url.contains("?")) {
|
||||
"&"
|
||||
} else {
|
||||
"?"
|
||||
} + "msgpad=${msgpad}&md=${digest}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val INT_TO_BASE64: CharArray = charArrayOf(
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'+',
|
||||
'/',
|
||||
)
|
||||
|
||||
private fun encodeBase64(arr: ByteArray): String {
|
||||
val groupsCount = arr.size / 3
|
||||
val extraCount = arr.size - groupsCount * 3
|
||||
|
||||
val sb = StringBuffer((arr.size + 2) / 3 * 4)
|
||||
val code = INT_TO_BASE64
|
||||
var groupIndex = 0
|
||||
var position = 0
|
||||
while (groupIndex < groupsCount) {
|
||||
val v4 = arr[position].toInt() and 0xFF
|
||||
val v5 = arr[position + 1].toInt() and 0xFF
|
||||
val v6 = arr[position + 2].toInt() and 0xFF
|
||||
sb.append(code[v4 shr 2])
|
||||
sb.append(code[v4 shl 4 and 0x3F or (v5 shr 4)])
|
||||
sb.append(code[v5 shl 2 and 0x3F or (v6 shr 6)])
|
||||
sb.append(code[v6 and 0x3F])
|
||||
++groupIndex
|
||||
position += 3
|
||||
}
|
||||
|
||||
if (extraCount != 0) {
|
||||
val v1 = arr[position].toInt() and 0xFF
|
||||
sb.append(code[v1 shr 2])
|
||||
if (extraCount == 1) {
|
||||
sb.append(code[v1 shl 4 and 0x3F])
|
||||
sb.append("==")
|
||||
} else {
|
||||
val v2 = arr[position + 1].toInt() and 0xFF
|
||||
sb.append(code[v1 shl 4 and 0x3F or (v2 shr 4)])
|
||||
sb.append(code[v2 shl 2 and 0x3F])
|
||||
sb.append('=')
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
Loading…
Reference in New Issue