feat(src/en): New source: CloudRecess (#19432)

* feat(src/en): Create CloudRecess base

* feat: Implement popular manga page

* feat: Implement latest updates page

* feat: Implement (basic) search manga page

* feat: Implement search filters

* feat: Implement manga details

* feat: Implement chapter list page

* feat: Show page list

* chore: Add source icons
This commit is contained in:
Claudemirovsky 2023-12-26 08:30:42 -03:00 committed by GitHub
parent 60c5d8807c
commit 5858835f78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 392 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".en.cloudrecess.CloudRecessUrlActivity"
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="cloudrecess.io"
android:pathPattern="/manga/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'CloudRecess'
pkgNameSuffix = 'en.cloudrecess'
extClass = '.CloudRecess'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,154 @@
package eu.kanade.tachiyomi.extension.en.cloudrecess
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
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.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
class CloudRecess : ParsedHttpSource() {
override val name = "CloudRecess"
override val baseUrl = "https://cloudrecess.io"
override val lang = "en"
override val supportsLatest = true
override val client by lazy {
network.cloudflareClient.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 2)
.build()
}
// To load images
override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/")
// ============================== Popular ===============================
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
override fun popularMangaSelector() = "swiper-container#popular-cards div#card-real > a"
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.selectFirst("h2.text-sm")?.text() ?: "Manga"
thumbnail_url = element.selectFirst("img")?.run {
absUrl("data-src").ifEmpty { absUrl("src") }
}
}
override fun popularMangaNextPageSelector() = null
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/?page=$page", headers)
override fun latestUpdatesSelector() = "section:has(h2:containsOwn(Recent Chapters)) div#card-real > a"
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
override fun latestUpdatesNextPageSelector() = "ul.pagination > li:last-child:not(.pagination-disabled)"
// =============================== Search ===============================
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val id = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/manga/$id"))
.asObservableSuccess()
.map(::searchMangaByIdParse)
} else {
super.fetchSearchManga(page, query, filters)
}
}
private fun searchMangaByIdParse(response: Response): MangasPage {
val details = mangaDetailsParse(response.use { it.asJsoup() })
return MangasPage(listOf(details), false)
}
override fun getFilterList() = CloudRecessFilters.FILTER_LIST
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/manga?title=$query&page=$page".toHttpUrl().newBuilder().apply {
val params = CloudRecessFilters.getSearchParameters(filters)
if (params.type.isNotEmpty()) addQueryParameter("type", params.type)
if (params.status.isNotEmpty()) addQueryParameter("status", params.status)
params.genres.forEach { addQueryParameter("genre[]", it) }
}.build()
return GET(url, headers)
}
override fun searchMangaSelector() = "main div#card-real > a"
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
// =========================== Manga Details ============================
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
// Absolutely required element, so throwing a NPE when it's not present
// seems reasonable.
with(document.selectFirst("main > section > div")!!) {
thumbnail_url = selectFirst("div.relative img")?.absUrl("src")
title = selectFirst("div.flex > h2")?.ownText() ?: "No name"
genre = select("div.flex > a.inline-block").eachText().joinToString()
description = selectFirst("div.comicInfoExtend__synopsis")?.text()
}
document.selectFirst("div#buttons + div.hidden")?.run {
status = when (getInfo("Status").orEmpty()) {
"Cancelled" -> SManga.CANCELLED
"Completed" -> SManga.COMPLETED
"Hiatus" -> SManga.ON_HIATUS
"Ongoing" -> SManga.ONGOING
else -> SManga.UNKNOWN
}
artist = getInfo("Artist")
author = getInfo("Author")
}
}
private fun Element.getInfo(text: String): String? =
selectFirst("p:has(span:containsOwn($text)) span.capitalize")
?.ownText()
?.trim()
// ============================== Chapters ==============================
override fun chapterListSelector() = "div#chapters-list > a[href]"
override fun chapterFromElement(element: Element) = SChapter.create().apply {
setUrlWithoutDomain(element.attr("href"))
name = element.selectFirst("span")?.ownText() ?: "Chapter"
}
// =============================== Pages ================================
override fun pageListParse(document: Document): List<Page> {
return document.select("div#chapter-container > img").map { element ->
val id = element.attr("data-id").toIntOrNull() ?: 0
val url = element.run {
absUrl("data-src").ifEmpty { absUrl("src") }
}
Page(id, "", url)
}
}
override fun imageUrlParse(document: Document): String {
throw UnsupportedOperationException("Not used.")
}
companion object {
const val PREFIX_SEARCH = "id:"
}
}

View File

@ -0,0 +1,163 @@
package eu.kanade.tachiyomi.extension.en.cloudrecess
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
object CloudRecessFilters {
open class QueryPartFilter(
displayName: String,
val vals: Array<Pair<String, String>>,
) : Filter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray(),
) {
fun toQueryPart() = vals[state].second
}
private inline fun <reified R> FilterList.asQueryPart(): String {
return (first { it is R } as QueryPartFilter).toQueryPart()
}
open class CheckBoxFilterList(name: String, val items: List<String>) :
Filter.Group<Filter.CheckBox>(name, items.map(::CheckBoxVal))
private class CheckBoxVal(name: String) : Filter.CheckBox(name, false)
private inline fun <reified R> FilterList.checkedItems(): List<String> {
return (first { it is R } as CheckBoxFilterList).state
.filter { it.state }
.map { it.name }
}
internal class TypeFilter : QueryPartFilter("Type", FiltersData.TYPE_LIST)
internal class StatusFilter : QueryPartFilter("Status", FiltersData.STATUS_LIST)
internal class GenresFilter : CheckBoxFilterList("Genres", FiltersData.GENRES_LIST)
val FILTER_LIST get() = FilterList(
TypeFilter(),
StatusFilter(),
GenresFilter(),
)
data class FilterSearchParams(
val type: String = "",
val status: String = "",
val genres: List<String> = emptyList(),
)
internal fun getSearchParameters(filters: FilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
return FilterSearchParams(
filters.asQueryPart<TypeFilter>(),
filters.asQueryPart<StatusFilter>(),
filters.checkedItems<GenresFilter>(),
)
}
private object FiltersData {
val TYPE_LIST = arrayOf(
Pair("All Types", ""),
Pair("Manga", "manga"),
Pair("Manhwa", "manhwa"),
Pair("OEL/Original", "oel"),
Pair("One Shot", "one-shot"),
Pair("Webtoon", "webtoon"),
)
val STATUS_LIST = arrayOf(
Pair("All Status", ""),
Pair("Cancelled", "cancelled"),
Pair("Completed", "completed"),
Pair("Hiatus", "hiatus"),
Pair("Ongoing", "ongoing"),
Pair("Pending", "pending"),
)
val GENRES_LIST = listOf(
"3P Relationship/s",
"Action",
"Adventure",
"Age Gap",
"Amnesia/Memory Loss",
"Art/s or Creative/s",
"BL",
"Bloody",
"Boss/Employee",
"Childhood Friend/s",
"Comedy",
"Coming of Age",
"Contractual Relationship",
"Crime",
"Cross Dressing",
"Crush",
"Depraved",
"Drama",
"Enemies to Lovers",
"Family Life",
"Fantasy",
"Fetish",
"First Love",
"Food",
"Friends to Lovers",
"Fxckbuddy",
"GL",
"Games",
"Guideverse",
"Hardcore",
"Harem",
"Historical",
"Horror",
"Idols/Celeb/Showbiz",
"Infidelity",
"Intense",
"Isekai",
"Josei",
"Light Hearted",
"Living Together",
"Love Triangle",
"Love/Hate",
"Manipulative",
"Master/Servant",
"Mature",
"Military",
"Music",
"Mystery",
"Nameverse",
"Obsessive",
"Omegaverse",
"On Campus/College Life",
"One Sided Love",
"Part Timer",
"Photography",
"Psychological",
"Rebirth/Reincarnation",
"Red Light",
"Retro",
"Revenge",
"Rich Kids",
"Romance",
"Royalty/Nobility/Gentry",
"SM/BDSM/SUB-DOM",
"School Life",
"Sci-Fi",
"Self-Discovery",
"Shounen Ai",
"Slice of Life",
"Smut",
"Sports",
"Step Family",
"Supernatural",
"Teacher/Student",
"Thriller",
"Tragedy",
"Tsundere",
"Uncensored",
"Violence",
"Voyeur",
"Work Place/Office Workers",
"Yakuza/Gangsters",
)
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.extension.en.cloudrecess
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://cloudrecess.io/manga/<item> intents
* and redirects them to the main Tachiyomi process.
*/
class CloudRecessUrlActivity : Activity() {
private val tag = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${CloudRecess.PREFIX_SEARCH}$item")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}