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
@ -1,5 +0,0 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 4
|
@ -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:"
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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">
|
||||
|
@ -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'))
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@ -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>,
|
||||
)
|
@ -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",
|
||||
),
|
||||
)
|
@ -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:"
|
@ -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))
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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()
|
@ -1,9 +1,7 @@
|
||||
ext {
|
||||
extName = 'Webtoons.com Translations'
|
||||
extClass = '.WebtoonsTranslateFactory'
|
||||
themePkg = 'webtoons'
|
||||
baseUrl = 'https://translate.webtoons.com'
|
||||
overrideVersionCode = 4
|
||||
extVersionCode = 9
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
BIN
src/all/webtoonstranslate/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
src/all/webtoonstranslate/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
src/all/webtoonstranslate/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
src/all/webtoonstranslate/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/all/webtoonstranslate/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 18 KiB |
@ -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,
|
||||
)
|
@ -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()
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -1,9 +1,7 @@
|
||||
ext {
|
||||
extName = 'Dongman Manhua'
|
||||
extClass = '.DongmanManhua'
|
||||
themePkg = 'webtoons'
|
||||
baseUrl = 'https://www.dongmanmanhua.cn'
|
||||
overrideVersionCode = 0
|
||||
extVersionCode = 5
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|