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:
parent
30e8ccc669
commit
2fd8684f2e
@ -1,9 +1,7 @@
|
||||
ext {
|
||||
extName = 'West Manga'
|
||||
extClass = '.WestManga'
|
||||
themePkg = 'mangathemesia'
|
||||
baseUrl = 'https://westmanga.me'
|
||||
overrideVersionCode = 5
|
||||
extVersionCode = 36
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -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>,
|
||||
)
|
@ -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[]",
|
||||
)
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user