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())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||