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:
Cuong-Tran 2024-11-17 20:06:56 +07:00 committed by GitHub
parent 1817e30e33
commit 54578a5282
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 405 additions and 53 deletions

View File

@ -1,9 +1,8 @@
ext {
extName = 'Manhwa18'
extClass = '.Manhwa18'
themePkg = 'mymangacms'
baseUrl = 'https://manhwa18.com'
overrideVersionCode = 9
extVersionCode = 12
isNsfw = true
}

View File

@ -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()
}

View File

@ -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,
)

View File

@ -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"),
)