Add InfinityScans (#19378)

* Add infinityscans

* Add nsfw flag

* move away from fetchXXXX functions

* remove log
This commit is contained in:
Secozzi 2023-12-22 17:31:09 +00:00 committed by GitHub
parent d161dafd17
commit b5f67c3778
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 454 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -0,0 +1,13 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'InfinityScans'
pkgNameSuffix = 'en.infinityscans'
extClass = '.InfinityScans'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1,295 @@
package eu.kanade.tachiyomi.extension.en.infinityscans
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit
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.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.nodes.Element
import uy.kohesive.injekt.injectLazy
import java.io.IOException
class InfinityScans : HttpSource() {
override val name = "InfinityScans"
override val baseUrl = "https://infinityscans.xyz"
private val cdnHost = "cdn.${baseUrl.toHttpUrl().host}"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
val response = chain.proceed(chain.request())
if (response.code == 401) {
response.close()
throw IOException("Solve Captcha in WebView")
}
response
}
.rateLimit(1)
.build()
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
private val json: Json by injectLazy()
// Popular
override fun popularMangaRequest(page: Int): Request =
searchMangaRequest(page, "", FilterList(SortFilter("popularity")))
override fun popularMangaParse(response: Response): MangasPage =
searchMangaParse(response)
// Latest
override fun latestUpdatesRequest(page: Int): Request =
searchMangaRequest(page, "", FilterList(SortFilter("latest")))
override fun latestUpdatesParse(response: Response): MangasPage =
searchMangaParse(response)
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val searchHeaders = headersBuilder().apply {
add("X-Requested-With", "XMLHttpRequest")
}.build()
val url = baseUrl.toHttpUrl().newBuilder()
.addPathSegments("ajax/comics")
.addQueryParameter("page", page.toString())
filters.forEach { filter ->
when (filter) {
is SortFilter -> {
url.addQueryParameter("sort", filter.selected)
}
is GenreFilter -> {
filter.checked?.also {
url.addQueryParameter("genre", it.joinToString("|"))
}
}
is AuthorFilter -> {
filter.checked?.also {
url.addQueryParameter("author", it.joinToString("|"))
}
}
is StatusFilter -> {
filter.checked?.also {
url.addQueryParameter("status", it.joinToString("|"))
}
}
else -> { /* Do Nothing */ }
}
}
if (query.isNotBlank()) url.addQueryParameter("title", query)
return GET(url.build(), searchHeaders)
}
override fun searchMangaParse(response: Response): MangasPage {
val page = response.request.url.queryParameter("page")!!.toInt()
val data = response.parseAs<ResponseDto<SearchResultDto>>().result
runCatching { updateFilters(data) }
val entries = data.comics
.map { it.toSManga(cdnHost) }
return MangasPage(entries, page < data.pages)
}
// Filters
private fun updateFilters(data: SearchResultDto) {
data.genres?.also { genreDto ->
genreList = genreDto.map { Pair(it.title, it.id.toString()) }
}
data.authors?.also { authorDto ->
authorList = authorDto.map { Pair(it.title, it.id.toString()) }
}
data.statuses?.also { status ->
statusList = status.map { Pair(it, it) }
}
}
private var genreList: List<Pair<String, String>> = emptyList()
private var authorList: List<Pair<String, String>> = emptyList()
private var statusList: List<Pair<String, String>> = emptyList()
override fun getFilterList(): FilterList {
val filters: MutableList<Filter<*>> = mutableListOf(
SortFilter(),
)
if (genreList.isNotEmpty() || authorList.isNotEmpty() || statusList.isNotEmpty()) {
if (genreList.isNotEmpty()) filters += listOf(GenreFilter("Genres", genreList))
if (authorList.isNotEmpty()) filters += listOf(AuthorFilter("Authors", authorList))
if (statusList.isNotEmpty()) filters += listOf(StatusFilter("Statuses", statusList))
} else {
filters += listOf(
Filter.Separator(),
Filter.Header("Press 'reset' to attempt to show additional filters"),
)
}
return FilterList(filters)
}
// Details
override fun mangaDetailsParse(response: Response): SManga {
val document = response.use { it.asJsoup() }
val desc = document.select("div.s1:has(>h2:contains(Summary)) p")
.text()
.split("</br>")
.joinToString("\n", transform = String::trim)
.trim()
return SManga.create().apply {
document.selectFirst("div.info")!!.also { details ->
description = desc
author = details.getLinks("Authors")
genre = details.getLinks("Genres")
status = details.getInfo("Status").parseStatus()
details.getInfo("Alternative Title")?.let {
description = "$desc\n\nAlternative Title: $it"
}
}
}
}
// From mangathemesia
private fun String?.parseStatus(): Int = when {
this == null -> SManga.UNKNOWN
listOf("ongoing", "publishing").any { this.contains(it, ignoreCase = true) } -> SManga.ONGOING
this.contains("hiatus", ignoreCase = true) -> SManga.ON_HIATUS
this.contains("completed", ignoreCase = true) -> SManga.COMPLETED
listOf("dropped", "cancelled").any { this.contains(it, ignoreCase = true) } -> SManga.CANCELLED
else -> SManga.UNKNOWN
}
private fun Element.getInfo(name: String): String? =
selectFirst("div:has(>b:matches($name:))")?.ownText()
private fun Element.getLinks(name: String): String? =
select("div:has(>b:matches($name:)) a")
.joinToString(", ", transform = Element::text).trim()
.takeIf { it.isNotBlank() }
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
val url = response.request.url
val slug = url.pathSegments.take(3).joinToString("/", prefix = "/")
// Create POST request
val id = url.pathSegments[1]
val form = FormBody.Builder().apply {
add("comic_id", id)
}.build()
val chapterHeaders = headersBuilder().apply {
add("Accept", "*/*")
add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
add("Host", baseUrl.toHttpUrl().host)
add("Origin", baseUrl)
set("Referer", url.toString())
add("X-Requested-With", "XMLHttpRequest")
}.build()
val chapterListData = client.newCall(
POST("$baseUrl/ajax/chapters", chapterHeaders, form),
).execute().parseAs<ResponseDto<ChapterDataDto>>()
return chapterListData.result.chapters.map {
it.toSChapter(slug)
}
}
// Pages
override fun pageListParse(response: Response): List<Page> {
val url = response.request.url
val boundary = buildString {
append((1..9).random())
repeat(28) {
append((0..9).random())
}
}
// Create POST request
val form = MultipartBody.Builder("-----------------------------$boundary").apply {
setType(MultipartBody.FORM)
addPart(
Headers.headersOf("Content-Disposition", "form-data; name=\"comic_id\""),
url.pathSegments[1].toRequestBody(null),
)
addPart(
Headers.headersOf("Content-Disposition", "form-data; name=\"chapter_id\""),
url.pathSegments[4].toRequestBody(null),
)
}.build()
val pageListHeaders = headersBuilder().apply {
add("Accept", "*/*")
add("Host", url.host)
add("Origin", baseUrl)
set("Referer", url.toString())
add("X-Requested-With", "XMLHttpRequest")
}.build()
val pageListData = client.newCall(
POST("$baseUrl/ajax/images", pageListHeaders, form),
).execute().parseAs<ResponseDto<PageDataDto>>()
return pageListData.result.images.mapIndexed { index, p ->
Page(index, url.toString(), p.link)
}
}
// Image
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used.")
override fun imageRequest(page: Page): Request {
val pageHeaders = headersBuilder().apply {
add("Accept", "image/avif,image/webp,*/*")
add("Host", page.imageUrl!!.toHttpUrl().host)
}.build()
return GET(page.imageUrl!!, pageHeaders)
}
// Utilities
private inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromStream(it.body.byteStream())
}
}

View File

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.extension.en.infinityscans
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
class ResponseDto<T>(val result: T)
@Serializable
data class SearchResultDto(
val pages: Int,
val comics: List<SearchEntryDto>,
val genres: List<FilterDto>? = null,
val authors: List<FilterDto>? = null,
val statuses: List<String>? = null,
)
@Serializable
data class SearchEntryDto(
val id: Int,
val title: String,
val slug: String,
val cover: String,
val updated: String? = null,
val created: String? = null,
) {
fun toSManga(cdnHost: String) = SManga.create().apply {
title = this@SearchEntryDto.title
thumbnail_url = "https://$cdnHost/$id/$cover?v=${getImageParameter()}"
url = "/comic/$id/$slug"
}
private fun getImageParameter(): Long {
val date = updated?.let { parseDate(it, DATE_FORMATTER) }
?: created?.let { parseDate(it, DATE_FORMATTER) }
?: 0L
return date / 1000L
}
}
@Serializable
data class FilterDto(val id: Int, val title: String)
@Serializable
data class ChapterDataDto(val chapters: List<ChapterEntryDto>)
@Serializable
data class ChapterEntryDto(
val id: Int,
val title: String,
val sequence: Int,
val date: String,
) {
fun toSChapter(slug: String) = SChapter.create().apply {
name = title
// Things like prologues mess up the sequence number
chapter_number = title.substringAfter("hapter ").toFloatOrNull() ?: sequence.toFloat()
date_upload = parseDate(date, CHAPTER_FORMATTER)
url = "$slug/chapter/$id"
}
}
@Serializable
data class PageDataDto(val images: List<PageEntryDto>)
@Serializable
data class PageEntryDto(val link: String)
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
}
private val CHAPTER_FORMATTER by lazy {
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
}
private fun parseDate(dateStr: String, formatter: SimpleDateFormat): Long {
return runCatching { formatter.parse(dateStr)?.time }
.getOrNull() ?: 0L
}

View File

@ -0,0 +1,60 @@
package eu.kanade.tachiyomi.extension.en.infinityscans
import eu.kanade.tachiyomi.source.model.Filter
abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String>>,
defaultValue: String? = null,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
) {
val selected get() = options[state].second.takeUnless { it.isEmpty() }
}
class CheckBoxFilter(
name: String,
val value: String,
) : Filter.CheckBox(name)
class GenreFilter(
name: String,
genres: List<Pair<String, String>>,
) : Filter.Group<CheckBoxFilter>(
name,
genres.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }.takeUnless { it.isEmpty() }
}
class AuthorFilter(
name: String,
authors: List<Pair<String, String>>,
) : Filter.Group<CheckBoxFilter>(
name,
authors.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }.takeUnless { it.isEmpty() }
}
class StatusFilter(
name: String,
status: List<Pair<String, String>>,
) : Filter.Group<CheckBoxFilter>(
name,
status.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }.takeUnless { it.isEmpty() }
}
class SortFilter(defaultSort: String? = null) : SelectFilter(
"Sort By",
listOf(
Pair("Title", "title"),
Pair("Popularity", "popularity"),
Pair("Latest", "latest"),
),
defaultSort,
)