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:
Federico d'Alonzo 2023-11-02 02:10:14 +01:00 committed by GitHub
parent 50d2356709
commit 4d1d90a07b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 810 additions and 0 deletions

View File

@ -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>

View File

@ -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

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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,
)

View File

@ -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)
}
}
}
}

View File

@ -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")
}

View File

@ -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)
}
}