Rawkuma: rewrite for new theme (#11270)

* Rawkuma: rewrite for new site layout

* update icon

* filters: use callback to not throw

* deep link

* fix index out of bound

* return if empty

* novels check in deeplink

* Add `@Synchronized` to `getNonce`
This commit is contained in:
AwkwardPeak7 2025-10-29 18:28:09 +05:00 committed by Draff
parent 9f720a3488
commit db1d8e26f4
Signed by: Draff
GPG Key ID: E8A89F3211677653
11 changed files with 551 additions and 12 deletions

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".ja.rawkuma.UrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="rawkuma.net" />
<data android:scheme="https" />
<data android:pathPattern="/manga/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,10 +1,12 @@
ext {
extName = 'Rawkuma'
extClass = '.Rawkuma'
themePkg = 'mangathemesia'
baseUrl = 'https://old.rawkuma.net'
overrideVersionCode = 3
isNsfw = true
extName = 'Rawkuma'
extClass = '.Rawkuma'
extVersionCode = 34
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,77 @@
package eu.kanade.tachiyomi.extension.ja.rawkuma
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.toJsonString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
@Serializable
class Term(
val name: String,
val slug: String,
val taxonomy: String,
)
@Serializable
class Manga(
val id: Int,
val slug: String,
val title: Rendered,
val content: Rendered,
@SerialName("_embedded")
val embedded: Embedded,
) {
fun toSManga() = SManga.create().apply {
url = MangaUrl(id, slug).toJsonString()
title = Parser.unescapeEntities(this@Manga.title.rendered, false)
description = Jsoup.parseBodyFragment(content.rendered).wholeText()
thumbnail_url = embedded.featuredMedia.firstOrNull()?.sourceUrl
author = embedded.getTerms("series-author").joinToString()
artist = embedded.getTerms("artist").joinToString()
genre = buildSet {
addAll(embedded.getTerms("genre"))
addAll(embedded.getTerms("type"))
}.joinToString()
status = with(embedded.getTerms("status")) {
when {
contains("Ongoing") -> SManga.ONGOING
contains("Completed") -> SManga.COMPLETED
contains("Cancelled") -> SManga.CANCELLED
contains("On Hiatus") -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
}
initialized = true
}
}
@Serializable
class Embedded(
@SerialName("wp:featuredmedia")
val featuredMedia: List<FeaturedMedia>,
@SerialName("wp:term")
private val terms: List<List<Term>>,
) {
fun getTerms(type: String): List<String> {
return terms.find { it.getOrNull(0)?.taxonomy == type }?.map { it.name } ?: emptyList()
}
}
@Serializable
class FeaturedMedia(
@SerialName("source_url")
val sourceUrl: String,
)
@Serializable
class Rendered(
val rendered: String,
)
@Serializable
class MangaUrl(
val id: Int,
val slug: String,
)

View File

@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.extension.ja.rawkuma
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
abstract class SelectFilter<T>(
name: String,
private val options: List<Pair<String, T>>,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second
}
class CheckBoxFilter<T>(name: String, val value: T) : Filter.CheckBox(name)
abstract class CheckBoxGroup<T>(
name: String,
options: List<Pair<String, T>>,
) : Filter.Group<CheckBoxFilter<T>>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }
}
class TriStateFilter<T>(name: String, val value: T) : Filter.TriState(name)
abstract class TriStateGroupFilter<T>(
name: String,
options: List<Pair<String, T>>,
) : Filter.Group<TriStateFilter<T>>(
name,
options.map { TriStateFilter(it.first, it.second) },
) {
val included get() = state.filter { it.isIncluded() }.map { it.value }
val excluded get() = state.filter { it.isExcluded() }.map { it.value }
}
class SortFilter(
selection: Int = 0,
) : Filter.Sort(
name = "Sort",
values = sortBy.map { it.first }.toTypedArray(),
state = Selection(selection, false),
) {
val sort get() = sortBy[state?.index ?: 0].second
val isAscending get() = state?.ascending ?: false
companion object {
private val sortBy = listOf(
"Popular" to "popular",
"Rating" to "rating",
"Updated" to "updated",
"Bookmarked" to "bookmarked",
"Title" to "title",
)
val popular = FilterList(SortFilter(0))
val latest = FilterList(SortFilter(2))
}
}
class GenreFilter(
genres: List<Pair<String, String>>,
) : TriStateGroupFilter<String>("Genre", genres)
class GenreInclusion : SelectFilter<String>(
name = "Genre Inclusion Mode",
options = listOf(
"OR" to "OR",
"AND" to "AND",
),
)
class GenreExclusion : SelectFilter<String>(
name = "Genre Exclusion Mode",
options = listOf(
"OR" to "OR",
"AND" to "AND",
),
)
class TypeFilter : CheckBoxGroup<String>(
name = "Type",
options = listOf(
"Manga" to "manga",
"Manhwa" to "manhwa",
"Manhua" to "manhua",
),
)
class StatusFilter : CheckBoxGroup<String>(
name = "Status",
options = listOf(
"Ongoing" to "ongoing",
"Completed" to "completed",
"Cancelled" to "cancelled",
"On Hiatus" to "on-hiatus",
"Unknown" to "unknown",
),
)

View File

@ -1,12 +1,320 @@
package eu.kanade.tachiyomi.extension.ja.rawkuma
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import okhttp3.OkHttpClient
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
class Rawkuma : MangaThemesia("Rawkuma", "https://old.rawkuma.net", "ja") {
class Rawkuma : HttpSource() {
override val name = "Rawkuma"
override val lang = "ja"
override val baseUrl = "https://rawkuma.net"
override val supportsLatest = true
override val versionId = 2
override val client: OkHttpClient = super.client.newBuilder()
.rateLimit(4)
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 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()
}
}

View File

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.extension.ja.rawkuma
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class UrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", intent.data.toString())
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("Rawkuma", "Unable to launch activity", e)
}
finish()
exitProcess(0)
}
}