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