feat(en): Add NyxScans (#7883)

* feat(en): Add NyxScans

* naming

* Always use next data for page parsing

* Use functions from `utils`
This commit is contained in:
Secozzi 2025-03-05 13:39:10 +01:00 committed by Draff
parent 5e4c156a27
commit d5acdde1d9
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
12 changed files with 118 additions and 70 deletions

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 6 baseVersionCode = 7

View File

@ -97,16 +97,21 @@ class Chapter(
private val createdAt: String, private val createdAt: String,
private val chapterStatus: String, private val chapterStatus: String,
private val isAccessible: Boolean, private val isAccessible: Boolean,
private val isLocked: Boolean? = false,
private val isTimeLocked: Boolean? = false,
private val mangaPost: ChapterPostDetails, private val mangaPost: ChapterPostDetails,
) { ) {
fun isPublic() = chapterStatus == "PUBLIC" fun isPublic() = chapterStatus == "PUBLIC"
fun isAccessible() = isAccessible fun isAccessible() = isAccessible
fun isLocked() = (isLocked == true) || (isTimeLocked == true)
fun toSChapter(mangaSlug: String?) = SChapter.create().apply { fun toSChapter(mangaSlug: String?) = SChapter.create().apply {
val prefix = if (isLocked()) "🔒 " else ""
val seriesSlug = mangaSlug ?: mangaPost.slug val seriesSlug = mangaSlug ?: mangaPost.slug
url = "/series/$seriesSlug/$slug#$id" url = "/series/$seriesSlug/$slug#$id"
name = "Chapter $number" name = "${prefix}Chapter $number"
scanlator = createdBy.name scanlator = createdBy.name
date_upload = try { date_upload = try {
dateFormat.parse(createdAt)!!.time dateFormat.parse(createdAt)!!.time

View File

@ -1,6 +1,10 @@
package eu.kanade.tachiyomi.multisrc.iken package eu.kanade.tachiyomi.multisrc.iken
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
@ -9,25 +13,26 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.json.Json import keiyoushi.utils.parseAs
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class Iken( abstract class Iken(
override val name: String, override val name: String,
override val lang: String, override val lang: String,
override val baseUrl: String, override val baseUrl: String,
) : HttpSource() { ) : HttpSource(), ConfigurableSource {
override val supportsLatest = true override val supportsLatest = true
override val client = network.cloudflareClient override val client = network.cloudflareClient
private val json by injectLazy<Json>() private val preferences: SharedPreferences by getPreferencesLazy()
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/") .set("Referer", "$baseUrl/")
@ -114,35 +119,76 @@ abstract class Iken(
throw UnsupportedOperationException() throw UnsupportedOperationException()
override fun chapterListRequest(manga: SManga): Request { override fun chapterListRequest(manga: SManga): Request {
val id = manga.url.substringAfterLast("#") return GET("$baseUrl/series/${manga.url}", headers)
val url = "$baseUrl/api/chapters?postId=$id&skip=0&take=1000&order=desc&userid="
return GET(url, headers)
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val data = response.parseAs<Post<ChapterListResponse>>() val userId = userIdRegex.find(response.body.string())?.groupValues?.get(1) ?: ""
val id = response.request.url.fragment!!
val chapterUrl = "$baseUrl/api/chapters?postId=$id&skip=0&take=1000&order=desc&userid=$userId"
val chapterResponse = client.newCall(GET(chapterUrl, headers)).execute()
val data = chapterResponse.parseAs<Post<ChapterListResponse>>()
assert(!data.post.isNovel) { "Novels are unsupported" } assert(!data.post.isNovel) { "Novels are unsupported" }
return data.post.chapters return data.post.chapters
.filter { it.isPublic() && it.isAccessible() } .filter { it.isPublic() && (it.isAccessible() || (preferences.getBoolean(showLockedChapterPrefKey, false) && it.isLocked())) }
.map { it.toSChapter(data.post.slug) } .map { it.toSChapter(data.post.slug) }
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup() val document = response.asJsoup()
return document.select("main section img").mapIndexed { idx, img -> if (document.selectFirst("svg.lucide-lock") != null) {
Page(idx, imageUrl = img.absUrl("src")) throw Exception("Unlock chapter in webview")
} }
return document.getNextJson("images").parseAs<List<PageParseDto>>().mapIndexed { idx, p ->
Page(idx, imageUrl = p.url)
}
}
@Serializable
class PageParseDto(
val url: String,
)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = showLockedChapterPrefKey
title = "Show locked chapters"
setDefaultValue(false)
}.also(screen::addPreference)
} }
override fun imageUrlParse(response: Response) = override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException() throw UnsupportedOperationException()
private inline fun <reified T> Response.parseAs(): T = protected fun Document.getNextJson(key: String): String {
json.decodeFromString(body.string()) val data = selectFirst("script:containsData($key)")
?.data()
?: throw Exception("Unable to retrieve NEXT data")
val keyIndex = data.indexOf(key)
val start = data.indexOf('[', keyIndex)
var depth = 1
var i = start + 1
while (i < data.length && depth > 0) {
when (data[i]) {
'[' -> depth++
']' -> depth--
}
i++
}
return "\"${data.substring(start, i)}\"".parseAs<String>()
}
} }
private const val perPage = 18 private const val perPage = 18
private const val showLockedChapterPrefKey = "pref_show_locked_chapters"
private val userIdRegex = Regex(""""user\\":\{\\"id\\":\\"([^"']+)\\"""")

View File

@ -1,38 +1,9 @@
package eu.kanade.tachiyomi.extension.en.arvenscans package eu.kanade.tachiyomi.extension.en.arvenscans
import eu.kanade.tachiyomi.multisrc.iken.Iken import eu.kanade.tachiyomi.multisrc.iken.Iken
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class VortexScans : Iken( class VortexScans : Iken(
"Vortex Scans", "Vortex Scans",
"en", "en",
"https://vortexscans.org", "https://vortexscans.org",
) { )
private val json by injectLazy<Json>()
private val regexImages = """\\"images\\":(.*?)\\"next""".toRegex()
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val images = document.selectFirst("script:containsData(images)")
?.data()
?.let { regexImages.find(it)!!.groupValues[1].trim(',') }
?.let { json.decodeFromString<String>("\"$it\"") }
?.let { json.parseToJsonElement(it).jsonArray }
?: throw Exception("Unable to parse images")
return images.mapIndexed { idx, img ->
Page(idx, imageUrl = img.jsonObject["url"]!!.jsonPrimitive.content)
}
}
}

View File

@ -1,21 +1,12 @@
package eu.kanade.tachiyomi.extension.en.infernalvoidscans package eu.kanade.tachiyomi.extension.en.infernalvoidscans
import eu.kanade.tachiyomi.multisrc.iken.Iken import eu.kanade.tachiyomi.multisrc.iken.Iken
import eu.kanade.tachiyomi.source.model.Page
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class HiveScans : Iken( class HiveScans : Iken(
"Hive Scans", "Hive Scans",
"en", "en",
"https://hivetoon.com", "https://hivetoon.com",
) { ) {
private val json by injectLazy<Json>()
override val versionId = 2 override val versionId = 2
override val client = super.client.newBuilder() override val client = super.client.newBuilder()
@ -28,20 +19,6 @@ class HiveScans : Iken(
} }
.build() .build()
private val pageRegex = Regex("""\\"images\\":(\[.*?]).*?nextChapter""")
@Serializable
class PageDTO(
val url: String,
)
override fun pageListParse(response: Response): List<Page> {
val pageDataArray = pageRegex.find(response.body.string())?.destructured?.component1()?.replace("\\", "") ?: return listOf()
return json.decodeFromString<List<PageDTO>>(pageDataArray).mapIndexed { idx, page ->
Page(idx, imageUrl = page.url)
}
}
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.set("Cache-Control", "max-age=0") .set("Cache-Control", "max-age=0")
} }

View File

@ -0,0 +1,10 @@
ext {
extName = 'Nyx Scans'
extClass = '.NyxScans'
themePkg = 'iken'
baseUrl = 'https://nyxscans.com'
overrideVersionCode = 0
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.extension.en.nyxscans
import eu.kanade.tachiyomi.multisrc.iken.Iken
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import kotlinx.serialization.Serializable
import okhttp3.Response
class NyxScans : Iken(
"Nyx Scans",
"en",
"https://nyxscans.com",
) {
// ============================== Popular ===============================
override fun popularMangaParse(response: Response): MangasPage {
val data = response.asJsoup().getNextJson("popularPosts")
val entries = data.parseAs<List<PopularParseDto>>().map { entry ->
SManga.create().apply {
title = entry.postTitle
thumbnail_url = entry.featuredImage
url = "${entry.slug}#${entry.id}"
}
}
return MangasPage(entries, false)
}
@Serializable
class PopularParseDto(
val id: Int,
val slug: String,
val postTitle: String,
val featuredImage: String? = null,
)
}