Update domain for NineHentai (+revert removal) (#6842)

* Revert 9bc701d65ae9493772848b5d5fd927a87b3d7fb5 (partial)

* NineHentai: update domain
This commit is contained in:
Vetle Ledaal 2024-12-29 07:44:53 +01:00 committed by Draff
parent 39ae8f5e73
commit 44ad6961d3
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
10 changed files with 532 additions and 0 deletions

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".en.ninehentai.NineHentaiUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<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:host="9hentai.so"
android:pathPattern="/g/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,379 @@
package eu.kanade.tachiyomi.extension.en.ninehentai
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
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.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.Buffer
import org.jsoup.nodes.Element
import rx.Observable
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.util.Calendar
class NineHentai : HttpSource() {
override val baseUrl = "https://9hentai.so"
override val name = "NineHentai"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
private val json: Json by injectLazy()
// Builds request for /api/getBooks endpoint
private fun buildSearchRequest(
searchText: String = "",
page: Int,
sort: Int = 0,
range: List<Int> = listOf(0, 2000),
includedTags: List<Tag> = listOf(),
excludedTags: List<Tag> = listOf(),
): Request {
val searchRequest = SearchRequest(
text = searchText,
page = page - 1, // Source starts counting from 0, not 1
sort = sort,
pages = Range(range),
tag = Items(
items = TagArrays(
included = includedTags,
excluded = excludedTags,
),
),
)
val jsonString = json.encodeToString(SearchRequestPayload(search = searchRequest))
return POST("$baseUrl$SEARCH_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
}
private fun parseSearchResponse(response: Response): MangasPage {
return response.use {
val page = json.decodeFromString<SearchRequestPayload>(it.request.bodyString).search.page
json.decodeFromString<SearchResponse>(it.body.string()).let { searchResponse ->
MangasPage(
searchResponse.results.map {
SManga.create().apply {
url = "/g/${it.id}"
title = it.title
// Cover is the compressed first page (cover might change if page count changes)
thumbnail_url = "${it.image_server}${it.id}/1.jpg?${it.total_page}"
}
},
searchResponse.totalCount - 1 > page,
)
}
}
}
// Builds request for /api/getBookById endpoint
private fun buildDetailRequest(id: Int): Request {
val jsonString = buildJsonObject { put("id", id) }.toString()
return POST("$baseUrl$MANGA_URL", headers, jsonString.toRequestBody(MEDIA_TYPE))
}
// Popular
override fun popularMangaRequest(page: Int): Request = buildSearchRequest(page = page, sort = 1)
override fun popularMangaParse(response: Response): MangasPage = parseSearchResponse(response)
// Latest
override fun latestUpdatesRequest(page: Int): Request = buildSearchRequest(page = page)
override fun latestUpdatesParse(response: Response): MangasPage = parseSearchResponse(response)
// Search
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (query.startsWith("id:")) {
val id = query.substringAfter("id:").toInt()
return client.newCall(buildDetailRequest(id))
.asObservableSuccess()
.map { response ->
fetchSingleManga(response)
}
}
return super.fetchSearchManga(page, query, filters)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val filterList = if (filters.isEmpty()) getFilterList() else filters
var sort = 0
val range = mutableListOf(0, 2000)
val includedTags = mutableListOf<Tag>()
val excludedTags = mutableListOf<Tag>()
for (filter in filterList) {
when (filter) {
is SortFilter -> {
sort = filter.state
}
is MinPagesFilter -> {
try {
range[0] = filter.state.toInt()
} catch (_: NumberFormatException) {
// Suppress and retain default value
}
}
is MaxPagesFilter -> {
try {
range[1] = filter.state.toInt()
} catch (_: NumberFormatException) {
// Suppress and retain default value
}
}
is IncludedFilter -> {
includedTags += getTags(filter.state, 1)
}
is ExcludedFilter -> {
excludedTags += getTags(filter.state, 1)
}
is GroupFilter -> {
includedTags += getTags(filter.state, 2)
}
is ParodyFilter -> {
includedTags += getTags(filter.state, 3)
}
is ArtistFilter -> {
includedTags += getTags(filter.state, 4)
}
is CharacterFilter -> {
includedTags += getTags(filter.state, 5)
}
is CategoryFilter -> {
includedTags += getTags(filter.state, 6)
}
else -> { /* Do nothing */ }
}
}
return buildSearchRequest(
searchText = query,
page = page,
sort = sort,
range = range,
includedTags = includedTags,
excludedTags = excludedTags,
)
}
override fun searchMangaParse(response: Response): MangasPage = parseSearchResponse(response)
// Manga Details
override fun mangaDetailsParse(response: Response): SManga {
return SManga.create().apply {
response.asJsoup().selectFirst("div#bigcontainer")!!.let { info ->
title = info.select("h1").text()
thumbnail_url = info.selectFirst("div#cover v-lazy-image")!!.attr("abs:src")
status = SManga.COMPLETED
artist = info.selectTextOrNull("div.field-name:contains(Artist:) a.tag")
author = info.selectTextOrNull("div.field-name:contains(Group:) a.tag") ?: "Unknown circle"
genre = info.selectTextOrNull("div.field-name:contains(Tag:) a.tag")
// Additional details
description = listOf(
Pair("Alternative Title", info.selectTextOrNull("h2")),
Pair("Pages", info.selectTextOrNull("div#info > div:contains(pages)")),
Pair("Parody", info.selectTextOrNull("div.field-name:contains(Parody:) a.tag")),
Pair("Category", info.selectTextOrNull("div.field-name:contains(Category:) a.tag")),
Pair("Language", info.selectTextOrNull("div.field-name:contains(Language:) a.tag")),
).filterNot { it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
}
}
}
// Ensures no exceptions are thrown when scraping additional details
private fun Element.selectTextOrNull(selector: String): String? {
val list = this.select(selector)
return if (list.isEmpty()) {
null
} else {
list.joinToString(", ") { it.text() }
}
}
// Chapter
override fun chapterListParse(response: Response): List<SChapter> {
val time = response.asJsoup().select("div#info div time").text()
return listOf(
SChapter.create().apply {
name = "Chapter"
date_upload = parseChapterDate(time)
url = response.request.url.encodedPath
},
)
}
private fun parseChapterDate(date: String): Long {
val dateStringSplit = date.split(" ")
val value = dateStringSplit[0].toInt()
return when (dateStringSplit[1].removeSuffix("s")) {
"sec" -> Calendar.getInstance().apply {
add(Calendar.SECOND, value * -1)
}.timeInMillis
"min" -> Calendar.getInstance().apply {
add(Calendar.MINUTE, value * -1)
}.timeInMillis
"hour" -> Calendar.getInstance().apply {
add(Calendar.HOUR_OF_DAY, value * -1)
}.timeInMillis
"day" -> Calendar.getInstance().apply {
add(Calendar.DATE, value * -1)
}.timeInMillis
"week" -> Calendar.getInstance().apply {
add(Calendar.DATE, value * 7 * -1)
}.timeInMillis
"month" -> Calendar.getInstance().apply {
add(Calendar.MONTH, value * -1)
}.timeInMillis
"year" -> Calendar.getInstance().apply {
add(Calendar.YEAR, value * -1)
}.timeInMillis
else -> {
return 0
}
}
}
// Page List
override fun pageListRequest(chapter: SChapter): Request {
val mangaId = chapter.url.substringAfter("/g/").toInt()
return buildDetailRequest(mangaId)
}
override fun pageListParse(response: Response): List<Page> {
val resultsObj = json.parseToJsonElement(response.body.string()).jsonObject["results"]!!
val manga = json.decodeFromJsonElement<Manga>(resultsObj)
val imageUrl = manga.image_server + manga.id
var totalPages = manga.total_page
client.newCall(
GET(
"$imageUrl/preview/${totalPages}t.jpg",
headersBuilder().build(),
),
).execute().code.let { code ->
if (code == 404) totalPages--
}
return (1..totalPages).map {
Page(it - 1, "", "$imageUrl/$it.jpg")
}
}
private fun getTags(queries: String, type: Int): List<Tag> {
return queries.split(",").map(String::trim)
.filterNot(String::isBlank).mapNotNull { query ->
val jsonString = buildJsonObject {
put("tag_name", query)
put("tag_type", type)
}.toString()
lookupTags(jsonString)
}
}
// Based on HentaiHand ext
private fun lookupTags(request: String): Tag? {
return client.newCall(POST("$baseUrl$TAG_URL", headers, request.toRequestBody(MEDIA_TYPE)))
.asObservableSuccess()
.subscribeOn(Schedulers.io())
.map { response ->
// Returns the first matched tag, or null if there are no results
val tagList = json.parseToJsonElement(response.body.string()).jsonObject["results"]!!.jsonArray.map {
json.decodeFromJsonElement<Tag>(it)
}
if (tagList.isEmpty()) {
return@map null
} else {
tagList.first()
}
}.toBlocking().first()
}
private fun fetchSingleManga(response: Response): MangasPage {
val resultsObj = json.parseToJsonElement(response.body.string()).jsonObject["results"]!!
val manga = json.decodeFromJsonElement<Manga>(resultsObj)
val list = listOf(
SManga.create().apply {
setUrlWithoutDomain("/g/${manga.id}")
title = manga.title
thumbnail_url = "${manga.image_server + manga.id}/cover.jpg"
},
)
return MangasPage(list, false)
}
// Filters
private class SortFilter : Filter.Select<String>(
"Sort by",
arrayOf("Newest", "Popular Right now", "Most Fapped", "Most Viewed", "By Title"),
)
private class MinPagesFilter : Filter.Text("Minimum Pages")
private class MaxPagesFilter : Filter.Text("Maximum Pages")
private class IncludedFilter : Filter.Text("Included Tags")
private class ExcludedFilter : Filter.Text("Excluded Tags")
private class ArtistFilter : Filter.Text("Artist")
private class GroupFilter : Filter.Text("Group")
private class ParodyFilter : Filter.Text("Parody")
private class CharacterFilter : Filter.Text("Character")
private class CategoryFilter : Filter.Text("Category")
override fun getFilterList() = FilterList(
Filter.Header("Search by id with \"id:\" in front of query"),
Filter.Separator(),
SortFilter(),
MinPagesFilter(),
MaxPagesFilter(),
IncludedFilter(),
ExcludedFilter(),
ArtistFilter(),
GroupFilter(),
ParodyFilter(),
CharacterFilter(),
CategoryFilter(),
)
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
private val Request.bodyString: String
get() {
val requestCopy = newBuilder().build()
val buffer = Buffer()
return runCatching { buffer.apply { requestCopy.body!!.writeTo(this) }.readUtf8() }
.getOrNull() ?: ""
}
companion object {
private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
private const val SEARCH_URL = "/api/getBook"
private const val MANGA_URL = "/api/getBookByID"
private const val TAG_URL = "/api/getTag"
}
}

View File

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.extension.en.ninehentai
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Manga(
val id: Int,
val title: String,
val image_server: String,
val total_page: Int,
)
/*
The basic search request JSON object looks like this:
{
"search": {
"text": "",
"page": 1,
"sort": 1,
"pages": {
"range": [0, 2000]
},
"tag": {
"items": {
"included": [],
"excluded": []
}
}
}
}
*/
/*
Sort = 0, Newest
Sort = 1, Popular right now
Sort = 2, Most Fapped
Sort = 3, Most Viewed
Sort = 4, By title
*/
@Serializable
data class SearchRequest(
val text: String,
val page: Int,
val sort: Int,
val pages: Range,
val tag: Items,
)
@Serializable
data class SearchRequestPayload(
val search: SearchRequest,
)
@Serializable
data class SearchResponse(
@SerialName("total_count") val totalCount: Int,
val results: List<Manga>,
)
@Serializable
data class Range(
val range: List<Int>,
)
@Serializable
data class Items(
val items: TagArrays,
)
@Serializable
data class TagArrays(
val included: List<Tag>,
val excluded: List<Tag>,
)
@Serializable
data class Tag(
val id: Int,
val name: String,
val description: String? = null,
val type: Int = 1,
)

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.extension.en.ninehentai
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://9hentai.so/g/xxxxxx intents and redirects them to
* the main Tachiyomi process.
*/
class NineHentaiUrlActivity : 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", "id:$id")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("NineHentaiUrlActivity", e.toString())
}
} else {
Log.e("NineHentaiUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}