diff --git a/src/en/pururin/build.gradle b/src/en/pururin/build.gradle index fd5668258..7de3fa00d 100644 --- a/src/en/pururin/build.gradle +++ b/src/en/pururin/build.gradle @@ -1,11 +1,12 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' ext { extName = 'Pururin' pkgNameSuffix = 'en.pururin' extClass = '.Pururin' - extVersionCode = 4 + extVersionCode = 5 isNsfw = true } diff --git a/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Pururin.kt b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Pururin.kt index 70897ea3f..1710ea99a 100644 --- a/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Pururin.kt +++ b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Pururin.kt @@ -1,22 +1,26 @@ package eu.kanade.tachiyomi.extension.en.pururin -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.asObservableSuccess 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 okhttp3.OkHttpClient +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.Request import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -class Pururin : ParsedHttpSource() { +import uy.kohesive.injekt.injectLazy +@ExperimentalSerializationApi +class Pururin : HttpSource() { override val name = "Pururin" override val baseUrl = "https://pururin.to" @@ -25,195 +29,137 @@ class Pururin : ParsedHttpSource() { override val supportsLatest = true - override val client: OkHttpClient = network.cloudflareClient + override val client = network.cloudflareClient - override fun latestUpdatesSelector() = "div.container div.row-gallery a" + private val searchUrl = "$baseUrl/api/search/advance" - override fun latestUpdatesRequest(page: Int): Request { - return if (page == 1) { - GET(baseUrl, headers) - } else { - GET("$baseUrl/browse/newest?page=$page", headers) - } - } + private val galleryUrl = "$baseUrl/api/contribute/gallery/info" - override fun latestUpdatesFromElement(element: Element): SManga { - val manga = SManga.create() + private val json by injectLazy() - manga.setUrlWithoutDomain(element.attr("href")) - manga.title = element.select("div.title").text() - manga.thumbnail_url = element.select("img.card-img-top").attr("abs:data-src") + override fun headersBuilder() = super.headersBuilder() + .set("Origin", baseUrl).set("X-Requested-With", "XMLHttpRequest") - return manga - } + override fun latestUpdatesRequest(page: Int) = + POST(searchUrl, headers, Search(Search.Sort.NEWEST, page)) - override fun latestUpdatesNextPageSelector() = "ul.pagination a.page-link[rel=next]" + override fun latestUpdatesParse(response: Response) = + searchMangaParse(response) - override fun mangaDetailsParse(document: Document): SManga { - val infoElement = document.select("div.box.box-gallery") - val manga = SManga.create() - val genres = mutableListOf() + override fun popularMangaRequest(page: Int) = + POST(searchUrl, headers, Search(Search.Sort.POPULAR, page)) - document.select("tr:has(td:containsOwn(Contents)) li").forEach { element -> - val genre = element.text() - genres.add(genre) - } + override fun popularMangaParse(response: Response) = + searchMangaParse(response) - manga.title = infoElement.select("h1").text() - manga.author = infoElement.select("tr:has(td:containsOwn(Artist)) a").text() - manga.artist = infoElement.select("tr:has(td:containsOwn(Circle)) a").text() - manga.status = SManga.COMPLETED - manga.genre = genres.joinToString(", ") - manga.thumbnail_url = document.select("div.cover-wrapper v-lazy-image").attr("abs:src") - - manga.description = getDesc(document) - manga.initialized = true - - return manga - } - - private fun getDesc(document: Document): String { - val infoElement = document.select("div.box.box-gallery") - val uploader = infoElement.select("tr:has(td:containsOwn(Uploader)) .user-link")?.text() - val pages = infoElement.select("tr:has(td:containsOwn(Pages)) td:eq(1)").text() - val ratingCount = infoElement.select("tr:has(td:containsOwn(Ratings)) span[itemprop=\"ratingCount\"]")?.attr("content") - - val rating = infoElement.select("tr:has(td:containsOwn(Ratings)) gallery-rating").attr(":rating")?.toFloatOrNull()?.let { - if (it > 5.0f) minOf(it, 5.0f) // cap rating to 5.0 for rare cases where value exceeds 5.0f - else it - } - - val multiDescriptions = listOf( - "Convention", - "Parody", - "Circle", - "Category", - "Character", - "Language" - ).map { it to infoElement.select("tr:has(td:containsOwn($it)) a").map { v -> v.text() } } - .filter { !it.second.isNullOrEmpty() } - .map { "${it.first}: ${it.second.joinToString()}" } - - val descriptions = listOf( - multiDescriptions.joinToString("\n\n"), - uploader?.let { "Uploader: $it" }, - pages?.let { "Pages: $it" }, - rating?.let { "Ratings: $it" + (ratingCount?.let { c -> " ($c ratings)" } ?: "") } - ) - - return descriptions.joinToString("\n\n") - } - - override fun chapterListParse(response: Response) = with(response.asJsoup()) { - val mangaInfoElements = this.select(".table-gallery-info tr td:first-child").map { - it.text() to it.nextElementSibling() - }.toMap() - - val chapters = this.select(".table-collection tbody tr") - if (!chapters.isNullOrEmpty()) - chapters.map { - val details = it.select("td") - SChapter.create().apply { - chapter_number = details[0].text().removePrefix("#").toFloat() - name = details[1].select("a").text() - setUrlWithoutDomain(details[1].select("a").attr("href")) - - if (it.hasClass("active") && mangaInfoElements.containsKey("Scanlator")) - scanlator = mangaInfoElements.getValue("Scanlator").select("li a")?.joinToString { s -> s.text() } - } - } - else - listOf( - SChapter.create().apply { - name = "Chapter" - setUrlWithoutDomain(response.request.url.toString()) - - if (mangaInfoElements.containsKey("Scanlator")) - scanlator = mangaInfoElements.getValue("Scanlator").select("li a")?.joinToString { s -> s.text() } - } - ) - } - - override fun chapterListSelector(): String = throw UnsupportedOperationException("Not used") - - override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException("Not used") - - override fun pageListParse(document: Document): List { - val pages = mutableListOf() - - // Gets gallery id from meta tags - val galleryUrl = document.select("meta[property='og:url']").attr("content") - val id = galleryUrl.substringAfter("gallery/").substringBefore('/') - // Gets total pages from gallery desc - val infoElement = document.select("div.box.box-gallery") - val total: Int = infoElement.select("tr:has(td:containsOwn(Pages)) td:eq(1)").text().substringBefore(' ').toInt() - - for (i in 1..total) { - pages.add(Page(i, "", "https://cdn.pururin.to/assets/images/data/$id/$i.jpg")) - } - - return pages - } - - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used") - - override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/browse/most-popular?page=$page", headers) - - override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element) - - override fun popularMangaSelector() = latestUpdatesSelector() - - override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector() - - private lateinit var tagUrl: String - - // TODO: Additional filter options, specifically the type[] parameter - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - var url = "$baseUrl/search?q=$query&page=$page" - - if (query.isBlank()) { - filters.forEach { filter -> - when (filter) { - is Tag -> { - url = if (page == 1) { - "$baseUrl/search/tag?q=${filter.state}&type[]=3" // "Contents" tag - } else { - "$tagUrl?page=$page" - } + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = + filters.ifEmpty(::getFilterList).run { + val whitelist = mutableListOf() + val blacklist = mutableListOf() + filterIsInstance>().forEach { group -> + group.state.forEach { + when { + it.isIncluded() -> whitelist += it.id + it.isExcluded() -> blacklist += it.id } } } + val body = Search( + find().sort, + page, + query, + whitelist, + blacklist, + find().mode, + find().range + ) + POST(searchUrl, headers, body) } - return GET(url, headers) - } - override fun searchMangaParse(response: Response): MangasPage { - return if (response.request.url.toString().contains("tag?")) { - response.asJsoup().select("table.table tbody tr a:first-of-type").attr("abs:href").let { - if (it.isNotEmpty()) { - tagUrl = it - super.searchMangaParse(client.newCall(GET(tagUrl, headers)).execute()) - } else { - MangasPage(emptyList(), false) - } + val results = json.decodeFromString( + response.jsonObject["results"]!!.jsonPrimitive.content + ) + val mp = results.map { + SManga.create().apply { + url = it.path + title = it.title + thumbnail_url = CDN_URL + it.cover } - } else { - super.searchMangaParse(response) + } + return MangasPage(mp, results.hasNext) + } + + override fun mangaDetailsParse(response: Response): SManga { + val gallery = json.decodeFromJsonElement( + response.jsonObject["gallery"]!! + ) + return SManga.create().apply { + description = gallery.description + artist = gallery.artists.joinToString() + author = gallery.authors.joinToString() + genre = gallery.genres.joinToString() } } - override fun searchMangaSelector() = latestUpdatesSelector() + override fun fetchMangaDetails(manga: SManga) = + client.newCall(chapterListRequest(manga)) + .asObservableSuccess().map(::mangaDetailsParse)!! - override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element) + override fun chapterListRequest(manga: SManga) = + POST(galleryUrl, headers, Search.info(manga.id)) - override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector() + override fun chapterListParse(response: Response): List { + val gallery = json.decodeFromJsonElement( + response.jsonObject["gallery"]!! + ) + val chapter = SChapter.create().apply { + name = "Chapter" + url = gallery.id.toString() + scanlator = gallery.scanlators.joinToString() + } + return listOf(chapter) + } + + override fun pageListRequest(chapter: SChapter) = + POST(galleryUrl, headers, Search.info(chapter.url)) + + override fun pageListParse(response: Response): List { + val pages = json.decodeFromJsonElement( + response.jsonObject["gallery"]!! + ).pages + return pages.mapIndexed { idx, img -> + Page(idx + 1, CDN_URL + img) + } + } + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException("Not used") + + override fun fetchImageUrl(page: Page) = + Request.Builder().url(page.url).head().build() + .run(client::newCall).asObservable().map { + when (it.code) { + 200 -> page.url + // try to fix images that are broken even on the site + 404 -> page.url.replaceAfterLast('.', "png") + else -> throw Error("HTTP error ${it.code}") + } + }!! override fun getFilterList() = FilterList( - Filter.Header("NOTE: Ignored if using text search!"), - Filter.Separator(), - Tag("Tag") + SortFilter(), + CategoryGroup(), + TagModeFilter(), + PagesGroup(), ) - private class Tag(name: String) : Filter.Text(name) + private inline val Response.jsonObject + get() = json.parseToJsonElement(body!!.string()).jsonObject + + private inline val SManga.id get() = url.split('/')[2] + + companion object { + private const val CDN_URL = "https://cdn.pururin.to/assets/images/data" + } } diff --git a/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/PururinAPI.kt b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/PururinAPI.kt new file mode 100644 index 000000000..c7a58ba80 --- /dev/null +++ b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/PururinAPI.kt @@ -0,0 +1,66 @@ +package eu.kanade.tachiyomi.extension.en.pururin + +import kotlinx.serialization.Serializable + +@Serializable +data class Results( + private val current_page: Int, + private val data: List, + private val last_page: Int +) : Iterable by data { + val hasNext get() = current_page != last_page +} + +@Serializable +data class Data( + private val id: Int, + val title: String, + private val slug: String +) { + val path get() = "/gallery/$id/$slug" + + val cover get() = "/$id/cover.jpg" +} + +@Serializable +data class Gallery( + val id: Int, + private val j_title: String, + private val alt_title: String?, + private val total_pages: Int, + private val image_extension: String, + private val tags: TagList +) { + val description get() = "$j_title\n${alt_title ?: ""}".trim() + + val pages get() = (1..total_pages).map { "/$id/$it.$image_extension" } + + val genres get() = tags.Parody + + tags.Contents + + tags.Category + + tags.Character + + tags.Convention + + val artists get() = tags.Artist + + val authors get() = tags.Circle.ifEmpty { tags.Artist } + + val scanlators get() = tags.Scanlator +} + +@Serializable +data class TagList( + val Artist: List, + val Circle: List, + val Parody: List, + val Contents: List, + val Category: List, + val Character: List, + val Scanlator: List, + val Convention: List +) + +@Serializable +data class Tag(private val name: String) { + override fun toString() = name +} diff --git a/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/PururinFilters.kt b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/PururinFilters.kt new file mode 100644 index 000000000..adf9f986b --- /dev/null +++ b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/PururinFilters.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.extension.en.pururin + +import eu.kanade.tachiyomi.source.model.Filter + +class SortFilter( + values: Array = Search.Sort.values() +) : Filter.Select("Sort by", values) { + inline val sort get() = values[state] +} + +sealed class TagFilter( + name: String, + val id: Int +) : Filter.TriState(name) + +sealed class TagGroup( + name: String, + values: List +) : Filter.Group(name, values) + +// TODO: Artist, Circle, Contents, Parody, Character, Convention + +class Category(name: String, id: Int) : TagFilter(name, id) + +class CategoryGroup( + values: List = categories +) : TagGroup("Categories", values) { + companion object { + private val categories get() = listOf( + Category("Doujinshi", 13003), + Category("Manga", 13004), + Category("Artist CG", 13006), + Category("Game CG", 13008), + Category("Artbook", 17783), + Category("Webtoon", 27939), + ) + } +} + +class TagModeFilter( + values: Array = Search.TagMode.values() +) : Filter.Select("Tag mode", values) { + inline val mode get() = values[state] +} + +class PagesFilter( + name: String, + default: Int, + values: Array = range, +) : Filter.Select(name, values, default) { + companion object { + private val range get() = Array(1001) { it } + } +} + +class PagesGroup( + values: List = minmax +) : Filter.Group("Pages", values) { + inline val range get() = IntRange(state[0].state, state[1].state).also { + require(it.first <= it.last) { "'Minimum' cannot exceed 'Maximum'" } + } + + companion object { + private val minmax get() = listOf( + PagesFilter("Minimum", 0), + PagesFilter("Maximum", 100) + ) + } +} + +inline fun List>.find() = find { it is T } as T diff --git a/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Search.kt b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Search.kt new file mode 100644 index 000000000..4a6150302 --- /dev/null +++ b/src/en/pururin/src/eu/kanade/tachiyomi/extension/en/pururin/Search.kt @@ -0,0 +1,75 @@ +package eu.kanade.tachiyomi.extension.en.pururin + +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +object Search { + private val jsonMime = "application/json".toMediaType() + + enum class Sort(private val label: String, val id: String) { + NEWEST("Newest", "newest"), + POPULAR("Most Popular", "most-popular"), + RATING("Highest Rated", "highest-rated"), + VIEWS("Most Viewed", "most-viewed"), + TITLE("Title", "title"); + + override fun toString() = label + } + + enum class TagMode(val id: String) { + AND("1"), OR("2"); + + override fun toString() = name + } + + operator fun invoke( + sort: Sort, + page: Int = 1, + query: String = "", + whitelist: List = emptyList(), + blacklist: List = emptyList(), + mode: TagMode = TagMode.AND, + range: IntRange = 0..100 + ) = buildJsonObject { + putJsonObject("search") { + put("sort", sort.id) + put("PageNumber", page) + putJsonObject("manga") { + put("string", query) + put("sort", "1") + } + putJsonObject("tag") { + putJsonObject("items") { + putJsonArray("whitelisted") { + whitelist.forEach { + addJsonObject { put("id", it) } + } + } + putJsonArray("blacklisted") { + blacklist.forEach { + addJsonObject { put("id", it) } + } + } + } + put("sort", mode.id) + } + putJsonObject("page") { + putJsonArray("range") { + add(range.first) + add(range.last) + } + } + } + }.toString().toRequestBody(jsonMime) + + fun info(id: String) = buildJsonObject { + put("id", id) + put("type", "1") + }.toString().toRequestBody(jsonMime) +}