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