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:
Luqman 2025-11-16 15:04:09 +07:00 committed by Draff
parent 6bced1de64
commit 1e768ace94
Signed by: Draff
GPG Key ID: E8A89F3211677653
13 changed files with 440 additions and 555 deletions

View File

@ -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.
*/

View File

@ -0,0 +1,9 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -1,9 +1,9 @@
ext {
extName = 'Kiryuu'
extClass = '.Kiryuu'
themePkg = 'mangathemesia'
themePkg = 'natsuid'
baseUrl = 'https://kiryuu03.com'
overrideVersionCode = 14
overrideVersionCode = 44
isNsfw = false
}

View File

@ -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"

View File

@ -1,7 +1,9 @@
ext {
extName = 'Ikiru'
extClass = '.Ikiru'
extVersionCode = 43
themePkg = 'natsuid'
baseUrl = 'https://02.ikiru.wtf'
overrideVersionCode = 43
isNsfw = true
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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()
}
}