NatsuId multisrc: Natsu (id), Ikiru (id), Kiryuu (id), rawkuma (ja) (#11592)
* 1 * 2 * 3 * fix ikiru with json cleaner * cleaning * also clean json for parsing genre list * dse * saving manga ID to desc so only use call once * fix okhttp client building override and cleanup --------- Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
parent
6bced1de64
commit
1e768ace94
@ -15,12 +15,30 @@ val jsonInstance: Json by injectLazy()
|
||||
inline fun <reified T> String.parseAs(json: Json = jsonInstance): T =
|
||||
json.decodeFromString(this)
|
||||
|
||||
/**
|
||||
* Parses JSON string into an object of type [T], applying a [transform] function to the string before parsing.
|
||||
*
|
||||
* @param json The [Json] instance to use for deserialization.
|
||||
* @param transform A function to transform the original JSON string before it is parsed.
|
||||
*/
|
||||
inline fun <reified T> String.parseAs(json: Json = jsonInstance, transform: (String) -> String): T =
|
||||
transform(this).parseAs(json)
|
||||
|
||||
/**
|
||||
* Parses the response body into an object of type [T].
|
||||
*/
|
||||
inline fun <reified T> Response.parseAs(json: Json = jsonInstance): T =
|
||||
use { json.decodeFromStream(body.byteStream()) }
|
||||
|
||||
/**
|
||||
* Parses the response body into an object of type [T], applying a transformation to the raw JSON string before parsing.
|
||||
*
|
||||
* @param json The [Json] instance to use for parsing. Defaults to the injected instance.
|
||||
* @param transform A function to transform the JSON string before it's decoded.
|
||||
*/
|
||||
inline fun <reified T> Response.parseAs(json: Json = jsonInstance, transform: (String) -> String): T =
|
||||
body.string().parseAs(json, transform)
|
||||
|
||||
/**
|
||||
* Serializes the object to a JSON string.
|
||||
*/
|
||||
|
||||
9
lib-multisrc/natsuid/build.gradle.kts
Normal file
9
lib-multisrc/natsuid/build.gradle.kts
Normal file
@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.rawkuma
|
||||
package eu.kanade.tachiyomi.multisrc.natsuid
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import keiyoushi.utils.toJsonString
|
||||
@ -23,10 +23,15 @@ class Manga(
|
||||
@SerialName("_embedded")
|
||||
val embedded: Embedded,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
fun toSManga(appendId: Boolean = false) = SManga.create().apply {
|
||||
url = MangaUrl(id, slug).toJsonString()
|
||||
title = Parser.unescapeEntities(this@Manga.title.rendered, false)
|
||||
description = Jsoup.parseBodyFragment(content.rendered).wholeText()
|
||||
description = buildString {
|
||||
append(Jsoup.parseBodyFragment(content.rendered).wholeText())
|
||||
if (appendId) {
|
||||
append("\n\nID: $id")
|
||||
}
|
||||
}
|
||||
thumbnail_url = embedded.featuredMedia.firstOrNull()?.sourceUrl
|
||||
author = embedded.getTerms("series-author").joinToString()
|
||||
artist = embedded.getTerms("artist").joinToString()
|
||||
@ -1,4 +1,4 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.rawkuma
|
||||
package eu.kanade.tachiyomi.multisrc.natsuid
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
@ -0,0 +1,360 @@
|
||||
package eu.kanade.tachiyomi.multisrc.natsuid
|
||||
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
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.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.firstInstanceOrNull
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.toJsonString
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.jsoup.Jsoup
|
||||
import rx.Observable
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
// https://themesinfo.com/natsu_id-theme-wordpress-c8x1c Wordpress Theme Author "Dzul Qurnain"
|
||||
abstract class NatsuId(
|
||||
override val name: String,
|
||||
override val lang: String,
|
||||
override val baseUrl: String,
|
||||
val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US),
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest: Boolean = true
|
||||
|
||||
protected open fun OkHttpClient.Builder.customizeClient(): OkHttpClient.Builder = this
|
||||
|
||||
final override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.customizeClient()
|
||||
// fix disk cache
|
||||
.apply {
|
||||
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
|
||||
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
|
||||
}
|
||||
.build()
|
||||
|
||||
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 fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith("https://")) {
|
||||
deepLink(query)
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php?action=advanced_search"
|
||||
val body = MultipartBody.Builder().apply {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("nonce", getNonce())
|
||||
filters.firstInstanceOrNull<GenreInclusion>()?.selected.also {
|
||||
addFormDataPart("inclusion", it ?: "OR")
|
||||
}
|
||||
filters.firstInstanceOrNull<GenreExclusion>()?.selected.also {
|
||||
addFormDataPart("exclusion", it ?: "OR")
|
||||
}
|
||||
addFormDataPart("page", page.toString())
|
||||
val genres = filters.firstInstanceOrNull<GenreFilter>()
|
||||
genres?.included.orEmpty().also {
|
||||
addFormDataPart("genre", it.toJsonString())
|
||||
}
|
||||
genres?.excluded.orEmpty().also {
|
||||
addFormDataPart("genre_exclude", it.toJsonString())
|
||||
}
|
||||
addFormDataPart("author", "[]")
|
||||
addFormDataPart("artist", "[]")
|
||||
addFormDataPart("project", "0")
|
||||
filters.firstInstanceOrNull<TypeFilter>()?.checked.orEmpty().also {
|
||||
addFormDataPart("type", it.toJsonString())
|
||||
}
|
||||
val sort = filters.firstInstance<SortFilter>()
|
||||
addFormDataPart("order", if (sort.isAscending) "asc" else "desc")
|
||||
addFormDataPart("orderby", sort.sort)
|
||||
addFormDataPart("query", query.trim())
|
||||
}.build()
|
||||
|
||||
return POST(url, headers, body)
|
||||
}
|
||||
|
||||
private var nonce: String? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getNonce(): String {
|
||||
if (nonce == null) {
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php?type=search_form&action=get_nonce"
|
||||
val response = client.newCall(GET(url, headers)).execute()
|
||||
|
||||
Jsoup.parseBodyFragment(response.body.string())
|
||||
.selectFirst("input[name=search_nonce]")
|
||||
?.attr("value")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.also {
|
||||
nonce = it
|
||||
}
|
||||
}
|
||||
|
||||
return nonce ?: throw Exception("Unable to get nonce")
|
||||
}
|
||||
|
||||
private val metadataClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
chain.proceed(chain.request()).newBuilder()
|
||||
.header("Cache-Control", "max-age=${24 * 60 * 60}")
|
||||
.removeHeader("Pragma")
|
||||
.removeHeader("Expires")
|
||||
.build()
|
||||
}.build()
|
||||
|
||||
override fun getFilterList() = runBlocking(Dispatchers.IO) {
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
SortFilter(),
|
||||
TypeFilter(),
|
||||
StatusFilter(),
|
||||
)
|
||||
|
||||
val url = "$baseUrl/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc"
|
||||
val response = metadataClient.newCall(
|
||||
GET(url, headers, CacheControl.FORCE_CACHE),
|
||||
).await()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
metadataClient.newCall(
|
||||
GET(url, headers, CacheControl.FORCE_NETWORK),
|
||||
).enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.closeQuietly()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(name, "Failed to fetch genre filter", e)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Press 'reset' to load genre filter"),
|
||||
),
|
||||
)
|
||||
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
val data = try {
|
||||
response.parseAs<List<Term>>(transform = ::transformJsonResponse)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(name, "Failed to parse genre filters", e)
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Failed to parse genre filter"),
|
||||
),
|
||||
)
|
||||
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
GenreFilter(
|
||||
data.map { it.name to it.slug },
|
||||
),
|
||||
GenreInclusion(),
|
||||
GenreInclusion(),
|
||||
),
|
||||
)
|
||||
|
||||
FilterList(filters)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
|
||||
val slugs = document.select("div > a[href*=/manga/]:has(> img)").map {
|
||||
it.absUrl("href").toHttpUrl().pathSegments[1]
|
||||
}.ifEmpty {
|
||||
return MangasPage(emptyList(), false)
|
||||
}
|
||||
|
||||
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder().apply {
|
||||
slugs.forEach { slug ->
|
||||
addQueryParameter("slug[]", slug)
|
||||
}
|
||||
addQueryParameter("per_page", "${slugs.size + 1}")
|
||||
addQueryParameter("_embed", null)
|
||||
}.build()
|
||||
|
||||
val details = client.newCall(GET(url, headers)).execute()
|
||||
.parseAs<List<Manga>>(transform = ::transformJsonResponse)
|
||||
.filterNot { manga ->
|
||||
manga.embedded.getTerms("type").contains("Novel")
|
||||
}
|
||||
.associateBy { it.slug }
|
||||
|
||||
val mangas = slugs.mapNotNull { slug ->
|
||||
details[slug]?.toSManga()
|
||||
}
|
||||
|
||||
val hasNextPage = document.selectFirst("button:has(svg)") != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
private fun deepLink(url: String): Observable<MangasPage> {
|
||||
val httpUrl = url.toHttpUrl()
|
||||
if (
|
||||
httpUrl.host == baseUrl.toHttpUrl().host &&
|
||||
httpUrl.pathSegments.size >= 2 &&
|
||||
httpUrl.pathSegments[0] == "manga"
|
||||
) {
|
||||
val slug = httpUrl.pathSegments[1]
|
||||
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("slug[]", slug)
|
||||
.addQueryParameter("_embed", null)
|
||||
.build()
|
||||
|
||||
return client.newCall(GET(url, headers))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val manga = response.parseAs<List<Manga>>(transform = ::transformJsonResponse)[0]
|
||||
|
||||
if (manga.embedded.getTerms("type").contains("Novel")) {
|
||||
throw Exception("Novels are not supported")
|
||||
}
|
||||
|
||||
MangasPage(listOf(manga.toSManga()), false)
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.error(Exception("Unsupported url"))
|
||||
}
|
||||
|
||||
private val descriptionIdRegex = Regex("""ID: (\d+)""")
|
||||
private fun getMangaId(manga: SManga): String {
|
||||
return if (manga.url.startsWith("{")) {
|
||||
manga.url.parseAs<MangaUrl>().id.toString()
|
||||
} else if (descriptionIdRegex.containsMatchIn(manga.description?.trim().orEmpty())) {
|
||||
descriptionIdRegex.find(manga.description!!.trim())!!.groupValues[1]
|
||||
} else {
|
||||
val document = client.newCall(
|
||||
GET(getMangaUrl(manga), headers),
|
||||
).execute().asJsoup()
|
||||
|
||||
document.selectFirst("#gallery-list")!!.attr("hx-get")
|
||||
.substringAfter("manga_id=").substringBefore("&")
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val id = getMangaId(manga)
|
||||
val appendId = !manga.url.startsWith("{")
|
||||
|
||||
return GET("$baseUrl/wp-json/wp/v2/manga/$id?_embed#$appendId", headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
val slug = if (manga.url.startsWith("{")) {
|
||||
manga.url.parseAs<MangaUrl>().slug
|
||||
} else {
|
||||
"$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
|
||||
}
|
||||
|
||||
return "$baseUrl/manga/$slug/"
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val manga = response.parseAs<Manga>(transform = ::transformJsonResponse)
|
||||
val appendId = response.request.url.fragment == "true"
|
||||
|
||||
return manga.toSManga(appendId)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = getMangaId(manga)
|
||||
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("manga_id", id)
|
||||
.addQueryParameter("page", "${Random.nextInt(99, 9999)}") // keep above 3 for loading hidden chapter
|
||||
.addQueryParameter("action", "chapter_list")
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
protected open val chapterListSelector = "div a:has(time)"
|
||||
protected open val chapterNameSelector = "span"
|
||||
protected open val chapterDateSelector = "time"
|
||||
protected open val chapterDateAttribute = "datetime"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
|
||||
|
||||
return document.select(chapterListSelector).map {
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(it.absUrl("href"))
|
||||
name = it.selectFirst(chapterNameSelector)!!.ownText()
|
||||
date_upload = dateFormat.tryParse(
|
||||
it.selectFirst(chapterDateSelector)?.attr(chapterDateAttribute),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open val pageListSelector = "main .relative section > img"
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select(pageListSelector).mapIndexed { idx, img ->
|
||||
Page(idx, imageUrl = img.absUrl("src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
protected open fun transformJsonResponse(responseBody: String): String = responseBody
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
ext {
|
||||
extName = 'Kiryuu'
|
||||
extClass = '.Kiryuu'
|
||||
themePkg = 'mangathemesia'
|
||||
themePkg = 'natsuid'
|
||||
baseUrl = 'https://kiryuu03.com'
|
||||
overrideVersionCode = 14
|
||||
overrideVersionCode = 44
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
||||
@ -1,51 +1,16 @@
|
||||
package eu.kanade.tachiyomi.extension.id.kiryuu
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
|
||||
import eu.kanade.tachiyomi.multisrc.natsuid.NatsuId
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class Kiryuu : MangaThemesia("Kiryuu", "https://kiryuu03.com", "id", dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale("id"))) {
|
||||
class Kiryuu : NatsuId(
|
||||
"Kiryuu",
|
||||
"id",
|
||||
"https://kiryuu03.com",
|
||||
) {
|
||||
// Formerly "Kiryuu (WP Manga Stream)"
|
||||
override val id = 3639673976007021338
|
||||
|
||||
override val client: OkHttpClient = super.client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
val mime = response.headers["Content-Type"]
|
||||
if (response.isSuccessful) {
|
||||
if (mime != "application/octet-stream") {
|
||||
return@addInterceptor response
|
||||
}
|
||||
// Fix image content type
|
||||
val type = IMG_CONTENT_TYPE.toMediaType()
|
||||
val body = response.body.source().asResponseBody(type)
|
||||
return@addInterceptor response.newBuilder().body(body).build()
|
||||
}
|
||||
response
|
||||
}
|
||||
.rateLimit(4)
|
||||
.build()
|
||||
|
||||
override fun Element.imgAttr(): String = when {
|
||||
hasAttr("data-lzl-src") -> attr("abs:data-lzl-src")
|
||||
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
|
||||
hasAttr("data-src") -> attr("abs:data-src")
|
||||
hasAttr("data-cfsrc") -> attr("abs:data-cfsrc")
|
||||
else -> attr("abs:src")
|
||||
}
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsParse(document: Document) = super.mangaDetailsParse(document).apply {
|
||||
title = document.selectFirst(seriesThumbnailSelector)!!.attr("title")
|
||||
}
|
||||
|
||||
override val hasProjectPage = true
|
||||
override fun OkHttpClient.Builder.customizeClient() = rateLimit(4)
|
||||
}
|
||||
|
||||
private const val IMG_CONTENT_TYPE = "image/jpeg"
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
ext {
|
||||
extName = 'Ikiru'
|
||||
extClass = '.Ikiru'
|
||||
extVersionCode = 43
|
||||
themePkg = 'natsuid'
|
||||
baseUrl = 'https://02.ikiru.wtf'
|
||||
overrideVersionCode = 43
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -1,177 +1,21 @@
|
||||
package eu.kanade.tachiyomi.extension.id.mangatale
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.multisrc.natsuid.NatsuId
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
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.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
class Ikiru : ParsedHttpSource() {
|
||||
class Ikiru : NatsuId(
|
||||
"Ikiru",
|
||||
"id",
|
||||
"https://02.ikiru.wtf",
|
||||
) {
|
||||
// Formerly "MangaTale"
|
||||
override val id = 1532456597012176985
|
||||
|
||||
override val name = "Ikiru"
|
||||
override val baseUrl = "https://02.ikiru.wtf"
|
||||
override val lang = "id"
|
||||
override val supportsLatest = true
|
||||
override fun OkHttpClient.Builder.customizeClient() = rateLimit(12, 3)
|
||||
|
||||
override val client: OkHttpClient = super.client.newBuilder()
|
||||
.rateLimit(12, 3)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", FilterList())
|
||||
|
||||
override fun popularMangaSelector() = searchMangaSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
|
||||
|
||||
// Latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/latest-update/?the_page=$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = "#search-results > div:not(.col-span-full)"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "#search-results ~ div.col-span-full a:has(svg):last-of-type"
|
||||
|
||||
// Search
|
||||
private var searchNonce: String? = null
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// TODO: Filter
|
||||
|
||||
if (searchNonce.isNullOrEmpty()) {
|
||||
val document = client.newCall(
|
||||
GET("$baseUrl/wp-admin/admin-ajax.php?type=search_form&action=get_nonce", headers),
|
||||
).execute().asJsoup()
|
||||
searchNonce = document.selectFirst("input[name=search_nonce]")!!.attr("value")
|
||||
}
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("query", query)
|
||||
.addFormDataPart("page", "$page")
|
||||
.addFormDataPart("nonce", searchNonce!!)
|
||||
.build()
|
||||
|
||||
return POST("$baseUrl/wp-admin/admin-ajax.php?action=advanced_search", body = requestBody)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "div.overflow-hidden:has(a.font-medium)"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")?.absUrl("href") ?: "")
|
||||
thumbnail_url = element.selectFirst("img")?.absUrl("src") ?: ""
|
||||
title = element.selectFirst("a.font-medium")?.text() ?: ""
|
||||
status = parseStatus(element.selectFirst("div span ~ p")?.text() ?: "")
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div button:has(svg)"
|
||||
|
||||
// Manga Details
|
||||
private fun Element.getMangaId() = selectFirst("#gallery-list")?.attr("hx-get")
|
||||
?.substringAfter("manga_id=")?.substringBefore("&")
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
document.selectFirst("article > section").let { element ->
|
||||
return SManga.create().apply {
|
||||
thumbnail_url = element!!.selectFirst(".contents img")?.absUrl("src") ?: ""
|
||||
title = element.selectFirst("h1.font-bold")?.text() ?: ""
|
||||
// TODO: prevent status value from browse change back to default
|
||||
|
||||
val altNames = element.selectFirst("h1 ~ .line-clamp-1")?.text() ?: ""
|
||||
val synopsis = element.selectFirst("#tabpanel-description div[data-show='false']")?.text() ?: ""
|
||||
description = buildString {
|
||||
append(synopsis)
|
||||
if (altNames.isNotEmpty()) {
|
||||
append("\n\nAlternative Title: ", altNames)
|
||||
}
|
||||
document.getMangaId()?.also {
|
||||
append("\n\nID: ", it) // for fetching chapter list
|
||||
}
|
||||
}
|
||||
genre = element.select(".space-y-2 div:has(img) p, #tabpanel-description .flex-wrap span").joinToString { it.text() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.fromCallable {
|
||||
val mangaId = manga.description
|
||||
?.substringAfterLast("ID: ", "")
|
||||
?.takeIf { it.toIntOrNull() != null }
|
||||
?: client.newCall(mangaDetailsRequest(manga)).execute().asJsoup().getMangaId()
|
||||
?: throw Exception("Could not find manga ID")
|
||||
|
||||
val chapterListUrl = "$baseUrl/wp-admin/admin-ajax.php".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("manga_id", mangaId)
|
||||
.addQueryParameter("page", "${Random.nextInt(99, 9999)}") // keep above 3 for loading hidden chapter
|
||||
.addQueryParameter("action", "chapter_list")
|
||||
.build()
|
||||
|
||||
val response = client.newCall(GET(chapterListUrl, headers)).execute()
|
||||
|
||||
response.asJsoup().select("div a").map { element ->
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
name = element.select("span").text()
|
||||
date_upload = dateFormat.tryParse(element.select("time").attr("datetime"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
|
||||
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("main .relative section > img").mapIndexed { i, element ->
|
||||
Page(i, imageUrl = element.attr("abs:src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
|
||||
// Others
|
||||
private fun parseStatus(element: String?): Int {
|
||||
if (element.isNullOrEmpty()) {
|
||||
return SManga.UNKNOWN
|
||||
}
|
||||
return when (element.lowercase()) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
"on hiatus" -> SManga.ON_HIATUS
|
||||
"canceled" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
|
||||
override fun transformJsonResponse(responseBody: String): String {
|
||||
val jsonStart = responseBody.indexOfFirst { it == '{' || it == '[' }
|
||||
return if (jsonStart >= 0) responseBody.substring(jsonStart) else responseBody
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
ext {
|
||||
extName = 'Natsu'
|
||||
extClass = '.Natsu'
|
||||
themePkg = 'mangathemesia'
|
||||
baseUrl = 'https://natsu.id'
|
||||
overrideVersionCode = 0
|
||||
themePkg = 'natsuid'
|
||||
baseUrl = 'https://natsu.tv'
|
||||
overrideVersionCode = 30
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
package eu.kanade.tachiyomi.extension.id.natsu
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
|
||||
import eu.kanade.tachiyomi.multisrc.natsuid.NatsuId
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jsoup.nodes.Document
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class Natsu : MangaThemesia("Natsu", "https://natsu.id", "id", dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale("id"))) {
|
||||
|
||||
override val client: OkHttpClient = super.client.newBuilder()
|
||||
.rateLimit(4)
|
||||
.build()
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsParse(document: Document) = super.mangaDetailsParse(document).apply {
|
||||
title = document.selectFirst(seriesThumbnailSelector)!!.attr("title")
|
||||
}
|
||||
class Natsu : NatsuId(
|
||||
"Natsu",
|
||||
"id",
|
||||
"https://natsu.tv",
|
||||
) {
|
||||
override fun OkHttpClient.Builder.customizeClient() = rateLimit(4)
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
ext {
|
||||
extName = 'Rawkuma'
|
||||
extClass = '.Rawkuma'
|
||||
extVersionCode = 35
|
||||
isNsfw = true
|
||||
extName = 'Rawkuma'
|
||||
extClass = '.Rawkuma'
|
||||
themePkg = 'natsuid'
|
||||
baseUrl = 'https://rawkuma.net'
|
||||
overrideVersionCode = 35
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
|
||||
}
|
||||
|
||||
@ -1,320 +1,11 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.rawkuma
|
||||
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
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.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.firstInstanceOrNull
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.toJsonString
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.jsoup.Jsoup
|
||||
import rx.Observable
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import eu.kanade.tachiyomi.multisrc.natsuid.NatsuId
|
||||
|
||||
class Rawkuma : HttpSource() {
|
||||
override val name = "Rawkuma"
|
||||
override val lang = "ja"
|
||||
override val baseUrl = "https://rawkuma.net"
|
||||
override val supportsLatest = true
|
||||
class Rawkuma : NatsuId(
|
||||
"Rawkuma",
|
||||
"ja",
|
||||
"https://rawkuma.net",
|
||||
) {
|
||||
override val versionId = 2
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
// fix disk cache
|
||||
.apply {
|
||||
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
|
||||
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
|
||||
}
|
||||
.build()
|
||||
|
||||
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 fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith("https://")) {
|
||||
deepLink(query)
|
||||
} else {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php?action=advanced_search"
|
||||
val body = MultipartBody.Builder().apply {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("nonce", getNonce())
|
||||
filters.firstInstanceOrNull<GenreInclusion>()?.selected.also {
|
||||
addFormDataPart("inclusion", it ?: "OR")
|
||||
}
|
||||
filters.firstInstanceOrNull<GenreExclusion>()?.selected.also {
|
||||
addFormDataPart("exclusion", it ?: "OR")
|
||||
}
|
||||
addFormDataPart("page", page.toString())
|
||||
val genres = filters.firstInstanceOrNull<GenreFilter>()
|
||||
genres?.included.orEmpty().also {
|
||||
addFormDataPart("genre", it.toJsonString())
|
||||
}
|
||||
genres?.excluded.orEmpty().also {
|
||||
addFormDataPart("genre_exclude", it.toJsonString())
|
||||
}
|
||||
addFormDataPart("author", "[]")
|
||||
addFormDataPart("artist", "[]")
|
||||
addFormDataPart("project", "0")
|
||||
filters.firstInstanceOrNull<TypeFilter>()?.checked.orEmpty().also {
|
||||
addFormDataPart("type", it.toJsonString())
|
||||
}
|
||||
val sort = filters.firstInstance<SortFilter>()
|
||||
addFormDataPart("order", if (sort.isAscending) "asc" else "desc")
|
||||
addFormDataPart("orderby", sort.sort)
|
||||
addFormDataPart("query", query.trim())
|
||||
}.build()
|
||||
|
||||
return POST(url, headers, body)
|
||||
}
|
||||
|
||||
private var nonce: String? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getNonce(): String {
|
||||
if (nonce == null) {
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php?type=search_form&action=get_nonce"
|
||||
val response = client.newCall(GET(url, headers)).execute()
|
||||
|
||||
Jsoup.parseBodyFragment(response.body.string())
|
||||
.selectFirst("input[name=search_nonce]")
|
||||
?.attr("value")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.also {
|
||||
nonce = it
|
||||
}
|
||||
}
|
||||
|
||||
return nonce ?: throw Exception("Unable to get nonce")
|
||||
}
|
||||
|
||||
private val metadataClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
chain.proceed(chain.request()).newBuilder()
|
||||
.header("Cache-Control", "max-age=${24 * 60 * 60}")
|
||||
.removeHeader("Pragma")
|
||||
.removeHeader("Expires")
|
||||
.build()
|
||||
}.build()
|
||||
|
||||
override fun getFilterList() = runBlocking(Dispatchers.IO) {
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
SortFilter(),
|
||||
TypeFilter(),
|
||||
StatusFilter(),
|
||||
)
|
||||
|
||||
val url = "$baseUrl/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc"
|
||||
val response = metadataClient.newCall(
|
||||
GET(url, headers, CacheControl.FORCE_CACHE),
|
||||
).await()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
metadataClient.newCall(
|
||||
GET(url, headers, CacheControl.FORCE_NETWORK),
|
||||
).enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.closeQuietly()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(name, "Failed to fetch genre filter", e)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Press 'reset' to load genre filter"),
|
||||
),
|
||||
)
|
||||
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
val data = try {
|
||||
response.parseAs<List<Term>>()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(name, "Failed to parse genre filters", e)
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Failed to parse genre filter"),
|
||||
),
|
||||
)
|
||||
|
||||
return@runBlocking FilterList(filters)
|
||||
}
|
||||
|
||||
filters.addAll(
|
||||
listOf(
|
||||
GenreFilter(
|
||||
data.map { it.name to it.slug },
|
||||
),
|
||||
GenreInclusion(),
|
||||
GenreInclusion(),
|
||||
),
|
||||
)
|
||||
|
||||
FilterList(filters)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
|
||||
val slugs = document.select("div > a[href*=/manga/]:has(> img)").map {
|
||||
it.absUrl("href").toHttpUrl().pathSegments[1]
|
||||
}.ifEmpty {
|
||||
return MangasPage(emptyList(), false)
|
||||
}
|
||||
|
||||
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder().apply {
|
||||
slugs.forEach { slug ->
|
||||
addQueryParameter("slug[]", slug)
|
||||
}
|
||||
addQueryParameter("per_page", "${slugs.size + 1}")
|
||||
addQueryParameter("_embed", null)
|
||||
}.build()
|
||||
|
||||
val details = client.newCall(GET(url, headers)).execute()
|
||||
.parseAs<List<Manga>>()
|
||||
.filterNot { manga ->
|
||||
manga.embedded.getTerms("type").contains("Novel")
|
||||
}
|
||||
.associateBy { it.slug }
|
||||
|
||||
val mangas = slugs.mapNotNull { slug ->
|
||||
details[slug]?.toSManga()
|
||||
}
|
||||
|
||||
val hasNextPage = document.selectFirst("button > svg") != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
private fun deepLink(url: String): Observable<MangasPage> {
|
||||
val httpUrl = url.toHttpUrl()
|
||||
if (
|
||||
httpUrl.host == baseUrl.toHttpUrl().host &&
|
||||
httpUrl.pathSegments.size >= 2 &&
|
||||
httpUrl.pathSegments[0] == "manga"
|
||||
) {
|
||||
val slug = httpUrl.pathSegments[1]
|
||||
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("slug[]", slug)
|
||||
.addQueryParameter("_embed", null)
|
||||
.build()
|
||||
|
||||
return client.newCall(GET(url, headers))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val manga = response.parseAs<List<Manga>>()[0]
|
||||
|
||||
if (manga.embedded.getTerms("type").contains("Novel")) {
|
||||
throw Exception("Novels are not supported")
|
||||
}
|
||||
|
||||
MangasPage(listOf(manga.toSManga()), false)
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.error(Exception("Unsupported url"))
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val id = manga.url.parseAs<MangaUrl>().id
|
||||
|
||||
return GET("$baseUrl/wp-json/wp/v2/manga/$id?_embed", headers)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
val slug = manga.url.parseAs<MangaUrl>().slug
|
||||
|
||||
return "$baseUrl/manga/$slug/"
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
return response.parseAs<Manga>().toSManga()
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = manga.url.parseAs<MangaUrl>().id
|
||||
val url = "$baseUrl/wp-admin/admin-ajax.php".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("manga_id", id.toString())
|
||||
.addQueryParameter("page", "1")
|
||||
.addQueryParameter("action", "chapter_list")
|
||||
.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
|
||||
|
||||
return document.select("#chapter-list > div[data-chapter-number] > a").map {
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(it.absUrl("href"))
|
||||
name = it.selectFirst("div > span")!!.ownText()
|
||||
date_upload = dateFormat.tryParse(
|
||||
it.selectFirst("time")?.attr("datetime"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
return document.select("main section img").mapIndexed { idx, img ->
|
||||
Page(idx, imageUrl = img.absUrl("src"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user