diff --git a/src/all/projectsuki/AndroidManifest.xml b/src/all/projectsuki/AndroidManifest.xml new file mode 100644 index 000000000..867eb056f --- /dev/null +++ b/src/all/projectsuki/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/projectsuki/build.gradle b/src/all/projectsuki/build.gradle new file mode 100644 index 000000000..5ea5dc348 --- /dev/null +++ b/src/all/projectsuki/build.gradle @@ -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" diff --git a/src/all/projectsuki/res/mipmap-hdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..57b7a5296 Binary files /dev/null and b/src/all/projectsuki/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/projectsuki/res/mipmap-mdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..5b568e2ae Binary files /dev/null and b/src/all/projectsuki/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/projectsuki/res/mipmap-xhdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ac0b1b10d Binary files /dev/null and b/src/all/projectsuki/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/projectsuki/res/mipmap-xxhdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..bd8d04b10 Binary files /dev/null and b/src/all/projectsuki/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/projectsuki/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..6aeb83f51 Binary files /dev/null and b/src/all/projectsuki/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/projectsuki/res/web_hi_res_512.png b/src/all/projectsuki/res/web_hi_res_512.png new file mode 100644 index 000000000..83e3bb55a Binary files /dev/null and b/src/all/projectsuki/res/web_hi_res_512.png differ diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/NormalizedURL.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/NormalizedURL.kt new file mode 100644 index 000000000..3f6840dde --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/NormalizedURL.kt @@ -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): 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() +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PS.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PS.kt new file mode 100644 index 000000000..68c4dd935 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PS.kt @@ -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 { + return select(selector) + .mapNotNull { element -> element.attrNormalizedUrl(attrName)?.let { element to it } } + .filter { (_, url) -> predicate(url) } + .toMap() +} + +fun Element.getAllBooks(): Map { + val bookUrls = getAllUrlElements("a", "href") { it.isBookURL() } + val byID: Map> = bookUrls.groupBy { (_, url) -> url.pathSegments[1] /* /book/ */ } + + @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 +} + +inline fun Map.groupBy(keySelector: (Map.Entry) -> SK): Map> = buildMap<_, MutableMap> { + this@groupBy.entries.forEach { entry -> + getOrPut(keySelector(entry)) { HashMap() }[entry.key] = entry.value + } +} + +private val absoluteDateFormat: ThreadLocal = 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() +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSBook.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSBook.kt new file mode 100644 index 000000000..87198dee1 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSBook.kt @@ -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, +) diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSFilters.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSFilters.kt new file mode 100644 index 000000000..dfa2d1646 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSFilters.kt @@ -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.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 = 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.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.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) + } + } + } +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSuki.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSuki.kt new file mode 100644 index 000000000..0dbf62fc6 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSuki.kt @@ -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().getSharedPreferences("source_$id", 0x0000) + } + + private fun String.processLangPref(): List = split(",").map { it.trim().lowercase(Locale.US) } + + private val SharedPreferences.whitelistedLanguages: List + get() = getString(PS.PREFERENCE_WHITELIST_LANGUAGES, "")!! + .processLangPref() + + private val SharedPreferences.blacklistedLanguages: List + 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 { + 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(this) + filters.applyFilter(this) + filters.applyFilter(this) + filters.applyFilter(this) + }.build(), + headers, + ) + } + + private inline fun 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 { + 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 = allLinks.filter { (_, url) -> + url.queryParameterNames.contains("author") + } + + val artists: Map = allLinks.filter { (_, url) -> + url.queryParameterNames.contains("artist") + } + + val statuses: Map = allLinks.filter { (_, url) -> + url.queryParameterNames.contains("status") + } + + val origins: Map = allLinks.filter { (_, url) -> + url.queryParameterNames.contains("origin") + } + + val genres: Map = 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 { + 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() ?: "" + 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> { + // chapter.url is /read///... + val url = chapter.url.toNormalURL() ?: return Observable.just(emptyList()) + + val bookid = url.pathSegments[1] // + val chapterid = url.pathSegments[2] // + + 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 { + // 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///? (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 = throw UnsupportedOperationException("not used") +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiUrlActivity.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiUrlActivity.kt new file mode 100644 index 000000000..a72a020b1 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiUrlActivity.kt @@ -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) + } +}