Webtoons.com: refactor and fix for site changes (#9245)

* Webtoons Translate: move out of multisrc & rework

it basically override everything from the main webtoons class, so split it off

* DongmanManhua: move to individual

* Webtoons: fix and make individual

* remove old multisrc

* use meta og:image

* deeplink fix

* fix deeplink crash & old details thumbnails
This commit is contained in:
AwkwardPeak7 2025-06-15 11:14:07 +05:00 committed by Draff
parent a9176c529b
commit a4347e9da1
Signed by: Draff
GPG Key ID: E8A89F3211677653
27 changed files with 822 additions and 828 deletions

View File

@ -1,5 +0,0 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 4

View File

@ -1,308 +0,0 @@
package eu.kanade.tachiyomi.multisrc.webtoons
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Filter.Header
import eu.kanade.tachiyomi.source.model.Filter.Select
import eu.kanade.tachiyomi.source.model.Filter.Separator
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.SocketException
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
open class Webtoons(
override val name: String,
override val baseUrl: String,
override val lang: String,
open val langCode: String = lang,
open val localeForCookie: String = lang,
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH),
) : ParsedHttpSource() {
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.cookieJar(
object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return listOf<Cookie>(
Cookie.Builder()
.domain("www.webtoons.com")
.path("/")
.name("ageGatePass")
.value("true")
.name("locale")
.value(localeForCookie)
.name("needGDPR")
.value("false")
.build(),
)
}
},
)
.addInterceptor(::sslRetryInterceptor)
.build()
// m.webtoons.com throws an SSL error that can be solved by a simple retry
private fun sslRetryInterceptor(chain: Interceptor.Chain): Response {
return try {
chain.proceed(chain.request())
} catch (e: SocketException) {
chain.proceed(chain.request())
}
}
private val day: String
get() {
return when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) {
Calendar.SUNDAY -> "div._list_SUNDAY"
Calendar.MONDAY -> "div._list_MONDAY"
Calendar.TUESDAY -> "div._list_TUESDAY"
Calendar.WEDNESDAY -> "div._list_WEDNESDAY"
Calendar.THURSDAY -> "div._list_THURSDAY"
Calendar.FRIDAY -> "div._list_FRIDAY"
Calendar.SATURDAY -> "div._list_SATURDAY"
else -> {
"div"
}
}
}
val json: Json by injectLazy()
override fun popularMangaSelector() = "not using"
override fun latestUpdatesSelector() = "div#dailyList > $day li > a"
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.add("Referer", "https://www.webtoons.com/$langCode/")
protected val mobileHeaders: Headers = super.headersBuilder()
.add("Referer", "https://m.webtoons.com")
.build()
override fun popularMangaRequest(page: Int) = GET("$baseUrl/$langCode/dailySchedule", headers)
override fun popularMangaParse(response: Response): MangasPage {
val mangas = mutableListOf<SManga>()
val document = response.asJsoup()
var maxChild = 0
// For ongoing webtoons rows are ordered by descending popularity, count how many rows there are
document.select("div#dailyList .daily_section").forEach { day ->
day.select("li").count().let { rowCount ->
if (rowCount > maxChild) maxChild = rowCount
}
}
// Process each row
for (i in 1..maxChild) {
document.select("div#dailyList .daily_section li:nth-child($i) a").map { mangas.add(popularMangaFromElement(it)) }
}
// Add completed webtoons, no sorting needed
document.select("div.daily_lst.comp li a").map { mangas.add(popularMangaFromElement(it)) }
return MangasPage(mangas.distinctBy { it.url }, false)
}
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/$langCode/dailySchedule?sortOrder=UPDATE&webtoonCompleteType=ONGOING", headers)
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.attr("href"))
manga.title = element.select("p.subj").text()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
override fun popularMangaNextPageSelector(): String? = null
override fun latestUpdatesNextPageSelector(): String? = null
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (!query.startsWith(URL_SEARCH_PREFIX)) {
return super.fetchSearchManga(page, query, filters)
}
val emptyResult = Observable.just(MangasPage(emptyList(), false))
// given a url to either a webtoon or an episode, returns a url path to corresponding webtoon
fun webtoonPath(u: HttpUrl) = when {
langCode == u.pathSegments[0] -> "/${u.pathSegments[0]}/${u.pathSegments[1]}/${u.pathSegments[2]}/list"
else -> "/${u.pathSegments[0]}/${u.pathSegments[1]}/list" // dongmanmanhua doesn't include langCode
}
return query.substringAfter(URL_SEARCH_PREFIX).toHttpUrlOrNull()?.let { url ->
val title_no = url.queryParameter("title_no")
val couldBeWebtoonOrEpisode = title_no != null && (url.pathSegments.size >= 3 && url.pathSegments.last().isNotEmpty())
val isThisLang = "$url".startsWith("$baseUrl/$langCode")
if (!(couldBeWebtoonOrEpisode && isThisLang)) {
emptyResult
} else {
val potentialUrl = "${webtoonPath(url)}?title_no=$title_no"
fetchMangaDetails(SManga.create().apply { this.url = potentialUrl }).map {
it.url = potentialUrl
MangasPage(listOf(it), false)
}
}
} ?: emptyResult
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/$langCode/search?keyword=$query".toHttpUrl().newBuilder()
val uriPart = (filters.find { it is SearchType } as? SearchType)?.toUriPart() ?: ""
url.addQueryParameter("searchType", uriPart)
if (uriPart != "WEBTOON" && page > 1) url.addQueryParameter("page", page.toString())
return GET(url.build(), headers)
}
override fun searchMangaSelector() = "#content > div.card_wrap.search ul:not(#filterLayer) li a"
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
override fun searchMangaNextPageSelector() = "div.more_area, div.paginate a[onclick] + a"
open fun parseDetailsThumbnail(document: Document): String? {
val picElement = document.select("#content > div.cont_box > div.detail_body")
val discoverPic = document.select("#content > div.cont_box > div.detail_header > span.thmb")
return picElement.attr("style").substringAfter("url(").substringBeforeLast(")").removeSurrounding("\"").removeSurrounding("'")
.ifBlank { discoverPic.select("img").not("[alt='Representative image']").first()?.attr("src") }
}
override fun mangaDetailsParse(document: Document): SManga {
val detailElement = document.select("#content > div.cont_box > div.detail_header > div.info")
val infoElement = document.select("#_asideDetail")
val manga = SManga.create()
manga.title = document.selectFirst("h1.subj, h3.subj")!!.text()
manga.author = detailElement.select(".author:nth-of-type(1)").first()?.ownText()
?: detailElement.select(".author_area").first()?.ownText()
manga.artist = detailElement.select(".author:nth-of-type(2)").first()?.ownText()
?: detailElement.select(".author_area").first()?.ownText() ?: manga.author
manga.genre = detailElement.select(".genre").joinToString(", ") { it.text() }
manga.description = infoElement.select("p.summary").text()
manga.status = infoElement.select("p.day_info").firstOrNull()?.text().orEmpty().toStatus()
manga.thumbnail_url = parseDetailsThumbnail(document)
return manga
}
open fun String.toStatus(): Int = when {
contains("UP") -> SManga.ONGOING
contains("COMPLETED") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun imageUrlParse(document: Document): String = document.select("img").first()!!.attr("src")
// Filters
override fun getFilterList(): FilterList {
return FilterList(
Header("Query can not be blank"),
Separator(),
SearchType(getOfficialList()),
)
}
override fun chapterListSelector() = "ul#_episodeList li[id*=episode]"
private class SearchType(vals: Array<Pair<String, String>>) : UriPartFilter("Official or Challenge", vals)
private fun getOfficialList() = arrayOf(
Pair("Any", ""),
Pair("Official only", "WEBTOON"),
Pair("Challenge only", "CHALLENGE"),
)
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a")
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = element.select("a > div.row > div.info > p.sub_title > span.ellipsis").text()
val select = element.select("a > div.row > div.num")
if (select.isNotEmpty()) {
chapter.name += " Ch. " + select.text().substringAfter("#")
}
if (element.select(".ico_bgm").isNotEmpty()) {
chapter.name += ""
}
chapter.date_upload = element.select("a > div.row > div.col > div.sub_info > span.date").text().let { chapterParseDate(it) } ?: 0
return chapter
}
open fun chapterParseDate(date: String): Long {
return try {
dateFormat.parse(date)?.time ?: 0
} catch (e: ParseException) {
0
}
}
override fun chapterListRequest(manga: SManga) = GET("https://m.webtoons.com" + manga.url, mobileHeaders)
override fun pageListParse(document: Document): List<Page> {
var pages = document.select("div#_imageList > img").mapIndexed { i, element -> Page(i, "", element.attr("data-url")) }
if (pages.isNotEmpty()) { return pages }
val docString = document.toString()
val docUrlRegex = Regex("documentURL:.*?'(.*?)'")
val motiontoonPathRegex = Regex("jpg:.*?'(.*?)\\{")
val docUrl = docUrlRegex.find(docString)!!.destructured.toList()[0]
val motiontoonPath = motiontoonPathRegex.find(docString)!!.destructured.toList()[0]
val motiontoonResponse = client.newCall(GET(docUrl, headers)).execute()
val motiontoonJson = json.parseToJsonElement(motiontoonResponse.body.string()).jsonObject
val motiontoonImages = motiontoonJson["assets"]!!.jsonObject["image"]!!.jsonObject
return motiontoonImages.entries
.filter { it.key.contains("layer") }
.mapIndexed { i, entry ->
Page(i, "", motiontoonPath + entry.value.jsonPrimitive.content)
}
}
companion object {
const val URL_SEARCH_PREFIX = "url:"
}
}

View File

@ -1,226 +0,0 @@
package eu.kanade.tachiyomi.multisrc.webtoons
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
open class WebtoonsTranslate(
override val name: String,
override val baseUrl: String,
override val lang: String,
private val translateLangCode: String,
) : Webtoons(name, baseUrl, lang) {
// popularMangaRequest already returns manga sorted by latest update
override val supportsLatest = false
private val apiBaseUrl = "https://global.apis.naver.com".toHttpUrl()
private val mobileBaseUrl = "https://m.webtoons.com".toHttpUrl()
private val thumbnailBaseUrl = "https://mwebtoon-phinf.pstatic.net"
private val pageSize = 24
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.removeAll("Referer")
.add("Referer", mobileBaseUrl.toString())
private fun mangaRequest(page: Int, requeztSize: Int): Request {
val url = apiBaseUrl
.resolve("/lineWebtoon/ctrans/translatedWebtoons_jsonp.json")!!
.newBuilder()
.addQueryParameter("orderType", "UPDATE")
.addQueryParameter("offset", "${(page - 1) * requeztSize}")
.addQueryParameter("size", "$requeztSize")
.addQueryParameter("languageCode", translateLangCode)
.build()
return GET(url, headers)
}
// Webtoons translations doesn't really have a "popular" sort; just "UPDATE", "TITLE_ASC",
// and "TITLE_DESC". Pick UPDATE as the most useful sort.
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, pageSize)
override fun popularMangaParse(response: Response): MangasPage {
val offset = response.request.url.queryParameter("offset")!!.toInt()
val result = json.parseToJsonElement(response.body.string()).jsonObject
val responseCode = result["code"]!!.jsonPrimitive.content
if (responseCode != "000") {
throw Exception("Error getting popular manga: error code $responseCode")
}
val titles = result["result"]!!.jsonObject
val totalCount = titles["totalCount"]!!.jsonPrimitive.int
val mangaList = titles["titleList"]!!.jsonArray
.map { mangaFromJson(it.jsonObject) }
return MangasPage(mangaList, hasNextPage = totalCount > pageSize + offset)
}
private fun mangaFromJson(manga: JsonObject): SManga {
val relativeThumnailURL = manga["thumbnailIPadUrl"]?.jsonPrimitive?.contentOrNull
?: manga["thumbnailMobileUrl"]?.jsonPrimitive?.contentOrNull
return SManga.create().apply {
title = manga["representTitle"]!!.jsonPrimitive.content
author = manga["writeAuthorName"]!!.jsonPrimitive.content
artist = manga["pictureAuthorName"]?.jsonPrimitive?.contentOrNull ?: author
thumbnail_url = if (relativeThumnailURL != null) "$thumbnailBaseUrl$relativeThumnailURL" else null
status = SManga.UNKNOWN
url = mobileBaseUrl
.resolve("/translate/episodeList")!!
.newBuilder()
.addQueryParameter("titleNo", manga["titleNo"]!!.jsonPrimitive.int.toString())
.addQueryParameter("languageCode", translateLangCode)
.addQueryParameter("teamVersion", (manga["teamVersion"]?.jsonPrimitive?.intOrNull ?: 0).toString())
.build()
.toString()
}
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response, query)
}
}
/**
* Don't see a search function for Fan Translations, so let's do it client side.
* There's 75 webtoons as of 2019/11/21, a hardcoded request of 200 should be a sufficient request
* to get all titles, in 1 request, for quite a while
*/
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = mangaRequest(page, 200)
private fun searchMangaParse(response: Response, query: String): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonObject
val responseCode = result["code"]!!.jsonPrimitive.content
if (responseCode != "000") {
throw Exception("Error getting manga: error code $responseCode")
}
val mangaList = result["result"]!!.jsonObject["titleList"]!!.jsonArray
.map { mangaFromJson(it.jsonObject) }
.filter { it.title.contains(query, ignoreCase = true) }
return MangasPage(mangaList, false)
}
override fun mangaDetailsRequest(manga: SManga): Request {
return GET(manga.url, headers)
}
override fun mangaDetailsParse(document: Document): SManga {
val getMetaProp = fun(property: String): String =
document.head().select("meta[property=\"$property\"]").attr("content")
var parsedAuthor = getMetaProp("com-linewebtoon:webtoon:author")
var parsedArtist = parsedAuthor
val authorSplit = parsedAuthor.split(" / ", limit = 2)
if (authorSplit.count() > 1) {
parsedAuthor = authorSplit[0]
parsedArtist = authorSplit[1]
}
return SManga.create().apply {
title = getMetaProp("og:title")
artist = parsedArtist
author = parsedAuthor
description = getMetaProp("og:description")
status = SManga.UNKNOWN
thumbnail_url = getMetaProp("og:image")
}
}
override fun chapterListSelector(): String = throw UnsupportedOperationException()
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
override fun chapterListRequest(manga: SManga): Request {
val mangaUrl = manga.url.toHttpUrl()
val titleNo = mangaUrl.queryParameter("titleNo")
val teamVersion = mangaUrl.queryParameter("teamVersion")
val chapterListUrl = apiBaseUrl
.resolve("/lineWebtoon/ctrans/translatedEpisodes_jsonp.json")!!
.newBuilder()
.addQueryParameter("titleNo", titleNo)
.addQueryParameter("languageCode", translateLangCode)
.addQueryParameter("offset", "0")
.addQueryParameter("limit", "10000")
.addQueryParameter("teamVersion", teamVersion)
.toString()
return GET(chapterListUrl, mobileHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
val result = json.parseToJsonElement(response.body.string()).jsonObject
val responseCode = result["code"]!!.jsonPrimitive.content
if (responseCode != "000") {
val message = result["message"]?.jsonPrimitive?.content ?: "error code $responseCode"
throw Exception("Error getting chapter list: $message")
}
return result["result"]!!.jsonObject["episodes"]!!.jsonArray
.filter { it.jsonObject["translateCompleted"]!!.jsonPrimitive.boolean }
.map { parseChapterJson(it.jsonObject) }
.reversed()
}
private fun parseChapterJson(obj: JsonObject): SChapter = SChapter.create().apply {
name = obj["title"]!!.jsonPrimitive.content + " #" + obj["episodeSeq"]!!.jsonPrimitive.int
chapter_number = obj["episodeSeq"]!!.jsonPrimitive.int.toFloat()
date_upload = obj["updateYmdt"]!!.jsonPrimitive.long
scanlator = obj["teamVersion"]!!.jsonPrimitive.int.takeIf { it != 0 }?.toString() ?: "(wiki)"
val chapterUrl = apiBaseUrl
.resolve("/lineWebtoon/ctrans/translatedEpisodeDetail_jsonp.json")!!
.newBuilder()
.addQueryParameter("titleNo", obj["titleNo"]!!.jsonPrimitive.int.toString())
.addQueryParameter("episodeNo", obj["episodeNo"]!!.jsonPrimitive.int.toString())
.addQueryParameter("languageCode", obj["languageCode"]!!.jsonPrimitive.content)
.addQueryParameter("teamVersion", obj["teamVersion"]!!.jsonPrimitive.int.toString())
.toString()
setUrlWithoutDomain(chapterUrl)
}
override fun pageListRequest(chapter: SChapter): Request {
return GET(apiBaseUrl.resolve(chapter.url)!!, headers)
}
override fun pageListParse(response: Response): List<Page> {
val result = json.parseToJsonElement(response.body.string()).jsonObject
return result["result"]!!.jsonObject["imageInfo"]!!.jsonArray
.mapIndexed { i, jsonEl ->
Page(i, "", jsonEl.jsonObject["imageUrl"]!!.jsonPrimitive.content)
}
}
override fun getFilterList(): FilterList = FilterList()
}

View File

@ -3,7 +3,7 @@
<application>
<activity
android:name="eu.kanade.tachiyomi.multisrc.webtoons.WebtoonsUrlActivity"
android:name="eu.kanade.tachiyomi.extension.all.webtoons.WebtoonsUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">

View File

@ -1,14 +1,13 @@
ext {
extName = 'Webtoons.com'
extClass = '.WebtoonsFactory'
themePkg = 'webtoons'
baseUrl = 'https://www.webtoons.com'
overrideVersionCode = 41
extVersionCode = 46
isNsfw = false
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib:cookieinterceptor'))
implementation(project(':lib:textinterceptor'))
}

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.extension.all.webtoons
import kotlinx.serialization.Serializable
@Serializable
class MotionToonResponse(
val assets: MotionToonAssets,
)
@Serializable
class MotionToonAssets(
val images: Map<String, String>,
)

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.extension.all.webtoons
import eu.kanade.tachiyomi.source.model.Filter
abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String?>>,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second
}
class SearchType : SelectFilter(
name = "Search Type",
options = listOf(
"ALL" to null,
"Originals" to "originals",
"Canvas" to "canvas",
),
)

View File

@ -0,0 +1,337 @@
package eu.kanade.tachiyomi.extension.all.webtoons
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.cookieinterceptor.CookieInterceptor
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptor
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import java.net.SocketException
import java.text.SimpleDateFormat
import java.util.Calendar
open class Webtoons(
override val lang: String,
private val langCode: String = lang,
localeForCookie: String = lang,
private val dateFormat: SimpleDateFormat,
) : HttpSource(), ConfigurableSource {
override val name = "Webtoons.com"
override val baseUrl = "https://www.webtoons.com"
private val mobileUrl = "https://m.webtoons.com"
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
private val mobileHeaders = super.headersBuilder()
.set("Referer", "$mobileUrl/")
.build()
override val client = network.cloudflareClient.newBuilder()
.addNetworkInterceptor(
CookieInterceptor(
domain = "webtoons.com",
cookies = listOf(
"ageGatePass" to "true",
"locale" to localeForCookie,
"needGDPR" to "false",
),
),
)
.addInterceptor { chain ->
// m.webtoons.com throws an SSL error that can be solved by a simple retry
try {
chain.proceed(chain.request())
} catch (e: SocketException) {
chain.proceed(chain.request())
}
}
.addInterceptor(TextInterceptor())
.build()
private val preferences by getPreferencesLazy()
override fun popularMangaRequest(page: Int): Request {
val ranking = when (page) {
1 -> "trending"
2 -> "popular"
3 -> "originals"
4 -> "canvas"
else -> throw Exception("page > 4 not available")
}
return GET("$baseUrl/$langCode/ranking/$ranking", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val entries = document.select(".webtoon_list li a")
.map(::mangaFromElement)
val hasNextPage = response.request.url.pathSegments.last() != "canvas"
return MangasPage(entries, hasNextPage)
}
private fun mangaFromElement(element: Element): SManga {
return SManga.create().apply {
setUrlWithoutDomain(element.absUrl("href"))
title = element.selectFirst(".title")!!.text()
thumbnail_url = element.selectFirst("img")?.absUrl("src")
}
}
override fun latestUpdatesRequest(page: Int): Request {
val day = when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) {
Calendar.MONDAY -> "monday"
Calendar.TUESDAY -> "tuesday"
Calendar.WEDNESDAY -> "wednesday"
Calendar.THURSDAY -> "thursday"
Calendar.FRIDAY -> "friday"
Calendar.SATURDAY -> "saturday"
Calendar.SUNDAY -> "sunday"
else -> throw Exception("Unknown day of week")
}
return GET("$baseUrl/$langCode/originals/$day?sortOrder=UPDATE", headers)
}
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val entries = document.select(".webtoon_list li a")
.map(::mangaFromElement)
return MangasPage(entries, false)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (query.startsWith(ID_SEARCH_PREFIX)) {
val (_, titleLang, titleNo) = query.split(":", limit = 3)
val tmpManga = SManga.create().apply {
url = "/episodeList?titleNo=$titleNo"
}
return if (titleLang == langCode) {
fetchMangaDetails(tmpManga).map {
MangasPage(listOf(it), false)
}
} else {
Observable.just(
MangasPage(emptyList(), false),
)
}
}
return super.fetchSearchManga(page, query, filters)
}
override fun getFilterList(): FilterList {
return FilterList(
SearchType(),
)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = baseUrl.toHttpUrl().newBuilder().apply {
var searchTypeAdded = false
addPathSegment(langCode)
addPathSegment("search")
filters.firstInstanceOrNull<SearchType>()?.selected?.also {
searchTypeAdded = true
addPathSegment(it)
}
addQueryParameter("keyword", query)
if (page > 1 && searchTypeAdded) {
addQueryParameter("page", page.toString())
}
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val entries = document.select(".webtoon_list li a").map(::mangaFromElement)
val hasNextPage = document.selectFirst("a.pagination[aria-current=true] + a") != null
return MangasPage(entries, hasNextPage)
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { mangaDetailsParse(it, manga) }
}
private fun mangaDetailsParse(response: Response, oldManga: SManga): SManga {
val document = response.asJsoup()
val detailElement = document.selectFirst(".detail_header .info")
val infoElement = document.selectFirst("#_asideDetail")
return SManga.create().apply {
setUrlWithoutDomain(document.location())
title = document.selectFirst("h1.subj, h3.subj")!!.text()
author = detailElement?.selectFirst(".author:nth-of-type(1)")?.ownText()
?: detailElement?.selectFirst(".author_area")?.ownText()
artist = detailElement?.selectFirst(".author:nth-of-type(2)")?.ownText()
?: detailElement?.selectFirst(".author_area")?.ownText() ?: author
genre = detailElement?.select(".genre").orEmpty().joinToString { it.text() }
description = infoElement?.selectFirst("p.summary")?.text()
status = with(infoElement?.selectFirst("p.day_info")?.text().orEmpty()) {
when {
contains("UP") || contains("EVERY") || contains("NOUVEAU") -> SManga.ONGOING
contains("END") || contains("TERMINÉ") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
thumbnail_url = run {
val bannerFile = document.selectFirst(".detail_header .thmb img")
?.absUrl("src")
?.toHttpUrl()
?.pathSegments
?.lastOrNull()
val oldThumbFile = oldManga.thumbnail_url
?.toHttpUrl()
?.pathSegments
?.lastOrNull()
val thumbnail = document.selectFirst("head meta[property=\"og:image\"]")
?.attr("content")
// replace banner image for toons in library
if (oldThumbFile != null && oldThumbFile != bannerFile) {
oldManga.thumbnail_url
} else {
thumbnail
}
}
}
}
override fun mangaDetailsParse(response: Response): SManga {
throw UnsupportedOperationException()
}
override fun chapterListRequest(manga: SManga) = GET(mobileUrl + manga.url, mobileHeaders)
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select("ul#_episodeList li[id*=episode] a").map { element ->
SChapter.create().apply {
setUrlWithoutDomain(element.absUrl("href"))
name = element.selectFirst(".sub_title > span.ellipsis")!!.text()
element.selectFirst("a > div.row > div.num")?.let {
name += " Ch. " + it.text().substringAfter("#")
}
element.selectFirst(".ico_bgm")?.also {
name += ""
}
date_upload = dateFormat.tryParse(element.selectFirst(".sub_info .date")?.text())
}
}
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val useMaxQuality = useMaxQualityPref()
val pages = document.select("div#_imageList > img").mapIndexed { i, element ->
val imageUrl = element.attr("data-url").toHttpUrl()
if (useMaxQuality && imageUrl.queryParameter("type") == "q90") {
val newImageUrl = imageUrl.newBuilder().apply {
removeAllQueryParameters("type")
}.build()
Page(i, imageUrl = newImageUrl.toString())
} else {
Page(i, imageUrl = imageUrl.toString())
}
}.toMutableList()
if (pages.isEmpty()) {
pages.addAll(
fetchMotionToonPages(document),
)
}
if (showAuthorsNotesPref()) {
val note = document.select("div.creator_note p.author_text").text()
if (note.isNotEmpty()) {
val creator = document.select("div.creator_note a.author_name span").text().trim()
pages += Page(
pages.size,
imageUrl = TextInterceptorHelper.createUrl("Author's Notes from $creator", note),
)
}
}
return pages
}
private fun fetchMotionToonPages(document: Document): List<Page> {
val docString = document.toString()
val docUrlRegex = Regex("documentURL:.*?'(.*?)'")
val motionToonPathRegex = Regex("jpg:.*?'(.*?)\\{")
val docUrl = docUrlRegex.find(docString)!!.groupValues[1]
val motionToonPath = motionToonPathRegex.find(docString)!!.groupValues[1]
val motionToonResponse = client.newCall(GET(docUrl, headers)).execute()
val motionToonImages = motionToonResponse.parseAs<MotionToonResponse>().assets.images
return motionToonImages.entries
.filter { it.key.contains("layer") }
.mapIndexed { i, entry ->
Page(i, imageUrl = motionToonPath + entry.value)
}
}
private fun showAuthorsNotesPref() = preferences.getBoolean(SHOW_AUTHORS_NOTES_KEY, false)
private fun useMaxQualityPref() = preferences.getBoolean(USE_MAX_QUALITY_KEY, false)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_AUTHORS_NOTES_KEY
title = "Show author's notes"
summary = "Enable to see the author's notes at the end of chapters (if they're there)."
setDefaultValue(false)
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = USE_MAX_QUALITY_KEY
title = "Use maximum quality images"
summary = "Enable to load images in maximum quality."
setDefaultValue(false)
}.also(screen::addPreference)
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
}
private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes"
private const val USE_MAX_QUALITY_KEY = "useMaxQuality"
const val ID_SEARCH_PREFIX = "id:"

View File

@ -1,66 +1,20 @@
package eu.kanade.tachiyomi.extension.all.webtoons
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.model.SManga
import java.text.SimpleDateFormat
import java.util.GregorianCalendar
import java.util.Locale
class WebtoonsFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
WebtoonsEN(),
WebtoonsID(),
WebtoonsTH(),
WebtoonsES(),
WebtoonsFR(),
WebtoonsZH(),
WebtoonsDE(),
override fun createSources() = listOf(
Webtoons("en", dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)),
Webtoons("id", dateFormat = SimpleDateFormat("d MMMM yyyy", Locale("id"))),
Webtoons("th", dateFormat = SimpleDateFormat("d MMM yyyy", Locale("th"))),
Webtoons("es", dateFormat = SimpleDateFormat("d MMMM. yyyy", Locale("es"))),
Webtoons("fr", dateFormat = SimpleDateFormat("d MMM yyyy", Locale.FRENCH)),
object : Webtoons("zh-Hant", "zh-hant", "zh_TW", SimpleDateFormat("yyyy/MM/dd", Locale.TRADITIONAL_CHINESE)) {
// Due to lang code getting more specific
override val id: Long = 2959982438613576472
},
Webtoons("de", dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)),
)
}
class WebtoonsEN : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "en")
class WebtoonsID : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "id") {
// Override ID as part of the name was removed to be more consiten with other enteries
override val id: Long = 8749627068478740298
// Android seems to be unable to parse Indonesian dates; we'll use a short hard-coded table
// instead.
private val dateMap: Array<String> = arrayOf(
"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des",
)
override fun chapterParseDate(date: String): Long {
val expr = Regex("""(\d{4}) ([A-Z][a-z]{2}) (\d+)""").find(date) ?: return 0
val (_, year, monthString, day) = expr.groupValues
val monthIndex = dateMap.indexOf(monthString)
return GregorianCalendar(year.toInt(), monthIndex, day.toInt()).time.time
}
}
class WebtoonsTH : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "th", dateFormat = SimpleDateFormat("d MMM yyyy", Locale("th")))
class WebtoonsES : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "es") {
// Android seems to be unable to parse es dates like Indonesian; we'll use a short hard-coded table instead.
private val dateMap: Array<String> = arrayOf(
"ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic",
)
override fun chapterParseDate(date: String): Long {
val expr = Regex("""(\d+)-([A-Za-z]{3})-(\d{4})""").find(date) ?: return 0
val (_, day, monthString, year) = expr.groupValues
val monthIndex = dateMap.indexOf(monthString.lowercase(Locale("es")))
return GregorianCalendar(year.toInt(), monthIndex, day.toInt()).time.time
}
}
class WebtoonsFR : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "fr", dateFormat = SimpleDateFormat("d MMM yyyy", Locale.FRENCH)) {
override fun String.toStatus(): Int = when {
contains("NOUVEAU") -> SManga.ONGOING
contains("TERMINÉ") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
class WebtoonsZH : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "zh-Hant", "zh-hant", "zh_TW", SimpleDateFormat("yyyy/MM/dd", Locale.TRADITIONAL_CHINESE)) {
// Due to lang code getting more specific
override val id: Long = 2959982438613576472
}
class WebtoonsDE : WebtoonsSrc("Webtoons.com", "https://www.webtoons.com", "de", dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN))

View File

@ -1,121 +0,0 @@
package eu.kanade.tachiyomi.extension.all.webtoons
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptor
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper
import eu.kanade.tachiyomi.multisrc.webtoons.Webtoons
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Page
import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import org.jsoup.nodes.Document
import java.text.SimpleDateFormat
import java.util.Locale
open class WebtoonsSrc(
override val name: String,
override val baseUrl: String,
override val lang: String,
langCode: String = lang,
override val localeForCookie: String = lang,
dateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH),
) : ConfigurableSource, Webtoons(name, baseUrl, lang, langCode, localeForCookie, dateFormat) {
override val client: OkHttpClient = super.client.newBuilder()
.addInterceptor(TextInterceptor())
.build()
private val preferences: SharedPreferences by getPreferencesLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val authorsNotesPref = SwitchPreferenceCompat(screen.context).apply {
key = SHOW_AUTHORS_NOTES_KEY
title = "Show author's notes"
summary = "Enable to see the author's notes at the end of chapters (if they're there)."
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit().putBoolean(SHOW_AUTHORS_NOTES_KEY, checkValue).commit()
}
}
screen.addPreference(authorsNotesPref)
val maxQualityPref = SwitchPreferenceCompat(screen.context).apply {
key = USE_MAX_QUALITY_KEY
title = "Use maximum quality images"
summary = "Enable to load images in maximum quality."
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit().putBoolean(USE_MAX_QUALITY_KEY, checkValue).commit()
}
}
screen.addPreference(maxQualityPref)
}
private fun showAuthorsNotesPref() = preferences.getBoolean(SHOW_AUTHORS_NOTES_KEY, false)
private fun useMaxQualityPref() = preferences.getBoolean(USE_MAX_QUALITY_KEY, false)
override fun pageListParse(document: Document): List<Page> {
val useMaxQuality = useMaxQualityPref()
var pages = document.select("div#_imageList > img").mapIndexed { i, element ->
val imageUrl = element.attr("data-url").toHttpUrl()
if (useMaxQuality && imageUrl.queryParameter("type") == "q90") {
val newImageUrl = imageUrl.newBuilder().apply {
removeAllQueryParameters("type")
}.build()
Page(i, "", newImageUrl.toString())
} else {
Page(i, "", imageUrl.toString())
}
}
if (showAuthorsNotesPref()) {
val note = document.select("div.creator_note p.author_text").text()
if (note.isNotEmpty()) {
val creator = document.select("div.creator_note a.author_name span").text().trim()
pages = pages + Page(
pages.size,
"",
TextInterceptorHelper.createUrl("Author's Notes from $creator", note),
)
}
}
if (pages.isNotEmpty()) { return pages }
val docString = document.toString()
val docUrlRegex = Regex("documentURL:.*?'(.*?)'")
val motiontoonPathRegex = Regex("jpg:.*?'(.*?)\\{")
val docUrl = docUrlRegex.find(docString)!!.destructured.toList()[0]
val motiontoonPath = motiontoonPathRegex.find(docString)!!.destructured.toList()[0]
val motiontoonResponse = client.newCall(GET(docUrl, headers)).execute()
val motiontoonJson = json.parseToJsonElement(motiontoonResponse.body.string()).jsonObject
val motiontoonImages = motiontoonJson["assets"]!!.jsonObject["image"]!!.jsonObject
return motiontoonImages.entries
.filter { it.key.contains("layer") }
.mapIndexed { i, entry ->
Page(i, "", motiontoonPath + entry.value.jsonPrimitive.content)
}
}
companion object {
private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes"
private const val USE_MAX_QUALITY_KEY = "useMaxQuality"
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.multisrc.webtoons
package eu.kanade.tachiyomi.extension.all.webtoons
import android.app.Activity
import android.content.ActivityNotFoundException
@ -18,24 +18,26 @@ import kotlin.system.exitProcess
*/
class WebtoonsUrlActivity : Activity() {
private val name = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
val title_no = intent?.data?.getQueryParameter("title_no")
if (pathSegments != null && pathSegments.size >= 3 && title_no != null) {
val titleNo = intent?.data?.getQueryParameter("title_no")
val lang = intent?.data?.pathSegments?.get(0)
if (titleNo != null) {
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Webtoons.URL_SEARCH_PREFIX}${intent?.data?.toString()}")
putExtra("query", "$ID_SEARCH_PREFIX$lang:$titleNo")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("WebtoonsUrlActivity", e.toString())
Log.e(name, e.toString())
}
} else {
Log.e("WebtoonsUrlActivity", "could not parse uri from intent $intent")
Log.e(name, "could not parse uri from intent $intent")
}
finish()

View File

@ -1,9 +1,7 @@
ext {
extName = 'Webtoons.com Translations'
extClass = '.WebtoonsTranslateFactory'
themePkg = 'webtoons'
baseUrl = 'https://translate.webtoons.com'
overrideVersionCode = 4
extVersionCode = 9
isNsfw = false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,83 @@
package eu.kanade.tachiyomi.extension.all.webtoonstranslate
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrl
@Serializable
class Result<T>(
val result: T?,
val code: String,
val message: String? = null,
)
@Serializable
class TitleList(
val totalCount: Int,
val titleList: List<Title>,
)
@Serializable
class Title(
private val titleNo: Int,
private val teamVersion: Int,
private val representTitle: String,
private val writeAuthorName: String?,
private val pictureAuthorName: String?,
private val thumbnailIPadUrl: String?,
private val thumbnailMobileUrl: String?,
) {
private val thumbnailUrl: String?
get() = (thumbnailIPadUrl ?: thumbnailMobileUrl)
?.let { "https://mwebtoon-phinf.pstatic.net$it" }
fun toSManga(baseUrl: String, translateLangCode: String) = SManga.create().apply {
url = baseUrl.toHttpUrl().newBuilder()
.addPathSegments("translate/episodeList")
.addQueryParameter("titleNo", titleNo.toString())
.addQueryParameter("languageCode", translateLangCode)
.addQueryParameter("teamVersion", teamVersion.toString())
.build()
.toString()
title = representTitle
author = writeAuthorName
artist = pictureAuthorName ?: writeAuthorName
thumbnail_url = thumbnailUrl
}
}
@Serializable
class EpisodeList(
val episodes: List<Episode>,
)
@Serializable
class Episode(
val translateCompleted: Boolean,
private val titleNo: Int,
private val episodeNo: Int,
private val languageCode: String,
private val teamVersion: Int,
private val title: String,
private val episodeSeq: Int,
private val updateYmdt: Long,
) {
fun toSChapter() = SChapter.create().apply {
url = "/lineWebtoon/ctrans/translatedEpisodeDetail_jsonp.json?titleNo=$titleNo&episodeNo=$episodeNo&languageCode=$languageCode&teamVersion=$teamVersion"
name = "$title #$episodeSeq"
chapter_number = episodeSeq.toFloat()
date_upload = updateYmdt
scanlator = teamVersion.takeIf { it != 0 }?.toString() ?: "(wiki)"
}
}
@Serializable
class ImageList(
val imageInfo: List<Image>,
)
@Serializable
class Image(
val imageUrl: String,
)

View File

@ -0,0 +1,183 @@
package eu.kanade.tachiyomi.extension.all.webtoonstranslate
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import rx.Observable
class WebtoonsTranslate(
override val lang: String,
private val translateLangCode: String,
private val extensionId: Long? = null,
) : HttpSource() {
override val name = "Webtoons.com Translations"
override val baseUrl = "https://translate.webtoons.com"
override val id get() = extensionId ?: super.id
override val supportsLatest = false
override val client = network.cloudflareClient
private val apiBaseUrl = "https://global.apis.naver.com"
private val mobileBaseUrl = "https://m.webtoons.com"
private val pageSize = 24
override fun headersBuilder() = super.headersBuilder()
.add("Referer", mobileBaseUrl)
private fun mangaRequest(page: Int, requestSize: Int): Request {
val url = apiBaseUrl.toHttpUrl().newBuilder()
.addPathSegments("lineWebtoon/ctrans/translatedWebtoons_jsonp.json")
.addQueryParameter("orderType", "UPDATE")
.addQueryParameter("offset", "${(page - 1) * requestSize}")
.addQueryParameter("size", "$requestSize")
.addQueryParameter("languageCode", translateLangCode)
.build()
return GET(url, headers)
}
// Webtoons translations doesn't really have a "popular" sort; just "UPDATE", "TITLE_ASC",
// and "TITLE_DESC". Pick UPDATE as the most useful sort.
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, pageSize)
override fun popularMangaParse(response: Response): MangasPage {
val offset = response.request.url.queryParameter("offset")!!.toInt()
val result = response.parseAs<Result<TitleList>>()
assert(result.code == "000") {
"Error getting popular manga: error code ${result.code}"
}
val mangaList = result.result!!.titleList
.map { it.toSManga(mobileBaseUrl, translateLangCode) }
return MangasPage(mangaList, hasNextPage = result.result.totalCount > pageSize + offset)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response, query)
}
}
/**
* Don't see a search function for Fan Translations, so let's do it client side.
* There's 75 webtoons as of 2019/11/21, a hardcoded request of 200 should be a sufficient request
* to get all titles, in 1 request, for quite a while
*/
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = mangaRequest(page, 200)
private fun searchMangaParse(response: Response, query: String): MangasPage {
val mangas = popularMangaParse(response).mangas
.filter {
it.title.contains(query, ignoreCase = true) ||
it.author?.contains(query, ignoreCase = true) == true ||
it.artist?.contains(query, ignoreCase = true) == true
}
return MangasPage(mangas, hasNextPage = false)
}
override fun mangaDetailsRequest(manga: SManga): Request {
return GET(manga.url, headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val (webtoonAuthor, webtoonArtist) = document.getMetaProp("com-linewebtoon:webtoon:author").let {
val split = it.split(" / ", limit = 2)
if (split.count() > 1) {
split[0] to split[1]
} else {
it to it
}
}
return SManga.create().apply {
title = document.getMetaProp("og:title")
artist = webtoonAuthor
author = webtoonArtist
description = document.getMetaProp("og:description")
thumbnail_url = document.getMetaProp("og:image")
}
}
private fun Document.getMetaProp(property: String): String =
head().select("meta[property=\"$property\"]").attr("content")
override fun chapterListRequest(manga: SManga): Request {
val (titleNo, teamVersion) = manga.url.toHttpUrl().let {
it.queryParameter("titleNo") to it.queryParameter("teamVersion")
}
val chapterListUrl = apiBaseUrl.toHttpUrl().newBuilder()
.addPathSegments("lineWebtoon/ctrans/translatedEpisodes_jsonp.json")
.addQueryParameter("titleNo", titleNo)
.addQueryParameter("languageCode", translateLangCode)
.addQueryParameter("offset", "0")
.addQueryParameter("limit", "10000")
.addQueryParameter("teamVersion", teamVersion)
.build()
return GET(chapterListUrl, headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val result = response.parseAs<Result<EpisodeList>>()
assert(result.code == "000") {
val message = result.message ?: "error ${result.code}"
throw Exception("Error getting chapter list: $message")
}
return result.result!!.episodes
.filter { it.translateCompleted }
.map { it.toSChapter() }
.reversed()
}
override fun pageListRequest(chapter: SChapter): Request {
return GET("$apiBaseUrl${chapter.url}", headers)
}
override fun pageListParse(response: Response): List<Page> {
val result = response.parseAs<Result<ImageList>>()
return result.result!!.imageInfo.mapIndexed { i, img ->
Page(i, imageUrl = img.imageUrl)
}
}
override fun searchMangaParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
}

View File

@ -1,83 +1,40 @@
package eu.kanade.tachiyomi.extension.all.webtoonstranslate
import eu.kanade.tachiyomi.multisrc.webtoons.WebtoonsTranslate
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class WebtoonsTranslateFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
WebtoonsTranslateEN(),
WebtoonsTranslateZH_CMN(),
WebtoonsTranslateZH_CMY(),
WebtoonsTranslateTH(),
WebtoonsTranslateID(),
WebtoonsTranslateFR(),
WebtoonsTranslateVI(),
WebtoonsTranslateRU(),
WebtoonsTranslateAR(),
WebtoonsTranslateFIL(),
WebtoonsTranslateDE(),
WebtoonsTranslateHI(),
WebtoonsTranslateIT(),
WebtoonsTranslateJA(),
WebtoonsTranslatePT_POR(),
WebtoonsTranslateTR(),
WebtoonsTranslateMS(),
WebtoonsTranslatePL(),
WebtoonsTranslatePT_POT(),
WebtoonsTranslateBG(),
WebtoonsTranslateDA(),
WebtoonsTranslateNL(),
WebtoonsTranslateRO(),
WebtoonsTranslateMN(),
WebtoonsTranslateEL(),
WebtoonsTranslateLT(),
WebtoonsTranslateCS(),
WebtoonsTranslateSV(),
WebtoonsTranslateBN(),
WebtoonsTranslateFA(),
WebtoonsTranslateUK(),
WebtoonsTranslateES(),
override fun createSources() = listOf(
WebtoonsTranslate("en", "ENG"),
WebtoonsTranslate("zh-Hans", "CMN", 5196522547754842244),
WebtoonsTranslate("zh-Hant", "CMT", 1016181401146312893),
WebtoonsTranslate("th", "THA"),
WebtoonsTranslate("id", "IND"),
WebtoonsTranslate("fr", "FRA"),
WebtoonsTranslate("vi", "VIE"),
WebtoonsTranslate("ru", "RUS"),
WebtoonsTranslate("ar", "ARA"),
WebtoonsTranslate("fil", "FIL"),
WebtoonsTranslate("de", "DEU"),
WebtoonsTranslate("hi", "HIN"),
WebtoonsTranslate("it", "ITA"),
WebtoonsTranslate("ja", "JPN"),
WebtoonsTranslate("pt-BR", "POR", 275670196689829558),
WebtoonsTranslate("tr", "TUR"),
WebtoonsTranslate("ms", "MAY"),
WebtoonsTranslate("pl", "POL"),
WebtoonsTranslate("pt", "POT", 9219933036054791613),
WebtoonsTranslate("bg", "BUL"),
WebtoonsTranslate("da", "DAN"),
WebtoonsTranslate("nl", "NLD"),
WebtoonsTranslate("ro", "RON"),
WebtoonsTranslate("mn", "MON"),
WebtoonsTranslate("el", "GRE"),
WebtoonsTranslate("lt", "LIT"),
WebtoonsTranslate("cs", "CES"),
WebtoonsTranslate("sv", "SWE"),
WebtoonsTranslate("bn", "BEN"),
WebtoonsTranslate("fa", "PER"),
WebtoonsTranslate("uk", "UKR"),
WebtoonsTranslate("es", "SPA"),
)
}
class WebtoonsTranslateEN : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "en", "ENG")
class WebtoonsTranslateZH_CMN : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "zh-Hans", "CMN") {
override val id: Long = 5196522547754842244
}
class WebtoonsTranslateZH_CMY : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "zh-Hant", "CMT") {
override val id: Long = 1016181401146312893
}
class WebtoonsTranslateTH : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "th", "THA")
class WebtoonsTranslateID : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "id", "IND")
class WebtoonsTranslateFR : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "fr", "FRA")
class WebtoonsTranslateVI : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "vi", "VIE")
class WebtoonsTranslateRU : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "ru", "RUS")
class WebtoonsTranslateAR : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "ar", "ARA")
class WebtoonsTranslateFIL : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "fil", "FIL")
class WebtoonsTranslateDE : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "de", "DEU")
class WebtoonsTranslateHI : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "hi", "HIN")
class WebtoonsTranslateIT : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "it", "ITA")
class WebtoonsTranslateJA : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "ja", "JPN")
class WebtoonsTranslatePT_POR : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "pt-BR", "POR") {
// Hardcode the id because the language code was wrong.
override val id: Long = 275670196689829558
}
class WebtoonsTranslateTR : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "tr", "TUR")
class WebtoonsTranslateMS : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "ms", "MAY")
class WebtoonsTranslatePL : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "pl", "POL")
class WebtoonsTranslatePT_POT : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "pt", "POT") {
override val id: Long = 9219933036054791613
}
class WebtoonsTranslateBG : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "bg", "BUL")
class WebtoonsTranslateDA : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "da", "DAN")
class WebtoonsTranslateNL : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "nl", "NLD")
class WebtoonsTranslateRO : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "ro", "RON")
class WebtoonsTranslateMN : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "mn", "MON")
class WebtoonsTranslateEL : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "el", "GRE")
class WebtoonsTranslateLT : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "lt", "LIT")
class WebtoonsTranslateCS : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "cs", "CES")
class WebtoonsTranslateSV : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "sv", "SWE")
class WebtoonsTranslateBN : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "bn", "BEN")
class WebtoonsTranslateFA : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "fa", "PER")
class WebtoonsTranslateUK : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "uk", "UKR")
class WebtoonsTranslateES : WebtoonsTranslate("Webtoons.com Translations", "https://translate.webtoons.com", "es", "SPA")

View File

@ -1,9 +1,7 @@
ext {
extName = 'Dongman Manhua'
extClass = '.DongmanManhua'
themePkg = 'webtoons'
baseUrl = 'https://www.dongmanmanhua.cn'
overrideVersionCode = 0
extVersionCode = 5
isNsfw = false
}

View File

@ -1,36 +1,132 @@
package eu.kanade.tachiyomi.extension.zh.dongmanmanhua
import eu.kanade.tachiyomi.multisrc.webtoons.Webtoons
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import keiyoushi.utils.tryParse
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class DongmanManhua : Webtoons("Dongman Manhua", "https://www.dongmanmanhua.cn", "zh", "", dateFormat = SimpleDateFormat("yyyy-M-d", Locale.ENGLISH)) {
class DongmanManhua : HttpSource() {
override val name = "Dongman Manhua"
override val lang = "zh"
override val baseUrl = "https://www.dongmanmanhua.cn"
override val supportsLatest = true
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.removeAll("Referer")
.add("Referer", baseUrl)
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
override val client = network.cloudflareClient
override fun popularMangaRequest(page: Int) = GET("$baseUrl/dailySchedule", headers)
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/dailySchedule?sortOrder=UPDATE&webtoonCompleteType=ONGOING", headers)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
override fun parseDetailsThumbnail(document: Document): String? {
return document.select("div.detail_body").attr("style").substringAfter("(").substringBefore(")")
val entries = document.select("div#dailyList .daily_section li a, div.daily_lst.comp li a")
.map(::mangaFromElement)
.distinctBy { it.url }
return MangasPage(entries, false)
}
override fun chapterListRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
private fun mangaFromElement(element: Element): SManga {
return SManga.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.selectFirst("p.subj")!!.text()
thumbnail_url = element.selectFirst("img")?.attr("abs:src")
}
}
override fun chapterListSelector() = "ul#_listUl li"
override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/dailySchedule?sortOrder=UPDATE&webtoonCompleteType=ONGOING", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val day = when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) {
Calendar.SUNDAY -> "div._list_SUNDAY"
Calendar.MONDAY -> "div._list_MONDAY"
Calendar.TUESDAY -> "div._list_TUESDAY"
Calendar.WEDNESDAY -> "div._list_WEDNESDAY"
Calendar.THURSDAY -> "div._list_THURSDAY"
Calendar.FRIDAY -> "div._list_FRIDAY"
Calendar.SATURDAY -> "div._list_SATURDAY"
else -> "div"
}
val entries = document.select("div#dailyList > $day li > a")
.map(::mangaFromElement)
.distinctBy { it.url }
return MangasPage(entries, false)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment("search")
addQueryParameter("keyword", query)
if (page > 1) {
addQueryParameter("page", page.toString())
}
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val entries = document.select("#content > div.card_wrap.search ul:not(#filterLayer) li a")
.map(::mangaFromElement)
val hasNextPage = document.selectFirst("div.more_area, div.paginate a[onclick] + a") != null
return MangasPage(entries, hasNextPage)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val detailElement = document.selectFirst(".detail_header .info")
val infoElement = document.selectFirst("#_asideDetail")
return SManga.create().apply {
title = document.selectFirst("h1.subj, h3.subj")!!.text()
author = detailElement?.selectFirst(".author:nth-of-type(1)")?.ownText()
?: detailElement?.selectFirst(".author_area")?.ownText()
artist = detailElement?.selectFirst(".author:nth-of-type(2)")?.ownText()
?: detailElement?.selectFirst(".author_area")?.ownText() ?: author
genre = detailElement?.select(".genre").orEmpty().joinToString { it.text() }
description = infoElement?.selectFirst("p.summary")?.text()
status = with(infoElement?.selectFirst("p.day_info")?.text().orEmpty()) {
when {
contains("更新") -> SManga.ONGOING
contains("完结") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
thumbnail_url = run {
val picElement = document.selectFirst("#content > div.cont_box > div.detail_body")
val discoverPic = document.selectFirst("#content > div.cont_box > div.detail_header > span.thmb")
picElement?.attr("style")
?.substringAfter("url(")
?.substringBeforeLast(")")
?.removeSurrounding("\"")
?.removeSurrounding("'")
?.takeUnless { it.isBlank() }
?: discoverPic?.selectFirst("img:not([alt='Representative image'])")
?.attr("src")
}
}
}
override fun chapterListParse(response: Response): List<SChapter> {
var document = response.asJsoup()
@ -38,7 +134,7 @@ class DongmanManhua : Webtoons("Dongman Manhua", "https://www.dongmanmanhua.cn",
val chapters = mutableListOf<SChapter>()
while (continueParsing) {
document.select(chapterListSelector()).map { chapters.add(chapterFromElement(it)) }
document.select("ul#_listUl li").map { chapters.add(chapterFromElement(it)) }
document.select("div.paginate a[onclick] + a").let { element ->
if (element.isNotEmpty()) {
document = client.newCall(GET(element.attr("abs:href"), headers)).execute().asJsoup()
@ -50,13 +146,25 @@ class DongmanManhua : Webtoons("Dongman Manhua", "https://www.dongmanmanhua.cn",
return chapters
}
override fun chapterFromElement(element: Element): SChapter {
private fun chapterFromElement(element: Element): SChapter {
return SChapter.create().apply {
name = element.select("span.subj span").text()
url = element.select("a").attr("href").substringAfter(".cn")
date_upload = chapterParseDate(element.select("span.date").text())
name = element.selectFirst("span.subj span")!!.text()
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
date_upload = dateFormat.tryParse(element.selectFirst("span.date")?.text())
}
}
override fun getFilterList(): FilterList = FilterList()
private val dateFormat = SimpleDateFormat("yyyy-M-d", Locale.ENGLISH)
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select("div#_imageList > img").mapIndexed { i, element ->
Page(i, imageUrl = element.attr("data-url"))
}
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
}