diff --git a/src/en/asurascans/build.gradle b/src/en/asurascans/build.gradle index 4ad8661bd..7c96bc363 100644 --- a/src/en/asurascans/build.gradle +++ b/src/en/asurascans/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Asura Scans' extClass = '.AsuraScans' - extVersionCode = 48 + extVersionCode = 49 } apply from: "$rootDir/common.gradle" diff --git a/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScans.kt b/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScans.kt index b9bab1174..5e33aa1fb 100644 --- a/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScans.kt +++ b/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScans.kt @@ -1,6 +1,8 @@ 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 @@ -16,17 +18,27 @@ 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 { @@ -44,6 +56,15 @@ 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 { @@ -65,6 +86,30 @@ 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) { + if (cookies.isEmpty()) return + for (cookie in cookies) { + runCatching { + cookieManager.setCookie(url.toString(), cookie.toString()) + } + } + runCatching { cookieManager.flush() } + } + + override fun loadForRequest(url: HttpUrl): MutableList { + 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() @@ -74,19 +119,32 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource { private fun forceHighQualityInterceptor(chain: Interceptor.Chain): Response { val request = chain.request() - 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") + 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") .build() - val response = chain.proceed(request.newBuilder().url(newUrl).build()) - if (response.code != 404) { + val hiResRequest = request.newBuilder() + .url(optimizedUrl) + .header(HQ_ATTEMPT_HEADER, "1") + .build() + val response = runCatching { chain.proceed(hiResRequest) }.getOrNull() + if (response?.isSuccessful == true) { return response } else { failedHighQuality = true - response.close() + response?.close() } } } @@ -94,6 +152,21 @@ 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/") @@ -266,8 +339,14 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource { override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) - override fun chapterListSelector() = - if (preferences.hidePremiumChapters()) "div.scrollbar-thumb-themecolor > div.group:not(:has(svg))" else "div.scrollbar-thumb-themecolor > div.group" + 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 chapterFromElement(element: Element) = SChapter.create().apply { setUrlWithoutDomain(element.selectFirst("a")!!.attr("abs:href").toPermSlugIfNeeded()) @@ -293,24 +372,157 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource { } override fun pageListParse(document: Document): List { - 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>(pagesData.unescape()).sortedBy { it.order } - return pageList.mapIndexed { i, page -> - val newUrl = page.url.toHttpUrlOrNull()?.run { - newBuilder() - .fragment("pageListParse") - .build() - .toString() + 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) } - Page(i, imageUrl = newUrl ?: page.url) + 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 { + 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 pageList = json.decodeFromString>(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})") + } + + val unlock = json.decodeFromString(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(responseBody) + return appendPageFragment(media.data) } } 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 List<*>.firstInstanceOrNull(): R? = @@ -333,12 +545,19 @@ class AsuraScans : ParsedHttpSource(), ConfigurableSource { SwitchPreferenceCompat(screen.context).apply { key = PREF_FORCE_HIGH_QUALITY - 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." + 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 } setDefaultValue(false) + setOnPreferenceChangeListener { _, _ -> + failedHighQuality = false + summary = baseSummary + true + } }.let(screen::addPreference) } @@ -379,13 +598,66 @@ 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 OPTIMIZED_IMAGE_PATH_REGEX = """^/storage/media/(\d+)/conversions/(.*)-optimized\.webp$""".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 const val PREF_SLUG_MAP = "pref_slug_map_2" private const val PREF_DYNAMIC_URL = "pref_dynamic_url" diff --git a/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScansDto.kt b/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScansDto.kt index b89ab68da..80bf04ed6 100644 --- a/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScansDto.kt +++ b/src/en/asurascans/src/eu/kanade/tachiyomi/extension/en/asurascans/AsuraScansDto.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.extension.en.asurascans +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -20,3 +21,51 @@ 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 = 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, +)