CatManga: 3.0 updates (#9263)

* CatManga: 3.0 updates

* CatManga: Bump version
This commit is contained in:
Ivan Iskandar 2021-09-28 19:18:02 +07:00 committed by GitHub
parent b6fb13ec97
commit a6c5e5b826
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 154 additions and 112 deletions

View File

@ -6,7 +6,7 @@ ext {
extName = 'CatManga' extName = 'CatManga'
pkgNameSuffix = "en.catmanga" pkgNameSuffix = "en.catmanga"
extClass = '.CatManga' extClass = '.CatManga'
extVersionCode = 4 extVersionCode = 5
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -0,0 +1,60 @@
package eu.kanade.tachiyomi.extension.en.catmanga
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
@Serializable
data class CatSeries(
val alt_titles: List<String>,
val authors: List<String>,
val genres: List<String>,
val chapters: List<CatSeriesChapter>,
val title: String,
val series_id: String,
val description: String,
val status: String,
val cover_art: CatSeriesCover,
val all_covers: List<CatSeriesCover>
) {
fun toSManga() = this.let { series ->
SManga.create().apply {
url = "/series/${series.series_id}"
title = series.title
thumbnail_url = series.cover_art.source
author = series.authors.joinToString(", ")
description = series.description
genre = series.genres.joinToString(", ")
status = when (series.status) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
if (chapters.isEmpty()) {
description = "[COMING SOON] $description"
}
if (alt_titles.isNotEmpty()) {
description += "\n\nAlternative titles:\n"
alt_titles.forEach {
description += "$it\n"
}
}
}
}
}
@Serializable
data class CatSeriesChapter(
val title: String? = null,
val groups: List<String>,
val number: Float,
val display_number: String? = null,
val volume: Int? = null
)
@Serializable
data class CatSeriesCover(
val source: String,
val width: Int,
val height: Int
)

View File

@ -10,128 +10,120 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request
import okhttp3.Protocol
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.HttpURLConnection
@ExperimentalSerializationApi
class CatManga : HttpSource() { class CatManga : HttpSource() {
private val application: Application by injectLazy() private val application: Application by injectLazy()
override val name = "CatManga" override val name = "CatManga"
override val baseUrl = "https://catmanga.org" override val baseUrl = "https://catmanga.org"
override val supportsLatest = true override val supportsLatest = false
override val lang = "en" override val lang = "en"
private val json: Json by injectLazy() private val json: Json by injectLazy()
private lateinit var seriesCache: LinkedHashMap<String, JsonSeries> // LinkedHashMap to preserve insertion order private val allSeriesRequest = GET("$baseUrl/api/series/allSeries")
private lateinit var latestSeries: List<String>
override val client = super.client.newBuilder().addInterceptor { chain ->
// An interceptor which facilitates caching the data retrieved from the homepage
when (chain.request().url) {
doNothingRequest.url -> Response.Builder().body(
"".toResponseBody("text/plain; charset=utf-8".toMediaType())
).code(HttpURLConnection.HTTP_NO_CONTENT).message("").protocol(Protocol.HTTP_1_0).request(chain.request()).build()
homepageRequest.url -> {
/* Homepage embeds a Json Object with information about every single series in the service */
val response = chain.proceed(chain.request())
val responseBody = response.peekBody(Long.MAX_VALUE).string()
val seriesList = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["series"]!!
val latests = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["latests"]!!
seriesCache = linkedMapOf(
*json.decodeFromJsonElement<List<JsonSeries>>(seriesList).map { it.series_id to it }.toTypedArray()
)
latestSeries = json.decodeFromJsonElement<List<List<JsonElement>>>(latests).map { json.decodeFromJsonElement<JsonSeries>(it[0]).series_id }
response
}
else -> chain.proceed(chain.request())
}
}.build()
private val homepageRequest = GET(baseUrl) override fun popularMangaRequest(page: Int) = allSeriesRequest
private val doNothingRequest = GET("https://dev.null")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = allSeriesRequest
override fun popularMangaRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
override fun latestUpdatesRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
override fun chapterListRequest(manga: SManga) = homepageRequest
private fun idOf(manga: SManga) = manga.url.substringAfterLast("/")
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters)) return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess() .asObservableSuccess()
.map { .map { response ->
val manga = seriesCache.asSequence().map { it.value }.filter { val manga = json.decodeFromString<List<CatSeries>>(response.body!!.string())
.filter {
if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) { if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
return@filter it.series_id.contains(query.removePrefix(SERIES_ID_SEARCH_PREFIX), true) return@filter it.series_id.contains(query.removePrefix(SERIES_ID_SEARCH_PREFIX), true)
} }
sequence { yieldAll(it.alt_titles); yield(it.title) } sequence { yieldAll(it.alt_titles); yield(it.title) }
.any { title -> title.contains(query, true) } .any { title -> title.contains(query, true) }
}.map { it.toSManga() }.toList() }
.map { it.toSManga() }
.toList()
MangasPage(manga, false) MangasPage(manga, false)
} }
} }
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = client.newCall(homepageRequest) override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
val seriesId = manga.url.substringAfter("/series/")
return client.newCall(allSeriesRequest)
.asObservableSuccess() .asObservableSuccess()
.map { seriesCache[idOf(manga)]?.toSManga() ?: manga } .map { response ->
json.decodeFromString<List<CatSeries>>(response.body!!.string())
.find { it.series_id == seriesId }
?.toSManga() ?: manga
}
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val seriesId = manga.url.substringAfter("/series/") val seriesId = manga.url.substringAfter("/series/")
return client.newCall(chapterListRequest(manga)) return client.newCall(allSeriesRequest)
.asObservableSuccess() .asObservableSuccess()
.map { .map { response ->
val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0) val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
val seriesPrefsEditor = seriesPrefs.edit() val seriesPrefsEditor = seriesPrefs.edit()
val cl = seriesCache[idOf(manga)]!!.chapters.asReversed().map { val chapters = json.decodeFromString<List<CatSeries>>(response.body!!.string())
val title = it.title ?: "" .find { it.series_id == seriesId }!!
val groups = it.groups.joinToString(", ") .chapters
val number = it.number.content .asReversed()
val displayNumber = it.display_number ?: number .map { chapter ->
val title = chapter.title ?: ""
val groups = chapter.groups.joinToString(", ")
val numberUrl = chapter.number.chapterNumberToUrlPath()
val displayNumber = chapter.display_number ?: numberUrl
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/$number" url = "${manga.url}/$numberUrl"
chapter_number = number.toFloat() chapter_number = chapter.number
name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
scanlator = groups scanlator = groups
// Save current time when a chapter is found for the first time, and reuse it on future checks to name = if (chapter.volume != null) {
// prevent manga entry without any new chapter bumped to the top of "Latest chapter" list "Vol.${chapter.volume} "
// when the library is updated. } else {
val currentTimeMillis = System.currentTimeMillis() ""
if (!seriesPrefs.contains(number)) {
seriesPrefsEditor.putLong(number, currentTimeMillis)
} }
date_upload = seriesPrefs.getLong(number, currentTimeMillis) name += "Ch.$displayNumber"
if (title.isNotBlank()) {
name += " - $title"
}
// Save current time when a chapter is found for the first time, and reuse it on future
// checks to prevent manga entry without any new chapter bumped to the top of
// "Latest chapter" list when the library is updated.
val currentTimeMillis = System.currentTimeMillis()
if (!seriesPrefs.contains(numberUrl)) {
seriesPrefsEditor.putLong(numberUrl, currentTimeMillis)
}
date_upload = seriesPrefs.getLong(numberUrl, currentTimeMillis)
} }
} }
seriesPrefsEditor.apply() seriesPrefsEditor.apply()
cl chapters
} }
} }
override fun popularMangaParse(response: Response) = MangasPage(seriesCache.map { it.value.toSManga() }, false) override fun popularMangaParse(response: Response): MangasPage {
val mangas = json.decodeFromString<List<CatSeries>>(response.body!!.string()).map { it.toSManga() }
override fun latestUpdatesParse(response: Response) = MangasPage( return MangasPage(mangas, false)
latestSeries.map { seriesCache[it]!!.toSManga() }, }
false
)
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
return json.decodeFromJsonElement<List<String>>(response.asJsoup().getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["pages"]!!).mapIndexed { index, s -> val jsonElement = response.asJsoup()
Page(index, "", s) .getDataJsonObject()["props"]!!
} .jsonObject["pageProps"]!!
.jsonObject["pages"]!!
return json.decodeFromJsonElement<List<String>>(jsonElement).mapIndexed { index, s -> Page(index, "", s) }
} }
/** /**
@ -139,6 +131,21 @@ class CatManga : HttpSource() {
*/ */
private fun Document.getDataJsonObject() = json.parseToJsonElement(getElementById("__NEXT_DATA__").html()).jsonObject private fun Document.getDataJsonObject() = json.parseToJsonElement(getElementById("__NEXT_DATA__").html()).jsonObject
/**
* Returns string without decimal when it is not relevant
*/
private fun Float.chapterNumberToUrlPath(): String {
return if (toInt().toFloat() == this) toInt().toString() else toString()
}
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException("Not used.")
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException("Not used.")
}
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
throw UnsupportedOperationException("Not used.") throw UnsupportedOperationException("Not used.")
} }
@ -159,28 +166,3 @@ class CatManga : HttpSource() {
const val SERIES_ID_SEARCH_PREFIX = "series_id:" const val SERIES_ID_SEARCH_PREFIX = "series_id:"
} }
} }
@Serializable
private data class JsonImage(val source: String, val width: Int, val height: Int)
@Serializable
private data class JsonChapter(val title: String? = null, val groups: List<String>, val number: JsonPrimitive, val display_number: String? = null, val volume: Int? = null)
@Serializable
private data class JsonSeries(val alt_titles: List<String>, val authors: List<String>, val genres: List<String>, val chapters: List<JsonChapter>, val title: String, val series_id: String, val description: String, val status: String, val cover_art: JsonImage, val all_covers: List<JsonImage>) {
fun toSManga() = this.let { jsonSeries ->
SManga.create().apply {
url = "/series/${jsonSeries.series_id}"
title = jsonSeries.title
thumbnail_url = jsonSeries.cover_art.source
author = jsonSeries.authors.joinToString(", ")
description = jsonSeries.description
genre = jsonSeries.genres.joinToString(", ")
status = when (jsonSeries.status) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
}