Add Line Webtoons sources (webtoons.com)

It uses the API of mobile app
pull/273/head
Nikita Strygin 3 years ago
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…
Cancel
Save