Add Zaimanhua (#5092)

This commit is contained in:
zhongfly 2024-09-20 15:30:43 +08:00 committed by Draff
parent 59e9181bf9
commit 5f905c713d
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
9 changed files with 406 additions and 0 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
val json: Json by injectLazy()
inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
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 String.formatList() = replace("/", ", ")

View File

@ -0,0 +1,225 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
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 okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.MessageDigest
class Zaimanhua : HttpSource(), ConfigurableSource {
override val lang = "zh"
override val supportsLatest = true
override val name = "再漫画"
override val baseUrl = "https://manhua.zaimanhua.com"
private val apiUrl = "https://v4api.zaimanhua.com/app/v1"
private val accountApiUrl = "https://account-api.zaimanhua.com/v1"
private val preferences: SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
override val client: OkHttpClient =
network.client.newBuilder().rateLimit(5).addInterceptor(::authIntercept).build()
private fun authIntercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.url.host != "v4api.zaimanhua.com" || !request.headers["authorization"].isNullOrBlank()) {
return chain.proceed(request)
}
var token: String = preferences.getString("TOKEN", "")!!
if (token.isBlank() || !isValid(token)) {
val username = preferences.getString("USERNAME", "")!!
val password = preferences.getString("PASSWORD", "")!!
token = getToken(username, password)
if (token.isBlank()) {
preferences.edit().putString("TOKEN", "").apply()
preferences.edit().putString("USERNAME", "").apply()
preferences.edit().putString("PASSWORD", "").apply()
return chain.proceed(request)
} else {
preferences.edit().putString("TOKEN", token).apply()
apiHeaders = apiHeaders.newBuilder().setToken(token).build()
}
}
val authRequest = request.newBuilder().apply {
header("authorization", "Bearer $token")
}.build()
return chain.proceed(authRequest)
}
private fun Headers.Builder.setToken(token: String): Headers.Builder = apply {
if (token.isNotBlank()) set("authorization", "Bearer $token")
}
private var apiHeaders = headersBuilder().setToken(preferences.getString("TOKEN", "")!!).build()
private fun isValid(token: String): Boolean {
val response = client.newCall(
GET(
"$accountApiUrl/userInfo/get",
headersBuilder().setToken(token).build(),
),
).execute().parseAs<ResponseDto<UserDto>>()
return response.errno == 0
}
private fun getToken(username: String, password: String): String {
if (username.isBlank() || password.isBlank()) return ""
val passwordEncoded =
MessageDigest.getInstance("MD5").digest(password.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
val formBody: RequestBody = FormBody.Builder().addEncoded("username", username)
.addEncoded("passwd", passwordEncoded).build()
val response = client.newCall(
POST(
"$accountApiUrl/login/passwd",
headers,
formBody,
),
).execute().parseAs<ResponseDto<UserDto>>()
return response.data.user?.token ?: ""
}
// Detail
// path: "/comic/detail/mangaId"
override fun mangaDetailsRequest(manga: SManga): Request =
GET("$apiUrl/comic/detail/${manga.url}", apiHeaders)
override fun mangaDetailsParse(response: Response): SManga {
val result = response.parseAs<ResponseDto<DataWrapperDto<MangaDto>>>()
if (result.errmsg.isNotBlank()) {
throw Exception(result.errmsg)
} else {
return result.data.data!!.toSManga()
}
}
// Chapter
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val result = response.parseAs<ResponseDto<DataWrapperDto<MangaDto>>>()
if (result.errmsg.isNotBlank()) {
throw Exception(result.errmsg)
} else {
return result.data.data!!.parseChapterList()
}
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
// PageList
// path: "/comic/chapter/mangaId/chapterId"
override fun pageListRequest(chapter: SChapter) =
GET("$apiUrl/comic/chapter/${chapter.url}", apiHeaders)
override fun pageListParse(response: Response): List<Page> {
val result = response.parseAs<ResponseDto<DataWrapperDto<ChapterImagesDto>>>()
if (result.errmsg.isNotBlank()) {
throw Exception(result.errmsg)
} else {
return result.data.data!!.images.mapIndexed { index, it ->
Page(index, imageUrl = it)
}
}
}
// Popular
private fun rankApiUrl(): HttpUrl.Builder =
"$apiUrl/comic/rank/list".toHttpUrl().newBuilder().addQueryParameter("by_time", "3")
.addQueryParameter("tag_id", "0").addQueryParameter("rank_type", "0")
override fun popularMangaRequest(page: Int): Request = GET(
rankApiUrl().apply {
addQueryParameter("page", page.toString())
}.build(),
apiHeaders,
)
override fun popularMangaParse(response: Response): MangasPage = latestUpdatesParse(response)
// Search
private fun searchApiUrl(): HttpUrl.Builder =
"$apiUrl/search/index".toHttpUrl().newBuilder().addQueryParameter("source", "0")
.addQueryParameter("size", "20")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET(
searchApiUrl().apply {
addQueryParameter("keyword", query)
addQueryParameter("page", page.toString())
}.build(),
apiHeaders,
)
override fun searchMangaParse(response: Response): MangasPage =
response.parseAs<ResponseDto<PageDto>>().data.toMangasPage()
// Latest
// "$apiUrl/comic/update/list/1/$page" is same content
override fun latestUpdatesRequest(page: Int): Request =
GET("$apiUrl/comic/update/list/0/$page", apiHeaders)
override fun latestUpdatesParse(response: Response): MangasPage {
val mangas = response.parseAs<ResponseDto<List<PageItemDto>>>().data
return MangasPage(mangas.map { it.toSManga() }, true)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
EditTextPreference(screen.context).apply {
key = "USERNAME"
title = "用户名"
summary = "该配置被修改后,会清空令牌(Token)以便重新登录;如果登录失败,会清空该配置"
setOnPreferenceChangeListener { _, _ ->
// clean token after username/password changed
preferences.edit().putString("TOKEN", "").apply()
true
}
}.let(screen::addPreference)
EditTextPreference(screen.context).apply {
key = "PASSWORD"
title = "密码"
summary = "该配置被修改后,会清空令牌(Token)以便重新登录;如果登录失败,会清空该配置"
setOnPreferenceChangeListener { _, _ ->
// clean token after username/password changed
preferences.edit().putString("TOKEN", "").apply()
true
}
}.let(screen::addPreference)
EditTextPreference(screen.context).apply {
key = "TOKEN"
title = "令牌(Token)"
summary = "当前登录状态:${
if (preferences.getString("TOKEN", "").isNullOrEmpty()) "未登录" else "已登录"
}\n填写用户名和密码后不会立刻尝试登录会在下次请求时自动尝试"
setEnabled(false)
}.let(screen::addPreference)
}
}
}

View File

@ -0,0 +1,144 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
class MangaDto(
private val id: Int,
private val title: String,
private val cover: String,
private val description: String? = null,
private val types: List<TagDto>,
private val status: List<TagDto>,
private val authors: List<TagDto>,
@SerialName("chapters")
private val chapterGroups: List<ChapterGroupDto>,
) {
fun toSManga() = SManga.create().apply {
url = id.toString()
title = this@MangaDto.title
author = authors.joinToString { it.name }
description = this@MangaDto.description
genre = types.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 ChapterGroupDto(
private val title: String,
private val data: List<ChapterDto>,
) {
fun toSChapterList(mangaId: String): List<SChapter> {
val groupName = title
val isDefaultGroup = groupName == "连载"
return data.map {
it.toSChapterInternal().apply {
url = "$mangaId/$url"
if (!isDefaultGroup) scanlator = groupName
}
}
}
val size get() = data.size
}
@Serializable
class ChapterDto(
@SerialName("chapter_id")
private val id: Int,
@SerialName("chapter_title")
private val name: String,
@SerialName("updatetime")
private val updateTime: Long = 0,
) {
fun toSChapterInternal() = SChapter.create().apply {
url = id.toString()
name = this@ChapterDto.name.formatChapterName()
date_upload = updateTime * 1000
}
}
@Serializable
class ChapterImagesDto(
@SerialName("page_url_hd")
val images: List<String>,
)
@Serializable
class PageDto(
private val list: List<PageItemDto>?,
private val page: Int,
private val size: Int,
private val total: Int,
) {
fun toMangasPage(): MangasPage {
if (list.isNullOrEmpty()) throw Exception("漫画结果为空,请检查输入")
val hasNextPage = page * size < total
return MangasPage(list.map { it.toSManga() }, hasNextPage)
}
}
@Serializable
class PageItemDto(
@JsonNames("comic_id")
private val id: Int,
private val title: String,
private val authors: String = "",
private val status: String,
private val cover: String,
private val types: String,
) {
fun toSManga() = SManga.create().apply {
url = this@PageItemDto.id.toString()
title = this@PageItemDto.title
author = authors.formatList()
genre = types.formatList()
status = parseStatus(this@PageItemDto.status)
thumbnail_url = cover
}
}
@Serializable
class TagDto(
@SerialName("tag_name")
val name: String,
)
@Serializable
class UserDto(
@JsonNames("userInfo")
val user: UserInfoDto?,
) {
@Serializable
class UserInfoDto(
val token: String,
)
}
@Serializable
class DataWrapperDto<T>(
val data: T?,
)
@Serializable
class ResponseDto<T>(
val errno: Int = 0,
val errmsg: String = "",
val data: T,
)