Add Zenko (#7185)
* Add Zenko * Apply requested changes * Apply requested changes * Added right url for opening manga on webview * Apply kotlin lint * Use HttpUrl for parsing ids & Added right url for chapter * Apply requested changes * Fixed generate id if chapter contains dot * Apply requested changes
This commit is contained in:
parent
3f86aa1c40
commit
4c3c2212e1
|
@ -0,0 +1,8 @@
|
||||||
|
ext {
|
||||||
|
extName = 'Zenko'
|
||||||
|
extClass = '.Zenko'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.7 KiB |
|
@ -0,0 +1,95 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.uk.zenko
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object StringProcessor {
|
||||||
|
private const val SEPARATOR = "@#%&;№%#&**#!@"
|
||||||
|
|
||||||
|
data class ParsedResult(
|
||||||
|
val part: String = "",
|
||||||
|
val chapter: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parse(input: String?): ParsedResult {
|
||||||
|
if (input.isNullOrEmpty()) {
|
||||||
|
return ParsedResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = input.split(SEPARATOR)
|
||||||
|
return when (parts.size) {
|
||||||
|
3 -> {
|
||||||
|
val (part, chapter, name) = parts
|
||||||
|
ParsedResult(part, chapter, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
val (part, chapter) = parts
|
||||||
|
ParsedResult(part, chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
val (name) = parts
|
||||||
|
ParsedResult(name = name)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ParsedResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gen ID by rule: part + chapter
|
||||||
|
// example
|
||||||
|
// 1 + 0 = 100
|
||||||
|
// 1 + 1 = 101
|
||||||
|
// 0 + 10 = 010
|
||||||
|
// 1 + 99 = 199
|
||||||
|
// 1 + 100.5 = 1100.5
|
||||||
|
fun generateId(input: String?): Double {
|
||||||
|
if (input.isNullOrEmpty()) {
|
||||||
|
return -1.0
|
||||||
|
}
|
||||||
|
val (part, chapter) = parse(input)
|
||||||
|
|
||||||
|
val partNumber = part.toIntOrNull() ?: 0
|
||||||
|
val chapterNumber = chapter.toDoubleOrNull() ?: 0
|
||||||
|
|
||||||
|
val formattedChapter = if (chapter.contains('.')) {
|
||||||
|
chapter.split('.').joinToString(".") { part ->
|
||||||
|
if (part == chapter.split('.').first() && part.length == 1) {
|
||||||
|
part.padStart(2, '0')
|
||||||
|
} else {
|
||||||
|
part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (chapter.length == 1) chapter.padStart(2, '0') else chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
val idString = if (partNumber > 0) {
|
||||||
|
"$partNumber$formattedChapter"
|
||||||
|
} else {
|
||||||
|
"$chapterNumber"
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
idString.toDouble()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
Log.d("ZENKO", "Invalid ID format: $idString")
|
||||||
|
-1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun format(input: String?): String {
|
||||||
|
val (part, chapter, name) = parse(input)
|
||||||
|
val chapterLabel = if (chapter.isNotEmpty()) {
|
||||||
|
"Розділ $chapter${if (name.isNotEmpty()) ":" else ""}"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
return listOf(
|
||||||
|
if (part.isNotEmpty()) "Том $part" else "",
|
||||||
|
chapterLabel,
|
||||||
|
name,
|
||||||
|
).filter { it.isNotEmpty() }.joinToString(" ")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,201 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.uk.zenko
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.uk.zenko.dtos.ChapterResponseItem
|
||||||
|
import eu.kanade.tachiyomi.extension.uk.zenko.dtos.MangaDetailsResponse
|
||||||
|
import eu.kanade.tachiyomi.extension.uk.zenko.dtos.ZenkoMangaListResponse
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||||
|
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 kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class Zenko : HttpSource() {
|
||||||
|
override val name = "Zenko"
|
||||||
|
override val baseUrl = "https://zenko.online"
|
||||||
|
override val lang = "uk"
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Origin", "$baseUrl")
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimitHost(API_URL.toHttpUrl(), 10)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
|
return "$baseUrl${manga.url}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChapterUrl(chapter: SChapter): String {
|
||||||
|
return "$baseUrl${chapter.url}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Popular ===============================
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
val offset = offsetCounter(page)
|
||||||
|
return makeZenkoMangaRequest(offset, "viewsCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response) = parseAsMangaResponseDto(response)
|
||||||
|
|
||||||
|
// =============================== Latest ===============================
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
val offset = offsetCounter(page)
|
||||||
|
return makeZenkoMangaRequest(offset, "lastChapterCreatedAt")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response) = parseAsMangaResponseDto(response)
|
||||||
|
|
||||||
|
// =============================== Search ===============================
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
if (query.length >= 2) {
|
||||||
|
val offset = offsetCounter(page)
|
||||||
|
val url = "$API_URL/titles".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("limit", "15")
|
||||||
|
.addQueryParameter("offset", offset.toString())
|
||||||
|
.addQueryParameter("name", query)
|
||||||
|
.build()
|
||||||
|
return GET(url, headers)
|
||||||
|
} else {
|
||||||
|
throw UnsupportedOperationException("Запит має містити щонайменше 2 символи / The query must contain at least 2 characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response) = parseAsMangaResponseDto(response)
|
||||||
|
|
||||||
|
// =========================== Manga Details ============================
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
val mangaId = "$baseUrl${manga.url}".toHttpUrl().pathSegments.last()
|
||||||
|
val url = "$API_URL/titles/$mangaId"
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||||
|
val mangaDto = response.parseAs<MangaDetailsResponse>()
|
||||||
|
setUrlWithoutDomain("/titles/${mangaDto.id}")
|
||||||
|
title = mangaDto.engName ?: mangaDto.name
|
||||||
|
thumbnail_url = buildImageUrl(mangaDto.coverImg)
|
||||||
|
description = "${mangaDto.name}\n${mangaDto.description}"
|
||||||
|
genre = mangaDto.genres!!.joinToString { it.name }
|
||||||
|
author = mangaDto.author!!.username
|
||||||
|
status = mangaDto.status.toStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Chapters ==============================
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
val mangaId = "$baseUrl${manga.url}".toHttpUrl().pathSegments.last()
|
||||||
|
val url = "$API_URL/titles/$mangaId/chapters"
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val result = response.parseAs<List<ChapterResponseItem>>()
|
||||||
|
return result.sortedByDescending { item ->
|
||||||
|
val id = StringProcessor.generateId(item.name)
|
||||||
|
if (id > 0) id else item.id.toDouble()
|
||||||
|
}.map { chapterResponseItem ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
setUrlWithoutDomain("/titles/${chapterResponseItem.titleId}/${chapterResponseItem.id}")
|
||||||
|
name = StringProcessor.format(chapterResponseItem.name)
|
||||||
|
date_upload = chapterResponseItem.createdAt!!.secToMs()
|
||||||
|
scanlator = chapterResponseItem.publisher!!.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Pages ================================
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val chapterId = "$baseUrl${chapter.url}".toHttpUrl().pathSegments.last()
|
||||||
|
val url = "$API_URL/chapters/$chapterId"
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val data = response.parseAs<ChapterResponseItem>()
|
||||||
|
return data.pages!!.map { page ->
|
||||||
|
Page(page.id, imageUrl = "$IMAGE_STORAGE_URL/${page.imgUrl}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = ""
|
||||||
|
|
||||||
|
// ============================= Utilities ==============================
|
||||||
|
private fun parseAsMangaResponseDto(response: Response): MangasPage {
|
||||||
|
val zenkoMangaListResponse = response.parseAs<ZenkoMangaListResponse>()
|
||||||
|
return makeMangasPage(zenkoMangaListResponse.data, zenkoMangaListResponse.meta.hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun offsetCounter(page: Int) = (page - 1) * 15
|
||||||
|
|
||||||
|
private fun makeZenkoMangaRequest(offset: Int, sortBy: String): Request {
|
||||||
|
val url = "$API_URL/titles".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("limit", "15")
|
||||||
|
.addQueryParameter("offset", offset.toString())
|
||||||
|
.addQueryParameter("sortBy", sortBy)
|
||||||
|
.addQueryParameter("order", "DESC")
|
||||||
|
.build()
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeMangasPage(
|
||||||
|
mangaList: List<MangaDetailsResponse>,
|
||||||
|
hasNextPage: Boolean = false,
|
||||||
|
): MangasPage {
|
||||||
|
return MangasPage(
|
||||||
|
mangaList.map(::makeSManga),
|
||||||
|
hasNextPage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeSManga(mangaDto: MangaDetailsResponse) = SManga.create().apply {
|
||||||
|
setUrlWithoutDomain("/titles/${mangaDto.id}")
|
||||||
|
title = mangaDto.engName ?: mangaDto.name
|
||||||
|
thumbnail_url = buildImageUrl(mangaDto.coverImg)
|
||||||
|
status = mangaDto.status.toStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toStatus(): Int {
|
||||||
|
val status = this.lowercase()
|
||||||
|
return when (status) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"finished" -> SManga.COMPLETED
|
||||||
|
"paused" -> SManga.ON_HIATUS
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.secToMs(): Long {
|
||||||
|
return this * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildImageUrl(imageId: String): String {
|
||||||
|
val url = "$IMAGE_STORAGE_URL/$imageId".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("optimizer", "image")
|
||||||
|
.addQueryParameter("width", "560")
|
||||||
|
.addQueryParameter("quality", "70")
|
||||||
|
.addQueryParameter("height", "auto")
|
||||||
|
.build()
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> Response.parseAs(): T = use {
|
||||||
|
json.decodeFromStream(it.body.byteStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val API_URL = "https://zenko-api.onrender.com"
|
||||||
|
private const val IMAGE_STORAGE_URL = "https://zenko.b-cdn.net"
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.uk.zenko.dtos
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ZenkoMangaListResponse(
|
||||||
|
val `data`: List<MangaDetailsResponse>,
|
||||||
|
val meta: Meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Meta(
|
||||||
|
val hasNextPage: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaDetailsResponse(
|
||||||
|
val author: Author? = null,
|
||||||
|
val coverImg: String,
|
||||||
|
val description: String,
|
||||||
|
val engName: String? = null,
|
||||||
|
val genres: List<Genre>? = null,
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Genre(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Author(
|
||||||
|
val username: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterResponseItem(
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
val id: Int,
|
||||||
|
val name: String?,
|
||||||
|
val pages: List<Page>? = null,
|
||||||
|
val titleId: Int?,
|
||||||
|
val publisher: Publisher? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Page(
|
||||||
|
val id: Int,
|
||||||
|
val imgUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Publisher(
|
||||||
|
val name: String? = null,
|
||||||
|
)
|
Loading…
Reference in New Issue