diff --git a/src/en/hentainexus/AndroidManifest.xml b/src/en/hentainexus/AndroidManifest.xml new file mode 100644 index 000000000..64db527dc --- /dev/null +++ b/src/en/hentainexus/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/src/en/hentainexus/build.gradle b/src/en/hentainexus/build.gradle new file mode 100644 index 000000000..8a01920cc --- /dev/null +++ b/src/en/hentainexus/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = "HentaiNexus" + extClass = ".HentaiNexus" + extVersionCode = 5 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/hentainexus/res/mipmap-hdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ce0177c2d Binary files /dev/null and b/src/en/hentainexus/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/hentainexus/res/mipmap-mdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..972f68631 Binary files /dev/null and b/src/en/hentainexus/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/hentainexus/res/mipmap-xhdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..d7d062dd7 Binary files /dev/null and b/src/en/hentainexus/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/hentainexus/res/mipmap-xxhdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..b6a2745b0 Binary files /dev/null and b/src/en/hentainexus/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/hentainexus/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..3d95cadfa Binary files /dev/null and b/src/en/hentainexus/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexus.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexus.kt new file mode 100644 index 000000000..85e467c7d --- /dev/null +++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexus.kt @@ -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 { + 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().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> { + 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 { + 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:" + } +} diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusActivity.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusActivity.kt new file mode 100644 index 000000000..5ef34cb8b --- /dev/null +++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusActivity.kt @@ -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) + } +} diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusFilters.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusFilters.kt new file mode 100644 index 000000000..4043f16df --- /dev/null +++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusFilters.kt @@ -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().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(" ") + } + } +} diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusUtils.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusUtils.kt new file mode 100644 index 000000000..ecb9547b8 --- /dev/null +++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusUtils.kt @@ -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()) + } + } + } +}