Add HentaiNexus (#719)

* Add HentaiNexus

* icons

* add parody filter

* Apply reviews
This commit is contained in:
beerpsi 2024-01-28 01:56:47 +07:00 committed by GitHub
parent 34e64761b9
commit 918557e5f6
11 changed files with 351 additions and 0 deletions

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:exported="true"
android:name=".en.hentainexus.HentaiNexusActivity"
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="hentainexus.com" />
<data android:pathPattern="/view/..*"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = "HentaiNexus"
extClass = ".HentaiNexus"
extVersionCode = 5
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -0,0 +1,181 @@
package eu.kanade.tachiyomi.extension.en.hentainexus
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
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.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
class HentaiNexus : ParsedHttpSource() {
override val name = "HentaiNexus"
override val lang = "en"
override val baseUrl = "https://hentainexus.com"
override val supportsLatest = false
// Images on this site goes through the free Jetpack Photon CDN.
override val client = network.cloudflareClient.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 1)
.build()
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
private val json: Json by injectLazy()
override fun popularMangaRequest(page: Int) = GET(
baseUrl + (if (page > 1) "/page/$page" else ""),
headers,
)
override fun popularMangaSelector() = ".container .column"
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
title = element.selectFirst(".card-header-title")!!.text()
thumbnail_url = element.selectFirst(".card-image img")?.absUrl("src")
}
override fun popularMangaNextPageSelector() = "a.pagination-next[href]"
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
val id = query.removePrefix(PREFIX_ID_SEARCH)
client.newCall(GET("$baseUrl/view/$id", headers)).asObservableSuccess()
.map { MangasPage(listOf(mangaDetailsParse(it).apply { url = "/view/$id" }), false) }
} else {
super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = baseUrl.toHttpUrl().newBuilder().apply {
val actualPage = page + (filters.filterIsInstance<OffsetPageFilter>().firstOrNull()?.state?.toIntOrNull() ?: 0)
if (actualPage > 1) {
addPathSegments("page/$actualPage")
}
addQueryParameter("q", (combineQuery(filters) + query).trim())
}.build()
return GET(url, headers)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
private val tagCountRegex = Regex("""\s*\([\d,]+\)$""")
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
val table = document.selectFirst(".view-page-details")!!
title = document.selectFirst("h1.title")!!.text()
artist = table.select("td.viewcolumn:contains(Artist) + td a").joinToString { it.ownText() }
author = table.select("td.viewcolumn:contains(Author) + td a").joinToString { it.ownText() }
description = buildString {
listOf("Circle", "Event", "Magazine", "Parody", "Publisher", "Pages", "Favorites").forEach { key ->
val cell = table.selectFirst("td.viewcolumn:contains($key) + td")
cell
?.ownText()
?.ifEmpty { cell.selectFirst("a")!!.ownText() }
?.let { appendLine("$key: $it") }
}
appendLine()
table.selectFirst("td.viewcolumn:contains(Description) + td")?.text()?.let {
appendLine(it)
}
}
genre = table.select("span.tag a").joinToString {
it.text().replace(tagCountRegex, "")
}
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val id = manga.url.split("/").last()
return Observable.just(
listOf(
SChapter.create().apply {
url = "/read/$id"
name = "Chapter"
},
),
)
}
override fun chapterListSelector() = throw UnsupportedOperationException()
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
override fun pageListParse(document: Document): List<Page> {
val script = document.selectFirst("script:containsData(initReader)")?.data()
?: throw Exception("Could not find chapter data")
val encoded = script.substringAfter("initReader(\"").substringBefore("\",")
val data = HentaiNexusUtils.decryptData(encoded)
return json.parseToJsonElement(data).jsonArray.mapIndexed { i, it ->
Page(i, imageUrl = it.jsonObject["image"]!!.jsonPrimitive.content)
}
}
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
override fun getFilterList() = FilterList(
Filter.Header(
"""
Separate items with commas (,)
Prepend with dash (-) to exclude
For items with multiple words, surround them with double quotes (")
""".trimIndent(),
),
TagFilter(),
ArtistFilter(),
AuthorFilter(),
CircleFilter(),
EventFilter(),
ParodyFilter(),
MagazineFilter(),
PublisherFilter(),
Filter.Separator(),
OffsetPageFilter(),
)
companion object {
const val PREFIX_ID_SEARCH = "id:"
}
}

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.extension.en.hentainexus
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://hentainexus.com/view/xxxx intents
* and redirects them to the main Tachiyomi process.
*/
class HentaiNexusActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val id = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${HentaiNexus.PREFIX_ID_SEARCH}$id")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("HentaiNexusActivity", e.toString())
}
} else {
Log.e("HentaiNexusActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.extension.en.hentainexus
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
class OffsetPageFilter : Filter.Text("Offset results by # pages")
class TagFilter : AdvSearchEntryFilter("Tags")
class ArtistFilter : AdvSearchEntryFilter("Artists")
class AuthorFilter : AdvSearchEntryFilter("Authors")
class CircleFilter : AdvSearchEntryFilter("Circles")
class EventFilter : AdvSearchEntryFilter("Events")
class ParodyFilter : AdvSearchEntryFilter("Parodies", "parody")
class MagazineFilter : AdvSearchEntryFilter("Magazines")
class PublisherFilter : AdvSearchEntryFilter("Publishers")
open class AdvSearchEntryFilter(
name: String,
val key: String = name.lowercase().removeSuffix("s"),
) : Filter.Text(name)
data class AdvSearchEntry(val key: String, val text: String, val exclude: Boolean)
internal fun combineQuery(filters: FilterList): String {
val advSearch = filters.filterIsInstance<AdvSearchEntryFilter>().flatMap { filter ->
val splitState = filter.state.split(",").map(String::trim).filterNot(String::isBlank)
splitState.map {
AdvSearchEntry(filter.key, it.removePrefix("-"), it.startsWith("-"))
}
}
return buildString {
advSearch.forEach { entry ->
if (entry.exclude) {
append("-")
}
append(entry.key)
append(":")
append(entry.text)
append(" ")
}
}
}

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.extension.en.hentainexus
import android.util.Base64
object HentaiNexusUtils {
fun decryptData(data: String): String = decryptData(Base64.decode(data, Base64.DEFAULT))
private val primeNumbers = listOf(2, 3, 5, 7, 11, 13, 17)
private fun decryptData(data: ByteArray): String {
val keyStream = data.slice(0 until 64).map { it.toUByte().toInt() }
val ciphertext = data.slice(64 until data.size).map { it.toUByte().toInt() }
val digest = (0..255).toMutableList()
var primeIdx = 0
for (i in 0 until 64) {
primeIdx = primeIdx xor keyStream[i]
for (j in 0 until 8) {
primeIdx = if (primeIdx and 1 != 0) {
primeIdx ushr 1 xor 12
} else {
primeIdx ushr 1
}
}
}
primeIdx = primeIdx and 7
var temp: Int
var key = 0
for (i in 0..255) {
key = (key + digest[i] + keyStream[i % 64]) % 256
temp = digest[i]
digest[i] = digest[key]
digest[key] = temp
}
val q = primeNumbers[primeIdx]
var k = 0
var n = 0
var p = 0
var xorKey = 0
return buildString(ciphertext.size) {
for (i in ciphertext.indices) {
k = (k + q) % 256
n = (p + digest[(n + digest[k]) % 256]) % 256
p = (p + k + digest[k]) % 256
temp = digest[k]
digest[k] = digest[n]
digest[n] = temp
xorKey = digest[(n + digest[(k + digest[(xorKey + p) % 256]) % 256]) % 256]
append((ciphertext[i].toUByte().toInt() xor xorKey).toChar())
}
}
}
}