Pururin refactor (#438)

This commit is contained in:
Mike 2024-01-21 01:42:02 -05:00 committed by GitHub
parent 7cb0522ade
commit e37f81eba2
13 changed files with 200 additions and 332 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Pururin'
extClass = '.Pururin'
extVersionCode = 6
extClass = '.PururinFactory'
extVersionCode = 7
isNsfw = true
}

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -0,0 +1,163 @@
package eu.kanade.tachiyomi.extension.all.pururin
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
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.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
abstract class Pururin(
override val lang: String = "all",
private val searchLang: String? = null,
private val langPath: String = "",
) : ParsedHttpSource() {
override val name = "Pururin"
override val baseUrl = "https://pururin.to"
override val supportsLatest = true
override val client = network.cloudflareClient
// Popular
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/browse$langPath?sort=most-popular&page=$page", headers)
}
override fun popularMangaSelector(): String = "a.card"
override fun popularMangaFromElement(element: Element): SManga {
return SManga.create().apply {
title = element.attr("title")
setUrlWithoutDomain(element.attr("abs:href"))
thumbnail_url = element.select("img").attr("abs:src")
}
}
override fun popularMangaNextPageSelector(): String = ".page-item [rel=next]"
// Latest
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/browse$langPath?page=$page", headers)
}
override fun latestUpdatesSelector(): String = popularMangaSelector()
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector()
// Search
private fun List<String>.toValue(): String {
return "[${this.joinToString(",")}]"
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val includeTags = mutableListOf<String>()
val excludeTags = mutableListOf<String>()
var pagesMin: Int
var pagesMax: Int
if (searchLang != null) includeTags.add(searchLang)
filters.filterIsInstance<TagGroup<*>>().map { group ->
group.state.map {
if (it.isIncluded()) includeTags.add(it.id)
if (it.isExcluded()) excludeTags.add(it.id)
}
}
filters.find<PagesGroup>().range.let {
pagesMin = it.first
pagesMax = it.last
}
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment("search")
addQueryParameter("q", query)
addQueryParameter("start_page", pagesMin.toString())
addQueryParameter("last_page", pagesMax.toString())
if (includeTags.isNotEmpty()) addQueryParameter("included_tags", includeTags.toValue())
if (excludeTags.isNotEmpty()) addQueryParameter("excluded_tags", excludeTags.toValue())
if (page > 1) addQueryParameter("page", page.toString())
}
return GET(url.build().toString(), headers)
}
override fun searchMangaSelector(): String = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector()
// Details
override fun mangaDetailsParse(document: Document): SManga {
return SManga.create().apply {
document.select(".box-gallery").let { e ->
initialized = true
title = e.select(".title").text()
author = e.select("[itemprop=author]").text()
description = e.select(".box-gallery .table-info tr")
.joinToString("\n") { tr ->
tr.select("td")
.joinToString(": ") { it.text() }
}
thumbnail_url = e.select("img").attr("abs:src")
}
}
}
// Chapters
override fun chapterListSelector(): String = ".table-collection tbody tr a"
override fun chapterFromElement(element: Element): SChapter {
return SChapter.create().apply {
name = element.text()
setUrlWithoutDomain(element.attr("abs:href"))
}
}
override fun chapterListParse(response: Response): List<SChapter> {
return response.asJsoup().select(chapterListSelector())
.map { chapterFromElement(it) }
.reversed()
.let { list ->
list.ifEmpty {
listOf(
SChapter.create().apply {
setUrlWithoutDomain(response.request.url.toString())
name = "Chapter"
},
)
}
}
}
// Pages
override fun pageListParse(document: Document): List<Page> {
return document.select(".gallery-preview a img")
.mapIndexed { i, img ->
Page(i, "", img.attr("abs:src").replace("t.", "."))
}
}
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
override fun getFilterList() = FilterList(
CategoryGroup(),
PagesGroup(),
)
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.extension.all.pururin
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class PururinFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
PururinAll(),
PururinEN(),
PururinJA(),
)
}
class PururinAll : Pururin()
class PururinEN : Pururin(
"en",
"{\"id\":13010,\"name\":\"English [Language]\"}",
"/tags/language/13010/english",
)
class PururinJA : Pururin(
"ja",
"{\"id\":13011,\"name\":\"Japanese [Language]\"}",
"/tags/language/13011/japanese",
)

View File

@ -1,16 +1,10 @@
package eu.kanade.tachiyomi.extension.en.pururin
package eu.kanade.tachiyomi.extension.all.pururin
import eu.kanade.tachiyomi.source.model.Filter
class SortFilter(
values: Array<Search.Sort> = Search.Sort.values(),
) : Filter.Select<Search.Sort>("Sort by", values) {
inline val sort get() = values[state]
}
sealed class TagFilter(
name: String,
val id: Int,
val id: String,
) : Filter.TriState(name)
sealed class TagGroup<T : TagFilter>(
@ -18,38 +12,30 @@ sealed class TagGroup<T : TagFilter>(
values: List<T>,
) : Filter.Group<T>(name, values)
// TODO: Artist, Circle, Contents, Parody, Character, Convention
class Category(name: String, id: Int) : TagFilter(name, id)
class Category(name: String, id: String) : TagFilter(name, id)
class CategoryGroup(
values: List<Category> = categories,
) : TagGroup<Category>("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),
Category("Doujinshi", "{\"id\":13003,\"name\":\"Doujinshi [Category]\"}"),
Category("Manga", "{\"id\":13004,\"name\":\"Manga [Category]\"}"),
Category("Artist CG", "{\"id\":13006,\"name\":\"Artist CG [Category]\"}"),
Category("Game CG", "{\"id\":13008,\"name\":\"Game CG [Category]\"}"),
Category("Artbook", "{\"id\":17783,\"name\":\"Artbook [Category]\"}"),
Category("Webtoon", "{\"id\":27939,\"name\":\"Webtoon [Category]\"}"),
)
}
}
class TagModeFilter(
values: Array<Search.TagMode> = Search.TagMode.values(),
) : Filter.Select<Search.TagMode>("Tag mode", values) {
inline val mode get() = values[state]
}
class PagesFilter(
name: String,
default: Int,
values: Array<Int> = range,
) : Filter.Select<Int>(name, values, default) {
companion object {
private val range get() = Array(1001) { it }
private val range get() = Array(301) { it }
}
}
@ -63,7 +49,7 @@ class PagesGroup(
companion object {
private val minmax get() = listOf(
PagesFilter("Minimum", 0),
PagesFilter("Maximum", 100),
PagesFilter("Maximum", 300),
)
}
}

View File

@ -1,163 +0,0 @@
package eu.kanade.tachiyomi.extension.en.pururin
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.HttpSource
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 uy.kohesive.injekt.injectLazy
class Pururin : HttpSource() {
override val name = "Pururin"
override val baseUrl = "https://pururin.to"
override val lang = "en"
override val supportsLatest = true
override val client = network.cloudflareClient
private val searchUrl = "$baseUrl/api/search/advance"
private val galleryUrl = "$baseUrl/api/contribute/gallery/info"
private val json by injectLazy<Json>()
override fun headersBuilder() = super.headersBuilder()
.set("Origin", baseUrl).set("X-Requested-With", "XMLHttpRequest")
override fun latestUpdatesRequest(page: Int) =
POST(searchUrl, headers, Search(Search.Sort.NEWEST, page))
override fun latestUpdatesParse(response: Response) =
searchMangaParse(response)
override fun popularMangaRequest(page: Int) =
POST(searchUrl, headers, Search(Search.Sort.POPULAR, page))
override fun popularMangaParse(response: Response) =
searchMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
filters.ifEmpty(::getFilterList).run {
val whitelist = mutableListOf<Int>()
val blacklist = mutableListOf<Int>()
filterIsInstance<TagGroup<*>>().forEach { group ->
group.state.forEach {
when {
it.isIncluded() -> whitelist += it.id
it.isExcluded() -> blacklist += it.id
}
}
}
val body = Search(
find<SortFilter>().sort,
page,
query,
whitelist,
blacklist,
find<TagModeFilter>().mode,
find<PagesGroup>().range,
)
POST(searchUrl, headers, body)
}
override fun searchMangaParse(response: Response): MangasPage {
val results = json.decodeFromString<Results>(
response.jsonObject["results"]!!.jsonPrimitive.content,
)
val mp = results.map {
SManga.create().apply {
url = it.path
title = it.title
thumbnail_url = CDN_URL + it.cover
}
}
return MangasPage(mp, results.hasNext)
}
override fun mangaDetailsParse(response: Response): SManga {
val gallery = json.decodeFromJsonElement<Gallery>(
response.jsonObject["gallery"]!!,
)
return SManga.create().apply {
description = gallery.description
artist = gallery.artists.joinToString()
author = gallery.authors.joinToString()
genre = gallery.genres.joinToString()
}
}
override fun fetchMangaDetails(manga: SManga) =
client.newCall(chapterListRequest(manga))
.asObservableSuccess().map(::mangaDetailsParse)!!
override fun chapterListRequest(manga: SManga) =
POST(galleryUrl, headers, Search.info(manga.id))
override fun chapterListParse(response: Response): List<SChapter> {
val gallery = json.decodeFromJsonElement<Gallery>(
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<Page> {
val pages = json.decodeFromJsonElement<Gallery>(
response.jsonObject["gallery"]!!,
).pages
return pages.mapIndexed { idx, img ->
Page(idx + 1, CDN_URL + img)
}
}
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException()
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(
SortFilter(),
CategoryGroup(),
TagModeFilter(),
PagesGroup(),
)
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"
}
}

View File

@ -1,66 +0,0 @@
package eu.kanade.tachiyomi.extension.en.pururin
import kotlinx.serialization.Serializable
@Serializable
data class Results(
private val current_page: Int,
private val data: List<Data>,
private val last_page: Int,
) : Iterable<Data> 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<Tag>,
val Circle: List<Tag>,
val Parody: List<Tag>,
val Contents: List<Tag>,
val Category: List<Tag>,
val Character: List<Tag>,
val Scanlator: List<Tag>,
val Convention: List<Tag>,
)
@Serializable
data class Tag(private val name: String) {
override fun toString() = name
}

View File

@ -1,76 +0,0 @@
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<Int> = emptyList(),
blacklist: List<Int> = 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)
}