Add new source: Lycantoons (#11707)
* remove source morta * add lycantoons * add LycanToons * remove o CARAI do espaço q ficou * formatando o comentario Latest pra ficar tudo igual oh ceus * reviews * remove serial name Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com> * fixing "," by default Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com> * ajeitando locale Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com> * parse simular a GreenShit + reviews --------- Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
9
src/pt/lycantoons/build.gradle
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'Lycan Toons'
|
||||||
|
extClass = '.LycanToons'
|
||||||
|
baseUrl = 'https://lycantoons.com'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = false
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
BIN
src/pt/lycantoons/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/pt/lycantoons/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/pt/lycantoons/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src/pt/lycantoons/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/pt/lycantoons/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,157 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.pt.lycantoons
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
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 keiyoushi.utils.jsonInstance
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
|
||||||
|
class LycanToons : HttpSource() {
|
||||||
|
|
||||||
|
override val name = "Lycan Toons"
|
||||||
|
|
||||||
|
override val baseUrl = "https://lycantoons.com"
|
||||||
|
|
||||||
|
override val lang = "pt-BR"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient
|
||||||
|
|
||||||
|
private val pageHeaders by lazy {
|
||||||
|
headers.newBuilder()
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================Popular=====================
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request = metricsRequest("popular", page)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage =
|
||||||
|
response.parseAs<PopularResponse>().data.toMangasPage()
|
||||||
|
|
||||||
|
// =====================Latest=====================
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = metricsRequest("recently-updated", page)
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage =
|
||||||
|
response.parseAs<PopularResponse>().data.toMangasPage()
|
||||||
|
|
||||||
|
// =====================Search=====================
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val payload = SearchRequestBody(
|
||||||
|
limit = PAGE_LIMIT,
|
||||||
|
page = page,
|
||||||
|
search = query,
|
||||||
|
seriesType = filters.valueOrEmpty<SeriesTypeFilter>(),
|
||||||
|
status = filters.valueOrEmpty<StatusFilter>(),
|
||||||
|
tags = filters.selectedTags(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val body = json.encodeToString(payload).toRequestBody(JSON_MEDIA_TYPE)
|
||||||
|
return POST("$baseUrl/api/series", headers, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage =
|
||||||
|
response.parseAs<SearchResponse>().series.toMangasPage()
|
||||||
|
|
||||||
|
override fun getFilterList(): FilterList = LycanToonsFilters.get()
|
||||||
|
|
||||||
|
// =====================Details=====================
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request = seriesRequest(manga.slug())
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val result = response.parseAs<SeriesDto>()
|
||||||
|
return result.toSManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================Chapters=====================
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request = seriesRequest(manga.slug())
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> =
|
||||||
|
response.parseAs<SeriesDto>().let { series ->
|
||||||
|
series.capitulos!!
|
||||||
|
.map { it.toSChapter(series.slug) }
|
||||||
|
.sortedByDescending { it.chapter_number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================Pages========================
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request = GET(
|
||||||
|
"$baseUrl${chapter.url}",
|
||||||
|
pageHeaders,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val html = response.body.string()
|
||||||
|
val (slug, chapterNumber) = response.extractSlugAndChapter()
|
||||||
|
|
||||||
|
val dto = extractScriptData(html)
|
||||||
|
val pageCount = dto.pageCount
|
||||||
|
|
||||||
|
val chapterPath = "$cdnUrl/$slug/$chapterNumber"
|
||||||
|
return List(pageCount) { index ->
|
||||||
|
val imageUrl = "$chapterPath/page-$index.jpg"
|
||||||
|
Page(index, imageUrl = imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractScriptData(html: String): PageListDto {
|
||||||
|
val document = Jsoup.parse(html)
|
||||||
|
|
||||||
|
val scriptData = document.select("script")
|
||||||
|
.map { it.data() }
|
||||||
|
.first { it.contains("chapterData") }
|
||||||
|
|
||||||
|
val rawJson = CHAPTER_DATA_REGEX.find(scriptData)!!.groupValues[1]
|
||||||
|
|
||||||
|
val cleanJson = "\"$rawJson\"".parseAs<String>()
|
||||||
|
|
||||||
|
return cleanJson.parseAs<PageListDto>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
// =====================Utils=====================
|
||||||
|
|
||||||
|
private fun Response.extractSlugAndChapter(): Pair<String, String> {
|
||||||
|
val segments = request.url.pathSegments
|
||||||
|
val slug = segments[1]
|
||||||
|
val chapterNumber = segments[2]
|
||||||
|
return slug to chapterNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metricsRequest(path: String, page: Int): Request =
|
||||||
|
GET("$baseUrl/api/metrics/$path?limit=$PAGE_LIMIT&page=$page", headers)
|
||||||
|
|
||||||
|
private fun List<SeriesDto>.toMangasPage(): MangasPage =
|
||||||
|
MangasPage(map { it.toSManga() }, false)
|
||||||
|
|
||||||
|
private fun seriesRequest(slug: String): Request = GET("$baseUrl/api/series/$slug", headers)
|
||||||
|
|
||||||
|
private fun SManga.slug(): String = url.substringAfterLast("/")
|
||||||
|
|
||||||
|
private val json by lazy { jsonInstance }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PAGE_LIMIT = 13
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||||
|
private const val cdnUrl = "https://cdn.lycantoons.com/file/lycantoons"
|
||||||
|
private val CHAPTER_DATA_REGEX = """\\?"chapterData\\?"\s*:\s*(\{.*?\})""".toRegex()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.pt.lycantoons
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PopularResponse(
|
||||||
|
val data: List<SeriesDto>,
|
||||||
|
val pagination: PaginationDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SeriesDto(
|
||||||
|
val title: String,
|
||||||
|
val slug: String,
|
||||||
|
val coverUrl: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val genre: List<String>? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val seriesType: String? = null,
|
||||||
|
val capitulos: List<ChapterDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PaginationDto(
|
||||||
|
val page: Int? = null,
|
||||||
|
val totalPages: Int? = null,
|
||||||
|
val hasNext: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchRequestBody(
|
||||||
|
val limit: Int,
|
||||||
|
val page: Int,
|
||||||
|
val search: String,
|
||||||
|
val seriesType: String,
|
||||||
|
val status: String,
|
||||||
|
val tags: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChapterDto(
|
||||||
|
val id: Int,
|
||||||
|
val numero: JsonElement,
|
||||||
|
val createdAt: String? = null,
|
||||||
|
val coverUrl: String? = null,
|
||||||
|
val capaUrl: String? = null,
|
||||||
|
val pageCount: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PageListDto(
|
||||||
|
val numero: JsonElement,
|
||||||
|
val pageCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchResponse(
|
||||||
|
val series: List<SeriesDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun SeriesDto.toSManga(): SManga = SManga.create().apply {
|
||||||
|
title = this@toSManga.title
|
||||||
|
url = "/series/$slug"
|
||||||
|
thumbnail_url = coverUrl
|
||||||
|
author = this@toSManga.author?.takeIf { it.isNotBlank() }
|
||||||
|
artist = this@toSManga.artist?.takeIf { it.isNotBlank() }
|
||||||
|
genre = this@toSManga.genre?.takeIf { it.isNotEmpty() }?.joinToString()
|
||||||
|
description = this@toSManga.description
|
||||||
|
status = parseStatus(this@toSManga.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ChapterDto.toSChapter(slug: String): SChapter = SChapter.create().apply {
|
||||||
|
val numberString = numero.jsonPrimitive.content
|
||||||
|
name = "Capítulo $numberString"
|
||||||
|
val pagesQuery = pageCount?.let { "?pages=$it" }.orEmpty()
|
||||||
|
url = "/series/$slug/$numberString$pagesQuery"
|
||||||
|
date_upload = dateFormat.tryParse(createdAt)
|
||||||
|
chapter_number = numberString.toFloatOrNull() ?: -1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(status: String?): Int = when (status) {
|
||||||
|
"ONGOING" -> SManga.ONGOING
|
||||||
|
"COMPLETED" -> SManga.COMPLETED
|
||||||
|
"HIATUS" -> SManga.ON_HIATUS
|
||||||
|
"CANCELLED" -> SManga.CANCELLED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.pt.lycantoons
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
|
||||||
|
class SeriesTypeFilter : ChoiceFilter(
|
||||||
|
"Tipo",
|
||||||
|
arrayOf(
|
||||||
|
"" to "Todos",
|
||||||
|
"MANGA" to "Mangá",
|
||||||
|
"MANHWA" to "Manhwa",
|
||||||
|
"MANHUA" to "Manhua",
|
||||||
|
"COMIC" to "Comic",
|
||||||
|
"WEBTOON" to "Webtoon",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class StatusFilter : ChoiceFilter(
|
||||||
|
"Status",
|
||||||
|
arrayOf(
|
||||||
|
"" to "Todos",
|
||||||
|
"ONGOING" to "Em andamento",
|
||||||
|
"COMPLETED" to "Completo",
|
||||||
|
"HIATUS" to "Hiato",
|
||||||
|
"CANCELLED" to "Cancelado",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
open class ChoiceFilter(
|
||||||
|
name: String,
|
||||||
|
private val entries: Array<Pair<String, String>>,
|
||||||
|
) : Filter.Select<String>(
|
||||||
|
name,
|
||||||
|
entries.map { it.second }.toTypedArray(),
|
||||||
|
) {
|
||||||
|
fun getValue(): String = entries[state].first
|
||||||
|
}
|
||||||
|
|
||||||
|
class TagsFilter : Filter.Group<TagCheckBox>(
|
||||||
|
"Tags",
|
||||||
|
listOf(
|
||||||
|
TagCheckBox("Ação", "action"),
|
||||||
|
TagCheckBox("Aventura", "adventure"),
|
||||||
|
TagCheckBox("Comédia", "comedy"),
|
||||||
|
TagCheckBox("Drama", "drama"),
|
||||||
|
TagCheckBox("Fantasia", "fantasy"),
|
||||||
|
TagCheckBox("Terror", "horror"),
|
||||||
|
TagCheckBox("Mistério", "mystery"),
|
||||||
|
TagCheckBox("Romance", "romance"),
|
||||||
|
TagCheckBox("Vida escolar", "school_life"),
|
||||||
|
TagCheckBox("Sci-fi", "sci_fi"),
|
||||||
|
TagCheckBox("Slice of life", "slice_of_life"),
|
||||||
|
TagCheckBox("Esportes", "sports"),
|
||||||
|
TagCheckBox("Sobrenatural", "supernatural"),
|
||||||
|
TagCheckBox("Thriller", "thriller"),
|
||||||
|
TagCheckBox("Tragédia", "tragedy"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TagCheckBox(
|
||||||
|
name: String,
|
||||||
|
val value: String,
|
||||||
|
) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
inline fun <reified T : Filter<*>> FilterList.find(): T? =
|
||||||
|
this.filterIsInstance<T>().firstOrNull()
|
||||||
|
|
||||||
|
inline fun <reified T : ChoiceFilter> FilterList.valueOrEmpty(): String =
|
||||||
|
find<T>()?.getValue().orEmpty()
|
||||||
|
|
||||||
|
fun FilterList.selectedTags(): List<String> =
|
||||||
|
find<TagsFilter>()?.state
|
||||||
|
?.filter { it.state }
|
||||||
|
?.map { it.value }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
object LycanToonsFilters {
|
||||||
|
fun get(): FilterList = FilterList(
|
||||||
|
SeriesTypeFilter(),
|
||||||
|
StatusFilter(),
|
||||||
|
TagsFilter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
ext {
|
|
||||||
extName = 'Wind Scan'
|
|
||||||
extClass = '.WindScan'
|
|
||||||
themePkg = 'greenshit'
|
|
||||||
baseUrl = 'https://windscan.xyz'
|
|
||||||
overrideVersionCode = 41
|
|
||||||
isNsfw = true
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
|
||||||
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 46 KiB |
@ -1,18 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.extension.pt.windscan
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit
|
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
|
||||||
|
|
||||||
class WindScan : GreenShit(
|
|
||||||
"Wind Scan",
|
|
||||||
"https://windscan.xyz",
|
|
||||||
"pt-BR",
|
|
||||||
scanId = 6,
|
|
||||||
) {
|
|
||||||
// Moved from Madara to GreenShit
|
|
||||||
override val versionId = 2
|
|
||||||
|
|
||||||
override val client = super.client.newBuilder()
|
|
||||||
.rateLimit(2)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||