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`
This commit is contained in:
AwkwardPeak7 2025-06-21 15:53:43 +05:00 committed by Draff
parent 30e8ccc669
commit 2fd8684f2e
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 425 additions and 41 deletions

View File

@ -1,9 +1,7 @@
ext {
extName = 'West Manga'
extClass = '.WestManga'
themePkg = 'mangathemesia'
baseUrl = 'https://westmanga.me'
overrideVersionCode = 5
extVersionCode = 36
isNsfw = true
}

View File

@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.extension.id.westmanga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class Data<T>(
val data: T,
)
@Serializable
class PaginatedData<T>(
val data: List<T>,
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<Genre>,
val chapters: List<Chapter>,
)
@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<String>,
)

View File

@ -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<Pair<String, String>>,
private val queryParameterName: String,
defaultValue: String? = null,
) : Filter.Select<String>(
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<Pair<String, String>>,
private val queryParameterName: String,
) : Filter.Group<CheckBoxFilter>(
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[]",
)

View File

@ -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<UrlFilter>().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<PaginatedData<BrowseManga>>()
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<Manga>>().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<SChapter> {
val data = response.parseAs<Data<Manga>>().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<Page> {
val data = response.parseAs<Data<ImageList>>().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"