Comico: new extension (#10210)

This commit is contained in:
ObserverOfTime 2021-12-24 14:59:43 +02:00 committed by GitHub
parent 8125f5ce74
commit 842225ad83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 368 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,13 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Comico'
pkgNameSuffix = 'all.comico'
extClass = '.ComicoFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,259 @@
package eu.kanade.tachiyomi.extension.all.comico
import android.webkit.CookieManager
import com.squareup.duktape.Duktape
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
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.JsonElement
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
open class Comico(
final override val baseUrl: String,
final override val name: String,
private val langCode: String
) : HttpSource() {
final override val supportsLatest = true
override val lang = langCode.substring(0, 2)
protected open val apiUrl = baseUrl.replace("www", "api")
private val json by injectLazy<Json>()
private val cookieManager by lazy { CookieManager.getInstance() }
private val cryptoJs by lazy {
client.newCall(GET(CRYPTOJS)).execute().body!!.string()
}
private val imgHeaders by lazy {
headersBuilder().set("Accept", ACCEPT_IMAGE).build()
}
private val apiHeaders: Headers
get() = headersBuilder().apply {
val time = System.currentTimeMillis() / 1000L
this["X-comico-request-time"] = time.toString()
this["X-comico-check-sum"] = sha256(time)
this["X-comico-client-immutable-uid"] = ANON_IP
this["X-comico-client-accept-mature"] = "Y"
this["X-comico-client-platform"] = "web"
this["X-comico-client-store"] = "other"
this["X-comico-client-os"] = "aos"
this["Origin"] = baseUrl
}.build()
override val client = network.client.newBuilder()
.cookieJar(object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) =
cookies.filter { it.matches(url) }.forEach {
cookieManager.setCookie(url.toString(), it.toString())
}
override fun loadForRequest(url: HttpUrl) =
cookieManager.getCookie(url.toString())?.split("; ")
?.mapNotNull { Cookie.parse(url, it) } ?: emptyList()
}).build()
override fun headersBuilder() = Headers.Builder()
.set("Accept-Language", langCode)
.set("User-Agent", userAgent)
.set("Referer", "$baseUrl/")
override fun latestUpdatesRequest(page: Int) =
paginate("all_comic/daily/$day", page)
override fun popularMangaRequest(page: Int) =
paginate("all_comic/ranking/trending", page)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
if (query.isEmpty()) paginate("all_comic/read_for_free", page)
else POST("$apiUrl/search", apiHeaders, search(query, page))
override fun chapterListRequest(manga: SManga) =
GET(apiUrl + manga.url + "/episode", apiHeaders)
override fun pageListRequest(chapter: SChapter) =
GET(apiUrl + chapter.url, apiHeaders)
override fun imageRequest(page: Page) =
GET(page.imageUrl!!.also { android.util.Log.w("URL", it) }, imgHeaders)
override fun latestUpdatesParse(response: Response) =
popularMangaParse(response)
override fun popularMangaParse(response: Response): MangasPage {
val data = response.data
val hasNext = data["page"]["hasNext"]
val mangas = data.map<ContentInfo, SManga>("contents") {
SManga.create().apply {
title = it.name
url = "/comic/${it.id}"
thumbnail_url = it.cover
description = it.description
status = when (it.status) {
"completed" -> SManga.COMPLETED
else -> SManga.ONGOING
}
author = it.authors.filter { it.isAuthor }.joinToString()
artist = it.authors.filter { it.isArtist }.joinToString()
genre = buildString {
it.genres.joinTo(this)
if (it.mature) append(", Mature")
if (it.original) append(", Original")
if (it.exclusive) append(", Exclusive")
}
}
}
return MangasPage(mangas, hasNext.jsonPrimitive.boolean)
}
override fun searchMangaParse(response: Response) =
popularMangaParse(response)
override fun chapterListParse(response: Response): List<SChapter> {
val content = response.data["episode"]["content"]
val id = content["id"].jsonPrimitive.int
return content.map<Chapter, SChapter>("chapters") {
SChapter.create().apply {
chapter_number = it.id.toFloat()
url = "/comic/$id/chapter/${it.id}/product"
name = it.name + if (it.isAvailable) "" else LOCK
date_upload = dateFormat.parse(it.publishedAt)?.time ?: 0L
}
}
}
override fun pageListParse(response: Response) =
response.data["chapter"].map<ChapterImage, Page>("images") {
Page(it.sort, "", it.url.decrypt() + "?" + it.parameter)
}
override fun fetchMangaDetails(manga: SManga) =
rx.Observable.just(manga.apply { initialized = true })!!
override fun fetchPageList(chapter: SChapter) =
if (!chapter.name.endsWith(LOCK)) super.fetchPageList(chapter)
else throw Error("You are not authorized to view this!")
private fun search(query: String, page: Int) =
FormBody.Builder().add("query", query)
.add("pageNo", (page - 1).toString())
.add("pageSize", "25").build()
private fun paginate(route: String, page: Int) =
GET("$apiUrl/$route?pageNo=${page - 1}&pageSize=25", apiHeaders)
private fun String.decrypt() = Duktape.create().use {
// javax.crypto.Cipher does not support empty IV
val script = """
const key = CryptoJS.enc.Utf8.parse('$AES_KEY'), iv = {words: []}
CryptoJS.AES.decrypt('$this', key, {iv}).toString(CryptoJS.enc.Utf8)
"""
it.evaluate(cryptoJs + script).toString()
}
private val Response.data: JsonElement?
get() = json.parseToJsonElement(body!!.string()).jsonObject.also {
val code = it["result"]["code"].jsonPrimitive.int
if (code != 200) throw Error(status(code))
}["data"]
private operator fun JsonElement?.get(key: String) =
this!!.jsonObject[key]!!
private inline fun <reified T, R> JsonElement?.map(
key: String,
transform: (T) -> R
) = json.decodeFromJsonElement<List<T>>(this[key]).map(transform)
override fun mangaDetailsParse(response: Response) =
throw UnsupportedOperationException("Not used")
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException("Not used")
companion object {
private const val ANON_IP = "0.0.0.0"
private const val LOCK = " \uD83D\uDD12"
private const val ISO_DATE = "yyyy-MM-dd'T'HH:mm:ss'Z'"
private const val WEB_KEY = "9241d2f090d01716feac20ae08ba791a"
private const val AES_KEY = "a7fc9dc89f2c873d79397f8a0028a4cd"
private const val CRYPTOJS =
"https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js"
private const val ACCEPT_IMAGE =
"image/avif,image/jxl,image/webp,image/*,*/*"
private val userAgent = System.getProperty("http.agent")!!
private val dateFormat = SimpleDateFormat(ISO_DATE, Locale.ROOT)
private val SHA256 = MessageDigest.getInstance("SHA-256")
private val day by lazy {
when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) {
Calendar.SUNDAY -> "sunday"
Calendar.MONDAY -> "monday"
Calendar.TUESDAY -> "tuesday"
Calendar.WEDNESDAY -> "wednesday"
Calendar.THURSDAY -> "thursday"
Calendar.FRIDAY -> "friday"
Calendar.SATURDAY -> "saturday"
else -> "completed"
}
}
fun sha256(timestamp: Long) = buildString(64) {
SHA256.digest((WEB_KEY + ANON_IP + timestamp).toByteArray())
.joinTo(this, "") { "%02x".format(it) }
SHA256.reset()
}
private fun status(code: Int) = when (code) {
400 -> "Bad Request"
401 -> "Unauthorized"
402 -> "Payment Required"
403 -> "Forbidden"
404 -> "Not Found"
408 -> "Request Timeout"
409 -> "Conflict"
410 -> "DormantAccount"
417 -> "Expectation Failed"
426 -> "Upgrade Required"
428 -> "성인 on/off 권한"
429 -> "Too Many Requests"
500 -> "Internal Server Error"
503 -> "Service Unavailable"
451 -> "성인 인증"
else -> "Error $code"
}
}
}

View File

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.extension.all.comico
import eu.kanade.tachiyomi.source.SourceFactory
class ComicoFactory : SourceFactory {
open class PocketComics(langCode: String) :
Comico("https://www.pocketcomics.com", "POCKET COMICS", langCode)
class ComicoJP : Comico("https://www.comico.jp", "コミコ", "ja-JP")
class ComicoKR : Comico("https://www.comico.kr", "코미코", "ko-KR")
override fun createSources() = listOf(
PocketComics("en-US"),
PocketComics("zh-TW"),
ComicoJP(),
ComicoKR()
)
}

View File

@ -0,0 +1,75 @@
package eu.kanade.tachiyomi.extension.all.comico
import kotlinx.serialization.Serializable
@Serializable
data class ContentInfo(
val id: Int,
val name: String,
val description: String,
val authors: List<Author>,
val genres: List<Genre>,
val original: Boolean,
val exclusive: Boolean,
val mature: Boolean,
val status: String? = null,
private val thumbnails: List<Thumbnail>,
) {
val cover: String
get() = thumbnails[0].toString()
}
@Serializable
data class Thumbnail(private val url: String) {
override fun toString() = url
}
@Serializable
data class Author(private val name: String, private val role: String) {
val isAuthor: Boolean
get() = role == "creator" ||
role == "writer" ||
role == "original_creator"
val isArtist: Boolean
get() = role == "creator" ||
role == "artist" ||
role == "studio" ||
role == "assistant"
override fun toString() = name
}
@Serializable
data class Genre(private val name: String) {
override fun toString() = name
}
@Serializable
data class Chapter(
val id: Int,
val name: String,
val publishedAt: String,
private val salesConfig: SalesConfig,
private val hasTrial: Boolean,
private val activity: Activity
) {
val isAvailable: Boolean
get() = salesConfig.free || hasTrial || activity.owned
}
@Serializable
data class SalesConfig(val free: Boolean)
@Serializable
data class Activity(val rented: Boolean, val unlocked: Boolean) {
inline val owned: Boolean
get() = rented || unlocked
}
@Serializable
data class ChapterImage(
val sort: Int,
val url: String,
val parameter: String
)