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