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