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
4e49ac42e7
commit
9419e9b07a
|
@ -1,9 +1,8 @@
|
||||||
ext {
|
ext {
|
||||||
extName = 'Manhwa18'
|
extName = 'Manhwa18'
|
||||||
extClass = '.Manhwa18'
|
extClass = '.Manhwa18'
|
||||||
themePkg = 'mymangacms'
|
|
||||||
baseUrl = 'https://manhwa18.com'
|
baseUrl = 'https://manhwa18.com'
|
||||||
overrideVersionCode = 9
|
extVersionCode = 12
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,62 +1,224 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.manhwa18
|
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.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 versionId = 2
|
||||||
|
|
||||||
override val parseAuthorString = "Author"
|
private val json: Json by injectLazy()
|
||||||
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"
|
|
||||||
|
|
||||||
override fun getFilterList(): FilterList = FilterList(
|
// popular
|
||||||
Author("Author"),
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
Status(
|
return GET("$apiUrl/get-data-products?page=$page", headers)
|
||||||
"Status",
|
}
|
||||||
"All",
|
|
||||||
"Ongoing",
|
|
||||||
"On hold",
|
|
||||||
"Completed",
|
|
||||||
),
|
|
||||||
Sort(
|
|
||||||
"Order",
|
|
||||||
"A-Z",
|
|
||||||
"Z-A",
|
|
||||||
"Latest update",
|
|
||||||
"New manhwa",
|
|
||||||
"Most view",
|
|
||||||
"Most like",
|
|
||||||
),
|
|
||||||
GenreList(getGenreList(), "Genre"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// To populate this list:
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
// console.log([...document.querySelectorAll("div.search-gerne_item")].map(elem => `Genre("${elem.textContent.trim()}", ${elem.querySelector("label").getAttribute("data-genre-id")}),`).join("\n"))
|
val result = json.decodeFromString<MangaListBrowse>(response.body.string()).browseList
|
||||||
override fun getGenreList() = listOf(
|
return MangasPage(
|
||||||
Genre("Adult", 4),
|
result.mangaList.map { manga ->
|
||||||
Genre("Doujinshi", 9),
|
manga.toSManga()
|
||||||
Genre("Harem", 17),
|
},
|
||||||
Genre("Manga", 24),
|
hasNextPage = result.current_page < result.last_page,
|
||||||
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 dateUpdatedParser(date: String): Long =
|
// latest
|
||||||
runCatching { dateFormatter.parse(date.substringAfter(" - "))?.time }.getOrNull() ?: 0L
|
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