diff --git a/src/zh/tencentcomics/AndroidManifest.xml b/src/zh/tencentcomics/AndroidManifest.xml new file mode 100644 index 000000000..5a719084a --- /dev/null +++ b/src/zh/tencentcomics/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/zh/tencentcomics/build.gradle b/src/zh/tencentcomics/build.gradle new file mode 100644 index 000000000..f2f969ee0 --- /dev/null +++ b/src/zh/tencentcomics/build.gradle @@ -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" diff --git a/src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..fe3830e4f Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..e0f65f368 Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7900cd4b4 Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..c6c8a33cf Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..78e56468b Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/tencentcomics/res/web_hi_res_512.png b/src/zh/tencentcomics/res/web_hi_res_512.png new file mode 100644 index 000000000..0f7e71234 Binary files /dev/null and b/src/zh/tencentcomics/res/web_hi_res_512.png differ diff --git a/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComics.kt b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComics.kt new file mode 100644 index 000000000..8b1b593f9 --- /dev/null +++ b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComics.kt @@ -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 { + val duktape = Duktape.create() + val pages = ArrayList() + var html = document.html() + + // Sometimes the nonce has commands that are unrunnable, just reload and hope + var nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("").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("").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 { + 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>) : + Filter.Select(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:" + } +} diff --git a/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComicsUrlActivity.kt b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComicsUrlActivity.kt new file mode 100644 index 000000000..9224fbbd8 --- /dev/null +++ b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComicsUrlActivity.kt @@ -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) + } +}