Add Zaimanhua (#5092)
This commit is contained in:
parent
59e9181bf9
commit
5f905c713d
|
@ -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 |
|
@ -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("/", ", ")
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
Loading…
Reference in New Issue