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 { ext {
extName = 'West Manga' extName = 'West Manga'
extClass = '.WestManga' extClass = '.WestManga'
themePkg = 'mangathemesia' extVersionCode = 36
baseUrl = 'https://westmanga.me'
overrideVersionCode = 5
isNsfw = true 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 package eu.kanade.tachiyomi.extension.id.westmanga
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit 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.model.SManga
import okhttp3.OkHttpClient import eu.kanade.tachiyomi.source.online.HttpSource
import org.jsoup.nodes.Document import keiyoushi.utils.parseAs
import java.util.Locale 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") { class WestManga : HttpSource() {
// Formerly "West Manga (WP Manga Stream)" override val name = "West Manga"
override val baseUrl = "https://westmanga.me"
override val lang = "id"
override val id = 8883916630998758688 override val id = 8883916630998758688
override val supportsLatest = true
override val client: OkHttpClient = super.client.newBuilder() override val client = network.cloudflareClient
.rateLimit(4)
.build()
override val seriesTitleSelector = "h1" override fun headersBuilder() = super.headersBuilder()
override val seriesDetailsSelector = ".seriestucontent" .set("Referer", "$baseUrl/")
override val seriesTypeSelector = ".infotable tr:contains(Type) td:last-child"
override fun mangaDetailsParse(document: Document) = SManga.create().apply { override fun popularMangaRequest(page: Int) =
document.selectFirst(seriesDetailsSelector)!!.let { seriesDetails -> searchMangaRequest(page, "", SortFilter.popular)
title = document.selectFirst("div.postbody h1")!!.text()
artist = seriesDetails.selectFirst(seriesArtistSelector)?.ownText().removeEmptyPlaceholder() override fun popularMangaParse(response: Response) =
author = seriesDetails.selectFirst(seriesAuthorSelector)?.ownText().removeEmptyPlaceholder() searchMangaParse(response)
description = seriesDetails.select(seriesDescriptionSelector).joinToString("\n") { it.text() }.trim()
// Add alternative name to manga description override fun latestUpdatesRequest(page: Int) =
val altName = document.selectFirst(".seriestualt")?.ownText().takeIf { it.isNullOrBlank().not() } searchMangaRequest(page, "", SortFilter.latest)
altName?.let {
description = "$description\n\n$altNamePrefix$altName".trim() 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() addQueryParameter("page", page.toString())
// Add series type (manga/manhwa/manhua/other) to genre addQueryParameter("per_page", "20")
seriesDetails.selectFirst(seriesTypeSelector)?.ownText().takeIf { it.isNullOrBlank().not() }?.let { genres.add(it) } addQueryParameter("type", "Comic")
genre = genres.map { genre -> filters.filterIsInstance<UrlFilter>().forEach {
genre.lowercase(Locale.forLanguageTag(lang)).replaceFirstChar { char -> it.addToUrl(this)
if (char.isLowerCase()) { }
char.titlecase(Locale.forLanguageTag(lang)) }.build()
} else {
char.toString() 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"