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