mirror of
https://github.com/keiyoushi/extensions-source.git
synced 2024-11-22 02:12:42 +01:00
Fix Manhwa18 (#6055)
* working browsing/latest/reading * convert from old-theme url to new url * using old-theme's url to avoid migration * support filters * split search results into page * cleanup description * minor fix to actual matching old-theme entries' url * use HttpUrl.Builder * remove chapter number & unused field * add cache for search request
This commit is contained in:
parent
1817e30e33
commit
54578a5282
@ -1,9 +1,8 @@
|
||||
ext {
|
||||
extName = 'Manhwa18'
|
||||
extClass = '.Manhwa18'
|
||||
themePkg = 'mymangacms'
|
||||
baseUrl = 'https://manhwa18.com'
|
||||
overrideVersionCode = 9
|
||||
extVersionCode = 12
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -1,62 +1,224 @@
|
||||
package eu.kanade.tachiyomi.extension.en.manhwa18
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mymangacms.MyMangaCMS
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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 kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
|
||||
class Manhwa18 : MyMangaCMS("Manhwa18", "https://manhwa18.com", "en") {
|
||||
class Manhwa18 : HttpSource() {
|
||||
|
||||
override val baseUrl = "https://manhwa18.com"
|
||||
private val apiUrl = "https://cdn3.manhwa18.com/api/v1"
|
||||
override val lang = "en"
|
||||
override val name = "Manhwa18"
|
||||
override val supportsLatest = true
|
||||
|
||||
// Migrated from FMReader to MyMangaCMS.
|
||||
override val versionId = 2
|
||||
|
||||
override val parseAuthorString = "Author"
|
||||
override val parseAlternativeNameString = "Other name"
|
||||
override val parseAlternative2ndNameString = "Doujinshi"
|
||||
override val parseStatusString = "Status"
|
||||
override val parseStatusOngoingStringLowerCase = "on going"
|
||||
override val parseStatusOnHoldStringLowerCase = "on hold"
|
||||
override val parseStatusCompletedStringLowerCase = "completed"
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
Author("Author"),
|
||||
Status(
|
||||
"Status",
|
||||
"All",
|
||||
"Ongoing",
|
||||
"On hold",
|
||||
"Completed",
|
||||
),
|
||||
Sort(
|
||||
"Order",
|
||||
"A-Z",
|
||||
"Z-A",
|
||||
"Latest update",
|
||||
"New manhwa",
|
||||
"Most view",
|
||||
"Most like",
|
||||
),
|
||||
GenreList(getGenreList(), "Genre"),
|
||||
)
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$apiUrl/get-data-products?page=$page", headers)
|
||||
}
|
||||
|
||||
// To populate this list:
|
||||
// console.log([...document.querySelectorAll("div.search-gerne_item")].map(elem => `Genre("${elem.textContent.trim()}", ${elem.querySelector("label").getAttribute("data-genre-id")}),`).join("\n"))
|
||||
override fun getGenreList() = listOf(
|
||||
Genre("Adult", 4),
|
||||
Genre("Doujinshi", 9),
|
||||
Genre("Harem", 17),
|
||||
Genre("Manga", 24),
|
||||
Genre("Manhwa", 26),
|
||||
Genre("Mature", 28),
|
||||
Genre("NTR", 33),
|
||||
Genre("Romance", 36),
|
||||
Genre("Webtoon", 57),
|
||||
Genre("Action", 59),
|
||||
Genre("Comedy", 60),
|
||||
Genre("BL", 61),
|
||||
Genre("Horror", 62),
|
||||
Genre("Raw", 63),
|
||||
Genre("Uncensore", 64),
|
||||
)
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = json.decodeFromString<MangaListBrowse>(response.body.string()).browseList
|
||||
return MangasPage(
|
||||
result.mangaList.map { manga ->
|
||||
manga.toSManga()
|
||||
},
|
||||
hasNextPage = result.current_page < result.last_page,
|
||||
)
|
||||
}
|
||||
|
||||
override fun dateUpdatedParser(date: String): Long =
|
||||
runCatching { dateFormatter.parse(date.substringAfter(" - "))?.time }.getOrNull() ?: 0L
|
||||
// latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$apiUrl/get-data-products-in-filter?arange=new-updated?page=$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
private var searchMangaCache: MangasPage? = null
|
||||
|
||||
// search
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return if (query.isBlank()) {
|
||||
client.newCall(filterMangaRequest(page, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
popularMangaParse(response)
|
||||
}
|
||||
} else {
|
||||
if (page == 1 || searchMangaCache == null) {
|
||||
searchMangaCache = super.fetchSearchManga(page, query, filters)
|
||||
.toBlocking()
|
||||
.last()
|
||||
}
|
||||
|
||||
// Handling a large manga list
|
||||
Observable.just(searchMangaCache!!)
|
||||
.map { mangaPage ->
|
||||
val mangas = mangaPage.mangas
|
||||
|
||||
val fromIndex = (page - 1) * MAX_MANGA_PER_PAGE
|
||||
val toIndex = page * MAX_MANGA_PER_PAGE
|
||||
|
||||
MangasPage(
|
||||
mangas.subList(
|
||||
min(fromIndex, mangas.size - 1),
|
||||
min(toIndex, mangas.size),
|
||||
),
|
||||
hasNextPage = toIndex < mangas.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterMangaRequest(page: Int, filters: FilterList): Request {
|
||||
val url = apiUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("get-data-products-in-filter")
|
||||
addQueryParameter("page", page.toString())
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is CategoryFilter -> {
|
||||
if (filter.checked.isNotBlank()) {
|
||||
addQueryParameter("category", filter.checked)
|
||||
}
|
||||
}
|
||||
is GenreFilter -> {
|
||||
if (filter.checked.isNotBlank()) {
|
||||
addQueryParameter("type", filter.checked)
|
||||
}
|
||||
}
|
||||
is NationFilter -> {
|
||||
if (filter.checked.isNotBlank()) {
|
||||
addQueryParameter("nation", filter.checked)
|
||||
}
|
||||
}
|
||||
is SortFilter -> {
|
||||
addQueryParameter("arrange", filter.getValue())
|
||||
}
|
||||
is StatusFilter -> {
|
||||
addQueryParameter("is_complete", filter.getValue())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = apiUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("get-search-suggest")
|
||||
addPathSegments(query)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val result = json.decodeFromString<List<Manga>>(response.body.string())
|
||||
return MangasPage(
|
||||
result
|
||||
.map { manga ->
|
||||
manga.toSManga()
|
||||
},
|
||||
hasNextPage = false,
|
||||
)
|
||||
}
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val slug = manga.url.substringAfterLast('/')
|
||||
return GET("$apiUrl/get-detail-product/$slug", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val mangaDetail = json.decodeFromString<MangaDetail>(response.body.string())
|
||||
return mangaDetail.manga.toSManga().apply {
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
return "${baseUrl}${manga.url}"
|
||||
}
|
||||
|
||||
// chapter list
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return mangaDetailsRequest(manga)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val mangaDetail = json.decodeFromString<MangaDetail>(response.body.string())
|
||||
val mangaSlug = mangaDetail.manga.slug
|
||||
|
||||
return mangaDetail.manga.episodes?.map { chapter ->
|
||||
SChapter.create().apply {
|
||||
// compatible with old theme
|
||||
setUrlWithoutDomain("/manga/$mangaSlug/${chapter.slug}")
|
||||
name = chapter.name
|
||||
date_upload = chapter.created_at?.parseDate() ?: 0L
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
return "${baseUrl}${chapter.url}"
|
||||
}
|
||||
|
||||
// page list
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val slug = chapter.url
|
||||
.removePrefix("/")
|
||||
.substringAfter('/')
|
||||
return GET("$apiUrl/get-episode/$slug", headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = json.decodeFromString<ChapterDetail>(response.body.string())
|
||||
return result.episode.servers?.first()?.images?.mapIndexed { index, image ->
|
||||
Page(index = index, imageUrl = image)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
// unused
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
private fun String.parseDate(): Long {
|
||||
return runCatching { DATE_FORMATTER.parse(this)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH)
|
||||
}
|
||||
|
||||
private const val MAX_MANGA_PER_PAGE = 15
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList = getFilters()
|
||||
}
|
||||
|
@ -0,0 +1,89 @@
|
||||
package eu.kanade.tachiyomi.extension.en.manhwa18
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MangaListBrowse(
|
||||
@SerialName("products") val browseList: MangaList,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaList(
|
||||
val current_page: Int,
|
||||
val last_page: Int,
|
||||
@SerialName("data") val mangaList: List<Manga>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaDetail(
|
||||
@SerialName("product") val manga: Manga,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val name: String,
|
||||
val url_avatar: String,
|
||||
val slug: String,
|
||||
// raw / sub
|
||||
val category_id: Int?,
|
||||
val is_end: Int?,
|
||||
val desc: String?,
|
||||
val episodes: List<Episode>?,
|
||||
// genre
|
||||
val types: List<Type>?,
|
||||
// korea / japan
|
||||
val nation: Nation?,
|
||||
) {
|
||||
fun toSManga(): SManga {
|
||||
return SManga.create().apply {
|
||||
// compatible with old theme
|
||||
url = "/manga/$slug"
|
||||
title = name
|
||||
description = desc?.trim()?.removePrefix("<p>")
|
||||
?.removeSuffix("</p>")?.trim()
|
||||
genre = listOfNotNull(
|
||||
types?.joinToString { it.name },
|
||||
nation?.name,
|
||||
category_id?.let { Categories[it] },
|
||||
)
|
||||
.joinToString()
|
||||
|
||||
status = when (is_end) {
|
||||
1 -> SManga.COMPLETED
|
||||
0 -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
thumbnail_url = url_avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChapterDetail(
|
||||
val episode: Episode,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Episode(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val created_at: String?,
|
||||
val servers: List<Images>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Images(
|
||||
val images: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Nation(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Type(
|
||||
val name: String,
|
||||
)
|
@ -0,0 +1,102 @@
|
||||
package eu.kanade.tachiyomi.extension.en.manhwa18
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
Filter.Header(name = "The filter is ignored when using text search."),
|
||||
CategoryFilter("Category", Categories),
|
||||
StatusFilter("Status", Statuses),
|
||||
SortFilter("Sort", getSortsList),
|
||||
NationFilter("Nation", Nations),
|
||||
GenreFilter("Type", getTypesList),
|
||||
)
|
||||
}
|
||||
|
||||
/** Filters **/
|
||||
internal class CategoryFilter(name: String, categoryList: Map<Int, String>) :
|
||||
GroupFilter(name, categoryList.map { (value, name) -> Pair(name, value.toString()) })
|
||||
|
||||
internal class StatusFilter(name: String, statusList: Map<Int, String>) :
|
||||
SelectFilter(name, statusList.map { (value, name) -> Pair(name, value.toString()) })
|
||||
|
||||
internal class SortFilter(name: String, sortList: List<Pair<String, String>>) :
|
||||
SelectFilter(name, sortList)
|
||||
|
||||
internal class NationFilter(name: String, nationList: Map<Int, String>) :
|
||||
GroupFilter(name, nationList.map { (value, name) -> Pair(name, value.toString()) })
|
||||
|
||||
internal class GenreFilter(name: String, genreList: List<Genre>) :
|
||||
GroupFilter(name, genreList.map { Pair(it.name, it.id.toString()) })
|
||||
|
||||
internal open class GroupFilter(name: String, vals: List<Pair<String, String>>) :
|
||||
Filter.Group<CheckBoxFilter>(name, vals.map { CheckBoxFilter(it.first, it.second) }) {
|
||||
|
||||
val checked get() = state.filter { it.state }.joinToString(",") { it.value }
|
||||
}
|
||||
|
||||
internal open class CheckBoxFilter(name: String, val value: String = "") : Filter.CheckBox(name)
|
||||
|
||||
internal open class SelectFilter(name: String, private val vals: List<Pair<String, String>>) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray()) {
|
||||
fun getValue() = vals[state].second
|
||||
}
|
||||
|
||||
internal class Genre(name: String, val id: Int) : Filter.CheckBox(name)
|
||||
|
||||
/** Filters Data **/
|
||||
val Categories = mapOf(
|
||||
1 to "Raw",
|
||||
2 to "Sub",
|
||||
)
|
||||
|
||||
val Nations = mapOf(
|
||||
1 to "Korea",
|
||||
2 to "Japan",
|
||||
)
|
||||
|
||||
val Statuses = mapOf(
|
||||
0 to "In-progress",
|
||||
1 to "Completed",
|
||||
)
|
||||
|
||||
private val getTypesList = listOf(
|
||||
Genre("Manhwa", 26),
|
||||
Genre("Action", 1),
|
||||
Genre("Adventure", 2),
|
||||
Genre("Comedy", 3),
|
||||
Genre("Drama", 4),
|
||||
Genre("Fantasy", 5),
|
||||
Genre("Horror", 6),
|
||||
Genre("Isekai", 7),
|
||||
Genre("Martial Arts", 8),
|
||||
Genre("Mystery", 9),
|
||||
Genre("Romance", 10),
|
||||
Genre("Sci-Fi", 11),
|
||||
Genre("Slice of Life", 12),
|
||||
Genre("Sports", 13),
|
||||
Genre("Supernatural", 14),
|
||||
Genre("Thriller", 15),
|
||||
Genre("Historical", 16),
|
||||
Genre("Mecha", 17),
|
||||
Genre("Psychological", 18),
|
||||
Genre("Seinen", 19),
|
||||
Genre("Shoujo", 20),
|
||||
Genre("Shounen", 21),
|
||||
Genre("Josei", 22),
|
||||
Genre("Yaoi", 23),
|
||||
Genre("Yuri", 24),
|
||||
Genre("Ecchi", 25),
|
||||
)
|
||||
|
||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||
Pair("Most View", "most-view"),
|
||||
Pair("Most Favourite", "most-favourite"),
|
||||
Pair("A-Z", "a-z"),
|
||||
Pair("Z-A", "z-a"),
|
||||
Pair("New Updated", "new-updated"),
|
||||
Pair("Old Updated", "old-updated"),
|
||||
Pair("New Created", "new-created"),
|
||||
Pair("Old Created", "old-created"),
|
||||
)
|
Loading…
Reference in New Issue
Block a user