* 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:
Altometer 2025-01-23 19:58:21 +02:00 committed by Draff
parent 3f86aa1c40
commit 4c3c2212e1
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
9 changed files with 361 additions and 0 deletions

View File

@ -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

View File

@ -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(" ")
}
}

View File

@ -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()
}
}

View File

@ -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,
)