CopyManga: rewrite (#12338)
* CopyManga: rewrite * update icons * ensure seamless update * remove duplicate class
|
@ -1,11 +1,12 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'CopyManga'
|
||||
pkgNameSuffix = 'zh.copymanga'
|
||||
extClass = '.CopyManga'
|
||||
extVersionCode = 28
|
||||
extVersionCode = 29
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -13,3 +14,13 @@ dependencies {
|
|||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
android {
|
||||
packagingOptions {
|
||||
exclude '/pinyin.txt'
|
||||
exclude '/polyphone.txt'
|
||||
exclude '/trad.txt'
|
||||
exclude '/traditional.txt'
|
||||
exclude '/unknown.txt'
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.9 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 132 KiB |
|
@ -2,10 +2,13 @@ package eu.kanade.tachiyomi.extension.zh.copymanga
|
|||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import com.luhuiguo.chinese.ChineseUtils
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
import android.util.Log
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.extension.zh.copymanga.MangaDto.Companion.parseChapterGroups
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
@ -14,434 +17,272 @@ 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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class CopyManga : ConfigurableSource, HttpSource() {
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class CopyManga : HttpSource(), ConfigurableSource {
|
||||
override val name = "拷贝漫画"
|
||||
override val baseUrl = "https://www.copymanga.org"
|
||||
override val lang = "zh"
|
||||
override val supportsLatest = true
|
||||
private val popularLatestPageSize = 50 // default
|
||||
private val searchPageSize = 12 // default
|
||||
private val apiUrl = "https://api.copymanga.org"
|
||||
|
||||
val replaceToMirror2 = Regex("mirror277\\.mangafuna\\.xyz\\:12001")
|
||||
val replaceToMirror = Regex("mirror77\\.mangafuna\\.xyz\\:12001")
|
||||
// val replaceToMirror2 = Regex("1767566263\\.rsc\\.cdn77\\.org")
|
||||
// val replaceToMirror = Regex("1025857477\\.rsc\\.cdn77\\.org")
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
private val preferences: SharedPreferences =
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val client: OkHttpClient = super.client.newBuilder()
|
||||
.rateLimit(1, 2) // 1 request per 2 seconds
|
||||
private var domain = DOMAINS[preferences.getString(DOMAIN_PREF, "0")!!.toInt().coerceIn(0, DOMAINS.size - 1)]
|
||||
override val baseUrl = WWW_PREFIX + domain
|
||||
private var apiUrl = API_PREFIX + domain // www. 也可以
|
||||
|
||||
override val client: OkHttpClient = network.client.newBuilder()
|
||||
.addInterceptor(NonblockingRateLimitInterceptor(2, 4)) // 2 requests per 4 seconds
|
||||
.build()
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics?ordering=-popular&offset=${(page - 1) * popularLatestPageSize}&limit=$popularLatestPageSize", headers)
|
||||
override fun popularMangaParse(response: Response): MangasPage = parseSearchMangaWithFilterOrPopularOrLatestResponse(response)
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics?ordering=-datetime_updated&offset=${(page - 1) * popularLatestPageSize}&limit=$popularLatestPageSize", headers)
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = parseSearchMangaWithFilterOrPopularOrLatestResponse(response)
|
||||
override fun headersBuilder() = headersBuilder(preferences.getBoolean(OVERSEAS_CDN_PREF, false))
|
||||
private fun headersBuilder(useOverseasCdn: Boolean) = Headers.Builder()
|
||||
.add("User-Agent", System.getProperty("http.agent")!!)
|
||||
.add("region", if (useOverseasCdn) "0" else "1")
|
||||
|
||||
private var apiHeaders = headersBuilder().build()
|
||||
|
||||
private var useWebp = preferences.getBoolean(WEBP_PREF, true)
|
||||
|
||||
init {
|
||||
MangaDto.convertToSc = preferences.getBoolean(SC_TITLE_PREF, false)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val offset = PAGE_SIZE * (page - 1)
|
||||
return GET("$apiUrl/api/v3/recs?pos=3200102&limit=$PAGE_SIZE&offset=$offset", apiHeaders)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val page: ListDto<MangaWrapperDto> = response.parseAs()
|
||||
val hasNextPage = page.offset + page.limit < page.total
|
||||
return MangasPage(page.list.map { it.toSManga() }, hasNextPage)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val offset = PAGE_SIZE * (page - 1)
|
||||
return GET("$apiUrl/api/v3/update/newest?limit=$PAGE_SIZE&offset=$offset", apiHeaders)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// when perform html search, sort by popular
|
||||
val apiUrlString = "$baseUrl/api/kb/web/searchs/comics?limit=$searchPageSize&offset=${(page - 1) * searchPageSize}&platform=2&q=$query&q_type="
|
||||
// val apiUrlString = "$baseUrl/api/v3/search/comic?limit=$searchPageSize&offset=${(page - 1) * searchPageSize}&platform=2&q=$query&q_type="
|
||||
val htmlUrlString = "$baseUrl/comics?offset=${(page - 1) * popularLatestPageSize}&limit=$popularLatestPageSize"
|
||||
val requestUrlString: String
|
||||
|
||||
val params = filters.map {
|
||||
if (it is MangaFilter) {
|
||||
it.toUriPart()
|
||||
} else ""
|
||||
}.filter { it != "" }.joinToString("&")
|
||||
// perform html search only when do have filter and not search anything
|
||||
if (params != "" && query == "") {
|
||||
requestUrlString = "$htmlUrlString&$params"
|
||||
val offset = PAGE_SIZE * (page - 1)
|
||||
val builder = apiUrl.toHttpUrl().newBuilder()
|
||||
.addQueryParameter("limit", "$PAGE_SIZE")
|
||||
.addQueryParameter("offset", "$offset")
|
||||
if (query.isNotBlank()) {
|
||||
builder.addPathSegments("api/v3/search/comic")
|
||||
.addQueryParameter("q", query)
|
||||
filters.filterIsInstance<SearchFilter>().firstOrNull()?.addQuery(builder)
|
||||
} else {
|
||||
requestUrlString = apiUrlString
|
||||
builder.addPathSegments("api/v3/comics")
|
||||
filters.filterIsInstance<CopyMangaFilter>().forEach {
|
||||
if (it !is SearchFilter) it.addQuery(builder)
|
||||
}
|
||||
}
|
||||
val url = requestUrlString.toHttpUrlOrNull()?.newBuilder()
|
||||
return GET(url.toString(), headers)
|
||||
return Request.Builder().url(builder.build()).headers(apiHeaders).build()
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (response.headers("content-type").filter { it.contains("json", true) }.any()) {
|
||||
// result from api request
|
||||
return parseSearchMangaResponseAsJson(response)
|
||||
val page: ListDto<MangaDto> = response.parseAs()
|
||||
val hasNextPage = page.offset + page.limit < page.total
|
||||
return MangasPage(page.list.map { it.toSManga() }, hasNextPage)
|
||||
}
|
||||
|
||||
// 让 WebView 打开网页而不是 API
|
||||
override fun mangaDetailsRequest(manga: SManga) = GET(WWW_PREFIX + domain + manga.url, apiHeaders)
|
||||
|
||||
private fun realMangaDetailsRequest(manga: SManga) =
|
||||
GET("$apiUrl/api/v3/comic2/${manga.url.removePrefix(MangaDto.URL_PREFIX)}", apiHeaders)
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||
client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess().map { mangaDetailsParse(it) }
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga =
|
||||
response.parseAs<MangaWrapperDto>().toSMangaDetails()
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Single.create<List<SChapter>> {
|
||||
val result = ArrayList<SChapter>()
|
||||
val groups = manga.description?.parseChapterGroups() ?: run {
|
||||
val response = client.newCall(realMangaDetailsRequest(manga)).execute()
|
||||
response.parseAs<MangaWrapperDto>().groups!!.values
|
||||
}
|
||||
val mangaSlug = manga.url.removePrefix(MangaDto.URL_PREFIX)
|
||||
result.fetchChapterGroup(mangaSlug, "default", "")
|
||||
for (group in groups) {
|
||||
result.fetchChapterGroup(mangaSlug, group.path_word, group.name)
|
||||
}
|
||||
it.onSuccess(result)
|
||||
}.toObservable()
|
||||
|
||||
private fun ArrayList<SChapter>.fetchChapterGroup(manga: String, key: String, name: String) {
|
||||
val result = ArrayList<SChapter>(0)
|
||||
var offset = 0
|
||||
var hasNextPage = true
|
||||
while (hasNextPage) {
|
||||
val response = client.newCall(GET("$apiUrl/api/v3/comic/$manga/group/$key/chapters?limit=$CHAPTER_PAGE_SIZE&offset=$offset", apiHeaders)).execute()
|
||||
val chapters: ListDto<ChapterDto> = response.parseAs()
|
||||
result.ensureCapacity(chapters.total)
|
||||
chapters.list.mapTo(result) { it.toSChapter(name) }
|
||||
offset += CHAPTER_PAGE_SIZE
|
||||
hasNextPage = offset < chapters.total
|
||||
}
|
||||
addAll(result.asReversed())
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = throw UnsupportedOperationException("Not used.")
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
// 新版 API 中间是 /chapter2/ 并且返回值需要排序
|
||||
override fun pageListRequest(chapter: SChapter) = GET("$apiUrl/api/v3${chapter.url}", apiHeaders)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
response.parseAs<ChapterPageListWrapperDto>().chapter.contents.mapIndexed { i, it ->
|
||||
Page(i, imageUrl = it.url)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
val imageUrl = page.imageUrl!!
|
||||
return if (useWebp && imageUrl.endsWith(".jpg")) {
|
||||
GET(imageUrl.removeSuffix(".jpg") + ".webp")
|
||||
} else {
|
||||
// result from html request
|
||||
return parseSearchMangaWithFilterOrPopularOrLatestResponse(response)
|
||||
GET(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) = GET(baseUrl + manga.url, headers)
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
var _title: String = document.select("div.comicParticulars-title-right > ul > li:eq(0) ").first().text()
|
||||
if (preferences.getBoolean(SHOW_Simplified_Chinese_TITLE_PREF, false)) {
|
||||
_title = ChineseUtils.toSimplified(_title)
|
||||
}
|
||||
val manga = SManga.create().apply {
|
||||
title = _title
|
||||
var picture = document.select("div.comicParticulars-title-left img").first().attr("data-src")
|
||||
if (preferences.getBoolean(CHANGE_CDN_OVERSEAS, false)) {
|
||||
picture = replaceToMirror2.replace(picture, "mirror2.mangafunc.fun:443")
|
||||
picture = replaceToMirror.replace(picture, "mirror.mangafunc.fun:443")
|
||||
}
|
||||
thumbnail_url = picture
|
||||
description = document.select("div.comicParticulars-synopsis p.intro").first().text().trim()
|
||||
}
|
||||
|
||||
val items = document.select("div.comicParticulars-title-right ul li")
|
||||
if (items.size >= 7) {
|
||||
manga.author = items[2].select("a").map { i -> i.text().trim() }.joinToString(", ")
|
||||
manga.status = when (items[5].select("span.comicParticulars-right-txt").first().text().trim()) {
|
||||
"已完結" -> SManga.COMPLETED
|
||||
"連載中" -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
manga.genre = items[6].select("a").map { i -> i.text().trim().trim('#') }.joinToString(", ")
|
||||
}
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val disposablePass = document.selectFirst("script:containsData(dio)").data()
|
||||
.substringAfter("'").substringBeforeLast("'")
|
||||
|
||||
// Get encrypted chapters data from another endpoint
|
||||
val chapterResponse =
|
||||
client.newCall(GET("${response.request.url}/chapters", headers)).execute()
|
||||
val disposableData = JSONObject(chapterResponse.body!!.string()).get("results").toString()
|
||||
|
||||
// Decrypt chapter JSON
|
||||
val chapterJsonString = decryptChapterData(disposableData, disposablePass)
|
||||
|
||||
val chapterJson = JSONObject(chapterJsonString)
|
||||
// Get the comic path word
|
||||
val comicPathWord = chapterJson.optJSONObject("build")?.optString("path_word")
|
||||
|
||||
// Get chapter groups
|
||||
val chapterGroups = chapterJson.optJSONObject("groups")
|
||||
if (chapterGroups == null) {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
val retChapter = ArrayList<SChapter>()
|
||||
// Get chapters according to groups
|
||||
val keys = chapterGroups.keys().asSequence().toList()
|
||||
keys.filter { it -> it == "default" }.forEach { groupName ->
|
||||
run {
|
||||
val chapterGroup = chapterGroups.getJSONObject(groupName)
|
||||
fillChapters(chapterGroup, retChapter, comicPathWord)
|
||||
}
|
||||
}
|
||||
|
||||
val otherChapters = ArrayList<SChapter>()
|
||||
keys.filter { it -> it != "default" }.forEach { groupName ->
|
||||
run {
|
||||
val chapterGroup = chapterGroups.getJSONObject(groupName)
|
||||
fillChapters(chapterGroup, otherChapters, comicPathWord)
|
||||
}
|
||||
}
|
||||
|
||||
// place others to top, as other group updates not so often
|
||||
retChapter.addAll(0, otherChapters)
|
||||
return retChapter.asReversed().apply {
|
||||
if (!isNewDateLogic) return@apply
|
||||
val latestDate = document.selectFirst(".comicParticulars-sigezi + .comicParticulars-right-txt").text()
|
||||
.let { DATE_FORMAT.parse(it)?.time ?: 0L }
|
||||
this.firstOrNull()?.date_upload = latestDate
|
||||
private inline fun <reified T> Response.parseAs(): T = use {
|
||||
if (code == 200) {
|
||||
json.decodeFromStream<ResultDto<T>>(body!!.byteStream()).results
|
||||
} else {
|
||||
throw Exception(json.decodeFromStream<ResultMessageDto>(body!!.byteStream()).message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) = GET("$apiUrl/api/v3${chapter.url}", headers)
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val jsonObject = JSONObject(response.body!!.string())
|
||||
val pageArray = jsonObject.getJSONObject("results").getJSONObject("chapter").getJSONArray("contents")
|
||||
val ret = ArrayList<Page>(pageArray.length())
|
||||
for (i in 0 until pageArray.length()) {
|
||||
val page = pageArray.getJSONObject(i).getString("url")
|
||||
ret.add(Page(i, "", page))
|
||||
private var genres: Array<Param> = emptyArray()
|
||||
private var isFetchingGenres = false
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val genreFilter = if (genres.isEmpty()) {
|
||||
fetchGenres()
|
||||
Filter.Header("点击“重置”尝试刷新题材分类")
|
||||
} else {
|
||||
GenreFilter(genres)
|
||||
}
|
||||
|
||||
return ret
|
||||
return FilterList(
|
||||
SearchFilter(),
|
||||
Filter.Separator(),
|
||||
Filter.Header("分类(搜索文本时无效)"),
|
||||
genreFilter,
|
||||
RegionFilter(),
|
||||
StatusFilter(),
|
||||
SortFilter(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun headersBuilder() = Headers.Builder()
|
||||
.add("User-Agent", String.format(USER_AGENT, preferences.getString(CHROME_VERSION_PREF, CHROME_VERSION_DEFAULT)))
|
||||
.add("region", if (preferences.getBoolean(CHANGE_CDN_OVERSEAS, false)) "0" else "1")
|
||||
|
||||
// Unused, we can get image urls directly from the chapter page
|
||||
override fun imageUrlParse(response: Response) =
|
||||
throw UnsupportedOperationException("This method should not be called!")
|
||||
|
||||
// Copymanga has different logic in polular and search page, mix two logic in search progress for now
|
||||
override fun getFilterList() = FilterList(
|
||||
MangaFilter(
|
||||
"题材",
|
||||
"theme",
|
||||
arrayOf(
|
||||
Pair("全部", ""),
|
||||
Pair("愛情", "aiqing"),
|
||||
Pair("歡樂向", "huanlexiang"),
|
||||
Pair("冒险", "maoxian"),
|
||||
Pair("百合", "baihe"),
|
||||
Pair("東方", "dongfang"),
|
||||
Pair("奇幻", "qihuan"),
|
||||
Pair("校园", "xiaoyuan"),
|
||||
Pair("科幻", "kehuan"),
|
||||
Pair("生活", "shenghuo"),
|
||||
Pair("轻小说", "qingxiaoshuo"),
|
||||
Pair("格鬥", "gedou"),
|
||||
Pair("神鬼", "shengui"),
|
||||
Pair("悬疑", "xuanyi"),
|
||||
Pair("耽美", "danmei"),
|
||||
Pair("其他", "qita"),
|
||||
Pair("舰娘", "jianniang"),
|
||||
Pair("职场", "zhichang"),
|
||||
Pair("治愈", "zhiyu"),
|
||||
Pair("萌系", "mengxi"),
|
||||
Pair("四格", "sige"),
|
||||
Pair("伪娘", "weiniang"),
|
||||
Pair("竞技", "jingji"),
|
||||
Pair("搞笑", "gaoxiao"),
|
||||
Pair("長條", "changtiao"),
|
||||
Pair("性转换", "xingzhuanhuan"),
|
||||
Pair("侦探", "zhentan"),
|
||||
Pair("节操", "jiecao"),
|
||||
Pair("热血", "rexue"),
|
||||
Pair("美食", "meishi"),
|
||||
Pair("後宮", "hougong"),
|
||||
Pair("励志", "lizhi"),
|
||||
Pair("音乐舞蹈", "yinyuewudao"),
|
||||
Pair("彩色", "COLOR"),
|
||||
Pair("AA", "aa"),
|
||||
Pair("异世界", "yishijie"),
|
||||
Pair("历史", "lishi"),
|
||||
Pair("战争", "zhanzheng"),
|
||||
Pair("机战", "jizhan"),
|
||||
Pair("C97", "comiket97"),
|
||||
Pair("C96", "comiket96"),
|
||||
Pair("宅系", "zhaixi"),
|
||||
Pair("C98", "C98"),
|
||||
Pair("C95", "comiket95"),
|
||||
Pair("恐怖", "%E6%81%90%E6%80 %96"),
|
||||
Pair("FATE", "fate"),
|
||||
Pair("無修正", "Uncensored"),
|
||||
Pair("穿越", "chuanyue"),
|
||||
Pair("武侠", "wuxia"),
|
||||
Pair("生存", "shengcun"),
|
||||
Pair("惊悚", "jingsong"),
|
||||
Pair("都市", "dushi"),
|
||||
Pair("LoveLive", "loveLive"),
|
||||
Pair("转生", "zhuansheng"),
|
||||
Pair("重生", "chongsheng"),
|
||||
Pair("仙侠", "xianxia")
|
||||
)
|
||||
),
|
||||
MangaFilter(
|
||||
"排序",
|
||||
"ordering",
|
||||
arrayOf(
|
||||
Pair("最热门", "-popular"),
|
||||
Pair("最冷门", "popular"),
|
||||
Pair("最新", "-datetime_updated"),
|
||||
Pair("最早", "datetime_updated"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
private class MangaFilter(
|
||||
displayName: String,
|
||||
searchName: String,
|
||||
val vals: Array<Pair<String, String>>,
|
||||
defaultValue: Int = 0
|
||||
) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), defaultValue) {
|
||||
val searchName = searchName
|
||||
fun toUriPart(): String {
|
||||
val selectVal = vals[state].second
|
||||
return if (selectVal != "") "$searchName=$selectVal" else ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchMangaWithFilterOrPopularOrLatestResponse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select("div.exemptComicList div.exemptComic-box").first().attr("list")
|
||||
|
||||
val comicArray = JSONArray(mangas)
|
||||
|
||||
// There is always a next pager, so use itemCount to check. XD
|
||||
val hasNextPage = comicArray.length() == popularLatestPageSize
|
||||
val ret = mangaListFromJsonArray(comicArray)
|
||||
|
||||
return MangasPage(ret, hasNextPage)
|
||||
}
|
||||
|
||||
private fun parseSearchMangaResponseAsJson(response: Response): MangasPage {
|
||||
val body = response.body!!.string()
|
||||
// results > comic > list []
|
||||
val res = JSONObject(body)
|
||||
val comicArray = res.optJSONObject("results")?.optJSONArray("list")
|
||||
if (comicArray == null) {
|
||||
return MangasPage(listOf(), false)
|
||||
}
|
||||
|
||||
val ret = mangaListFromJsonArray(comicArray)
|
||||
|
||||
return MangasPage(ret, comicArray.length() == searchPageSize)
|
||||
}
|
||||
|
||||
private fun mangaListFromJsonArray(comicArray: JSONArray): ArrayList<SManga> {
|
||||
val ret = ArrayList<SManga>(comicArray.length())
|
||||
|
||||
for (i in 0 until comicArray.length()) {
|
||||
val obj = comicArray.getJSONObject(i)
|
||||
val authorArray = obj.getJSONArray("author")
|
||||
var _title: String = obj.getString("name")
|
||||
if (preferences.getBoolean(SHOW_Simplified_Chinese_TITLE_PREF, false)) {
|
||||
_title = ChineseUtils.toSimplified(_title)
|
||||
}
|
||||
ret.add(
|
||||
SManga.create().apply {
|
||||
title = _title
|
||||
var picture = obj.getString("cover")
|
||||
if (preferences.getBoolean(CHANGE_CDN_OVERSEAS, false)) {
|
||||
picture = replaceToMirror2.replace(picture, "mirror2.mangafunc.fun:443")
|
||||
picture = replaceToMirror.replace(picture, "mirror.mangafunc.fun:443")
|
||||
}
|
||||
thumbnail_url = picture
|
||||
author = Array<String?>(authorArray.length()) { i -> authorArray.getJSONObject(i).getString("name") }.joinToString(", ")
|
||||
status = SManga.UNKNOWN
|
||||
url = "/comic/${obj.getString("path_word")}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
private fun fillChapters(chapterGroup: JSONObject, retChapter: ArrayList<SChapter>, comicPathWord: String?) {
|
||||
// group's last update time
|
||||
val groupLastUpdateTime =
|
||||
chapterGroup.optJSONObject("last_chapter")?.optString("datetime_created")
|
||||
|
||||
// chapters in the group to
|
||||
val chapterArray = chapterGroup.optJSONArray("chapters")
|
||||
if (chapterArray != null) {
|
||||
for (i in 0 until chapterArray.length()) {
|
||||
val chapter = chapterArray.getJSONObject(i)
|
||||
retChapter.add(
|
||||
SChapter.create().apply {
|
||||
name = chapter.getString("name")
|
||||
url = "/comic/$comicPathWord/chapter/${chapter.getString("id")}"
|
||||
date_upload = stringToUnixTimestamp(groupLastUpdateTime)
|
||||
}
|
||||
)
|
||||
private fun fetchGenres() {
|
||||
if (genres.isNotEmpty() || isFetchingGenres) return
|
||||
isFetchingGenres = true
|
||||
thread {
|
||||
try {
|
||||
val response = client.newCall(GET("$apiUrl/api/v3/theme/comic/count?limit=500", apiHeaders)).execute()
|
||||
val list = response.parseAs<ListDto<KeywordDto>>().list
|
||||
val result = ArrayList<Param>(list.size + 1).apply { add(Param("全部", "")) }
|
||||
genres = list.mapTo(result) { it.toParam() }.toTypedArray()
|
||||
} catch (e: Exception) {
|
||||
Log.e("CopyManga", "failed to fetch genres", e)
|
||||
} finally {
|
||||
isFetchingGenres = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(string: String): ByteArray {
|
||||
val bytes = ByteArray(string.length / 2)
|
||||
for (i in 0 until string.length / 2) {
|
||||
bytes[i] = string.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun stringToUnixTimestamp(string: String?, pattern: String = "yyyy-MM-dd", locale: Locale = Locale.CHINA): Long {
|
||||
if (string == null) System.currentTimeMillis()
|
||||
|
||||
return try {
|
||||
val time = SimpleDateFormat(pattern, locale).parse(string)?.time
|
||||
if (time != null) time else System.currentTimeMillis()
|
||||
} catch (ex: Exception) {
|
||||
// Set the time to current in order to display the updated manga in the "Recent updates" section
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
// thanks to unpacker toolsite, http://matthewfl.com/unPacker.html
|
||||
private fun decryptChapterData(disposableData: String, disposablePass: String?): String {
|
||||
val prePart = disposableData.substring(0, 16)
|
||||
val postPart = disposableData.substring(16, disposableData.length)
|
||||
val disposablePassByteArray = (disposablePass ?: "hotmanga.aes.key").toByteArray(Charsets.UTF_8)
|
||||
val prepartByteArray = prePart.toByteArray(Charsets.UTF_8)
|
||||
val dataByteArray = hexStringToByteArray(postPart)
|
||||
|
||||
val secretKey = SecretKeySpec(disposablePassByteArray, "AES")
|
||||
val iv = IvParameterSpec(prepartByteArray)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
|
||||
val result = String(cipher.doFinal(dataByteArray), Charsets.UTF_8)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Change Title to Simplified Chinese For Library Gobal Search Optionally
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
val zhPreference = androidx.preference.SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SHOW_Simplified_Chinese_TITLE_PREF
|
||||
title = "将标题转换为简体中文"
|
||||
summary = "需要重启软件以生效。已添加漫画需要迁移改变标题。"
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = DOMAIN_PREF
|
||||
title = "网址域名"
|
||||
summary = "连接不稳定时可以尝试切换"
|
||||
entries = DOMAINS
|
||||
entryValues = DOMAIN_INDICES
|
||||
setDefaultValue("0")
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(SHOW_Simplified_Chinese_TITLE_PREF, newValue as Boolean).commit()
|
||||
}
|
||||
}
|
||||
val cdnPreference = androidx.preference.SwitchPreferenceCompat(screen.context).apply {
|
||||
key = CHANGE_CDN_OVERSEAS
|
||||
title = "转换图片CDN为境外CDN"
|
||||
summary = "需要重启软件(及清除章节缓存)以生效。加载图片使用境外CDN,使用代理的情况下推荐打开此选项(境外CDN可能无法查看一些刚刚更新的漫画,需要等待资源更新到CDN)"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(CHANGE_CDN_OVERSEAS, newValue as Boolean).commit()
|
||||
}
|
||||
}
|
||||
val chromeVersionPreference = androidx.preference.EditTextPreference(screen.context).apply {
|
||||
key = CHROME_VERSION_PREF
|
||||
title = "User Agent 中的 Chrome 版本号"
|
||||
summary = "访问出现异常时,可以尝试输入最新的 Chrome 版本号。重启生效。"
|
||||
setDefaultValue(CHROME_VERSION_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putString(CHROME_VERSION_PREF, newValue as String).apply()
|
||||
val index = newValue as String
|
||||
preferences.edit().putString(DOMAIN_PREF, index).apply()
|
||||
domain = DOMAINS[index.toInt()]
|
||||
apiUrl = API_PREFIX + domain
|
||||
true
|
||||
}
|
||||
}
|
||||
screen.addPreference(zhPreference)
|
||||
screen.addPreference(cdnPreference)
|
||||
screen.addPreference(chromeVersionPreference)
|
||||
}.let { screen.addPreference(it) }
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = OVERSEAS_CDN_PREF
|
||||
title = "使用“港台及海外线路”"
|
||||
summary = "连接不稳定时可以尝试切换,关闭时使用“大陆用户线路”,已阅读章节需要清空缓存才能生效"
|
||||
setDefaultValue(false)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val useOverseasCdn = newValue as Boolean
|
||||
preferences.edit().putBoolean(OVERSEAS_CDN_PREF, useOverseasCdn).apply()
|
||||
apiHeaders = headersBuilder(useOverseasCdn).build()
|
||||
true
|
||||
}
|
||||
}.let { screen.addPreference(it) }
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = WEBP_PREF
|
||||
title = "使用 WebP 图片格式"
|
||||
summary = "默认开启,可以节省网站流量"
|
||||
setDefaultValue(true)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val webp = newValue as Boolean
|
||||
preferences.edit().putBoolean(WEBP_PREF, webp).apply()
|
||||
useWebp = webp
|
||||
true
|
||||
}
|
||||
}.let { screen.addPreference(it) }
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SC_TITLE_PREF
|
||||
title = "将作品标题转换为简体中文"
|
||||
summary = "修改后,已添加漫画需要迁移才能更新标题"
|
||||
setDefaultValue(false)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val convertToSc = newValue as Boolean
|
||||
preferences.edit().putBoolean(SC_TITLE_PREF, convertToSc).apply()
|
||||
MangaDto.convertToSc = convertToSc
|
||||
true
|
||||
}
|
||||
}.let { screen.addPreference(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHOW_Simplified_Chinese_TITLE_PREF = "showSCTitle"
|
||||
private const val CHANGE_CDN_OVERSEAS = "changeCDN"
|
||||
private const val CHROME_VERSION_PREF = "chromeVersion"
|
||||
private const val CHROME_VERSION_DEFAULT = "103"
|
||||
private const val DOMAIN_PREF = "domain"
|
||||
private const val OVERSEAS_CDN_PREF = "changeCDN"
|
||||
private const val SC_TITLE_PREF = "showSCTitle"
|
||||
private const val WEBP_PREF = "webp"
|
||||
// private const val CHROME_VERSION_PREF = "chromeVersion" // default value was "103"
|
||||
|
||||
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36"
|
||||
private const val WWW_PREFIX = "https://www."
|
||||
private const val API_PREFIX = "https://api."
|
||||
private val DOMAINS = arrayOf("copymanga.org", "copymanga.info", "copymanga.net")
|
||||
private val DOMAIN_INDICES = arrayOf("0", "1", "2")
|
||||
|
||||
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
|
||||
private val isNewDateLogic = AppInfo.getVersionCode() >= 81
|
||||
private const val PAGE_SIZE = 20
|
||||
private const val CHAPTER_PAGE_SIZE = 500
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
package eu.kanade.tachiyomi.extension.zh.copymanga
|
||||
|
||||
import com.luhuiguo.chinese.ChineseUtils
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
class MangaDto(
|
||||
val name: String,
|
||||
val path_word: String,
|
||||
val author: List<KeywordDto>,
|
||||
val cover: String,
|
||||
val region: ValueDto? = null,
|
||||
val status: ValueDto? = null,
|
||||
val theme: List<KeywordDto>? = null,
|
||||
val brief: String? = null,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = URL_PREFIX + path_word
|
||||
title = if (convertToSc) ChineseUtils.toSimplified(name) else name
|
||||
author = this@MangaDto.author.joinToString { it.name }
|
||||
thumbnail_url = cover.removeSuffix(".328x422.jpg")
|
||||
}
|
||||
|
||||
fun toSMangaDetails(groups: ChapterGroups) = toSManga().apply {
|
||||
description = brief + groups.toDescription()
|
||||
genre = buildList(theme!!.size + 1) {
|
||||
add(region!!.display)
|
||||
theme.mapTo(this) { it.name }
|
||||
}.joinToString { ChineseUtils.toSimplified(it) }
|
||||
status = when (this@MangaDto.status!!.value) {
|
||||
0 -> SManga.ONGOING
|
||||
1 -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal var convertToSc = false
|
||||
|
||||
const val URL_PREFIX = "/comic/"
|
||||
|
||||
private const val CHAPTER_GROUP_DELIMITER = ","
|
||||
private const val CHAPTER_GROUP_PREFIX = "\n\n【其他版本:"
|
||||
private const val CHAPTER_GROUP_POSTFIX = "】"
|
||||
private const val NO_CHAPTER_GROUP = "无"
|
||||
|
||||
private fun ChapterGroups.toDescription(): String {
|
||||
if (size <= 1) return CHAPTER_GROUP_PREFIX + NO_CHAPTER_GROUP + CHAPTER_GROUP_POSTFIX
|
||||
val groups = ArrayList<KeywordDto>(size - 1)
|
||||
for ((key, group) in this) {
|
||||
if (key != "default") groups.add(group)
|
||||
}
|
||||
return groups.joinToString(CHAPTER_GROUP_DELIMITER, CHAPTER_GROUP_PREFIX, CHAPTER_GROUP_POSTFIX) {
|
||||
it.name + '#' + it.path_word
|
||||
}
|
||||
}
|
||||
|
||||
fun String.parseChapterGroups(): List<KeywordDto>? {
|
||||
val index = lastIndexOf(CHAPTER_GROUP_PREFIX)
|
||||
if (index < 0) return null
|
||||
val groups = substring(index + CHAPTER_GROUP_PREFIX.length, length - CHAPTER_GROUP_POSTFIX.length)
|
||||
if (groups == NO_CHAPTER_GROUP) return emptyList()
|
||||
return groups.split(CHAPTER_GROUP_DELIMITER).map {
|
||||
val delimiterIndex = it.indexOf('#')
|
||||
KeywordDto(it.substring(0, delimiterIndex), it.substring(delimiterIndex + 1, it.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterDto(
|
||||
val uuid: String,
|
||||
val name: String,
|
||||
val comic_path_word: String,
|
||||
val datetime_created: String,
|
||||
) {
|
||||
fun toSChapter(group: String) = SChapter.create().apply {
|
||||
url = "/comic/$comic_path_word/chapter/$uuid"
|
||||
name = if (group.isEmpty()) this@ChapterDto.name else group + ':' + this@ChapterDto.name
|
||||
date_upload = dateFormat.parse(datetime_created)?.time ?: 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class KeywordDto(val name: String, val path_word: String) {
|
||||
fun toParam() = Param(ChineseUtils.toSimplified(name), path_word)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ValueDto(val value: Int, val display: String)
|
||||
|
||||
@Serializable
|
||||
class MangaWrapperDto(val comic: MangaDto, val groups: ChapterGroups? = null) {
|
||||
fun toSManga() = comic.toSManga()
|
||||
fun toSMangaDetails() = comic.toSMangaDetails(groups!!)
|
||||
}
|
||||
|
||||
typealias ChapterGroups = LinkedHashMap<String, KeywordDto>
|
||||
|
||||
@Serializable
|
||||
class ChapterPageListDto(val contents: List<UrlDto>)
|
||||
|
||||
@Serializable
|
||||
class UrlDto(val url: String)
|
||||
|
||||
@Serializable
|
||||
class ChapterPageListWrapperDto(val chapter: ChapterPageListDto)
|
||||
|
||||
@Serializable
|
||||
class ListDto<T>(
|
||||
val total: Int,
|
||||
val limit: Int,
|
||||
val offset: Int,
|
||||
val list: List<T>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ResultDto<T>(val results: T)
|
||||
|
||||
@Serializable
|
||||
class ResultMessageDto(val code: Int, val message: String)
|
|
@ -0,0 +1,53 @@
|
|||
package eu.kanade.tachiyomi.extension.zh.copymanga
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class Param(val name: String, val value: String)
|
||||
|
||||
open class CopyMangaFilter(name: String, private val key: String, private val params: Array<Param>) :
|
||||
Filter.Select<String>(name, params.map { it.name }.toTypedArray()) {
|
||||
fun addQuery(builder: HttpUrl.Builder) {
|
||||
val param = params[state].value
|
||||
if (param.isNotEmpty())
|
||||
builder.addQueryParameter(key, param)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchFilter : CopyMangaFilter("文本搜索范围", "q_type", SEARCH_FILTER_VALUES)
|
||||
|
||||
private val SEARCH_FILTER_VALUES = arrayOf(
|
||||
Param("全部", ""),
|
||||
Param("名称", "name"),
|
||||
Param("作者", "author"),
|
||||
Param("汉化组", "local"),
|
||||
)
|
||||
|
||||
class GenreFilter(genres: Array<Param>) : CopyMangaFilter("题材", "theme", genres)
|
||||
|
||||
class RegionFilter : CopyMangaFilter("地区", "region", REGION_VALUES)
|
||||
|
||||
private val REGION_VALUES = arrayOf(
|
||||
Param("全部", ""),
|
||||
Param("日本", "0"),
|
||||
Param("韩国", "1"),
|
||||
Param("欧美", "2"),
|
||||
)
|
||||
|
||||
class StatusFilter : CopyMangaFilter("状态", "status", STATUS_VALUES)
|
||||
|
||||
private val STATUS_VALUES = arrayOf(
|
||||
Param("全部", ""),
|
||||
Param("连载中", "0"),
|
||||
Param("已完结", "1"),
|
||||
Param("短篇", "2"),
|
||||
)
|
||||
|
||||
class SortFilter : CopyMangaFilter("排序", "ordering", SORT_VALUES)
|
||||
|
||||
private val SORT_VALUES = arrayOf(
|
||||
Param("热门", "-popular"),
|
||||
Param("热门(逆序)", "popular"),
|
||||
Param("更新时间", "-datetime_updated"),
|
||||
Param("更新时间(逆序)", "datetime_updated"),
|
||||
)
|
|
@ -0,0 +1,58 @@
|
|||
package eu.kanade.tachiyomi.extension.zh.copymanga
|
||||
|
||||
import android.os.SystemClock
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// See https://github.com/tachiyomiorg/tachiyomi/pull/7389
|
||||
internal class NonblockingRateLimitInterceptor(
|
||||
private val permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) : Interceptor {
|
||||
|
||||
private val requestQueue = ArrayList<Long>(permits)
|
||||
private val rateLimitMillis = unit.toMillis(period)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
// Ignore canceled calls, otherwise they would jam the queue
|
||||
if (chain.call().isCanceled()) {
|
||||
throw IOException()
|
||||
}
|
||||
|
||||
synchronized(requestQueue) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val waitTime = if (requestQueue.size < permits) {
|
||||
0
|
||||
} else {
|
||||
val oldestReq = requestQueue[0]
|
||||
val newestReq = requestQueue[permits - 1]
|
||||
|
||||
if (newestReq - oldestReq > rateLimitMillis) {
|
||||
0
|
||||
} else {
|
||||
oldestReq + rateLimitMillis - now // Remaining time
|
||||
}
|
||||
}
|
||||
|
||||
// Final check
|
||||
if (chain.call().isCanceled()) {
|
||||
throw IOException()
|
||||
}
|
||||
|
||||
if (requestQueue.size == permits) {
|
||||
requestQueue.removeAt(0)
|
||||
}
|
||||
if (waitTime > 0) {
|
||||
requestQueue.add(now + waitTime)
|
||||
Thread.sleep(waitTime) // Sleep inside synchronized to pause queued requests
|
||||
} else {
|
||||
requestQueue.add(now)
|
||||
}
|
||||
}
|
||||
|
||||
return chain.proceed(chain.request())
|
||||
}
|
||||
}
|