diff --git a/src/ja/rawz/AndroidManifest.xml b/src/ja/rawz/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/ja/rawz/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/ja/rawz/build.gradle b/src/ja/rawz/build.gradle new file mode 100644 index 000000000..6d6d84359 --- /dev/null +++ b/src/ja/rawz/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'RawZ' + extClass = '.RawZ' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZ.kt b/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZ.kt new file mode 100644 index 000000000..5cc56a260 --- /dev/null +++ b/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZ.kt @@ -0,0 +1,180 @@ +package eu.kanade.tachiyomi.extension.ja.rawz + +import eu.kanade.tachiyomi.network.GET +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.UnsupportedOperationException + +class RawZ : HttpSource() { + + override val name = "RawZ" + + override val baseUrl = "https://stmanga.com" + + private val apiUrl = "https://api.rawz.org/api" + + override val lang = "ja" + + override val supportsLatest = true + + private val json by injectLazy() + + override val client = network.cloudflareClient + + 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 searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$apiUrl/manga".toHttpUrl().newBuilder().apply { + addQueryParameter("name", query.trim()) + filters.filterIsInstance().forEach { + it.addQueryParameter(this) + } + addQueryParameter("page", page.toString()) + addQueryParameter("limit", LIMIT.toString()) + }.build() + + return GET(url, headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + fetchGenres() + + val result = response.parseAs>>() + + val entries = result.data.map { it.toSManga() } + val hasNextPage = result.data.size == LIMIT + + return MangasPage(entries, hasNextPage) + } + + private var genreCache: List> = emptyList() + private var genreFetchAttempts = 0 + private var genreFetchFailed = false + + private fun fetchGenres() { + if ((genreCache.isEmpty() || genreFetchFailed) && genreFetchAttempts < 3) { + val genres = runCatching { + client.newCall( + GET("$apiUrl/taxonomy-browse?type=genres&limit=100&page=1", headers), + ) + .execute() + .parseAs>() + .data.genres.map { + Pair(it.name, it.id.toString()) + } + } + + genreCache = genres.getOrNull().orEmpty() + genreFetchFailed = genres.isFailure + genreFetchAttempts++ + } + } + + override fun getFilterList(): FilterList { + val filters = mutableListOf>( + SortFilter(), + TypeFilter(), + StatusFilter(), + ChapterNumFilter(), + ) + + filters += if (genreCache.isEmpty()) { + listOf( + Filter.Separator(), + Filter.Header("Press Reset to attempt to display genre"), + ) + } else { + listOf( + GenreFilter(genreCache), + ) + } + + return FilterList(filters) + } + + override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}" + + override fun mangaDetailsRequest(manga: SManga): Request { + return GET("$apiUrl/manga/${manga.url.substringAfter(".")}") + } + + override fun mangaDetailsParse(response: Response): SManga { + return response.parseAs>().data.toSManga() + } + + override fun chapterListRequest(manga: SManga): Request { + val slug = manga.url + .substringAfter("/manga/") + .substringBefore(".") + + val id = manga.url.substringAfterLast(".") + + return GET("$apiUrl/manga/$id/childs#$slug", headers) + } + + override fun chapterListParse(response: Response): List { + val result = response.parseAs>>() + val mangaSlug = response.request.url.fragment!! + + return result.data.map { + SChapter.create().apply { + url = "/read/$mangaSlug.${it.id}/${it.slug}" + name = it.name + date_upload = runCatching { + dateFormat.parse(it.createdAt!!)!!.time + }.getOrDefault(0L) + } + } + } + + override fun getChapterUrl(chapter: SChapter) = "$baseUrl${chapter.url}" + + override fun pageListRequest(chapter: SChapter): Request { + val id = chapter.url + .substringBeforeLast("/") + .substringAfterLast(".") + + return GET("$apiUrl/child-detail/$id", headers) + } + + override fun pageListParse(response: Response): List { + val result = response.parseAs>() + + return result.data.images.mapIndexed { idx, img -> + Page(idx, "", img.url) + } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + + private inline fun Response.parseAs(): T = use { + json.decodeFromString(body.string()) + } + + companion object { + private const val LIMIT = 30 + private val dateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH) + } + } +} diff --git a/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZDto.kt b/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZDto.kt new file mode 100644 index 000000000..1526cbbd0 --- /dev/null +++ b/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZDto.kt @@ -0,0 +1,97 @@ +package eu.kanade.tachiyomi.extension.ja.rawz + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import java.util.Locale + +@Serializable +data class Data( + val data: T, +) + +@Serializable +data class Manga( + val id: Int, + val name: String, + val slug: String, + val description: String? = null, + val status: String? = null, + val type: String? = null, + val image: String? = null, + @Serializable(with = EmptyArrayOrTaxonomySerializer::class) + val taxonomy: Taxonomy, +) { + fun toSManga() = SManga.create().apply { + url = "/manga/$slug.$id" + thumbnail_url = image + title = name + description = this@Manga.description + genre = ( + taxonomy.genres.map { + it.name + }.let { + type?.run { + it.plus( + this.replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase(Locale.getDefault()) + } else { + it.toString() + } + }, + ) + } + } + )?.joinToString() + status = when (this@Manga.status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + initialized = true + } +} + +@Serializable +data class Taxonomy( + val genres: List, +) + +object EmptyArrayOrTaxonomySerializer : JsonTransformingSerializer(Taxonomy.serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement { + return if (element is JsonArray) { + JsonObject(mapOf(Pair("genres", JsonArray(emptyList())))) + } else { + element + } + } +} + +@Serializable +data class Genre( + val id: Int, + val name: String, +) + +@Serializable +data class Chapter( + val id: Int, + val name: String, + val slug: String, + @SerialName("created_at") val createdAt: String? = null, +) + +@Serializable +data class Pages( + val images: List, +) + +@Serializable +data class Url( + val url: String, +) diff --git a/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZFilters.kt b/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZFilters.kt new file mode 100644 index 000000000..b90692bdc --- /dev/null +++ b/src/ja/rawz/src/eu/kanade/tachiyomi/extension/ja/rawz/RawZFilters.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.extension.ja.rawz + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl + +interface UriPartFilter { + fun addQueryParameter(url: HttpUrl.Builder) +} + +class CheckBoxFilter( + name: String, + val value: String, +) : Filter.CheckBox(name) + +abstract class CheckBoxFilterGroup( + name: String, + genres: List>, +) : UriPartFilter, Filter.Group( + name, + genres.map { CheckBoxFilter(it.first, it.second) }, +) { + abstract val queryParameter: String + override fun addQueryParameter(url: HttpUrl.Builder) { + state.filter { it.state }.forEach { + url.addQueryParameter(queryParameter, it.value) + } + } +} + +abstract class SelectFilter( + name: String, + private val options: List>, + defaultValue: String? = null, +) : UriPartFilter, Filter.Select( + name, + options.map { it.first }.toTypedArray(), + options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, +) { + abstract val queryParameter: String + override fun addQueryParameter(url: HttpUrl.Builder) { + url.addQueryParameter(queryParameter, options[state].second) + } +} + +class TypeFilter : CheckBoxFilterGroup("タイプ", types) { + override val queryParameter = "type[]" + companion object { + private val types = listOf( + Pair("Manga", "manga"), + Pair("Manhua", "manhua"), + Pair("Manhwa", "manhwa"), + Pair("Oneshot", "oneshot"), + Pair("Doujinshi", "doujinshi"), + ) + } +} + +class GenreFilter(genres: List>) : CheckBoxFilterGroup("ジャンル", genres) { + override val queryParameter = "taxonomy[]" +} + +class StatusFilter : CheckBoxFilterGroup("ステータス", status) { + override val queryParameter = "status[]" + companion object { + private val status = listOf( + Pair("Ongoing", "ongoing"), + Pair("Completed", "completed"), + ) + } +} + +class ChapterNumFilter : SelectFilter("最小章", minChapNum) { + override val queryParameter = "minchap" + companion object { + private val minChapNum = listOf( + Pair(">= 1 chapters", "1"), + Pair(">= 3 chapters", "3"), + Pair(">= 5 chapters", "5"), + Pair(">= 10 chapters", "10"), + Pair(">= 20 chapters", "20"), + Pair(">= 30 chapters", "30"), + Pair(">= 50 chapters", "50"), + ) + } +} + +class SortFilter(default: String? = null) : SelectFilter("並び替え", sorts, default) { + override val queryParameter = "order_by" + companion object { + private val sorts = listOf( + Pair("Recently updated", "updated_at"), + Pair("Recently added", "created_at"), + Pair("Trending", "views"), + Pair("Name A-Z", "name"), + ) + + val POPULAR = FilterList(SortFilter("views")) + val LATEST = FilterList(SortFilter("updated_at")) + } +}