diff --git a/src/zh/copymanga/build.gradle b/src/zh/copymanga/build.gradle index e38e26daf..c18dcd63a 100644 --- a/src/zh/copymanga/build.gradle +++ b/src/zh/copymanga/build.gradle @@ -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' + } +} diff --git a/src/zh/copymanga/res/mipmap-hdpi/ic_launcher.png b/src/zh/copymanga/res/mipmap-hdpi/ic_launcher.png index 6758738de..dc1dbdf7f 100644 Binary files a/src/zh/copymanga/res/mipmap-hdpi/ic_launcher.png and b/src/zh/copymanga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/copymanga/res/mipmap-ldpi/ic_launcher.png b/src/zh/copymanga/res/mipmap-ldpi/ic_launcher.png deleted file mode 100644 index df13f6821..000000000 Binary files a/src/zh/copymanga/res/mipmap-ldpi/ic_launcher.png and /dev/null differ diff --git a/src/zh/copymanga/res/mipmap-mdpi/ic_launcher.png b/src/zh/copymanga/res/mipmap-mdpi/ic_launcher.png index d3d39fdb6..78b4178ff 100644 Binary files a/src/zh/copymanga/res/mipmap-mdpi/ic_launcher.png and b/src/zh/copymanga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/copymanga/res/mipmap-xhdpi/ic_launcher.png b/src/zh/copymanga/res/mipmap-xhdpi/ic_launcher.png index 0262da86a..2e3b6c60a 100644 Binary files a/src/zh/copymanga/res/mipmap-xhdpi/ic_launcher.png and b/src/zh/copymanga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/copymanga/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/copymanga/res/mipmap-xxhdpi/ic_launcher.png index 6fe1bb7e5..1632a0d98 100644 Binary files a/src/zh/copymanga/res/mipmap-xxhdpi/ic_launcher.png and b/src/zh/copymanga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/copymanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/copymanga/res/mipmap-xxxhdpi/ic_launcher.png index 4c9044add..c1ce7e534 100644 Binary files a/src/zh/copymanga/res/mipmap-xxxhdpi/ic_launcher.png and b/src/zh/copymanga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/copymanga/res/web_hi_res_512.png b/src/zh/copymanga/res/web_hi_res_512.png index cb6a47e53..2eaaf1433 100644 Binary files a/src/zh/copymanga/res/web_hi_res_512.png and b/src/zh/copymanga/res/web_hi_res_512.png differ diff --git a/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyManga.kt b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyManga.kt index 6d830f7ee..744daef57 100644 --- a/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyManga.kt +++ b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyManga.kt @@ -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().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 = 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().firstOrNull()?.addQuery(builder) } else { - requestUrlString = apiUrlString + builder.addPathSegments("api/v3/comics") + filters.filterIsInstance().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 = 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 = + client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess().map { mangaDetailsParse(it) } + + override fun mangaDetailsParse(response: Response): SManga = + response.parseAs().toSMangaDetails() + + override fun fetchChapterList(manga: SManga): Observable> = Single.create> { + val result = ArrayList() + val groups = manga.description?.parseChapterGroups() ?: run { + val response = client.newCall(realMangaDetailsRequest(manga)).execute() + response.parseAs().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.fetchChapterGroup(manga: String, key: String, name: String) { + val result = ArrayList(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 = 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 = + response.parseAs().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 { - 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() - // 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() - 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 Response.parseAs(): T = use { + if (code == 200) { + json.decodeFromStream>(body!!.byteStream()).results + } else { + throw Exception(json.decodeFromStream(body!!.byteStream()).message) } } - override fun pageListRequest(chapter: SChapter) = GET("$apiUrl/api/v3${chapter.url}", headers) - override fun pageListParse(response: Response): List { - val jsonObject = JSONObject(response.body!!.string()) - val pageArray = jsonObject.getJSONObject("results").getJSONObject("chapter").getJSONArray("contents") - val ret = ArrayList(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 = 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>, - defaultValue: Int = 0 - ) : - Filter.Select(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 { - val ret = ArrayList(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(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, 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>().list + val result = ArrayList(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 } } diff --git a/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyMangaDto.kt b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyMangaDto.kt new file mode 100644 index 000000000..27e592cd6 --- /dev/null +++ b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyMangaDto.kt @@ -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, + val cover: String, + val region: ValueDto? = null, + val status: ValueDto? = null, + val theme: List? = 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(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? { + 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 + +@Serializable +class ChapterPageListDto(val contents: List) + +@Serializable +class UrlDto(val url: String) + +@Serializable +class ChapterPageListWrapperDto(val chapter: ChapterPageListDto) + +@Serializable +class ListDto( + val total: Int, + val limit: Int, + val offset: Int, + val list: List, +) + +@Serializable +class ResultDto(val results: T) + +@Serializable +class ResultMessageDto(val code: Int, val message: String) diff --git a/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyMangaFilters.kt b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyMangaFilters.kt new file mode 100644 index 000000000..74ef8726c --- /dev/null +++ b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/CopyMangaFilters.kt @@ -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) : + Filter.Select(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) : 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"), +) diff --git a/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/NonblockingRateLimitInterceptor.kt b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/NonblockingRateLimitInterceptor.kt new file mode 100644 index 000000000..ccdf8dd92 --- /dev/null +++ b/src/zh/copymanga/src/eu/kanade/tachiyomi/extension/zh/copymanga/NonblockingRateLimitInterceptor.kt @@ -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(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()) + } +}