diff --git a/src/all/novelcool/AndroidManifest.xml b/src/all/novelcool/AndroidManifest.xml new file mode 100644 index 000000000..a7a6beb78 --- /dev/null +++ b/src/all/novelcool/AndroidManifest.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/src/all/novelcool/build.gradle b/src/all/novelcool/build.gradle new file mode 100644 index 000000000..f1afc77b4 --- /dev/null +++ b/src/all/novelcool/build.gradle @@ -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" \ No newline at end of file diff --git a/src/all/novelcool/res/mipmap-hdpi/ic_launcher.png b/src/all/novelcool/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..1630e0248 Binary files /dev/null and b/src/all/novelcool/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/novelcool/res/mipmap-mdpi/ic_launcher.png b/src/all/novelcool/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..579024f45 Binary files /dev/null and b/src/all/novelcool/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/novelcool/res/mipmap-xhdpi/ic_launcher.png b/src/all/novelcool/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..b47d8315d Binary files /dev/null and b/src/all/novelcool/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/novelcool/res/mipmap-xxhdpi/ic_launcher.png b/src/all/novelcool/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f8b8e8b78 Binary files /dev/null and b/src/all/novelcool/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/novelcool/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/novelcool/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..6b682ecee Binary files /dev/null and b/src/all/novelcool/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/novelcool/res/web_hi_res_512.png b/src/all/novelcool/res/web_hi_res_512.png new file mode 100644 index 000000000..84e3ff782 Binary files /dev/null and b/src/all/novelcool/res/web_hi_res_512.png differ diff --git a/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCool.kt b/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCool.kt new file mode 100644 index 000000000..69fcf926a --- /dev/null +++ b/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCool.kt @@ -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().getSharedPreferences("source_$id", 0x0000) + } + + override fun fetchPopularManga(page: Int): Observable { + 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 { + 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 { + 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>) : + Filter.Group(title, genres.map { Genre(it.first, it.second) }) { + val included: List + get() = state.filter { it.isIncluded() }.map { it.id } + + val excluded: List + 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>) : + Filter.Select(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>) : + Filter.Select(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> = 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> = 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> { + 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> { + 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 { + val script = document.select("script:containsData(all_imgs_url)").html() + + val images = imgRegex.find(script)?.groupValues?.get(1) + ?.let { json.decodeFromString>("[$it]") } + ?: return singlePageParse(document) + + return images.mapIndexed { idx, img -> + Page(idx, "", img) + } + } + + private fun singlePageParse(document: Document): List { + 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(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 = arrayOf( + "completed", + "completo", + "completado", + "concluído", + "concluido", + "finalizado", + "terminé", + "hoàn thành", + ) + + private val ongoingStatusList: Array = 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", + ) + } +} diff --git a/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCoolDto.kt b/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCoolDto.kt new file mode 100644 index 000000000..ab513d2e6 --- /dev/null +++ b/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCoolDto.kt @@ -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? = emptyList(), +) + +@Serializable +data class Manga( + val url: String, + val name: String, + val cover: String, +) diff --git a/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCoolFactory.kt b/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCoolFactory.kt new file mode 100644 index 000000000..1417808cd --- /dev/null +++ b/src/all/novelcool/src/eu/kanade/tachiyomi/extension/all/novelcool/NovelCoolFactory.kt @@ -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 = 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"), + ) +}