Add Comikey source (#8273)
This commit is contained in:
parent
bf119937ff
commit
8049bae936
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".en.comikey.ComikeyURLActivity"
|
||||
android:excludeFromRecents="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"
|
||||
android:pathPattern="/comics/..*"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="comikey.com"
|
||||
android:pathPattern="/read/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
<data
|
||||
android:host="comikey.com"
|
||||
android:pathPattern="/comics/..*"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="comikey.com"
|
||||
android:pathPattern="/read/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
|
@ -0,0 +1,13 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Comikey'
|
||||
pkgNameSuffix = 'en.comikey'
|
||||
extClass = '.Comikey'
|
||||
extVersionCode = 1
|
||||
libVersion = '1.2'
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
|
@ -0,0 +1,295 @@
|
|||
package eu.kanade.tachiyomi.extension.en.comikey
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import eu.kanade.tachiyomi.extension.en.comikey.dto.MangaDetailsDto
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.parser.Parser
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.net.URLEncoder
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
class Comikey : HttpSource(), ConfigurableSource {
|
||||
override val name = "Comikey"
|
||||
|
||||
override val baseUrl = "https://comikey.com"
|
||||
|
||||
private val apiUrl = "$baseUrl/sapi"
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SLUG_SEARCH_PREFIX = "slug:"
|
||||
}
|
||||
|
||||
// Home page functions
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics/?order=-views&page=$page", headers)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics/?page=$page", headers)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/comics/?q=${URLEncoder.encode(query, "utf-8")}&page=$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response) = mangaParse(response)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = mangaParse(response)
|
||||
|
||||
override fun searchMangaParse(response: Response) = mangaParse(response)
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith(SLUG_SEARCH_PREFIX)) {
|
||||
val manga = SManga.create().apply {
|
||||
url = "/comics/" + query.removePrefix(SLUG_SEARCH_PREFIX)
|
||||
}
|
||||
return fetchMangaDetails(manga).map { mangaWithDetails ->
|
||||
MangasPage(listOf(mangaWithDetails), false)
|
||||
}
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mangaParse(response: Response): MangasPage {
|
||||
|
||||
val responseJson = response.asJsoup()
|
||||
|
||||
val mangaList = responseJson.select("section#series-list div.series-listing[data-view=list] > ul > li")
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
title = it.selectFirst("span.title a").text()
|
||||
url = it.selectFirst("span.title a[href]").attr("href")
|
||||
|
||||
val subtitle = it.selectFirst("span.subtitle").text().removePrefix("by")
|
||||
author = subtitle.substringBefore("|").trim()
|
||||
artist = subtitle.substringAfter("|").trim()
|
||||
|
||||
thumbnail_url = it.selectFirst("div.image[style*=url(]")
|
||||
?.attr("style")
|
||||
?.substringAfter("url(")?.substringBefore(")")
|
||||
?: "https://comikey.com/static/images/svgs/no-cover.svg"
|
||||
|
||||
genre = it.select("div.categories > ul.category-listing > li > span.category-button")
|
||||
.joinToString(", ") { el -> el.text() }
|
||||
|
||||
description = it.selectFirst("div.description").text()
|
||||
status = SManga.UNKNOWN
|
||||
initialized = true // we already have all of the fields
|
||||
}
|
||||
}
|
||||
// we have a next page if the "Next Page" button is not disabled
|
||||
val hasNextPage = responseJson.selectFirst("li.page-item.active ~ li.page-item.disabled") == null &&
|
||||
responseJson.selectFirst("li.page-item.active ~ li.page-item:not(.disabled)") != null
|
||||
|
||||
return MangasPage(mangaList, hasNextPage)
|
||||
}
|
||||
|
||||
// Manga page functions
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return getMangaId(manga).flatMap { id ->
|
||||
client.newCall(mangaDetailsRequest(id))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mangaDetailsParse(response).apply { initialized = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mangaDetailsRequest(id: Int) = GET("$apiUrl/comics/$id?format=json", headers)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val details = json.decodeFromString<MangaDetailsDto>(response.body!!.string())
|
||||
return SManga.create().apply {
|
||||
title = details.name!!
|
||||
url = details.link!!
|
||||
author = details.author?.map { it?.name }?.joinToString(", ")
|
||||
artist = details.artist?.map { it?.name }?.joinToString(", ")
|
||||
thumbnail_url = details.cover
|
||||
genre = details.tags?.map { it?.name }?.joinToString(", ")
|
||||
description = details.excerpt + "\n\n" + details.description
|
||||
status = SManga.UNKNOWN
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMangaId(manga: SManga): Observable<Int> {
|
||||
val mangaId = manga.url.trimEnd('/').substringAfterLast('/').toIntOrNull()
|
||||
return if (mangaId != null) {
|
||||
Observable.just(mangaId)
|
||||
} else {
|
||||
client.newCall(GET(baseUrl + manga.url, headers))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
manga.url = response.asJsoup().selectFirst("meta[property=og:url]").attr("content")
|
||||
manga.url.trimEnd('/').substringAfterLast('/').toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rssFeedRequest(mangaId: Int) = GET("$apiUrl/comics/$mangaId/feed.rss", headers)
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
val chapterList = getMangaId(manga).flatMap { mangaId ->
|
||||
client.newCall(rssFeedRequest(mangaId))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
chapterListParse(response, mangaId)
|
||||
}
|
||||
}
|
||||
return if (preferences.getBoolean("filterOwnedChapter", false)) {
|
||||
chapterList.flatMap { it.filterChapterList() }
|
||||
} else {
|
||||
chapterList
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = throw UnsupportedOperationException("Not used (chapterListRequest)")
|
||||
|
||||
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used (chapterListParse)")
|
||||
|
||||
private fun chapterListParse(response: Response, mangaId: Int): List<SChapter> {
|
||||
return Jsoup.parse(response.body!!.string(), response.request.url.toString(), Parser.xmlParser())
|
||||
.select("channel > item").map { item ->
|
||||
SChapter.create().apply {
|
||||
val chapterGuid = item.selectFirst("guid").text().substringAfterLast(':')
|
||||
url = "$apiUrl/comics/$mangaId/read?format=json&content=$chapterGuid"
|
||||
name = item.selectFirst("title").text()
|
||||
date_upload = SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", java.util.Locale.US)
|
||||
.parse(item.selectFirst("pubDate").text())
|
||||
?.time ?: 0L
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
private data class IndexedChapter(val index: Int, val chapter: SChapter) : Comparable<IndexedChapter> {
|
||||
override fun compareTo(other: IndexedChapter) = this.index.compareTo(other.index)
|
||||
}
|
||||
|
||||
// determine which chapters the user has access to, and which are locked behind a paywall
|
||||
private fun List<SChapter>.filterChapterList(): Observable<List<SChapter>> {
|
||||
return Observable.from(this.mapIndexed { index, chapter -> IndexedChapter(index, chapter) })
|
||||
.filterByObservable { (_, chapter) ->
|
||||
chapter.isAvailable()
|
||||
}.toSortedList()
|
||||
.map { indexed -> indexed.map { it.chapter } }
|
||||
}
|
||||
|
||||
private fun <T> Observable<T>.filterByObservable(predicate: rx.functions.Func1<in T, Observable<Boolean>>): Observable<T> {
|
||||
return this.flatMap { item ->
|
||||
predicate.call(item)
|
||||
.first()
|
||||
.filter { it }
|
||||
.map { item }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SChapter.isAvailable(): Observable<Boolean> {
|
||||
return client.newCall(pageListRequest(this))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
response.body?.string()
|
||||
?.let { Json.parseToJsonElement(it) }
|
||||
?.jsonObject?.get("ok")
|
||||
?.jsonPrimitive?.booleanOrNull
|
||||
?: true // Default to displaying the chapter if we get an error
|
||||
}
|
||||
}
|
||||
|
||||
// Chapter page functions
|
||||
|
||||
private val urlForbidden = "https://fakeimg.pl/1800x2252/FFFFFF/000000/?font_size=120&text=This%20chapter%20is%20not%20available%20for%20free.%0A%0AIf%20you%20have%20purchased%20this%20chapter%2C%20please%20%0Aopen%20the%20website%20in%20web%20view%20and%20log%20in."
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.flatMap { response ->
|
||||
val request = getActualPageList(response)
|
||||
?: return@flatMap Observable.just(listOf(Page(0, urlForbidden, urlForbidden)))
|
||||
|
||||
client.newCall(request)
|
||||
.asObservableSuccess()
|
||||
.map { responseActual ->
|
||||
pageListParse(responseActual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) = GET(chapter.url, headers)
|
||||
|
||||
private fun getActualPageList(response: Response): Request? {
|
||||
val element = Json.parseToJsonElement(response.body!!.string()).jsonObject
|
||||
val ok = element["ok"]?.jsonPrimitive?.booleanOrNull
|
||||
if (ok != null && !ok) {
|
||||
return null
|
||||
}
|
||||
val url = element["href"]?.jsonPrimitive?.content
|
||||
return GET(url!!, headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
return Json.parseToJsonElement(response.body!!.string())
|
||||
.jsonObject["readingOrder"]!!
|
||||
.jsonArray.mapIndexed { index, element ->
|
||||
val url = element.jsonObject["href"]!!.jsonPrimitive.content
|
||||
Page(index, url, url)
|
||||
}
|
||||
}
|
||||
|
||||
// the image url is always equal to the page url
|
||||
override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.url)
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used (imageUrlParse)")
|
||||
|
||||
// Preferences
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
val filterOwnedChapterPref = androidx.preference.CheckBoxPreference(screen.context).apply {
|
||||
key = "filterOwnedChapter"
|
||||
title = "[Experimental] Only show free/owned chapters"
|
||||
setDefaultValue(false)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Boolean
|
||||
preferences.edit().putBoolean("filterOwnedChapter", checkValue).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(filterOwnedChapterPref)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package eu.kanade.tachiyomi.extension.en.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 mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", Comikey.SLUG_SEARCH_PREFIX + pathSegments[1])
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("ComikeyUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("ComikeyUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
package eu.kanade.tachiyomi.extension.en.comikey.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MangaDetailsDto(
|
||||
@SerialName("id")
|
||||
val id: Int? = null,
|
||||
@SerialName("link")
|
||||
val link: String? = null,
|
||||
@SerialName("name")
|
||||
val name: String? = null,
|
||||
@SerialName("e4pid")
|
||||
val e4pid: String? = null,
|
||||
@SerialName("uslug")
|
||||
val uslug: String? = null,
|
||||
@SerialName("alt")
|
||||
val alt: String? = null,
|
||||
@SerialName("author")
|
||||
val author: List<Author?>? = null,
|
||||
@SerialName("artist")
|
||||
val artist: List<Artist?>? = null,
|
||||
@SerialName("adult")
|
||||
val adult: Boolean? = null,
|
||||
@SerialName("tags")
|
||||
val tags: List<Tag?>? = null,
|
||||
@SerialName("keywords")
|
||||
val keywords: String? = null,
|
||||
@SerialName("description")
|
||||
val description: String? = null,
|
||||
@SerialName("excerpt")
|
||||
val excerpt: String? = null,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String? = null,
|
||||
@SerialName("modified_at")
|
||||
val modifiedAt: String? = null,
|
||||
@SerialName("publisher")
|
||||
val publisher: Publisher? = null,
|
||||
@SerialName("color")
|
||||
val color: String? = null,
|
||||
@SerialName("in_exclusive")
|
||||
val inExclusive: Boolean? = null,
|
||||
@SerialName("in_hype")
|
||||
val inHype: Boolean? = null,
|
||||
@SerialName("all_free")
|
||||
val allFree: Boolean? = null,
|
||||
@SerialName("availability_strategy")
|
||||
val availabilityStrategy: AvailabilityStrategy? = null,
|
||||
@SerialName("campaigns")
|
||||
val campaigns: List<String?>? = null, // unknown list element type, was null
|
||||
@SerialName("last_updated")
|
||||
val lastUpdated: String? = null,
|
||||
@SerialName("chapter_count")
|
||||
val chapterCount: Int? = null,
|
||||
@SerialName("update_status")
|
||||
val updateStatus: Int? = null,
|
||||
@SerialName("update_text")
|
||||
val updateText: String? = null,
|
||||
@SerialName("format")
|
||||
val format: Int? = null,
|
||||
@SerialName("cover")
|
||||
val cover: String? = null,
|
||||
@SerialName("logo")
|
||||
val logo: String? = null,
|
||||
@SerialName("banner")
|
||||
val banner: String? = null,
|
||||
@SerialName("showcase")
|
||||
val showcase: String? = null, // unknown type, was null
|
||||
@SerialName("preview")
|
||||
val preview: String? = null, // unknown type, was null
|
||||
@SerialName("chapter_title")
|
||||
val chapterTitle: String? = null,
|
||||
@SerialName("geoblocks")
|
||||
val geoblocks: String? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Author(
|
||||
@SerialName("id")
|
||||
val id: Int? = null,
|
||||
@SerialName("name")
|
||||
val name: String? = null,
|
||||
@SerialName("alt")
|
||||
val alt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
@SerialName("id")
|
||||
val id: Int? = null,
|
||||
@SerialName("name")
|
||||
val name: String? = null,
|
||||
@SerialName("alt")
|
||||
val alt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Tag(
|
||||
@SerialName("name")
|
||||
val name: String? = null,
|
||||
@SerialName("description")
|
||||
val description: String? = null,
|
||||
@SerialName("slug")
|
||||
val slug: String? = null,
|
||||
@SerialName("color")
|
||||
val color: String? = null,
|
||||
@SerialName("is_primary")
|
||||
val isPrimary: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Publisher(
|
||||
@SerialName("id")
|
||||
val id: Int? = null,
|
||||
@SerialName("name")
|
||||
val name: String? = null,
|
||||
@SerialName("language")
|
||||
val language: String? = null,
|
||||
@SerialName("homepage")
|
||||
val homepage: String? = null,
|
||||
@SerialName("logo")
|
||||
val logo: String? = null,
|
||||
@SerialName("geoblocks")
|
||||
val geoblocks: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AvailabilityStrategy(
|
||||
@SerialName("starting_count")
|
||||
val startingCount: Int? = null,
|
||||
@SerialName("latest_only_free")
|
||||
val latestOnlyFree: Boolean? = null,
|
||||
@SerialName("catchup_count")
|
||||
val catchupCount: Int? = null,
|
||||
@SerialName("simulpub")
|
||||
val simulpub: Boolean? = null,
|
||||
@SerialName("fpf_becomes_paid")
|
||||
val fpfBecomesPaid: String? = null,
|
||||
@SerialName("fpf_becomes_free")
|
||||
val fpfBecomesFree: String? = null,
|
||||
@SerialName("fpf_becomes_backlog")
|
||||
val fpfBecomesBacklog: String? = null,
|
||||
@SerialName("backlog_becomes_backlog")
|
||||
val backlogBecomesBacklog: String? = null
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue