diff --git a/lib-multisrc/keyoapp/build.gradle.kts b/lib-multisrc/keyoapp/build.gradle.kts new file mode 100644 index 000000000..dc076cc37 --- /dev/null +++ b/lib-multisrc/keyoapp/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 1 diff --git a/lib-multisrc/keyoapp/res/mipmap-hdpi/ic_launcher.png b/lib-multisrc/keyoapp/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..816d64158 Binary files /dev/null and b/lib-multisrc/keyoapp/res/mipmap-hdpi/ic_launcher.png differ diff --git a/lib-multisrc/keyoapp/res/mipmap-mdpi/ic_launcher.png b/lib-multisrc/keyoapp/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..bbe154df4 Binary files /dev/null and b/lib-multisrc/keyoapp/res/mipmap-mdpi/ic_launcher.png differ diff --git a/lib-multisrc/keyoapp/res/mipmap-xhdpi/ic_launcher.png b/lib-multisrc/keyoapp/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..5134dd20f Binary files /dev/null and b/lib-multisrc/keyoapp/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/lib-multisrc/keyoapp/res/mipmap-xxhdpi/ic_launcher.png b/lib-multisrc/keyoapp/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..37f28c8fb Binary files /dev/null and b/lib-multisrc/keyoapp/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/lib-multisrc/keyoapp/res/mipmap-xxxhdpi/ic_launcher.png b/lib-multisrc/keyoapp/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..582a3c744 Binary files /dev/null and b/lib-multisrc/keyoapp/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/lib-multisrc/keyoapp/src/eu/kanade/tachiyomi/multisrc/keyoapp/Keyoapp.kt b/lib-multisrc/keyoapp/src/eu/kanade/tachiyomi/multisrc/keyoapp/Keyoapp.kt new file mode 100644 index 000000000..9fa1b40c5 --- /dev/null +++ b/lib-multisrc/keyoapp/src/eu/kanade/tachiyomi/multisrc/keyoapp/Keyoapp.kt @@ -0,0 +1,271 @@ +package eu.kanade.tachiyomi.multisrc.keyoapp + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +abstract class Keyoapp( + override val name: String, + override val baseUrl: String, + final override val lang: String, +) : ParsedHttpSource() { + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private val json: Json by injectLazy() + + // Popular + + override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers) + + override fun popularMangaSelector(): String = "div.flex-col div.grid > div.group.border" + + override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.getImageUrl("*[style*=background-image]") + element.selectFirst("a[href]")!!.run { + title = attr("title") + setUrlWithoutDomain(attr("abs:href")) + } + } + + override fun popularMangaNextPageSelector(): String? = null + + override fun popularMangaParse(response: Response): MangasPage { + runCatching { fetchGenres() } + return super.popularMangaParse(response) + } + + // Latest + + override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/latest/", headers) + + override fun latestUpdatesSelector(): String = "div.grid > div.group" + + override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun latestUpdatesNextPageSelector(): String? = null + + override fun latestUpdatesParse(response: Response): MangasPage { + runCatching { fetchGenres() } + return super.latestUpdatesParse(response) + } + + // Search + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment("") + if (query.isNotBlank()) { + addQueryParameter("q", query) + } + filters.firstOrNull { it is GenreList }?.also { + val filter = it as GenreList + filter.state + .filter { it.state } + .forEach { genre -> + addQueryParameter("genre", genre.id) + } + } + }.build() + + return GET(url, headers) + } + + override fun searchMangaSelector() = "#searched_series_page > button" + + override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector(): String? = null + + override fun searchMangaParse(response: Response): MangasPage { + runCatching { fetchGenres() } + val document = response.asJsoup() + + val query = response.request.url.queryParameter("q") ?: "" + val genres = response.request.url.queryParameterValues("genre") + + val mangaList = document.select(searchMangaSelector()) + .toTypedArray() + .filter { it.attr("title").contains(query, true) } + .filter { entry -> + val entryGenres = json.decodeFromString>(entry.attr("tags")) + genres.all { genre -> entryGenres.any { it.equals(genre, true) } } + } + .map(::searchMangaFromElement) + + return MangasPage(mangaList, false) + } + + // Filters + + /** + * Automatically fetched genres from the source to be used in the filters. + */ + private var genresList: List = emptyList() + + /** + * Inner variable to control the genre fetching failed state. + */ + private var fetchGenresFailed: Boolean = false + + /** + * Inner variable to control how much tries the genres request was called. + */ + private var fetchGenresAttempts: Int = 0 + + class Genre(name: String, val id: String = name) : Filter.CheckBox(name) + + protected class GenreList(title: String, genres: List) : Filter.Group(title, genres) + + override fun getFilterList(): FilterList { + return if (genresList.isNotEmpty()) { + FilterList( + GenreList("Genres", genresList), + ) + } else { + FilterList( + Filter.Header("Press 'Reset' to attempt to show the genres"), + ) + } + } + + /** + * Fetch the genres from the source to be used in the filters. + */ + protected open fun fetchGenres() { + if (fetchGenresAttempts <= 3 && (genresList.isEmpty() || fetchGenresFailed)) { + val genres = runCatching { + client.newCall(genresRequest()).execute() + .use { parseGenres(it.asJsoup()) } + } + + fetchGenresFailed = genres.isFailure + genresList = genres.getOrNull().orEmpty() + fetchGenresAttempts++ + } + } + + private fun genresRequest(): Request = GET("$baseUrl/series/", headers) + + /** + * Get the genres from the search page document. + * + * @param document The search page document + */ + protected open fun parseGenres(document: Document): List { + return document.select("#series_tags_page > button") + .map { btn -> + Genre(btn.text(), btn.attr("tag")) + } + } + + // Details + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + title = document.selectFirst("div.grid > h1")!!.text() + thumbnail_url = document.getImageUrl("div[class*=photoURL]") + description = document.selectFirst("div.grid > div.overflow-hidden > p")?.text() + status = document.selectFirst("div[alt=Status]").parseStatus() + author = document.selectFirst("div[alt=Author]")?.text() + artist = document.selectFirst("div[alt=Artist]")?.text() + genre = document.select("div.grid:has(>h1) > div > a").joinToString { it.text() } + } + + private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) { + "ongoing" -> SManga.ONGOING + "dropped" -> SManga.CANCELLED + "paused" -> SManga.ON_HIATUS + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + // Chapter list + + override fun chapterListSelector(): String = "#chapters > a" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + setUrlWithoutDomain(element.selectFirst("a[href]")!!.attr("href")) + name = element.selectFirst(".text-sm")!!.text() + element.selectFirst(".text-xs")?.run { + date_upload = text().trim().parseDate() + } + } + + // Image list + + override fun pageListParse(document: Document): List { + return document.select("#pages > img").map { + val index = it.attr("count").toInt() + Page(index, document.location(), it.imgAttr("150")) + } + } + + override fun imageUrlParse(document: Document) = "" + + // Utilities + + // From mangathemesia + private fun Element.imgAttr(width: String): String { + val url = when { + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("data-src") -> attr("abs:data-src") + else -> attr("abs:src") + } + return url.toHttpUrl() + .newBuilder() + .addQueryParameter("w", width) + .build() + .toString() + } + + private fun Element.getImageUrl(selector: String): String? { + return this.selectFirst(selector)?.let { + it.attr("style") + .substringAfter(":url(", "") + .substringBefore(")", "") + .takeIf { it.isNotEmpty() } + ?.toHttpUrlOrNull()?.let { + it.newBuilder() + .setQueryParameter("w", "480") + .build() + .toString() + } + } + } + + private fun String.parseDate(): Long { + return runCatching { DATE_FORMATTER.parse(this)?.time } + .getOrNull() ?: 0L + } + + companion object { + private val DATE_FORMATTER by lazy { + SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH) + } + } +} diff --git a/src/en/rudrascans/build.gradle b/src/en/rudrascans/build.gradle index 2161de2d5..bb975fbe1 100644 --- a/src/en/rudrascans/build.gradle +++ b/src/en/rudrascans/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'Rudra Scans' extClass = '.RudraScans' - extVersionCode = 1 + themePkg = 'keyoapp' + baseUrl = 'https://rudrascans.com' + overrideVersionCode = 1 } apply from: "$rootDir/common.gradle" diff --git a/src/en/rudrascans/src/eu/kanade/tachiyomi/extension/en/rudrascans/RudraScans.kt b/src/en/rudrascans/src/eu/kanade/tachiyomi/extension/en/rudrascans/RudraScans.kt index fa53cb677..8edf82a56 100644 --- a/src/en/rudrascans/src/eu/kanade/tachiyomi/extension/en/rudrascans/RudraScans.kt +++ b/src/en/rudrascans/src/eu/kanade/tachiyomi/extension/en/rudrascans/RudraScans.kt @@ -1,178 +1,5 @@ package eu.kanade.tachiyomi.extension.en.rudrascans -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.interceptor.rateLimit -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.ParsedHttpSource -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.Locale +import eu.kanade.tachiyomi.multisrc.keyoapp.Keyoapp -class RudraScans : ParsedHttpSource() { - - override val name = "Rudra Scans" - - override val baseUrl = "https://rudrascans.com" - - override val lang = "en" - - override val supportsLatest = true - - override val client = super.client.newBuilder() - .rateLimit(1) - .build() - - override fun headersBuilder() = super.headersBuilder() - .add("Referer", "$baseUrl/") - - // Popular - - override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers) - - override fun popularMangaSelector(): String = "div.flex-col div.grid > div.group.border" - - override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { - thumbnail_url = element.getImageUrl("*[style*=background-image]") - element.selectFirst("a[href]")!!.run { - title = attr("title") - setUrlWithoutDomain(attr("href")) - } - } - - override fun popularMangaNextPageSelector(): String? = null - - // Latest - - override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/latest/", headers) - - override fun latestUpdatesSelector(): String = "div.grid > div.group" - - override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun latestUpdatesNextPageSelector(): String? = null - - // Search - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = baseUrl.toHttpUrl().newBuilder().apply { - addPathSegment("series") - addPathSegment("") - addQueryParameter("q", query) - }.build() - - return GET(url, headers) - } - - override fun searchMangaSelector() = "#searched_series_page > a" - - override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { - thumbnail_url = element.getImageUrl("*[style*=background-image]") - title = element.attr("title") - setUrlWithoutDomain(element.attr("href")) - } - - override fun searchMangaNextPageSelector(): String? = null - - override fun searchMangaParse(response: Response): MangasPage { - val document = response.use { it.asJsoup() } - val query = response.request.url.queryParameter("q")!! - - val mangaList = document.select(searchMangaSelector()) - .map(::searchMangaFromElement) - .filter { it.title.contains(query, true) } - - return MangasPage(mangaList, false) - } - - // Details - - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - title = document.selectFirst("div.grid > h1")!!.text() - thumbnail_url = document.getImageUrl("div[class*=photoURL]") - description = document.selectFirst("div.grid > div.overflow-hidden > p")?.text() - status = document.selectFirst("div[alt=Status]").parseStatus() - genre = document.select("div.grid:has(>h1) > div.flex > div.leading-none:not([alt])").joinToString(", ") { - it.text().trim() - } - } - - private fun Element?.parseStatus(): Int = when (this?.text()?.trim()) { - "ongoing" -> SManga.ONGOING - else -> SManga.UNKNOWN - } - - // Chapter list - - override fun chapterListSelector(): String = "#chapters > a" - - override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { - setUrlWithoutDomain(element.selectFirst("a[href]")!!.attr("href")) - name = element.selectFirst(".text-sm")!!.text() - element.selectFirst(".text-xs")?.run { - date_upload = text().trim().parseDate() - } - } - - // Image list - - override fun pageListParse(document: Document): List { - return document.select("#pages > img").map { - val index = it.attr("count").toInt() - Page(index, document.location(), it.imgAttr("150")) - } - } - - override fun imageUrlParse(document: Document) = "" - - // Utilities - - // From mangathemesia - private fun Element.imgAttr(width: String): String { - val url = when { - hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") - hasAttr("data-src") -> attr("abs:data-src") - else -> attr("abs:src") - } - return url.toHttpUrl() - .newBuilder() - .addQueryParameter("w", width) - .build() - .toString() - } - - private fun Element.getImageUrl(selector: String): String? { - return this.selectFirst(selector)?.let { - it.attr("style") - .substringAfter(":url(", "") - .substringBefore(")", "") - .takeIf { it.isNotEmpty() } - ?.toHttpUrlOrNull()?.let { - it.newBuilder() - .setQueryParameter("w", "480") - .build() - .toString() - } - } - } - - private fun String.parseDate(): Long { - return runCatching { DATE_FORMATTER.parse(this)?.time } - .getOrNull() ?: 0L - } - - companion object { - private val DATE_FORMATTER by lazy { - SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH) - } - } -} +class RudraScans : Keyoapp("Rudra Scans", "https://rudrascans.com", "en") diff --git a/src/en/wickedscans/build.gradle b/src/en/wickedscans/build.gradle new file mode 100644 index 000000000..296b9cbe8 --- /dev/null +++ b/src/en/wickedscans/build.gradle @@ -0,0 +1,9 @@ +ext { + extName = 'Wicked Scans' + extClass = '.WickedScans' + themePkg = 'keyoapp' + baseUrl = 'https://rudrascans.com' + overrideVersionCode = 0 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/wickedscans/res/mipmap-hdpi/ic_launcher.png b/src/en/wickedscans/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a24cfef32 Binary files /dev/null and b/src/en/wickedscans/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/wickedscans/res/mipmap-mdpi/ic_launcher.png b/src/en/wickedscans/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..7f07e7949 Binary files /dev/null and b/src/en/wickedscans/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/wickedscans/res/mipmap-xhdpi/ic_launcher.png b/src/en/wickedscans/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..d8e2a09ac Binary files /dev/null and b/src/en/wickedscans/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/wickedscans/res/mipmap-xxhdpi/ic_launcher.png b/src/en/wickedscans/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..aa17e7b0d Binary files /dev/null and b/src/en/wickedscans/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/wickedscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/wickedscans/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..9009c3fcf Binary files /dev/null and b/src/en/wickedscans/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/wickedscans/src/eu/kanade/tachiyomi/extension/en/wickedscans/WickedScans.kt b/src/en/wickedscans/src/eu/kanade/tachiyomi/extension/en/wickedscans/WickedScans.kt new file mode 100644 index 000000000..d847405e3 --- /dev/null +++ b/src/en/wickedscans/src/eu/kanade/tachiyomi/extension/en/wickedscans/WickedScans.kt @@ -0,0 +1,5 @@ +package eu.kanade.tachiyomi.extension.en.wickedscans + +import eu.kanade.tachiyomi.multisrc.keyoapp.Keyoapp + +class WickedScans : Keyoapp("Wicked Scans", "https://wickedscans.com", "en")