diff --git a/src/es/lectorjpg/build.gradle b/src/es/lectorjpg/build.gradle index ddf13f00b..99de2b0c9 100644 --- a/src/es/lectorjpg/build.gradle +++ b/src/es/lectorjpg/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'LectorJPG' extClass = '.LectorJpg' - themePkg = 'madara' - baseUrl = 'https://lectorjpg.com' - overrideVersionCode = 0 + extVersionCode = 44 isNsfw = true } diff --git a/src/es/lectorjpg/res/mipmap-hdpi/ic_launcher.png b/src/es/lectorjpg/res/mipmap-hdpi/ic_launcher.png index b7321739f..7ae147725 100644 Binary files a/src/es/lectorjpg/res/mipmap-hdpi/ic_launcher.png and b/src/es/lectorjpg/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/es/lectorjpg/res/mipmap-mdpi/ic_launcher.png b/src/es/lectorjpg/res/mipmap-mdpi/ic_launcher.png index e13ff0d71..77c6c889f 100644 Binary files a/src/es/lectorjpg/res/mipmap-mdpi/ic_launcher.png and b/src/es/lectorjpg/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/es/lectorjpg/res/mipmap-xhdpi/ic_launcher.png b/src/es/lectorjpg/res/mipmap-xhdpi/ic_launcher.png index 14b7e6235..c5cba28d6 100644 Binary files a/src/es/lectorjpg/res/mipmap-xhdpi/ic_launcher.png and b/src/es/lectorjpg/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/es/lectorjpg/res/mipmap-xxhdpi/ic_launcher.png b/src/es/lectorjpg/res/mipmap-xxhdpi/ic_launcher.png index 588e3d6ae..df909f4b3 100644 Binary files a/src/es/lectorjpg/res/mipmap-xxhdpi/ic_launcher.png and b/src/es/lectorjpg/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/es/lectorjpg/res/mipmap-xxxhdpi/ic_launcher.png b/src/es/lectorjpg/res/mipmap-xxxhdpi/ic_launcher.png index 3d1106d9b..c6962c828 100644 Binary files a/src/es/lectorjpg/res/mipmap-xxxhdpi/ic_launcher.png and b/src/es/lectorjpg/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpg.kt b/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpg.kt index 5b6c1eab6..78a3dbe78 100644 --- a/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpg.kt +++ b/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpg.kt @@ -1,81 +1,292 @@ package eu.kanade.tachiyomi.extension.es.lectorjpg -import eu.kanade.tachiyomi.multisrc.madara.Madara +import android.util.Base64 +import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +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 keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse import okhttp3.HttpUrl.Companion.toHttpUrl -import org.jsoup.nodes.Document +import okhttp3.Request +import okhttp3.Response import org.jsoup.nodes.Element import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date import java.util.Locale +import java.util.TimeZone -class LectorJpg : Madara( - "LectorJPG", - "https://lectorjpg.com", - "es", - dateFormat = SimpleDateFormat("d MMMM, yyyy", Locale("es")), -) { +class LectorJpg : HttpSource() { - override val versionId = 2 + override val versionId = 3 - override val mangaSubString = "serie" + override val name = "LectorJPG" - override val useLoadMoreRequest = LoadMoreStrategy.Always + override val lang = "es" - override val client = super.client.newBuilder() + override val baseUrl = "https://lectorjpg.com" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() .rateLimitHost(baseUrl.toHttpUrl(), 3, 1) .build() - override fun popularMangaSelector() = "div:not([class]):has(> div.break-words)" - - override fun popularMangaFromElement(element: Element) = SManga.create().apply { - title = element.selectFirst("h3")!!.text() - thumbnail_url = element.selectFirst("img")?.let { imageFromElement(it) } - setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) - } - - override fun searchMangaSelector() = "button.group > div.grid" - - override fun searchMangaFromElement(element: Element) = SManga.create().apply { - title = element.selectFirst("h3")!!.text() - thumbnail_url = element.selectFirst("div[style].bg-cover")?.let { imageFromElement(it) } - setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) - } - - override val mangaDetailsSelectorTitle = "div.wp-manga div.grid > h1" - override val mangaDetailsSelectorStatus = "div.wp-manga div[alt=type]:eq(0) > span" - override val mangaDetailsSelectorGenre = "div.wp-manga div[alt=type]:gt(0) > span" - override val mangaDetailsSelectorDescription = "div.wp-manga div#expand_content" - override val mangaDetailsSelectorThumbnail = "div.grid.border div.bg-cover" - - override fun chapterListSelector() = "ul#list-chapters li > a" - - override fun chapterFromElement(element: Element) = SChapter.create().apply { - name = element.selectFirst("div.grid > span")!!.text() - date_upload = element.selectFirst("div.grid > div")?.text()?.let { parseChapterDate(it) } ?: 0 - setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) - } - - override fun parseGenres(document: Document): List { - return document.select("div:has(> input[type=checkbox])") - .orEmpty() - .map { li -> - Genre( - li.selectFirst("label")!!.text(), - li.selectFirst("input[type=checkbox]")!!.`val`(), - ) - } - } - - override fun imageFromElement(element: Element): String? { - return when { - element.hasAttr("data-src") -> element.attr("abs:data-src") - element.hasAttr("data-lazy-src") -> element.attr("abs:data-lazy-src") - element.hasAttr("srcset") -> element.attr("abs:srcset").getSrcSetImage() - element.hasAttr("data-cfsrc") -> element.attr("abs:data-cfsrc") - element.hasAttr("style") -> element.attr("style").substringAfter("url(").substringBefore(")") - else -> element.attr("abs:src") + class LimitedCache() : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 8 } } + + data class SearchKey(val page: Int, val query: String, val filters: String?) + + private val latestMangaCursor = LimitedCache() + private val searchMangaCursor = LimitedCache() + + override fun popularMangaRequest(page: Int): Request { + return GET(baseUrl, headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + val mangas = document.select("div.relative div.flex.w-fit article").map { element -> + SManga.create().apply { + title = element.selectFirst("h3")!!.text() + url = element.selectFirst("a")!!.attr("href").substringAfterLast("/series/").removeSuffix("/") + thumbnail_url = element.selectFirst("div.bg-cover")?.imageFromStyle() + } + } + return MangasPage(mangas, false) + } + + override fun latestUpdatesRequest(page: Int): Request { + val cursor = latestMangaCursor[page - 1] ?: createLatestCursor() + val url = "$baseUrl/serie-query".toHttpUrl().newBuilder() + .addQueryParameter("cursor", cursor) + .addQueryParameter("perPage", "35") + .addQueryParameter("type", "updated") + .fragment(page.toString()) + + return GET(url.build(), headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val page = response.request.url.fragment!!.toInt() + val result = response.parseAs() + latestMangaCursor[page] = result.nextCursor + val mangas = result.data.map { it.toSManga() } + return MangasPage(mangas, result.hasNextPage()) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val genresParam = filters + .filterIsInstance() + .flatMap { filter -> filter.state.filter { it.state }.map { it.key } } + .takeIf { it.isNotEmpty() } + ?.joinToString(",") + + val searchKey = SearchKey(page - 1, query, genresParam) + + val cursor = searchMangaCursor[searchKey] ?: "" + val url = "$baseUrl/serie-query".toHttpUrl().newBuilder() + .addQueryParameter("cursor", cursor) + .addQueryParameter("perPage", "35") + .addQueryParameter("type", "query") + .addQueryParameter("name", query) + .fragment(page.toString()) + + if (genresParam != null) { + url.addQueryParameter("genres", genresParam) + } + + return GET(url.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val page = response.request.url.fragment!!.toInt() + val query = response.request.url.queryParameter("name") ?: "" + val genresParam = response.request.url.queryParameter("genres") + + val searchKey = SearchKey(page, query, genresParam) + + val result = response.parseAs() + searchMangaCursor[searchKey] = result.nextCursor + val mangas = result.data.map { it.toSManga() } + return MangasPage(mangas, result.hasNextPage()) + } + + override fun getFilterList(): FilterList { + return FilterList( + GenreFilter("Géneros", getGenreList()), + ) + } + + override fun mangaDetailsRequest(manga: SManga): Request = GET("$baseUrl/series/${manga.url}", headers) + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + return SManga.create().apply { + title = document.selectFirst("div.grid > h1")!!.text() + thumbnail_url = document.selectFirst("div.bg_main.bg-cover")?.imageFromStyle() + description = document.select("div.grid > div.container > p").text() + status = document.selectFirst("div.grid:has(>div.flex:has(>span:contains(Status))) > div:last-child").parseStatus() + genre = document.select("a[href*=/series?genres] > span").joinToString { it.text() } + } + } + + override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return document.select("div.grid > a.group").map { element -> + SChapter.create().apply { + name = element.selectFirst("span.truncate")!!.text() + url = element.selectFirst("a")!!.attr("href") + date_upload = element.selectFirst("span.w-fit")?.text()?.let { parseChapterDate(it) } ?: 0L + } + } + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + return document.select("div.grid > img").mapIndexed { i, element -> + Page(i, imageUrl = element.attr("abs:src")) + } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + + private val cursorDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private fun createLatestCursor(): String { + val now: String? = cursorDateFormat.format(Date()) + val json = """{"last_update_at":"$now","id":0,"_pointsToNextItems":true}""" + return Base64.encodeToString(json.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + } + + private fun Element?.parseStatus(): Int { + return when (this?.text()?.lowercase()) { + "on-going" -> SManga.ONGOING + "end" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + + private fun Element.imageFromStyle(): String? { + val style = this.attr("style").replace(""", "\"") + return style.substringAfterLast("url(").substringBefore(")").removeSurrounding("\"") + } + + private val chapterDateFormat = SimpleDateFormat("dd/MM/yyyy", Locale("es")) + + private fun parseChapterDate(date: String): Long { + if (date.contains("hace")) { + val cleanDate = date.substringAfter("hace").trim() + when { + "hora" in cleanDate -> { + val hours = cleanDate.substringBefore("hora").trim().toIntOrNull() ?: return 0L + return System.currentTimeMillis() - hours * 60 * 60 * 1000 + } + + "minuto" in cleanDate -> { + val minutes = cleanDate.substringBefore("minuto").trim().toIntOrNull() ?: return 0L + return System.currentTimeMillis() - minutes * 60 * 1000 + } + + "segundo" in cleanDate -> { + val seconds = cleanDate.substringBefore("segundo").trim().toIntOrNull() ?: return 0L + return System.currentTimeMillis() - seconds * 1000 + } + + "día" in cleanDate -> { + val days = cleanDate.substringBefore("día").trim().toIntOrNull() ?: return 0L + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -days) + return calendar.timeInMillis + } + + else -> { + return 0L + } + } + } + + if (date.equals("ayer", true)) { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -1) + return calendar.timeInMillis + } + + return chapterDateFormat.tryParse(date) + } + + private fun getGenreList() = listOf( + Genre("BDSM", "bdsm"), + Genre("Bebes", "bebes"), + Genre("Bestias", "bestias"), + Genre("BL Sin Censura", "bl-sin-censura"), + Genre("Boys Love", "boys-love"), + Genre("Che Tenete Un Poco De Amor Propio", "che-tenete-un-poco-de-amor-propio"), + Genre("Ciencia Ficción", "ciencia-ficcion"), + Genre("Comedia", "comedia"), + Genre("Crimen", "crimen"), + Genre("Del Campo", "del-campo"), + Genre("Demonios", "demonios"), + Genre("Deportes", "deportes"), + Genre("Drama", "drama"), + Genre("Escolar", "escolar"), + Genre("Espacial", "espacial"), + Genre("Fantasía", "fantasia"), + Genre("Furro", "furro"), + Genre("Harem", "harem"), + Genre("Harem Inverso", "harem-inverso"), + Genre("Historia", "historia"), + Genre("Josei", "josei"), + Genre("Juego", "juego"), + Genre("Mafia", "mafia"), + Genre("Magia", "magia"), + Genre("Manhwa +19", "manhwa-19"), + Genre("Militar", "militar"), + Genre("Moderno", "moderno"), + Genre("Morocho Hermoso", "morocho-hermoso"), + Genre("Mucho Gogogo", "mucho-gogogo"), + Genre("Música", "musica"), + Genre("Novela", "novela"), + Genre("Odio-Amor", "odio-amor"), + Genre("Omegaverse", "omegaverse"), + Genre("Psicológico", "psicologico"), + Genre("Reencarnación", "reencarnacion"), + Genre("Relación Por Convivencia", "relacion-por-convivencia"), + Genre("Romance", "romance"), + Genre("Smut", "smut"), + Genre("Telenovela", "telenovela"), + Genre("Tetón", "teton"), + Genre("Toxicidad", "toxicidad"), + Genre("Toxicidad Nivel Chernóbil", "toxicidad-nivel-chernobil"), + Genre("Universitario", "universitario"), + Genre("Venganza", "venganza"), + Genre("Shoujo", "Shoujo"), + Genre("Shounen", "Shounen"), + Genre("Seinen", "Seinen"), + Genre("+18 Sin Censura", "+ 18 Sin Censura"), + Genre("NoBL\uD83D\uDC8C", "nobl"), + Genre("Girls Love", "gl"), + Genre("Adulto", "adulto"), + Genre("+18", "18"), + Genre("Sistema", "sistema"), + Genre("PuchiLovers", "puchilovers"), + Genre("Goheart Scan", "goheart-scan"), + Genre("Acción", "Acción"), + Genre("Aventura", "Aventura"), + Genre("Sobrenatural", "Sobrenatural"), + Genre("Transmigración", "Transmigración"), + ) } diff --git a/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpgDto.kt b/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpgDto.kt new file mode 100644 index 000000000..bcd7e8194 --- /dev/null +++ b/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpgDto.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.extension.es.lectorjpg + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class SeriesQueryDto( + val data: List = emptyList(), + @SerialName("next_cursor") val nextCursor: String? = null, +) { + fun hasNextPage() = nextCursor != null +} + +@Serializable +class SeriesDto( + private val name: String, + private val slug: String, + @SerialName("cover_url") private val cover: String, +) { + fun toSManga() = SManga.create().apply { + title = name + url = slug + thumbnail_url = cover + } +} diff --git a/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpgFilters.kt b/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpgFilters.kt new file mode 100644 index 000000000..ef195ed9b --- /dev/null +++ b/src/es/lectorjpg/src/eu/kanade/tachiyomi/extension/es/lectorjpg/LectorJpgFilters.kt @@ -0,0 +1,6 @@ +package eu.kanade.tachiyomi.extension.es.lectorjpg + +import eu.kanade.tachiyomi.source.model.Filter + +class Genre(title: String, val key: String) : Filter.CheckBox(title) +class GenreFilter(title: String, genres: List) : Filter.Group(title, genres)