diff --git a/src/zh/dmzj/API.md b/src/zh/dmzj/API.md new file mode 100644 index 000000000..84da5bc51 --- /dev/null +++ b/src/zh/dmzj/API.md @@ -0,0 +1,138 @@ +# API v4 + +## Manga details + +Mostly taken from [here](https://github.com/xiaoyaocz/dmzj_flutter/blob/aac6ba3/lib/protobuf/comic/detail_response.proto). + +```protobuf +syntax = "proto3"; + +package dmzj.comic; + + +message ComicDetailResponse { + int32 Errno = 1; + string Errmsg = 2; + ComicDetailInfoResponse Data= 3; +} + +message ComicDetailInfoResponse { + int32 Id = 1; + string Title = 2; + int32 Direction=3; + int32 Islong=4; + int32 IsDmzj=5; + string Cover=6; + string Description=7; + int64 LastUpdatetime=8; + string LastUpdateChapterName=9; + int32 Copyright=10; + string FirstLetter=11; + string ComicPy=12; + int32 Hidden=13; + int32 HotNum=14; + int32 HitNum=15; + int32 Uid=16; + int32 IsLock=17; + int32 LastUpdateChapterId=18; + repeated ComicDetailTypeItemResponse Types=19; + repeated ComicDetailTypeItemResponse Status=20; + repeated ComicDetailTypeItemResponse Authors=21; + int32 SubscribeNum=22; + repeated ComicDetailChapterResponse Chapters=23; + int32 IsNeedLogin=24; + //object UrlLinks=25; { string name = 1; repeated object links = 2; } + // link { int32 = 1; string name = 2; string uriOrApk = 3; string icon = 4; string packageName = 5; string apk = 6; int32 = 7; } + int32 IsHideChapter=26; + //repeated object DhUrlLinks=27; { string name = 1; } + +} + +message ComicDetailTypeItemResponse { + int32 TagId = 1; + string TagName = 2; +} + +message ComicDetailChapterResponse { + string Title = 1; + repeated ComicDetailChapterInfoResponse Data=2; +} +message ComicDetailChapterInfoResponse { + int32 ChapterId = 1; + string ChapterTitle = 2; + int64 Updatetime=3; + int32 Filesize=4; + int32 ChapterOrder=5; +} +``` + +## Ranking + +Taken from [here](https://github.com/xiaoyaocz/dmzj_flutter/blob/e7f1b1e/lib/protobuf/comic/rank_list_response.proto). + +```protobuf +syntax = "proto3"; + +package dmzj.comic; + + +message ComicRankListResponse { + int32 Errno = 1; + string Errmsg = 2; + repeated ComicRankListItemResponse Data= 3; +} + +message ComicRankListItemResponse { + int32 ComicId = 1; + string Title = 2; + string Authors=3; + string Status=4; + string Cover=5; + string Types=6; + int64 LastUpdatetime=7; + string LastUpdateChapterName=8; + string ComicPy=9; + int32 Num=10; + int32 TagId=11; + string ChapterName=12; + int32 ChapterId=13; +} +``` + +## Chapter images + +```kotlin +@Serializable +class ResponseDto( + @ProtoNumber(1) val code: Int?, + @ProtoNumber(2) val message: String?, + @ProtoNumber(3) val data: T?, +) + +@Serializable +class ChapterImagesDto( + @ProtoNumber(1) val id: Int, + @ProtoNumber(2) val mangaId: Int, + @ProtoNumber(3) val name: String, + @ProtoNumber(4) val order: Int, + @ProtoNumber(5) val direction: Int, + @ProtoNumber(6) val lowResImages: List, + @ProtoNumber(7) val pageCount: Int?, + @ProtoNumber(8) val images: List, + @ProtoNumber(9) val commentCount: Int, +) +``` + +# Unused legacy API + +## Chapter images + +```kotlin +val webviewPageListApiUrl = "https://m.dmzj.com/chapinfo" +GET("$webviewPageListApiUrl/${chapter.url}.html") +``` + +```kotlin +val oldPageListApiUrl = "http://api.m.dmzj.com" // this domain has an expired certificate +GET("$oldPageListApiUrl/comic/chapter/${chapter.url}.html") +``` diff --git a/src/zh/dmzj/build.gradle b/src/zh/dmzj/build.gradle index 3efbb6521..d771227d7 100644 --- a/src/zh/dmzj/build.gradle +++ b/src/zh/dmzj/build.gradle @@ -3,10 +3,10 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlinx-serialization' ext { - extName = 'Dmzj' + extName = 'DMZJ' pkgNameSuffix = 'zh.dmzj' extClass = '.Dmzj' - extVersionCode = 31 + extVersionCode = 32 } apply from: "$rootDir/common.gradle" diff --git a/src/zh/dmzj/res/mipmap-hdpi/ic_launcher.png b/src/zh/dmzj/res/mipmap-hdpi/ic_launcher.png index aaddbe171..48efce517 100644 Binary files a/src/zh/dmzj/res/mipmap-hdpi/ic_launcher.png and b/src/zh/dmzj/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/dmzj/res/mipmap-mdpi/ic_launcher.png b/src/zh/dmzj/res/mipmap-mdpi/ic_launcher.png index d4ed1385d..46c20b2ec 100644 Binary files a/src/zh/dmzj/res/mipmap-mdpi/ic_launcher.png and b/src/zh/dmzj/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/dmzj/res/mipmap-xhdpi/ic_launcher.png b/src/zh/dmzj/res/mipmap-xhdpi/ic_launcher.png index 233aa08c7..49b015793 100644 Binary files a/src/zh/dmzj/res/mipmap-xhdpi/ic_launcher.png and b/src/zh/dmzj/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/dmzj/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/dmzj/res/mipmap-xxhdpi/ic_launcher.png index c12efcf74..1edcb656e 100644 Binary files a/src/zh/dmzj/res/mipmap-xxhdpi/ic_launcher.png and b/src/zh/dmzj/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/dmzj/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/dmzj/res/mipmap-xxxhdpi/ic_launcher.png index 7f7f4b514..5e053fbeb 100644 Binary files a/src/zh/dmzj/res/mipmap-xxxhdpi/ic_launcher.png and b/src/zh/dmzj/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/dmzj/res/web_hi_res_512.png b/src/zh/dmzj/res/web_hi_res_512.png index 85314a5c8..d040c2123 100644 Binary files a/src/zh/dmzj/res/web_hi_res_512.png and b/src/zh/dmzj/res/web_hi_res_512.png differ diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiSearch.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiSearch.kt new file mode 100644 index 000000000..c5487b154 --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiSearch.kt @@ -0,0 +1,42 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Response + +object ApiSearch { + + fun textSearchUrl(query: String) = + "http://s.acg.dmzj.com/comicsum/search.php".toHttpUrl().newBuilder() + .addQueryParameter("s", query) + .toString() + + fun parsePage(response: Response): MangasPage { + if (!response.isSuccessful) { + response.close() + return MangasPage(emptyList(), false) + } + // "var g_search_data = [...];" + val js = response.body!!.string().run { substring(20, length - 1) } + val data: List = json.decodeFromString(js) + return MangasPage(data.map { it.toSManga() }, false) + } + + @Serializable + class MangaDto( + private val id: Int, + private val comic_name: String, + private val comic_author: String, + private val comic_cover: String, + ) { + fun toSManga() = SManga.create().apply { + url = getMangaUrl(id.toString()) + title = comic_name + author = comic_author.formatList() + thumbnail_url = comic_cover + } + } +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiV3.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiV3.kt new file mode 100644 index 000000000..2752ac34a --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiV3.kt @@ -0,0 +1,99 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Response + +object ApiV3 { + + private const val v3apiUrl = "https://v3api.dmzj.com" + private const val apiUrl = "https://api.dmzj.com" + + fun popularMangaUrl(page: Int) = "$v3apiUrl/classify/0/0/${page - 1}.json" + + fun latestUpdatesUrl(page: Int) = "$v3apiUrl/classify/0/1/${page - 1}.json" + + fun pageUrl(page: Int, filters: FilterList) = "$v3apiUrl/classify/${parseFilters(filters)}/${page - 1}.json" + + fun parsePage(response: Response): MangasPage { + val data: List = response.parseAs() + return MangasPage(data.map { it.toSManga() }, data.isNotEmpty()) + } + + fun mangaInfoUrlV1(id: String) = "$apiUrl/dynamic/comicinfo/$id.json" + + private fun parseMangaInfoV1(response: Response): ResponseDto = response.parseAs() + + fun parseMangaDetailsV1(response: Response): SManga { + return parseMangaInfoV1(response).data.info.toSManga() + } + + fun parseChapterListV1(response: Response): List { + return parseMangaInfoV1(response).data.list.map { it.toSChapter() } + } + + fun chapterCommentsUrl(path: String) = "$v3apiUrl/viewPoint/0/$path.json" + + fun parseChapterComments(response: Response): List { + val result: List = response.parseAs() + (result as MutableList).sort() + return result.map { it.toString() } + } + + @Serializable + class MangaDto( + private val id: JsonPrimitive, // can be int or string + private val title: String, + private val authors: String, + private val status: String, + private val cover: String, + private val types: String, + private val description: String? = null, + ) { + fun toSManga() = SManga.create().apply { + url = getMangaUrl(id.content) + title = this@MangaDto.title + author = authors.formatList() + genre = types.formatList() + status = parseStatus(this@MangaDto.status) + thumbnail_url = cover + + val desc = this@MangaDto.description ?: return@apply + description = "$desc\n\n漫画 ID (2): ${id.content}" // hidden + initialized = true + } + } + + @Serializable + class ChapterDto( + private val id: String, + private val comic_id: String, + private val chapter_name: String, + private val updatetime: String, + ) { + fun toSChapter() = SChapter.create().apply { + url = "$comic_id/$id" + name = chapter_name.formatChapterName() + date_upload = updatetime.toLong() * 1000 + } + } + + @Serializable + class ChapterCommentDto( + private val content: String, + private val num: Int, + ) : Comparable { + override fun toString() = if (num > 0) "$content [+$num]" else content + override fun compareTo(other: ChapterCommentDto) = other.num.compareTo(num) // descending + } + + @Serializable + class DataDto(val info: MangaDto, val list: List) + + @Serializable + class ResponseDto(val data: DataDto) +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiV4.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiV4.kt new file mode 100644 index 000000000..c1ce63f3e --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ApiV4.kt @@ -0,0 +1,193 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import eu.kanade.tachiyomi.extension.zh.dmzj.utils.RSA +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoBuf +import kotlinx.serialization.protobuf.ProtoNumber +import kotlinx.serialization.serializer +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Response +import kotlin.math.max +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +object ApiV4 { + + private const val v4apiUrl = "https://nnv4api.dmzj.com" + private const val imageSmallCDNUrl = "https://imgsmall.dmzj.com" + + fun mangaInfoUrl(id: String) = "$v4apiUrl/comic/detail/$id?uid=2665531" + + fun parseMangaInfo(response: Response): ParseResult { + val result: ResponseDto = response.decrypt() + return when (val manga = result.data) { + null -> ParseResult.Error(result.message) + else -> ParseResult.Ok(manga) + } + } + + // path = "mangaId/chapterId" + fun chapterImagesUrl(path: String) = "$v4apiUrl/comic/chapter/$path" + + fun parseChapterImages(response: Response): ArrayList { + val result: ResponseDto = response.decrypt() + return result.data!!.toPageList() + } + + fun rankingUrl(page: Int, filters: RankingGroup) = + "$v4apiUrl/comic/rank/list?${filters.parse()}&uid=2665531&page=$page" + + fun parseRanking(response: Response): MangasPage { + val result: ResponseDto> = response.decrypt() + val data = result.data ?: return MangasPage(emptyList(), false) + return MangasPage(data.map { it.toSManga() }, data.isNotEmpty()) + } + + private inline fun Response.decrypt(): T = decrypt(typeOf()) + + @Suppress("UNCHECKED_CAST") + private fun Response.decrypt(type: KType): T { + val bytes = RSA.decrypt(body!!.string(), cipher) + val deserializer = serializer(type) as KSerializer + return ProtoBuf.decodeFromByteArray(deserializer, bytes) + } + + @Serializable + class MangaDto( + @ProtoNumber(1) private val id: Int, + @ProtoNumber(2) private val title: String, + @ProtoNumber(6) private val cover: String, + @ProtoNumber(7) private val description: String, + @ProtoNumber(19) private val genres: List, + @ProtoNumber(20) private val status: List, + @ProtoNumber(21) private val authors: List, + @ProtoNumber(23) private val chapterGroups: List, + ) { + val isLicensed get() = chapterGroups.isEmpty() + + fun toSManga() = SManga.create().apply { + url = getMangaUrl(id.toString()) + title = this@MangaDto.title + author = authors.joinToString { it.name } + description = if (isLicensed) { + "${this@MangaDto.description}\n\n漫画 ID (1): $id" + } else { + this@MangaDto.description + } + genre = genres.joinToString { it.name } + status = parseStatus(this@MangaDto.status[0].name) + thumbnail_url = cover + initialized = true + } + + fun parseChapterList(): List { + val mangaId = id.toString() + val size = chapterGroups.sumOf { it.size } + return chapterGroups.flatMapTo(ArrayList(size)) { + it.toSChapterList(mangaId) + } + } + } + + @Serializable + class TagDto(@ProtoNumber(2) val name: String) + + @Serializable + class ChapterGroupDto( + @ProtoNumber(1) private val name: String, + @ProtoNumber(2) private val chapters: List, + ) { + fun toSChapterList(mangaId: String): List { + val groupName = name + val isDefaultGroup = groupName == "连载" + return chapters.map { + it.toSChapterInternal().apply { + url = "$mangaId/$url" + if (!isDefaultGroup) name = "$groupName: $name" + } + } + } + + val size get() = chapters.size + } + + @Serializable + class ChapterDto( + @ProtoNumber(1) private val id: Int, + @ProtoNumber(2) private val name: String, + @ProtoNumber(3) private val updateTime: Long, + ) { + fun toSChapterInternal() = SChapter.create().apply { + url = id.toString() + name = this@ChapterDto.name.formatChapterName() + date_upload = updateTime * 1000 + } + } + + @Serializable + class ChapterImagesDto( + @ProtoNumber(1) private val id: Int, + @ProtoNumber(2) private val mangaId: Int, + @ProtoNumber(6) private val lowResImages: List, + @ProtoNumber(8) private val images: List, + ) { + // page count can be messy, see manga ID 55847 chapters 107-109 + fun toPageList(): ArrayList { + val pageCount = max(images.size, lowResImages.size) + val list = ArrayList(pageCount + 1) // for comments page + for (i in 0 until pageCount) { + val imageUrl = images.getOrNull(i)?.fixFilename()?.toHttps() + val lowResUrl = lowResImages.getOrElse(i) { + // this is sometimes different in low-res URLs and might fail, see manga ID 56649 + val initial = imageUrl!!.decodePath().toHttpUrl().pathSegments[0] + "$imageSmallCDNUrl/$initial/$mangaId/$id/$i.jpg" + }.toHttps() + list.add(Page(i, url = lowResUrl, imageUrl = imageUrl ?: lowResUrl)) + } + return list + } + } + + // same as ApiV3.MangaDto + @Serializable + class RankingItemDto( + @ProtoNumber(1) private val id: Int?, + @ProtoNumber(2) private val title: String, + @ProtoNumber(3) private val authors: String, + @ProtoNumber(4) private val status: String, + @ProtoNumber(5) private val cover: String, + @ProtoNumber(6) private val genres: String, + @ProtoNumber(9) private val slug: String?, + ) { + fun toSManga() = SManga.create().apply { + url = when { + id != null -> getMangaUrl(id.toString()) + slug != null -> PREFIX_ID_SEARCH + slug + else -> throw Exception("无法解析") + } + title = this@RankingItemDto.title + author = authors.formatList() + genre = genres.formatList() + status = parseStatus(this@RankingItemDto.status) + thumbnail_url = cover + } + } + + @Serializable + class ResponseDto( + @ProtoNumber(2) val message: String?, + @ProtoNumber(3) val data: T?, + ) + + sealed interface ParseResult { + class Ok(val manga: MangaDto) : ParseResult + class Error(val message: String?) : ParseResult + } + + private val cipher by lazy { RSA.getPrivateKey("MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAK8nNR1lTnIfIes6oRWJNj3mB6OssDGx0uGMpgpbVCpf6+VwnuI2stmhZNoQcM417Iz7WqlPzbUmu9R4dEKmLGEEqOhOdVaeh9Xk2IPPjqIu5TbkLZRxkY3dJM1htbz57d/roesJLkZXqssfG5EJauNc+RcABTfLb4IiFjSMlTsnAgMBAAECgYEAiz/pi2hKOJKlvcTL4jpHJGjn8+lL3wZX+LeAHkXDoTjHa47g0knYYQteCbv+YwMeAGupBWiLy5RyyhXFoGNKbbnvftMYK56hH+iqxjtDLnjSDKWnhcB7089sNKaEM9Ilil6uxWMrMMBH9v2PLdYsqMBHqPutKu/SigeGPeiB7VECQQDizVlNv67go99QAIv2n/ga4e0wLizVuaNBXE88AdOnaZ0LOTeniVEqvPtgUk63zbjl0P/pzQzyjitwe6HoCAIpAkEAxbOtnCm1uKEp5HsNaXEJTwE7WQf7PrLD4+BpGtNKkgja6f6F4ld4QZ2TQ6qvsCizSGJrjOpNdjVGJ7bgYMcczwJBALvJWPLmDi7ToFfGTB0EsNHZVKE66kZ/8Stx+ezueke4S556XplqOflQBjbnj2PigwBN/0afT+QZUOBOjWzoDJkCQClzo+oDQMvGVs9GEajS/32mJ3hiWQZrWvEzgzYRqSf3XVcEe7PaXSd8z3y3lACeeACsShqQoc8wGlaHXIJOHTcCQQCZw5127ZGs8ZDTSrogrH73Kw/HvX55wGAeirKYcv28eauveCG7iyFR0PFB/P/EDZnyb+ifvyEFlucPUI0+Y87F") } +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/CommentsInterceptor.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/CommentsInterceptor.kt new file mode 100644 index 000000000..b70f242d2 --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/CommentsInterceptor.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.ByteArrayOutputStream + +object CommentsInterceptor : Interceptor { + + class Tag + + private const val MAX_HEIGHT = 1920 + private const val WIDTH = 1080 + private const val UNIT = 32 + private const val UNIT_F = UNIT.toFloat() + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + if (request.tag(Tag::class.java) == null) return response + + val comments = ApiV3.parseChapterComments(response) + .take(MAX_HEIGHT / (UNIT * 2)) + .ifEmpty { listOf("没有吐槽") } + + val paint = TextPaint().apply { + color = Color.BLACK + textSize = UNIT_F + isAntiAlias = true + } + + var height = UNIT + val layouts = comments.map { + @Suppress("DEPRECATION") + StaticLayout(it, paint, WIDTH - 2 * UNIT, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false) + }.takeWhile { + val lineHeight = it.height + UNIT + if (height + lineHeight <= MAX_HEIGHT) { + height += lineHeight + true + } else { + false + } + } + + val bitmap = Bitmap.createBitmap(WIDTH, height, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + val canvas = Canvas(bitmap) + + var y = UNIT + for (layout in layouts) { + canvas.save() + canvas.translate(UNIT_F, y.toFloat()) + layout.draw(canvas) + canvas.restore() + y += layout.height + UNIT + } + + val output = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 0, output) + val body = output.toByteArray().toResponseBody("image/png".toMediaType()) + return response.newBuilder().body(body).build() + } +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Common.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Common.kt new file mode 100644 index 000000000..acfa8d579 --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Common.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Response +import uy.kohesive.injekt.injectLazy +import java.net.URLDecoder + +const val PREFIX_ID_SEARCH = "id:" + +val json: Json by injectLazy() + +inline fun Response.parseAs(): T { + return json.decodeFromString(body!!.string()) +} + +fun getMangaUrl(id: String) = "/comic/comic_$id.json?version=2.7.019" + +fun String.extractMangaId(): String { + val start = 13 // length of "/comic/comic_" + return substring(start, indexOf('.', start)) +} + +fun String.formatList() = replace("/", ", ") + +fun parseStatus(status: String): Int = when (status) { + "连载中" -> SManga.ONGOING + "已完结" -> SManga.COMPLETED + else -> SManga.UNKNOWN +} + +fun String.formatChapterName(): String { + val replaced = removePrefix("连载") + if (!replaced[0].isDigit()) return replaced + return when (replaced.last()) { + '话', '卷' -> "第$replaced" + else -> replaced + } +} + +fun String.toHttps() = "https:" + substringAfter(':') + +// see https://github.com/tachiyomiorg/tachiyomi-extensions/issues/3457 +fun String.fixFilename() = if (endsWith(".jp")) this + 'g' else this + +fun String.decodePath(): String = URLDecoder.decode(this, "UTF-8") + +const val COMMENTS_FLAG = "COMMENTS" diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Dmzj.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Dmzj.kt index 4cf0bea6c..0800add98 100644 --- a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Dmzj.kt +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Dmzj.kt @@ -2,36 +2,20 @@ package eu.kanade.tachiyomi.extension.zh.dmzj import android.app.Application import android.content.SharedPreferences -import android.net.Uri -import android.util.Base64 -import androidx.preference.ListPreference -import androidx.preference.Preference +import android.util.Log import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.extension.zh.dmzj.protobuf.ComicDetailResponse -import eu.kanade.tachiyomi.extension.zh.dmzj.utils.HttpGetFailoverInterceptor -import eu.kanade.tachiyomi.extension.zh.dmzj.utils.RSA import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.asObservableSuccess -import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.source.ConfigurableSource -import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.util.asJsoup -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.protobuf.ProtoBuf -import okhttp3.Headers -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.json.JSONArray -import org.json.JSONObject import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -45,603 +29,201 @@ class Dmzj : ConfigurableSource, HttpSource() { override val supportsLatest = true override val name = "动漫之家" override val baseUrl = "https://m.dmzj.com" - private val v3apiUrl = "https://v3api.dmzj.com" - private val v3ChapterApiUrl = "https://nnv3api.muwai.com" - - // v3api now shutdown the functionality to fetch manga detail and chapter list, so move these logic to v4api - private val v4apiUrl = "https://nnv4api.muwai.com" // https://v4api.dmzj1.com - private val apiUrl = "https://api.dmzj.com" - private val oldPageListApiUrl = "http://api.m.dmzj.com" // this domain has an expired certificate - private val webviewPageListApiUrl = "https://m.dmzj.com/chapinfo" - private val imageCDNUrl = "https://images.dmzj.com" - private val imageSmallCDNUrl = "https://imgsmall.dmzj.com" - - private fun cleanUrl(url: String) = if (url.startsWith("//")) - "https:$url" - else url private val preferences: SharedPreferences by lazy { Injekt.get().getSharedPreferences("source_$id", 0x0000) } - private val httpGetFailoverInterceptor = HttpGetFailoverInterceptor() - override val client: OkHttpClient = network.client.newBuilder() - .addInterceptor(httpGetFailoverInterceptor) - .rateLimitHost( - apiUrl.toHttpUrlOrNull()!!, - preferences.getString(API_RATELIMIT_PREF, "5")!!.toInt() - ) - .rateLimitHost( - v3apiUrl.toHttpUrlOrNull()!!, - preferences.getString(API_RATELIMIT_PREF, "5")!!.toInt() - ) - .rateLimitHost( - v4apiUrl.toHttpUrlOrNull()!!, - preferences.getString(API_RATELIMIT_PREF, "5")!!.toInt() - ) - .rateLimitHost( - imageCDNUrl.toHttpUrlOrNull()!!, - preferences.getString(IMAGE_CDN_RATELIMIT_PREF, "5")!!.toInt() - ) - .rateLimitHost( - imageSmallCDNUrl.toHttpUrlOrNull()!!, - preferences.getString(IMAGE_CDN_RATELIMIT_PREF, "5")!!.toInt() - ) + .addInterceptor(ImageUrlInterceptor) + .addInterceptor(CommentsInterceptor) + .rateLimit(4) .build() - override fun headersBuilder() = Headers.Builder().apply { - set("Referer", "https://www.dmzj.com/") - set( - "User-Agent", - "Mozilla/5.0 (Linux; Android 10) " + - "AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/88.0.4324.93 " + - "Mobile Safari/537.36 " + - "Tachiyomi/1.0" - ) + // API v4 randomly fails + private val retryClient = network.client.newBuilder() + .addInterceptor(RetryInterceptor) + .rateLimit(2) + .build() + + private fun fetchIdBySlug(slug: String): String { + val request = GET("https://manhua.dmzj.com/$slug/", headers) + val html = client.newCall(request).execute().body!!.string() + val start = "g_comic_id = \"" + val startIndex = html.indexOf(start) + start.length + val endIndex = html.indexOf('"', startIndex) + return html.substring(startIndex, endIndex) } - // for simple searches (query only, no filters) - private fun simpleSearchJsonParse(json: String): MangasPage { - val arr = JSONArray(json) - val ret = ArrayList(arr.length()) - for (i in 0 until arr.length()) { - val obj = arr.getJSONObject(i) - val cid = obj.getString("id") - ret.add( - SManga.create().apply { - title = obj.getString("comic_name") - thumbnail_url = cleanUrl(obj.getString("comic_cover")) - author = obj.optString("comic_author") - url = "/comic/comic_$cid.json?version=2.7.019" - } - ) + private fun fetchMangaInfoV4(id: String): ApiV4.MangaDto? { + val response = retryClient.newCall(GET(ApiV4.mangaInfoUrl(id), headers)).execute() + return when (val result = ApiV4.parseMangaInfo(response)) { + is ApiV4.ParseResult.Ok -> { + val manga = result.manga + if (manga.isLicensed) preferences.addLicensed(id) + manga + } + is ApiV4.ParseResult.Error -> { + Log.e("DMZJ", "no data for manga $id: ${result.message}") + preferences.addHidden(id) + null + } } - return MangasPage(ret, false) } - // for popular, latest, and filtered search - private fun mangaFromJSON(json: String): MangasPage { - val arr = JSONArray(json) - val ret = ArrayList(arr.length()) - for (i in 0 until arr.length()) { - val obj = arr.getJSONObject(i) - val cid = obj.getString("id") - ret.add( - SManga.create().apply { - title = obj.getString("title") - thumbnail_url = obj.getString("cover") - author = obj.optString("authors") - status = when (obj.getString("status")) { - "已完结" -> SManga.COMPLETED - "连载中" -> SManga.ONGOING - else -> SManga.UNKNOWN - } - url = "/comic/comic_$cid.json?version=2.7.019" - } - ) - } - return MangasPage(ret, arr.length() != 0) - } + override fun popularMangaRequest(page: Int) = GET(ApiV3.popularMangaUrl(page), headers) - private fun customUrlBuilder(baseUrl: String): HttpUrl.Builder { - val rightNow = System.currentTimeMillis() / 1000 - return baseUrl.toHttpUrlOrNull()!!.newBuilder() - .addQueryParameter("channel", "android") - .addQueryParameter("version", "3.0.0") - .addQueryParameter("timestamp", rightNow.toInt().toString()) - } + override fun popularMangaParse(response: Response) = ApiV3.parsePage(response) - private fun decryptProtobufData(rawData: String): ByteArray { - return RSA.decrypt(Base64.decode(rawData, Base64.DEFAULT), privateKey) - } + override fun latestUpdatesRequest(page: Int) = GET(ApiV3.latestUpdatesUrl(page), headers) - override fun popularMangaRequest(page: Int) = GET("$v3apiUrl/classify/0/0/${page - 1}.json") - - override fun popularMangaParse(response: Response) = searchMangaParse(response) - - override fun latestUpdatesRequest(page: Int) = GET("$v3apiUrl/classify/0/1/${page - 1}.json") - - override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response) + override fun latestUpdatesParse(response: Response) = ApiV3.parsePage(response) private fun searchMangaById(id: String): MangasPage { - val comicNumberID = if (checkComicIdIsNumericalRegex.matches(id)) { + val idNumber = if (id.all { it.isDigit() }) { id } else { // Chinese Pinyin ID - val document = client.newCall(GET("$baseUrl/info/$id.html", headers)).execute().asJsoup() - extractComicIdFromWebpageRegex.find( - document.select("#Subscribe").attr("onclick") - )!!.groups[1]!!.value // onclick="addSubscribe('{comicNumberID}')" + fetchIdBySlug(id) } - val sManga = try { - val r = client.newCall(GET("$v4apiUrl/comic/detail/$comicNumberID.json", headers)).execute() - mangaDetailsParse(r) - } catch (_: Exception) { - val r = client.newCall(GET("$apiUrl/dynamic/comicinfo/$comicNumberID.json", headers)).execute() - mangaDetailsParse(r) - } - // Change url format to as same as mangaFromJSON, which used by popularMangaParse and latestUpdatesParse. - // manga.url being used as key to identity a manga in tachiyomi, so if url format don't match popularMangaParse and latestUpdatesParse, - // tachiyomi will mark them as unsubscribe in popularManga and latestUpdates page. - sManga.url = "/comic/comic_$comicNumberID.json?version=2.7.019" + val sManga = fetchMangaDetails(idNumber) return MangasPage(listOf(sManga), false) } override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - return if (query.startsWith(PREFIX_ID_SEARCH)) { + return if (query.isEmpty()) { + val ranking = filters.filterIsInstance().firstOrNull() + if (ranking != null && ranking.isEnabled) { + val call = retryClient.newCall(GET(ApiV4.rankingUrl(page, ranking), headers)) + return Observable.fromCallable { + val result = ApiV4.parseRanking(call.execute()) + // result has no manga ID if filtered by certain genres; this can be slow + for (manga in result.mangas) if (manga.url.startsWith(PREFIX_ID_SEARCH)) { + manga.url = getMangaUrl(fetchIdBySlug(manga.url.removePrefix(PREFIX_ID_SEARCH))) + } + result + } + } + val call = client.newCall(GET(ApiV3.pageUrl(page, filters), headers)) + Observable.fromCallable { ApiV3.parsePage(call.execute()) } + } else if (query.startsWith(PREFIX_ID_SEARCH)) { // ID may be numbers or Chinese pinyin val id = query.removePrefix(PREFIX_ID_SEARCH).removeSuffix(".html") - Observable.just(searchMangaById(id)) + Observable.fromCallable { searchMangaById(id) } } else { - client.newCall(searchMangaRequest(page, query, filters)) - .asObservableSuccess() - .map { response -> - searchMangaParse(response) + val request = GET(ApiSearch.textSearchUrl(query), headers) + Observable.fromCallable { + // this API fails randomly, and might return empty list + repeat(8) { + val result = ApiSearch.parsePage(client.newCall(request).execute()) + if (result.mangas.isNotEmpty()) return@fromCallable result } + throw Exception("搜索出错或无结果") + } } } override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - if (query != "") { - val uri = Uri.parse("http://s.acg.dmzj.com/comicsum/search.php").buildUpon() - uri.appendQueryParameter("s", query) - return GET(uri.toString()) - } else { - var params = filters.map { - if (it !is SortFilter && it is UriPartFilter) { - it.toUriPart() - } else "" - }.filter { it != "" }.joinToString("-") - if (params == "") { - params = "0" - } - - val order = filters.filterIsInstance().joinToString("") { (it as UriPartFilter).toUriPart() } - - return GET("$v3apiUrl/classify/$params/$order/${page - 1}.json") - } + throw UnsupportedOperationException() } override fun searchMangaParse(response: Response): MangasPage { - val body = response.body!!.string() - - return if (body.contains("g_search_data")) { - simpleSearchJsonParse(body.substringAfter("=").trim().removeSuffix(";")) - } else { - mangaFromJSON(body) - } + throw UnsupportedOperationException() } // Bypass mangaDetailsRequest, fetch api url directly override fun fetchMangaDetails(manga: SManga): Observable { - val cid = extractComicIdFromMangaUrlRegex.find(manga.url)!!.groups[1]!!.value - return try { - // Not using client.newCall().asObservableSuccess() to ensure we can catch exception here. - val response = client.newCall( - GET( - customUrlBuilder("$v4apiUrl/comic/detail/$cid").build().toString(), headers - ) - ).execute() - val sManga = mangaDetailsParse(response).apply { initialized = true } - Observable.just(sManga) - } catch (e: Exception) { - val response = client.newCall(GET("$apiUrl/dynamic/comicinfo/$cid.json", headers)).execute() - val sManga = mangaDetailsParse(response).apply { initialized = true } - Observable.just(sManga) - } catch (e: Exception) { - Observable.error(e) + val id = manga.url.extractMangaId() + return Observable.fromCallable { fetchMangaDetails(id) } + } + + private fun fetchMangaDetails(id: String): SManga { + if (id !in preferences.hiddenList) { + fetchMangaInfoV4(id)?.run { return toSManga() } } + val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute() + return ApiV3.parseMangaDetailsV1(response) } // Workaround to allow "Open in browser" use human readable webpage url. + // headers are not needed override fun mangaDetailsRequest(manga: SManga): Request { - val cid = extractComicIdFromMangaUrlRegex.find(manga.url)!!.groups[1]!!.value + val cid = manga.url.extractMangaId() return GET("$baseUrl/info/$cid.html") } override fun mangaDetailsParse(response: Response) = SManga.create().apply { - val responseBody = response.body!!.string() - if (response.request.url.toString().startsWith(v4apiUrl)) { - val pb = ProtoBuf.decodeFromByteArray(decryptProtobufData(responseBody)) - val pbData = pb.Data - title = pbData.Title - thumbnail_url = pbData.Cover - author = pbData.Authors.joinToString(separator = ", ") { it.TagName } - genre = pbData.TypesTypes.joinToString(separator = ", ") { it.TagName } - - status = when (pbData.Status[0].TagName) { - "已完结" -> SManga.COMPLETED - "连载中" -> SManga.ONGOING - else -> SManga.UNKNOWN - } - description = pbData.Description - } else { - val obj = JSONObject(responseBody) - val data = obj.getJSONObject("data").getJSONObject("info") - title = data.getString("title") - thumbnail_url = data.getString("cover") - author = data.getString("authors") - genre = data.getString("types").replace("/", ", ") - status = when (data.getString("status")) { - "连载中" -> SManga.ONGOING - "已完结" -> SManga.COMPLETED - else -> SManga.UNKNOWN - } - description = data.getString("description") - } + throw UnsupportedOperationException() } override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException("Not used.") override fun fetchChapterList(manga: SManga): Observable> { - val cid = extractComicIdFromMangaUrlRegex.find(manga.url)!!.groups[1]!!.value - return if (manga.status != SManga.LICENSED) { - try { - val response = - client.newCall( - GET( - customUrlBuilder("$v4apiUrl/comic/detail/$cid").build().toString(), - headers - ) - ).execute() - Observable.just(chapterListParse(response)) - } catch (e: Exception) { - val response = client.newCall(GET("$apiUrl/dynamic/comicinfo/$cid.json", headers)).execute() - Observable.just(chapterListParse(response)) - } catch (e: Exception) { - Observable.error(e) + return Observable.fromCallable { + val id = manga.url.extractMangaId() + if (id !in preferences.licensedList && id !in preferences.hiddenList) { + val result = fetchMangaInfoV4(id) + if (result != null && !result.isLicensed) { + return@fromCallable result.parseChapterList() + } } - } else { - Observable.error(Exception("Licensed - No chapters to show")) + val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute() + ApiV3.parseChapterListV1(response) } } override fun chapterListParse(response: Response): List { - val ret = ArrayList() - val responseBody = response.body!!.string() - if (response.request.url.toString().startsWith(v4apiUrl)) { - val pb = ProtoBuf.decodeFromByteArray(decryptProtobufData(responseBody)) - val mangaPBData = pb.Data - // v4api can contain multiple series of chapters. - if (mangaPBData.Chapters.isEmpty()) { - throw Exception("empty chapter list") - } - mangaPBData.Chapters.forEach { chapterList -> - for (i in chapterList.Data.indices) { - val chapter = chapterList.Data[i] - ret.add( - SChapter.create().apply { - name = "${chapterList.Title}: ${chapter.ChapterTitle}" - date_upload = chapter.Updatetime * 1000 - url = "${mangaPBData.Id}/${chapter.ChapterId}" - } - ) - } - } - } else { - // get chapter info from old api - // Old api may only contain one series of chapters - val obj = JSONObject(responseBody) - val chaptersList = obj.getJSONObject("data").getJSONArray("list") - for (i in 0 until chaptersList.length()) { - val chapter = chaptersList.getJSONObject(i) - ret.add( - SChapter.create().apply { - name = chapter.getString("chapter_name") - date_upload = chapter.getString("updatetime").toLong() * 1000 - url = "${chapter.getString("comic_id")}/${chapter.getString("id")}" - } - ) - } - } - return ret + throw UnsupportedOperationException() } - override fun pageListRequest(chapter: SChapter) = throw UnsupportedOperationException("Not used.") + // for WebView, headers are not needed + override fun pageListRequest(chapter: SChapter) = GET("$baseUrl/view/${chapter.url}.html") override fun fetchPageList(chapter: SChapter): Observable> { - return try { - // webpage api - val response = client.newCall(GET("$webviewPageListApiUrl/${chapter.url}.html", headers)).execute() - Observable.just(pageListParse(response, chapter)) - } catch (e: Exception) { - // api.m.dmzj.com - val response = client.newCall(GET("$oldPageListApiUrl/comic/chapter/${chapter.url}.html", headers)).execute() - Observable.just(pageListParse(response, chapter)) - } catch (e: Exception) { - // v3api - val response = client.newCall( - GET( - customUrlBuilder("$v3ChapterApiUrl/chapter/${chapter.url}.json").build().toString(), - headers - ) - ).execute() - Observable.just(pageListParse(response, chapter)) - } catch (e: Exception) { - Observable.error(e) + val path = chapter.url + return Observable.fromCallable { + val response = retryClient.newCall(GET(ApiV4.chapterImagesUrl(path), headers)).execute() + val result = ApiV4.parseChapterImages(response) + if (preferences.showChapterComments) { + result.add(Page(result.size, COMMENTS_FLAG, ApiV3.chapterCommentsUrl(path))) + } + result } } override fun pageListParse(response: Response): List { - return pageListParse(response, null) - } - - private fun pageListParse(response: Response, chapter: SChapter?): List { - val requestUrl = response.request.url.toString() - val responseBody = response.body!!.string() - val arr = if ( - requestUrl.startsWith(webviewPageListApiUrl) || - requestUrl.startsWith(v3ChapterApiUrl) - ) { - // webpage api or v3api - JSONObject(responseBody).getJSONArray("page_url") - } else if (requestUrl.startsWith(oldPageListApiUrl)) { - try { - val obj = JSONObject(responseBody) - obj.getJSONObject("chapter").getJSONArray("page_url") - } catch (e: org.json.JSONException) { - // JSON data from api.m.dmzj.com may be incomplete, extract page_url list using regex - val extractPageList = extractPageListRegex.find(responseBody)?.value - if (extractPageList != null) { - JSONObject("{$extractPageList}").getJSONArray("page_url") - } else { - // The responseBody content is a sentence, for example, "The comic does not exist". - throw Exception(responseBody) - } - } - } else { - throw Exception("can't parse response") - } - val ret = ArrayList(arr.length()) - for (i in 0 until arr.length()) { - // Seems image urls from webpage api and api.m.dmzj.com may be URL encoded multiple times - val imageUrl = Uri.decode(Uri.decode(arr.getString(i))) - .replace("http:", "https:") - .replace("dmzj1.com", "dmzj.com") - // Use url to store lo-res image url - val url = if (chapter != null && chapter.url != "") { - // imageUrl be like: https://image.dmzj.com/m/manga_name/chapter_name/file_name.jpg - // Path node before manga_name is the initial letter of pinyin of the manga name, - // which is also used for small images. - val imgUrl = imageUrl.toHttpUrlOrNull() - if (imgUrl != null) { - val initial = imgUrl.encodedPath.trim('/').substringBefore('/') - "$imageSmallCDNUrl/$initial/${chapter.url}/$i.jpg" - } else "" - } else "" - ret.add(Page(i, url, imageUrl)) - } - return ret - } - - private fun String.encoded(): String { - return this.chunked(1) - .joinToString("") { if (it in setOf("%", " ", "+", "#")) Uri.encode(it) else it } - .let { if (it.endsWith(".jp")) "${it}g" else it } + throw UnsupportedOperationException() } + // see https://github.com/tachiyomiorg/tachiyomi-extensions/issues/10475 override fun imageRequest(page: Page): Request { - return when (preferences.getString(IMAGE_SOURCE_PREF, "")) { - ImageSource.ORIG_RES_ONLY.name -> GET(page.imageUrl!!.encoded(), headers) - ImageSource.LOW_RES_ONLY.name -> GET(page.url, headers) - else -> GET(page.imageUrl!!.encoded(), headers).newBuilder() - .addHeader(HttpGetFailoverInterceptor.RETRY_WITH_HEADER, page.url) + val url = page.url + val imageUrl = page.imageUrl!! + if (url == COMMENTS_FLAG) { + return GET(imageUrl, headers).newBuilder() + .tag(CommentsInterceptor.Tag::class.java, CommentsInterceptor.Tag()) .build() } + val fallbackUrl = when (preferences.imageQuality) { + AUTO_RES -> url + ORIGINAL_RES -> null + LOW_RES -> return GET(url, headers) + else -> url + } + return GET(imageUrl, headers).newBuilder() + .tag(ImageUrlInterceptor.Tag::class.java, ImageUrlInterceptor.Tag(fallbackUrl)) + .build() } // Unused, we can get image urls directly from the chapter page override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("This method should not be called!") - override fun getFilterList() = FilterList( - SortFilter(), - GenreGroup(), - StatusFilter(), - TypeFilter(), - ReaderFilter() - ) - - private class GenreGroup : UriPartFilter( - "分类", - arrayOf( - Pair("全部", ""), - Pair("冒险", "4"), - Pair("百合", "3243"), - Pair("生活", "3242"), - Pair("四格", "17"), - Pair("伪娘", "3244"), - Pair("悬疑", "3245"), - Pair("后宫", "3249"), - Pair("热血", "3248"), - Pair("耽美", "3246"), - Pair("其他", "16"), - Pair("恐怖", "14"), - Pair("科幻", "7"), - Pair("格斗", "6"), - Pair("欢乐向", "5"), - Pair("爱情", "8"), - Pair("侦探", "9"), - Pair("校园", "13"), - Pair("神鬼", "12"), - Pair("魔法", "11"), - Pair("竞技", "10"), - Pair("历史", "3250"), - Pair("战争", "3251"), - Pair("魔幻", "5806"), - Pair("扶她", "5345"), - Pair("东方", "5077"), - Pair("奇幻", "5848"), - Pair("轻小说", "6316"), - Pair("仙侠", "7900"), - Pair("搞笑", "7568"), - Pair("颜艺", "6437"), - Pair("性转换", "4518"), - Pair("高清单行", "4459"), - Pair("治愈", "3254"), - Pair("宅系", "3253"), - Pair("萌系", "3252"), - Pair("励志", "3255"), - Pair("节操", "6219"), - Pair("职场", "3328"), - Pair("西方魔幻", "3365"), - Pair("音乐舞蹈", "3326"), - Pair("机战", "3325") - ) - ) - - private class StatusFilter : UriPartFilter( - "连载状态", - arrayOf( - Pair("全部", ""), - Pair("连载", "2309"), - Pair("完结", "2310") - ) - ) - - private class TypeFilter : UriPartFilter( - "地区", - arrayOf( - Pair("全部", ""), - Pair("日本", "2304"), - Pair("韩国", "2305"), - Pair("欧美", "2306"), - Pair("港台", "2307"), - Pair("内地", "2308"), - Pair("其他", "8453") - ) - ) - - private class SortFilter : UriPartFilter( - "排序", - arrayOf( - Pair("人气", "0"), - Pair("更新", "1") - ) - ) - - private class ReaderFilter : UriPartFilter( - "读者", - arrayOf( - Pair("全部", ""), - Pair("少年", "3262"), - Pair("少女", "3263"), - Pair("青年", "3264") - ) - ) - - private open class UriPartFilter( - displayName: String, - val vals: Array>, - defaultValue: Int = 0 - ) : - Filter.Select(displayName, vals.map { it.first }.toTypedArray(), defaultValue) { - open fun toUriPart() = vals[state].second - } + override fun getFilterList() = getFilterListInternal() override fun setupPreferenceScreen(screen: PreferenceScreen) { - val apiRateLimitPreference = ListPreference(screen.context).apply { - key = API_RATELIMIT_PREF - title = API_RATELIMIT_PREF_TITLE - summary = API_RATELIMIT_PREF_SUMMARY - entries = ENTRIES_ARRAY - entryValues = ENTRIES_ARRAY - - setDefaultValue("5") - setOnPreferenceChangeListener(onStringPreferenceChangeListener(API_RATELIMIT_PREF)) - } - - val imgCDNRateLimitPreference = ListPreference(screen.context).apply { - key = IMAGE_CDN_RATELIMIT_PREF - title = IMAGE_CDN_RATELIMIT_PREF_TITLE - summary = IMAGE_CDN_RATELIMIT_PREF_SUMMARY - entries = ENTRIES_ARRAY - entryValues = ENTRIES_ARRAY - - setDefaultValue("5") - setOnPreferenceChangeListener(onStringPreferenceChangeListener(IMAGE_CDN_RATELIMIT_PREF)) - } - - val imgSourcePreference = ListPreference(screen.context).apply { - key = IMAGE_SOURCE_PREF - title = IMAGE_SOURCE_PREF_TITLE - summary = IMAGE_SOURCE_PREF_SUMMARY - entries = enumValues().map { "${it.desc} (${it.name})" }.toTypedArray() - entryValues = enumValues().map { it.name }.toTypedArray() - - setDefaultValue(ImageSource.PREFER_ORIG_RES.name) - setOnPreferenceChangeListener(onStringPreferenceChangeListener(IMAGE_SOURCE_PREF)) - } - - screen.addPreference(apiRateLimitPreference) - screen.addPreference(imgCDNRateLimitPreference) - screen.addPreference(imgSourcePreference) - } - - private fun onStringPreferenceChangeListener(key: String): Preference.OnPreferenceChangeListener { - return Preference.OnPreferenceChangeListener { _, newValue -> - try { - val setting = preferences.edit().putString(key, newValue as String).commit() - setting - } catch (e: Exception) { - e.printStackTrace() - false - } - } - } - - private enum class ImageSource(val desc: String) { - PREFER_ORIG_RES("优先标清"), // "Prefer Original Resolution" - ORIG_RES_ONLY("只用标清"), // "Original Resolution Only" - LOW_RES_ONLY("只用低清"), // "Low Resolution Only" - } - - companion object { - private const val API_RATELIMIT_PREF = "apiRatelimitPreference" - private const val API_RATELIMIT_PREF_TITLE = "主站每秒连接数限制" // "Ratelimit permits per second for main website" - private const val API_RATELIMIT_PREF_SUMMARY = "此值影响向动漫之家网站发起连接请求的数量。调低此值可能减少发生HTTP 429(连接请求过多)错误的几率,但加载速度也会变慢。需要重启软件以生效。\n当前值:%s" // "This value affects network request amount to dmzj's url. Lower this value may reduce the chance to get HTTP 429 error, but loading speed will be slower too. Tachiyomi restart required. Current value: %s" - - private const val IMAGE_CDN_RATELIMIT_PREF = "imgCDNRatelimitPreference" - private const val IMAGE_CDN_RATELIMIT_PREF_TITLE = "图片CDN每秒连接数限制" // "Ratelimit permits per second for image CDN" - private const val IMAGE_CDN_RATELIMIT_PREF_SUMMARY = "此值影响加载图片时发起连接请求的数量。调低此值可能减小图片加载错误的几率,但加载速度也会变慢。需要重启软件以生效。\n当前值:%s" // "This value affects network request amount for loading image. Lower this value may reduce the chance to get error when loading image, but loading speed will be slower too. Tachiyomi restart required. Current value: %s" - - private const val IMAGE_SOURCE_PREF = "imageSourcePreference" - private const val IMAGE_SOURCE_PREF_TITLE = "图源偏好" // "Image source preference" - private const val IMAGE_SOURCE_PREF_SUMMARY = "此值影响图片的加载来源。可以选择只用标清图源,只用低清图源,或优先尝试标清图源再回退到低清图源。部分漫画章节可能只能在低清图源下观看。不需要重启软件。\n当前值:%s" // "This value affects image load source. You can choose to use original resolution image source only, or use low resolution image source only, or try original resolution image source before fallback to low resolution image source. Some manga chapters may only be available from low resolution image source. Tachiyomi restart not required. Current value: %s" - - private val extractComicIdFromWebpageRegex = Regex("""addSubscribe\((\d+)\)""") - private val checkComicIdIsNumericalRegex = Regex("""^\d+$""") - private val extractComicIdFromMangaUrlRegex = Regex("""(\d+)\.(json|html)""") // Get comic ID from manga.url - private val extractPageListRegex = Regex(""""page_url".+?]""") - - private val ENTRIES_ARRAY = (1..10).map { i -> i.toString() }.toTypedArray() - const val PREFIX_ID_SEARCH = "id:" - - private const val privateKey = - "MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAK8nNR1lTnIfIes6oRWJNj3mB6OssDGx0uGMpgpbVCpf6+VwnuI2stmhZNoQcM417Iz7WqlPzbUmu9R4dEKmLGEEqOhOdVaeh9Xk2IPPjqIu5TbkLZRxkY3dJM1htbz57d/roesJLkZXqssfG5EJauNc+RcABTfLb4IiFjSMlTsnAgMBAAECgYEAiz/pi2hKOJKlvcTL4jpHJGjn8+lL3wZX+LeAHkXDoTjHa47g0knYYQteCbv+YwMeAGupBWiLy5RyyhXFoGNKbbnvftMYK56hH+iqxjtDLnjSDKWnhcB7089sNKaEM9Ilil6uxWMrMMBH9v2PLdYsqMBHqPutKu/SigeGPeiB7VECQQDizVlNv67go99QAIv2n/ga4e0wLizVuaNBXE88AdOnaZ0LOTeniVEqvPtgUk63zbjl0P/pzQzyjitwe6HoCAIpAkEAxbOtnCm1uKEp5HsNaXEJTwE7WQf7PrLD4+BpGtNKkgja6f6F4ld4QZ2TQ6qvsCizSGJrjOpNdjVGJ7bgYMcczwJBALvJWPLmDi7ToFfGTB0EsNHZVKE66kZ/8Stx+ezueke4S556XplqOflQBjbnj2PigwBN/0afT+QZUOBOjWzoDJkCQClzo+oDQMvGVs9GEajS/32mJ3hiWQZrWvEzgzYRqSf3XVcEe7PaXSd8z3y3lACeeACsShqQoc8wGlaHXIJOHTcCQQCZw5127ZGs8ZDTSrogrH73Kw/HvX55wGAeirKYcv28eauveCG7iyFR0PFB/P/EDZnyb+ifvyEFlucPUI0+Y87F" + getPreferencesInternal(screen.context, preferences).forEach(screen::addPreference) } } diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/DmzjUrlActivity.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/DmzjUrlActivity.kt index fe988945e..d0fed1ccb 100644 --- a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/DmzjUrlActivity.kt +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/DmzjUrlActivity.kt @@ -25,7 +25,7 @@ class DmzjUrlActivity : Activity() { } val mainIntent = Intent().apply { action = "eu.kanade.tachiyomi.SEARCH" - putExtra("query", "${Dmzj.PREFIX_ID_SEARCH}$titleId") + putExtra("query", "$PREFIX_ID_SEARCH$titleId") putExtra("filter", packageName) } diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Filters.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Filters.kt new file mode 100644 index 000000000..e18eaae56 --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Filters.kt @@ -0,0 +1,199 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +fun getFilterListInternal() = FilterList( + RankingGroup(), + Filter.Separator(), + Filter.Header("分类筛选(查看排行榜、搜索文本时无效)"), + GenreGroup(), + StatusFilter(), + ReaderFilter(), + RegionFilter(), + SortFilter(), +) + +// region Ranking filters + +class RankingGroup : Filter.Group>( + "排行榜(搜索文本时无效)", + listOf>( + EnabledFilter(), + TimeFilter(), + SortFilter(), + GenreFilter(), + ) +) { + val isEnabled get() = (state[0] as EnabledFilter).state + + fun parse() = state.filterIsInstance().joinToString("&") { it.uriPart } + + private class EnabledFilter : CheckBox("查看排行榜") + + private class TimeFilter : QueryFilter( + "榜单", + "by_time", + arrayOf( + Pair("日排行", "0"), + Pair("周排行", "1"), + Pair("月排行", "2"), + Pair("总排行", "3"), + ) + ) + + private class SortFilter : QueryFilter( + "排序", + "rank_type", + arrayOf( + Pair("人气", "0"), + Pair("吐槽", "1"), + Pair("订阅", "2"), + ) + ) + + private class GenreFilter : QueryFilter("题材(慎用/易出错)", "tag_id", genres) + + private open class QueryFilter( + name: String, + private val query: String, + values: Array>, + ) : SelectFilter(name, values) { + override val uriPart get() = query + '=' + super.uriPart + } +} + +// endregion + +// region Normal filters + +fun parseFilters(filters: FilterList): String { + val tags = filters.filterIsInstance().mapNotNull { + it.uriPart.takeUnless(String::isEmpty) + }.joinToString("-").ifEmpty { "0" } + val sort = filters.filterIsInstance().firstOrNull()?.uriPart ?: "0" + return "$tags/$sort" +} + +private interface TagFilter : UriPartFilter + +private class GenreGroup : TagFilter, Filter.Group( + "题材(作品需包含勾选的所有项目)", + genres.drop(1).map { GenreFilter(it.first, it.second) } +) { + override val uriPart get() = state.filter { it.state }.joinToString("-") { it.value } +} + +private class GenreFilter(name: String, val value: String) : Filter.CheckBox(name) + +private class StatusFilter : TagFilter, SelectFilter( + "状态", + arrayOf( + Pair("全部", ""), + Pair("连载中", "2309"), + Pair("已完结", "2310"), + ) +) + +private class ReaderFilter : TagFilter, SelectFilter( + "受众", + arrayOf( + Pair("全部", ""), + Pair("少年漫画", "3262"), + Pair("少女漫画", "3263"), + Pair("青年漫画", "3264"), + Pair("女青漫画", "13626"), + ) +) + +private class RegionFilter : TagFilter, SelectFilter( + "地域", + arrayOf( + Pair("全部", ""), + Pair("日本", "2304"), + Pair("韩国", "2305"), + Pair("欧美", "2306"), + Pair("港台", "2307"), + Pair("内地", "2308"), + Pair("其他", "8453"), + ) +) + +private class SortFilter : SelectFilter( + "排序", + arrayOf( + Pair("人气", "0"), + Pair("更新", "1"), + ) +) + +// endregion + +private val genres + get() = arrayOf( + Pair("全部", ""), + Pair("冒险", "4"), + Pair("欢乐向", "5"), + Pair("格斗", "6"), + Pair("科幻", "7"), + Pair("爱情", "8"), + Pair("侦探", "9"), + Pair("竞技", "10"), + Pair("魔法", "11"), + Pair("神鬼", "12"), + Pair("校园", "13"), + Pair("惊悚", "14"), + Pair("其他", "16"), + Pair("四格", "17"), + Pair("生活", "3242"), + Pair("ゆり", "3243"), + Pair("秀吉", "3244"), + Pair("悬疑", "3245"), + Pair("纯爱", "3246"), + Pair("热血", "3248"), + Pair("泛爱", "3249"), + Pair("历史", "3250"), + Pair("战争", "3251"), + Pair("萌系", "3252"), + Pair("宅系", "3253"), + Pair("治愈", "3254"), + Pair("励志", "3255"), + Pair("武侠", "3324"), + Pair("机战", "3325"), + Pair("音乐舞蹈", "3326"), + Pair("美食", "3327"), + Pair("职场", "3328"), + Pair("西方魔幻", "3365"), + Pair("高清单行", "4459"), + Pair("TS", "4518"), + Pair("东方", "5077"), + Pair("魔幻", "5806"), + Pair("奇幻", "5848"), + Pair("节操", "6219"), + Pair("轻小说", "6316"), + Pair("颜艺", "6437"), + Pair("搞笑", "7568"), + Pair("仙侠", "7900"), + Pair("舰娘", "13627"), + Pair("动画", "17192"), + Pair("AA", "18522"), + Pair("福瑞", "23323"), + Pair("生存", "23388"), + Pair("2021大赛", "23399"), + Pair("未来漫画家", "25011"), + ) + +interface UriPartFilter { + val uriPart: String +} + +private open class SelectFilter( + name: String, + values: Array>, +) : UriPartFilter, Filter.Select( + name = name, + values = Array(values.size) { values[it].first }, +) { + private val uriParts = Array(values.size) { values[it].second } + override val uriPart get() = uriParts[state] +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ImageUrlInterceptor.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ImageUrlInterceptor.kt new file mode 100644 index 000000000..1cc8c5c06 --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/ImageUrlInterceptor.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +object ImageUrlInterceptor : Interceptor { + + class Tag(val url: String?) + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val tag = request.tag(Tag::class.java) ?: return chain.proceed(request) + + try { + val response = chain.proceed(request) + if (response.isSuccessful) return response + response.close() + Log.e("DMZJ", "failed to fetch '${request.url}': HTTP ${response.code}") + } catch (e: IOException) { + Log.e("DMZJ", "failed to fetch '${request.url}'", e) + } + + // this can sometimes bypass encoding issues by decoding '+' to ' ' + val decodedUrl = request.url.toString().decodePath() + val newRequest = request.newBuilder().url(decodedUrl).build() + try { + val response = chain.proceed(newRequest) + if (response.isSuccessful) return response + response.close() + Log.e("DMZJ", "failed to fetch '$decodedUrl': HTTP ${response.code}") + } catch (e: IOException) { + Log.e("DMZJ", "failed to fetch '$decodedUrl'", e) + } + + val url = tag.url ?: throw IOException() + val fallbackRequest = request.newBuilder().url(url).build() + return chain.proceed(fallbackRequest) + } +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Preferences.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Preferences.kt new file mode 100644 index 000000000..7c9b0f3f4 --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Preferences.kt @@ -0,0 +1,87 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.SwitchPreferenceCompat + +// Legacy preferences: +// "apiRatelimitPreference" -> 1..10 default "5" +// "imgCDNRatelimitPreference" -> 1..10 default "5" + +fun getPreferencesInternal(context: Context, preferences: SharedPreferences) = arrayOf( + + ListPreference(context).apply { + key = IMAGE_QUALITY_PREF + title = "图片质量" + summary = "%s\n如果选择“只用原图”可能会有部分图片无法加载。" + entries = arrayOf("优先原图", "只用原图", "只用低清") + entryValues = arrayOf(AUTO_RES, ORIGINAL_RES, LOW_RES) + setDefaultValue(AUTO_RES) + }, + + SwitchPreferenceCompat(context).apply { + key = CHAPTER_COMMENTS_PREF + title = "章末吐槽页" + summary = "修改后,已加载的章节需要清除章节缓存才能生效。" + setDefaultValue(false) + }, + + MultiSelectListPreference(context).setupIdList( + LICENSED_LIST_PREF, + "特殊漫画 ID 列表 (1)", + preferences.licensedList.toTypedArray(), + ), + + MultiSelectListPreference(context).setupIdList( + HIDDEN_LIST_PREF, + "特殊漫画 ID 列表 (2)", + preferences.hiddenList.toTypedArray(), + ), +) + +val SharedPreferences.imageQuality get() = getString(IMAGE_QUALITY_PREF, AUTO_RES)!! + +val SharedPreferences.showChapterComments get() = getBoolean(CHAPTER_COMMENTS_PREF, false) + +val SharedPreferences.licensedList: Set get() = getStringSet(LICENSED_LIST_PREF, emptySet())!! +val SharedPreferences.hiddenList: Set get() = getStringSet(HIDDEN_LIST_PREF, emptySet())!! + +fun SharedPreferences.addLicensed(id: String) = addToSet(LICENSED_LIST_PREF, id, licensedList) +fun SharedPreferences.addHidden(id: String) = addToSet(HIDDEN_LIST_PREF, id, hiddenList) + +private fun MultiSelectListPreference.setupIdList( + key: String, + title: String, + values: Array, +): MultiSelectListPreference { + this.key = key + this.title = title + summary = "如果漫画网页版可以正常访问,但是应用内章节目录加载异常,可以点开列表删除记录。" + + "删除方法是【取消勾选】要删除的 ID 再点击确定,勾选的项目会保留。" + + "如果点开为空,就表示没有记录。刷新漫画页并展开简介即可查看 ID。" + entries = values + entryValues = values + setDefaultValue(emptySet()) + return this +} + +@Synchronized +private fun SharedPreferences.addToSet(key: String, id: String, oldSet: Set) { + if (id in oldSet) return + val newSet = HashSet((oldSet.size + 1) * 2) + newSet.addAll(oldSet) + newSet.add(id) + edit().putStringSet(key, newSet).apply() +} + +private const val IMAGE_QUALITY_PREF = "imageSourcePreference" +const val AUTO_RES = "PREFER_ORIG_RES" +const val ORIGINAL_RES = "ORIG_RES_ONLY" +const val LOW_RES = "LOW_RES_ONLY" + +private const val CHAPTER_COMMENTS_PREF = "chapterComments" + +private const val LICENSED_LIST_PREF = "licensedList" +private const val HIDDEN_LIST_PREF = "hiddenList" diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/RetryInterceptor.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/RetryInterceptor.kt new file mode 100644 index 000000000..f6e45754a --- /dev/null +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/RetryInterceptor.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.zh.dmzj + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Response + +object RetryInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + repeat(2) { + val response = chain.proceed(request) + if (response.isSuccessful) return response + response.close() + Log.e("DMZJ", "failed to fetch '${request.url}': HTTP ${response.code}") + } + return chain.proceed(request) + } +} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/protobuf/V4apiComicDetailResponse.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/protobuf/V4apiComicDetailResponse.kt deleted file mode 100644 index cbe0604ad..000000000 --- a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/protobuf/V4apiComicDetailResponse.kt +++ /dev/null @@ -1,66 +0,0 @@ -package eu.kanade.tachiyomi.extension.zh.dmzj.protobuf - -/* - * Created by reference to https://github.com/xiaoyaocz/dmzj_flutter/blob/23b04c2af930cb7c18a74665e8ec0bf1ccc6f09b/lib/protobuf/comic/detail_response.proto - * All credit goes to their outstanding work. - */ - -import kotlinx.serialization.Serializable -import kotlinx.serialization.protobuf.ProtoNumber - -@Serializable -data class ComicDetailResponse( - @ProtoNumber(1) val Errno: Int = 0, - @ProtoNumber(2) val Errmsg: String = "", - @ProtoNumber(3) val Data: ComicDetailInfoResponse, -) - -@Serializable -data class ComicDetailInfoResponse( - @ProtoNumber(1) val Id: Int, - @ProtoNumber(2) val Title: String, - @ProtoNumber(3) val Direction: Int? = null, - @ProtoNumber(4) val Islong: Int? = null, - @ProtoNumber(5) val IsDmzj: Int? = null, - @ProtoNumber(6) val Cover: String, - @ProtoNumber(7) val Description: String, - @ProtoNumber(8) val LastUpdatetime: Long? = null, - @ProtoNumber(9) val LastUpdateChapterName: String? = null, - @ProtoNumber(10) val Copyright: Int? = null, - @ProtoNumber(11) val FirstLetter: String? = null, - @ProtoNumber(12) val ComicPy: String? = null, - @ProtoNumber(13) val Hidden: Int? = null, - @ProtoNumber(14) val HotNum: Int? = null, - @ProtoNumber(15) val HitNum: Int? = null, - @ProtoNumber(16) val Uid: Int? = null, - @ProtoNumber(17) val IsLock: Int? = null, - @ProtoNumber(18) val LastUpdateChapterId: Int? = null, - @ProtoNumber(19) val TypesTypes: List = emptyList(), - @ProtoNumber(20) val Status: List = emptyList(), - @ProtoNumber(21) val Authors: List = emptyList(), - @ProtoNumber(22) val SubscribeNum: Int? = null, - @ProtoNumber(23) val Chapters: List = emptyList(), - @ProtoNumber(24) val IsNeedLogin: Int? = null, - @ProtoNumber(26) val IsHideChapter: Int? = null, -) - -@Serializable -data class ComicDetailTypeItemResponse( - @ProtoNumber(1) val TagId: Int, - @ProtoNumber(2) val TagName: String, -) - -@Serializable -data class ComicDetailChapterResponse( - @ProtoNumber(1) val Title: String, - @ProtoNumber(2) val Data: List = emptyList(), -) - -@Serializable -data class ComicDetailChapterInfoResponse( - @ProtoNumber(1) val ChapterId: Int, - @ProtoNumber(2) val ChapterTitle: String, - @ProtoNumber(3) val Updatetime: Long, - @ProtoNumber(4) val Filesize: Int = 0, - @ProtoNumber(5) val ChapterOrder: Int = 0, -) diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/HttpGetFailoverInterceptor.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/HttpGetFailoverInterceptor.kt deleted file mode 100644 index 773332267..000000000 --- a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/HttpGetFailoverInterceptor.kt +++ /dev/null @@ -1,55 +0,0 @@ -package eu.kanade.tachiyomi.extension.zh.dmzj.utils - -import android.util.Log -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.Interceptor -import okhttp3.Response - -/** - * An OkHttp interceptor that will switch to a failover address and retry when an HTTP GET request - * failed. - * - * Because failover addresses are provided per request, we use request headers to pass such info to - * the interceptor. Headers used for indicating failover addresses will be deleted before the request - * starts. - */ -class HttpGetFailoverInterceptor : Interceptor { - companion object { - const val RETRY_WITH_HEADER = "x-tachiyomi-retry-with" - - private const val LOG_TAG = "extension.zh.dmzj.utils" - } - - override fun intercept(chain: Interceptor.Chain): Response { - var request = chain.request() - if (request.method != "GET") { - return chain.proceed(request) - } - - val retries = request.headers(RETRY_WITH_HEADER).mapNotNull { it.toHttpUrlOrNull() }.toList() - if (retries.isNotEmpty()) { - request = request.newBuilder().removeHeader(RETRY_WITH_HEADER).build() - } - - for (retry in retries) { - var response: Response? = null - try { - Log.d(LOG_TAG, "[HttpGetFailoverInterceptor] try for ${request.url}") - response = chain.proceed(request) - if (response.code < 400) { - return response - } - Log.d(LOG_TAG, "[HttpGetFailoverInterceptor] failed with http status ${response.code}, next: $retry") - } catch (e: Exception) { - Log.d(LOG_TAG, "[HttpGetFailoverInterceptor] failed with exception, next: $retry", e) - } - try { - response?.close() - } catch (_: Exception) { - // Ignore exceptions - } - request = request.newBuilder().url(retry).build() - } - return chain.proceed(request) - } -} diff --git a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/RSA.kt b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/RSA.kt index 4d12d1326..4ec3595f7 100644 --- a/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/RSA.kt +++ b/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/utils/RSA.kt @@ -1,43 +1,42 @@ package eu.kanade.tachiyomi.extension.zh.dmzj.utils import android.util.Base64 -import java.io.ByteArrayOutputStream import java.security.KeyFactory +import java.security.PrivateKey import java.security.spec.PKCS8EncodedKeySpec import javax.crypto.Cipher +import kotlin.math.min object RSA { + private val cipher by lazy(LazyThreadSafetyMode.NONE) { + Cipher.getInstance("RSA/ECB/PKCS1Padding") + } + private const val MAX_DECRYPT_BLOCK = 128 - fun decrypt(encryptedData: ByteArray, privateKey: String): ByteArray { + fun getPrivateKey(privateKey: String): PrivateKey { val keyBytes = Base64.decode(privateKey, Base64.DEFAULT) val pkcs8KeySpec = PKCS8EncodedKeySpec(keyBytes) val keyFactory = KeyFactory.getInstance("RSA") val privateK = keyFactory.generatePrivate(pkcs8KeySpec) - val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") - cipher.init(Cipher.DECRYPT_MODE, privateK) - return doFinal(encryptedData, cipher) + return privateK } - private fun doFinal(encryptedData: ByteArray, cipher: Cipher): ByteArray { + @Synchronized // because Cipher is not thread-safe + fun decrypt(encrypted: String, key: PrivateKey): ByteArray { + val cipher = this.cipher + cipher.init(Cipher.DECRYPT_MODE, key) // always reset in case of illegal state + val encryptedData = Base64.decode(encrypted, Base64.DEFAULT) val inputLen = encryptedData.size - ByteArrayOutputStream().use { out -> - var offSet = 0 - var cache: ByteArray - var i = 0 - val block = MAX_DECRYPT_BLOCK - while (inputLen - offSet > 0) { - cache = if (inputLen - offSet > block) { - cipher.doFinal(encryptedData, offSet, block) - } else { - cipher.doFinal(encryptedData, offSet, inputLen - offSet) - } - out.write(cache, 0, cache.size) - i++ - offSet = i * block - } - return out.toByteArray() + val result = ByteArray(inputLen) + var resultSize = 0 + + for (offset in 0 until inputLen step MAX_DECRYPT_BLOCK) { + val blockLen = min(MAX_DECRYPT_BLOCK, inputLen - offset) + resultSize += cipher.doFinal(encryptedData, offset, blockLen, result, resultSize) } + + return result.copyOf(resultSize) } }