999Hentai: update urls and some changes (#18953)

* update urls again

* small update to filters

* remove unnecessary function and add short title preference

short title stolen from Nhentai ext

* default off

* add magazine info to description

* add description from site

* unify popular and search parsing

* auto update cdn url
This commit is contained in:
AwkwardPeak7 2023-11-15 18:13:59 +05:00 committed by GitHub
parent ff603c36d2
commit 850965dd3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 122 additions and 79 deletions

View File

@ -6,7 +6,7 @@ ext {
extName = '999Hentai' extName = '999Hentai'
pkgNameSuffix = 'all.ninenineninehentai' pkgNameSuffix = 'all.ninenineninehentai'
extClass = '.NineNineNineHentaiFactory' extClass = '.NineNineNineHentaiFactory'
extVersionCode = 4 extVersionCode = 5
isNsfw = true isNsfw = true
} }

View File

@ -5,7 +5,9 @@ import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.all.ninenineninehentai.Url.Companion.toAbsUrl import eu.kanade.tachiyomi.extension.all.ninenineninehentai.Url.Companion.toAbsUrl
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
@ -16,10 +18,11 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter 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 kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
@ -39,15 +42,32 @@ open class NineNineNineHentai(
override val name = "999Hentai" override val name = "999Hentai"
override val baseUrl = "https://999hentai.to" override val baseUrl = "https://999hentai.net"
private val apiUrl = "https://hapi.allanime.day/api" private val apiUrl = "https://api.999hentai.net/api"
override val supportsLatest = true override val supportsLatest = true
private val json: Json by injectLazy() private val json: Json by injectLazy()
override val client = network.cloudflareClient.newBuilder() override val client = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
val request = chain.request()
val url = request.url
if (url.host != "127.0.0.1") {
return@addInterceptor chain.proceed(request)
}
val newRequest = request.newBuilder()
.url(
url.newBuilder()
.host(preference.cdnUrl)
.build(),
).build()
return@addInterceptor chain.proceed(newRequest)
}
.rateLimit(1) .rateLimit(1)
.build() .build()
@ -55,35 +75,21 @@ open class NineNineNineHentai(
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
} }
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val payload = GraphQL( val payload = GraphQL(
PopularVariables(size, page, 1, siteLang), PopularVariables(size, page, 1, siteLang),
POPULAR_QUERY, POPULAR_QUERY,
) ).toJsonRequestBody()
val requestBody = payload.toJsonRequestBody() return POST(apiUrl, headers, payload)
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
return POST(apiUrl, apiHeaders, requestBody)
} }
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response) = browseMangaParse<PopularResponse>(response)
val res = response.parseAs<ApiPopularResponse>()
val mangas = res.data.popular.edges
val dateMap = preference.dateMap
val entries = mangas.map { manga ->
manga.uploadDate?.let { dateMap[manga.id] = it }
manga.toSManga()
}
preference.dateMap = dateMap
val hasNextPage = mangas.size == size
return MangasPage(entries, hasNextPage)
}
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", FilterList()) override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", FilterList())
override fun latestUpdatesParse(response: Response) = searchMangaParse(response) override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
@ -114,42 +120,21 @@ open class NineNineNineHentai(
), ),
), ),
SEARCH_QUERY, SEARCH_QUERY,
) ).toJsonRequestBody()
val requestBody = payload.toJsonRequestBody() return POST(apiUrl, headers, payload)
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
return POST(apiUrl, apiHeaders, requestBody)
}
override fun searchMangaParse(response: Response): MangasPage {
val res = response.parseAs<ApiSearchResponse>()
val mangas = res.data.search.edges
val dateMap = preference.dateMap
val entries = mangas.map { manga ->
manga.uploadDate?.let { dateMap[manga.id] = it }
manga.toSManga()
}
preference.dateMap = dateMap
val hasNextPage = mangas.size == size
return MangasPage(entries, hasNextPage)
} }
override fun searchMangaParse(response: Response) = browseMangaParse<SearchResponse>(response)
override fun getFilterList() = getFilters() override fun getFilterList() = getFilters()
private fun mangaFromIDRequest(id: String): Request { private fun mangaFromIDRequest(id: String): Request {
val payload = GraphQL( val payload = GraphQL(
IdVariables(id), IdVariables(id),
DETAILS_QUERY, DETAILS_QUERY,
) ).toJsonRequestBody()
val requestBody = payload.toJsonRequestBody() return POST(apiUrl, headers, payload)
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
return POST(apiUrl, apiHeaders, requestBody)
} }
private fun searchMangaFromIDParse(response: Response): MangasPage { private fun searchMangaFromIDParse(response: Response): MangasPage {
@ -161,7 +146,7 @@ open class NineNineNineHentai(
preference.dateMap = preference.dateMap.also { dateMap -> preference.dateMap = preference.dateMap.also { dateMap ->
manga.uploadDate?.let { dateMap[manga.id] = it } manga.uploadDate?.let { dateMap[manga.id] = it }
} }
manga.toSManga() manga.toSManga(preference.shortTitle)
} }
return MangasPage(listOfNotNull(manga), false) return MangasPage(listOfNotNull(manga), false)
@ -179,7 +164,7 @@ open class NineNineNineHentai(
manga.uploadDate?.let { dateMap[manga.id] = it } manga.uploadDate?.let { dateMap[manga.id] = it }
} }
return manga.toSManga() return manga.toSManga(preference.shortTitle)
} }
override fun getMangaUrl(manga: SManga) = "$baseUrl/hchapter/${manga.url}" override fun getMangaUrl(manga: SManga) = "$baseUrl/hchapter/${manga.url}"
@ -209,13 +194,9 @@ open class NineNineNineHentai(
val payload = GraphQL( val payload = GraphQL(
IdVariables(chapter.url), IdVariables(chapter.url),
PAGES_QUERY, PAGES_QUERY,
) ).toJsonRequestBody()
val requestBody = payload.toJsonRequestBody() return POST(apiUrl, headers, payload)
val apiHeaders = headersBuilder().buildApiHeaders(requestBody)
return POST(apiUrl, apiHeaders, requestBody)
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
@ -224,7 +205,8 @@ open class NineNineNineHentai(
val pages = res.data.chapter.pages?.firstOrNull() val pages = res.data.chapter.pages?.firstOrNull()
?: return emptyList() ?: return emptyList()
val cdn = pages.urlPart.toAbsUrl() val cdnUrl = "https://${getUpdatedCdn(res.data.chapter.id)}/"
val cdn = pages.urlPart.toAbsUrl(cdnUrl)
val selectedImages = when (preference.getString(PREF_IMG_QUALITY_KEY, "original")) { val selectedImages = when (preference.getString(PREF_IMG_QUALITY_KEY, "original")) {
"medium" -> pages.qualityMedium?.mapIndexed { i, it -> "medium" -> pages.qualityMedium?.mapIndexed { i, it ->
@ -238,6 +220,21 @@ open class NineNineNineHentai(
} }
} }
private fun getUpdatedCdn(chapterId: String): String {
val url = "$baseUrl/hchapter/$chapterId"
val document = client.newCall(GET(url, headers))
.execute().use { it.asJsoup() }
val cdnHost = document.selectFirst("meta[property=og:image]")
?.attr("content")
?.toHttpUrlOrNull()
?.host
return cdnHost?.also {
preference.cdnUrl = it
} ?: preference.cdnUrl
}
private inline fun <reified T> String.parseAs(): T = private inline fun <reified T> String.parseAs(): T =
json.decodeFromString(this) json.decodeFromString(this)
@ -251,17 +248,27 @@ open class NineNineNineHentai(
json.encodeToString(this) json.encodeToString(this)
.toRequestBody(JSON_MEDIA_TYPE) .toRequestBody(JSON_MEDIA_TYPE)
private fun Headers.Builder.buildApiHeaders(requestBody: RequestBody) = this
.add("Content-Length", requestBody.contentLength().toString())
.add("Content-Type", requestBody.contentType().toString())
.build()
private fun String?.parseDate(): Long { private fun String?.parseDate(): Long {
return runCatching { return runCatching {
dateFormat.parse(this!!.trim())!!.time dateFormat.parse(this!!.trim())!!.time
}.getOrDefault(0L) }.getOrDefault(0L)
} }
private inline fun <reified T : BrowseResponse> browseMangaParse(response: Response): MangasPage {
val res = response.parseAs<Data<T>>()
val mangas = res.data.chapters.edges
val dateMap = preference.dateMap
val useShortTitle = preference.shortTitle
val entries = mangas.map { manga ->
manga.uploadDate?.let { dateMap[manga.id] = it }
manga.toSManga(useShortTitle)
}
preference.dateMap = dateMap
val hasNextPage = mangas.size == size
return MangasPage(entries, hasNextPage)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = PREF_IMG_QUALITY_KEY key = PREF_IMG_QUALITY_KEY
@ -271,6 +278,14 @@ open class NineNineNineHentai(
setDefaultValue("original") setDefaultValue("original")
summary = "%s" summary = "%s"
}.also(screen::addPreference) }.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_SHORT_TITLE
title = "Display Short Titles"
summaryOff = "Showing Long Titles"
summaryOn = "Showing short Titles"
setDefaultValue(false)
}.also(screen::addPreference)
} }
private var SharedPreferences.dateMap: MutableMap<String, String> private var SharedPreferences.dateMap: MutableMap<String, String>
@ -287,6 +302,16 @@ open class NineNineNineHentai(
.commit() .commit()
} }
private var SharedPreferences.cdnUrl: String
get() = getString(PREF_CDN_URL, DEFAULT_CDN) ?: DEFAULT_CDN
@SuppressLint("ApplySharedPref")
set(cdnUrl) {
edit().putString(PREF_CDN_URL, cdnUrl).commit()
}
private val SharedPreferences.shortTitle get() = getBoolean(PREF_SHORT_TITLE, false)
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not Used") override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not Used")
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used") override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used")
@ -300,6 +325,10 @@ open class NineNineNineHentai(
} }
private const val PREF_DATE_MAP_KEY = "pref_date_map" private const val PREF_DATE_MAP_KEY = "pref_date_map"
private const val PREF_CDN_URL = "pref_cdn_url"
private const val PREF_IMG_QUALITY_KEY = "pref_image_quality" private const val PREF_IMG_QUALITY_KEY = "pref_image_quality"
private const val PREF_SHORT_TITLE = "pref_short_title"
private const val DEFAULT_CDN = "edge.fast4speed.rsvp"
} }
} }

View File

@ -6,10 +6,6 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.Locale import java.util.Locale
typealias ApiPopularResponse = Data<PopularResponse>
typealias ApiSearchResponse = Data<SearchResponse>
typealias ApiDetailsResponse = Data<DetailsResponse> typealias ApiDetailsResponse = Data<DetailsResponse>
typealias ApiPageListResponse = Data<PageList> typealias ApiPageListResponse = Data<PageList>
@ -20,15 +16,19 @@ data class Data<T>(val data: T)
@Serializable @Serializable
data class Edges<T>(val edges: List<T>) data class Edges<T>(val edges: List<T>)
interface BrowseResponse {
val chapters: Edges<ChapterResponse>
}
@Serializable @Serializable
data class PopularResponse( data class PopularResponse(
@SerialName("queryPopularChapters") val popular: Edges<ChapterResponse>, @SerialName("queryPopularChapters") override val chapters: Edges<ChapterResponse>,
) ) : BrowseResponse
@Serializable @Serializable
data class SearchResponse( data class SearchResponse(
@SerialName("queryChapters") val search: Edges<ChapterResponse>, @SerialName("queryChapters") override val chapters: Edges<ChapterResponse>,
) ) : BrowseResponse
@Serializable @Serializable
data class DetailsResponse( data class DetailsResponse(
@ -41,24 +41,27 @@ data class ChapterResponse(
val name: String, val name: String,
val uploadDate: String? = null, val uploadDate: String? = null,
val format: String? = null, val format: String? = null,
val description: String? = null,
val language: String? = null, val language: String? = null,
val pages: Int? = null, val pages: Int? = null,
@SerialName("firstPics") val cover: List<Url>? = emptyList(), @SerialName("firstPics") val cover: List<Url>? = emptyList(),
val tags: List<Tag>? = emptyList(), val tags: List<Tag>? = emptyList(),
) { ) {
fun toSManga() = SManga.create().apply { fun toSManga(shortTitle: Boolean) = SManga.create().apply {
url = id url = id
title = name title = if (shortTitle) name.replace(shortenTitleRegex, "").trim() else name
thumbnail_url = cover?.firstOrNull()?.absUrl thumbnail_url = cover?.firstOrNull()?.absUrl
author = this@ChapterResponse.author author = this@ChapterResponse.author
artist = author artist = author
genre = genres genre = genres
description = buildString { description = buildString {
if (!this@ChapterResponse.description.isNullOrEmpty()) append(this@ChapterResponse.description.trim(), "\n\n")
if (formatParsed != null) append("Format: ${formatParsed}\n") if (formatParsed != null) append("Format: ${formatParsed}\n")
if (languageParsed != null) append("Language: $languageParsed\n") if (languageParsed != null) append("Language: $languageParsed\n")
if (group != null) append("Group: $group\n") if (group != null) append("Group: $group\n")
if (characters != null) append("Character(s): $characters\n") if (characters != null) append("Character(s): $characters\n")
if (parody != null) append("Parody: $parody\n") if (parody != null) append("Parody: $parody\n")
if (magazine != null) append("Magazine: $magazine\n")
if (pages != null) append("Pages: $pages\n") if (pages != null) append("Pages: $pages\n")
} }
status = SManga.COMPLETED status = SManga.COMPLETED
@ -69,6 +72,7 @@ data class ChapterResponse(
private val formatParsed = when (format) { private val formatParsed = when (format) {
"artistcg" -> "ArtistCG" "artistcg" -> "ArtistCG"
"gamecg" -> "GameCG" "gamecg" -> "GameCG"
"imageset" -> "ImageSet"
else -> format?.capitalize() else -> format?.capitalize()
} }
@ -94,12 +98,17 @@ data class ChapterResponse(
?.joinToString { it.tagName.capitalize() } ?.joinToString { it.tagName.capitalize() }
?.takeUnless { it.isEmpty() } ?.takeUnless { it.isEmpty() }
private val magazine = tags?.filter { it.tagType == "magazine" }
?.joinToString { it.tagName.capitalize() }
?.takeUnless { it.isEmpty() }
private val genres = tags?.filterNot { it.tagType in filterTags } private val genres = tags?.filterNot { it.tagType in filterTags }
?.joinToString { it.tagName.capitalize() } ?.joinToString { it.tagName.capitalize() }
?.takeUnless { it.isEmpty() } ?.takeUnless { it.isEmpty() }
companion object { companion object {
private val filterTags = listOf("artist", "group", "character", "parody") private val filterTags = listOf("artist", "group", "character", "parody", "magazine")
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private fun String.capitalize(): String { private fun String.capitalize(): String {
return this.trim().split(" ").joinToString(" ") { word -> return this.trim().split(" ").joinToString(" ") { word ->
@ -122,15 +131,15 @@ data class Url(val url: String) {
val absUrl get() = url.toAbsUrl() val absUrl get() = url.toAbsUrl()
companion object { companion object {
fun String.toAbsUrl(): String { fun String.toAbsUrl(baseUrl: String = loUrl): String {
return if (this.matches(urlRegex)) { return if (this.matches(urlRegex)) {
this this
} else { } else {
cdnUrl + this baseUrl + this
} }
} }
private const val cdnUrl = "https://edge.fast4speed.rsvp/" private const val loUrl = "https://127.0.0.1/"
private val urlRegex = Regex("^https?://.*") private val urlRegex = Regex("^https?://.*")
} }
} }
@ -148,6 +157,7 @@ data class PageList(
@Serializable @Serializable
data class PageUrl( data class PageUrl(
@SerialName("_id") val id: String,
@SerialName("pictureUrls") val pages: List<Pages?>? = emptyList(), @SerialName("pictureUrls") val pages: List<Pages?>? = emptyList(),
) )

View File

@ -31,7 +31,6 @@ class SortFilter : SelectFilter(
arrayOf( arrayOf(
Pair("Update", ""), Pair("Update", ""),
Pair("Popular", "Popular"), Pair("Popular", "Popular"),
Pair("Top", "Top"),
Pair("Name Ascending", "Name_ASC"), Pair("Name Ascending", "Name_ASC"),
Pair("Name Descending", "Name_DESC"), Pair("Name Descending", "Name_DESC"),
), ),
@ -45,6 +44,7 @@ class FormatFilter : SelectFilter(
Pair("Doujinshi", "doujinshi"), Pair("Doujinshi", "doujinshi"),
Pair("ArtistCG", "artistcg"), Pair("ArtistCG", "artistcg"),
Pair("GameCG", "gamecg"), Pair("GameCG", "gamecg"),
Pair("ImageSet", "imageset"),
), ),
) )

View File

@ -25,6 +25,7 @@ val POPULAR_QUERY: String = buildQuery {
name name
uploadDate uploadDate
format format
description
language language
pages pages
firstPics firstPics
@ -52,6 +53,7 @@ val SEARCH_QUERY: String = buildQuery {
name name
uploadDate uploadDate
format format
description
language language
pages pages
firstPics firstPics
@ -74,6 +76,7 @@ val DETAILS_QUERY: String = buildQuery {
name name
uploadDate uploadDate
format format
description
language language
pages pages
firstPics firstPics
@ -91,6 +94,7 @@ val PAGES_QUERY: String = buildQuery {
queryChapter( queryChapter(
chapterId: %id chapterId: %id
) { ) {
_id
pictureUrls { pictureUrls {
picCdn picCdn
pics pics