Slimeread: Move slimeread to lib-multsrc and add mahouscan (#6780)
Move slimeread to lib-multsrc and add mahouscan
|
@ -2,7 +2,7 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
android:name=".pt.slimeread.SlimeReadUrlActivity"
|
android:name="eu.kanade.tachiyomi.multisrc.slimereadtheme.SlimeReadThemeUrlActivity"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@android:style/Theme.NoDisplay">
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
@ -12,10 +12,10 @@
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data
|
||||||
android:host="slimeread.com"
|
android:host="${SOURCEHOST}"
|
||||||
android:pathPattern="/manga/..*"
|
android:pathPattern="/manga/..*"
|
||||||
android:scheme="https" />
|
android:scheme="${SOURCESCHEME}" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
|
@ -0,0 +1,5 @@
|
||||||
|
plugins {
|
||||||
|
id("lib-multisrc")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseVersionCode = 1
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
@ -0,0 +1,246 @@
|
||||||
|
package eu.kanade.tachiyomi.multisrc.slimereadtheme
|
||||||
|
|
||||||
|
import app.cash.quickjs.QuickJs
|
||||||
|
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.ChapterDto
|
||||||
|
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.LatestResponseDto
|
||||||
|
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.MangaInfoDto
|
||||||
|
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PageListDto
|
||||||
|
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PopularMangaDto
|
||||||
|
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.toSMangaList
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
abstract class SlimeReadTheme(
|
||||||
|
override val name: String,
|
||||||
|
override val baseUrl: String,
|
||||||
|
override val lang: String,
|
||||||
|
) : HttpSource() {
|
||||||
|
|
||||||
|
protected open val apiUrl: String by lazy { getApiUrlFromPage() }
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
protected open val urlInfix: String = "slimeread.com"
|
||||||
|
|
||||||
|
protected open fun getApiUrlFromPage(): String {
|
||||||
|
val initClient = network.cloudflareClient
|
||||||
|
val response = initClient.newCall(GET(baseUrl, headers)).execute()
|
||||||
|
if (!response.isSuccessful) throw Exception("HTTP error ${response.code}")
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val scriptUrl = document.selectFirst("script[src*=pages/_app]")?.attr("abs:src")
|
||||||
|
?: throw Exception("Could not find script URL")
|
||||||
|
val scriptResponse = initClient.newCall(GET(scriptUrl, headers)).execute()
|
||||||
|
if (!scriptResponse.isSuccessful) throw Exception("HTTP error ${scriptResponse.code}")
|
||||||
|
val script = scriptResponse.body.string()
|
||||||
|
val apiUrl = FUNCTION_REGEX.find(script)?.let { result ->
|
||||||
|
val varBlock = result.groups["script"]?.value ?: return@let null
|
||||||
|
val varUrlInfix = result.groups["infix"]?.value ?: return@let null
|
||||||
|
|
||||||
|
val block = """${varBlock.replace(varUrlInfix, "\"$urlInfix\"")}.toString()"""
|
||||||
|
|
||||||
|
try {
|
||||||
|
QuickJs.create().use { it.evaluate(block) as String }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiUrl?.let { "https://$it" } ?: throw Exception("Could not find API URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Popular ===============================
|
||||||
|
private var currentSlice = 0
|
||||||
|
private var popularMangeCache: MangasPage? = null
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) =
|
||||||
|
GET("$apiUrl/book_search?order=1&status=0", headers)
|
||||||
|
|
||||||
|
// Returns a large JSON, so the app can't handle the list without pagination
|
||||||
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||||
|
if (page == 1 || popularMangeCache == null) {
|
||||||
|
popularMangeCache = super.fetchPopularManga(page)
|
||||||
|
.toBlocking()
|
||||||
|
.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handling a large manga list
|
||||||
|
return Observable.just(popularMangeCache!!)
|
||||||
|
.map { mangaPage ->
|
||||||
|
val mangas = mangaPage.mangas
|
||||||
|
val pageSize = 15
|
||||||
|
|
||||||
|
currentSlice = (page - 1) * pageSize
|
||||||
|
|
||||||
|
val startIndex = min(mangas.size - 1, currentSlice)
|
||||||
|
val endIndex = min(mangas.size, currentSlice + pageSize)
|
||||||
|
|
||||||
|
val slice = mangas.subList(startIndex, endIndex)
|
||||||
|
|
||||||
|
MangasPage(slice, slice.isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val items = response.parseAs<List<PopularMangaDto>>()
|
||||||
|
val mangaList = items.toSMangaList()
|
||||||
|
return MangasPage(mangaList, mangaList.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Latest ===============================
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$apiUrl/books?page=$page", headers)
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val dto = response.parseAs<LatestResponseDto>()
|
||||||
|
val mangaList = dto.data.toSMangaList()
|
||||||
|
val hasNextPage = dto.page < dto.pages
|
||||||
|
return MangasPage(mangaList, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Search ===============================
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
|
||||||
|
val id = query.removePrefix(PREFIX_SEARCH)
|
||||||
|
client.newCall(GET("$apiUrl/book/$id", headers))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map(::searchMangaByIdParse)
|
||||||
|
} else {
|
||||||
|
super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMangaByIdParse(response: Response): MangasPage {
|
||||||
|
val details = mangaDetailsParse(response)
|
||||||
|
return MangasPage(listOf(details), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList() = SlimeReadThemeFilters.FILTER_LIST
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val params = SlimeReadThemeFilters.getSearchParameters(filters)
|
||||||
|
|
||||||
|
val url = "$apiUrl/book_search".toHttpUrl().newBuilder()
|
||||||
|
.addIfNotBlank("query", query)
|
||||||
|
.addIfNotBlank("genre[]", params.genre)
|
||||||
|
.addIfNotBlank("status", params.status)
|
||||||
|
.addIfNotBlank("searchMethod", params.searchMethod)
|
||||||
|
.apply {
|
||||||
|
params.categories.forEach {
|
||||||
|
addQueryParameter("categories[]", it)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||||
|
|
||||||
|
// =========================== Manga Details ============================
|
||||||
|
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.replace("/book/", "/manga/")
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga) = GET(apiUrl + manga.url, headers)
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||||
|
val info = response.parseAs<MangaInfoDto>()
|
||||||
|
thumbnail_url = info.thumbnail_url
|
||||||
|
title = info.name
|
||||||
|
description = info.description
|
||||||
|
genre = info.categories.joinToString()
|
||||||
|
url = "/book/${info.id}"
|
||||||
|
status = when (info.status) {
|
||||||
|
1 -> SManga.ONGOING
|
||||||
|
2 -> SManga.COMPLETED
|
||||||
|
3, 4 -> SManga.CANCELLED
|
||||||
|
5 -> SManga.ON_HIATUS
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Chapters ==============================
|
||||||
|
override fun chapterListRequest(manga: SManga) =
|
||||||
|
GET("$apiUrl/book_cap_units_all?manga_id=${manga.url.substringAfterLast("/")}", headers)
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val items = response.parseAs<List<ChapterDto>>()
|
||||||
|
val mangaId = response.request.url.queryParameter("manga_id")!!
|
||||||
|
return items.map {
|
||||||
|
SChapter.create().apply {
|
||||||
|
name = "Cap " + parseChapterNumber(it.number)
|
||||||
|
chapter_number = it.number
|
||||||
|
scanlator = it.scan?.scan_name
|
||||||
|
url = "/book_cap_units?manga_id=$mangaId&cap=${it.number}"
|
||||||
|
}
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterNumber(number: Float): String {
|
||||||
|
val cap = number + 1F
|
||||||
|
return "%.2f".format(cap)
|
||||||
|
.let { if (cap < 10F) "0$it" else it }
|
||||||
|
.replace(",00", "")
|
||||||
|
.replace(",", ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChapterUrl(chapter: SChapter): String {
|
||||||
|
val url = "$baseUrl${chapter.url}".toHttpUrl()
|
||||||
|
val id = url.queryParameter("manga_id")!!
|
||||||
|
val cap = url.queryParameter("cap")!!.toFloat()
|
||||||
|
val num = parseChapterNumber(cap)
|
||||||
|
return "$baseUrl/ler/$id/cap-$num"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Pages ================================
|
||||||
|
override fun pageListRequest(chapter: SChapter) = GET(apiUrl + chapter.url, headers)
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val body = response.body.string()
|
||||||
|
val pages = if (body.startsWith("{")) {
|
||||||
|
json.decodeFromString<Map<String, PageListDto>>(body).values.flatMap { it.pages }
|
||||||
|
} else {
|
||||||
|
json.decodeFromString<List<PageListDto>>(body).flatMap { it.pages }
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.mapIndexed { index, item ->
|
||||||
|
Page(index, "", item.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================= Utilities ==============================
|
||||||
|
private inline fun <reified T> Response.parseAs(): T = use {
|
||||||
|
json.decodeFromStream(it.body.byteStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
|
||||||
|
if (value.isNotBlank()) addQueryParameter(query, value)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX_SEARCH = "id:"
|
||||||
|
val FUNCTION_REGEX = """(?<script>\[""\.concat\("[^,]+,"\."\)\.concat\((?<infix>[^,]+),":\d+"\)\])""".toRegex(RegexOption.DOT_MATCHES_ALL)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
package eu.kanade.tachiyomi.extension.pt.slimeread
|
package eu.kanade.tachiyomi.multisrc.slimereadtheme
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
|
||||||
object SlimeReadFilters {
|
object SlimeReadThemeFilters {
|
||||||
open class SelectFilter(
|
open class SelectFilter(
|
||||||
displayName: String,
|
displayName: String,
|
||||||
val vals: Array<Pair<String, String>>,
|
val vals: Array<Pair<String, String>>,
|
|
@ -1,4 +1,4 @@
|
||||||
package eu.kanade.tachiyomi.extension.pt.slimeread
|
package eu.kanade.tachiyomi.multisrc.slimereadtheme
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
@ -11,7 +11,7 @@ import kotlin.system.exitProcess
|
||||||
* Springboard that accepts https://slimeread.com/manga/<id>/<slug> intents
|
* Springboard that accepts https://slimeread.com/manga/<id>/<slug> intents
|
||||||
* and redirects them to the main Tachiyomi process.
|
* and redirects them to the main Tachiyomi process.
|
||||||
*/
|
*/
|
||||||
class SlimeReadUrlActivity : Activity() {
|
class SlimeReadThemeUrlActivity : Activity() {
|
||||||
|
|
||||||
private val tag = javaClass.simpleName
|
private val tag = javaClass.simpleName
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ class SlimeReadUrlActivity : Activity() {
|
||||||
val item = pathSegments[1]
|
val item = pathSegments[1]
|
||||||
val mainIntent = Intent().apply {
|
val mainIntent = Intent().apply {
|
||||||
action = "eu.kanade.tachiyomi.SEARCH"
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
putExtra("query", "${SlimeRead.PREFIX_SEARCH}$item")
|
putExtra("query", "${SlimeReadTheme.PREFIX_SEARCH}$item")
|
||||||
putExtra("filter", packageName)
|
putExtra("filter", packageName)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
ext {
|
||||||
|
extName = 'MahouScan'
|
||||||
|
extClass = '.MahouScan'
|
||||||
|
themePkg = 'slimereadtheme'
|
||||||
|
baseUrl = 'https://mahouscan.com'
|
||||||
|
overrideVersionCode = 0
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 39 KiB |
|
@ -0,0 +1,14 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.pt.mahouscan
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.multisrc.slimereadtheme.SlimeReadTheme
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
|
||||||
|
class MahouScan : SlimeReadTheme(
|
||||||
|
"MahouScan",
|
||||||
|
"https://mahouscan.com",
|
||||||
|
"pt-BR",
|
||||||
|
) {
|
||||||
|
override val client = super.client.newBuilder()
|
||||||
|
.rateLimit(2)
|
||||||
|
.build()
|
||||||
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
ext {
|
ext {
|
||||||
extName = 'SlimeRead'
|
extName = 'SlimeRead'
|
||||||
extClass = '.SlimeRead'
|
extClass = '.SlimeRead'
|
||||||
extVersionCode = 15
|
themePkg = 'slimereadtheme'
|
||||||
|
baseUrl = 'https://slimeread.com'
|
||||||
|
overrideVersionCode = 15
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,270 +1,17 @@
|
||||||
package eu.kanade.tachiyomi.extension.pt.slimeread
|
package eu.kanade.tachiyomi.extension.pt.slimeread
|
||||||
|
|
||||||
import app.cash.quickjs.QuickJs
|
import eu.kanade.tachiyomi.multisrc.slimereadtheme.SlimeReadTheme
|
||||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.ChapterDto
|
|
||||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.LatestResponseDto
|
|
||||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.MangaInfoDto
|
|
||||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PageListDto
|
|
||||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PopularMangaDto
|
|
||||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.toSMangaList
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
class SlimeRead : HttpSource() {
|
class SlimeRead : SlimeReadTheme(
|
||||||
|
"SlimeRead",
|
||||||
|
"https://slimeread.com",
|
||||||
|
"pt-BR",
|
||||||
|
) {
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Origin", baseUrl)
|
||||||
|
|
||||||
override val name = "SlimeRead"
|
override val client = super.client.newBuilder()
|
||||||
|
.rateLimit(2)
|
||||||
override val baseUrl = "https://slimeread.com"
|
.build()
|
||||||
|
|
||||||
private val apiUrl: String by lazy { getApiUrlFromPage() }
|
|
||||||
|
|
||||||
override val lang = "pt-BR"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
override val client by lazy {
|
|
||||||
network.cloudflareClient.newBuilder()
|
|
||||||
.rateLimit(2)
|
|
||||||
.addInterceptor { chain ->
|
|
||||||
val response = chain.proceed(chain.request())
|
|
||||||
val mime = response.headers["Content-Type"]
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
if (mime == "application/octet-stream") {
|
|
||||||
val type = "image/jpeg".toMediaType()
|
|
||||||
val body = response.body.bytes().toResponseBody(type)
|
|
||||||
return@addInterceptor response.newBuilder().body(body)
|
|
||||||
.header("Content-Type", "image/jpeg").build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun headersBuilder() = super.headersBuilder().add("Origin", baseUrl)
|
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
private fun getApiUrlFromPage(): String {
|
|
||||||
val initClient = network.cloudflareClient
|
|
||||||
val response = initClient.newCall(GET(baseUrl, headers)).execute()
|
|
||||||
if (!response.isSuccessful) throw Exception("HTTP error ${response.code}")
|
|
||||||
val document = response.asJsoup()
|
|
||||||
val scriptUrl = document.selectFirst("script[src*=pages/_app]")?.attr("abs:src")
|
|
||||||
?: throw Exception("Could not find script URL")
|
|
||||||
val scriptResponse = initClient.newCall(GET(scriptUrl, headers)).execute()
|
|
||||||
if (!scriptResponse.isSuccessful) throw Exception("HTTP error ${scriptResponse.code}")
|
|
||||||
val script = scriptResponse.body.string()
|
|
||||||
val apiUrl = FUNCTION_REGEX.find(script)?.value?.let { function ->
|
|
||||||
BASEURL_VAL_REGEX.find(function)?.groupValues?.get(1)?.let { baseUrlVar ->
|
|
||||||
val regex = """let.*?$baseUrlVar\s*=.*?(?=,\s*\w\s*=)""".toRegex(RegexOption.DOT_MATCHES_ALL)
|
|
||||||
regex.find(function)?.value?.let { varBlock ->
|
|
||||||
try {
|
|
||||||
QuickJs.create().use {
|
|
||||||
it.evaluate("$varBlock;$baseUrlVar") as String
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return apiUrl?.removeSuffix("/") ?: throw Exception("Could not find API URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================== Popular ===============================
|
|
||||||
private var currentSlice = 0
|
|
||||||
private var popularMangeCache: MangasPage? = null
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int) =
|
|
||||||
GET("$apiUrl/book_search?order=1&status=0", headers)
|
|
||||||
|
|
||||||
// Returns a large JSON, so the app can't handle the list without pagination
|
|
||||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
|
||||||
if (page == 1 || popularMangeCache == null) {
|
|
||||||
popularMangeCache = super.fetchPopularManga(page)
|
|
||||||
.toBlocking()
|
|
||||||
.last()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handling a large manga list
|
|
||||||
return Observable.just(popularMangeCache!!)
|
|
||||||
.map { mangaPage ->
|
|
||||||
val mangas = mangaPage.mangas
|
|
||||||
val pageSize = 15
|
|
||||||
|
|
||||||
currentSlice = (page - 1) * pageSize
|
|
||||||
|
|
||||||
val startIndex = min(mangas.size - 1, currentSlice)
|
|
||||||
val endIndex = min(mangas.size, currentSlice + pageSize)
|
|
||||||
|
|
||||||
val slice = mangas.subList(startIndex, endIndex)
|
|
||||||
|
|
||||||
MangasPage(slice, slice.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
|
||||||
val items = response.parseAs<List<PopularMangaDto>>()
|
|
||||||
val mangaList = items.toSMangaList()
|
|
||||||
return MangasPage(mangaList, mangaList.isNotEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== Latest ===============================
|
|
||||||
override fun latestUpdatesRequest(page: Int) = GET("$apiUrl/books?page=$page", headers)
|
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
|
||||||
val dto = response.parseAs<LatestResponseDto>()
|
|
||||||
val mangaList = dto.data.toSMangaList()
|
|
||||||
val hasNextPage = dto.page < dto.pages
|
|
||||||
return MangasPage(mangaList, hasNextPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== Search ===============================
|
|
||||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
|
||||||
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
|
|
||||||
val id = query.removePrefix(PREFIX_SEARCH)
|
|
||||||
client.newCall(GET("$apiUrl/book/$id", headers))
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map(::searchMangaByIdParse)
|
|
||||||
} else {
|
|
||||||
super.fetchSearchManga(page, query, filters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun searchMangaByIdParse(response: Response): MangasPage {
|
|
||||||
val details = mangaDetailsParse(response)
|
|
||||||
return MangasPage(listOf(details), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFilterList() = SlimeReadFilters.FILTER_LIST
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
val params = SlimeReadFilters.getSearchParameters(filters)
|
|
||||||
|
|
||||||
val url = "$apiUrl/book_search".toHttpUrl().newBuilder()
|
|
||||||
.addIfNotBlank("query", query)
|
|
||||||
.addIfNotBlank("genre[]", params.genre)
|
|
||||||
.addIfNotBlank("status", params.status)
|
|
||||||
.addIfNotBlank("searchMethod", params.searchMethod)
|
|
||||||
.apply {
|
|
||||||
params.categories.forEach {
|
|
||||||
addQueryParameter("categories[]", it)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return GET(url, headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
|
||||||
|
|
||||||
// =========================== Manga Details ============================
|
|
||||||
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.replace("/book/", "/manga/")
|
|
||||||
|
|
||||||
override fun mangaDetailsRequest(manga: SManga) = GET(apiUrl + manga.url, headers)
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
|
||||||
val info = response.parseAs<MangaInfoDto>()
|
|
||||||
thumbnail_url = info.thumbnail_url
|
|
||||||
title = info.name
|
|
||||||
description = info.description
|
|
||||||
genre = info.categories.joinToString()
|
|
||||||
url = "/book/${info.id}"
|
|
||||||
status = when (info.status) {
|
|
||||||
1 -> SManga.ONGOING
|
|
||||||
2 -> SManga.COMPLETED
|
|
||||||
3, 4 -> SManga.CANCELLED
|
|
||||||
5 -> SManga.ON_HIATUS
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================== Chapters ==============================
|
|
||||||
override fun chapterListRequest(manga: SManga) =
|
|
||||||
GET("$apiUrl/book_cap_units_all?manga_id=${manga.url.substringAfterLast("/")}", headers)
|
|
||||||
|
|
||||||
override fun chapterListParse(response: Response): List<SChapter> {
|
|
||||||
val items = response.parseAs<List<ChapterDto>>()
|
|
||||||
val mangaId = response.request.url.queryParameter("manga_id")!!
|
|
||||||
return items.map {
|
|
||||||
SChapter.create().apply {
|
|
||||||
name = "Cap " + parseChapterNumber(it.number)
|
|
||||||
chapter_number = it.number
|
|
||||||
scanlator = it.scan?.scan_name
|
|
||||||
url = "/book_cap_units?manga_id=$mangaId&cap=${it.number}"
|
|
||||||
}
|
|
||||||
}.reversed()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterNumber(number: Float): String {
|
|
||||||
val cap = number + 1F
|
|
||||||
return "%.2f".format(cap)
|
|
||||||
.let { if (cap < 10F) "0$it" else it }
|
|
||||||
.replace(",00", "")
|
|
||||||
.replace(",", ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChapterUrl(chapter: SChapter): String {
|
|
||||||
val url = "$baseUrl${chapter.url}".toHttpUrl()
|
|
||||||
val id = url.queryParameter("manga_id")!!
|
|
||||||
val cap = url.queryParameter("cap")!!.toFloat()
|
|
||||||
val num = parseChapterNumber(cap)
|
|
||||||
return "$baseUrl/ler/$id/cap-$num"
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== Pages ================================
|
|
||||||
override fun pageListRequest(chapter: SChapter) = GET(apiUrl + chapter.url, headers)
|
|
||||||
|
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
|
||||||
val body = response.body.string()
|
|
||||||
val pages = if (body.startsWith("{")) {
|
|
||||||
json.decodeFromString<Map<String, PageListDto>>(body).values.flatMap { it.pages }
|
|
||||||
} else {
|
|
||||||
json.decodeFromString<List<PageListDto>>(body).flatMap { it.pages }
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages.mapIndexed { index, item ->
|
|
||||||
Page(index, "", item.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(response: Response): String {
|
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================= Utilities ==============================
|
|
||||||
private inline fun <reified T> Response.parseAs(): T = use {
|
|
||||||
json.decodeFromStream(it.body.byteStream())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
|
|
||||||
if (value.isNotBlank()) addQueryParameter(query, value)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val PREFIX_SEARCH = "id:"
|
|
||||||
val FUNCTION_REGEX = """\{[^{]*slimeread\.com:8443[^}]*\}""".toRegex(RegexOption.DOT_MATCHES_ALL)
|
|
||||||
val BASEURL_VAL_REGEX = """baseURL\s*:\s*(\w+)""".toRegex()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|