From 9419e9b07ab75465dd0c550e71bb4e0b4ca59766 Mon Sep 17 00:00:00 2001 From: Cuong-Tran Date: Sun, 17 Nov 2024 20:06:56 +0700 Subject: [PATCH] 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 --- src/en/manhwa18/build.gradle | 3 +- .../extension/en/manhwa18/Manhwa18.kt | 264 ++++++++++++++---- .../extension/en/manhwa18/Manhwa18Dto.kt | 89 ++++++ .../extension/en/manhwa18/Manhwa18Filters.kt | 102 +++++++ 4 files changed, 405 insertions(+), 53 deletions(-) create mode 100644 src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Dto.kt create mode 100644 src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Filters.kt diff --git a/src/en/manhwa18/build.gradle b/src/en/manhwa18/build.gradle index ecc892b1a..d4ee6d6a3 100644 --- a/src/en/manhwa18/build.gradle +++ b/src/en/manhwa18/build.gradle @@ -1,9 +1,8 @@ ext { extName = 'Manhwa18' extClass = '.Manhwa18' - themePkg = 'mymangacms' baseUrl = 'https://manhwa18.com' - overrideVersionCode = 9 + extVersionCode = 12 isNsfw = true } diff --git a/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18.kt b/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18.kt index 78a4ed202..6cc8aab99 100644 --- a/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18.kt +++ b/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18.kt @@ -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(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 { + 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>(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(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 { + val mangaDetail = json.decodeFromString(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 { + val result = json.decodeFromString(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() } diff --git a/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Dto.kt b/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Dto.kt new file mode 100644 index 000000000..99684b37e --- /dev/null +++ b/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Dto.kt @@ -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, +) + +@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?, + // genre + val types: List?, + // 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("

") + ?.removeSuffix("

")?.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?, +) + +@Serializable +data class Images( + val images: List, +) + +@Serializable +data class Nation( + val name: String, +) + +@Serializable +data class Type( + val name: String, +) diff --git a/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Filters.kt b/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Filters.kt new file mode 100644 index 000000000..a8d57c818 --- /dev/null +++ b/src/en/manhwa18/src/eu/kanade/tachiyomi/extension/en/manhwa18/Manhwa18Filters.kt @@ -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) : + GroupFilter(name, categoryList.map { (value, name) -> Pair(name, value.toString()) }) + +internal class StatusFilter(name: String, statusList: Map) : + SelectFilter(name, statusList.map { (value, name) -> Pair(name, value.toString()) }) + +internal class SortFilter(name: String, sortList: List>) : + SelectFilter(name, sortList) + +internal class NationFilter(name: String, nationList: Map) : + GroupFilter(name, nationList.map { (value, name) -> Pair(name, value.toString()) }) + +internal class GenreFilter(name: String, genreList: List) : + GroupFilter(name, genreList.map { Pair(it.name, it.id.toString()) }) + +internal open class GroupFilter(name: String, vals: List>) : + Filter.Group(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>) : + Filter.Select(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> = 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"), +)