Add Manga Cross (#11936)

* Add Manga Cross

* Manga Cross: show chapter end date in scanlator field

* Manga Cross: set next date to 10 am JST
This commit is contained in:
kasperskier 2022-05-24 08:11:12 +08:00 committed by GitHub
parent 6e8fe89f53
commit bdeafa7883
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 297 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Manga Cross'
pkgNameSuffix = 'ja.mangacross'
extClass = '.MangaCross'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.extension.ja.mangacross
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.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import kotlin.concurrent.thread
const val maxEntries = 9999
class MangaCross : HttpSource() {
override val name = "Manga Cross"
override val lang = "ja"
override val baseUrl = "https://mangacross.jp"
override val supportsLatest = true
private val json: Json by injectLazy()
override fun popularMangaRequest(page: Int) = GET("$baseUrl/api/comics.json?count=$maxEntries", headers)
override fun popularMangaParse(response: Response) = MangasPage(
json.decodeFromString<MCComicList>(response.body!!.string()).comics.map(MCComic::toSManga),
false // pagination does not work
)
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/episodes.json?page=$page", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val result = json.decodeFromString<MCEpisodeList>(response.body!!.string())
return MangasPage(result.episodes.map { it.comic!!.toSManga() }, result.current_page < result.total_pages)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
if (query.isNotEmpty()) {
GET("$baseUrl/api/comics/keywords/$query.json", headers)
} else when (val tag = filters.filterIsInstance<TagFilter>().firstOrNull()?.getTag()) {
null -> popularMangaRequest(page)
is MCComicCategory -> GET("$baseUrl/api/comics/categories/${tag.name}.json", headers)
is MCComicGenre -> GET("$baseUrl/api/comics/tags/${tag.name}.json", headers)
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { mangaDetailsParse(it).apply { initialized = true } }
// mangaDetailsRequest untouched in order to let WebView open web page instead of json
override fun mangaDetailsParse(response: Response) =
json.decodeFromString<MCComicDetails>(response.body!!.string()).comic.toSManga()
override fun chapterListRequest(manga: SManga) = GET("$baseUrl/api${manga.url}.json", headers)
override fun chapterListParse(response: Response) =
json.decodeFromString<MCComicDetails>(response.body!!.string()).comic.toSChapterList()
override fun pageListParse(response: Response): List<Page> {
return try {
json.decodeFromString<MCViewer>(response.body!!.string()).episode_pages.mapIndexed { i, it ->
Page(i, "", it.image.original_url)
}
} catch (e: SerializationException) {
throw Exception("Chapter is no longer available!")
}
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
private lateinit var tags: List<Pair<String, MCComicTag?>>
init {
thread {
val response = client.newCall(GET("$baseUrl/api/menus.json", headers)).execute()
val filterList = json.decodeFromString<MCMenu>(response.body!!.string()).toFilterList()
tags = listOf(Pair("None", null)) + filterList
}
}
override fun getFilterList() =
if (::tags.isInitialized) FilterList(
Filter.Header("NOTE: Ignored if using text search!"),
TagFilter("Tag", tags)
) else FilterList(
Filter.Header("Tags not fetched yet. Go back and retry."),
)
private class TagFilter(displayName: String, private val tags: List<Pair<String, MCComicTag?>>) :
Filter.Select<String>(displayName, tags.map { it.first }.toTypedArray()) {
fun getTag() = tags[state].second
}
}

View File

@ -0,0 +1,180 @@
package eu.kanade.tachiyomi.extension.ja.mangacross
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import java.text.DateFormat.getDateTimeInstance
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Serializable
data class MCComicList(
val comics: List<MCComic>,
// Note: Pagination does not work. Pages after 1 return nothing.
// val current_count: Int,
// val current_page: Int?,
// val total_count: Int,
// val total_pages: Int,
)
// Useless fields are omitted while interesting ones are commented
@Serializable
data class MCComic(
val dir_name: String,
val title: String,
val author: String,
val comic_category: MCComicCategory? = null,
val comic_tags: List<MCComicGenre>,
val image_double_url: String, // horizontal
val list_image_double_url: String, // square
// val restricted: Boolean, // is NSFW
// Details below
val outline: String? = null,
val episodes: List<MCEpisode>? = null,
// val books: List<MCBook>? = null,
) {
fun toSManga() = SManga.create().apply {
url = "/comics/$dir_name"
title = this@MCComic.title
author = this@MCComic.author
description = getDescription()
genre = getGenre()
status = getStatus()
thumbnail_url = list_image_double_url
}
fun toSChapterList() = episodes!!
// .filter { it.status == "public" } // preserve private chapters in case user downloaded before
.map { it.toSChapter("/comics/$dir_name") }
private fun getDescription() = listOfNotNull(
episodes?.firstOrNull()?.getNextDatePrefix(),
outline?.stripHtml()
).joinToString("\n")
private fun getGenre() = listOfNotNull(
comic_category?.display_name,
comic_tags.joinToString(", ") { it.name }
).joinToString(", ")
private fun getStatus() = when {
episodes?.firstOrNull()?.episode_next_date.isNullOrEmpty() -> SManga.UNKNOWN
else -> SManga.ONGOING
}
private fun String.stripHtml() = Jsoup.parseBodyFragment(this).text()
}
sealed class MCComicTag
@Serializable
data class MCComicCategory(val name: String, val display_name: String) : MCComicTag()
@Serializable
data class MCComicGenre(val name: String) : MCComicTag()
@Serializable
data class MCComicDetails(val comic: MCComic)
@Serializable
data class MCEpisodeList(
val episodes: List<MCEpisode>,
// Note: Pagination works.
val current_count: Int,
val current_page: Int,
val total_count: Int,
val total_pages: Int,
)
private val jstDate by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.ENGLISH).apply {
timeZone = TimeZone.getTimeZone("GMT+09:00")
}
}
private val localDate by lazy { getDateTimeInstance() }
private fun parseJSTDate(date: String) = date.removeSuffix("+09:00").let { jstDate.parse(it) }!!
@Serializable
data class MCEpisode(
// val id: Long,
val volume: String,
val sort_volume: Int,
val title: String,
val publish_start: String, // all dates are in ISO time format
val publish_end: String?,
// Note: AFAIK these dates are always identical to those above.
// val member_publish_start: String,
// val member_publish_end: String?,
val status: String, // public or private
// val page_url: String,
// val list_image_double_url: String,
val episode_next_date: String?,
val next_date_customize_text: String?,
val comic: MCComic? = null, // in latest
) {
fun toSChapter(urlPrefix: String) = SChapter.create().apply {
url = "$urlPrefix/$sort_volume/viewer.json"
val prefix = if (status == "public") "" else "🔒 "
name = "$prefix$volume $title"
// milliseconds are always 000
date_upload = parseJSTDate(publish_start).time
// show end date in scanlator field
scanlator = publish_end?.let { "~" + localDate.format(parseJSTDate(it)) }
}
fun getNextDatePrefix(): String? = when {
!episode_next_date.isNullOrEmpty() -> {
val date = parseJSTDate(episode_next_date)
date.setTime(date.getTime() + 10 * 3600 * 1000) // 10 am JST
"【Next: ${localDate.format(date)}"
}
!next_date_customize_text.isNullOrEmpty() -> "$next_date_customize_text】"
else -> null
}
}
@Serializable
data class MCBook(
val cover_url: String, // resolution is too low
)
@Serializable
data class MCViewer(
// val sort_volume: Int,
// val created_at: String,
// val updated_at: String,
// val volume: String,
// val title: String,
// val page_count: Int,
// episode_viewer_setting: { page_direction: "horizontal" }
val episode_pages: List<MCEpisodePage>,
)
@Serializable
data class MCEpisodePage(
// val order_index: Int,
val image: MCImage,
// val is_spread_start_page: Boolean,
)
@Serializable
data class MCImage(
// val pc_url: String, // has highest resolution but is upscaled from original, thus unnecessary
// val sp_url: String,
// val thumbnail_url: String,
val original_url: String,
// {pc,sp,thumbnail,original}_geometry: { width: Int, height: Int }
)
@Serializable
data class MCMenu(
val comic_categories: List<MCComicCategory>,
val comic_tags: List<MCComicGenre>,
) {
fun toFilterList(): List<Pair<String, MCComicTag>> =
comic_categories.map { Pair(it.display_name, it) } + comic_tags.map { Pair(it.name, it) }
}