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 000000000..57432f520 Binary files /dev/null and b/src/all/webtoonstranslate/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/webtoonstranslate/res/mipmap-mdpi/ic_launcher.png b/src/all/webtoonstranslate/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..eac699395 Binary files /dev/null and b/src/all/webtoonstranslate/res/mipmap-mdpi/ic_launcher.png differ 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 000000000..0545ab944 Binary files /dev/null and b/src/all/webtoonstranslate/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/webtoonstranslate/res/mipmap-xxhdpi/ic_launcher.png b/src/all/webtoonstranslate/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..780faa922 Binary files /dev/null and b/src/all/webtoonstranslate/res/mipmap-xxhdpi/ic_launcher.png differ 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 000000000..6b7af1a03 Binary files /dev/null and b/src/all/webtoonstranslate/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/Dto.kt b/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/Dto.kt new file mode 100644 index 000000000..879be3968 --- /dev/null +++ b/src/all/webtoonstranslate/src/eu/kanade/tachiyomi/extension/all/webtoonstranslate/Dto.kt @@ -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( + 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() + } }