* basic encrypted request done

* implement everything except search/filters

* search & filters

* remove logs

* Add home page sections

- Add home page sections as a filter.
- This filter does not work with other filters.

* Fix filters

- Remove duplicate filters
- Remove commented out code

* QToon: Fix opening in webview

- Remove logging
- Fix manga and chapter URLs for non-English languages

* QToon: Add URL intent filter

- Add URL intent filter for manga details and reader pages.
- Handle URL intents to open manga directly in the app.

* set details when browsing

* update icon and www. domain in manifest

* append
This commit is contained in:
AwkwardPeak7 2025-10-22 10:27:22 +05:00 committed by Draff
parent 141b80aa19
commit 80a5052273
Signed by: Draff
GPG Key ID: E8A89F3211677653
13 changed files with 657 additions and 0 deletions

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.qtoon.UrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="qtoon.com" />
<data android:host="www.qtoon.com" />
<data android:scheme="https" />
<data android:pathPattern="/detail/..*" />
<data android:pathPattern="/.*/detail/..*" />
<data android:pathPattern="/reader/..*" />
<data android:pathPattern="/.*/reader/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'QToon'
extClass = '.QToonFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -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<Comic>,
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<Tag>,
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<Tag>,
)
@Serializable
class ChapterEpisodes(
val episodes: List<Episode>,
)
@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<EpisodeDefinition>,
)
@Serializable
class EpisodeDefinition(
val token: String,
)
@Serializable
class EpisodeResources(
val resources: List<Resource>,
val more: Int,
)
@Serializable
class Resource(
val url: String,
val rgIdx: Int,
)

View File

@ -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<EncryptedResponse>()
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 <reified T> 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,
)

View File

@ -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<Pair<String, String>>,
) : Filter.Select<String>(
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",
),
)

View File

@ -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<MangasPage> {
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<HomePageFilter>().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<TagFilter>().selected)
addQueryParameter("gender", filters.firstInstance<GenderFilter>().selected)
addQueryParameter("serialStatus", filters.firstInstance<StatusFilter>().selected)
addQueryParameter("sortType", filters.firstInstance<SortFilter>().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<ComicsList>()
return MangasPage(
mangas = data.comics.map(Comic::toSManga),
hasNextPage = data.more == 1,
)
}
override fun mangaDetailsRequest(manga: SManga): Request {
val comicUrl = manga.url.parseAs<ComicUrl>()
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<ComicUrl>()
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<ComicDetailsResponse>().comic
return comic.toSManga()
}
override fun chapterListRequest(manga: SManga) =
mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val episodes = response.decryptAs<ChapterEpisodes>().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<EpisodeUrl>()
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<EpisodeUrl>()
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<Page> {
val token = response.decryptAs<EpisodeResponse>().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<Resource>()
while (hasNextPage) {
val url = urlBuilder
.setQueryParameter("page", page.toString())
.build()
val data = client.newCall(apiRequest(url)).execute()
.decryptAs<EpisodeResources>()
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)
}
}

View File

@ -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"),
)
}

View File

@ -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)
}
}