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>
This commit is contained in:
Felipe Ávila 2025-11-21 19:24:52 -03:00 committed by Draff
parent 948ff10002
commit 0bfc8ce9ae
Signed by: Draff
GPG Key ID: E8A89F3211677653
16 changed files with 350 additions and 28 deletions

View File

@ -0,0 +1,9 @@
ext {
extName = 'Lycan Toons'
extClass = '.LycanToons'
baseUrl = 'https://lycantoons.com'
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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()
}
}

View File

@ -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")
}

View File

@ -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(),
)
}

View File

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -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()
}