FuzzyDoodle: Make CloudRecess a multisrc and add/fix some sources (#2290)

* CloudRecess multisrc

* ScyllaScans: move to cloudrecess multisrc

* FleksyScans: new source

* remove baseUrl

* simplify popular

always use /manga which isn't technically popular but list of all manga which is good enough

* HentaiSlayer (ar): move to cloudrecess

* remove CloudRecess (en): site appears to be dead

* small change

* rename

* review changes

why do I forget headers

* add alternative titles to description

* parse filters from popular and search response

avoid extra call

* remove placeholder author/artist

* LelscanVF: move to FuzzyDoodle

also improve date parsing

* add icons to lelscanvf

* flexyScans: change icon

other one is of Aksy Scan which also posts on the site
This commit is contained in:
AwkwardPeak7 2024-04-08 06:33:47 +05:00 committed by GitHub
parent 5ac1d4d11b
commit 5d95700faf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 423 additions and 684 deletions

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

View File

@ -0,0 +1,62 @@
package eu.kanade.tachiyomi.multisrc.fuzzydoodle
import eu.kanade.tachiyomi.source.model.Filter
import okhttp3.HttpUrl
interface UrlPartFilter {
fun addUrlParameter(url: HttpUrl.Builder)
}
abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String>>,
private val urlParameter: String,
) : UrlPartFilter, Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
override fun addUrlParameter(url: HttpUrl.Builder) {
url.addQueryParameter(urlParameter, options[state].second)
}
}
class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name)
abstract class CheckBoxGroup(
name: String,
options: List<Pair<String, String>>,
private val urlParameter: String,
) : UrlPartFilter, Filter.Group<CheckBoxFilter>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
override fun addUrlParameter(url: HttpUrl.Builder) {
state.filter { it.state }.forEach {
url.addQueryParameter(urlParameter, it.value)
}
}
}
class TypeFilter(
options: List<Pair<String, String>>,
) : SelectFilter(
"Type",
options,
"type",
)
class StatusFilter(
options: List<Pair<String, String>>,
) : SelectFilter(
"Status",
options,
"status",
)
class GenreFilter(
options: List<Pair<String, String>>,
) : CheckBoxGroup(
"Genres",
options,
"genre[]",
)

View File

@ -0,0 +1,317 @@
package eu.kanade.tachiyomi.multisrc.fuzzydoodle
import android.util.Log
import eu.kanade.tachiyomi.network.GET
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.lang.Exception
import java.util.Calendar
/*
* https://github.com/jhin1m/fuzzy-doodle
*/
abstract class FuzzyDoodle(
override val name: String,
override val baseUrl: String,
override val lang: String,
) : ParsedHttpSource() {
override val supportsLatest = true
override val client = network.cloudflareClient
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
// Popular
override fun popularMangaRequest(page: Int) =
GET("$baseUrl/manga?page=$page", headers)
override fun popularMangaSelector() = "div#card-real"
override fun popularMangaNextPageSelector() = "ul.pagination > li:last-child:not(.pagination-disabled)"
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
launchIO { fetchFilters(document) }
val entries = document.select(popularMangaSelector())
.map(::popularMangaFromElement)
val hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null
return MangasPage(entries, hasNextPage)
}
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
title = element.selectFirst("h2.text-sm")!!.text()
thumbnail_url = element.selectFirst("img")?.imgAttr()
}
// latest
protected open val latestFromHomePage = false
override fun latestUpdatesRequest(page: Int) =
if (latestFromHomePage) {
latestHomePageRequest(page)
} else {
latestPageRequest(page)
}
protected open fun latestHomePageRequest(page: Int) =
GET("$baseUrl/?page=$page", headers)
protected open fun latestPageRequest(page: Int) =
GET("$baseUrl/latest?page=$page", headers)
override fun latestUpdatesSelector() =
if (latestFromHomePage) {
"section:has(h2:containsOwn(Recent Chapters)) div#card-real," +
" section:has(h2:containsOwn(Chapitres récents)) div#card-real"
} else {
popularMangaSelector()
}
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
override fun latestUpdatesParse(response: Response): MangasPage {
launchIO { fetchFilters() }
return super.latestUpdatesParse(response)
}
// search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/manga".toHttpUrl().newBuilder().apply {
addQueryParameter("title", query.trim())
filters.filterIsInstance<UrlPartFilter>().forEach {
it.addUrlParameter(this)
}
if (page > 1) {
addQueryParameter("page", page.toString())
}
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
// filters
protected var typeList = listOf<Pair<String, String>>()
protected var statusList = listOf<Pair<String, String>>()
protected var genreList = listOf<Pair<String, String>>()
private var fetchFilterAttempts = 0
protected suspend fun fetchFilters(document: Document? = null) {
if (fetchFilterAttempts < 3 && (typeList.isEmpty() || statusList.isEmpty() || genreList.isEmpty())) {
try {
val doc = document ?: client.newCall(filtersRequest())
.await()
.asJsoup()
parseFilters(doc)
} catch (e: Exception) {
Log.e("$name: Filters", e.stackTraceToString())
}
fetchFilterAttempts++
}
}
protected open fun filtersRequest() = GET("$baseUrl/manga", headers)
protected open fun parseFilters(document: Document) {
typeList = document.select("select[name=type] > option").map {
it.ownText() to it.attr("value")
}
statusList = document.select("select[name=status] > option").map {
it.ownText() to it.attr("value")
}
genreList = document.select("div.grid > div.flex:has(> input[name=genre[]])").mapNotNull {
val label = it.selectFirst("label")?.ownText()
?: return@mapNotNull null
val value = it.selectFirst("input")?.attr("value")
?: return@mapNotNull null
label to value
}
}
override fun getFilterList(): FilterList {
val filters = mutableListOf<Filter<*>>()
if (typeList.isNotEmpty()) {
filters.add(TypeFilter(typeList))
}
if (statusList.isNotEmpty()) {
filters.add(StatusFilter(statusList))
}
if (genreList.isNotEmpty()) {
filters.add(GenreFilter(genreList))
}
if (filters.size < 3) {
filters.add(0, Filter.Header("Press 'reset' to load more filters"))
}
return FilterList(filters)
}
private val scope = CoroutineScope(Dispatchers.IO)
protected fun launchIO(block: suspend () -> Unit) = scope.launch { block() }
// details
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
val genres = mutableListOf<String>()
with(document.selectFirst("main > section > div")!!) {
thumbnail_url = selectFirst("div.relative img")?.imgAttr()
title = selectFirst("div.flex > h1, div.flex > h2")!!.ownText()
genres.addAll(select("div.flex > a.inline-block").eachText())
description = buildString {
selectFirst("div:has(> p#description)")?.let {
it.selectFirst("span.font-semibold")?.remove()
it.select("#show-more").remove()
append(it.text())
}
selectFirst("div.flex > h1 + div > span.text-sm, div.flex > h2 + div > span.text-sm")?.text()?.let {
if (it.isNotEmpty()) {
append("\n\n")
append("Alternative Title: ")
append(it.trim())
}
}
}.trim()
}
document.selectFirst("div#buttons + div.hidden, div:has(> div#buttons) + div.flex")?.run {
status = (getInfo("Status") ?: getInfo("Statut")).parseStatus()
artist = (getInfo("Artist") ?: getInfo("المؤلف") ?: getInfo("Artiste")).removePlaceHolder()
author = (getInfo("Author") ?: getInfo("الرسام") ?: getInfo("Auteur")).removePlaceHolder()
(getInfo("Type") ?: getInfo("النوع"))?.also { genres.add(0, it) }
}
genre = genres.joinToString()
}
protected open fun String?.parseStatus(): Int {
this ?: return SManga.UNKNOWN
return when {
listOf("ongoing", "مستمر", "en cours").any { contains(it, true) } -> SManga.ONGOING
listOf("dropped", "cancelled", "متوقف").any { contains(it, true) } -> SManga.CANCELLED
listOf("completed", "مكتمل", "terminé").any { contains(it, true) } -> SManga.COMPLETED
listOf("hiatus").any { contains(it, true) } -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
}
protected fun Element.getInfo(text: String): String? =
selectFirst("p:has(span:containsOwn($text)) span.capitalize")
?.ownText()
?.trim()
protected fun String?.removePlaceHolder(): String? =
takeUnless { it == "-" }
// chapters
override fun chapterListParse(response: Response): List<SChapter> {
val originalUrl = response.request.url.toString()
val chapterList = buildList {
var page = 1
do {
val doc = when {
isEmpty() -> response // First page
else -> {
page++
client.newCall(GET("$originalUrl?page=$page", headers)).execute()
}
}.asJsoup()
addAll(doc.select(chapterListSelector()).map(::chapterFromElement))
} while (doc.selectFirst(chapterListNextPageSelector()) != null)
}
return chapterList
}
override fun chapterListSelector() = "div#chapters-list > a[href]"
protected fun chapterListNextPageSelector() = latestUpdatesNextPageSelector()
override fun chapterFromElement(element: Element) = SChapter.create().apply {
setUrlWithoutDomain(element.attr("href"))
name = element.selectFirst("#item-title, span")!!.ownText()
date_upload = element.selectFirst("span.text-gray-500")?.text().parseRelativeDate()
}
// from madara
protected open fun String?.parseRelativeDate(): Long {
this ?: return 0L
val number = Regex("""(\d+)""").find(this)?.value?.toIntOrNull() ?: return 0L
val cal = Calendar.getInstance()
return when {
listOf("detik", "segundo", "second", "วินาที").any { contains(it, true) } -> {
cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
}
listOf("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").any { contains(it, true) } -> {
cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
}
listOf("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").any { contains(it, true) } -> {
cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
}
listOf("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "").any { contains(it, true) } -> {
cal.apply { add(Calendar.DAY_OF_YEAR, -number) }.timeInMillis
}
listOf("week", "sema").any { contains(it, true) } -> {
cal.apply { add(Calendar.WEEK_OF_YEAR, -number) }.timeInMillis
}
listOf("month", "mes").any { it in this } -> {
cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
}
listOf("year", "año").any { it in this } -> {
cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
}
else -> 0L
}
}
// pages
override fun pageListParse(document: Document): List<Page> {
return document.select("div#chapter-container > img").mapIndexed { idx, img ->
Page(idx, imageUrl = img.imgAttr())
}
}
private fun Element.imgAttr(): String {
return when {
hasAttr("srcset") -> attr("srcset").substringBefore(" ")
hasAttr("data-cfsrc") -> absUrl("data-cfsrc")
hasAttr("data-src") -> absUrl("data-src")
hasAttr("data-lazy-src") -> absUrl("data-lazy-src")
else -> absUrl("src")
}
}
override fun imageUrlParse(document: Document): String {
throw UnsupportedOperationException()
}
}

View File

@ -1,7 +1,8 @@
ext { ext {
extName = 'Hentai Slayer' extName = 'Hentai Slayer'
extClass = '.HentaiSlayer' extClass = '.HentaiSlayer'
extVersionCode = 2 themePkg = 'fuzzydoodle'
overrideVersionCode = 2
isNsfw = true isNsfw = true
} }

View File

@ -4,199 +4,28 @@ import android.app.Application
import android.widget.Toast import android.widget.Toast
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.multisrc.fuzzydoodle.FuzzyDoodle
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
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 okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.Calendar
class HentaiSlayer : ParsedHttpSource(), ConfigurableSource { class HentaiSlayer : FuzzyDoodle("هنتاي سلاير", "https://hentaislayer.net", "ar"), ConfigurableSource {
override val name = "هنتاي سلاير" override val client = super.client.newBuilder()
override val baseUrl = "https://hentaislayer.net"
override val lang = "ar"
override val supportsLatest = true
override val client = network.cloudflareClient.newBuilder()
.rateLimit(2) .rateLimit(2)
.build() .build()
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
.set("Origin", baseUrl) .set("Origin", baseUrl)
private val preferences by lazy { private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
} }
// ============================== Popular =============================== override fun latestPageRequest(page: Int) = GET("$baseUrl/latest-${getLatestTypes()}?page=$page", headers)
override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?page=$page", headers)
override fun popularMangaSelector() = "div > div:has(div#card-real)"
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
with(element.selectFirst("div#card-real a")!!) {
setUrlWithoutDomain(absUrl("href"))
with(selectFirst("figure")!!) {
with(selectFirst("img.object-cover")!!) {
thumbnail_url = imgAttr()
title = attr("alt")
}
genre = select("span p.drop-shadow-sm").text()
}
}
}
override fun popularMangaNextPageSelector() = "ul.pagination > li:last-child:not(.pagination-disabled)"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/latest-${getLatestTypes()}?page=$page", headers)
override fun latestUpdatesSelector() = popularMangaSelector()
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
// =============================== Search ===============================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/manga?title=$query".toHttpUrl().newBuilder()
filters.forEach { filter ->
when (filter) {
is TypeFilter -> url.addQueryParameter("type", filter.toUriPart())
is StatusFilter -> url.addQueryParameter("status", filter.toUriPart())
is GenresFilter ->
filter.state
.filter { it.state }
.forEach { url.addQueryParameter("genre[]", it.uriPart) }
else -> {}
}
}
url.addQueryParameter("page", page.toString())
return GET(url.build(), headers)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
// =========================== Manga Details ============================
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
with(document.selectFirst("main section")!!) {
thumbnail_url = selectFirst("img#manga-cover")!!.imgAttr()
with(selectFirst("section > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(2)")!!) {
status = parseStatus(select("a[href*='?status=']").text())
genre = select("a[href*='?type=']").text()
author = select("p:has(span:contains(المؤلف)) span:nth-child(2)").text()
artist = select("p:has(span:contains(الرسام)) span:nth-child(2)").text()
}
var desc = "\u061C"
with(selectFirst("section > div:nth-child(1) > div:nth-child(2)")!!) {
title = selectFirst("h1")!!.text()
genre = select("a[href*='?genre=']")
.map { it.text() }
.let {
listOf(genre) + it
}
.joinToString()
select("h2").text().takeIf { it.isNotEmpty() }?.let {
desc += "أسماء أُخرى: $it\n"
}
}
description = desc + select("#description").text()
}
}
private fun parseStatus(status: String) = when {
status.contains("مستمر") -> SManga.ONGOING
status.contains("متوقف") -> SManga.CANCELLED
status.contains("مكتمل") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
// ============================== Chapters ==============================
override fun chapterListSelector() = "main section #chapters-list a#chapter-item"
override fun chapterFromElement(element: Element) = SChapter.create().apply {
setUrlWithoutDomain(element.attr("href"))
name = "\u061C" + element.select("#item-title").text() // Add unicode ARABIC LETTER MARK to ensure all titles are right to left
date_upload = parseRelativeDate(element.select("#item-title + span").text()) ?: 0L
}
/**
* Parses dates in this form:
* `11 days ago`
*/
private fun parseRelativeDate(date: String): Long? {
val trimmedDate = date.split(" ")
if (trimmedDate[2] != "ago") return null
val number = trimmedDate[0].toIntOrNull() ?: return null
val unit = trimmedDate[1].removeSuffix("s") // Remove 's' suffix
val now = Calendar.getInstance()
// Map English unit to Java unit
val javaUnit = when (unit) {
"year", "yr" -> Calendar.YEAR
"month" -> Calendar.MONTH
"week", "wk" -> Calendar.WEEK_OF_MONTH
"day" -> Calendar.DAY_OF_MONTH
"hour", "hr" -> Calendar.HOUR
"minute", "min" -> Calendar.MINUTE
"second", "sec" -> Calendar.SECOND
else -> return null
}
now.add(javaUnit, -number)
return now.timeInMillis
}
// =============================== Pages ================================
override fun pageListParse(document: Document): List<Page> {
return document.select("img.chapter-image").mapIndexed { index, item ->
Page(index = index, imageUrl = item.imgAttr())
}
}
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
private fun Element.imgAttr(): String? {
return when {
hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ")
hasAttr("data-cfsrc") -> attr("abs:data-cfsrc")
hasAttr("data-src") -> attr("abs:data-src")
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
else -> attr("abs:src")
}
}
override fun getFilterList() = FilterList(
GenresFilter(),
TypeFilter(),
StatusFilter(),
)
// ============================== Settings ==============================
companion object { companion object {
private const val LATEST_PREF = "LatestType" private const val LATEST_PREF = "LatestType"
private val LATEST_PREF_ENTRIES get() = arrayOf( private val LATEST_PREF_ENTRIES get() = arrayOf(

View File

@ -1,81 +0,0 @@
package eu.kanade.tachiyomi.extension.ar.hentaislayer
import eu.kanade.tachiyomi.source.model.Filter
class StatusFilter : UriPartFilter(
"الحالة",
arrayOf(
Pair("الكل", ""),
Pair("مستمر", "مستمر"),
Pair("متوقف", "متوقف"),
Pair("مكتمل", "مكتمل"),
),
)
class TypeFilter : UriPartFilter(
"النوع",
arrayOf(
Pair("الكل", ""),
Pair("مانجا", "مانجا"),
Pair("مانهوا", "مانهوا"),
Pair("كوميكس", "كوميكس"),
),
)
private val genres = listOf(
Genre("أكشن", "أكشن"),
Genre("ألعاب جنسية", "ألعاب جنسية"),
Genre("إذلال", "إذلال"),
Genre("إيلف", "إيلف"),
Genre("ابتزاز", "ابتزاز"),
Genre("استعباد", "استعباد"),
Genre("اغتصاب", "اغتصاب"),
Genre("بدون حجب", "بدون حجب"),
Genre("بشرة سمراء", "بشرة سمراء"),
Genre("تاريخي", "تاريخي"),
Genre("تحكم بالعقل", "تحكم بالعقل"),
Genre("تراب", "تراب"),
Genre("تسوندري", "تسوندري"),
Genre("تصوير", "تصوير"),
Genre("جنس بالقدم", "جنس بالقدم"),
Genre("جنس جماعي", "جنس جماعي"),
Genre("جنس شرجي", "جنس شرجي"),
Genre("حريم", "حريم"),
Genre("حمل", "حمل"),
Genre("خادمة", "خادمة"),
Genre("خيال", "خيال"),
Genre("خيانة", "خيانة"),
Genre("دراغون بول", "دراغون بول"),
Genre("دراما", "دراما"),
Genre("رومانسي", "رومانسي"),
Genre("سحر", "سحر"),
Genre("شوتا", "شوتا"),
Genre("شيطانة", "شيطانة"),
Genre("شيميل", "شيميل"),
Genre("طالبة مدرسة", "طالبة مدرسة"),
Genre("عمة", "عمة"),
Genre("فوتا", "فوتا"),
Genre("لولي", "لولي"),
Genre("محارم", "محارم"),
Genre("مدرسي", "مدرسي"),
Genre("مكان عام", "مكان عام"),
Genre("ملون", "ملون"),
Genre("ميلف", "ميلف"),
Genre("ناروتو", "ناروتو"),
Genre("هجوم العمالقة", "هجوم العمالقة"),
Genre("ون بيس", "ون بيس"),
Genre("ياوي", "ياوي"),
Genre("يوري", "يوري"),
)
class Genre(val name: String, val uriPart: String)
class GenreCheckBox(name: String, val uriPart: String) : Filter.CheckBox(name)
class GenresFilter :
Filter.Group<GenreCheckBox>("التصنيفات", genres.map { GenreCheckBox(it.name, it.uriPart) })
open class UriPartFilter(displayName: String, private val pairs: Array<Pair<String, String>>) :
Filter.Select<String>(displayName, pairs.map { it.first }.toTypedArray()) {
fun toUriPart() = pairs[state].second
}

View File

@ -1,22 +0,0 @@
<?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

@ -1,8 +0,0 @@
ext {
extName = 'CloudRecess'
extClass = '.CloudRecess'
extVersionCode = 2
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,175 +0,0 @@
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 chapterListParse(response: Response): List<SChapter> {
val originalUrl = response.request.url.toString()
val chapterList = buildList {
var page = 1
do {
val doc = when {
isEmpty() -> response // First page
else -> {
page++
client.newCall(GET("$originalUrl?page=$page", headers)).execute()
}
}.use { it.asJsoup() }
addAll(doc.select(chapterListSelector()).map(::chapterFromElement))
} while (doc.selectFirst(latestUpdatesNextPageSelector()) != null)
}
return chapterList
}
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()
}
companion object {
const val PREFIX_SEARCH = "id:"
}
}

View File

@ -1,163 +0,0 @@
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

@ -1,41 +0,0 @@
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)
}
}

View File

@ -0,0 +1,9 @@
ext {
extName = 'FleksyScans'
extClass = '.FleksyScans'
themePkg = 'fuzzydoodle'
overrideVersionCode = 0
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,5 @@
package eu.kanade.tachiyomi.extension.en.fleksyscans
import eu.kanade.tachiyomi.multisrc.fuzzydoodle.FuzzyDoodle
class FleksyScans : FuzzyDoodle("FleksyScans", "https://flexscans.com", "en")

View File

@ -1,9 +1,8 @@
ext { ext {
extName = 'Scylla Scans' extName = 'Scylla Scans'
extClass = '.ScyllaScans' extClass = '.ScyllaScans'
themePkg = 'readerfront' themePkg = 'fuzzydoodle'
baseUrl = 'https://scyllascans.org' overrideVersionCode = 9
overrideVersionCode = 1
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -1,9 +1,11 @@
package eu.kanade.tachiyomi.extension.en.scyllascans package eu.kanade.tachiyomi.extension.en.scyllascans
import eu.kanade.tachiyomi.multisrc.readerfront.ReaderFront import eu.kanade.tachiyomi.multisrc.fuzzydoodle.FuzzyDoodle
class ScyllaScans : ReaderFront("Scylla Scans", "https://scyllascans.org", "en") { class ScyllaScans : FuzzyDoodle("Scylla Scans", "https://scyllascans.org", "en") {
override fun getImageCDN(path: String, width: Int) =
"https://i${(0..2).random()}.wp.com/api.scyllascans.org" + // readerfront -> fuzzydoodle
"$path?strip=all&quality=100&w=$width" override val versionId = 2
override val latestFromHomePage = true
} }

View File

@ -1,9 +1,8 @@
ext { ext {
extName = 'Lelscan-VF' extName = 'Lelscan-VF'
extClass = '.LelscanVF' extClass = '.LelscanVF'
themePkg = 'mmrcms' themePkg = 'fuzzydoodle'
baseUrl = 'https://lelscanvf.cc' overrideVersionCode = 13
overrideVersionCode = 2
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.extension.fr.lelscanvf package eu.kanade.tachiyomi.extension.fr.lelscanvf
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.multisrc.fuzzydoodle.FuzzyDoodle
class LelscanVF : MMRCMS( class LelscanVF : FuzzyDoodle("Lelscan-VF", "https://lelscanfr.com", "fr") {
"Lelscan-VF",
"https://lelscanvf.cc", // mmrcms -> FuzzyDoodle
"fr", override val versionId = 2
supportsAdvancedSearch = false,
) override val latestFromHomePage = true
}