From 2fd8684f2ecba1466a3907296a4079b9a3659030 Mon Sep 17 00:00:00 2001 From: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> Date: Sat, 21 Jun 2025 15:53:43 +0500 Subject: [PATCH] west manga (id): update for new site (#9336) * west manga: update for new site * signature * review changes * some fields can be nullable * encode urls with `HttpUrl.Builder` --- src/id/westmanga/build.gradle | 4 +- .../tachiyomi/extension/id/westmanga/Dto.kt | 67 +++++ .../extension/id/westmanga/Filters.kt | 133 +++++++++ .../extension/id/westmanga/WestManga.kt | 262 +++++++++++++++--- 4 files changed, 425 insertions(+), 41 deletions(-) create mode 100644 src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Dto.kt create mode 100644 src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Filters.kt diff --git a/src/id/westmanga/build.gradle b/src/id/westmanga/build.gradle index 16fd53f3d..16066c0c0 100644 --- a/src/id/westmanga/build.gradle +++ b/src/id/westmanga/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'West Manga' extClass = '.WestManga' - themePkg = 'mangathemesia' - baseUrl = 'https://westmanga.me' - overrideVersionCode = 5 + extVersionCode = 36 isNsfw = true } diff --git a/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Dto.kt b/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Dto.kt new file mode 100644 index 000000000..cb44c6f51 --- /dev/null +++ b/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Dto.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.extension.id.westmanga + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class Data( + val data: T, +) + +@Serializable +class PaginatedData( + val data: List, + val paginator: Paginator, +) + +@Serializable +class Paginator( + @SerialName("current_page") private val current: Int, + @SerialName("last_page") private val last: Int, +) { + fun hasNextPage() = current < last +} + +@Serializable +class BrowseManga( + val title: String, + val slug: String, + val cover: String? = null, +) + +@Serializable +class Manga( + val title: String, + val slug: String, + @SerialName("alternative_name") val alternativeName: String? = null, + @SerialName("sinopsis") val synopsis: String? = null, + val cover: String? = null, + val author: String? = null, + @SerialName("country_id") val country: String? = null, + val status: String? = null, + val color: Boolean? = null, + val genres: List, + val chapters: List, +) + +@Serializable +class Genre( + val name: String, +) + +@Serializable +class Chapter( + val slug: String, + val number: String, + @SerialName("updated_at") val updatedAt: Time, +) + +@Serializable +class Time( + val time: Long, +) + +@Serializable +class ImageList( + val images: List, +) diff --git a/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Filters.kt b/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Filters.kt new file mode 100644 index 000000000..1d38b3bdb --- /dev/null +++ b/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/Filters.kt @@ -0,0 +1,133 @@ +package eu.kanade.tachiyomi.extension.id.westmanga + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl + +interface UrlFilter { + fun addToUrl(url: HttpUrl.Builder) +} + +abstract class SelectFilter( + name: String, + private val options: List>, + private val queryParameterName: String, + defaultValue: String? = null, +) : Filter.Select( + name, + options.map { it.first }.toTypedArray(), + options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, +), + UrlFilter { + private val selected get() = options[state].second + + override fun addToUrl(url: HttpUrl.Builder) { + url.addQueryParameter(queryParameterName, selected) + } +} + +class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name) + +abstract class CheckBoxGroup( + name: String, + options: List>, + private val queryParameterName: String, +) : Filter.Group( + name, + options.map { CheckBoxFilter(it.first, it.second) }, +), + UrlFilter { + private val checked get() = state.filter { it.state }.map { it.value } + + override fun addToUrl(url: HttpUrl.Builder) { + checked.forEach { + url.addQueryParameter(queryParameterName, it) + } + } +} + +class SortFilter( + defaultValue: String? = null, +) : SelectFilter( + name = "Order", + options = listOf( + "Default" to "Default", + "A-Z" to "Az", + "Z-A" to "Za", + "Updated" to "Update", + "Added" to "Added", + "Popular" to "Popular", + ), + queryParameterName = "orderBy", + defaultValue = defaultValue, +) { + companion object { + val popular = FilterList(SortFilter("Popular")) + val latest = FilterList(SortFilter("Update")) + } +} + +class StatusFilter : SelectFilter( + name = "Status", + options = listOf( + "All" to "All", + "Ongoing" to "Ongoing", + "Completed" to "Completed", + "Hiatus" to "Hiatus", + ), + queryParameterName = "status", +) + +class CountryFilter : SelectFilter( + name = "Country", + options = listOf( + "All" to "All", + "Japan" to "JP", + "China" to "CN", + "Korea" to "KR", + ), + queryParameterName = "country", +) + +class ColorFilter : SelectFilter( + name = "Color", + options = listOf( + "All" to "All", + "Colored" to "Colored", + "Uncolored" to "Uncolored", + ), + queryParameterName = "color", +) + +class GenreFilter : CheckBoxGroup( + name = "Genre", + options = listOf( + "4-Koma" to "344", + "Action" to "13", + "Adult" to "2279", + "Adventure" to "4", + "Anthology" to "1494", + "Comedy" to "5", + "Comedy. Ecchi" to "2028", + "Cooking" to "54", + "Crime" to "856", + "Crossdressing" to "1306", + "Demon" to "1318", + "Demons" to "64", + "Drama" to "6", + "Ecchi" to "14", + "Ecchi. Comedy" to "1837", + "Fantasy" to "7", + "Game" to "36", + "Gender Bender" to "149", + "Genderswap" to "157", + "genre drama" to "1843", + "Ghosts" to "1579", + "Gore" to "56", + "Gyaru" to "812", + "Harem" to "17", + "Historical" to "44", + "Horror" to "211", + ), + queryParameterName = "genre[]", +) diff --git a/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/WestManga.kt b/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/WestManga.kt index a3ef1da9a..dba51d5a4 100644 --- a/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/WestManga.kt +++ b/src/id/westmanga/src/eu/kanade/tachiyomi/extension/id/westmanga/WestManga.kt @@ -1,53 +1,239 @@ package eu.kanade.tachiyomi.extension.id.westmanga -import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia -import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.network.GET +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 okhttp3.OkHttpClient -import org.jsoup.nodes.Document -import java.util.Locale +import eu.kanade.tachiyomi.source.online.HttpSource +import keiyoushi.utils.parseAs +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec -class WestManga : MangaThemesia("West Manga", "https://westmanga.me", "id") { - // Formerly "West Manga (WP Manga Stream)" +class WestManga : HttpSource() { + override val name = "West Manga" + override val baseUrl = "https://westmanga.me" + override val lang = "id" override val id = 8883916630998758688 + override val supportsLatest = true - override val client: OkHttpClient = super.client.newBuilder() - .rateLimit(4) - .build() + override val client = network.cloudflareClient - override val seriesTitleSelector = "h1" - override val seriesDetailsSelector = ".seriestucontent" - override val seriesTypeSelector = ".infotable tr:contains(Type) td:last-child" + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") - override fun mangaDetailsParse(document: Document) = SManga.create().apply { - document.selectFirst(seriesDetailsSelector)!!.let { seriesDetails -> - title = document.selectFirst("div.postbody h1")!!.text() - artist = seriesDetails.selectFirst(seriesArtistSelector)?.ownText().removeEmptyPlaceholder() - author = seriesDetails.selectFirst(seriesAuthorSelector)?.ownText().removeEmptyPlaceholder() - description = seriesDetails.select(seriesDescriptionSelector).joinToString("\n") { it.text() }.trim() - // Add alternative name to manga description - val altName = document.selectFirst(".seriestualt")?.ownText().takeIf { it.isNullOrBlank().not() } - altName?.let { - description = "$description\n\n$altNamePrefix$altName".trim() + override fun popularMangaRequest(page: Int) = + searchMangaRequest(page, "", SortFilter.popular) + + override fun popularMangaParse(response: Response) = + searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int) = + searchMangaRequest(page, "", SortFilter.latest) + + override fun latestUpdatesParse(response: Response) = + searchMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("api") + addPathSegment("contents") + if (query.isNotBlank()) { + addQueryParameter("q", query) } - val genres = seriesDetails.select(seriesGenreSelector).map { it.text() }.toMutableList() - // Add series type (manga/manhwa/manhua/other) to genre - seriesDetails.selectFirst(seriesTypeSelector)?.ownText().takeIf { it.isNullOrBlank().not() }?.let { genres.add(it) } - genre = genres.map { genre -> - genre.lowercase(Locale.forLanguageTag(lang)).replaceFirstChar { char -> - if (char.isLowerCase()) { - char.titlecase(Locale.forLanguageTag(lang)) - } else { - char.toString() - } + addQueryParameter("page", page.toString()) + addQueryParameter("per_page", "20") + addQueryParameter("type", "Comic") + filters.filterIsInstance().forEach { + it.addToUrl(this) + } + }.build() + + return apiRequest(url) + } + + override fun getFilterList(): FilterList { + return FilterList( + SortFilter(), + StatusFilter(), + CountryFilter(), + ColorFilter(), + GenreFilter(), + ) + } + + override fun searchMangaParse(response: Response): MangasPage { + val data = response.parseAs>() + + val entries = data.data.map { + SManga.create().apply { + // old urls compatibility + setUrlWithoutDomain( + baseUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + .addPathSegment(it.slug) + .addPathSegment("") + .toString(), + ) + title = it.title + thumbnail_url = it.cover + } + } + + return MangasPage(entries, data.paginator.hasNextPage()) + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val path = "$baseUrl${manga.url}".toHttpUrl().pathSegments + assert(path.size == 3) { "Migrate from $name to $name" } + val slug = path[1] + + val url = baseUrl.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("comic") + .addPathSegment(slug) + .build() + + return apiRequest(url) + } + + override fun getMangaUrl(manga: SManga): String { + val slug = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1] + val url = baseUrl.toHttpUrl().newBuilder() + .addPathSegment("comic") + .addPathSegment(slug) + .build() + + return url.toString() + } + + override fun mangaDetailsParse(response: Response): SManga { + val data = response.parseAs>().data + + return SManga.create().apply { + // old urls compatibility + setUrlWithoutDomain( + baseUrl.toHttpUrl().newBuilder() + .addPathSegment("manga") + .addPathSegment(data.slug) + .addPathSegment("") + .toString(), + ) + title = data.title + thumbnail_url = data.cover + author = data.author + status = when (data.status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + "hiatus" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + genre = buildList { + when (data.country) { + "JP" -> add("Manga") + "CN" -> add("Manhua") + "KR" -> add("Manhwa") + } + if (data.color == true) { + add("Colored") + } + data.genres.forEach { add(it.name) } + }.joinToString() + description = buildString { + data.synopsis?.let { + append( + Jsoup.parseBodyFragment(it).wholeText().trim(), + ) + } + data.alternativeName?.let { + append("\n\n") + append("Alternative Name: ") + append(it.trim()) } } - .joinToString { it.trim() } - - status = seriesDetails.selectFirst(seriesStatusSelector)?.text().parseStatus() - thumbnail_url = seriesDetails.select(seriesThumbnailSelector).imgAttr() } } - override val hasProjectPage = true + override fun chapterListRequest(manga: SManga) = + mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List { + val data = response.parseAs>().data + + return data.chapters.map { + SChapter.create().apply { + setUrlWithoutDomain( + baseUrl.toHttpUrl().newBuilder() + .addPathSegment(it.slug) + .addPathSegment("") + .toString(), + ) + name = "Chapter ${it.number}" + date_upload = it.updatedAt.time * 1000 + } + } + } + + override fun pageListRequest(chapter: SChapter): Request { + val path = "$baseUrl${chapter.url}".toHttpUrl().pathSegments + assert(path.size == 2) { "Refresh Chapter List" } + val slug = path[0] + + val url = baseUrl.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v") + .addPathSegment(slug) + .build() + + return apiRequest(url) + } + + override fun getChapterUrl(chapter: SChapter): String { + val slug = "$baseUrl${chapter.url}".toHttpUrl().pathSegments[0] + val url = baseUrl.toHttpUrl().newBuilder() + .addPathSegment("view") + .addPathSegment(slug) + + return url.toString() + } + + override fun pageListParse(response: Response): List { + val data = response.parseAs>().data + + return data.images.mapIndexed { idx, img -> + Page(idx, imageUrl = img) + } + } + + private fun apiRequest(url: HttpUrl): Request { + val timestamp = (System.currentTimeMillis() / 1000).toString() + val message = "wm-api-request" + val key = timestamp + "GET" + url.encodedPath + accessKey + secretKey + val mac = Mac.getInstance("HmacSHA256") + val secretKeySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(secretKeySpec) + val hash = mac.doFinal(message.toByteArray(Charsets.UTF_8)) + val signature = hash.joinToString("") { "%02x".format(it) } + + val apiHeaders = headersBuilder() + .set("x-wm-request-time", timestamp) + .set("x-wm-accses-key", accessKey) + .set("x-wm-request-signature", signature) + .build() + + return GET(url, apiHeaders) + } + + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } } + +private const val accessKey = "WM_WEB_FRONT_END" +private const val secretKey = "xxxoidj"