diff --git a/src/all/toomics/build.gradle b/src/all/toomics/build.gradle index 7f3007a7b..91cd33d23 100644 --- a/src/all/toomics/build.gradle +++ b/src/all/toomics/build.gradle @@ -1,7 +1,8 @@ ext { extName = 'Toomics' extClass = '.ToomicsFactory' - extVersionCode = 7 + extVersionCode = 8 + isNsfw = true } apply from: "$rootDir/common.gradle" diff --git a/src/all/toomics/src/eu/kanade/tachiyomi/extension/all/toomics/ToomicsGlobal.kt b/src/all/toomics/src/eu/kanade/tachiyomi/extension/all/toomics/ToomicsGlobal.kt index 0adea603b..1c870f813 100644 --- a/src/all/toomics/src/eu/kanade/tachiyomi/extension/all/toomics/ToomicsGlobal.kt +++ b/src/all/toomics/src/eu/kanade/tachiyomi/extension/all/toomics/ToomicsGlobal.kt @@ -3,17 +3,26 @@ package eu.kanade.tachiyomi.extension.all.toomics import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST 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 kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.FormBody import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import rx.Observable +import uy.kohesive.injekt.injectLazy import java.net.URLDecoder import java.text.ParseException import java.text.SimpleDateFormat @@ -32,6 +41,8 @@ abstract class ToomicsGlobal( override val supportsLatest = true + private val json: Json by injectLazy() + override val client: OkHttpClient = super.client.newBuilder() .connectTimeout(1, TimeUnit.MINUTES) .readTimeout(1, TimeUnit.MINUTES) @@ -42,25 +53,30 @@ abstract class ToomicsGlobal( .add("Referer", "$baseUrl/$siteLang") .add("User-Agent", USER_AGENT) - override fun popularMangaRequest(page: Int): Request { - return GET("$baseUrl/$siteLang/webtoon/favorite", headers) - } + // ================================== Popular ======================================= - // ToomicsGlobal does not have a popular list, so use recommended instead. - override fun popularMangaSelector(): String = "li > div.visual" + override fun popularMangaRequest(page: Int) = GET("$baseUrl/$siteLang/webtoon/ranking", headers) + + override fun popularMangaSelector(): String = "li > div.visual a:has(img)" override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { title = element.select("h4[class$=title]").first()!!.ownText() - // sometimes href contains "/ab/on" at the end and redirects to a chapter instead of manga - setUrlWithoutDomain(element.select("a").attr("href").removeSuffix("/ab/on")) - thumbnail_url = element.select("img").attr("src") + + thumbnail_url = element.selectFirst("img")?.let { img -> + when { + img.hasAttr("data-original") -> img.attr("data-original") + else -> img.attr("src") + } + } + // The path segment '/search/Y' bypasses the age check and prevents redirection to the chapter + setUrlWithoutDomain("${element.absUrl("href")}/search/Y") } override fun popularMangaNextPageSelector(): String? = null - override fun latestUpdatesRequest(page: Int): Request { - return GET("$baseUrl/$siteLang/webtoon/new_comics", headers) - } + // ================================== Latest ======================================= + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/$siteLang/webtoon/new_comics", headers) override fun latestUpdatesSelector(): String = popularMangaSelector() @@ -68,51 +84,60 @@ abstract class ToomicsGlobal( override fun latestUpdatesNextPageSelector(): String? = null + // ================================== Search ======================================= + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val newHeaders = headersBuilder() - .add("Content-Type", "application/x-www-form-urlencoded") + val formBody = FormBody.Builder() + .add("toonData", query) .build() - - val rbody = "toonData=$query&offset=0&limit=20".toRequestBody(null) - - return POST("$baseUrl/$siteLang/webtoon/ajax_search", newHeaders, rbody) + return POST("$baseUrl/$siteLang/webtoon/ajax_search", headers, formBody) } - override fun searchMangaSelector(): String = "div.recently_list ul li" + override fun searchMangaSelector(): String = "#search-list-items li" override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { - title = element.select("a div.search_box dl dt span.title").text() - thumbnail_url = element.select("div.search_box p.img img").attr("abs:src") + title = element.selectFirst("strong")!!.text() + thumbnail_url = element.selectFirst("img")?.absUrl("src") - // When the family mode is off, the url is encoded and is available in the onclick. - element.select("a:not([href^=javascript])").let { - if (it != null) { - setUrlWithoutDomain(it.attr("href")) - } else { - val toonId = element.select("a").attr("onclick") - .substringAfter("Base.setDisplay('A', '") - .substringBefore("'") - .let { url -> URLDecoder.decode(url, "UTF-8") } - .substringAfter("?toon=") - .substringBefore("&") - url = "/$siteLang/webtoon/episode/toon/$toonId" + element.selectFirst("a.relative")!!.attr("href").let { + val href = it.substringAfter("Base.setFamilyMode('N', '").substringBefore("'") + val url = when { + href.contains(baseUrl, true) -> href.toHttpUrl() + else -> "$baseUrl${URLDecoder.decode(href, "UTF-8")}".toHttpUrl() } + // The path segment '/search/Y' bypasses the age check and prevents redirection to the chapter + setUrlWithoutDomain("$baseUrl/$siteLang/webtoon/episode/toon/${url.queryParameter("toon")}/search/Y") } } override fun searchMangaNextPageSelector(): String? = null - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - val header = document.select("#glo_contents header.ep-cover_ch div.title_content") - - title = header.select("h1").text() - author = header.select("p.type_box span.writer").text() - artist = header.select("p.type_box span.writer").text() - genre = header.select("p.type_box span.type").text().replace("/", ",") - description = header.select("h2").text() - thumbnail_url = document.select("head meta[property='og:image']").attr("content") + override fun searchMangaParse(response: Response): MangasPage { + val searchDto = json.decodeFromStream(response.body.byteStream()) + val document = Jsoup.parseBodyFragment(searchDto.content.clearHtml(), baseUrl) + val mangas = document.select(searchMangaSelector()).map(::searchMangaFromElement) + return MangasPage(mangas, false) } + // ================================== Manga Details ================================ + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + val header = document.selectFirst("#glo_contents section.relative:has(img[src*=thumb])")!! + + title = header.selectFirst("h2")!!.text() + header.selectFirst(".mb-0.text-xs.font-normal")?.let { + val info = it.text().split("|") + artist = info.first() + author = info.last() + } + + genre = header.selectFirst("dt:contains(genres) + dd")?.text()?.replace("/", ",") + description = header.selectFirst(".break-noraml.text-xs")?.text() + thumbnail_url = document.selectFirst("head meta[property='og:image']")?.attr("content") + } + + // ================================== Chapters ===================================== + override fun fetchChapterList(manga: SManga): Observable> { return super.fetchChapterList(manga) .map { it.reversed() } @@ -122,18 +147,20 @@ abstract class ToomicsGlobal( override fun chapterListSelector(): String = "li.normal_ep:has(.coin-type1, .coin-type6)" override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { - val num = element.select("div.cell-num").text() + val num = element.selectFirst("div.cell-num")!!.text() val numText = if (num.isNotEmpty()) "$num - " else "" - name = numText + element.select("div.cell-title strong").first()?.ownText() + name = numText + (element.selectFirst("div.cell-title strong")?.ownText() ?: "") chapter_number = num.toFloatOrNull() ?: -1f date_upload = parseChapterDate(element.select("div.cell-time time").text()) scanlator = "Toomics" - url = element.select("a").attr("onclick") + url = element.selectFirst("a")!!.attr("onclick") .substringAfter("href='") .substringBefore("'") } + // ================================== Pages ======================================== + override fun pageListParse(document: Document): List { if (document.select("div.section_age_verif").isNotEmpty()) { throw Exception("Verify age via WebView") @@ -155,6 +182,15 @@ abstract class ToomicsGlobal( return GET(page.imageUrl!!, newHeaders) } + // ================================== Utilities ==================================== + @Serializable + class SearchDto(@SerialName("webtoon") private val html: Html) { + val content: String get() = html.data + + @Serializable + class Html(@SerialName("sHtml") val data: String) + } + private fun parseChapterDate(date: String): Long { return try { dateFormat.parse(date)?.time ?: 0L @@ -163,7 +199,21 @@ abstract class ToomicsGlobal( } } + fun String.clearHtml(): String { + return this.unicode().replace(ESCAPE_CHAR_REGEX, "") + } + + fun String.unicode(): String { + return UNICODE_REGEX.replace(this) { match -> + val hex = match.groupValues[1].ifEmpty { match.groupValues[2] } + val value = hex.toInt(16) + value.toChar().toString() + } + } + companion object { private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36" + val UNICODE_REGEX = "\\\\u([0-9A-Fa-f]{4})|\\\\U([0-9A-Fa-f]{8})".toRegex() + val ESCAPE_CHAR_REGEX = """(\\n)|(\\r)|(\\{1})""".toRegex() } }