add QToon (#11131)
* 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:
parent
141b80aa19
commit
80a5052273
26
src/all/qtoon/AndroidManifest.xml
Normal file
26
src/all/qtoon/AndroidManifest.xml
Normal 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>
|
||||||
8
src/all/qtoon/build.gradle
Normal file
8
src/all/qtoon/build.gradle
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'QToon'
|
||||||
|
extClass = '.QToonFactory'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
BIN
src/all/qtoon/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/all/qtoon/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/all/qtoon/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/all/qtoon/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src/all/qtoon/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/all/qtoon/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src/all/qtoon/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/all/qtoon/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
src/all/qtoon/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/all/qtoon/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
125
src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Dto.kt
Normal file
125
src/all/qtoon/src/eu/kanade/tachiyomi/extension/all/qtoon/Dto.kt
Normal 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,
|
||||||
|
)
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user