diff --git a/src/zh/zaimanhua/build.gradle b/src/zh/zaimanhua/build.gradle new file mode 100644 index 000000000..fc35f92bd --- /dev/null +++ b/src/zh/zaimanhua/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Zaimanhua' + extClass = '.Zaimanhua' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/zaimanhua/res/mipmap-hdpi/ic_launcher.png b/src/zh/zaimanhua/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f09cf1fbd Binary files /dev/null and b/src/zh/zaimanhua/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/zaimanhua/res/mipmap-mdpi/ic_launcher.png b/src/zh/zaimanhua/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..626a6b583 Binary files /dev/null and b/src/zh/zaimanhua/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/zaimanhua/res/mipmap-xhdpi/ic_launcher.png b/src/zh/zaimanhua/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..b119042fd Binary files /dev/null and b/src/zh/zaimanhua/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/zaimanhua/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/zaimanhua/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..c9c18528d Binary files /dev/null and b/src/zh/zaimanhua/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/zaimanhua/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/zaimanhua/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..0495adc33 Binary files /dev/null and b/src/zh/zaimanhua/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Common.kt b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Common.kt new file mode 100644 index 000000000..64e93963f --- /dev/null +++ b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Common.kt @@ -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 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("/", ", ") diff --git a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt new file mode 100644 index 000000000..d95c46ff3 --- /dev/null +++ b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt @@ -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().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>() + 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>() + 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>>() + 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 { + val result = response.parseAs>>() + 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 { + val result = response.parseAs>>() + 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>().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>>().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) + } + } +} diff --git a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt new file mode 100644 index 000000000..cf4e8c334 --- /dev/null +++ b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt @@ -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, + private val status: List, + private val authors: List, + @SerialName("chapters") + private val chapterGroups: List, +) { + 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 { + 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, +) { + fun toSChapterList(mangaId: String): List { + 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, +) + +@Serializable +class PageDto( + private val list: List?, + 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( + val data: T?, +) + +@Serializable +class ResponseDto( + val errno: Int = 0, + val errmsg: String = "", + val data: T, +)