diff --git a/src/pt/remangas/build.gradle.kts b/src/pt/remangas/build.gradle.kts index 7ab30926308..60c39472b07 100644 --- a/src/pt/remangas/build.gradle.kts +++ b/src/pt/remangas/build.gradle.kts @@ -6,13 +6,13 @@ plugins { keiyoushi { name = "NoxManga" - versionCode = 53 - contentWarning = ContentWarning.SAFE - libVersion = "1.4" + versionCode = 54 + contentWarning = ContentWarning.MIXED + libVersion = "1.6" source { lang = "pt-BR" - baseUrl = "https://noxtoons.com" + baseUrl = "https://noxmangas.org" id = 7462657023971681136 } } diff --git a/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Dto.kt b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Dto.kt index 252c91e05d9..748dc347ba9 100644 --- a/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Dto.kt +++ b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Dto.kt @@ -1,53 +1,133 @@ package eu.kanade.tachiyomi.extension.pt.remangas +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import keiyoushi.utils.tryParse import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonNames -import java.text.SimpleDateFormat -import java.util.Locale +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.time.Instant @Serializable -class PageableDto( - @JsonNames("chapters", "comics") - val list: List, - private val page: Long, - @SerialName("total_pages") - private val totalPages: Long = 0, +class MangaListDto( + val comics: List = emptyList(), + private val page: Int = 1, + @SerialName("total_pages") private val totalPages: Int = 1, ) { - fun hasNextPage() = page < totalPages + val hasNextPage get() = page < totalPages } @Serializable class MangaDto( val slug: String, - val title: String, - val cover: String? = null, + private val title: String, + @SerialName("title_alt") private val alternativeTitles: List? = null, + private val synopsis: String? = null, + private val cover: String? = null, + private val type: String? = null, + private val status: String? = null, + private val genres: List = emptyList(), ) { fun toSManga() = SManga.create().apply { + url = "/manga/$slug" title = this@MangaDto.title thumbnail_url = cover - url = "/manga/$slug" + genre = (listOfNotNull(type?.replaceFirstChar(Char::uppercase)) + genres.map(GenreDto::name)).joinToString() + status = when (this@MangaDto.status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + "hiatus" -> SManga.ON_HIATUS + "cancelled" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + description = buildString { + synopsis?.takeIf(String::isNotBlank)?.let(::appendLine) + alternativeTitles?.filter(String::isNotBlank)?.takeIf(List::isNotEmpty)?.let { + appendLine() + append("Títulos alternativos: ", it.joinToString()) + } + }.trim() + } +} + +@Serializable +class GenreDto( + val name: String, + val slug: String = "", +) + +@Serializable +class GenreListDto( + val data: List = emptyList(), +) + +@Serializable +class ChapterUpdateListDto( + private val data: List = emptyList(), + private val page: Int = 1, + @SerialName("total_pages") private val totalPages: Int = 1, +) { + val mangas get() = data.distinctBy(ChapterUpdateDto::comicSlug).map(ChapterUpdateDto::toSManga) + val hasNextPage get() = page < totalPages +} + +@Serializable +class ChapterUpdateDto( + @SerialName("comic_slug") val comicSlug: String, + @SerialName("comic_title") private val comicTitle: String, + @SerialName("comic_cover") private val comicCover: String? = null, +) { + fun toSManga() = SManga.create().apply { + url = "/manga/$comicSlug" + title = comicTitle + thumbnail_url = comicCover } } +@Serializable +class ChapterListDto( + val chapters: List = emptyList(), +) + @Serializable class ChapterDto( - val number: Float, - val slug: String, - @SerialName("created_at") - val createdAt: String, + private val id: String, + private val number: Float, + private val slug: String, + private val title: String? = null, + @SerialName("published_at") private val publishedAt: String? = null, ) { fun toSChapter(mangaSlug: String) = SChapter.create().apply { - name = "Capítulo ${number.toString().replace(".0", "")}" + url = id + name = buildString { + append("Capítulo ", number.formatted()) + title?.trim() + ?.takeIf { it.isNotEmpty() && it != number.formatted() && !it.startsWith("Capítulo", ignoreCase = true) } + ?.let { append(" - ", it) } + } chapter_number = number - url = "/ler/$mangaSlug/$slug" - date_upload = dateFormat.tryParse(createdAt) + date_upload = Instant.tryParse(publishedAt) + memo = buildJsonObject { + put("mangaSlug", mangaSlug) + put("slug", slug) + } } - companion object { - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT) - } + private fun Float.formatted(): String = toString().removeSuffix(".0") } + +@Serializable +class ChapterPagesDto( + private val pages: List = emptyList(), +) { + fun toPageList() = pages.sortedBy(PageDto::number) + .mapIndexed { index, page -> Page(index, imageUrl = page.imageUrl) } +} + +@Serializable +class PageDto( + val number: Int = 0, + @SerialName("image_url") val imageUrl: String, +) diff --git a/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Filters.kt b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Filters.kt new file mode 100644 index 00000000000..778fc252214 --- /dev/null +++ b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/Filters.kt @@ -0,0 +1,94 @@ +package eu.kanade.tachiyomi.extension.pt.remangas + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UrlFilter { + fun addToUrl(builder: HttpUrl.Builder) +} + +open class SelectFilter( + name: String, + private val parameter: String, + private val options: List>, +) : Filter.Select(name, options.map { it.first }.toTypedArray()), + UrlFilter { + val selectedValue get() = options[state].second + + override fun addToUrl(builder: HttpUrl.Builder) { + selectedValue.takeIf(String::isNotEmpty)?.let { builder.addQueryParameter(parameter, it) } + } +} + +class SortFilter : + SelectFilter( + "Ordenar por", + "sort", + listOf( + "Populares" to "popular", + "Atualizados recentemente" to "latest", + "Adicionados recentemente" to "newest", + "Melhor avaliados" to "rating", + "Mais seguidos" to "followers", + "Mais vistos" to "views", + ), + ) + +class TypeFilter : + SelectFilter( + "Tipo", + "type", + listOf( + "Todos" to "", + "Mangá" to "manga", + "Manhwa" to "manhwa", + "Manhua" to "manhua", + "Webtoon" to "webtoon", + "Pornhwa" to "pornhwa", + "Novel" to "novel", + ), + ) + +class StatusFilter : + SelectFilter( + "Status", + "status", + listOf( + "Todos" to "", + "Em andamento" to "ongoing", + "Completo" to "completed", + "Hiato" to "hiatus", + "Cancelado" to "cancelled", + ), + ) + +class DemographicFilter : + SelectFilter( + "Demografia", + "demographic", + listOf( + "Todas" to "", + "Shounen" to "shounen", + "Seinen" to "seinen", + "Shoujo" to "shoujo", + "Josei" to "josei", + ), + ) + +class ContentFilter : + SelectFilter( + "Conteúdo", + "adult", + listOf( + "Todos" to "", + "Sem conteúdo adulto" to "false", + "Apenas conteúdo adulto" to "true", + ), + ) + +class GenreFilter(genres: List) : + SelectFilter( + "Gênero", + "genre", + listOf("Todos" to "") + genres.map { it.name to it.slug }, + ) diff --git a/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/NoxManga.kt b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/NoxManga.kt index 03fed19e6dd..a4d321c0660 100644 --- a/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/NoxManga.kt +++ b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/NoxManga.kt @@ -1,124 +1,153 @@ package eu.kanade.tachiyomi.extension.pt.remangas -import eu.kanade.tachiyomi.network.GET 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.HttpSource -import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.source.model.SMangaUpdate import keiyoushi.annotation.Source +import keiyoushi.network.get import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource import keiyoushi.utils.parseAs +import keiyoushi.utils.stringOrNull +import keiyoushi.utils.toJsonElement +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.JsonElement import okhttp3.Headers +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response import kotlin.time.Duration.Companion.seconds @Source -abstract class NoxManga : HttpSource() { +abstract class NoxManga : KeiSource() { - override val supportsLatest: Boolean = true + private val apiUrl get() = "$baseUrl/api/v1" - override val client: OkHttpClient = network.client.newBuilder() - .rateLimit(3, 1.seconds) - .build() - - private val apiUrl: String = "https://xodneo.site/api/v1/comics" + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = apply { + addInterceptor(SignatureInterceptor { baseUrl }) + rateLimit(3, 1.seconds) { it.encodedPath.startsWith("/api/") } + } - override fun headersBuilder(): Headers.Builder = super.headersBuilder() - .set("Origin", baseUrl) + override fun Headers.Builder.configureHeaders(): Headers.Builder = add("Sec-Fetch-Site", "same-origin") - // ====================== Popular ==================================== + override suspend fun getPopularManga(page: Int): MangasPage = getMangaList(page) - override fun popularMangaRequest(page: Int): Request { - val url = apiUrl.toHttpUrl().newBuilder() - .addQueryParameter("per_page", "24") - .addQueryParameter("sort", "popular") - .addQueryParameter("period", "week") + override suspend fun getLatestUpdates(page: Int): MangasPage { + val url = "$apiUrl/chapters/recent".toHttpUrl().newBuilder() .addQueryParameter("page", page.toString()) + .addQueryParameter("per_page", PAGE_SIZE.toString()) + .addQueryParameter("unique", "true") + .addQueryParameter("sort", "new") .build() - return GET(url, headers) - } - override fun popularMangaParse(response: Response): MangasPage { - val dto = response.parseAs>() - val mangas = dto.list.map(MangaDto::toSManga) - return MangasPage(mangas, hasNextPage = dto.hasNextPage()) + val result = client.get(url).parseAs() + return MangasPage(result.mangas, result.hasNextPage) } - // ====================== Latest ==================================== + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage = getMangaList(page, query, filters) - override fun latestUpdatesRequest(page: Int): Request { - val url = apiUrl.toHttpUrl().newBuilder() - .addQueryParameter("per_page", "24") - .addQueryParameter("sort", "latest") - .addQueryParameter("period", "week") + private suspend fun getMangaList(page: Int, query: String = "", filters: FilterList = FilterList()): MangasPage { + val url = "$apiUrl/comics".toHttpUrl().newBuilder() .addQueryParameter("page", page.toString()) + .addQueryParameter("per_page", PAGE_SIZE.toString()) + .apply { + if (query.isNotBlank()) { + addQueryParameter("q", query) + } + filters.filterIsInstance().forEach { it.addToUrl(this) } + } .build() - return GET(url, headers) - } - override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) + val result = client.get(url).parseAs() + return MangasPage(result.comics.map(MangaDto::toSManga), result.hasNextPage) + } - // ====================== Search ==================================== + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + if (url.pathSegments.firstOrNull() !in MANGA_PATH_SEGMENTS) return null + val slug = url.pathSegments.getOrNull(1)?.takeIf(String::isNotEmpty) ?: return null + val manga = SManga.create().apply { this.url = "/manga/$slug" } - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = "$apiUrl/search".toHttpUrl().newBuilder() - .addQueryParameter("q", query) - .addQueryParameter("page", page.toString()) - .build() - return GET(url, headers) + return fetchMangaUpdate(manga, emptyList(), fetchDetails = true, fetchChapters = false) + .manga + .apply { initialized = true } } - override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) - - // ====================== Details ==================================== - - override fun mangaDetailsParse(response: Response): SManga { - val document = response.asJsoup() - return SManga.create().apply { - title = document.selectFirst(".detail-title")!!.text() - thumbnail_url = document.selectFirst(".detail-cover img")?.absUrl("src") - description = document.selectFirst(".detail-description")?.text() - genre = document.select(".detail-tags a").joinToString { it.text() } - genre = document.select(".detail-tags a").joinToString { it.text() } - document.selectFirst(".status-badge")?.text()?.let { - status = when (it.lowercase()) { - "em andamento" -> SManga.ONGOING - "hiato" -> SManga.ONGOING - "completo" -> SManga.COMPLETED - "cancelado" -> SManga.CANCELLED - else -> SManga.UNKNOWN - } + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = coroutineScope { + val slug = manga.slug + + val details = async { + if (fetchDetails) { + client.get("$apiUrl/comics/slug/$slug").parseAs().toSManga() + } else { + manga } } - } - // ====================== Chapters ==================================== + val chapterList = async { + if (fetchChapters) { + val url = "$apiUrl/comics/slug/$slug/chapters".toHttpUrl().newBuilder() + .addQueryParameter("page", "1") + .addQueryParameter("per_page", CHAPTER_PAGE_SIZE.toString()) + .addQueryParameter("sort", "newest") + .build() + + client.get(url).parseAs().chapters.map { it.toSChapter(slug) } + } else { + chapters + } + } - override fun chapterListRequest(manga: SManga): Request { - val slug = manga.url.substringAfterLast("/") - val url = "$apiUrl/slug/$slug/chapters".toHttpUrl().newBuilder() - .addQueryParameter("page", "1") - .addQueryParameter("per_page", "999") - .addQueryParameter("sort", "newest") - .build() - return GET(url, headers) + SMangaUpdate(manga = details.await(), chapters = chapterList.await()) } - override fun chapterListParse(response: Response): List { - val pathSegments = response.request.url.pathSegments - val dto = response.parseAs>() - return dto.list.map { it.toSChapter(pathSegments[pathSegments.size - 2]) } + override suspend fun getPageList(chapter: SChapter): List = client.get("$apiUrl/chapters/${chapter.url}?skip_view=true") + .parseAs() + .toPageList() + + override val supportsFilterFetching: Boolean get() = true + + override suspend fun fetchFilterData(): JsonElement = client.get("$apiUrl/genres") + .parseAs() + .data + .filter { it.slug.isNotEmpty() } + .toJsonElement() + + override fun getFilterList(data: JsonElement?): FilterList { + val genres = data?.parseAs>() + ?: return FilterList(SortFilter(), TypeFilter(), StatusFilter(), DemographicFilter(), ContentFilter()) + + return FilterList( + SortFilter(), + TypeFilter(), + StatusFilter(), + DemographicFilter(), + ContentFilter(), + GenreFilter(genres), + ) } - override fun pageListParse(response: Response): List = response.asJsoup().select("section > img").mapIndexed { index, element -> - Page(index, imageUrl = element.absUrl("src")) + override fun getChapterUrl(chapter: SChapter): String { + val mangaSlug = chapter.memo["mangaSlug"]?.stringOrNull + val slug = chapter.memo["slug"]?.stringOrNull + if (mangaSlug == null || slug == null) throw Exception("Atualize a lista de capítulos") + + return "$baseUrl/read/$mangaSlug/$slug" } - override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + private val SManga.slug get() = url.substringAfterLast('/') + + companion object { + private const val PAGE_SIZE = 24 + private const val CHAPTER_PAGE_SIZE = 10000 + private val MANGA_PATH_SEGMENTS = listOf("manga", "read", "ler") + } } diff --git a/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/SignatureInterceptor.kt b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/SignatureInterceptor.kt new file mode 100644 index 00000000000..b7ad0462e5d --- /dev/null +++ b/src/pt/remangas/src/eu/kanade/tachiyomi/extension/pt/remangas/SignatureInterceptor.kt @@ -0,0 +1,91 @@ +package eu.kanade.tachiyomi.extension.pt.remangas + +import android.util.Base64 +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.security.MessageDigest + +class SignatureInterceptor(private val baseUrl: () -> String) : Interceptor { + + @Volatile + private var signer: Signer? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!request.url.encodedPath.startsWith(API_PATH)) { + return chain.proceed(request) + } + + val response = chain.proceed(request.signedWith(signer ?: chain.fetchSigner(request))) + if (response.code != 401) { + return response + } + + response.close() + return chain.proceed(request.signedWith(chain.fetchSigner(request))) + } + + private fun Interceptor.Chain.fetchSigner(request: Request): Signer { + val signerRequest = Request.Builder() + .url("${baseUrl()}$SIGNER_PATH") + .headers(request.headers) + .build() + + val script = proceed(signerRequest).use { response -> + if (!response.isSuccessful) { + throw IOException("Não foi possível assinar a requisição (HTTP ${response.code})") + } + response.body.string() + } + + return script.toSigner().also { signer = it } + } + + private fun Request.signedWith(signer: Signer): Request = newBuilder() + .header("X-Site-ID", SITE_ID) + .header("X-Web-Slot", signer.slot) + .header("X-Web-Token", signer.token) + .header("X-Web-Signature", signer.sign(method, url.encodedPath)) + .build() + + private fun String.toSigner(): Signer { + val parts = ARRAY_REGEX.find(this) + ?.groupValues?.get(1) + ?.let { array -> STRING_REGEX.findAll(array).map { it.groupValues[1].reversed() }.toList() } + val slotIndex = SLOT_REGEX.find(this)?.groupValues?.get(1)?.toIntOrNull() + val keyBounds = KEY_REGEX.find(this)?.destructured?.let { (start, end) -> start.toInt() to end.toInt() } + val tokenStart = TOKEN_REGEX.find(this)?.groupValues?.get(1)?.toIntOrNull() + + if (parts == null || slotIndex == null || keyBounds == null || tokenStart == null) { + throw IOException("Falha ao interpretar o assinador do site") + } + + return Signer( + slot = parts[slotIndex], + key = parts.subList(keyBounds.first, keyBounds.second).joinToString(""), + token = parts.subList(tokenStart, parts.size).joinToString(""), + ) + } + + private class Signer(val slot: String, val key: String, val token: String) { + fun sign(method: String, path: String): String { + val payload = listOf(method.uppercase(), path, SITE_ID, slot, token, key).joinToString("|") + val digest = MessageDigest.getInstance("SHA-256").digest(payload.toByteArray()) + return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + } + + companion object { + private const val API_PATH = "/api/v1/" + private const val SIGNER_PATH = "/_nix/signer.js" + private const val SITE_ID = "00000000-0000-0000-0000-000000000003" + + private val ARRAY_REGEX = """z\s*=\s*\[(.*?)]""".toRegex() + private val STRING_REGEX = """"([^"]*)"""".toRegex() + private val SLOT_REGEX = """=\s*r\(z\[(\d+)]\)""".toRegex() + private val KEY_REGEX = """k\s*=\s*j\(z\.slice\((\d+)\s*,\s*(\d+)\)\)""".toRegex() + private val TOKEN_REGEX = """t\s*=\s*j\(z\.slice\((\d+)\)\)""".toRegex() + } +}