New Source: Project Suki (#18774)
* projectsuki initial commit * update preferences * non-lazy client * buildMap -> mapOf Co-authored-by: Alessandro Jean <14254807+alessandrojean@users.noreply.github.com> * inline constants * switched from custom NormalizedURL to HttpUrl * band-aid fix for "No results found" Has edge case where current page has 30 results and next page has 0 results. * update remote & strip debug Log statements --------- Co-authored-by: Alessandro Jean <14254807+alessandrojean@users.noreply.github.com>
This commit is contained in:
parent
50d2356709
commit
4d1d90a07b
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name=".all.projectsuki.ProjectSukiUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="projectsuki.com"
|
||||||
|
android:pathPattern="/book/..*"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="projectsuki.com"
|
||||||
|
android:pathPattern="/read/..*"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,15 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'Project Suki'
|
||||||
|
pkgNameSuffix = 'all.projectsuki'
|
||||||
|
extClass = '.ProjectSuki'
|
||||||
|
extVersionCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lib-randomua"))
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
|
@ -0,0 +1,54 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.projectsuki
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
|
typealias NormalizedURL = HttpUrl
|
||||||
|
|
||||||
|
val NormalizedURL.rawAbsolute: String
|
||||||
|
get() = toString()
|
||||||
|
|
||||||
|
private val psDomainURI = """https://projectsuki.com/""".toHttpUrl().toUri()
|
||||||
|
|
||||||
|
val NormalizedURL.rawRelative: String?
|
||||||
|
get() {
|
||||||
|
val uri = toUri()
|
||||||
|
return psDomainURI
|
||||||
|
.relativize(uri)
|
||||||
|
.takeIf { it != uri }
|
||||||
|
?.let { """/$it""" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val protocolMatcher = """^https?://""".toRegex()
|
||||||
|
private val domainMatcher = """^https?://(?:[a-zA-Z\d\-]+\.)+[a-zA-Z\d\-]+""".toRegex()
|
||||||
|
fun String.toNormalURL(): NormalizedURL? {
|
||||||
|
if (contains(':') && !contains(protocolMatcher)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val toParse = StringBuilder()
|
||||||
|
|
||||||
|
if (!contains(domainMatcher)) {
|
||||||
|
toParse.append("https://projectsuki.com")
|
||||||
|
if (!this.startsWith("/")) toParse.append('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
toParse.append(this)
|
||||||
|
|
||||||
|
return toParse.toString().toHttpUrlOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NormalizedURL.pathStartsWith(other: Iterable<String>): Boolean = pathSegments.zip(other).all { (l, r) -> l == r }
|
||||||
|
|
||||||
|
fun NormalizedURL.isPSUrl() = host.endsWith("${PS.identifier}.com")
|
||||||
|
|
||||||
|
fun NormalizedURL.isBookURL() = isPSUrl() && pathSegments.first() == "book"
|
||||||
|
fun NormalizedURL.isReadURL() = isPSUrl() && pathStartsWith(PS.chapterPath)
|
||||||
|
fun NormalizedURL.isImagesGalleryURL() = isPSUrl() && pathStartsWith(PS.pagePath)
|
||||||
|
|
||||||
|
fun Element.attrNormalizedUrl(attrName: String): NormalizedURL? {
|
||||||
|
val attrValue = attr("abs:$attrName").takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return attrValue.toNormalURL()
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
@file:Suppress("MayBeConstant", "unused")
|
||||||
|
|
||||||
|
package eu.kanade.tachiyomi.extension.all.projectsuki
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.concurrent.getOrSet
|
||||||
|
|
||||||
|
@Suppress("MemberVisibilityCanBePrivate")
|
||||||
|
internal object PS {
|
||||||
|
const val identifier: String = "projectsuki"
|
||||||
|
const val identifierShort: String = "ps"
|
||||||
|
|
||||||
|
val bookPath = listOf("book")
|
||||||
|
val pagePath = listOf("images", "gallery")
|
||||||
|
val chapterPath = listOf("read")
|
||||||
|
|
||||||
|
const val SEARCH_INTENT_PREFIX: String = "$identifierShort:"
|
||||||
|
|
||||||
|
const val PREFERENCE_WHITELIST_LANGUAGES = "$identifier-languages-whitelist"
|
||||||
|
const val PREFERENCE_WHITELIST_LANGUAGES_TITLE = "Whitelist the following languages:"
|
||||||
|
const val PREFERENCE_WHITELIST_LANGUAGES_SUMMARY =
|
||||||
|
"Will keep project chapters in the following languages." +
|
||||||
|
" Takes precedence over blacklisted languages." +
|
||||||
|
" It will match the string present in the \"Language\" column of the chapter." +
|
||||||
|
" Whitespaces will be trimmed." +
|
||||||
|
" Leave empty to allow all languages." +
|
||||||
|
" Separate each entry with a comma ','"
|
||||||
|
|
||||||
|
const val PREFERENCE_BLACKLIST_LANGUAGES = "$identifier-languages-blacklist"
|
||||||
|
const val PREFERENCE_BLACKLIST_LANGUAGES_TITLE = "Blacklist the following languages:"
|
||||||
|
const val PREFERENCE_BLACKLIST_LANGUAGES_SUMMARY =
|
||||||
|
"Will hide project chapters in the following languages." +
|
||||||
|
" Works identically to whitelisting."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Element.containsBookLinks(): Boolean = select("a").any {
|
||||||
|
it.attrNormalizedUrl("href")?.isBookURL() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Element.containsReadLinks(): Boolean = select("a").any {
|
||||||
|
it.attrNormalizedUrl("href")?.isReadURL() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Element.containsImageGalleryLinks(): Boolean = select("a").any {
|
||||||
|
it.attrNormalizedUrl("href")?.isImagesGalleryURL() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Element.getAllUrlElements(selector: String, attrName: String, predicate: (NormalizedURL) -> Boolean): Map<Element, NormalizedURL> {
|
||||||
|
return select(selector)
|
||||||
|
.mapNotNull { element -> element.attrNormalizedUrl(attrName)?.let { element to it } }
|
||||||
|
.filter { (_, url) -> predicate(url) }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Element.getAllBooks(): Map<String, PSBook> {
|
||||||
|
val bookUrls = getAllUrlElements("a", "href") { it.isBookURL() }
|
||||||
|
val byID: Map<String, Map<Element, NormalizedURL>> = bookUrls.groupBy { (_, url) -> url.pathSegments[1] /* /book/<bookid> */ }
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return byID.mapValues { (bookid, elements) ->
|
||||||
|
val thumb: Element? = elements.entries.firstNotNullOfOrNull { (element, _) ->
|
||||||
|
element.select("img").firstOrNull()
|
||||||
|
}
|
||||||
|
val title = elements.entries.firstOrNull { (element, _) ->
|
||||||
|
element.select("img").isEmpty() && element.text().let {
|
||||||
|
it.isNotBlank() && it.lowercase(Locale.US) != "show more"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumb != null && title != null) {
|
||||||
|
PSBook(thumb, title.key, title.key.text(), bookid, title.value)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.filterValues { it != null } as Map<String, PSBook>
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <SK, K, V> Map<K, V>.groupBy(keySelector: (Map.Entry<K, V>) -> SK): Map<SK, Map<K, V>> = buildMap<_, MutableMap<K, V>> {
|
||||||
|
this@groupBy.entries.forEach { entry ->
|
||||||
|
getOrPut(keySelector(entry)) { HashMap() }[entry.key] = entry.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val absoluteDateFormat: ThreadLocal<java.text.SimpleDateFormat> = ThreadLocal()
|
||||||
|
fun String.parseDate(ifFailed: Long = 0L): Long {
|
||||||
|
return when {
|
||||||
|
endsWith("ago") -> {
|
||||||
|
// relative
|
||||||
|
val number = takeWhile { it.isDigit() }.toInt()
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
|
||||||
|
when {
|
||||||
|
contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }
|
||||||
|
contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }
|
||||||
|
contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }
|
||||||
|
contains("second") -> cal.apply { add(Calendar.SECOND, -number) }
|
||||||
|
contains("week") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }
|
||||||
|
contains("month") -> cal.apply { add(Calendar.MONTH, -number) }
|
||||||
|
contains("year") -> cal.apply { add(Calendar.YEAR, -number) }
|
||||||
|
else -> null
|
||||||
|
}?.timeInMillis ?: ifFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// absolute?
|
||||||
|
absoluteDateFormat.getOrSet { java.text.SimpleDateFormat("MMMM dd, yyyy", Locale.US) }.parse(this)?.time ?: ifFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val imageExtensions = setOf(".jpg", ".png", ".jpeg", ".webp", ".gif", ".avif", ".tiff")
|
||||||
|
private val simpleSrcVariants = listOf("src", "data-src", "data-lazy-src")
|
||||||
|
fun Element.imgNormalizedURL(): NormalizedURL? {
|
||||||
|
simpleSrcVariants.forEach { variant ->
|
||||||
|
if (hasAttr(variant)) {
|
||||||
|
return attrNormalizedUrl(variant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAttr("srcset")) {
|
||||||
|
return attr("abs:srcset").substringBefore(" ").toNormalURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes().firstOrNull {
|
||||||
|
it.key.contains("src") && imageExtensions.any { ext -> it.value.contains(ext) }
|
||||||
|
}?.value?.substringBefore(" ")?.toNormalURL()
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.projectsuki
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
|
data class PSBook(
|
||||||
|
val imgElement: Element,
|
||||||
|
val titleElement: Element,
|
||||||
|
val title: String,
|
||||||
|
val mangaID: String,
|
||||||
|
val url: NormalizedURL,
|
||||||
|
)
|
|
@ -0,0 +1,90 @@
|
||||||
|
@file:Suppress("CanSealedSubClassBeObject")
|
||||||
|
|
||||||
|
package eu.kanade.tachiyomi.extension.all.projectsuki
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
object PSFilters {
|
||||||
|
internal sealed interface AutoFilter {
|
||||||
|
fun applyTo(builder: HttpUrl.Builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun HttpUrl.Builder.setAdv() = setQueryParameter("adv", "1")
|
||||||
|
|
||||||
|
class Author : Filter.Text("Author"), AutoFilter {
|
||||||
|
|
||||||
|
override fun applyTo(builder: HttpUrl.Builder) {
|
||||||
|
when {
|
||||||
|
state.isNotBlank() -> builder.setAdv().addQueryParameter("author", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val ownHeader by lazy { Header("Cannot search by multiple authors") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Artist : Filter.Text("Artist"), AutoFilter {
|
||||||
|
|
||||||
|
override fun applyTo(builder: HttpUrl.Builder) {
|
||||||
|
when {
|
||||||
|
state.isNotBlank() -> builder.setAdv().addQueryParameter("artist", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val ownHeader by lazy { Header("Cannot search by multiple artists") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Status : Filter.Select<Status.Value>("Status", Value.values()), AutoFilter {
|
||||||
|
enum class Value(val display: String, val query: String) {
|
||||||
|
ANY("Any", ""),
|
||||||
|
ONGOING("Ongoing", "ongoing"),
|
||||||
|
COMPLETED("Completed", "completed"),
|
||||||
|
HIATUS("Hiatus", "hiatus"),
|
||||||
|
CANCELLED("Cancelled", "cancelled"),
|
||||||
|
;
|
||||||
|
|
||||||
|
override fun toString(): String = display
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val values: Array<Value> = values()
|
||||||
|
operator fun get(ordinal: Int) = values[ordinal]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyTo(builder: HttpUrl.Builder) {
|
||||||
|
when (val state = Value[state]) {
|
||||||
|
Value.ANY -> {} // default, do nothing
|
||||||
|
else -> builder.setAdv().addQueryParameter("status", state.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Origin : Filter.Select<Origin.Value>("Origin", Value.values()), AutoFilter {
|
||||||
|
enum class Value(val display: String, val query: String?) {
|
||||||
|
ANY("Any", null),
|
||||||
|
KOREA("Korea", "kr"),
|
||||||
|
CHINA("China", "cn"),
|
||||||
|
JAPAN("Japan", "jp"),
|
||||||
|
;
|
||||||
|
|
||||||
|
override fun toString(): String = display
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val values: Array<Value> = Value.values()
|
||||||
|
operator fun get(ordinal: Int) = values[ordinal]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyTo(builder: HttpUrl.Builder) {
|
||||||
|
when (val state = Value[state]) {
|
||||||
|
Value.ANY -> {} // default, do nothing
|
||||||
|
else -> builder.setAdv().addQueryParameter("origin", state.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,443 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.projectsuki
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
|
||||||
|
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
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.model.UpdateStrategy
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
class ProjectSuki : HttpSource(), ConfigurableSource {
|
||||||
|
override val name: String = "Project Suki"
|
||||||
|
override val baseUrl: String = "https://projectsuki.com"
|
||||||
|
override val lang: String = "en"
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.processLangPref(): List<String> = split(",").map { it.trim().lowercase(Locale.US) }
|
||||||
|
|
||||||
|
private val SharedPreferences.whitelistedLanguages: List<String>
|
||||||
|
get() = getString(PS.PREFERENCE_WHITELIST_LANGUAGES, "")!!
|
||||||
|
.processLangPref()
|
||||||
|
|
||||||
|
private val SharedPreferences.blacklistedLanguages: List<String>
|
||||||
|
get() = getString(PS.PREFERENCE_BLACKLIST_LANGUAGES, "")!!
|
||||||
|
.processLangPref()
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
addRandomUAPreferenceToScreen(screen)
|
||||||
|
|
||||||
|
screen.addPreference(
|
||||||
|
EditTextPreference(screen.context).apply {
|
||||||
|
key = PS.PREFERENCE_WHITELIST_LANGUAGES
|
||||||
|
title = PS.PREFERENCE_WHITELIST_LANGUAGES_TITLE
|
||||||
|
summary = PS.PREFERENCE_WHITELIST_LANGUAGES_SUMMARY
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
screen.addPreference(
|
||||||
|
EditTextPreference(screen.context).apply {
|
||||||
|
key = PS.PREFERENCE_BLACKLIST_LANGUAGES
|
||||||
|
title = PS.PREFERENCE_BLACKLIST_LANGUAGES_TITLE
|
||||||
|
summary = PS.PREFERENCE_BLACKLIST_LANGUAGES_SUMMARY
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
|
.setRandomUserAgent(
|
||||||
|
userAgentType = preferences.getPrefUAType(),
|
||||||
|
customUA = preferences.getPrefCustomUA(),
|
||||||
|
filterInclude = listOf("chrome"),
|
||||||
|
)
|
||||||
|
.rateLimit(4)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
|
||||||
|
|
||||||
|
// differentiating between popular and latest manga in the main page is
|
||||||
|
// *theoretically possible* but a pain, as such, this is fine "for now"
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val allBooks = document.getAllBooks()
|
||||||
|
return MangasPage(
|
||||||
|
mangas = allBooks.mapNotNull mangas@{ (_, psbook) ->
|
||||||
|
val (img, _, titleText, _, url) = psbook
|
||||||
|
|
||||||
|
val relativeUrl = url.rawRelative ?: return@mangas null
|
||||||
|
|
||||||
|
SManga.create().apply {
|
||||||
|
this.url = relativeUrl
|
||||||
|
this.title = titleText
|
||||||
|
this.thumbnail_url = img.imgNormalizedURL()?.rawAbsolute
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hasNextPage = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val supportsLatest: Boolean = false
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return when {
|
||||||
|
/*query.startsWith(PS.SEARCH_INTENT_PREFIX) -> {
|
||||||
|
val id = query.substringAfter(PS.SEARCH_INTENT_PREFIX)
|
||||||
|
client.newCall(getMangaByIdAsSearchResult(id))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response -> searchMangaParse(response) }
|
||||||
|
}*/
|
||||||
|
|
||||||
|
else -> Observable.defer {
|
||||||
|
try {
|
||||||
|
client.newCall(searchMangaRequest(page, query, filters))
|
||||||
|
.asObservableSuccess()
|
||||||
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
throw RuntimeException(e)
|
||||||
|
}
|
||||||
|
}.map { response -> searchMangaParse(response) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
return GET(
|
||||||
|
baseUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
addPathSegment("search")
|
||||||
|
addQueryParameter("page", (page - 1).toString())
|
||||||
|
addQueryParameter("q", query)
|
||||||
|
|
||||||
|
filters.applyFilter<PSFilters.Origin>(this)
|
||||||
|
filters.applyFilter<PSFilters.Status>(this)
|
||||||
|
filters.applyFilter<PSFilters.Author>(this)
|
||||||
|
filters.applyFilter<PSFilters.Artist>(this)
|
||||||
|
}.build(),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> FilterList.applyFilter(to: HttpUrl.Builder) where T : Filter<*>, T : PSFilters.AutoFilter {
|
||||||
|
firstNotNullOfOrNull { it as? T }?.applyTo(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
Filter.Header("Filters only take effect when searching for something!"),
|
||||||
|
PSFilters.Origin(),
|
||||||
|
PSFilters.Status(),
|
||||||
|
PSFilters.Author.ownHeader,
|
||||||
|
PSFilters.Author(),
|
||||||
|
PSFilters.Artist.ownHeader,
|
||||||
|
PSFilters.Artist(),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val allBooks = document.getAllBooks()
|
||||||
|
|
||||||
|
val mangas = allBooks.mapNotNull mangas@{ (_, psbook) ->
|
||||||
|
val (img, _, titleText, _, url) = psbook
|
||||||
|
|
||||||
|
val relativeUrl = url.rawRelative ?: return@mangas null
|
||||||
|
|
||||||
|
SManga.create().apply {
|
||||||
|
this.url = relativeUrl
|
||||||
|
this.title = titleText
|
||||||
|
this.thumbnail_url = img.imgNormalizedURL()?.rawAbsolute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(
|
||||||
|
mangas = mangas,
|
||||||
|
hasNextPage = mangas.size >= 30, // observed max number of results in search
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||||
|
return client.newCall(mangaDetailsRequest(manga))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
mangaDetailsParse(response, incomplete = manga).apply { initialized = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val displayNoneMatcher = """display: ?none;""".toRegex()
|
||||||
|
private val emptyImageURLAbsolute = """https://projectsuki.com/images/gallery/empty.jpg""".toNormalURL()!!.rawAbsolute
|
||||||
|
private val emptyImageURLRelative = """https://projectsuki.com/images/gallery/empty.jpg""".toNormalURL()!!.rawRelative!!
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException("not used")
|
||||||
|
private fun mangaDetailsParse(response: Response, incomplete: SManga): SManga {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val allLinks = document.getAllUrlElements("a", "href") { it.isPSUrl() }
|
||||||
|
|
||||||
|
val thumb: Element? = document.select("img").firstOrNull { img ->
|
||||||
|
img.attr("onerror").let {
|
||||||
|
it.contains(emptyImageURLAbsolute) ||
|
||||||
|
it.contains(emptyImageURLRelative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val authors: Map<Element, NormalizedURL> = allLinks.filter { (_, url) ->
|
||||||
|
url.queryParameterNames.contains("author")
|
||||||
|
}
|
||||||
|
|
||||||
|
val artists: Map<Element, NormalizedURL> = allLinks.filter { (_, url) ->
|
||||||
|
url.queryParameterNames.contains("artist")
|
||||||
|
}
|
||||||
|
|
||||||
|
val statuses: Map<Element, NormalizedURL> = allLinks.filter { (_, url) ->
|
||||||
|
url.queryParameterNames.contains("status")
|
||||||
|
}
|
||||||
|
|
||||||
|
val origins: Map<Element, NormalizedURL> = allLinks.filter { (_, url) ->
|
||||||
|
url.queryParameterNames.contains("origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
val genres: Map<Element, NormalizedURL> = allLinks.filter { (_, url) ->
|
||||||
|
url.pathStartsWith(listOf("genre"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val description = document.select("#descriptionCollapse").joinToString("\n-----\n", postfix = "\n") { it.wholeText() }
|
||||||
|
|
||||||
|
val alerts = document.select(".alert, .alert-info")
|
||||||
|
.filter(
|
||||||
|
predicate = {
|
||||||
|
it.parents().none { parent ->
|
||||||
|
parent.attr("style")
|
||||||
|
.contains(displayNoneMatcher)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val userRating = document.select("#ratings")
|
||||||
|
.firstOrNull()
|
||||||
|
?.children()
|
||||||
|
?.count { it.hasClass("text-warning") }
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
url = incomplete.url
|
||||||
|
title = incomplete.title
|
||||||
|
thumbnail_url = thumb?.imgNormalizedURL()?.rawAbsolute ?: incomplete.thumbnail_url
|
||||||
|
|
||||||
|
author = authors.keys.joinToString(", ") { it.text() }
|
||||||
|
artist = artists.keys.joinToString(", ") { it.text() }
|
||||||
|
status = when (statuses.keys.joinToString("") { it.text().trim() }.lowercase(Locale.US)) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"completed" -> SManga.PUBLISHING_FINISHED
|
||||||
|
"hiatus" -> SManga.ON_HIATUS
|
||||||
|
"cancelled" -> SManga.CANCELLED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
this.description = buildString {
|
||||||
|
if (alerts.isNotEmpty()) {
|
||||||
|
appendLine("Alerts have been found, refreshing the manga later might help in removing them.")
|
||||||
|
appendLine()
|
||||||
|
|
||||||
|
alerts.forEach { alert ->
|
||||||
|
var appendedSomething = false
|
||||||
|
alert.select("h4").singleOrNull()?.let {
|
||||||
|
appendLine(it.text())
|
||||||
|
appendedSomething = true
|
||||||
|
}
|
||||||
|
alert.select("p").singleOrNull()?.let {
|
||||||
|
appendLine(it.text())
|
||||||
|
appendedSomething = true
|
||||||
|
}
|
||||||
|
if (!appendedSomething) {
|
||||||
|
appendLine(alert.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLine()
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLine(description)
|
||||||
|
|
||||||
|
fun appendToDescription(by: String, data: String?) {
|
||||||
|
if (data != null) append(by).appendLine(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendToDescription("User Rating: ", """${userRating ?: "?"}/5""")
|
||||||
|
appendToDescription("Authors: ", author)
|
||||||
|
appendToDescription("Artists: ", artist)
|
||||||
|
appendToDescription("Status: ", statuses.keys.joinToString(", ") { it.text() })
|
||||||
|
appendToDescription("Origin: ", origins.keys.joinToString(", ") { it.text() })
|
||||||
|
appendToDescription("Genres: ", genres.keys.joinToString(", ") { it.text() })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update_strategy = if (status != SManga.CANCELLED) UpdateStrategy.ALWAYS_UPDATE else UpdateStrategy.ONLY_FETCH_ONCE
|
||||||
|
this.genre = buildList {
|
||||||
|
addAll(genres.keys.map { it.text() })
|
||||||
|
origins.values.forEach { url ->
|
||||||
|
when (url.queryParameter("origin")) {
|
||||||
|
"kr" -> add("Manhwa")
|
||||||
|
"cn" -> add("Manhua")
|
||||||
|
"jp" -> add("Manga")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.joinToString(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val chapterHeaderMatcher = """chapters?""".toRegex()
|
||||||
|
private val groupHeaderMatcher = """groups?""".toRegex()
|
||||||
|
private val dateHeaderMatcher = """added|date""".toRegex()
|
||||||
|
private val languageHeaderMatcher = """language""".toRegex()
|
||||||
|
private val chapterNumberMatcher = """[Cc][Hh][Aa][Pp][Tt][Ee][Rr]\s*(\d+)(?:\s*[.,-]\s*(\d+))?""".toRegex()
|
||||||
|
private val looseNumberMatcher = """(\d+)(?:\s*[.,-]\s*(\d+))?""".toRegex()
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val chaptersTable = document.select("table").firstOrNull { it.containsReadLinks() } ?: return emptyList()
|
||||||
|
|
||||||
|
val thead: Element = chaptersTable.select("thead").firstOrNull() ?: return emptyList()
|
||||||
|
val tbody: Element = chaptersTable.select("tbody").firstOrNull() ?: return emptyList()
|
||||||
|
|
||||||
|
val columnTypes = thead.select("tr").firstOrNull()?.children()?.select("td") ?: return emptyList()
|
||||||
|
val textTypes = columnTypes.map { it.text().lowercase(Locale.US) }
|
||||||
|
val normalSize = textTypes.size
|
||||||
|
|
||||||
|
val chaptersIndex: Int = textTypes.indexOfFirst { it.matches(chapterHeaderMatcher) }.takeIf { it >= 0 } ?: return emptyList()
|
||||||
|
val dateIndex: Int = textTypes.indexOfFirst { it.matches(dateHeaderMatcher) }.takeIf { it >= 0 } ?: return emptyList()
|
||||||
|
val groupIndex: Int? = textTypes.indexOfFirst { it.matches(groupHeaderMatcher) }.takeIf { it >= 0 }
|
||||||
|
val languageIndex: Int? = textTypes.indexOfFirst { it.matches(languageHeaderMatcher) }.takeIf { it >= 0 }
|
||||||
|
|
||||||
|
val dataRows = tbody.children().select("tr")
|
||||||
|
|
||||||
|
val blLangs = preferences.blacklistedLanguages
|
||||||
|
val wlLangs = preferences.whitelistedLanguages
|
||||||
|
|
||||||
|
return dataRows.mapNotNull chapters@{ tr ->
|
||||||
|
val rowData = tr.children().select("td")
|
||||||
|
|
||||||
|
if (rowData.size != normalSize) {
|
||||||
|
return@chapters null
|
||||||
|
}
|
||||||
|
|
||||||
|
val chapter: Element = rowData[chaptersIndex]
|
||||||
|
val date: Element = rowData[dateIndex]
|
||||||
|
val group: Element? = groupIndex?.let(rowData::get)
|
||||||
|
val language: Element? = languageIndex?.let(rowData::get)
|
||||||
|
|
||||||
|
language?.text()?.lowercase(Locale.US)?.let { lang ->
|
||||||
|
if (lang in blLangs && lang !in wlLangs) return@chapters null
|
||||||
|
}
|
||||||
|
|
||||||
|
val chapterLink = chapter.select("a").first()!!.attrNormalizedUrl("href")!!
|
||||||
|
|
||||||
|
val relativeURL = chapterLink.rawRelative ?: return@chapters null
|
||||||
|
|
||||||
|
SChapter.create().apply {
|
||||||
|
chapter_number = chapter.text()
|
||||||
|
.let { (chapterNumberMatcher.find(it) ?: looseNumberMatcher.find(it)) }
|
||||||
|
?.let { result ->
|
||||||
|
val integral = result.groupValues[1]
|
||||||
|
val fractional = result.groupValues.getOrNull(2)
|
||||||
|
|
||||||
|
"""${integral}$fractional""".toFloat()
|
||||||
|
} ?: -1f
|
||||||
|
|
||||||
|
url = relativeURL
|
||||||
|
scanlator = group?.text() ?: "<UNKNOWN>"
|
||||||
|
name = chapter.text()
|
||||||
|
date_upload = date.text().parseDate()
|
||||||
|
}
|
||||||
|
}.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||||
|
|
||||||
|
private val callpageUrl = """https://projectsuki.com/callpage"""
|
||||||
|
private val jsonMediaType = "application/json;charset=UTF-8".toMediaType()
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
// chapter.url is /read/<bookid>/<chapterid>/...
|
||||||
|
val url = chapter.url.toNormalURL() ?: return Observable.just(emptyList())
|
||||||
|
|
||||||
|
val bookid = url.pathSegments[1] // <bookid>
|
||||||
|
val chapterid = url.pathSegments[2] // <chapterid>
|
||||||
|
|
||||||
|
val callpageHeaders = headersBuilder()
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.add("Content-Type", "application/json;charset=UTF-8")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val callpageBody = Json.encodeToString(
|
||||||
|
mapOf(
|
||||||
|
"bookid" to bookid,
|
||||||
|
"chapterid" to chapterid,
|
||||||
|
"first" to "true",
|
||||||
|
),
|
||||||
|
).toRequestBody(jsonMediaType)
|
||||||
|
|
||||||
|
return client.newCall(
|
||||||
|
POST(callpageUrl, callpageHeaders, callpageBody),
|
||||||
|
).asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
callpageParse(chapter, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
private fun callpageParse(chapter: SChapter, response: Response): List<Page> {
|
||||||
|
// response contains the html src with images
|
||||||
|
val src = Json.parseToJsonElement(response.body.string()).jsonObject["src"]?.jsonPrimitive?.content ?: return emptyList()
|
||||||
|
val images = Jsoup.parseBodyFragment(src).select("img")
|
||||||
|
// images urls are /images/gallery/<bookid>/<uuid>/<pagenum>? (empty query for some reason)
|
||||||
|
val urls = images.mapNotNull { it.attrNormalizedUrl("src") }
|
||||||
|
if (urls.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val anUrl = urls.random()
|
||||||
|
val pageNums = urls.mapTo(ArrayList()) { it.pathSegments[4] }
|
||||||
|
pageNums += "001"
|
||||||
|
|
||||||
|
fun makeURL(pageNum: String) = anUrl.newBuilder()
|
||||||
|
.setPathSegment(anUrl.pathSegments.lastIndex, pageNum)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return pageNums.distinct().sortedBy { it.toInt() }.mapIndexed { index, number ->
|
||||||
|
Page(
|
||||||
|
index,
|
||||||
|
"",
|
||||||
|
makeURL(number).rawAbsolute,
|
||||||
|
)
|
||||||
|
}.distinctBy { it.imageUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> = throw UnsupportedOperationException("not used")
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.projectsuki
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class ProjectSukiUrlActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
if (pathSegments != null && pathSegments.size > 1) {
|
||||||
|
val mainIntent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
putExtra("query", "${PS.SEARCH_INTENT_PREFIX}${pathSegments[1]}")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("PSUrlActivity", e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("PSUrlActivity", "could not parse uri from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue