Add Comick.fun (#7159)

* Add Comick.fun

* Bugfixes + Improve Chapter Titles and Descriptions
This commit is contained in:
h-hyuuga 2021-05-24 06:13:43 -04:00 committed by GitHub
parent 54a7cdb041
commit 6a404595b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 645 additions and 0 deletions

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.extension">
<application>
<activity
android:name=".all.comickfun.ComickFunUrlActivity"
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="comick.fun"
android:pathPattern="/comic/.*/..*"
android:scheme="https" />
<data
android:host="comick.fun"
android:pathPattern="/comic/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'Comick.fun'
pkgNameSuffix = 'all.comickfun'
extClass = '.ComickFunFactory'
extVersionCode = 1
libVersion = '1.2'
containsNsfw = true
}
dependencies {
implementation project(':lib-ratelimit')
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -0,0 +1,490 @@
package eu.kanade.tachiyomi.extension.all.comickfun
import android.os.Build
import android.text.Html
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.Filter
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 okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import java.lang.UnsupportedOperationException
import java.text.SimpleDateFormat
import kotlin.math.pow
import kotlin.math.truncate
const val SEARCH_PAGE_LIMIT = 100
abstract class ComickFun(override val lang: String, private val comickFunLang: String) : HttpSource() {
override val name = "Comick.fun"
final override val baseUrl = "https://comick.fun"
private val apiBase = "$baseUrl/api"
override val supportsLatest = true
private val mangaIdCache = mutableMapOf<String, Int>()
final override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Tachiyomi " + System.getProperty("http.agent"))
}
final override val client: OkHttpClient
init {
val rateLimiter = RateLimitInterceptor(2)
val builder = super.client.newBuilder()
if (comickFunLang != "all")
// Add interceptor to enforce language
builder.addInterceptor(
Interceptor { chain ->
val request = chain.request()
when {
request.url.toString().contains(Regex("""$apiBase/(?:get_chapters|get_newest_chapters)""")) ->
chain.proceed(request.newBuilder().url(request.url.newBuilder().addQueryParameter("lang", comickFunLang).build()).build())
else -> chain.proceed(request)
}
}
)
/** Rate Limiter, shamelessly ~stolen from~ inspired by MangaDex
* Rate limits all requests that go to the baseurl
*/
builder.addNetworkInterceptor(
Interceptor { chain ->
return@Interceptor when (chain.request().url.toString().startsWith(baseUrl)) {
false -> chain.proceed(chain.request())
true -> rateLimiter.intercept(chain)
}
}
)
this.client = builder.build()
}
/** Utils **/
/**
* Parses a json object with information suitable for showing an entry of a manga within a
* catalogue
*
* Attempts to cache the manga's numerical Id
*
* @return SManga - with url, thumbnail_url and title set
*/
private fun parseMangaObj(it: JsonElement) = it.asJsonObject.let { info ->
info["id"]?.asInt?.let { mangaIdCache.getOrPut(info["slug"].asString, { it }) }
val thumbnail = info["coverURL"]?.nullString
?: info["md_covers"]?.asJsonArray?.get(0)?.asJsonObject?.let { cover ->
cover["gpurl"]?.nullString ?: "$baseUrl${cover["url"].asString}"
}
SManga.create().apply {
url = "/comic/${info["slug"].asString}"
thumbnail_url = thumbnail
title = info["title"].asString
}
}
/** Returns an observable which emits a single value -> the manga's id **/
private fun chapterId(manga: SManga): Observable<Int> {
val mangaSlug = slug(manga)
return mangaIdCache[mangaSlug]?.let { Observable.just(it) }
?: fetchMangaDetails(manga).map { mangaIdCache[mangaSlug] }
}
private fun parseStatus(status: Int) = when (status) {
1 -> SManga.ONGOING
2 -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
/** Attempts to parse an ISO-8601 compliant Date Time string with offset to epoch.
* @returns epochtime on success, 0 on failure
**/
private fun parseISO8601(s: String): Long {
var fractionalPart_ms: Long = 0
val sNoFraction = Regex("""\.\d+""").replace(s) { match ->
fractionalPart_ms = truncate(
match.value.substringAfter(".").toFloat() * 10.0f.pow(-(match.value.length - 1)) * // seconds
1000 // milliseconds
).toLong()
""
}
val ret = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZZ").parse(sNoFraction)?.let {
fractionalPart_ms + it.time
} ?: 0
return ret
}
/** Returns an identifier referred to as `hid` for chapter **/
private fun hid(chapter: SChapter) = "$baseUrl${chapter.url}".toHttpUrl().pathSegments[2].substringBefore("-")
/** Returns an identifier referred to as a `slug` for manga **/
private fun slug(manga: SManga) = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
private fun formatChapterTitle(title: String?, chap: String?, vol: String?): String {
val numNonNull = listOfNotNull(title.takeIf { !it.isNullOrBlank() }, chap, vol).size
if (numNonNull == 0) throw RuntimeException("formatChapterTitle requires at least one non-null argument")
var formattedTitle = StringBuilder()
if (vol != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Vol." } ?: "Volume"} $vol")
if (vol != null && chap != null) formattedTitle.append(", ")
if (chap != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Ch." } ?: "Chapter"} $chap")
if (!title.isNullOrBlank()) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { ": " } ?: ""} $title")
return formattedTitle.toString()
}
/** Popular Manga **/
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", FilterList(emptyList()))
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException("Not used")
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
/** Latest Manga **/
override fun latestUpdatesParse(response: Response): MangasPage {
val noResults = MangasPage(emptyList(), false)
if (response.code == 204)
return noResults
return JsonParser.parseString(response.body!!.string()).obj["data"]?.array?.let { manga ->
MangasPage(manga.map { parseMangaObj(it["md_comics"]) }, true)
} ?: noResults
}
override fun latestUpdatesRequest(page: Int): Request {
val url = "$apiBase/get_newest_chapters".toHttpUrl().newBuilder()
.addQueryParameter("page", "${page - 1}")
.addQueryParameter("device-memory", "8")
return GET("$url", headers)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (!query.startsWith(SLUG_SEARCH_PREFIX))
return super.fetchSearchManga(page, query, filters)
// deeplinking
val potentialUrl = "/comic/${query.substringAfter(SLUG_SEARCH_PREFIX)}"
return fetchMangaDetails(SManga.create().apply { this.url = potentialUrl })
.map { MangasPage(listOf(it.apply { this.url = potentialUrl }), false) }
.onErrorReturn { MangasPage(emptyList(), false) }
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = apiBase.toHttpUrl().newBuilder()
if (query.isNotEmpty()) {
url.addPathSegment("search_title")
.addQueryParameter("t", "1")
.addQueryParameter("q", query)
} else {
url.addPathSegment("search")
.addQueryParameter("page", "$page")
.addQueryParameter("limit", "$SEARCH_PAGE_LIMIT")
filters.forEach { filter ->
when (filter) {
is UrlEncoded -> filter.encode(url)
}
}
}
return GET("$url", headers)
}
override fun searchMangaParse(response: Response): MangasPage = JsonParser.parseString(response.body!!.string()).let {
if (it.isJsonObject)
MangasPage(it["comics"].array.map(::parseMangaObj), it["comics"].array.size() == SEARCH_PAGE_LIMIT)
else // search_title isn't paginated
MangasPage(it.array.map(::parseMangaObj), false)
}
/** Manga Details **/
private fun apiMangaDetailsRequest(manga: SManga): Request {
return GET("$apiBase/get_comic?slug=${slug(manga)}", headers)
}
// Shenanigans to allow "open in webview" to show a webpage instead of JSON
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(apiMangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
override fun mangaDetailsParse(response: Response) = JsonParser.parseString(response.body!!.string())["data"].let { data ->
fun cleanDesc(s: String) = (
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
Html.fromHtml(s, Html.FROM_HTML_MODE_LEGACY) else Html.fromHtml(s)
).toString()
fun nameList(e: JsonElement?) = e?.array?.asSequence()?.map { it["name"].asString }
data["comic"]["id"].asInt.let { mangaIdCache.getOrPut(response.request.url.queryParameter("slug")!!, { it }) }
SManga.create().apply {
title = data["comic"]["title"].asString
thumbnail_url = data["coverURL"].asString
description = cleanDesc(data["comic"]["desc"].asString)
status = parseStatus(data["comic"]["status"].asInt)
artist = nameList(data["artists"])?.joinToString(", ")
author = nameList(data["authors"])?.joinToString(", ")
genre = (
(nameList(data["genres"]) ?: sequenceOf()) + sequence {
data["demographic"].nullString?.let { yield(it) }
mapOf("kr" to "Manhwa", "jp" to "Manga", "cn" to "Manhua")[data["comic"]["country"].nullString]
?.let { yield(it) }
}
).joinToString(", ")
}
}
/** Chapter List **/
private fun chapterListRequest(page: Int, mangaId: Int) =
GET("$apiBase/get_chapters?comicid=$mangaId&page=$page&limit=$SEARCH_PAGE_LIMIT", headers)
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return if (manga.status != SManga.LICENSED) {
chapterId(manga).concatMap { id ->
/**
* Returns an observable which emits the list of chapters found on a page,
* for every page starting from specified page
*/
fun getAllPagesFrom(page: Int, pred: Observable<List<SChapter>> = Observable.just(emptyList())): Observable<List<SChapter>> =
client.newCall(chapterListRequest(page, id))
.asObservableSuccess()
.concatMap { response ->
val cp = chapterListParse(response).map { it.apply { this.url = "${manga.url}${this.url}" } }
if (cp.size == SEARCH_PAGE_LIMIT)
getAllPagesFrom(page + 1, pred = pred.concatWith(Observable.just(cp))) // tail call to avoid blowing the stack
else // by the pigeon-hole principle
pred.concatWith(Observable.just(cp))
}
getAllPagesFrom(1).reduce(List<SChapter>::plus)
}
} else {
Observable.error(Exception("Licensed - No chapters to show"))
}
}
override fun chapterListParse(response: Response) = JsonParser.parseString(response.body!!.string()).obj["data"]["chapters"].array.map { elem ->
val chapter = elem.asJsonObject
val num = chapter["chap"].nullString ?: "-1"
SChapter.create().apply {
date_upload = parseISO8601(chapter["created_at"].asString)
name = formatChapterTitle(chapter["title"].nullString, chapter["chap"].nullString, chapter["vol"].nullString)
chapter_number = num.toFloat()
url = "/${chapter["hid"].asString}-chapter-${chapter["chap"].nullString}-${chapter["iso639_1"].asString}" // incomplete, is finished in fetchChapterList
scanlator = chapter.get("md_groups")?.array?.get(0)?.obj?.get("title")?.asString
}
}
/** Page List **/
override fun pageListRequest(chapter: SChapter) = GET("$apiBase/get_chapter?hid=${hid(chapter)}", headers, CacheControl.FORCE_NETWORK)
override fun pageListParse(response: Response) = JsonParser.parseString(response.body!!.string())["data"]["chapter"]["images"].array.mapIndexed { i, url ->
Page(i, imageUrl = url.asString)
}
override fun imageUrlParse(response: Response) = "" // idk what this does, leave me alone kotlin
/** Filters **/
private interface UrlEncoded {
fun encode(url: HttpUrl.Builder)
}
private interface ArrayUrlParam : UrlEncoded {
val paramName: String
val selected: Sequence<LabeledValue>
override fun encode(url: HttpUrl.Builder) {
url.addQueryParameter(paramName, selected.joinToString(",") { it.value })
}
}
private interface QueryParam : UrlEncoded {
val paramName: String
val selected: LabeledValue
override fun encode(url: HttpUrl.Builder) {
url.addQueryParameter(paramName, selected.value)
}
}
// essentially a named pair
protected class LabeledValue(private val displayname: String, private val _value: String?) {
val value: String get() = _value ?: displayname
override fun toString(): String = displayname
}
private open class Select<T>(header: String, values: Array<T>, state: Int = 0) : Filter.Select<T>(header, values, state) {
val selected: T
get() = this.values[this.state]
}
private open class MultiSelect<T>(header: String, val elems: List<T>) :
Filter.Group<Filter.CheckBox>(header, elems.map { object : Filter.CheckBox("$it") {} }) {
val selected: Sequence<T>
get() = this.elems.asSequence().filterIndexed { i, _ -> this.state[i].state }
}
override fun getFilterList() = FilterList(
Filter.Header("NOTE: Ignored if using text search!"),
GenreFilter(),
DemographicFilter(),
TypesFilter(),
CreatedAtFilter(),
MinChaptersFilter()
)
private fun GenreFilter() = object : MultiSelect<LabeledValue>("Genre", getGenreList()), ArrayUrlParam {
override val paramName = "genres"
}
private fun DemographicFilter() = object : MultiSelect<LabeledValue>("Demographic", getDemographics()), ArrayUrlParam {
override val paramName = "demographic"
}
private fun TypesFilter() = object : MultiSelect<LabeledValue>("Type", getContentType()), ArrayUrlParam {
override val paramName = "country"
}
private fun CreatedAtFilter() = object : Select<LabeledValue>("Created At", getCreatedAt()), QueryParam {
override val paramName = "time"
}
private fun MinChaptersFilter() = object : Filter.Text("Minimum Chapters", ""), UrlEncoded {
override fun encode(url: HttpUrl.Builder) {
if (state.isBlank()) return
state.toIntOrNull()?.takeUnless { it < 0 }?.let {
url.addQueryParameter("minimum", "$it")
} ?: throw RuntimeException("Minimum must be an integer greater than 0")
}
}
protected fun getGenreList() = listOf(
LabeledValue("4-Koma", "4-koma"),
LabeledValue("Action", "action"),
LabeledValue("Adaptation", "adaptation"),
LabeledValue("Adult", "adult"),
LabeledValue("Adventure", "adventure"),
LabeledValue("Aliens", "aliens"),
LabeledValue("Animals", "animals"),
LabeledValue("Anthology", "anthology"),
LabeledValue("Award Winning", "award-winning"),
LabeledValue("Comedy", "comedy"),
LabeledValue("Cooking", "cooking"),
LabeledValue("Crime", "crime"),
LabeledValue("Crossdressing", "crossdressing"),
LabeledValue("Delinquents", "delinquents"),
LabeledValue("Demons", "demons"),
LabeledValue("Doujinshi", "doujinshi"),
LabeledValue("Drama", "drama"),
LabeledValue("Ecchi", "ecchi"),
LabeledValue("Fan Colored", "fan-colored"),
LabeledValue("Fantasy", "fantasy"),
LabeledValue("Full Color", "full-color"),
LabeledValue("Gender Bender", "gender-bender"),
LabeledValue("Genderswap", "genderswap"),
LabeledValue("Ghosts", "ghosts"),
LabeledValue("Gore", "gore"),
LabeledValue("Gyaru", "gyaru"),
LabeledValue("Harem", "harem"),
LabeledValue("Historical", "historical"),
LabeledValue("Horror", "horror"),
LabeledValue("Incest", "incest"),
LabeledValue("Isekai", "isekai"),
LabeledValue("Loli", "loli"),
LabeledValue("Long Strip", "long-strip"),
LabeledValue("Mafia", "mafia"),
LabeledValue("Magic", "magic"),
LabeledValue("Magical Girls", "magical-girls"),
LabeledValue("Martial Arts", "martial-arts"),
LabeledValue("Mature", "mature"),
LabeledValue("Mecha", "mecha"),
LabeledValue("Medical", "medical"),
LabeledValue("Military", "military"),
LabeledValue("Monster Girls", "monster-girls"),
LabeledValue("Monsters", "monsters"),
LabeledValue("Music", "music"),
LabeledValue("Mystery", "mystery"),
LabeledValue("Ninja", "ninja"),
LabeledValue("Office Workers", "office-workers"),
LabeledValue("Official Colored", "official-colored"),
LabeledValue("Oneshot", "oneshot"),
LabeledValue("Philosophical", "philosophical"),
LabeledValue("Police", "police"),
LabeledValue("Post-Apocalyptic", "post-apocalyptic"),
LabeledValue("Psychological", "psychological"),
LabeledValue("Reincarnation", "reincarnation"),
LabeledValue("Reverse Harem", "reverse-harem"),
LabeledValue("Romance", "romance"),
LabeledValue("Samurai", "samurai"),
LabeledValue("School Life", "school-life"),
LabeledValue("Sci-Fi", "sci-fi"),
LabeledValue("Sexual Violence", "sexual-violence"),
LabeledValue("Shota", "shota"),
LabeledValue("Shoujo Ai", "shoujo-ai"),
LabeledValue("Shounen Ai", "shounen-ai"),
LabeledValue("Slice of Life", "slice-of-life"),
LabeledValue("Smut", "smut"),
LabeledValue("Sports", "sports"),
LabeledValue("Superhero", "superhero"),
LabeledValue("Supernatural", "supernatural"),
LabeledValue("Survival", "survival"),
LabeledValue("Thriller", "thriller"),
LabeledValue("Time Travel", "time-travel"),
LabeledValue("Traditional Games", "traditional-games"),
LabeledValue("Tragedy", "tragedy"),
LabeledValue("User Created", "user-created"),
LabeledValue("Vampires", "vampires"),
LabeledValue("Video Games", "video-games"),
LabeledValue("Villainess", "villainess"),
LabeledValue("Virtual Reality", "virtual-reality"),
LabeledValue("Web Comic", "web-comic"),
LabeledValue("Wuxia", "wuxia"),
LabeledValue("Yaoi", "yaoi"),
LabeledValue("Yuri", "yuri"),
LabeledValue("Zombies", "zombies")
)
private fun getDemographics() = listOf(
LabeledValue("Shonen", "1"),
LabeledValue("Shoujo", "2"),
LabeledValue("Seinen", "3"),
LabeledValue("Josei", "4"),
)
private fun getContentType() = listOf(
LabeledValue("Manga", "jp"),
LabeledValue("Manhwa", "kr"),
LabeledValue("Manhua", "cn"),
)
private fun getCreatedAt() = arrayOf(
LabeledValue("", ""),
LabeledValue("30 days", "30"),
LabeledValue("3 months", "90"),
LabeledValue("6 months", "180"),
LabeledValue("1 year", "365"),
)
companion object {
const val SLUG_SEARCH_PREFIX = "id:"
}
}

View File

@ -0,0 +1,77 @@
package eu.kanade.tachiyomi.extension.all.comickfun
import eu.kanade.tachiyomi.annotations.Nsfw
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
val toISO639 = mapOf(
"gb" to "en", // English
"br" to "pt-BR", // Brazillian Portugese
"mx" to "es-419", // Latin-American Spanish
"vn" to "vi", // Vietnemese
"hk" to "zh-Hant", // Traditional Chinese,
"cn" to "zh-Hans", // Simplified Chinese
"sa" to "ar", // Arabic
"ct" to "ca", // Catalan; Valencian
"ir" to "fa", // Persian
"ua" to "uk", // Ukranian
"il" to "he", // hebrew
"my" to "ms", // Malay
"ph" to "tl", // Filipino
"jp" to "ja", // Japanese
"in" to "hi", // Hindi
"kr" to "ko", // Korean
"cz" to "cs", // Czech
"bd" to "bn", // Bengali
"gr" to "el", // Modern Greek
"rs" to "sr", // Serbo-Croatian
"dk" to "da", // Danish
).withDefault { it } // country code matches language code
@Nsfw
class ComickFunFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
"all",
"gb",
"br",
"ru",
"fr",
"mx",
"pl",
"tr",
"it",
"es",
"id",
"hu",
"vn",
"hk",
"sa",
"de",
"cn",
"ct",
"bg",
"th",
"ir",
"ua",
"mn",
"ro",
"il",
"my",
"ph",
"jp",
"in",
"mm",
"kr",
"cz",
"pt",
"nl",
"se",
"bd",
"no",
"lt",
"gr",
"rs",
"dk"
).map { object : ComickFun(toISO639.getValue(it), it) {} }
}

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.extension.all.comickfun
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 ComickFunUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val slug = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${ComickFun.SLUG_SEARCH_PREFIX}$slug")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("ComickFunUrlActivity", e.toString())
}
} else {
Log.e("ComickFunUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}