diff --git a/src/all/comico/AndroidManifest.xml b/src/all/comico/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/all/comico/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/comico/build.gradle b/src/all/comico/build.gradle new file mode 100644 index 000000000..1e456374d --- /dev/null +++ b/src/all/comico/build.gradle @@ -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" diff --git a/src/all/comico/res/mipmap-hdpi/ic_launcher.png b/src/all/comico/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..c691fb27c Binary files /dev/null and b/src/all/comico/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/comico/res/mipmap-mdpi/ic_launcher.png b/src/all/comico/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..a846eabc1 Binary files /dev/null and b/src/all/comico/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/comico/res/mipmap-xhdpi/ic_launcher.png b/src/all/comico/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ec698790a Binary files /dev/null and b/src/all/comico/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/comico/res/mipmap-xxhdpi/ic_launcher.png b/src/all/comico/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f592ec627 Binary files /dev/null and b/src/all/comico/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..19ea10e56 Binary files /dev/null and b/src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/comico/res/web_hi_res_512.png b/src/all/comico/res/web_hi_res_512.png new file mode 100644 index 000000000..4ead0a852 Binary files /dev/null and b/src/all/comico/res/web_hi_res_512.png differ diff --git a/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/Comico.kt b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/Comico.kt new file mode 100644 index 000000000..784b5062b --- /dev/null +++ b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/Comico.kt @@ -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() + + 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) = + 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("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 { + val content = response.data["episode"]["content"] + val id = content["id"].jsonPrimitive.int + return content.map("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("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 JsonElement?.map( + key: String, + transform: (T) -> R + ) = json.decodeFromJsonElement>(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" + } + } +} diff --git a/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoFactory.kt b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoFactory.kt new file mode 100644 index 000000000..a76b390ca --- /dev/null +++ b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoFactory.kt @@ -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() + ) +} diff --git a/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoModels.kt b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoModels.kt new file mode 100644 index 000000000..fe6032af9 --- /dev/null +++ b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoModels.kt @@ -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, + val genres: List, + val original: Boolean, + val exclusive: Boolean, + val mature: Boolean, + val status: String? = null, + private val thumbnails: List, +) { + 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 +)