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") 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.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.json.Json import keiyoushi.utils.parseAs
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject 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 kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
@ -32,10 +27,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale import java.util.Locale
abstract class HentaiHand( abstract class HentaiHand(
@ -48,32 +41,15 @@ abstract class HentaiHand(
override val supportsLatest = true override val supportsLatest = true
private val json: Json by injectLazy() override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
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
}
}
// Popular // Popular
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val jsonResponse = json.parseToJsonElement(response.body.string()) val resp = response.parseAs<ResponseDto<List<MangaDto>>>()
val mangaList = jsonResponse.jsonObject["data"]!!.jsonArray.map { val hasNextPage = !resp.next_page_url.isNullOrEmpty()
val obj = it.jsonObject return MangasPage(resp.data.map { it.toSManga() }, hasNextPage)
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)
} }
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
@ -116,9 +92,7 @@ abstract class HentaiHand(
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.map { response -> .map { response ->
// Returns the first matched id, or null if there are no results // Returns the first matched id, or null if there are no results
val idList = json.parseToJsonElement(response.body.string()).jsonObject["data"]!!.jsonArray.map { val idList = response.parseAs<ResponseDto<List<IdDto>>>().data.map { it.id }
it.jsonObject["id"]!!.jsonPrimitive.content
}
if (idList.isEmpty()) { if (idList.isEmpty()) {
return@map null return@map null
} else { } else {
@ -177,33 +151,7 @@ abstract class HentaiHand(
} }
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
val obj = json.parseToJsonElement(response.body.string()).jsonObject return response.parseAs<MangaDetailsResponseDto>().toSMangaDetails()
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}" }
}
} }
// Chapters // Chapters
@ -220,40 +168,13 @@ abstract class HentaiHand(
override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga) override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val slug = response.request.url.toString().substringAfter("/api/comics/").removeSuffix("/chapters") return if (this.chapters) {
return if (chapters) { val slug = response.request.url.toString()
val array = json.parseToJsonElement(response.body.string()).jsonArray .substringAfter("/api/comics/")
array.map { .removeSuffix("/chapters")
SChapter.create().apply { response.parseAs<ChapterListResponseDto>().map { it.toSChapter(slug) }
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 { } else {
DATE_FORMAT.parse(it.jsonObject["added_at"]!!.jsonPrimitive.content)?.time ?: 0 listOf(response.parseAs<ChapterResponseDto>().toSChapter())
}
}
}
} 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
},
)
} }
} }
@ -264,13 +185,7 @@ abstract class HentaiHand(
return GET("$baseUrl/api/comics/$slug/images") return GET("$baseUrl/api/comics/$slug/images")
} }
override fun pageListParse(response: Response): List<Page> = override fun pageListParse(response: Response): List<Page> = response.parseAs<PageListResponseDto>().toPageList()
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 imageUrlParse(response: Response): String = throw UnsupportedOperationException() override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
@ -278,7 +193,10 @@ abstract class HentaiHand(
protected fun authIntercept(chain: Interceptor.Chain): Response { protected fun authIntercept(chain: Interceptor.Chain): Response {
val request = chain.request() 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) return chain.proceed(request)
} }
@ -304,7 +222,7 @@ abstract class HentaiHand(
} }
try { try {
// Returns access token as a string, unless unparseable // 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) { } catch (e: IllegalArgumentException) {
throw IOException("Cannot parse login response body") throw IOException("Cannot parse login response body")
} }

View File

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

View File

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