add Vortex Scans (#2350)
* add VortexScans * remove arven * Revert "remove arven" This reverts commit 4c67556a6cc71ff9e9f38b26d870f437d01daca6. * in place update and actual popular * use next cached thumbnails
@ -1,9 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'Arven Scans'
|
extName = 'Vortex Scans'
|
||||||
extClass = '.ArvenScans'
|
extClass = '.VortexScans'
|
||||||
themePkg = 'mangathemesia'
|
extVersionCode = 31
|
||||||
baseUrl = 'https://arvenscans.com'
|
|
||||||
overrideVersionCode = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 8.5 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 50 KiB |
@ -1,13 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.extension.en.arvenscans
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
|
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class ArvenScans : MangaThemesia("Arven Scans", "https://arvenscans.com", "en", "/series") {
|
|
||||||
|
|
||||||
override val client: OkHttpClient = super.client.newBuilder()
|
|
||||||
.rateLimit(20, 5, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
}
|
|
@ -0,0 +1,113 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.arvenscans
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SearchResponse(
|
||||||
|
val posts: List<Manga>,
|
||||||
|
val totalCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Manga(
|
||||||
|
val id: Int,
|
||||||
|
val slug: String,
|
||||||
|
private val postTitle: String,
|
||||||
|
private val postContent: String? = null,
|
||||||
|
val isNovel: Boolean,
|
||||||
|
private val featuredImage: String? = null,
|
||||||
|
private val alternativeTitles: String? = null,
|
||||||
|
private val author: String? = null,
|
||||||
|
private val artist: String? = null,
|
||||||
|
private val seriesType: String? = null,
|
||||||
|
private val seriesStatus: String? = null,
|
||||||
|
private val genres: List<Name>? = emptyList(),
|
||||||
|
) {
|
||||||
|
fun toSManga(baseUrl: String) = SManga.create().apply {
|
||||||
|
url = "$slug#$id"
|
||||||
|
title = postTitle
|
||||||
|
thumbnail_url = "$baseUrl/_next/image".toHttpUrl().newBuilder().apply {
|
||||||
|
addQueryParameter("url", featuredImage)
|
||||||
|
addQueryParameter("w", "828")
|
||||||
|
addQueryParameter("q", "75")
|
||||||
|
}.toString()
|
||||||
|
author = this@Manga.author?.takeUnless { it.isEmpty() }
|
||||||
|
artist = this@Manga.artist?.takeUnless { it.isEmpty() }
|
||||||
|
description = buildString {
|
||||||
|
postContent?.takeUnless { it.isEmpty() }?.let { desc ->
|
||||||
|
val tmpDesc = desc.replace("\n", "<br>")
|
||||||
|
|
||||||
|
append(Jsoup.parse(tmpDesc).text())
|
||||||
|
}
|
||||||
|
alternativeTitles?.takeUnless { it.isEmpty() }?.let { altName ->
|
||||||
|
append("\n\n")
|
||||||
|
append("Alternative Names: ")
|
||||||
|
append(altName)
|
||||||
|
}
|
||||||
|
}.trim()
|
||||||
|
genre = getGenres()
|
||||||
|
status = when (seriesStatus) {
|
||||||
|
"ONGOING", "COMING_SOON" -> SManga.ONGOING
|
||||||
|
"COMPLETED" -> SManga.COMPLETED
|
||||||
|
"CANCELLED", "DROPPED" -> SManga.CANCELLED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGenres() = buildList {
|
||||||
|
when (seriesType) {
|
||||||
|
"MANGA" -> add("Manga")
|
||||||
|
"MANHUA" -> add("Manhua")
|
||||||
|
"MANHWA" -> add("Manhwa")
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
genres?.forEach { add(it.name) }
|
||||||
|
}.distinct().joinToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Name(val name: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Post<T>(val post: T)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterListResponse(
|
||||||
|
val isNovel: Boolean,
|
||||||
|
val slug: String,
|
||||||
|
val chapters: List<Chapter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Chapter(
|
||||||
|
private val id: Int,
|
||||||
|
private val slug: String,
|
||||||
|
private val number: JsonPrimitive,
|
||||||
|
private val createdBy: Name,
|
||||||
|
private val createdAt: String,
|
||||||
|
private val chapterStatus: String,
|
||||||
|
) {
|
||||||
|
fun isPublic() = chapterStatus == "PUBLIC"
|
||||||
|
|
||||||
|
fun toSChapter(mangaSlug: String) = SChapter.create().apply {
|
||||||
|
url = "/series/$mangaSlug/$slug#$id"
|
||||||
|
name = "Chapter $number"
|
||||||
|
scanlator = createdBy.name
|
||||||
|
date_upload = try {
|
||||||
|
dateFormat.parse(createdAt)!!.time
|
||||||
|
} catch (_: ParseException) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH)
|
@ -0,0 +1,101 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.arvenscans
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
interface UrlPartFilter {
|
||||||
|
fun addUrlParameter(url: HttpUrl.Builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class SelectFilter(
|
||||||
|
name: String,
|
||||||
|
private val urlParameter: String,
|
||||||
|
private val options: List<Pair<String, 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,
|
||||||
|
private val urlParameter: String,
|
||||||
|
options: List<Pair<String, String>>,
|
||||||
|
) : UrlPartFilter, Filter.Group<CheckBoxFilter>(
|
||||||
|
name,
|
||||||
|
options.map { CheckBoxFilter(it.first, it.second) },
|
||||||
|
) {
|
||||||
|
override fun addUrlParameter(url: HttpUrl.Builder) {
|
||||||
|
val checked = state.filter { it.state }.map { it.value }
|
||||||
|
|
||||||
|
if (checked.isNotEmpty()) {
|
||||||
|
url.addQueryParameter(urlParameter, checked.joinToString(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatusFilter : SelectFilter(
|
||||||
|
"Status",
|
||||||
|
"seriesStatus",
|
||||||
|
listOf(
|
||||||
|
Pair("", ""),
|
||||||
|
Pair("Ongoing", "ONGOING"),
|
||||||
|
Pair("Completed", "COMPLETED"),
|
||||||
|
Pair("Cancelled", "CANCELLED"),
|
||||||
|
Pair("Dropped", "DROPPED"),
|
||||||
|
Pair("Mass Released", "MASS_RELEASED"),
|
||||||
|
Pair("Coming Soon", "COMING_SOON"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TypeFilter : SelectFilter(
|
||||||
|
"Type",
|
||||||
|
"seriesType",
|
||||||
|
listOf(
|
||||||
|
Pair("", ""),
|
||||||
|
Pair("Manga", "MANGA"),
|
||||||
|
Pair("Manhua", "MANHUA"),
|
||||||
|
Pair("Manhwa", "MANHWA"),
|
||||||
|
Pair("Russian", "RUSSIAN"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class GenreFilter : CheckBoxGroup(
|
||||||
|
"Genres",
|
||||||
|
"genreIds",
|
||||||
|
listOf(
|
||||||
|
Pair("Action", "1"),
|
||||||
|
Pair("Adventure", "13"),
|
||||||
|
Pair("Comedy", "7"),
|
||||||
|
Pair("Drama", "2"),
|
||||||
|
Pair("elf", "25"),
|
||||||
|
Pair("Fantas", "28"),
|
||||||
|
Pair("Fantasy", "8"),
|
||||||
|
Pair("Historical", "19"),
|
||||||
|
Pair("Horror", "9"),
|
||||||
|
Pair("Josei", "21"),
|
||||||
|
Pair("Manhwa", "5"),
|
||||||
|
Pair("Martial Arts", "6"),
|
||||||
|
Pair("Mature", "12"),
|
||||||
|
Pair("Monsters", "14"),
|
||||||
|
Pair("Reincarnation", "16"),
|
||||||
|
Pair("Revenge", "17"),
|
||||||
|
Pair("Romance", "20"),
|
||||||
|
Pair("School Life", "23"),
|
||||||
|
Pair("Seinen", "10"),
|
||||||
|
Pair("shojo", "26"),
|
||||||
|
Pair("Shoujo", "22"),
|
||||||
|
Pair("Shounen", "3"),
|
||||||
|
Pair("Slice Of Life", "18"),
|
||||||
|
Pair("Sports", "4"),
|
||||||
|
Pair("Supernatural", "11"),
|
||||||
|
Pair("System", "15"),
|
||||||
|
Pair("terror", "24"),
|
||||||
|
Pair("Video Games", "27"),
|
||||||
|
),
|
||||||
|
)
|
@ -0,0 +1,145 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.arvenscans
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
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 eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class VortexScans : HttpSource() {
|
||||||
|
|
||||||
|
override val name = "Vortex Scans"
|
||||||
|
|
||||||
|
override val baseUrl = "https://vortexscans.com"
|
||||||
|
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient
|
||||||
|
|
||||||
|
private val json by injectLazy<Json>()
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.set("Referer", "$baseUrl/")
|
||||||
|
|
||||||
|
private val titleCache by lazy {
|
||||||
|
val response = client.newCall(GET("$baseUrl/api/query?perPage=9999", headers)).execute()
|
||||||
|
val data = response.parseAs<SearchResponse>()
|
||||||
|
|
||||||
|
data.posts
|
||||||
|
.filterNot { it.isNovel }
|
||||||
|
.associateBy { it.slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val slugs = document.select("div:contains(Popular) + div.swiper div.manga-swipe > a")
|
||||||
|
.map { it.absUrl("href").substringAfterLast("/series/") }
|
||||||
|
|
||||||
|
val entries = slugs.mapNotNull {
|
||||||
|
titleCache[it]?.toSManga(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(entries, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", getFilterList())
|
||||||
|
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = "$baseUrl/api/query".toHttpUrl().newBuilder().apply {
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
addQueryParameter("perPage", perPage.toString())
|
||||||
|
addQueryParameter("searchTerm", query.trim())
|
||||||
|
filters.filterIsInstance<UrlPartFilter>().forEach {
|
||||||
|
it.addUrlParameter(this)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
val data = response.parseAs<SearchResponse>()
|
||||||
|
val page = response.request.url.queryParameter("page")!!.toInt()
|
||||||
|
|
||||||
|
val entries = data.posts
|
||||||
|
.filterNot { it.isNovel }
|
||||||
|
.map { it.toSManga(baseUrl) }
|
||||||
|
|
||||||
|
val hasNextPage = data.totalCount > (page * perPage)
|
||||||
|
|
||||||
|
return MangasPage(entries, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
StatusFilter(),
|
||||||
|
TypeFilter(),
|
||||||
|
GenreFilter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
val id = manga.url.substringAfterLast("#")
|
||||||
|
val url = "$baseUrl/api/chapters?postId=$id&skip=0&take=1000&order=desc&userid="
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
|
val slug = manga.url.substringBeforeLast("#")
|
||||||
|
|
||||||
|
return "$baseUrl/series/$slug"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val data = response.parseAs<Post<Manga>>()
|
||||||
|
|
||||||
|
assert(!data.post.isNovel) { "Novels are unsupported" }
|
||||||
|
|
||||||
|
// genres are only returned in search call
|
||||||
|
// and not when fetching details
|
||||||
|
return data.post.toSManga(baseUrl).apply {
|
||||||
|
genre = titleCache[data.post.slug]?.getGenres()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val data = response.parseAs<Post<ChapterListResponse>>()
|
||||||
|
|
||||||
|
assert(!data.post.isNovel) { "Novels are unsupported" }
|
||||||
|
|
||||||
|
return data.post.chapters
|
||||||
|
.filter { it.isPublic() }
|
||||||
|
.map { it.toSChapter(data.post.slug) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
|
||||||
|
return document.select("main > section > img").mapIndexed { idx, img ->
|
||||||
|
Page(idx, imageUrl = img.absUrl("src"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response) =
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
private inline fun <reified T> Response.parseAs(): T =
|
||||||
|
json.decodeFromString(body.string())
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val perPage = 18
|