Novelcool (#17430)
* Novelcool very shit source * use better selector to filter novels * fix genre fetching in search * add parsing logic for popular/latest as well in case they break the app api
This commit is contained in:
parent
ae577ed610
commit
562e7fa256
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.kanade.tachiyomi.extension"/>
|
|
@ -0,0 +1,13 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'NovelCool'
|
||||
pkgNameSuffix = 'all.novelcool'
|
||||
extClass = '.NovelCoolFactory'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
|
@ -0,0 +1,455 @@
|
|||
package eu.kanade.tachiyomi.extension.all.novelcool
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
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.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Elements
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
open class NovelCool(
|
||||
final override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
private val siteLang: String = lang,
|
||||
) : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
override val name = "NovelCool"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val apiUrl = "https://api.novelcool.com"
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1)
|
||||
.build()
|
||||
|
||||
private val pageClient by lazy {
|
||||
client.newBuilder()
|
||||
.addInterceptor(::jsRedirect)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preference by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return when (preference.useAppApi) {
|
||||
true -> client.newCall(commonApiRequest("$apiUrl/elite/hot/", page))
|
||||
.asObservableSuccess()
|
||||
.map(::commonApiResponseParse)
|
||||
else -> super.fetchPopularManga(page)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
// popular on the site only have novels
|
||||
return GET("$baseUrl/category/new_list.html", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
runCatching { fetchGenres() }
|
||||
|
||||
return super.popularMangaParse(response)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
|
||||
|
||||
override fun popularMangaSelector() = searchMangaSelector()
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return when (preference.useAppApi) {
|
||||
true -> client.newCall(commonApiRequest("$apiUrl/elite/latest/", page))
|
||||
.asObservableSuccess()
|
||||
.map(::commonApiResponseParse)
|
||||
else -> super.fetchLatestUpdates(page)
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/category/latest.html", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
runCatching { fetchGenres() }
|
||||
|
||||
return super.latestUpdatesParse(response)
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesSelector() = searchMangaSelector()
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when (preference.useAppApi) {
|
||||
true -> client.newCall(commonApiRequest("$apiUrl/book/search/", page, query))
|
||||
.asObservableSuccess()
|
||||
.map(::popularMangaParse)
|
||||
else -> super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("name", query.trim())
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is AuthorFilter -> {
|
||||
addQueryParameter("author", filter.state.trim())
|
||||
}
|
||||
is GenreFilter -> {
|
||||
addQueryParameter("category_id", filter.included.joinToString(",", ","))
|
||||
addQueryParameter("out_category_id", filter.excluded.joinToString(",", ","))
|
||||
}
|
||||
is StatusFilter -> {
|
||||
addQueryParameter("completed_series", filter.getValue())
|
||||
}
|
||||
is RatingFilter -> {
|
||||
addQueryParameter("rate_star", filter.getValue())
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
addQueryParameter("page", page.toString())
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = Jsoup.parse(response.peekBody(Long.MAX_VALUE).string())
|
||||
runCatching { fetchGenres(document) }
|
||||
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
title = element.select(".book-pic").attr("title")
|
||||
setUrlWithoutDomain(element.select("a").attr("href"))
|
||||
thumbnail_url = element.select("img").imgAttr()
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = ".book-list .book-item:not(:has(.book-type-novel))"
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div.page-nav a div.next"
|
||||
|
||||
private class AuthorFilter(title: String) : Filter.Text(title)
|
||||
|
||||
private class GenreFilter(title: String, genres: List<Pair<String, String>>) :
|
||||
Filter.Group<Genre>(title, genres.map { Genre(it.first, it.second) }) {
|
||||
val included: List<String>
|
||||
get() = state.filter { it.isIncluded() }.map { it.id }
|
||||
|
||||
val excluded: List<String>
|
||||
get() = state.filter { it.isExcluded() }.map { it.id }
|
||||
}
|
||||
class Genre(name: String, val id: String) : Filter.TriState(name)
|
||||
|
||||
private fun getStatusList() = listOf(
|
||||
Pair("All", ""),
|
||||
Pair("Completed", "YES"),
|
||||
Pair("Ongoing", "NO"),
|
||||
)
|
||||
|
||||
private class StatusFilter(title: String, private val status: List<Pair<String, String>>) :
|
||||
Filter.Select<String>(title, status.map { it.first }.toTypedArray()) {
|
||||
fun getValue() = status[state].second
|
||||
}
|
||||
|
||||
private fun getRatingList() = listOf(
|
||||
Pair("All", ""),
|
||||
Pair("5 Star", "5"),
|
||||
Pair("4 Star", "4"),
|
||||
Pair("3 Star", "3"),
|
||||
Pair("2 Star", "2"),
|
||||
)
|
||||
private class RatingFilter(title: String, private val ratings: List<Pair<String, String>>) :
|
||||
Filter.Select<String>(title, ratings.map { it.first }.toTypedArray()) {
|
||||
fun getValue() = ratings[state].second
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
if (preference.useAppApi) {
|
||||
return FilterList(Filter.Header("Not supported when using App API"))
|
||||
}
|
||||
|
||||
val filters: MutableList<Filter<*>> = mutableListOf(
|
||||
AuthorFilter("Author"),
|
||||
StatusFilter("Status", getStatusList()),
|
||||
RatingFilter("Rating", getRatingList()),
|
||||
)
|
||||
|
||||
filters += if (genresList.isNotEmpty()) {
|
||||
listOf(
|
||||
GenreFilter("Genres", genresList),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Press 'Reset' to attempt to show the genres"),
|
||||
)
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
private var fetchGenresAttempts = 0
|
||||
private var fetchGenresFailed = false
|
||||
private var genresList: List<Pair<String, String>> = emptyList()
|
||||
|
||||
private fun fetchGenres(document: Document? = null) {
|
||||
if (fetchGenresAttempts < 3 && (genresList.isEmpty() || fetchGenresFailed) && !preference.useAppApi) {
|
||||
val genres = runCatching {
|
||||
if (document == null) {
|
||||
client.newCall(genresRequest()).execute()
|
||||
.use { parseGenres(it.asJsoup()) }
|
||||
} else {
|
||||
parseGenres(document)
|
||||
}
|
||||
}
|
||||
|
||||
fetchGenresFailed = genres.isFailure
|
||||
genresList = genres.getOrNull().orEmpty()
|
||||
fetchGenresAttempts++
|
||||
}
|
||||
}
|
||||
|
||||
private fun genresRequest(): Request {
|
||||
return GET("$baseUrl/search/", headers)
|
||||
}
|
||||
|
||||
private fun parseGenres(document: Document): List<Pair<String, String>> {
|
||||
return document.selectFirst(".category-list")
|
||||
?.select(".category-id-item")
|
||||
.orEmpty()
|
||||
.map { div ->
|
||||
Pair(
|
||||
div.attr("title"),
|
||||
div.attr("cate_id"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
title = document.selectFirst("h1.bookinfo-title")!!.text()
|
||||
description = document.selectFirst("div.bk-summary-txt")?.text()
|
||||
genre = document.select(".bookinfo-category-list a").joinToString { it.text() }
|
||||
author = document.selectFirst(".bookinfo-author > a")?.attr("title")
|
||||
thumbnail_url = document.selectFirst(".bookinfo-pic-img")?.attr("abs:src")
|
||||
status = document.select(".bookinfo-category-list a").first()?.text().parseStatus()
|
||||
}
|
||||
|
||||
private fun String?.parseStatus(): Int {
|
||||
this ?: return SManga.UNKNOWN
|
||||
return when {
|
||||
this.lowercase() in completedStatusList -> SManga.COMPLETED
|
||||
this.lowercase() in ongoingStatusList -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".chapter-item-list a"
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
name = element.attr("title")
|
||||
date_upload = element.select(".chapter-item-time").text().parseDate()
|
||||
}
|
||||
|
||||
private fun String.parseDate(): Long {
|
||||
return runCatching { DATE_FORMATTER.parse(this)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return pageClient.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map(::pageListParse)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return super.pageListRequest(chapter).newBuilder()
|
||||
.addHeader("Referer", baseUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val script = document.select("script:containsData(all_imgs_url)").html()
|
||||
|
||||
val images = imgRegex.find(script)?.groupValues?.get(1)
|
||||
?.let { json.decodeFromString<List<String>>("[$it]") }
|
||||
?: return singlePageParse(document)
|
||||
|
||||
return images.mapIndexed { idx, img ->
|
||||
Page(idx, "", img)
|
||||
}
|
||||
}
|
||||
|
||||
private fun singlePageParse(document: Document): List<Page> {
|
||||
return document.selectFirst(".mangaread-pagenav > .sl-page")?.select("option")
|
||||
?.mapIndexed { idx, page ->
|
||||
Page(idx, page.attr("value"))
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
return document.select(".mangaread-manga-pic").attr("src")
|
||||
}
|
||||
|
||||
private fun Elements.imgAttr(): String {
|
||||
return when {
|
||||
hasAttr("lazy_url") -> attr("abs:lazy_url")
|
||||
else -> attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_API_SEARCH
|
||||
title = "Use App API for browse"
|
||||
summary = "Results may be more reliable"
|
||||
setDefaultValue(true)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private val SharedPreferences.useAppApi: Boolean
|
||||
get() = getBoolean(PREF_API_SEARCH, true)
|
||||
|
||||
private fun jsRedirect(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val document = Jsoup.parse(response.peekBody(Long.MAX_VALUE).string())
|
||||
val jsRedirect = document.selectFirst("script:containsData(window.location.href)")?.html()
|
||||
?.substringAfter("\"")
|
||||
?.substringBefore("\"")
|
||||
?: return response
|
||||
|
||||
val requestUrl = response.request.url
|
||||
|
||||
val url = "${requestUrl.scheme}://${requestUrl.host}$jsRedirect".toHttpUrlOrNull()
|
||||
?: return response
|
||||
|
||||
response.close()
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.add("Referer", requestUrl.toString())
|
||||
.build()
|
||||
|
||||
return chain.proceed(
|
||||
request.newBuilder()
|
||||
.url(url)
|
||||
.headers(newHeaders)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun commonApiRequest(url: String, page: Int, query: String? = null): Request {
|
||||
val payload = NovelCoolBrowsePayload(
|
||||
appId = appId,
|
||||
lang = siteLang,
|
||||
query = query,
|
||||
type = "manga",
|
||||
page = page.toString(),
|
||||
size = size.toString(),
|
||||
secret = appSecret,
|
||||
)
|
||||
|
||||
val body = json.encodeToString(payload)
|
||||
.toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiHeaders = headersBuilder()
|
||||
.add("Content-Length", body.contentLength().toString())
|
||||
.add("Content-Type", body.contentType().toString())
|
||||
.build()
|
||||
|
||||
return POST(url, apiHeaders, body)
|
||||
}
|
||||
|
||||
private fun commonApiResponseParse(response: Response): MangasPage {
|
||||
runCatching { fetchGenres() }
|
||||
|
||||
val browse = json.decodeFromString<NovelCoolBrowseResponse>(response.body.string())
|
||||
|
||||
val hasNextPage = browse.list?.size == size
|
||||
|
||||
return browse.list?.map {
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(it.url)
|
||||
title = it.name
|
||||
thumbnail_url = it.cover
|
||||
}
|
||||
}.let { MangasPage(it ?: emptyList(), hasNextPage) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val appId = "202201290625004"
|
||||
private const val appSecret = "c73a8590641781f203660afca1d37ada"
|
||||
private const val size = 20
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH)
|
||||
}
|
||||
private val imgRegex = Regex("""all_imgs_url\s*:\s*\[\s*([^]]*)\s*,\s*]""")
|
||||
|
||||
private const val PREF_API_SEARCH = "pref_use_search_api"
|
||||
|
||||
// copied from Madara
|
||||
private val completedStatusList: Array<String> = arrayOf(
|
||||
"completed",
|
||||
"completo",
|
||||
"completado",
|
||||
"concluído",
|
||||
"concluido",
|
||||
"finalizado",
|
||||
"terminé",
|
||||
"hoàn thành",
|
||||
)
|
||||
|
||||
private val ongoingStatusList: Array<String> = arrayOf(
|
||||
"ongoing", "Продолжается", "updating", "em lançamento", "em lançamento", "em andamento",
|
||||
"em andamento", "en cours", "ativo", "lançando", "Đang Tiến Hành", "devam ediyor",
|
||||
"devam ediyor", "in corso", "in arrivo", "en curso", "en curso", "emision",
|
||||
"curso", "en marcha", "Publicandose", "en emision",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package eu.kanade.tachiyomi.extension.all.novelcool
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NovelCoolBrowsePayload(
|
||||
val appId: String,
|
||||
@SerialName("keyword") val query: String? = null,
|
||||
val lang: String,
|
||||
@SerialName("lc_type") val type: String,
|
||||
val page: String,
|
||||
@SerialName("page_size") val size: String,
|
||||
val secret: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NovelCoolBrowseResponse(
|
||||
val list: List<Manga>? = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val url: String,
|
||||
val name: String,
|
||||
val cover: String,
|
||||
)
|
|
@ -0,0 +1,16 @@
|
|||
package eu.kanade.tachiyomi.extension.all.novelcool
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class NovelCoolFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
NovelCool("https://www.novelcool.com", "en"),
|
||||
NovelCool("https://es.novelcool.com", "es"),
|
||||
NovelCool("https://de.novelcool.com", "de"),
|
||||
NovelCool("https://ru.novelcool.com", "ru"),
|
||||
NovelCool("https://it.novelcool.com", "it"),
|
||||
NovelCool("https://br.novelcool.com", "pt-BR", "br"),
|
||||
NovelCool("https://fr.novelcool.com", "fr"),
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue