Pururin refactor (#438)
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Pururin'
|
||||
extClass = '.Pururin'
|
||||
extVersionCode = 6
|
||||
extClass = '.PururinFactory'
|
||||
extVersionCode = 7
|
||||
isNsfw = true
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
@ -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(),
|
||||
)
|
||||
}
|
@ -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",
|
||||
)
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|