diff --git a/src/all/qtoon/AndroidManifest.xml b/src/all/qtoon/AndroidManifest.xml new file mode 100644 index 000000000..d298433de --- /dev/null +++ b/src/all/qtoon/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/qtoon/build.gradle b/src/all/qtoon/build.gradle new file mode 100644 index 000000000..9f87c368a --- /dev/null +++ b/src/all/qtoon/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'QToon' + extClass = '.QToonFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/qtoon/res/mipmap-hdpi/ic_launcher.png b/src/all/qtoon/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..6cbc2266b Binary files /dev/null and b/src/all/qtoon/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/qtoon/res/mipmap-mdpi/ic_launcher.png b/src/all/qtoon/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..75d65d16a Binary files /dev/null and b/src/all/qtoon/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/qtoon/res/mipmap-xhdpi/ic_launcher.png b/src/all/qtoon/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7db118f24 Binary files /dev/null and b/src/all/qtoon/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/qtoon/res/mipmap-xxhdpi/ic_launcher.png b/src/all/qtoon/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..ab3b6eac8 Binary files /dev/null and b/src/all/qtoon/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/qtoon/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/qtoon/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..298955737 Binary files /dev/null and b/src/all/qtoon/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Dto.kt b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Dto.kt new file mode 100644 index 000000000..6e811181b --- /dev/null +++ b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Dto.kt @@ -0,0 +1,125 @@ +package eu.kanade.tachiyomi.extension.all.qtoon + +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.toJsonString +import kotlinx.serialization.Serializable + +@Serializable +class EncryptedResponse( + val ts: Long, + val data: String, +) + +@Serializable +class ComicsList( + val comics: List, + val more: Int, +) + +@Serializable +class ComicUrl( + val csid: String, + val webLinkId: String, +) + +@Serializable +class Image( + val thumb: Thumb, +) + +@Serializable +class Thumb( + val url: String, +) + +@Serializable +class ComicDetailsResponse( + val comic: Comic, +) + +@Serializable +class Comic( + val csid: String, + val webLinkId: String? = null, + val title: String, + val image: Image, + val tags: List, + val author: String? = null, + val serialStatus2: Int, + val updateMemo: String? = null, + val introduction: String, + val corners: Corner, +) { + fun toSManga() = SManga.create().apply { + url = ComicUrl(csid, webLinkId.orEmpty()).toJsonString() + title = this@Comic.title + thumbnail_url = image.thumb.url + author = this@Comic.author + description = buildString { + append(introduction) + if (!updateMemo.isNullOrBlank()) { + append("\n\nUpdates: ", updateMemo) + } + } + genre = buildSet { + tags.mapTo(this) { it.name } + corners.cornerTags.mapTo(this) { it.name } + }.joinToString() + status = when (serialStatus2) { + 101 -> SManga.ONGOING + 103 -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + initialized = true + } +} + +@Serializable +class Tag( + val name: String, +) + +@Serializable +class Corner( + val cornerTags: List, +) + +@Serializable +class ChapterEpisodes( + val episodes: List, +) + +@Serializable +class Episode( + val esid: String, + val title: String, + val serialNo: Int, +) + +@Serializable +class EpisodeUrl( + val esid: String, + val csid: String, +) + +@Serializable +class EpisodeResponse( + val definitions: List, +) + +@Serializable +class EpisodeDefinition( + val token: String, +) + +@Serializable +class EpisodeResources( + val resources: List, + val more: Int, +) + +@Serializable +class Resource( + val url: String, + val rgIdx: Int, +) diff --git a/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/EncryptionUtils.kt b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/EncryptionUtils.kt new file mode 100644 index 000000000..a3030dd0a --- /dev/null +++ b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/EncryptionUtils.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.extension.all.qtoon + +import android.util.Base64 +import keiyoushi.utils.parseAs +import okhttp3.Response +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +fun generateRandomString(length: Int): String { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('2'..'8') + return (1..length) + .map { allowedChars.random() } + .joinToString("") +} + +private fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(input.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { + "%02x".format(it) + } +} + +private fun aesDecrypt(data: String, key: ByteArray, iv: ByteArray): String { + val encryptedData = Base64.decode(data, Base64.DEFAULT) + + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + val keySpec = SecretKeySpec(key, "AES") + val ivSpec = IvParameterSpec(iv) + + init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + } + + val decryptedData = cipher.doFinal(encryptedData) + + return String(decryptedData, Charsets.UTF_8) +} + +fun decrypt(response: Response): String { + val res = response.parseAs() + val requestToken = response.request.header("did")!! + + val inner = md5("$requestToken${res.ts}") + val outer = md5("${inner}OQlM9JBJgLWsgffb") + + val key = outer.substring(0, 16).toByteArray(Charsets.UTF_8) + val iv = outer.substring(16, 32).toByteArray(Charsets.UTF_8) + + return aesDecrypt(res.data, key, iv) +} + +inline fun Response.decryptAs(): T { + return decrypt(this).parseAs() +} + +fun decryptImageUrl(url: String, requestToken: String): String { + val inner = md5(requestToken) + val outer = md5("${inner}9tv86uBwmOYs7QZ0") + + val key = outer.substring(0, 16).toByteArray(Charsets.UTF_8) + val iv = outer.substring(16, 32).toByteArray(Charsets.UTF_8) + + return aesDecrypt(url, key, iv) +} + +val mobileUserAgentRegex = Regex( + """android|avantgo|blackberry|iemobile|ipad|iphone|ipod|j2me|midp|mobile|opera mini|phone|palm|pocket|psp|symbian|up.browser|up.link|wap|windows ce|xda|xiino""", + RegexOption.IGNORE_CASE, +) diff --git a/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Filters.kt b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Filters.kt new file mode 100644 index 000000000..9451add3a --- /dev/null +++ b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Filters.kt @@ -0,0 +1,121 @@ +package eu.kanade.tachiyomi.extension.all.qtoon + +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 TagFilter : SelectFilter( + name = "Tags", + options = listOf( + "All" to "-1", + "Action" to "4", + "Adaptation" to "22", + "Adult" to "21", + "Adventure" to "38", + "Age Gap" to "41", + "BL" to "5", + "Bloody" to "53", + "Cheating/Infidelity" to "44", + "Childhood Friends" to "42", + "College life" to "29", + "Comedy" to "3", + "Crime" to "48", + "Doujinshi" to "43", + "Drama" to "6", + "Fantasy" to "2", + "GL" to "17", + "Harem" to "31", + "Hentai" to "34", + "Historical" to "16", + "Horror" to "12", + "Isekai" to "25", + "Josei(W)" to "23", + "Magic" to "28", + "Manga" to "26", + "Manhwa" to "19", + "Mature" to "18", + "Mystery" to "14", + "Office Workers" to "33", + "Omegaverse" to "35", + "Oneshot" to "50", + "Psychological" to "32", + "Reincarnation" to "30", + "Revenge" to "45", + "Reverse Harem" to "52", + "Romance" to "1", + "Royalty" to "49", + "School Life" to "27", + "Sci-fi" to "9", + "Seinen(M)" to "24", + "Shoujo" to "55", + "Shounen ai" to "36", + "Shounen(B)" to "40", + "Slice of life" to "10", + "Smut" to "20", + "Sports" to "15", + "Superhero" to "13", + "Supernatural" to "8", + "Thriller" to "7", + "Time Travel" to "39", + "Tragedy" to "56", + "Transmigration" to "51", + "Vampires" to "54", + "Villainess" to "46", + "Violence" to "37", + "Yakuzas" to "47", + ), +) + +class GenderFilter : SelectFilter( + name = "Gender", + options = listOf( + "All" to "-1", + "Male" to "101", + "Female" to "103", + ), +) + +class StatusFilter : SelectFilter( + name = "Status", + options = listOf( + "All" to "-1", + "Ongoing" to "101", + "Completed" to "103", + ), +) + +class SortFilter : SelectFilter( + name = "Sort", + options = listOf( + "Hot" to "hot", + "New" to "new", + "Rate" to "rate", + ), +) + +class HomePageFilter : SelectFilter( + name = "Home Page Section", + options = listOf( + "" to "", + "✨ Trending Updates ✨" to "as_l9zC15glGlkcS7yIamHQ", + "đŸĨĩ Hottest BL" to "as_8CgkZpYmgOr0aAYHsePs", + "â¤ī¸â€đŸ”Ĩ Hot & Sweet Desire â¤ī¸â€đŸ”Ĩ" to "as_DP6QM8o_pgvu4Q8uVNjt", + "🔄 Rebirth. Revenge. Reclaim. đŸ’Ĩ" to "as_16RPgJOVcNQ11N97pOe4B3", + "đŸ‡¯đŸ‡ĩ Manga Paradise â›Šī¸" to "as_eF_lw9vKVUWpf0trKDk1", + "đŸĢ Campus Love, Teen Feels 💓" to "as_RtRk4KegzUjsoEEUGWOK", + "📖 Reborn in a Novel/Game 🎮" to "as_16IPE5so_KZ13zYzBRSf4O", + "âš”ī¸ Level Up to a Top Hunter!" to "as_fQnbLm2ZSymVTHEWoxMf", + "âœī¸ Must-Read Completed" to "as_fdZX3BgTPGRELzqlfg_A", + "🌸 BL Vibes, Innocent Hearts 💝" to "as_FPRnQVKG6qJ5poOo7FKE", + "🌅 Reborn! A New Life Awaits đŸ”Ĩ" to "as_eth_Jc0XcLftyVnVJOnb", + "💕 Beyond Friendship 💕 LGBT+" to "as_JW0c05O4zWPFSmDW0iCH", + ), +) diff --git a/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/QToon.kt b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/QToon.kt new file mode 100644 index 000000000..4921d2545 --- /dev/null +++ b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/QToon.kt @@ -0,0 +1,266 @@ +package eu.kanade.tachiyomi.extension.all.qtoon + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.Filter +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 keiyoushi.utils.firstInstance +import keiyoushi.utils.parseAs +import keiyoushi.utils.toJsonString +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import rx.Observable + +class QToon( + override val lang: String, + private val siteLang: String, +) : HttpSource() { + override val name = "QToon" + + private val domain = "qtoon.com" + override val baseUrl = "https://$domain" + private val apiUrl = "https://api.$domain" + + override val supportsLatest = true + + override val client = network.cloudflareClient + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + + override fun popularMangaRequest(page: Int) = + searchMangaRequest(page, "", getFilterList()) + + override fun popularMangaParse(response: Response) = + searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int): Request { + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/ranking/page/comics") + addQueryParameter("page", page.toString()) + addQueryParameter("rsid", "daily_hot") + }.build() + + return apiRequest(url) + } + + override fun latestUpdatesParse(response: Response) = + searchMangaParse(response) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith("https://")) { + val urlPath = query.toHttpUrl().pathSegments + val csid = if ( + urlPath.size == 2 && + (urlPath[0] == "detail" || urlPath[0] == "reader") && + siteLang == "en-US" + ) { + urlPath[1] + } else if ( + urlPath.size == 3 && + (urlPath[1] == "detail" || urlPath[1] == "reader") && + urlPath[0] == siteLang.split("-", limit = 2)[0] + ) { + urlPath[2] + } else { + return Observable.just(MangasPage(emptyList(), false)) + } + + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/comic/detail") + addQueryParameter("csid", csid) + }.build() + + return client.newCall(apiRequest(url)) + .asObservableSuccess() + .map(::mangaDetailsParse) + .map { MangasPage(listOf(it), false) } + } + + return super.fetchSearchManga(page, query, filters) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotBlank()) { + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/search/comic/search") + addQueryParameter("title", query.trim()) + addQueryParameter("page", page.toString()) + }.build() + + return apiRequest(url) + } + + val homePageSection = filters.firstInstance().selected + if (homePageSection.isNotBlank()) { + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/album/page/comics") + addQueryParameter("page", page.toString()) + addQueryParameter("asid", homePageSection) + }.build() + + return apiRequest(url) + } + + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/search/comic/gallery") + addQueryParameter("area", "-1") + addQueryParameter("tag", filters.firstInstance().selected) + addQueryParameter("gender", filters.firstInstance().selected) + addQueryParameter("serialStatus", filters.firstInstance().selected) + addQueryParameter("sortType", filters.firstInstance().selected) + addQueryParameter("page", page.toString()) + }.build() + + return apiRequest(url) + } + + override fun getFilterList() = FilterList( + Filter.Header("Filters don't work with text search"), + TagFilter(), + StatusFilter(), + SortFilter(), + GenderFilter(), + Filter.Separator(), + Filter.Header("Home Page section don't work with other filters"), + HomePageFilter(), + ) + + override fun searchMangaParse(response: Response): MangasPage { + val data = response.decryptAs() + + return MangasPage( + mangas = data.comics.map(Comic::toSManga), + hasNextPage = data.more == 1, + ) + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val comicUrl = manga.url.parseAs() + + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/comic/detail") + addQueryParameter("csid", comicUrl.webLinkId.ifBlank { comicUrl.csid }) + }.build() + + return apiRequest(url) + } + + override fun getMangaUrl(manga: SManga): String { + val comicUrl = manga.url.parseAs() + val siteLangDir = siteLang.split("-", limit = 2).first() + + return buildString { + append(baseUrl) + if (siteLangDir != "en") { + append("/") + append(siteLangDir) + } + append("/detail/") + append(comicUrl.webLinkId.ifBlank { comicUrl.csid }) + } + } + + override fun mangaDetailsParse(response: Response): SManga { + val comic = response.decryptAs().comic + + return comic.toSManga() + } + + override fun chapterListRequest(manga: SManga) = + mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List { + val episodes = response.decryptAs().episodes + val csid = response.request.url.queryParameter("csid")!! + + return episodes.map { episode -> + SChapter.create().apply { + url = EpisodeUrl(episode.esid, csid).toJsonString() + name = episode.title + chapter_number = episode.serialNo.toFloat() + } + }.asReversed() + } + + override fun pageListRequest(chapter: SChapter): Request { + val episodeUrl = chapter.url.parseAs() + + val url = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/comic/episode/detail") + addQueryParameter("esid", episodeUrl.esid) + }.build() + + return apiRequest(url) + } + + override fun getChapterUrl(chapter: SChapter): String { + val episodeUrl = chapter.url.parseAs() + val siteLangDir = siteLang.split("-", limit = 2).first() + + return buildString { + append(baseUrl) + if (siteLangDir != "en") { + append(("/")) + append(siteLangDir) + } + append("/reader/") + append(episodeUrl.csid) + append("?chapter=") + append(episodeUrl.esid) + } + } + + override fun pageListParse(response: Response): List { + val token = response.decryptAs().definitions[0].token + + val urlBuilder = apiUrl.toHttpUrl().newBuilder().apply { + addPathSegments("api/w/resource/group/rslv") + addQueryParameter("token", token) + } + var page = 1 + var hasNextPage = true + val resources = mutableListOf() + + while (hasNextPage) { + val url = urlBuilder + .setQueryParameter("page", page.toString()) + .build() + + val data = client.newCall(apiRequest(url)).execute() + .decryptAs() + + hasNextPage = data.more == 1 + resources.addAll(data.resources) + page++ + } + + return resources.map { + Page(it.rgIdx, imageUrl = decryptImageUrl(it.url, requestToken)) + }.sortedBy { it.index } + } + + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } + + private val requestToken = generateRandomString(24) + + private fun apiRequest(url: HttpUrl): Request { + val headers = headersBuilder().apply { + val platform = mobileUserAgentRegex.containsMatchIn(headers["User-Agent"]!!) + add("platform", if (platform) "h5" else "pc") + add("lth", siteLang) + add("did", requestToken) + }.build() + + return GET(url, headers) + } +} diff --git a/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/QToonFactory.kt b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/QToonFactory.kt new file mode 100644 index 000000000..5df2ecd10 --- /dev/null +++ b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/QToonFactory.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.extension.all.qtoon + +import eu.kanade.tachiyomi.source.SourceFactory + +class QToonFactory : SourceFactory { + override fun createSources() = listOf( + QToon("en", "en-US"), + QToon("es", "es-ES"), + QToon("pt-BR", "pt-PT"), + ) +} diff --git a/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/UrlActivity.kt b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/UrlActivity.kt new file mode 100644 index 000000000..503a3e3b9 --- /dev/null +++ b/src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/UrlActivity.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.extension.all.qtoon + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class UrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", intent.data.toString()) + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("QToon", "Unable to launch url activity", e) + } + + finish() + exitProcess(0) + } +}