Revert "AsuraScans: add auth and premium support (#11339)"
This reverts commit efa420949cea319a0389f7102f7e1ee83f8d1b50.
This commit is contained in:
parent
2a7a3f0e2b
commit
1251cbb432
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Asura Scans'
|
||||
extClass = '.AsuraScans'
|
||||
extVersionCode = 49
|
||||
extVersionCode = 48
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package eu.kanade.tachiyomi.extension.en.asurascans
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.webkit.CookieManager
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
@ -18,27 +16,17 @@ import keiyoushi.utils.getPreferences
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.text.RegexOption
|
||||
|
||||
class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
@ -56,15 +44,6 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
private val preferences: SharedPreferences = getPreferences()
|
||||
|
||||
private val application: Application by injectLazy()
|
||||
private val cookieManager by lazy { CookieManager.getInstance() }
|
||||
|
||||
@Volatile
|
||||
private var cachedAuthState: Boolean? = null
|
||||
|
||||
@Volatile
|
||||
private var lastAuthCheck: Long = 0L
|
||||
|
||||
init {
|
||||
// remove legacy preferences
|
||||
preferences.run {
|
||||
@ -86,30 +65,6 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.cookieJar(
|
||||
object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
if (cookies.isEmpty()) return
|
||||
for (cookie in cookies) {
|
||||
runCatching {
|
||||
cookieManager.setCookie(url.toString(), cookie.toString())
|
||||
}
|
||||
}
|
||||
runCatching { cookieManager.flush() }
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): MutableList<Cookie> {
|
||||
val cookieString = runCatching {
|
||||
cookieManager.getCookie(url.toString())
|
||||
}.getOrNull() ?: return mutableListOf()
|
||||
|
||||
return cookieString.split(';')
|
||||
.mapNotNull { Cookie.parse(url, it.trim()) }
|
||||
.toMutableList()
|
||||
}
|
||||
},
|
||||
)
|
||||
.addInterceptor(::authInterceptor)
|
||||
.addInterceptor(::forceHighQualityInterceptor)
|
||||
.rateLimit(2, 2)
|
||||
.build()
|
||||
@ -119,32 +74,19 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
private fun forceHighQualityInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
val shouldTryHighQuality = runCatching {
|
||||
request.header(HQ_ATTEMPT_HEADER) == null &&
|
||||
preferences.forceHighQuality() &&
|
||||
isAuthenticated() &&
|
||||
!failedHighQuality &&
|
||||
request.url.fragment == "pageListParse"
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (shouldTryHighQuality) {
|
||||
STANDARD_IMAGE_PATH_REGEX.find(request.url.encodedPath)?.also { match ->
|
||||
val (id, filename) = match.destructured
|
||||
val optimizedName = "$filename-optimized.webp"
|
||||
val optimizedUrl = request.url.newBuilder()
|
||||
.encodedPath("/storage/media/$id/conversions/$optimizedName")
|
||||
if (preferences.forceHighQuality() && !failedHighQuality && request.url.fragment == "pageListParse") {
|
||||
OPTIMIZED_IMAGE_PATH_REGEX.find(request.url.encodedPath)?.also { match ->
|
||||
val (id, page) = match.destructured
|
||||
val newUrl = request.url.newBuilder()
|
||||
.encodedPath("/storage/media/$id/$page.webp")
|
||||
.build()
|
||||
|
||||
val hiResRequest = request.newBuilder()
|
||||
.url(optimizedUrl)
|
||||
.header(HQ_ATTEMPT_HEADER, "1")
|
||||
.build()
|
||||
val response = runCatching { chain.proceed(hiResRequest) }.getOrNull()
|
||||
if (response?.isSuccessful == true) {
|
||||
val response = chain.proceed(request.newBuilder().url(newUrl).build())
|
||||
if (response.code != 404) {
|
||||
return response
|
||||
} else {
|
||||
failedHighQuality = true
|
||||
response?.close()
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -152,21 +94,6 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun authInterceptor(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
handleSessionExpiry(response)
|
||||
}
|
||||
|
||||
val location = response.header("Location")
|
||||
if (location != null && location.contains("/login", ignoreCase = true)) {
|
||||
handleSessionExpiry(response)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
@ -339,14 +266,8 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListSelector(): String {
|
||||
val authenticated = runCatching { isAuthenticated() }.getOrDefault(false)
|
||||
return when {
|
||||
authenticated -> "div.scrollbar-thumb-themecolor > div.group"
|
||||
preferences.hidePremiumChapters() -> "div.scrollbar-thumb-themecolor > div.group:not(:has(svg))"
|
||||
else -> "div.scrollbar-thumb-themecolor > div.group"
|
||||
}
|
||||
}
|
||||
override fun chapterListSelector() =
|
||||
if (preferences.hidePremiumChapters()) "div.scrollbar-thumb-themecolor > div.group:not(:has(svg))" else "div.scrollbar-thumb-themecolor > div.group"
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("abs:href").toPermSlugIfNeeded())
|
||||
@ -372,157 +293,24 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val chapterMeta = document.extractChapterMetadata()
|
||||
val isPremium = chapterMeta?.isEarlyAccess == true
|
||||
|
||||
if (isPremium) {
|
||||
val chapterId = chapterMeta?.id ?: throw Exception("Chapter metadata not found")
|
||||
if (!isAuthenticated()) {
|
||||
throw Exception(PREMIUM_AUTH_MESSAGE)
|
||||
}
|
||||
|
||||
val unlockData = unlockChapter(chapterId)
|
||||
val unlockToken = unlockData.unlockToken ?: run {
|
||||
resetAuthCache()
|
||||
throw Exception("Missing unlock token. Please login again.")
|
||||
}
|
||||
|
||||
val quality = MEDIA_QUALITY_MAX
|
||||
val orderedPages = unlockData.pages.sortedBy { it.order }
|
||||
if (orderedPages.isEmpty()) {
|
||||
throw Exception("Premium chapter pages unavailable.")
|
||||
}
|
||||
|
||||
return orderedPages.mapIndexed { index, page ->
|
||||
val imageUrl = fetchMediaUrl(page.id, chapterId, unlockToken, quality)
|
||||
Page(index, imageUrl = appendPageFragment(imageUrl))
|
||||
}
|
||||
}
|
||||
|
||||
return parseStandardPageList(document)
|
||||
}
|
||||
|
||||
private fun parseStandardPageList(document: Document): List<Page> {
|
||||
val scriptElement = document.select("script")
|
||||
.firstOrNull { PAGES_REGEX.containsMatchIn(it.data()) }
|
||||
?: throw Exception("Failed to find chapter pages")
|
||||
val scriptData = scriptElement.data()
|
||||
val pagesData = PAGES_REGEX.find(scriptData)?.groupValues?.get(1)
|
||||
?: throw Exception("Failed to find chapter pages")
|
||||
val scriptData = document.select("script:containsData(self.__next_f.push)")
|
||||
.joinToString("") { it.data().substringAfter("\"").substringBeforeLast("\"") }
|
||||
val pagesData = PAGES_REGEX.find(scriptData)?.groupValues?.get(1) ?: throw Exception("Failed to find chapter pages")
|
||||
val pageList = json.decodeFromString<List<PageDto>>(pagesData.unescape()).sortedBy { it.order }
|
||||
return pageList.mapIndexed { index, page ->
|
||||
Page(index, imageUrl = appendPageFragment(page.url))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Document.extractChapterMetadata(): ChapterMetadata? {
|
||||
val script = select("script")
|
||||
.map { it.data() }
|
||||
.firstOrNull { it.contains(CHAPTER_DATA_TOKEN) }
|
||||
?: return null
|
||||
val match = CHAPTER_DATA_REGEX.find(script) ?: return null
|
||||
val id = match.groupValues[1].toIntOrNull() ?: return null
|
||||
val isEarly = match.groupValues[2].toBoolean()
|
||||
return ChapterMetadata(id, isEarly)
|
||||
}
|
||||
|
||||
private fun appendPageFragment(url: String): String {
|
||||
return url.toHttpUrlOrNull()?.newBuilder()
|
||||
?.fragment("pageListParse")
|
||||
?.build()
|
||||
?.toString()
|
||||
?: url
|
||||
}
|
||||
|
||||
private fun buildApiPostRequest(url: String, body: RequestBody): Request {
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.headers(headersBuilder().build())
|
||||
.post(body)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
getXsrfToken()?.let { token ->
|
||||
builder.addHeader("X-XSRF-TOKEN", token)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun getXsrfToken(): String? {
|
||||
val cookieToken = sequence {
|
||||
apiUrl.toHttpUrlOrNull()?.let { yield(it) }
|
||||
baseUrl.toHttpUrlOrNull()?.let { yield(it) }
|
||||
}.mapNotNull { httpUrl ->
|
||||
client.cookieJar.loadForRequest(httpUrl)
|
||||
.firstOrNull { it.name.equals("XSRF-TOKEN", ignoreCase = true) }
|
||||
?.value
|
||||
}.firstOrNull()
|
||||
|
||||
return decodeCookieValue(cookieToken)
|
||||
}
|
||||
|
||||
private fun decodeCookieValue(value: String?): String? {
|
||||
if (value.isNullOrEmpty()) return null
|
||||
return runCatching { URLDecoder.decode(value, StandardCharsets.UTF_8.name()) }.getOrDefault(value)
|
||||
}
|
||||
|
||||
private fun unlockChapter(chapterId: Int): UnlockDataDto {
|
||||
val payload = json.encodeToString(UnlockRequestDto(chapterId))
|
||||
val body = payload.toRequestBody(JSON_MEDIA_TYPE)
|
||||
val request = buildApiPostRequest("$apiUrl/chapter/unlock", body)
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: throw Exception("Empty unlock response")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
if (response.code == 401 || response.code == 403 || response.code == 419) {
|
||||
resetAuthCache()
|
||||
throw Exception("Session expired. Please login again.")
|
||||
}
|
||||
throw Exception("Unable to unlock premium chapter. (${response.code})")
|
||||
return pageList.mapIndexed { i, page ->
|
||||
val newUrl = page.url.toHttpUrlOrNull()?.run {
|
||||
newBuilder()
|
||||
.fragment("pageListParse")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
|
||||
val unlock = json.decodeFromString<UnlockResponseDto>(responseBody)
|
||||
val data = unlock.data
|
||||
if (!unlock.success || data == null || data.unlockToken.isNullOrEmpty()) {
|
||||
resetAuthCache()
|
||||
throw Exception(unlock.message ?: "Unable to unlock premium chapter. Please login again.")
|
||||
}
|
||||
cachedAuthState = true
|
||||
lastAuthCheck = System.currentTimeMillis()
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchMediaUrl(mediaId: Int, chapterId: Int, token: String, quality: String): String {
|
||||
val payload = json.encodeToString(MediaRequestDto(mediaId, chapterId, token, quality))
|
||||
val body = payload.toRequestBody(JSON_MEDIA_TYPE)
|
||||
val request = buildApiPostRequest("$apiUrl/media", body)
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: throw Exception("Empty media response")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
if (response.code == 401 || response.code == 403 || response.code == 419) {
|
||||
resetAuthCache()
|
||||
throw Exception("Session expired while fetching media. Please login again.")
|
||||
}
|
||||
throw Exception("Unable to fetch media URL. (${response.code})")
|
||||
}
|
||||
|
||||
val media = json.decodeFromString<MediaResponseDto>(responseBody)
|
||||
return appendPageFragment(media.data)
|
||||
Page(i, imageUrl = newUrl ?: page.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||
|
||||
private data class ChapterMetadata(
|
||||
val id: Int,
|
||||
val isEarlyAccess: Boolean,
|
||||
)
|
||||
|
||||
private enum class FiltersState { NOT_FETCHED, FETCHING, FETCHED }
|
||||
|
||||
private inline fun <reified R> List<*>.firstInstanceOrNull(): R? =
|
||||
@ -545,19 +333,12 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_FORCE_HIGH_QUALITY
|
||||
title = "Enable max quality"
|
||||
val baseSummary = "Asura+ Basic/Premium subscribers can request optimized max quality images. Requires authentication. Increases bandwidth by ~50%."
|
||||
summary = if (failedHighQuality) {
|
||||
"$baseSummary\n*DISABLED* because of missing max quality images."
|
||||
} else {
|
||||
baseSummary
|
||||
title = "Force high quality chapter images"
|
||||
summary = "Attempt to use high quality chapter images.\nWill increase bandwidth by ~50%."
|
||||
if (failedHighQuality) {
|
||||
summary = "$summary\n*DISABLED* because of missing high quality images."
|
||||
}
|
||||
setDefaultValue(false)
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
failedHighQuality = false
|
||||
summary = baseSummary
|
||||
true
|
||||
}
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
@ -598,66 +379,13 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource {
|
||||
return UNESCAPE_REGEX.replace(this, "$1")
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(response: Response): Nothing {
|
||||
resetAuthCache()
|
||||
response.close()
|
||||
throw Exception("Authentication failed. Please login again via WebView.")
|
||||
}
|
||||
|
||||
private fun resetAuthCache() {
|
||||
cachedAuthState = null
|
||||
lastAuthCheck = 0L
|
||||
}
|
||||
|
||||
private fun isAuthenticated(force: Boolean = false): Boolean {
|
||||
// Check if we have WebView cookies
|
||||
val hasWebViewCookies = runCatching {
|
||||
cookieManager.getCookie("https://$ASURA_MAIN_HOST")?.isNotEmpty() == true ||
|
||||
cookieManager.getCookie("https://$ASURA_API_HOST")?.isNotEmpty() == true
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (!hasWebViewCookies) {
|
||||
cachedAuthState = false
|
||||
lastAuthCheck = System.currentTimeMillis()
|
||||
return false
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val cached = cachedAuthState
|
||||
if (!force && cached != null && now - lastAuthCheck < AUTH_CACHE_DURATION) {
|
||||
return cached
|
||||
}
|
||||
|
||||
val request = GET("$apiUrl/user", headersBuilder().build())
|
||||
val isAuthed = runCatching { client.newCall(request).execute() }.getOrNull()?.use { resp ->
|
||||
if (!resp.isSuccessful) return@use false
|
||||
val body = resp.body?.string() ?: return@use false
|
||||
val root = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return@use false
|
||||
root["data"] != null
|
||||
} ?: false
|
||||
|
||||
cachedAuthState = isAuthed
|
||||
lastAuthCheck = now
|
||||
return isAuthed
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val UNESCAPE_REGEX = """\\(.)""".toRegex()
|
||||
private val PAGES_REGEX = """\\"pages\\":(\[.*?])""".toRegex()
|
||||
private val CLEAN_DATE_REGEX = """(\d+)(st|nd|rd|th)""".toRegex()
|
||||
private val OLD_FORMAT_MANGA_REGEX = """^/manga/(\d+-)?([^/]+)/?$""".toRegex()
|
||||
private val OLD_FORMAT_CHAPTER_REGEX = """^/(\d+-)?[^/]*-chapter-\d+(-\d+)*/?$""".toRegex()
|
||||
private val STANDARD_IMAGE_PATH_REGEX = """^/storage/media/(\d+)/([^/]+?)\.[^./]+$""".toRegex(RegexOption.IGNORE_CASE)
|
||||
private val CHAPTER_DATA_REGEX = """\\"chapter\\":\{\\"id\\":(\d+).*?\\"is_early_access\\":(true|false)""".toRegex(RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
private const val ASURA_MAIN_HOST = "asuracomic.net"
|
||||
private const val ASURA_API_HOST = "gg.asuracomic.net"
|
||||
private const val AUTH_CACHE_DURATION = 60_000L
|
||||
private const val CHAPTER_DATA_TOKEN = """\"chapter\":"""
|
||||
private const val PREMIUM_AUTH_MESSAGE = "Premium chapter requires authentication. Login via WebView."
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
private const val MEDIA_QUALITY_MAX = "max-quality"
|
||||
private const val HQ_ATTEMPT_HEADER = "X-Asura-HQ-Attempt"
|
||||
private val OPTIMIZED_IMAGE_PATH_REGEX = """^/storage/media/(\d+)/conversions/(.*)-optimized\.webp$""".toRegex()
|
||||
|
||||
private const val PREF_SLUG_MAP = "pref_slug_map_2"
|
||||
private const val PREF_DYNAMIC_URL = "pref_dynamic_url"
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package eu.kanade.tachiyomi.extension.en.asurascans
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@ -21,51 +20,3 @@ class PageDto(
|
||||
val order: Int,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class UnlockRequestDto(
|
||||
@SerialName("chapterId")
|
||||
val chapterId: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class UnlockResponseDto(
|
||||
val success: Boolean,
|
||||
val data: UnlockDataDto? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class UnlockDataDto(
|
||||
val id: Int,
|
||||
val name: Int,
|
||||
val title: String? = null,
|
||||
@SerialName("is_early_access")
|
||||
val isEarlyAccess: Boolean,
|
||||
@SerialName("unlock_token")
|
||||
val unlockToken: String? = null,
|
||||
val pages: List<UnlockPageDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class UnlockPageDto(
|
||||
val order: Int,
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MediaRequestDto(
|
||||
@SerialName("media_id")
|
||||
val mediaId: Int,
|
||||
@SerialName("chapter_id")
|
||||
val chapterId: Int,
|
||||
val token: String,
|
||||
val quality: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MediaResponseDto(
|
||||
val data: String,
|
||||
@SerialName("content-type")
|
||||
val contentType: String? = null,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user