diff --git a/src/ja/rawkuma/AndroidManifest.xml b/src/ja/rawkuma/AndroidManifest.xml
new file mode 100644
index 000000000..24cbded35
--- /dev/null
+++ b/src/ja/rawkuma/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ja/rawkuma/build.gradle b/src/ja/rawkuma/build.gradle
index 44cf6729a..4cb0f3b59 100644
--- a/src/ja/rawkuma/build.gradle
+++ b/src/ja/rawkuma/build.gradle
@@ -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")
+}
diff --git a/src/ja/rawkuma/res/mipmap-hdpi/ic_launcher.png b/src/ja/rawkuma/res/mipmap-hdpi/ic_launcher.png
index 3a331c5bb..e8f622924 100644
Binary files a/src/ja/rawkuma/res/mipmap-hdpi/ic_launcher.png and b/src/ja/rawkuma/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/ja/rawkuma/res/mipmap-mdpi/ic_launcher.png b/src/ja/rawkuma/res/mipmap-mdpi/ic_launcher.png
index a7513e106..20111d0cf 100644
Binary files a/src/ja/rawkuma/res/mipmap-mdpi/ic_launcher.png and b/src/ja/rawkuma/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/ja/rawkuma/res/mipmap-xhdpi/ic_launcher.png b/src/ja/rawkuma/res/mipmap-xhdpi/ic_launcher.png
index c217f988e..1520b6ef9 100644
Binary files a/src/ja/rawkuma/res/mipmap-xhdpi/ic_launcher.png and b/src/ja/rawkuma/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/ja/rawkuma/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/rawkuma/res/mipmap-xxhdpi/ic_launcher.png
index ec867c650..f56d8e8e9 100644
Binary files a/src/ja/rawkuma/res/mipmap-xxhdpi/ic_launcher.png and b/src/ja/rawkuma/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/ja/rawkuma/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/rawkuma/res/mipmap-xxxhdpi/ic_launcher.png
index 3f9ace4da..55192a63d 100644
Binary files a/src/ja/rawkuma/res/mipmap-xxxhdpi/ic_launcher.png and b/src/ja/rawkuma/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Dto.kt b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Dto.kt
new file mode 100644
index 000000000..9529e672c
--- /dev/null
+++ b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Dto.kt
@@ -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,
+ @SerialName("wp:term")
+ private val terms: List>,
+) {
+ fun getTerms(type: String): List {
+ 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,
+)
diff --git a/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Filter.kt b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Filter.kt
new file mode 100644
index 000000000..7205ea8cc
--- /dev/null
+++ b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Filter.kt
@@ -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(
+ name: String,
+ private val options: List>,
+) : Filter.Select(
+ name,
+ options.map { it.first }.toTypedArray(),
+) {
+ val selected get() = options[state].second
+}
+
+class CheckBoxFilter(name: String, val value: T) : Filter.CheckBox(name)
+
+abstract class CheckBoxGroup(
+ name: String,
+ options: List>,
+) : Filter.Group>(
+ name,
+ options.map { CheckBoxFilter(it.first, it.second) },
+) {
+ val checked get() = state.filter { it.state }.map { it.value }
+}
+
+class TriStateFilter(name: String, val value: T) : Filter.TriState(name)
+
+abstract class TriStateGroupFilter(
+ name: String,
+ options: List>,
+) : Filter.Group>(
+ 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>,
+) : TriStateGroupFilter("Genre", genres)
+
+class GenreInclusion : SelectFilter(
+ name = "Genre Inclusion Mode",
+ options = listOf(
+ "OR" to "OR",
+ "AND" to "AND",
+ ),
+)
+
+class GenreExclusion : SelectFilter(
+ name = "Genre Exclusion Mode",
+ options = listOf(
+ "OR" to "OR",
+ "AND" to "AND",
+ ),
+)
+
+class TypeFilter : CheckBoxGroup(
+ name = "Type",
+ options = listOf(
+ "Manga" to "manga",
+ "Manhwa" to "manhwa",
+ "Manhua" to "manhua",
+ ),
+)
+
+class StatusFilter : CheckBoxGroup(
+ name = "Status",
+ options = listOf(
+ "Ongoing" to "ongoing",
+ "Completed" to "completed",
+ "Cancelled" to "cancelled",
+ "On Hiatus" to "on-hiatus",
+ "Unknown" to "unknown",
+ ),
+)
diff --git a/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Rawkuma.kt b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Rawkuma.kt
index 8b0a6b07c..8286a48b3 100644
--- a/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Rawkuma.kt
+++ b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/Rawkuma.kt
@@ -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 {
+ 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()?.selected.also {
+ addFormDataPart("inclusion", it ?: "OR")
+ }
+ filters.firstInstanceOrNull()?.selected.also {
+ addFormDataPart("exclusion", it ?: "OR")
+ }
+ addFormDataPart("page", page.toString())
+ val genres = filters.firstInstanceOrNull()
+ 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()?.checked.orEmpty().also {
+ addFormDataPart("type", it.toJsonString())
+ }
+ val sort = filters.firstInstance()
+ 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> = 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>()
+ } 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>()
+ .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 {
+ 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>()[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().id
+
+ return GET("$baseUrl/wp-json/wp/v2/manga/$id?_embed", headers)
+ }
+
+ override fun getMangaUrl(manga: SManga): String {
+ val slug = manga.url.parseAs().slug
+
+ return "$baseUrl/manga/$slug/"
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ return response.parseAs().toSManga()
+ }
+
+ override fun chapterListRequest(manga: SManga): Request {
+ val id = manga.url.parseAs().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 {
+ 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 {
+ 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()
+ }
}
diff --git a/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/UrlActivity.kt b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/UrlActivity.kt
new file mode 100644
index 000000000..04b58dba5
--- /dev/null
+++ b/src/ja/rawkuma/src/eu/kanade/tachiyomi/extension/ja/rawkuma/UrlActivity.kt
@@ -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)
+ }
+}