CopyManga: rewrite (#12338)
* CopyManga: rewrite * update icons * ensure seamless update * remove duplicate class
|
@ -1,11 +1,12 @@
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
extName = 'CopyManga'
|
extName = 'CopyManga'
|
||||||
pkgNameSuffix = 'zh.copymanga'
|
pkgNameSuffix = 'zh.copymanga'
|
||||||
extClass = '.CopyManga'
|
extClass = '.CopyManga'
|
||||||
extVersionCode = 28
|
extVersionCode = 29
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
@ -13,3 +14,13 @@ dependencies {
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
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.app.Application
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import com.luhuiguo.chinese.ChineseUtils
|
import android.util.Log
|
||||||
import eu.kanade.tachiyomi.AppInfo
|
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.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.ConfigurableSource
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
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.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
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.Headers
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.json.JSONArray
|
import rx.Observable
|
||||||
import org.json.JSONObject
|
import rx.Single
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.text.SimpleDateFormat
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.util.Locale
|
import kotlin.concurrent.thread
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
class CopyManga : ConfigurableSource, HttpSource() {
|
|
||||||
|
|
||||||
|
class CopyManga : HttpSource(), ConfigurableSource {
|
||||||
override val name = "拷贝漫画"
|
override val name = "拷贝漫画"
|
||||||
override val baseUrl = "https://www.copymanga.org"
|
|
||||||
override val lang = "zh"
|
override val lang = "zh"
|
||||||
override val supportsLatest = true
|
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")
|
private val json: Json by injectLazy()
|
||||||
val replaceToMirror = Regex("mirror77\\.mangafuna\\.xyz\\:12001")
|
|
||||||
// val replaceToMirror2 = Regex("1767566263\\.rsc\\.cdn77\\.org")
|
|
||||||
// val replaceToMirror = Regex("1025857477\\.rsc\\.cdn77\\.org")
|
|
||||||
|
|
||||||
private val preferences: SharedPreferences by lazy {
|
private val preferences: SharedPreferences =
|
||||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
}
|
|
||||||
|
|
||||||
override val client: OkHttpClient = super.client.newBuilder()
|
private var domain = DOMAINS[preferences.getString(DOMAIN_PREF, "0")!!.toInt().coerceIn(0, DOMAINS.size - 1)]
|
||||||
.rateLimit(1, 2) // 1 request per 2 seconds
|
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()
|
.build()
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics?ordering=-popular&offset=${(page - 1) * popularLatestPageSize}&limit=$popularLatestPageSize", headers)
|
override fun headersBuilder() = headersBuilder(preferences.getBoolean(OVERSEAS_CDN_PREF, false))
|
||||||
override fun popularMangaParse(response: Response): MangasPage = parseSearchMangaWithFilterOrPopularOrLatestResponse(response)
|
private fun headersBuilder(useOverseasCdn: Boolean) = Headers.Builder()
|
||||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics?ordering=-datetime_updated&offset=${(page - 1) * popularLatestPageSize}&limit=$popularLatestPageSize", headers)
|
.add("User-Agent", System.getProperty("http.agent")!!)
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage = parseSearchMangaWithFilterOrPopularOrLatestResponse(response)
|
.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 {
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
// when perform html search, sort by popular
|
val offset = PAGE_SIZE * (page - 1)
|
||||||
val apiUrlString = "$baseUrl/api/kb/web/searchs/comics?limit=$searchPageSize&offset=${(page - 1) * searchPageSize}&platform=2&q=$query&q_type="
|
val builder = apiUrl.toHttpUrl().newBuilder()
|
||||||
// val apiUrlString = "$baseUrl/api/v3/search/comic?limit=$searchPageSize&offset=${(page - 1) * searchPageSize}&platform=2&q=$query&q_type="
|
.addQueryParameter("limit", "$PAGE_SIZE")
|
||||||
val htmlUrlString = "$baseUrl/comics?offset=${(page - 1) * popularLatestPageSize}&limit=$popularLatestPageSize"
|
.addQueryParameter("offset", "$offset")
|
||||||
val requestUrlString: String
|
if (query.isNotBlank()) {
|
||||||
|
builder.addPathSegments("api/v3/search/comic")
|
||||||
val params = filters.map {
|
.addQueryParameter("q", query)
|
||||||
if (it is MangaFilter) {
|
filters.filterIsInstance<SearchFilter>().firstOrNull()?.addQuery(builder)
|
||||||
it.toUriPart()
|
|
||||||
} else ""
|
|
||||||
}.filter { it != "" }.joinToString("&")
|
|
||||||
// perform html search only when do have filter and not search anything
|
|
||||||
if (params != "" && query == "") {
|
|
||||||
requestUrlString = "$htmlUrlString&$params"
|
|
||||||
} else {
|
} 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 Request.Builder().url(builder.build()).headers(apiHeaders).build()
|
||||||
return GET(url.toString(), headers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response): MangasPage {
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
if (response.headers("content-type").filter { it.contains("json", true) }.any()) {
|
val page: ListDto<MangaDto> = response.parseAs()
|
||||||
// result from api request
|
val hasNextPage = page.offset + page.limit < page.total
|
||||||
return parseSearchMangaResponseAsJson(response)
|
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 {
|
} else {
|
||||||
// result from html request
|
GET(imageUrl)
|
||||||
return parseSearchMangaWithFilterOrPopularOrLatestResponse(response)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mangaDetailsRequest(manga: SManga) = GET(baseUrl + manga.url, headers)
|
private inline fun <reified T> Response.parseAs(): T = use {
|
||||||
override fun mangaDetailsParse(response: Response): SManga {
|
if (code == 200) {
|
||||||
val document = response.asJsoup()
|
json.decodeFromStream<ResultDto<T>>(body!!.byteStream()).results
|
||||||
var _title: String = document.select("div.comicParticulars-title-right > ul > li:eq(0) ").first().text()
|
} else {
|
||||||
if (preferences.getBoolean(SHOW_Simplified_Chinese_TITLE_PREF, false)) {
|
throw Exception(json.decodeFromStream<ResultMessageDto>(body!!.byteStream()).message)
|
||||||
_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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListRequest(chapter: SChapter) = GET("$apiUrl/api/v3${chapter.url}", headers)
|
private var genres: Array<Param> = emptyArray()
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
private var isFetchingGenres = false
|
||||||
val jsonObject = JSONObject(response.body!!.string())
|
|
||||||
val pageArray = jsonObject.getJSONObject("results").getJSONObject("chapter").getJSONArray("contents")
|
override fun getFilterList(): FilterList {
|
||||||
val ret = ArrayList<Page>(pageArray.length())
|
val genreFilter = if (genres.isEmpty()) {
|
||||||
for (i in 0 until pageArray.length()) {
|
fetchGenres()
|
||||||
val page = pageArray.getJSONObject(i).getString("url")
|
Filter.Header("点击“重置”尝试刷新题材分类")
|
||||||
ret.add(Page(i, "", page))
|
} else {
|
||||||
|
GenreFilter(genres)
|
||||||
}
|
}
|
||||||
|
return FilterList(
|
||||||
return ret
|
SearchFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("分类(搜索文本时无效)"),
|
||||||
|
genreFilter,
|
||||||
|
RegionFilter(),
|
||||||
|
StatusFilter(),
|
||||||
|
SortFilter(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun headersBuilder() = Headers.Builder()
|
private fun fetchGenres() {
|
||||||
.add("User-Agent", String.format(USER_AGENT, preferences.getString(CHROME_VERSION_PREF, CHROME_VERSION_DEFAULT)))
|
if (genres.isNotEmpty() || isFetchingGenres) return
|
||||||
.add("region", if (preferences.getBoolean(CHANGE_CDN_OVERSEAS, false)) "0" else "1")
|
isFetchingGenres = true
|
||||||
|
thread {
|
||||||
// Unused, we can get image urls directly from the chapter page
|
try {
|
||||||
override fun imageUrlParse(response: Response) =
|
val response = client.newCall(GET("$apiUrl/api/v3/theme/comic/count?limit=500", apiHeaders)).execute()
|
||||||
throw UnsupportedOperationException("This method should not be called!")
|
val list = response.parseAs<ListDto<KeywordDto>>().list
|
||||||
|
val result = ArrayList<Param>(list.size + 1).apply { add(Param("全部", "")) }
|
||||||
// Copymanga has different logic in polular and search page, mix two logic in search progress for now
|
genres = list.mapTo(result) { it.toParam() }.toTypedArray()
|
||||||
override fun getFilterList() = FilterList(
|
} catch (e: Exception) {
|
||||||
MangaFilter(
|
Log.e("CopyManga", "failed to fetch genres", e)
|
||||||
"题材",
|
} finally {
|
||||||
"theme",
|
isFetchingGenres = false
|
||||||
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 hexStringToByteArray(string: String): ByteArray {
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
val bytes = ByteArray(string.length / 2)
|
ListPreference(screen.context).apply {
|
||||||
for (i in 0 until string.length / 2) {
|
key = DOMAIN_PREF
|
||||||
bytes[i] = string.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
title = "网址域名"
|
||||||
}
|
summary = "连接不稳定时可以尝试切换"
|
||||||
return bytes
|
entries = DOMAINS
|
||||||
}
|
entryValues = DOMAIN_INDICES
|
||||||
|
setDefaultValue("0")
|
||||||
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 = "需要重启软件以生效。已添加漫画需要迁移改变标题。"
|
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
preferences.edit().putBoolean(SHOW_Simplified_Chinese_TITLE_PREF, newValue as Boolean).commit()
|
val index = newValue as String
|
||||||
}
|
preferences.edit().putString(DOMAIN_PREF, index).apply()
|
||||||
}
|
domain = DOMAINS[index.toInt()]
|
||||||
val cdnPreference = androidx.preference.SwitchPreferenceCompat(screen.context).apply {
|
apiUrl = API_PREFIX + domain
|
||||||
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()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}.let { screen.addPreference(it) }
|
||||||
screen.addPreference(zhPreference)
|
|
||||||
screen.addPreference(cdnPreference)
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
screen.addPreference(chromeVersionPreference)
|
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 {
|
companion object {
|
||||||
private const val SHOW_Simplified_Chinese_TITLE_PREF = "showSCTitle"
|
private const val DOMAIN_PREF = "domain"
|
||||||
private const val CHANGE_CDN_OVERSEAS = "changeCDN"
|
private const val OVERSEAS_CDN_PREF = "changeCDN"
|
||||||
private const val CHROME_VERSION_PREF = "chromeVersion"
|
private const val SC_TITLE_PREF = "showSCTitle"
|
||||||
private const val CHROME_VERSION_DEFAULT = "103"
|
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 const val PAGE_SIZE = 20
|
||||||
private val isNewDateLogic = AppInfo.getVersionCode() >= 81
|
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())
|
||||||
|
}
|
||||||
|
}
|