Remove DMZJ (#10248)

This commit is contained in:
stevenyomi 2025-08-24 09:52:01 +00:00 committed by Draff
parent 70df8cbfa9
commit 8acd1707ae
Signed by: Draff
GPG Key ID: E8A89F3211677653
20 changed files with 0 additions and 1322 deletions

View File

@ -1,140 +0,0 @@
# 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,
// initial letter is sometimes different from that in original URLs, see manga ID 56649
@ProtoNumber(6) val lowResImages: List<String>,
// page count of low-res images
@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

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".zh.dmzj.DmzjUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="m.dmzj.com"
android:pathPattern="/info/..*"
android:scheme="https" />
<data
android:host="www.dmzj.com"
android:pathPattern="/info/..*"
android:scheme="https" />
<data
android:host="manhua.dmzj.com"
android:pathPattern="/..*"
android:scheme="https" />
<data
android:host="m.muwai.com"
android:pathPattern="/info/..*"
android:scheme="https" />
<data
android:host="www.muwai.com"
android:pathPattern="/info/..*"
android:scheme="https" />
<data
android:host="manhua.muwai.com"
android:pathPattern="/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,7 +0,0 @@
ext {
extName = 'DMZJ'
extClass = '.Dmzj'
extVersionCode = 46
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,64 +0,0 @@
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 okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
object ApiSearch {
fun searchUrlV1(page: Int, query: String) =
"https://manhua.idmzj.com/api/v1/comic2/search".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("size", "30")
.addQueryParameter("keyword", query)
.toString()
fun parsePageV1(response: Response): MangasPage {
if (!response.isSuccessful) {
response.close()
return MangasPage(emptyList(), false)
}
val result = response.parseAs<ResponseDto<SearchResultDto?>>()
if (result.errmsg.isNotBlank()) {
throw Exception(result.errmsg)
} else {
val url = response.request.url
val page = url.queryParameter("page")?.toInt()
val size = url.queryParameter("size")?.toInt()
return if (result.data != null) {
MangasPage(result.data.comicList.map { it.toSManga() }, page!! * size!! < result.data.totalNum)
} else {
MangasPage(emptyList(), false)
}
}
}
@Serializable
class MangaDtoV1(
private val id: Int,
private val name: String,
private val authors: String,
private val cover: String,
) {
fun toSManga() = SManga.create().apply {
url = getMangaUrl(id.toString())
title = name
author = authors
thumbnail_url = cover
}
}
@Serializable
class SearchResultDto(
val comicList: List<MangaDtoV1>,
val totalNum: Int,
)
@Serializable
class ResponseDto<T>(
val errmsg: String = "",
val data: T,
)
}

View File

@ -1,135 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj
import android.content.SharedPreferences
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
import org.jsoup.parser.Parser
object ApiV3 {
lateinit var preferences: SharedPreferences
private val v3apiUrl: String
get() = if (preferences.isOlderV3API == true) {
"https://v3api.idmzj.com"
} else {
"https://nnv3api.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 = try {
response.parseAs()
} catch (_: Throwable) {
throw Exception("获取漫画信息失败")
}
fun parseMangaDetailsV1(response: Response): SManga {
return parseMangaInfoV1(response).data.info.toSManga()
}
fun parseChapterListV1(response: Response): List<SChapter> {
val data = parseMangaInfoV1(response).data
return buildList(data.list.size + data.alone.size) {
data.list.mapTo(this) {
it.toSChapter()
}
data.alone.mapTo(this) {
it.toSChapter().apply { name = "单行本: $name" }
}
}
}
fun chapterImagesUrlV1(path: String) = "https://m.idmzj.com/chapinfo/$path.html"
fun parseChapterImagesV1(response: Response) =
response.parseAs<ChapterImagesDto>().toPageList()
fun chapterCommentsUrl(path: String) = "$v3apiUrl/viewPoint/0/$path.json"
fun parseChapterComments(response: Response, count: Int): List<String> {
val result: List<ChapterCommentDto> = response.parseAs()
if (result.isEmpty()) return listOf("没有吐槽")
val aggregated = result.groupBy({ it.content }, { it.num }).map { (content, likes) ->
ChapterCommentDto(Parser.unescapeEntities(content, false), likes.sum())
} as ArrayList
aggregated.sort()
return aggregated.take(count).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 ChapterImagesDto(
private val page_url: List<String>,
) {
fun toPageList() = parsePageList(page_url)
}
@Serializable
class ChapterCommentDto(
val content: String,
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>, val alone: List<ChapterDto>)
@Serializable
class ResponseDto(val data: DataDto)
}

View File

@ -1,174 +0,0 @@
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.Response
import kotlin.reflect.KType
import kotlin.reflect.typeOf
object ApiV4 {
private const val v4apiUrl = "https://nnv4api.dmzj.com"
fun mangaInfoUrl(id: String) = "$v4apiUrl/comic/detail/$id?uid=2665531"
fun parseMangaInfo(response: Response): MangaDto? {
val result: ResponseDto<MangaDto> = response.decrypt()
return result.data
}
// path = "mangaId/chapterId"
fun chapterImagesUrl(path: String) = "$v4apiUrl/comic/chapter/$path"
fun parseChapterImages(response: Response, isLowRes: Boolean): ArrayList<Page> {
val result: ResponseDto<ChapterImagesDto> = response.decrypt()
return result.data!!.toPageList(isLowRes)
}
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(6) private val lowResImages: List<String>,
@ProtoNumber(8) private val images: List<String>,
) {
fun toPageList(isLowRes: Boolean) =
// page count can be messy, see manga ID 55847 chapters 107-109
if (images.size == lowResImages.size) {
parsePageList(images, lowResImages)
} else if (isLowRes) {
parsePageList(lowResImages, lowResImages)
} else {
parsePageList(images)
}
}
// 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?,
)
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

@ -1,69 +0,0 @@
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) == null) return response
val comments = ApiV3.parseChapterComments(response, MAX_HEIGHT / (UNIT * 2))
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

@ -1,57 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj
import eu.kanade.tachiyomi.source.model.Page
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
}
private val chapterNameRegex = Regex("""(?:连载版?)?(\d[.\d]*)([话卷])?""")
fun String.formatChapterName(): String {
val match = chapterNameRegex.matchEntire(this) ?: return this
val (number, optionalType) = match.destructured
val type = optionalType.ifEmpty { "" }
return "$number$type"
}
fun parsePageList(
images: List<String>,
lowResImages: List<String> = List(images.size) { "" },
): ArrayList<Page> {
val pageCount = images.size
val list = ArrayList<Page>(pageCount + 1) // for comments page
for (i in 0 until pageCount) {
list.add(Page(i, lowResImages[i], images[i]))
}
return list
}
fun String.decodePath(): String = URLDecoder.decode(this, "UTF-8")
const val COMMENTS_FLAG = "COMMENTS"

View File

@ -1,224 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
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 keiyoushi.utils.getPreferences
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
/**
* Dmzj source
*/
class Dmzj : ConfigurableSource, HttpSource() {
override val lang = "zh"
override val supportsLatest = true
override val name = "动漫之家"
override val baseUrl = "https://m.idmzj.com"
private val preferences: SharedPreferences = getPreferences()
init {
ApiV3.preferences = preferences
}
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor(ImageUrlInterceptor)
.addInterceptor(CommentsInterceptor)
.rateLimit(4)
.apply {
val interceptors = interceptors()
val index = interceptors.indexOfFirst { "Brotli" in it.javaClass.simpleName }
if (index >= 0) {
interceptors.add(interceptors.removeAt(index))
}
}
.build()
// API v4 randomly fails
private val retryClient = network.cloudflareClient.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)
}
private fun fetchMangaInfoV4(id: String): ApiV4.MangaDto? {
val response = retryClient.newCall(GET(ApiV4.mangaInfoUrl(id), headers)).execute()
return ApiV4.parseMangaInfo(response)
}
override fun popularMangaRequest(page: Int) = GET(ApiV3.popularMangaUrl(page), headers)
override fun popularMangaParse(response: Response) = ApiV3.parsePage(response)
override fun latestUpdatesRequest(page: Int) = GET(ApiV3.latestUpdatesUrl(page), headers)
override fun latestUpdatesParse(response: Response) = ApiV3.parsePage(response)
private fun searchMangaById(id: String): MangasPage {
val idNumber = if (id.all { it.isDigit() }) {
id
} else {
// Chinese Pinyin ID
fetchIdBySlug(id)
}
val sManga = fetchMangaDetails(idNumber)
return MangasPage(listOf(sManga), false)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
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.fromCallable { searchMangaById(id) }
} else {
val request = GET(ApiSearch.searchUrlV1(page, query), headers)
Observable.fromCallable {
// this API fails randomly, and might return empty list
repeat(5) {
val result = ApiSearch.parsePageV1(client.newCall(request).execute())
if (result.mangas.isNotEmpty()) return@fromCallable result
}
throw Exception("搜索出错或无结果")
}
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
throw UnsupportedOperationException()
}
override fun searchMangaParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
val id = manga.url.extractMangaId()
return Observable.fromCallable { fetchMangaDetails(id) }
}
private fun fetchMangaDetails(id: String): SManga {
fetchMangaInfoV4(id)?.run { return toSManga() }
val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute()
return ApiV3.parseMangaDetailsV1(response)
}
override fun mangaDetailsRequest(manga: SManga): Request {
throw UnsupportedOperationException()
}
override fun getMangaUrl(manga: SManga): String {
val cid = manga.url.extractMangaId()
return "$baseUrl/info/$cid.html"
}
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
throw UnsupportedOperationException()
}
override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException()
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.fromCallable {
val id = manga.url.extractMangaId()
val result = fetchMangaInfoV4(id)
if (result != null && !result.isLicensed) {
return@fromCallable result.parseChapterList()
}
val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute()
ApiV3.parseChapterListV1(response)
}
}
override fun chapterListParse(response: Response): List<SChapter> {
throw UnsupportedOperationException()
}
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/view/${chapter.url}.html"
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val path = chapter.url
return Observable.fromCallable {
val response = retryClient.newCall(GET(ApiV4.chapterImagesUrl(path), headers)).execute()
val result = try {
ApiV4.parseChapterImages(response, preferences.imageQuality == LOW_RES)
} catch (_: Throwable) {
client.newCall(GET(ApiV3.chapterImagesUrlV1(path), headers)).execute()
.let(ApiV3::parseChapterImagesV1)
}
if (preferences.showChapterComments) {
result.add(Page(result.size, COMMENTS_FLAG, ApiV3.chapterCommentsUrl(path)))
}
result
}
}
override fun pageListParse(response: Response): List<Page> {
throw UnsupportedOperationException()
}
// see https://github.com/tachiyomiorg/tachiyomi-extensions/issues/10475
override fun imageRequest(page: Page): Request {
val url = page.url.takeIf { it.isNotEmpty() }
val imageUrl = page.imageUrl!!
if (url == COMMENTS_FLAG) {
return GET(imageUrl, headers).newBuilder()
.tag(CommentsInterceptor.Tag::class, CommentsInterceptor.Tag())
.build()
}
val fallbackUrl = when (preferences.imageQuality) {
AUTO_RES -> url
ORIGINAL_RES -> null
LOW_RES -> if (url == null) null else return GET(url, headers)
else -> url
}
return GET(imageUrl, headers).newBuilder()
.tag(ImageUrlInterceptor.Tag::class, ImageUrlInterceptor.Tag(fallbackUrl))
.build()
}
// Unused, we can get image urls directly from the chapter page
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException()
override fun getFilterList() = getFilterListInternal(preferences.isMultiGenreFilter)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
getPreferencesInternal(screen.context).forEach(screen::addPreference)
}
}

View File

@ -1,44 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://www.dmzj.com/info/xxx intents and redirects them to
* the main tachiyomi process. The idea is to not install the intent filter unless
* you have this extension installed, but still let the main tachiyomi app control
* things.
*/
class DmzjUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 0) {
val titleId = if (pathSegments.size > 1) {
pathSegments[1] // [m,www].dmzj.com/info/{titleId}
} else {
pathSegments[0] // manhua.dmzj.com/{titleId}
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "$PREFIX_ID_SEARCH$titleId")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("DmzjUrlActivity", e.toString())
}
} else {
Log.e("DmzjUrlActivity", "Could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -1,201 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun getFilterListInternal(isMultiGenre: Boolean) = FilterList(
RankingGroup(),
Filter.Separator(),
Filter.Header("分类筛选(查看排行榜、搜索文本时无效)"),
if (isMultiGenre) GenreGroup() else GenreSelectFilter(),
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 GenreSelectFilter : TagFilter, SelectFilter("题材", genres)
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

@ -1,41 +0,0 @@
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) ?: 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

@ -1,63 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.SwitchPreferenceCompat
// Legacy preferences:
// "apiRatelimitPreference" -> 1..10 default "5"
// "imgCDNRatelimitPreference" -> 1..10 default "5"
// "licensedList" -> StringSet of manga ID
// "hiddenList" -> StringSet of manga ID
fun getPreferencesInternal(context: Context) = 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)
},
SwitchPreferenceCompat(context).apply {
key = MULTI_GENRE_FILTER_PREF
title = "分类筛选时允许勾选多个题材"
summary = "可以更精细地筛选出同时符合多个题材的作品。"
setDefaultValue(false)
},
SwitchPreferenceCompat(context).apply {
key = DMZJ_V3API_PREF
title = "V3API选择"
summary = "是否使用旧版v3API(默认nnv3api)"
setDefaultValue(false)
},
)
val SharedPreferences.imageQuality get() = getString(IMAGE_QUALITY_PREF, AUTO_RES)!!
val SharedPreferences.showChapterComments get() = getBoolean(CHAPTER_COMMENTS_PREF, false)
val SharedPreferences.isMultiGenreFilter get() = getBoolean(MULTI_GENRE_FILTER_PREF, false)
val SharedPreferences.isOlderV3API get() = getBoolean(DMZJ_V3API_PREF, false)
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 MULTI_GENRE_FILTER_PREF = "multiGenreFilter"
private const val DMZJ_V3API_PREF = "v3APIVersion"

View File

@ -1,18 +0,0 @@
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,42 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.dmzj.utils
import android.util.Base64
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 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)
return privateK
}
@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
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)
}
}