From a4347e9da1a2d001b2c122203be42a7745126f00 Mon Sep 17 00:00:00 2001 From: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> Date: Sun, 15 Jun 2025 11:14:07 +0500 Subject: [PATCH] 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 --- lib-multisrc/webtoons/build.gradle.kts | 5 - .../tachiyomi/multisrc/webtoons/Webtoons.kt | 308 ---------------- .../multisrc/webtoons/WebtoonsTranslate.kt | 226 ------------ src/all/webtoons/AndroidManifest.xml | 2 +- src/all/webtoons/build.gradle | 5 +- .../webtoons/res/mipmap-hdpi/ic_launcher.png | Bin .../webtoons/res/mipmap-mdpi/ic_launcher.png | Bin .../webtoons/res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher.png | Bin .../tachiyomi/extension/all/webtoons/Dto.kt | 13 + .../extension/all/webtoons/Filters.kt | 22 ++ .../extension/all/webtoons/Webtoons.kt | 337 ++++++++++++++++++ .../extension/all/webtoons/WebtoonsFactory.kt | 68 +--- .../extension/all/webtoons/WebtoonsSrc.kt | 121 ------- .../all}/webtoons/WebtoonsUrlActivity.kt | 16 +- src/all/webtoonstranslate/build.gradle | 4 +- .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4889 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2659 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6618 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 11807 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 18397 bytes .../extension/all/webtoonstranslate/Dto.kt | 83 +++++ .../webtoonstranslate/WebtoonsTranslate.kt | 183 ++++++++++ .../WebtoonsTranslateFactory.kt | 109 ++---- src/zh/dongmanmanhua/build.gradle | 4 +- .../zh/dongmanmanhua/DongmanManhua.kt | 144 +++++++- 27 files changed, 822 insertions(+), 828 deletions(-) delete mode 100644 lib-multisrc/webtoons/build.gradle.kts delete mode 100644 lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/Webtoons.kt delete mode 100644 lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsTranslate.kt rename {lib-multisrc => src/all}/webtoons/res/mipmap-hdpi/ic_launcher.png (100%) rename {lib-multisrc => src/all}/webtoons/res/mipmap-mdpi/ic_launcher.png (100%) rename {lib-multisrc => src/all}/webtoons/res/mipmap-xhdpi/ic_launcher.png (100%) rename {lib-multisrc => src/all}/webtoons/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {lib-multisrc => src/all}/webtoons/res/mipmap-xxxhdpi/ic_launcher.png (100%) create mode 100644 src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Dto.kt create mode 100644 src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Filters.kt create mode 100644 src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Webtoons.kt delete mode 100644 src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt rename {lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc => src/all/webtoons/src/eu/kanade/tachiyomi/extension/all}/webtoons/WebtoonsUrlActivity.kt (69%) create mode 100644 src/all/webtoonstranslate/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/all/webtoonstranslate/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/all/webtoonstranslate/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/all/webtoonstranslate/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/all/webtoonstranslate/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/Dto.kt create mode 100644 src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslate.kt diff --git a/lib-multisrc/webtoons/build.gradle.kts b/lib-multisrc/webtoons/build.gradle.kts deleted file mode 100644 index 6e70fd158..000000000 --- a/lib-multisrc/webtoons/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -plugins { - id("lib-multisrc") -} - -baseVersionCode = 4 diff --git a/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/Webtoons.kt b/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/Webtoons.kt deleted file mode 100644 index 453508eaa..000000000 --- a/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/Webtoons.kt +++ /dev/null @@ -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) {} - override fun loadForRequest(url: HttpUrl): List { - return listOf( - 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() - 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 { - 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>) : 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>) : - Select(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 { - 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:" - } -} diff --git a/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsTranslate.kt b/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsTranslate.kt deleted file mode 100644 index 6efa157b6..000000000 --- a/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsTranslate.kt +++ /dev/null @@ -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 { - 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 = 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 { - 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 { - 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() -} diff --git a/src/all/webtoons/AndroidManifest.xml b/src/all/webtoons/AndroidManifest.xml index 1ce564382..fc72181a7 100644 --- a/src/all/webtoons/AndroidManifest.xml +++ b/src/all/webtoons/AndroidManifest.xml @@ -3,7 +3,7 @@ diff --git a/src/all/webtoons/build.gradle b/src/all/webtoons/build.gradle index 894621893..522512917 100644 --- a/src/all/webtoons/build.gradle +++ b/src/all/webtoons/build.gradle @@ -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')) } diff --git a/lib-multisrc/webtoons/res/mipmap-hdpi/ic_launcher.png b/src/all/webtoons/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from lib-multisrc/webtoons/res/mipmap-hdpi/ic_launcher.png rename to src/all/webtoons/res/mipmap-hdpi/ic_launcher.png diff --git a/lib-multisrc/webtoons/res/mipmap-mdpi/ic_launcher.png b/src/all/webtoons/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from lib-multisrc/webtoons/res/mipmap-mdpi/ic_launcher.png rename to src/all/webtoons/res/mipmap-mdpi/ic_launcher.png diff --git a/lib-multisrc/webtoons/res/mipmap-xhdpi/ic_launcher.png b/src/all/webtoons/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from lib-multisrc/webtoons/res/mipmap-xhdpi/ic_launcher.png rename to src/all/webtoons/res/mipmap-xhdpi/ic_launcher.png diff --git a/lib-multisrc/webtoons/res/mipmap-xxhdpi/ic_launcher.png b/src/all/webtoons/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from lib-multisrc/webtoons/res/mipmap-xxhdpi/ic_launcher.png rename to src/all/webtoons/res/mipmap-xxhdpi/ic_launcher.png diff --git a/lib-multisrc/webtoons/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/webtoons/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from lib-multisrc/webtoons/res/mipmap-xxxhdpi/ic_launcher.png rename to src/all/webtoons/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Dto.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Dto.kt new file mode 100644 index 000000000..2ab97c19c --- /dev/null +++ b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Dto.kt @@ -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, +) diff --git a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Filters.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Filters.kt new file mode 100644 index 000000000..154c37caa --- /dev/null +++ b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Filters.kt @@ -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>, +) : Filter.Select( + 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", + ), +) diff --git a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Webtoons.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Webtoons.kt new file mode 100644 index 000000000..39c762cd1 --- /dev/null +++ b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/Webtoons.kt @@ -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 { + 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()?.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 { + 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 { + 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 { + 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 { + 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().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:" diff --git a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsFactory.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsFactory.kt index 7fcb4c4d9..c23e5c4f2 100644 --- a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsFactory.kt +++ b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsFactory.kt @@ -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 = 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 = 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 = 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)) diff --git a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt deleted file mode 100644 index a13dc2c4a..000000000 --- a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt +++ /dev/null @@ -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 { - 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" - } -} diff --git a/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsUrlActivity.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsUrlActivity.kt similarity index 69% rename from lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsUrlActivity.kt rename to src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsUrlActivity.kt index b395312f3..64ed9e60e 100644 --- a/lib-multisrc/webtoons/src/eu/kanade/tachiyomi/multisrc/webtoons/WebtoonsUrlActivity.kt +++ b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsUrlActivity.kt @@ -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() diff --git a/src/all/webtoonstranslate/build.gradle b/src/all/webtoonstranslate/build.gradle index 813357fd3..01fc6565d 100644 --- a/src/all/webtoonstranslate/build.gradle +++ b/src/all/webtoonstranslate/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'Webtoons.com Translations' extClass = '.WebtoonsTranslateFactory' - themePkg = 'webtoons' - baseUrl = 'https://translate.webtoons.com' - overrideVersionCode = 4 + extVersionCode = 9 isNsfw = false } diff --git a/src/all/webtoonstranslate/res/mipmap-hdpi/ic_launcher.png b/src/all/webtoonstranslate/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..57432f520d146872a22dbf9d38a2be71dc6afc69 GIT binary patch literal 4889 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%0D3$@*;RI(*M)9NMP@5^V&EGpWWe zMlZr(MKspy@~}>yhiS?|*d9I$-?pl;sOAXHMZbsJ#!4E2k-kZIBWO5QYER%d@?Mzi z-yg{(OYwQ+JS58_utIwrS+ytVdVdQZix0x4($8HYKz^8`%SBG@SB|9WDN>>=aV?cl zV)6U5WRem1NaR~I3idAB!kI9+{3HCkr4U)6lki^1SeoGEI+58_%??@~hx;U8XxUms zOH{NO?y0+oqfOS4<F1aj#;NJO}=+@>UMSB`I<9Kcbg(-@L~vINcs@|B6;@+kY8tOPasGC6*c7g zpwb4zIv~-D4+7R1@YV_xio*CD$I#d193aNXh9<3SjkT#cQLv8{ZN!{*W)63{%Hv_4 zxBravnuC#la#-mW0aA@fw=H68Y)>27JZ=T=E-oFfcmjT)yf+pGkHYVjgCP})y+eRl zSyJ;kHW*I3XviIKl9;6Hl9?z(S^Nf zr{qyCd*${Fx+NiEjL2xG;XDP`M;4CW5*4lM^l5|BCf#}AAB^{qs?O?={J}yW%np18 z@2bYovExC%ced4S0v%%N!54M{cF&WDEesxwjKHB#iTymV&~}T*1UQbX_rxQftWF5@ zQ)i*SED8&PM`A`m8e~q1>4^6}o#%!FmxCbe39GCe(g>76@wN(>DyipIzA=ChYCO--4s3E zV^(rf(qNO0N2io_a-@@xHb_nx9_KkRDPSzA;_ia`xPW<0XulHhnk&0#=>N<+CVa1F RfrJ16002ovPDHLkV1jLK=p6t6 literal 0 HcmV?d00001 diff --git a/src/all/webtoonstranslate/res/mipmap-xhdpi/ic_launcher.png b/src/all/webtoonstranslate/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0545ab944403560fd367080eba850a5b255cbe6d GIT binary patch literal 6618 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 N002ovPDHLkV1ih)wJQJs literal 0 HcmV?d00001 diff --git a/src/all/webtoonstranslate/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/webtoonstranslate/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..6b7af1a03f84bd867fbb897b8f9b39b100253a1a GIT binary patch literal 18397 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 e?9co43j9BpdLzDRc4=(@0000( + val result: T?, + val code: String, + val message: String? = null, +) + +@Serializable +class TitleList( + val totalCount: Int, + val titleList: List, +) + +@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, +) diff --git a/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslate.kt b/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslate.kt new file mode 100644 index 000000000..815cf2935 --- /dev/null +++ b/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslate.kt @@ -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() + } +} diff --git a/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslateFactory.kt b/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslateFactory.kt index 265850565..f5a72ec47 100644 --- a/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslateFactory.kt +++ b/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/WebtoonsTranslateFactory.kt @@ -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") diff --git a/src/zh/dongmanmanhua/build.gradle b/src/zh/dongmanmanhua/build.gradle index e69796115..2a7455601 100644 --- a/src/zh/dongmanmanhua/build.gradle +++ b/src/zh/dongmanmanhua/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'Dongman Manhua' extClass = '.DongmanManhua' - themePkg = 'webtoons' - baseUrl = 'https://www.dongmanmanhua.cn' - overrideVersionCode = 0 + extVersionCode = 5 isNsfw = false } diff --git a/src/zh/dongmanmanhua/src/eu/kanade/tachiyomi/extension/zh/dongmanmanhua/DongmanManhua.kt b/src/zh/dongmanmanhua/src/eu/kanade/tachiyomi/extension/zh/dongmanmanhua/DongmanManhua.kt index 510d57ed2..57ba4aa60 100644 --- a/src/zh/dongmanmanhua/src/eu/kanade/tachiyomi/extension/zh/dongmanmanhua/DongmanManhua.kt +++ b/src/zh/dongmanmanhua/src/eu/kanade/tachiyomi/extension/zh/dongmanmanhua/DongmanManhua.kt @@ -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() + } }