DMZJ: rewrite, add rankings and comments (#13689)

* DMZJ: rewrite, add rankings and comments

* update launcher icons

* fix ranking issue

* add more description

* add more description

* add more description

* add prompt if comment is empty
This commit is contained in:
stevenyomi 2022-10-03 19:48:28 +08:00 committed by GitHub
parent 123db0a17a
commit 3a13bc8408
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1076 additions and 679 deletions

138
src/zh/dmzj/API.md Normal file
View File

@ -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<T>(
@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<String>,
@ProtoNumber(7) val pageCount: Int?,
@ProtoNumber(8) val images: List<String>,
@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")
```

View File

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -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<MangaDto> = 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
}
}
}

View File

@ -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<MangaDto> = 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<SChapter> {
return parseMangaInfoV1(response).data.list.map { it.toSChapter() }
}
fun chapterCommentsUrl(path: String) = "$v3apiUrl/viewPoint/0/$path.json"
fun parseChapterComments(response: Response): List<String> {
val result: List<ChapterCommentDto> = response.parseAs()
(result as MutableList<ChapterCommentDto>).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<ChapterCommentDto> {
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<ChapterDto>)
@Serializable
class ResponseDto(val data: DataDto)
}

View File

@ -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<MangaDto> = 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<Page> {
val result: ResponseDto<ChapterImagesDto> = 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<List<RankingItemDto>> = response.decrypt()
val data = result.data ?: return MangasPage(emptyList(), false)
return MangasPage(data.map { it.toSManga() }, data.isNotEmpty())
}
private inline fun <reified T> Response.decrypt(): T = decrypt(typeOf<T>())
@Suppress("UNCHECKED_CAST")
private fun <T> Response.decrypt(type: KType): T {
val bytes = RSA.decrypt(body!!.string(), cipher)
val deserializer = serializer(type) as KSerializer<T>
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<TagDto>,
@ProtoNumber(20) private val status: List<TagDto>,
@ProtoNumber(21) private val authors: List<TagDto>,
@ProtoNumber(23) private val chapterGroups: List<ChapterGroupDto>,
) {
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<SChapter> {
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<ChapterDto>,
) {
fun toSChapterList(mangaId: String): List<SChapter> {
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<String>,
@ProtoNumber(8) private val images: List<String>,
) {
// page count can be messy, see manga ID 55847 chapters 107-109
fun toPageList(): ArrayList<Page> {
val pageCount = max(images.size, lowResImages.size)
val list = ArrayList<Page>(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<T>(
@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") }
}

View File

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

View File

@ -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 <reified T> 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"

View File

@ -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<Application>().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<SManga>(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<SManga>(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<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
return if (query.isEmpty()) {
val ranking = filters.filterIsInstance<RankingGroup>().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<SortFilter>().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<SManga> {
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<ComicDetailResponse>(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<List<SChapter>> {
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<SChapter> {
val ret = ArrayList<SChapter>()
val responseBody = response.body!!.string()
if (response.request.url.toString().startsWith(v4apiUrl)) {
val pb = ProtoBuf.decodeFromByteArray<ComicDetailResponse>(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<List<Page>> {
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<Page> {
return pageListParse(response, null)
}
private fun pageListParse(response: Response, chapter: SChapter?): List<Page> {
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<Page>(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<Pair<String, String>>,
defaultValue: Int = 0
) :
Filter.Select<String>(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<ImageSource>().map { "${it.desc} (${it.name})" }.toTypedArray()
entryValues = enumValues<ImageSource>().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)
}
}

View File

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

View File

@ -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<Filter<*>>(
"排行榜(搜索文本时无效)",
listOf<Filter<*>>(
EnabledFilter(),
TimeFilter(),
SortFilter(),
GenreFilter(),
)
) {
val isEnabled get() = (state[0] as EnabledFilter).state
fun parse() = state.filterIsInstance<QueryFilter>().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<Pair<String, String>>,
) : SelectFilter(name, values) {
override val uriPart get() = query + '=' + super.uriPart
}
}
// endregion
// region Normal filters
fun parseFilters(filters: FilterList): String {
val tags = filters.filterIsInstance<TagFilter>().mapNotNull {
it.uriPart.takeUnless(String::isEmpty)
}.joinToString("-").ifEmpty { "0" }
val sort = filters.filterIsInstance<SortFilter>().firstOrNull()?.uriPart ?: "0"
return "$tags/$sort"
}
private interface TagFilter : UriPartFilter
private class GenreGroup : TagFilter, Filter.Group<GenreFilter>(
"题材(作品需包含勾选的所有项目)",
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<Pair<String, String>>,
) : UriPartFilter, Filter.Select<String>(
name = name,
values = Array(values.size) { values[it].first },
) {
private val uriParts = Array(values.size) { values[it].second }
override val uriPart get() = uriParts[state]
}

View File

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

View File

@ -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<String> get() = getStringSet(LICENSED_LIST_PREF, emptySet())!!
val SharedPreferences.hiddenList: Set<String> 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<String>,
): MultiSelectListPreference {
this.key = key
this.title = title
summary = "如果漫画网页版可以正常访问,但是应用内章节目录加载异常,可以点开列表删除记录。" +
"删除方法是【取消勾选】要删除的 ID 再点击确定,勾选的项目会保留。" +
"如果点开为空,就表示没有记录。刷新漫画页并展开简介即可查看 ID。"
entries = values
entryValues = values
setDefaultValue(emptySet<Nothing>())
return this
}
@Synchronized
private fun SharedPreferences.addToSet(key: String, id: String, oldSet: Set<String>) {
if (id in oldSet) return
val newSet = HashSet<String>((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"

View File

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

View File

@ -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<ComicDetailTypeItemResponse> = emptyList(),
@ProtoNumber(20) val Status: List<ComicDetailTypeItemResponse> = emptyList(),
@ProtoNumber(21) val Authors: List<ComicDetailTypeItemResponse> = emptyList(),
@ProtoNumber(22) val SubscribeNum: Int? = null,
@ProtoNumber(23) val Chapters: List<ComicDetailChapterResponse> = 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<ComicDetailChapterInfoResponse> = 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,
)

View File

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

View File

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