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 {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
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)
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.set("Referer", "$baseUrl/")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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()
|
.build()
|
||||||
|
|
||||||
override val seriesTitleSelector = "h1"
|
return apiRequest(url)
|
||||||
override val seriesDetailsSelector = ".seriestucontent"
|
}
|
||||||
override val seriesTypeSelector = ".infotable tr:contains(Type) td:last-child"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
document.selectFirst(seriesDetailsSelector)!!.let { seriesDetails ->
|
val slug = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
|
||||||
title = document.selectFirst("div.postbody h1")!!.text()
|
val url = baseUrl.toHttpUrl().newBuilder()
|
||||||
artist = seriesDetails.selectFirst(seriesArtistSelector)?.ownText().removeEmptyPlaceholder()
|
.addPathSegment("comic")
|
||||||
author = seriesDetails.selectFirst(seriesAuthorSelector)?.ownText().removeEmptyPlaceholder()
|
.addPathSegment(slug)
|
||||||
description = seriesDetails.select(seriesDescriptionSelector).joinToString("\n") { it.text() }.trim()
|
.build()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.joinToString { it.trim() }
|
|
||||||
|
|
||||||
status = seriesDetails.selectFirst(seriesStatusSelector)?.text().parseStatus()
|
return url.toString()
|
||||||
thumbnail_url = seriesDetails.select(seriesThumbnailSelector).imgAttr()
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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