Add Comikey (#1110)

* Add Comikey

* Remove logging

* i18n

* Comikey Brasil, paid chapters toggle, use other chapter endpoint

* Don't parse author/artist in searchMangaFromElement

* makeEpisodeSlug private

* Move gundamUrl outside of class constructor

* paginate latest

* paginate search

* Properly distinguish i18n keys from normal messages in WebView script

* Parse statuses better

* Add genre for entry format

* remove unnecessary getChapterUrl

* Fix status on BR

* ACTUALLY fix status on BR

* Fix more Comikey Brasil stupidity

* Validate that manifestUrl is valid

* Revert "Validate that manifestUrl is valid"

This reverts commit d744fd42b45ae46baf48308ec3f354546d1452af.

* Proper i18n in WebView script

* Add explanation for weird binding

* Move helper functions to bottom

* Support signing in through WebView

* Fix chapter list when signed in

* Properly filter locked chapters

* Remove WebView logging
This commit is contained in:
beerpsi 2024-02-09 00:11:18 +07:00 committed by Draff
parent 879eb629b1
commit 4682cc8752
15 changed files with 732 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.comikey.ComikeyUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="comikey.com" />
<data android:host="br.comikey.com" />
<data
android:pathPattern="/comics/..*/..*/"
android:scheme="https"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,23 @@
sort_by=Sort by
sort_last_updated=Last updated
sort_name=Name
sort_popularity=Popularity
sort_chapter_count=Chapter count
filter_by=Filter by
all=All
manga=Manga
webtoon=Webtoon
new=New
complete=Complete
exclusive=Exclusive
simulpub=Simulpub
search_use_two_characters=Please use at least 2 characters when searching by title.
pref_hide_locked_chapters=Hide locked chapters
pref_hide_locked_chapters_summary=App restart required
error_timed_out_decrypting_image_links=Timed out decrypting image links
error_locked_chapter_unlock_in_webview=Locked chapter, unlock in WebView.
error_open_in_webview_then_try_again=Open chapter in WebView, then try again
error_token_expired=token expired
error_token_not_found=token not found
error_webview_script_not_found=WebView script not found.
error_unknown_error=Unknown error

View File

@ -0,0 +1,16 @@
sort_by=Ordenar por
sort_last_updated=Última atualização
sort_name=Nome
sort_popularity=Popularidade
sort_chapter_count=Capítulos
filter_by=Filtrar por
all=Todos
manga=Manga
webtoon=Webtoon
new=Novo
complete=Completo
exclusive=Exclusivo
simulpub=Simulpub
search_use_two_characters=Use pelo menos 2 caracteres ao pesquisar por título.
pref_hide_locked_chapters=Ocultar capítulos bloqueados
pref_hide_locked_chapters_summary=Se requiere reiniciar la app

View File

@ -0,0 +1,54 @@
document.addEventListener("DOMContentLoaded", (e) => {
// This is intentional. Simply binding `_` to `window.__interface__.gettext` will
// throw an error: "Java bridge method can't be invoked on a non-injected object".
const _ = (key) => window.__interface__.gettext(key);
if (document.querySelector("#unlock-full")) {
window.__interface__.passError(_("error_locked_chapter_unlock_in_webview"));
}
});
document.addEventListener(
"you-right-now:reeeeeee",
async (e) => {
const _ = (key) => window.__interface__.gettext(key);
try {
const db = await new Promise((resolve, reject) => {
const request = indexedDB.open("firebase-app-check-database");
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(event.target);
});
const act = await new Promise((resolve, reject) => {
db.onerror = (event) => reject(event.target);
const request = db.transaction("firebase-app-check-store").objectStore("firebase-app-check-store").getAll();
request.onsuccess = (event) => {
const entries = event.target.result;
db.close();
if (entries.length < 1) {
window.__interface__.passError(`${_("error_open_in_webview_then_try_again")} (${_("error_token_not_found")}).`);
}
const value = entries[0].value;
if (value.expireTimeMillis < Date.now()) {
window.__interface__.passError(`${_("error_open_in_webview_then_try_again")} (${_("error_token_expired")}).`);
}
resolve(value.token)
}
});
const manifest = JSON.parse(document.querySelector("#lmao-init").textContent).manifest;
window.__interface__.passPayload(manifest, act, await e.detail);
} catch (e) {
window.__interface__.passError(`${_("error_unknown_error")}: ${e}`);
}
},
{ once: true },
);

View File

@ -0,0 +1,11 @@
ext {
extName = "Comikey"
extClass = ".ComikeyFactory"
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:i18n"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,405 @@
package eu.kanade.tachiyomi.extension.all.comikey
import android.annotation.SuppressLint
import android.app.Application
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
open class Comikey(
final override val lang: String,
override val name: String = "Comikey",
override val baseUrl: String = "https://comikey.com",
private val defaultLanguage: String = "en",
) : ParsedHttpSource(), ConfigurableSource {
private val gundamUrl: String = "https://gundam.comikey.net"
override val supportsLatest = true
override val client = network.cloudflareClient.newBuilder()
.rateLimit(3)
.build()
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
private val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT)
}
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
private val intl = Intl(
language = lang,
baseLanguage = "en",
availableLanguages = setOf("en", "pt-BR"),
classLoader = this::class.java.classLoader!!,
)
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics/?order=-views&page=$page", headers)
override fun popularMangaSelector() = searchMangaSelector()
override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics/?page=$page", headers)
override fun latestUpdatesSelector() = searchMangaSelector()
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return if (query.startsWith(PREFIX_SLUG_SEARCH)) {
val slug = query.removePrefix(PREFIX_SLUG_SEARCH)
val url = "/comics/$slug/"
fetchMangaDetails(SManga.create().apply { this.url = url })
.map { MangasPage(listOf(it), false) }
} else {
super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/comics/".toHttpUrl().newBuilder().apply {
if (page > 1) {
addQueryParameter("page", page.toString())
}
if (query.length >= 2) {
addQueryParameter("q", query)
}
filters.ifEmpty { getFilterList() }
.filterIsInstance<UriFilter>()
.forEach { it.addToUri(this) }
}.build()
return GET(url, headers)
}
override fun searchMangaSelector() = "div.series-listing[data-view=list] > ul > li"
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
element.selectFirst("div.series-data span.title a")!!.let {
setUrlWithoutDomain(it.attr("href"))
title = it.text()
}
description = element.select("div.excerpt p").text() +
"\n\n" +
element.select("div.desc p").text()
genre = element.select("ul.category-listing li a").joinToString { it.text() }
thumbnail_url = element.selectFirst("div.image picture img")?.absUrl("src")
}
override fun searchMangaNextPageSelector() = "ul.pagination li.next-page:not(.disabled)"
override fun mangaDetailsParse(document: Document): SManga {
val data = json.decodeFromString<ComikeyComic>(
document.selectFirst("script#comic")!!.data(),
)
return SManga.create().apply {
url = data.link
title = data.name
author = data.author.joinToString { it.name }
artist = data.artist.joinToString { it.name }
description = "\"${data.excerpt}\"\n\n${data.description}"
thumbnail_url = "$baseUrl${data.fullCover}"
status = when (data.updateStatus) {
// HACK: Comikey Brasil
0 -> when {
data.updateText.startsWith("toda", true) -> SManga.ONGOING
listOf("em pausa", "hiato").any { data.updateText.startsWith(it, true) } -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
1 -> SManga.COMPLETED
3 -> SManga.ON_HIATUS
in (4..14) -> SManga.ONGOING // daily, weekly, bi-weekly, monthly, every day of the week
else -> SManga.UNKNOWN
}
genre = buildList(data.tags.size + 1) {
addAll(data.tags.map { it.name })
when (data.format) {
0 -> add("Comic")
1 -> add("Manga")
2 -> add("Webtoon")
else -> {}
}
}.joinToString()
}
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val mangaSlug = response.request.url.pathSegments[1]
val mangaData = json.decodeFromString<ComikeyComic>(
document.selectFirst("script#comic")!!.data(),
)
val defaultChapterPrefix = if (mangaData.format == 2) "episode" else "chapter"
val chapterUrl = gundamUrl.toHttpUrl().newBuilder().apply {
val mangaId = response.request.url.pathSegments[2]
val gundamToken = document.selectFirst("script:containsData(GUNDAM.token)")
?.data()
?.substringAfter("= \"")
?.substringBefore("\";")
if (gundamToken != null) {
addPathSegment("comic")
} else {
addPathSegment("comic.public")
}
addPathSegment(mangaId)
addPathSegment("episodes")
addQueryParameter("language", lang.lowercase())
gundamToken?.let { addQueryParameter("token", gundamToken) }
}.build()
val data = json.decodeFromString<ComikeyEpisodeListResponse>(
client.newCall(GET(chapterUrl, headers))
.execute()
.body
.string(),
)
val currentTime = System.currentTimeMillis()
return data.episodes
.filter { it.readable || !hideLockedChapters }
.map {
SChapter.create().apply {
url = "/read/$mangaSlug/${makeEpisodeSlug(it, defaultChapterPrefix)}/"
name = buildString {
append(it.title)
if (it.subtitle != null) {
append(": ")
append(it.subtitle)
}
}
chapter_number = it.number
date_upload = try {
dateFormat.parse(it.releasedAt)!!.time
} catch (e: Exception) {
0L
}
}
}
.filter { it.date_upload <= currentTime }
.reversed()
}
override fun chapterListSelector() = throw UnsupportedOperationException()
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.fromCallable {
pageListParse(chapter)
}
}
override fun pageListParse(document: Document) = throw UnsupportedOperationException()
@SuppressLint("SetJavaScriptEnabled")
private fun pageListParse(chapter: SChapter): List<Page> {
val interfaceName = randomString()
val handler = Handler(Looper.getMainLooper())
val latch = CountDownLatch(1)
val jsInterface = JsInterface(latch, json, intl)
var webView: WebView? = null
handler.post {
val innerWv = WebView(Injekt.get<Application>())
webView = innerWv
innerWv.settings.domStorageEnabled = true
innerWv.settings.javaScriptEnabled = true
innerWv.settings.blockNetworkImage = true
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
innerWv.addJavascriptInterface(jsInterface, interfaceName)
innerWv.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
view?.evaluateJavascript(webviewScript.replace("__interface__", interfaceName)) {}
}
}
innerWv.loadUrl("$baseUrl${chapter.url}")
}
latch.await(30, TimeUnit.SECONDS)
handler.post { webView?.destroy() }
if (latch.count == 1L) {
throw Exception(intl["error_timed_out_decrypting_image_links"])
}
if (jsInterface.error.isNotEmpty()) {
throw Exception(jsInterface.error)
}
val manifestUrl = jsInterface.manifestUrl.toHttpUrl()
return jsInterface.images.mapIndexed { i, it ->
val href = it.alternate.firstOrNull { it.type == "image/webp" }?.href
?: it.href
val url = manifestUrl.newBuilder().apply {
removePathSegment(manifestUrl.pathSegments.size - 1)
addPathSegments(href)
addQueryParameter("act", jsInterface.act)
}.build()
Page(i, imageUrl = url.toString())
}
}
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
override fun getFilterList() = getComikeyFilters(intl)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = PREF_HIDE_LOCKED_CHAPTERS
title = intl["pref_hide_locked_chapters"]
summary = intl["pref_hide_locked_chapters_summary"]
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(PREF_HIDE_LOCKED_CHAPTERS, newValue as Boolean).commit()
}
}.also(screen::addPreference)
}
private val hideLockedChapters by lazy {
preferences.getBoolean(PREF_HIDE_LOCKED_CHAPTERS, false)
}
private val webviewScript by lazy {
javaClass.getResource("/assets/webview-script.js")?.readText()
?: throw Exception(intl["error_webview_script_not_found"])
}
private fun randomString() = buildString(15) {
val charPool = ('a'..'z') + ('A'..'Z')
for (i in 0 until 15) {
append(charPool.random())
}
}
private fun makeEpisodeSlug(episode: ComikeyEpisode, defaultChapterPrefix: String): String {
val e4pid = episode.id.split("-", limit = 2).last()
val chapterPrefix = if (defaultChapterPrefix == "chapter" && lang != defaultLanguage) {
when (lang) {
"es" -> "capitulo-espanol"
"pt-br" -> "capitulo-portugues"
"fr" -> "chapitre-francais"
"id" -> "bab-bahasa"
else -> "chapter"
}
} else {
defaultChapterPrefix
}
return "$e4pid/$chapterPrefix-${episode.number.toString().replace(".", "-")}"
}
private class JsInterface(
private val latch: CountDownLatch,
private val json: Json,
private val intl: Intl,
) {
var images: List<ComikeyPage> = emptyList()
private set
var manifestUrl: String = ""
private set
var act: String = ""
private set
var error: String = ""
private set
@JavascriptInterface
@Suppress("UNUSED")
fun gettext(key: String): String {
return intl[key]
}
@JavascriptInterface
@Suppress("UNUSED")
fun passError(msg: String) {
error = msg
latch.countDown()
}
@JavascriptInterface
@Suppress("UNUSED")
fun passPayload(manifestUrl: String, act: String, rawData: String) {
this.manifestUrl = manifestUrl
this.act = act
images = json.decodeFromString<ComikeyEpisodeManifest>(rawData).readingOrder
latch.countDown()
}
}
companion object {
internal const val PREFIX_SLUG_SEARCH = "slug:"
internal const val PREF_HIDE_LOCKED_CHAPTERS = "hide_locked_chapters"
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.extension.all.comikey
import eu.kanade.tachiyomi.source.SourceFactory
class ComikeyFactory : SourceFactory {
override fun createSources() = listOf(
Comikey("en"),
Comikey("es"),
Comikey("id"),
Comikey("pt-BR"),
Comikey("pt-BR", "Comikey Brasil", "https://br.comikey.com", defaultLanguage = "pt-BR"),
)
}

View File

@ -0,0 +1,68 @@
package eu.kanade.tachiyomi.extension.all.comikey
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import okhttp3.HttpUrl
fun getComikeyFilters(intl: Intl) = FilterList(
Filter.Header(intl["search_use_two_characters"]),
Filter.Separator(),
SortFilter(intl["sort_by"], getSortOptions(intl)),
TypeFilter(intl["filter_by"], getTypeOptions(intl)),
)
fun getSortOptions(intl: Intl) = arrayOf(
intl["sort_last_updated"],
intl["sort_name"],
intl["sort_popularity"],
intl["sort_chapter_count"],
)
fun getTypeOptions(intl: Intl) = arrayOf(
intl["all"],
intl["manga"],
intl["webtoon"],
intl["new"],
intl["complete"],
intl["exclusive"],
intl["simulpub"],
)
interface UriFilter {
fun addToUri(builder: HttpUrl.Builder)
}
class SortFilter(name: String, values: Array<String>) :
Filter.Sort(name, values, Selection(2, false)),
UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
val state = this.state ?: return
val value = buildString {
if (!state.ascending) {
append("-")
}
when (state.index) {
0 -> append("updated")
1 -> append("name")
2 -> append("views")
3 -> append("chapters")
}
}
builder.addQueryParameter("order", value)
}
}
class TypeFilter(name: String, values: Array<String>) :
Filter.Select<String>(name, values),
UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
if (state == 0) {
return
}
builder.addQueryParameter("filter", values[state].lowercase())
}
}

View File

@ -0,0 +1,85 @@
package eu.kanade.tachiyomi.extension.all.comikey
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ComikeyComic(
val id: Int,
val link: String,
val name: String,
val author: List<ComikeyAuthor>,
val artist: List<ComikeyAuthor>,
val tags: List<ComikeyNameWrapper>,
val description: String,
val excerpt: String,
val e4pid: String,
val format: Int,
val uslug: String,
@SerialName("full_cover") val fullCover: String,
@SerialName("update_status") val updateStatus: Int,
@SerialName("update_text") val updateText: String,
)
@Serializable
data class ComikeyEpisodeListResponse(
val episodes: List<ComikeyEpisode> = emptyList(),
)
@Serializable
data class ComikeyEpisode(
val id: String,
val number: Float = 0F,
val title: String,
val subtitle: String? = null,
val releasedAt: String,
val availability: ComikeyEpisodeAvailability,
val finalPrice: Int = 0,
val owned: Boolean = false,
) {
val readable
get() = finalPrice == 0 || owned
}
@Serializable
data class ComikeyEpisodeManifest(
val readingOrder: List<ComikeyPage>,
)
@Serializable
data class ComikeyPage(
val href: String,
val type: String,
val height: Int,
val width: Int,
val alternate: List<ComikeyAlternatePage>,
)
@Serializable
data class ComikeyAlternatePage(
val href: String,
val type: String,
val height: Int,
val width: Int,
)
@Serializable
data class ComikeyEpisodeAvailability(
val purchaseEnabled: Boolean = false,
)
@Serializable
data class ComikeyLmaoInitData(
val manifest: String,
)
@Serializable
data class ComikeyNameWrapper(
val name: String,
)
@Serializable
data class ComikeyAuthor(
val id: Int,
val name: String,
)

View File

@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.extension.all.comikey
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class ComikeyUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 2) {
val intent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Comikey.PREFIX_SLUG_SEARCH}${pathSegments[1]}/${pathSegments[2]}")
putExtra("filter", packageName)
}
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.e("ComikeyUrlActivity", "Could not start activity", e)
}
} else {
Log.e("ComikeyUrlActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}