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