fix(nhentaicom): fix image and language loading errors. (#10412)

* fix(nhentaicom): lang code error

* fix(nhentaicom): token parse error

* chore(nhentaicom): update version code

* fix(nhentaicom): fix 403 error

* fix(nhentaicom): Fix HentaiHand image loading when logged in

The token interceptor was incorrectly trying to add a token to image requests, which are not on the source's domain, causing image loading to fail when logged in.

* fix(nhentai): fix null description

* wip: restore code from the crashed rog laptop.

* refactor: use @Serializable

* fix: parse error

* wip: use keiyoushi.utils.parseAs
This commit is contained in:
ipcjs 2025-10-05 04:46:38 +08:00 committed by Draff
parent d4d2785853
commit 6a29aa1afd
Signed by: Draff
GPG Key ID: E8A89F3211677653
5 changed files with 194 additions and 165 deletions

View File

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

View File

@ -0,0 +1,135 @@
@file:Suppress("PrivatePropertyName", "PropertyName")
package eu.kanade.tachiyomi.multisrc.hentaihand
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* Created by ipcjs on 2025/9/23.
*/
@Serializable
class ResponseDto<T>(
val data: T,
val next_page_url: String?,
)
@Serializable
class LoginResponseDto(val auth: AuthDto) {
@Serializable
class AuthDto(val access_token: String)
}
@Serializable
class PageListResponseDto(val images: List<PageDto>) {
fun toPageList() = images.map { Page(it.page, "", it.source_url) }
@Serializable
class PageDto(
val page: Int,
val source_url: String,
)
}
typealias ChapterListResponseDto = List<ChapterDto>
typealias ChapterResponseDto = ChapterDto
@Serializable
class ChapterDto(
private val slug: String,
private val name: String?,
private val added_at: String?,
private val updated_at: String?,
) {
companion object {
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
}
private fun parseDate(date: String?): Long =
if (date == null) {
0
} else if (date.contains("day")) {
Calendar.getInstance().apply {
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
}.timeInMillis
} else {
DATE_FORMAT.parse(date)?.time ?: 0
}
fun toSChapter(slug: String) = SChapter.create().also { chapter ->
chapter.url = "$slug/${this.slug}"
chapter.name = name ?: "Chapter"
chapter.date_upload = parseDate(added_at)
}
fun toSChapter() = SChapter.create().also { chapter ->
chapter.url = slug
chapter.name = "Chapter"
chapter.date_upload = parseDate(updated_at)
chapter.chapter_number = 1f
}
}
typealias MangaDetailsResponseDto = MangaDto
@Serializable
class MangaDto(
private val slug: String,
private val title: String,
private val image_url: String?,
private val artists: List<NameDto>?,
private val authors: List<NameDto>?,
private val tags: List<NameDto>?,
private val relationships: List<NameDto>?,
private val status: String?,
private val alternative_title: String?,
private val groups: List<NameDto>?,
private val description: String?,
private val pages: Int?,
private val category: NameDto?,
private val language: NameDto?,
private val parodies: List<NameDto>?,
private val characters: List<NameDto>?,
) {
fun toSManga() = SManga.create().also { manga ->
manga.url = slug.prependIndent("/en/comic/")
manga.title = title
manga.thumbnail_url = image_url
}
fun toSMangaDetails() = toSManga().also { manga ->
manga.artist = artists?.toNames()
manga.author = authors?.toNames() ?: manga.artist
manga.genre = listOfNotNull(tags, relationships).flatten().toNames()
manga.status = when (status) {
"complete" -> SManga.COMPLETED
"ongoing" -> SManga.ONGOING
"onhold" -> SManga.ONGOING
"canceled" -> SManga.COMPLETED
else -> SManga.COMPLETED
}
manga.description = listOf(
Pair("Alternative Title", alternative_title),
Pair("Groups", groups?.toNames()),
Pair("Description", description),
Pair("Pages", pages?.toString()),
Pair("Category", category?.name),
Pair("Language", language?.name),
Pair("Parodies", parodies?.toNames()),
Pair("Characters", characters?.toNames()),
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
}
}
@Serializable
class NameDto(val name: String)
fun List<NameDto>.toNames() = if (this.isEmpty()) null else this.joinToString { it.name }
@Serializable
class IdDto(val id: String)

View File

@ -16,13 +16,8 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import keiyoushi.utils.parseAs
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
@ -32,10 +27,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
abstract class HentaiHand(
@ -48,32 +41,15 @@ abstract class HentaiHand(
override val supportsLatest = true
private val json: Json by injectLazy()
private fun slugToUrl(json: JsonObject) = json["slug"]!!.jsonPrimitive.content.prependIndent("/en/comic/")
private fun jsonArrayToString(arrayKey: String, obj: JsonObject): String? {
val array = obj[arrayKey]!!.jsonArray
if (array.isEmpty()) return null
return array.joinToString(", ") {
it.jsonObject["name"]!!.jsonPrimitive.content
}
}
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
// Popular
override fun popularMangaParse(response: Response): MangasPage {
val jsonResponse = json.parseToJsonElement(response.body.string())
val mangaList = jsonResponse.jsonObject["data"]!!.jsonArray.map {
val obj = it.jsonObject
SManga.create().apply {
url = slugToUrl(obj)
title = obj["title"]!!.jsonPrimitive.content
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
}
}
val hasNextPage = jsonResponse.jsonObject["next_page_url"]!!.jsonPrimitive.content.isNotEmpty()
return MangasPage(mangaList, hasNextPage)
val resp = response.parseAs<ResponseDto<List<MangaDto>>>()
val hasNextPage = !resp.next_page_url.isNullOrEmpty()
return MangasPage(resp.data.map { it.toSManga() }, hasNextPage)
}
override fun popularMangaRequest(page: Int): Request {
@ -116,9 +92,7 @@ abstract class HentaiHand(
.subscribeOn(Schedulers.io())
.map { response ->
// Returns the first matched id, or null if there are no results
val idList = json.parseToJsonElement(response.body.string()).jsonObject["data"]!!.jsonArray.map {
it.jsonObject["id"]!!.jsonPrimitive.content
}
val idList = response.parseAs<ResponseDto<List<IdDto>>>().data.map { it.id }
if (idList.isEmpty()) {
return@map null
} else {
@ -177,33 +151,7 @@ abstract class HentaiHand(
}
override fun mangaDetailsParse(response: Response): SManga {
val obj = json.parseToJsonElement(response.body.string()).jsonObject
return SManga.create().apply {
url = slugToUrl(obj)
title = obj["title"]!!.jsonPrimitive.content
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
artist = jsonArrayToString("artists", obj)
author = jsonArrayToString("authors", obj) ?: artist
genre = listOfNotNull(jsonArrayToString("tags", obj), jsonArrayToString("relationships", obj)).joinToString(", ")
status = when (obj["status"]!!.jsonPrimitive.content) {
"complete" -> SManga.COMPLETED
"ongoing" -> SManga.ONGOING
"onhold" -> SManga.ONGOING
"canceled" -> SManga.COMPLETED
else -> SManga.COMPLETED
}
description = listOf(
Pair("Alternative Title", obj["alternative_title"]!!.jsonPrimitive.content),
Pair("Groups", jsonArrayToString("groups", obj)),
Pair("Description", obj["description"]!!.jsonPrimitive.content),
Pair("Pages", obj["pages"]!!.jsonPrimitive.content),
Pair("Category", try { obj["category"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
Pair("Language", try { obj["language"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
Pair("Parodies", jsonArrayToString("parodies", obj)),
Pair("Characters", jsonArrayToString("characters", obj)),
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
}
return response.parseAs<MangaDetailsResponseDto>().toSMangaDetails()
}
// Chapters
@ -220,40 +168,13 @@ abstract class HentaiHand(
override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val slug = response.request.url.toString().substringAfter("/api/comics/").removeSuffix("/chapters")
return if (chapters) {
val array = json.parseToJsonElement(response.body.string()).jsonArray
array.map {
SChapter.create().apply {
url = "$slug/${it.jsonObject["slug"]!!.jsonPrimitive.content}"
name = it.jsonObject["name"]!!.jsonPrimitive.content
val date = it.jsonObject["added_at"]!!.jsonPrimitive.content
date_upload = if (date.contains("day")) {
Calendar.getInstance().apply {
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
}.timeInMillis
} else {
DATE_FORMAT.parse(it.jsonObject["added_at"]!!.jsonPrimitive.content)?.time ?: 0
}
}
}
return if (this.chapters) {
val slug = response.request.url.toString()
.substringAfter("/api/comics/")
.removeSuffix("/chapters")
response.parseAs<ChapterListResponseDto>().map { it.toSChapter(slug) }
} else {
val obj = json.parseToJsonElement(response.body.string()).jsonObject
listOf(
SChapter.create().apply {
url = obj["slug"]!!.jsonPrimitive.content
name = "Chapter"
val date = obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content
date_upload = if (date.contains("day")) {
Calendar.getInstance().apply {
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
}.timeInMillis
} else {
DATE_FORMAT.parse(obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content)?.time ?: 0
}
chapter_number = 1f
},
)
listOf(response.parseAs<ChapterResponseDto>().toSChapter())
}
}
@ -264,13 +185,7 @@ abstract class HentaiHand(
return GET("$baseUrl/api/comics/$slug/images")
}
override fun pageListParse(response: Response): List<Page> =
json.parseToJsonElement(response.body.string()).jsonObject["images"]!!.jsonArray.map {
val imgObj = it.jsonObject
val index = imgObj["page"]!!.jsonPrimitive.int
val imgUrl = imgObj["source_url"]!!.jsonPrimitive.content
Page(index, "", imgUrl)
}
override fun pageListParse(response: Response): List<Page> = response.parseAs<PageListResponseDto>().toPageList()
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
@ -278,7 +193,10 @@ abstract class HentaiHand(
protected fun authIntercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (username.isEmpty() or password.isEmpty()) {
if (username.isEmpty() or password.isEmpty()
// image request doesn't need token
or !request.url.toString().startsWith(baseUrl)
) {
return chain.proceed(request)
}
@ -304,7 +222,7 @@ abstract class HentaiHand(
}
try {
// Returns access token as a string, unless unparseable
return json.parseToJsonElement(response.body.string()).jsonObject["auth"]!!.jsonObject["access-token"]!!.jsonPrimitive.content
return response.parseAs<LoginResponseDto>().auth.access_token
} catch (e: IllegalArgumentException) {
throw IOException("Cannot parse login response body")
}

View File

@ -3,7 +3,7 @@ ext {
extClass = '.NHentaiComFactory'
themePkg = 'hentaihand'
baseUrl = 'https://nhentai.com'
overrideVersionCode = 4
overrideVersionCode = 5
isNsfw = true
}

View File

@ -9,39 +9,27 @@ class NHentaiComFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
// https://nhentai.com/api/languages?per_page=50
NHentaiComAll(),
NHentaiComEn(),
NHentaiComZh(),
NHentaiComEn(),
NHentaiComJa(),
NHentaiComNoText(),
NHentaiComEo(),
NHentaiComCeb(),
NHentaiComCs(),
NHentaiComAr(),
NHentaiComSk(),
NHentaiComMn(),
NHentaiComUk(),
NHentaiComLa(),
NHentaiComTl(),
NHentaiComEs(),
NHentaiComIt(),
NHentaiComKo(),
NHentaiComTh(),
NHentaiComPl(),
NHentaiComFr(),
NHentaiComPtBr(),
NHentaiComDe(),
NHentaiComFi(),
NHentaiComRu(),
NHentaiComHu(),
NHentaiComId(),
NHentaiComVi(),
NHentaiComNl(),
NHentaiComTr(),
NHentaiComEl(),
NHentaiComBg(),
NHentaiComSr(),
NHentaiComJv(),
NHentaiComHi(),
NHentaiComBg(),
NHentaiComCs(),
NHentaiComUk(),
NHentaiComSk(),
NHentaiComEo(),
NHentaiComMn(),
NHentaiComLa(),
NHentaiComCeb(),
NHentaiComTl(),
NHentaiComFi(),
NHentaiComTr(),
NHentaiComSr(),
NHentaiComEl(),
NHentaiComKo(),
NHentaiComRo(),
)
}
abstract class NHentaiComCommon(
@ -58,42 +46,30 @@ class NHentaiComAll : NHentaiComCommon("all") {
override val id: Long = 9165839893600661480
}
class NHentaiComJa : NHentaiComCommon("ja", listOf(1, 29))
class NHentaiComEn : NHentaiComCommon("en", listOf(2, 27)) {
class NHentaiComZh : NHentaiComCommon("zh", listOf(1))
class NHentaiComEn : NHentaiComCommon("en", listOf(2)) {
override val id: Long = 5591830863732393712
}
class NHentaiComZh : NHentaiComCommon("zh", listOf(3, 50))
class NHentaiComBg : NHentaiComCommon("bg", listOf(4))
class NHentaiComCeb : NHentaiComCommon("ceb", listOf(5, 44))
class NHentaiComNoText : NHentaiComCommon("other", listOf(6)) {
class NHentaiComJa : NHentaiComCommon("ja", listOf(3))
class NHentaiComNoText : NHentaiComCommon("other", listOf(4)) {
override val id: Long = 5817327335315373850
}
class NHentaiComTl : NHentaiComCommon("tl", listOf(7, 55))
class NHentaiComAr : NHentaiComCommon("ar", listOf(8, 49))
class NHentaiComEl : NHentaiComCommon("el", listOf(9))
class NHentaiComSr : NHentaiComCommon("sr", listOf(10))
class NHentaiComJv : NHentaiComCommon("jv", listOf(11, 51))
class NHentaiComUk : NHentaiComCommon("uk", listOf(12, 46))
class NHentaiComTr : NHentaiComCommon("tr", listOf(13, 41))
class NHentaiComFi : NHentaiComCommon("fi", listOf(14, 54))
class NHentaiComLa : NHentaiComCommon("la", listOf(15))
class NHentaiComMn : NHentaiComCommon("mn", listOf(16))
class NHentaiComEo : NHentaiComCommon("eo", listOf(17, 47))
class NHentaiComSk : NHentaiComCommon("sk", listOf(18))
class NHentaiComCs : NHentaiComCommon("cs", listOf(19, 52)) {
class NHentaiComAr : NHentaiComCommon("ar", listOf(5))
class NHentaiComJv : NHentaiComCommon("jv", listOf(6))
class NHentaiComBg : NHentaiComCommon("bg", listOf(7))
class NHentaiComCs : NHentaiComCommon("cs", listOf(8)) {
override val id: Long = 1144495813995437124
}
class NHentaiComKo : NHentaiComCommon("ko", listOf(30, 39))
class NHentaiComRu : NHentaiComCommon("ru", listOf(31))
class NHentaiComIt : NHentaiComCommon("it", listOf(32))
class NHentaiComEs : NHentaiComCommon("es", listOf(33, 37))
class NHentaiComPtBr : NHentaiComCommon("pt-BR", listOf(34))
class NHentaiComTh : NHentaiComCommon("th", listOf(35, 40))
class NHentaiComFr : NHentaiComCommon("fr", listOf(36))
class NHentaiComId : NHentaiComCommon("id", listOf(38))
class NHentaiComVi : NHentaiComCommon("vi", listOf(42))
class NHentaiComDe : NHentaiComCommon("de", listOf(43))
class NHentaiComPl : NHentaiComCommon("pl", listOf(45))
class NHentaiComHu : NHentaiComCommon("hu", listOf(48))
class NHentaiComNl : NHentaiComCommon("nl", listOf(53))
class NHentaiComHi : NHentaiComCommon("hi", listOf(56))
class NHentaiComUk : NHentaiComCommon("uk", listOf(9))
class NHentaiComSk : NHentaiComCommon("sk", listOf(10))
class NHentaiComEo : NHentaiComCommon("eo", listOf(11))
class NHentaiComMn : NHentaiComCommon("mn", listOf(12))
class NHentaiComLa : NHentaiComCommon("la", listOf(13))
class NHentaiComCeb : NHentaiComCommon("ceb", listOf(14))
class NHentaiComTl : NHentaiComCommon("tl", listOf(15))
class NHentaiComFi : NHentaiComCommon("fi", listOf(16))
class NHentaiComTr : NHentaiComCommon("tr", listOf(17))
class NHentaiComSr : NHentaiComCommon("sr", listOf(18))
class NHentaiComEl : NHentaiComCommon("el", listOf(19))
class NHentaiComKo : NHentaiComCommon("ko", listOf(20))
class NHentaiComRo : NHentaiComCommon("ro", listOf(21))