Created the Tencent Comics extension (#8524)

This commit is contained in:
ShadesOfRay 2021-08-12 07:34:03 -04:00 committed by GitHub
parent 7ab57f5c51
commit 1ff375ebf6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 371 additions and 0 deletions

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.extension">
<application>
<activity
android:name=".zh.tencentcomics.TencentComicsUrlActivity"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="m.ac.qq.com"
android:pathPattern="/comic/index/id/..*"
android:scheme="https" />
<data
android:host="*ac.qq.com"
android:pathPattern="/Comic/comicInfo/id/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'Tencent Comics (ac.qq.com)'
pkgNameSuffix = 'zh.tencentcomics'
extClass = '.TencentComics'
extVersionCode = 1
libVersion = '1.2'
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View File

@ -0,0 +1,298 @@
package eu.kanade.tachiyomi.extension.zh.tencentcomics
import android.util.Base64
import com.squareup.duktape.Duktape
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import kotlin.collections.ArrayList
class TencentComics : ParsedHttpSource() {
override val name = "Tencent Comics (ac.qq.com)"
// its easier to parse the mobile version of the website
override val baseUrl = "https://m.ac.qq.com"
private val desktopUrl = "https://ac.qq.com"
override val lang = "zh"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
override fun chapterListSelector(): String = "ul.chapter-wrap-list.reverse > li > a"
override fun chapterFromElement(element: Element): SChapter {
return SChapter.create().apply {
url = element.attr("href").trim()
name = element.text().trim()
chapter_number = element.attr("data-seq").toFloat()
}
}
override fun popularMangaSelector(): String = "ul.ret-search-list.clearfix > li"
override fun popularMangaFromElement(element: Element): SManga {
return SManga.create().apply {
url = "/comic/index/" + element.select("div > a").attr("href").substringAfter("/Comic/comicInfo/")
title = element.select("div > a").attr("title").trim()
thumbnail_url = element.select("div > a > img").attr("data-original")
author = element.select("div > p.ret-works-author").text().trim()
description = element.select("div > p.ret-works-decs").text().trim()
}
}
override fun popularMangaNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.")
override fun popularMangaRequest(page: Int): Request = GET("$desktopUrl/Comic/all/search/hot/page/$page)", headers)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
// next page buttons do not exist
// even if the total searches happen to be 12 the website fills the next page anyway
return MangasPage(mangas, mangas.size == 12)
}
override fun latestUpdatesSelector(): String = "ul.ret-search-list.clearfix > li"
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun latestUpdatesNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.")
override fun latestUpdatesRequest(page: Int): Request = GET("$desktopUrl/Comic/all/search/time/page/$page)", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
return popularMangaParse(response)
}
// desktop version of the site has more info
override fun mangaDetailsRequest(manga: SManga): Request = GET("$desktopUrl/Comic/comicInfo/" + manga.url.substringAfter("/index/"), headers)
override fun mangaDetailsParse(document: Document): SManga {
return SManga.create().apply {
thumbnail_url = document.select("div.works-cover.ui-left > a > img").attr("src")
title = document.select("h2.works-intro-title.ui-left > strong").text().trim()
description = document.select("p.works-intro-short").text().trim()
author = document.select("p.works-intro-digi > span > em").text().trim()
status = when (document.select("label.works-intro-status").text().trim()) {
"连载中" -> SManga.ONGOING
"已完结" -> SManga.COMPLETED
"連載中" -> SManga.ONGOING
"已完結" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
// convert url to desktop since some chapters are blocked on mobile
override fun pageListRequest(chapter: SChapter): Request = GET("$desktopUrl/ComicView/" + chapter.url.substringAfter("/chapter/"), headers)
private val jsDecodeFunction = """
raw = raw.split('');
nonce = nonce.match(/\d+[a-zA-Z]+/g);
var len = nonce.length;
while (len--) {
var offset = parseInt(nonce[len]) & 255;
var noise = nonce[len].replace(/\d+/g, '');
raw.splice(offset, noise.length);
}
raw.join('');
"""
override fun pageListParse(document: Document): List<Page> {
val duktape = Duktape.create()
val pages = ArrayList<Page>()
var html = document.html()
// Sometimes the nonce has commands that are unrunnable, just reload and hope
var nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("</script>").trim()
while (nonce.contains("document") || nonce.contains("window")) {
html = client.newCall(GET(desktopUrl + document.select("li.now-reading > a").attr("href"), headers)).execute().body!!.string()
nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("</script>").trim()
}
val raw = html.substringAfterLast("var DATA =").substringBefore("PRELOAD_NUM").trim().replace(Regex("^\'|\',$"), "")
val decodePrefix = "var raw = \"$raw\"; var nonce = $nonce"
val full = duktape.evaluate(decodePrefix + jsDecodeFunction).toString()
val chapterData = JSONObject(String(Base64.decode(full, Base64.DEFAULT)))
if (!chapterData.getJSONObject("chapter").getBoolean("canRead")) throw Exception("[此章节为付费内容]")
val pictures = chapterData.getJSONArray("picture")
for (i in 0 until pictures.length()) {
pages.add(Page(i, "", pictures.getJSONObject(i).getString("url")))
}
return pages
}
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.")
override fun searchMangaSelector() = "ul > li.comic-item > a"
override fun searchMangaFromElement(element: Element): SManga {
return SManga.create().apply {
url = element.attr("href")
title = element.select("div > strong").text().trim()
thumbnail_url = element.select("div > img").attr("src")
description = element.select("div > small.comic-desc").text().trim()
genre = element.select("div > small.comic-tag").text().trim().replace(" ", ", ")
}
}
override fun searchMangaNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.")
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(ID_SEARCH_PREFIX)) {
val id = query.removePrefix(ID_SEARCH_PREFIX)
client.newCall(searchMangaByIdRequest(id))
.asObservableSuccess()
.map { response -> searchMangaByIdParse(response, id) }
} else {
super.fetchSearchManga(page, query, filters)
}
}
private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/comic/index/id/$id", headers)
private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
val sManga = mangaDetailsParse(response)
sManga.url = "/comic/index/id/$id"
return MangasPage(listOf(sManga), false)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
// impossible to search a manga use the filters
return if (query.isNotEmpty()) {
GET("$baseUrl/search/result?word=$query&page=$page", headers)
} else {
lateinit var genre: String
lateinit var status: String
lateinit var popularity: String
lateinit var vip: String
filters.forEach { filter ->
when (filter) {
is GenreFilter -> {
genre = filter.toUriPart()
if (genre.isNotEmpty()) genre = "theme/$genre/"
}
is StatusFilter -> {
status = filter.toUriPart()
}
is PopularityFilter -> {
popularity = filter.toUriPart()
}
is VipFilter -> {
vip = filter.toUriPart()
}
}
}
GET("$desktopUrl/Comic/all/$genre${status}search/$popularity${vip}page/$page")
}
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
// Normal search
return if (response.request.url.host.contains("m.ac.qq.com")) {
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
MangasPage(mangas, mangas.size == 10)
// Filter search
} else {
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
// next page buttons do not exist
// even if the total searches happen to be 12 the website fills the next page anyway
MangasPage(mangas, mangas.size == 12)
}
}
override fun getFilterList() = FilterList(
Filter.Header("注意:不影響按標題搜索"),
PopularityFilter(),
VipFilter(),
StatusFilter(),
GenreFilter()
)
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
private class PopularityFilter : UriPartFilter(
"热门人气/更新时间",
arrayOf(
Pair("热门人气", "hot/"),
Pair("更新时间", "time/")
)
)
private class VipFilter : UriPartFilter(
"属性",
arrayOf(
Pair("全部", ""),
Pair("付费", "vip/2/"),
Pair("免费", "vip/1/")
)
)
private class StatusFilter : UriPartFilter(
"进度",
arrayOf(
Pair("全部", ""),
Pair("连载中", "finish/1/"),
Pair("已完结", "finish/2/")
)
)
private class GenreFilter : UriPartFilter(
"标签",
arrayOf(
Pair("全部", ""),
Pair("恋爱", "105"),
Pair("玄幻", "101"),
Pair("异能", "103"),
Pair("恐怖", "110"),
Pair("剧情", "106"),
Pair("科幻", "108"),
Pair("悬疑", "112"),
Pair("奇幻", "102"),
Pair("冒险", "104"),
Pair("犯罪", "111"),
Pair("动作", "109"),
Pair("日常", "113"),
Pair("竞技", "114"),
Pair("武侠", "115"),
Pair("历史", "116"),
Pair("战争", "117")
)
)
companion object {
const val ID_SEARCH_PREFIX = "id:"
}
}

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.extension.zh.tencentcomics
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 TencentComicsUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 3) {
val id = pathSegments[3]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${TencentComics.ID_SEARCH_PREFIX}$id")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("TencentUrlActivity", e.toString())
}
} else {
Log.e("TencentUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}