Comico: new extension (#10210)
This commit is contained in:
parent
8125f5ce74
commit
842225ad83
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -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 |
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
Loading…
Reference in New Issue