WebNovel: Fix cover url and refactor (#6672)

This commit is contained in:
FourTOne5 2024-12-19 17:48:32 +06:00 committed by Draff
parent f03fd3c5f7
commit be9c14bcae
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
3 changed files with 165 additions and 121 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'WebNovel' extName = 'WebNovel'
extClass = '.WebNovel' extClass = '.WebNovel'
extVersionCode = 12 extVersionCode = 13
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -22,7 +22,6 @@ import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale
class WebNovel : HttpSource() { class WebNovel : HttpSource() {
@ -34,7 +33,7 @@ class WebNovel : HttpSource() {
private val baseApiUrl = "$baseUrl$BASE_API_ENDPOINT" private val baseApiUrl = "$baseUrl$BASE_API_ENDPOINT"
private val baseCoverURl = baseUrl.replace("www", "img") private val baseCoverURl = baseUrl.replace("www", "book-pic")
private val baseCdnUrl = baseUrl.replace("www", "comic-image") private val baseCdnUrl = baseUrl.replace("www", "comic-image")
@ -89,60 +88,22 @@ class WebNovel : HttpSource() {
} }
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
val browseResponseDto = if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) { return if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) {
response.parseAsForWebNovel<QuerySearchResponseDto>().browseResponse response.parseAsForWebNovel<QuerySearchResponse>().toMangasPage(::getCoverUrl)
} else { } else {
response.parseAsForWebNovel() response.parseAsForWebNovel<FilterSearchResponse>().toMangasPage(::getCoverUrl)
} }
val manga = browseResponseDto.items.map {
SManga.create().apply {
title = it.name
url = it.id
thumbnail_url = getCoverUrl(it.id)
}
}
return MangasPage(manga, browseResponseDto.isLast == 0)
} }
// Manga details // Manga details
override fun getMangaUrl(manga: SManga): String = "$baseUrl/comic/${manga.getId}" override fun getMangaUrl(manga: SManga): String = "$baseUrl/comic/${manga.getId}"
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response)
}
}
override fun mangaDetailsRequest(manga: SManga): Request { override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$baseApiUrl/comic/getComicDetailPage?comicId=${manga.getId}", headers) return GET("$baseApiUrl/comic/getComicDetailPage?comicId=${manga.getId}", headers)
} }
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
val comic = response.parseAsForWebNovel<ComicDetailInfoResponseDto>().info return response.parseAsForWebNovel<ComicDetailInfoResponse>().toSManga(::getCoverUrl)
return SManga.create().apply {
title = comic.name
url = comic.id
thumbnail_url = getCoverUrl(comic.id)
author = comic.authorName
description = buildString {
append(comic.description)
if (comic.actionStatus == ComicDetailInfoDto.ONGOING && comic.updateCycle.isNotBlank()) {
append("\n\nInformation:")
append("\n${comic.updateCycle.replaceFirstChar { it.uppercase(Locale.ENGLISH) }}")
}
}
genre = comic.categoryName
status = when (comic.actionStatus) {
ComicDetailInfoDto.ONGOING -> SManga.ONGOING
ComicDetailInfoDto.COMPLETED -> SManga.COMPLETED
ComicDetailInfoDto.ON_HIATUS -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
}
} }
// chapters // chapters
@ -151,9 +112,9 @@ class WebNovel : HttpSource() {
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val chapterList = response.parseAsForWebNovel<ComicChapterListDto>() val chapterList = response.parseAsForWebNovel<ComicChapterListResponse>()
val comic = chapterList.comicInfo val comic = chapterList.comic
val chapters = chapterList.comicChapters.reversed().asSequence() val chapters = chapterList.chapters.reversed().asSequence()
val accurateUpdateTimes = runCatching { val accurateUpdateTimes = runCatching {
client.newCall(GET("$WEBNOVEL_UPLOAD_TIME/${comic.id}.json")) client.newCall(GET("$WEBNOVEL_UPLOAD_TIME/${comic.id}.json"))
@ -164,30 +125,19 @@ class WebNovel : HttpSource() {
val updateTimes = chapters.map { accurateUpdateTimes[it.id] ?: it.publishTime.toDate() } val updateTimes = chapters.map { accurateUpdateTimes[it.id] ?: it.publishTime.toDate() }
// You can pay to get some chapter earlier than others. This privilege is divided into some tiers val filteredChapters = chapters.filter { it.isVisible }
// We check if user's tier same or more than chapter's.
val filteredChapters = chapters.filter { it.userLevel >= it.chapterLevel }
// When new privileged chapter is released oldest privileged chapter becomes normal one (in most cases) // When new privileged chapter is released oldest privileged chapter becomes normal one (in most cases)
// but since those normal chapter retain the original upload time we improvise. (This isn't optimal but meh) // but since those normal chapter retain the original upload time we improvise. (This isn't optimal but meh)
return filteredChapters.zip(updateTimes) { chapter, updateTime -> return filteredChapters.zip(updateTimes) { chapter, updateTime ->
val namePrefix = when {
chapter.isPremium && !chapter.isAccessibleByUser -> "\uD83D\uDD12 "
else -> ""
}
SChapter.create().apply { SChapter.create().apply {
name = namePrefix + chapter.name name = if (chapter.isLocked) "\uD83D\uDD12 ${chapter.name}" else chapter.name
url = "${comic.id}:${chapter.id}" url = "${comic.id}:${chapter.id}"
date_upload = updateTime date_upload = updateTime
} }
}.toList() }.toList()
} }
private val ComicChapterDto.isPremium get() = isVip != 0 || price != 0
// This can mean the chapter is free or user has paid to unlock it (check with [isPremium] for this case)
private val ComicChapterDto.isAccessibleByUser get() = isAuth == 1
private fun String.toDate(): Long { private fun String.toDate(): Long {
if (contains("now", ignoreCase = true)) return Date().time if (contains("now", ignoreCase = true)) return Date().time
@ -224,7 +174,7 @@ class WebNovel : HttpSource() {
} }
private fun pageListRequest(comicId: String, chapterId: String): Request { private fun pageListRequest(comicId: String, chapterId: String): Request {
// Given a high [width] parameter it gives the highest resolution image available // Given a high [width] value WebNovel returns the highest resolution image publicly available
return GET("$baseApiUrl/comic/getContent?comicId=$comicId&chapterId=$chapterId&width=9999") return GET("$baseApiUrl/comic/getContent?comicId=$comicId&chapterId=$chapterId&width=9999")
} }
@ -235,14 +185,13 @@ class WebNovel : HttpSource() {
// LinkedHashMap with a capacity of 25. When exceeding the capacity the oldest entry is removed. // LinkedHashMap with a capacity of 25. When exceeding the capacity the oldest entry is removed.
private val chapterPageCache = object : LinkedHashMap<String, List<ChapterPage>>() { private val chapterPageCache = object : LinkedHashMap<String, List<ChapterPage>>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<ChapterPage>>?): Boolean { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<ChapterPage>>?): Boolean {
return size > 25 return size > 25
} }
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val chapterContent = response.parseAsForWebNovel<ChapterContentResponseDto>().chapterContent val chapterContent = response.parseAsForWebNovel<ChapterContentResponse>().data
return chapterContent.pages.map { ChapterPage(it.id, it.url) } return chapterContent.pages.map { ChapterPage(it.id, it.url) }
.also { chapterPageCache[chapterContent.id.toString()] = it } .also { chapterPageCache[chapterContent.id.toString()] = it }
.mapIndexed { i, chapterPage -> Page(i, imageUrl = chapterPage.url) } .mapIndexed { i, chapterPage -> Page(i, imageUrl = chapterPage.url) }
@ -271,8 +220,8 @@ class WebNovel : HttpSource() {
return comicId to chapterId return comicId to chapterId
} }
private fun getCoverUrl(comicId: String): String { private fun getCoverUrl(comicId: String, coverUpdatedAt: Long): String {
return "$baseCoverURl/bookcover/$comicId/0/600.jpg" return "$baseCoverURl/bookcover/$comicId?imageId=$coverUpdatedAt&imageMogr2/thumbnail/1024x"
} }
private fun csrfTokenInterceptor(chain: Interceptor.Chain): Response { private fun csrfTokenInterceptor(chain: Interceptor.Chain): Response {
@ -333,9 +282,9 @@ class WebNovel : HttpSource() {
} }
private inline fun <reified T> Response.parseAsForWebNovel(): T = use { private inline fun <reified T> Response.parseAsForWebNovel(): T = use {
val parsed = parseAs<ResponseDto<T>>() val parsed = parseAs<ResponseWrapper<T>>()
if (parsed.code != 0) error("Error ${parsed.code}: ${parsed.msg}") if (parsed.code != 0) error("Error ${parsed.code}: ${parsed.msg}")
requireNotNull(parsed.data) { "Response data is null" } requireNotNull(parsed.data) { "Received response data was null" }
} }
private inline fun <reified T> List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T private inline fun <reified T> List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T

View File

@ -1,86 +1,181 @@
package eu.kanade.tachiyomi.extension.en.webnovel package eu.kanade.tachiyomi.extension.en.webnovel
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames import kotlinx.serialization.json.JsonNames
import java.util.Locale
@Serializable @Serializable
data class ResponseDto<T>( class ResponseWrapper<T>(
val code: Int, val code: Int,
val data: T?, val data: T?,
val msg: String, val msg: String,
) )
@Serializable @Serializable
data class QuerySearchResponseDto( class QuerySearchResponse(
@SerialName("comicInfo") val browseResponse: BrowseResponseDto, @SerialName("comicInfo") private val response: BrowseResponse<QuerySearchItem>,
)
@Serializable
data class BrowseResponseDto(
val isLast: Int,
@JsonNames("comicItems") val items: List<ComicInfoDto>,
)
@Serializable
data class ComicInfoDto(
@JsonNames("bookId", "comicId") val id: String,
@JsonNames("bookName", "comicName") val name: String,
)
@Serializable
data class ComicDetailInfoResponseDto(
@SerialName("comicInfo") val info: ComicDetailInfoDto,
)
@Serializable
data class ComicDetailInfoDto(
@SerialName("comicId") val id: String,
@SerialName("comicName") val name: String,
val actionStatus: Int,
val authorName: String,
val categoryName: String,
val description: String,
val updateCycle: String,
) { ) {
companion object { fun toMangasPage(coverUrl: (id: String, coverUpdatedAt: Long) -> String): MangasPage {
const val ONGOING = 1 return response.toMangasPage(coverUrl)
const val COMPLETED = 2
const val ON_HIATUS = 3
} }
} }
typealias FilterSearchResponse = BrowseResponse<FilterSearchItem>
@Serializable @Serializable
data class ComicChapterListDto( class BrowseResponse<T : ComicItem>(
val comicInfo: ComicInfoDto, private val isLast: Int,
val comicChapters: List<ComicChapterDto>, @JsonNames("comicItems") private val items: List<T>,
) {
fun toMangasPage(coverUrl: (id: String, coverUpdatedAt: Long) -> String): MangasPage {
return MangasPage(
mangas = items.map { it.toSManga(coverUrl) },
hasNextPage = isLast == 0,
) )
}
}
@Serializable @Serializable
data class ComicChapterDto( class QuerySearchItem(
@SerialName("comicId") private val id: String,
@SerialName("bookName") private val title: String,
@SerialName("categoryName") private val genre: String,
@SerialName("CV") private val coverUpdatedAt: Long,
) : ComicItem {
override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga {
return SManga.create().also {
it.url = id
it.title = title
it.genre = genre
it.thumbnail_url = coverUrl(id, coverUpdatedAt)
}
}
}
@Serializable
class FilterSearchItem(
@SerialName("bookId") private val id: String,
@SerialName("bookName") private val title: String,
@SerialName("authorName") private val author: String,
private val description: String,
@SerialName("categoryName") private val genre: String,
@SerialName("coverUpdateTime") private val coverUpdatedAt: Long,
) : ComicItem {
override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga {
return SManga.create().also {
it.url = id
it.title = title
it.author = author
it.description = description
it.genre = genre
it.thumbnail_url = coverUrl(id, coverUpdatedAt)
}
}
}
@Serializable
class ComicDetailInfoResponse(
@SerialName("comicInfo") private val comic: ComicDetailInfo,
) : ComicItem {
override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga {
return comic.toSManga(coverUrl)
}
}
@Serializable
data class ComicDetailInfo(
@SerialName("comicId") private val id: String,
@SerialName("comicName") private val title: String,
@SerialName("authorName") private val author: String,
private val description: String,
private val updateCycle: String,
@SerialName("categoryName") private val genre: String,
@SerialName("actionStatus") private val status: Int,
@SerialName("CV") private val coverUpdatedAt: Long,
) : ComicItem {
override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga {
return SManga.create().also {
it.url = id
it.title = title
it.author = author
it.description = buildString {
append(description)
if (status == ONGOING && updateCycle.isNotBlank()) {
append("\n\nInformation:")
append("\n${updateCycle.replaceFirstChar { c -> c.uppercase(Locale.ENGLISH) }}")
}
}
it.genre = genre
it.status = when (status) {
ONGOING -> SManga.ONGOING
COMPLETED -> SManga.COMPLETED
ON_HIATUS -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
it.thumbnail_url = coverUrl(id, coverUpdatedAt)
}
}
companion object {
private const val ONGOING = 1
private const val COMPLETED = 2
private const val ON_HIATUS = 3
}
}
@Serializable
class ComicChapterListResponse(
@SerialName("comicInfo") val comic: Comic,
@SerialName("comicChapters") val chapters: List<ComicChapter>,
) {
@Serializable
class Comic(@SerialName("comicId") val id: String)
}
@Serializable
class ComicChapter(
@SerialName("chapterId") val id: String, @SerialName("chapterId") val id: String,
@SerialName("chapterName") val name: String, @SerialName("chapterName") val name: String,
val publishTime: String, val publishTime: String,
val price: Int,
val isVip: Int, private val chapterLevel: Int,
val isAuth: Int, private val userLevel: Int,
val chapterLevel: Int, private val price: Int,
val userLevel: Int, private val isVip: Int,
private val isAuth: Int,
) {
val isLocked = isPremium && !isAccessibleByUser
// You can pay to get some chapter earlier than others. This privilege is divided into some tiers
// We check if user's tier same or more than chapter's.
val isVisible = userLevel >= chapterLevel
private val isPremium: Boolean get() = isVip != 0 || price != 0
// This can mean the chapter is free or user has paid to unlock it (check with [isPremium] for this case)
private val isAccessibleByUser: Boolean get() = isAuth == 1
}
@Serializable
class ChapterContentResponse(
@SerialName("chapterInfo") val data: ChapterContent,
) )
@Serializable @Serializable
data class ChapterContentResponseDto( class ChapterContent(
@SerialName("chapterInfo") val chapterContent: ChapterContentDto,
)
@Serializable
data class ChapterContentDto(
@SerialName("chapterId") val id: Long, @SerialName("chapterId") val id: Long,
@SerialName("chapterPage") val pages: List<ChapterPageDto>, @SerialName("chapterPage") val pages: List<ChapterPage>,
) )
@Serializable @Serializable
data class ChapterPageDto( class ChapterPage(
@SerialName("pageId") val id: String, @SerialName("pageId") val id: String,
val url: String, val url: String,
) )
interface ComicItem {
fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga
}