Add KadoComi (Comic Walker) (#6473)

* KadoComi (Comic Walker) extension

Initial working commit for KadoComi (Comic Walker)

* Cleanup

* Clean up duplicate code

* Remove unnecessary dependencies from build.gradle

* Use book cover for thumbnail if available

* Additional code clean up

* Convert to using DTOs

* Remove unnecessary fragment handling

Fragment isn't sent to the server, so it's not necessary to do any handling here

* Use fragment from URL

* Fix author/artist names

* Fix thumbnails

Default of bookCover was interfering with thumbnail determination logic

* Implement changes from feedback

* Remove unneeded data keyword
This commit is contained in:
hatozuki-programmer 2024-12-07 16:01:11 -05:00 committed by Draff
parent dcf3bb75d5
commit da6869d9b4
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
8 changed files with 373 additions and 0 deletions

View File

@ -0,0 +1,7 @@
ext {
extName = "KadoComi"
extClass = ".KadoComi"
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -0,0 +1,295 @@
package eu.kanade.tachiyomi.extension.ja.kadocomi
import eu.kanade.tachiyomi.network.GET
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.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.experimental.xor
class KadoComi : HttpSource() {
override val name = "カドコミ" // KadoComi, formerly Comic Walker
override val baseUrl = "https://comic-walker.com"
private val apiUrl = "https://comic-walker.com/api"
private val cdnUrl = "https://cdn.comic-walker.com"
override val lang = "ja"
override val supportsLatest = true
private val imageDescrambler: Interceptor = Interceptor { chain ->
val request: Request = chain.request()
val urlString = request.url.toString()
val drmHash = request.url.fragment ?: ""
val response: Response = chain.proceed(request)
if (urlString.contains("$cdnUrl/images/") && urlString.contains("&Key-Pair-Id=")) {
val oldBody = response.body.bytes()
val descrambled = descrambleImage(oldBody, drmHash.decodeHex())
val newBody = descrambled.toResponseBody("image/jpeg".toMediaTypeOrNull())
response.newBuilder()
.body(newBody)
.build()
} else {
response
}
}
override val client = network.client.newBuilder()
.addNetworkInterceptor(imageDescrambler)
.build()
private val json: Json by injectLazy()
// ============================== Manga Details ===============================
override fun getMangaUrl(manga: SManga): String {
return "$baseUrl/detail/${getWorkCode(manga)}"
}
override fun mangaDetailsRequest(manga: SManga): Request {
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("contents")
addPathSegment("details")
addPathSegment("work")
addQueryParameter("workCode", getWorkCode(manga))
}
return GET(url.build(), headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val details = json.decodeFromString<KadoComiWorkDto>(response.body.string())
var mangaAuthor: String? = null
var mangaArtist: String? = null
details.work.authors?.forEach {
when (it.role) {
in AUTHOR_ROLES -> {
mangaAuthor = it.name
}
in ARTIST_ROLES -> {
mangaArtist = it.name
}
in COMBINED_ROLES -> {
mangaAuthor = it.name
mangaArtist = it.name
}
}
}
return SManga.create().apply {
url = "/detail/${details.work.code}"
title = details.work.title
thumbnail_url = getThumbnailUrl(details.work)
author = mangaAuthor
artist = mangaArtist
description = details.work.summary
genre = getGenres(details.work)
status = when (details.work.serializationStatus.lowercase()) {
"ongoing" -> SManga.ONGOING
"unknown" -> SManga.UNKNOWN
else -> SManga.UNKNOWN
}
}
}
private fun getGenres(work: KadoComiWork): String {
return listOfNotNull(work.genre?.name, work.subGenre?.name)
.plus(work.tags.orEmpty().map { it.name })
.joinToString()
}
// ============================== Chapters ===============================
override fun getChapterUrl(chapter: SChapter): String {
// fragment contains two parameters in the format: #workCode={workCode}&episodeCode={episodeCode}"
// fragment comes from the URL as a single string, so manipulate to acquire the relevant values
val fragment = "$baseUrl${chapter.url}".toHttpUrl().fragment
val params = fragment!!.split("&")
val workCode = params[0].split("=")[1]
val episodeCode = params[1].split("=")[1]
return "$baseUrl/detail/$workCode/episodes/$episodeCode"
}
override fun chapterListRequest(manga: SManga): Request {
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("contents")
addPathSegment("details")
addPathSegment("work")
addQueryParameter("workCode", getWorkCode(manga))
}
return GET(url.build(), headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val details = json.decodeFromString<KadoComiWorkDto>(response.body.string())
val workCode = details.work.code
return details.latestEpisodes?.result.orEmpty().map { episode ->
SChapter.create().apply {
url = "/api/contents/viewer?episodeId=${episode.id}&imageSizeType=width%3A1284#workCode=$workCode&episodeCode=${episode.code}"
name = "${if (!episode.isActive) LOCK else ""} ${episode.title}"
date_upload = parseDate(episode.updateDate)
chapter_number = episode.internal.episodeNo.toFloat()
}
}
}
// ============================== Pages ===============================
override fun pageListParse(response: Response): List<Page> {
val viewer = json.decodeFromString<KadoComiViewerDto>(response.body.string())
val pages = viewer.manuscripts.mapIndexed { idx, manuscript ->
Page(idx, imageUrl = "${manuscript.drmImageUrl.substringAfter(baseUrl)}#${manuscript.drmHash}")
}
if (pages.isEmpty()) {
throw Exception("このチャプターは非公開です\nChapter is not available!")
}
return pages
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
// ============================== Search ===============================
override fun searchMangaParse(response: Response): MangasPage {
val results = json.decodeFromString<KadoComiSearchResultsDto>(response.body.string())
return MangasPage(searchResultsParse(results), results.result.size >= SEARCH_LIMIT)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val offset = (SEARCH_LIMIT * page) - SEARCH_LIMIT
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("search")
addPathSegment("keywords")
addQueryParameter("keywords", query)
addQueryParameter("limit", SEARCH_LIMIT.toString())
addQueryParameter("offset", offset.toString())
addQueryParameter("sortBy", "popularity")
}
return GET(url.build(), headers)
}
// ============================== Latest ===============================
override fun latestUpdatesParse(response: Response): MangasPage {
val results = json.decodeFromString<KadoComiSearchResultsDto>(response.body.string())
return MangasPage(searchResultsParse(results), false)
}
override fun latestUpdatesRequest(page: Int): Request {
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("series")
addPathSegment("new")
addQueryParameter("limit", NEW_LIMIT.toString())
}
return GET(url.build(), headers)
}
// ============================== Popular ===============================
override fun popularMangaParse(response: Response): MangasPage {
val results = json.decodeFromString<KadoComiSearchResultsDto>(response.body.string())
return MangasPage(searchResultsParse(results), false)
}
override fun popularMangaRequest(page: Int): Request {
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegment("ranking")
addQueryParameter("limit", RANKING_LIMIT.toString())
}
return GET(url.build(), headers)
}
// ============================= Utilities ==============================
private fun getWorkCode(manga: SManga): String {
return manga.url.substringAfterLast("/")
}
private fun getThumbnailUrl(work: KadoComiWork): String {
return work.bookCover ?: work.thumbnail
}
// https://stackoverflow.com/a/66614516
private fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
private fun descrambleImage(imageByteArray: ByteArray, hashByteArray: ByteArray): ByteArray {
return imageByteArray.mapIndexed { idx, byte ->
byte xor hashByteArray[idx % hashByteArray.size]
}.toByteArray()
}
private fun searchResultsParse(results: KadoComiSearchResultsDto): List<SManga> {
return results.result.map {
SManga.create().apply {
url = "/detail/${it.code}"
title = it.title
thumbnail_url = getThumbnailUrl(it)
}
}
}
companion object {
// inactive chapter icon
private const val LOCK = "🔒 "
// date formatting
private fun parseDate(dateStr: String): Long {
return try {
dateFormat.parse(dateStr)!!.time
} catch (_: ParseException) {
0L
}
}
private val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
}
// search limits, mimics site functionality
private const val SEARCH_LIMIT = 20
private const val RANKING_LIMIT = 50
private const val NEW_LIMIT = 100
// author/artist roles
private val AUTHOR_ROLES = arrayOf("原作")
private val ARTIST_ROLES = arrayOf("漫画", "作画")
private val COMBINED_ROLES = arrayOf("著者")
}
}

View File

@ -0,0 +1,71 @@
package eu.kanade.tachiyomi.extension.ja.kadocomi
import kotlinx.serialization.Serializable
@Serializable
class KadoComiWorkDto(
val work: KadoComiWork,
val latestEpisodes: KadoComiEpisodesResult?,
)
@Serializable
class KadoComiSearchResultsDto(
val result: List<KadoComiWork> = emptyList(),
)
@Serializable
class KadoComiViewerDto(
val manuscripts: List<KadoComiManuscript> = emptyList(),
)
@Serializable
class KadoComiWork(
val code: String = "",
val id: String = "",
val thumbnail: String = "",
val bookCover: String?,
val title: String = "",
val serializationStatus: String = "",
val summary: String? = "",
val genre: KadoComiTag?,
val subGenre: KadoComiTag?,
val tags: List<KadoComiTag>? = emptyList(),
val authors: List<KadoComiAuthor>? = emptyList(),
)
@Serializable
class KadoComiTag(
val name: String = "",
)
@Serializable
class KadoComiAuthor(
val name: String = "",
val role: String = "",
)
@Serializable
class KadoComiEpisodesResult(
val result: List<KadoComiEpisode> = emptyList(),
)
@Serializable
class KadoComiEpisode(
val id: String = "",
val code: String = "",
val title: String = "",
val updateDate: String = "",
val isActive: Boolean = false,
val internal: KadoComiEpisodeInternalInfo,
)
@Serializable
class KadoComiEpisodeInternalInfo(
val episodeNo: Int = 1,
)
@Serializable
class KadoComiManuscript(
val drmHash: String = "",
val drmImageUrl: String = "",
)