diff --git a/src/en/asurascans/build.gradle b/src/en/asurascans/build.gradle index 7c96bc363..4ad8661bd 100644 --- a/src/en/asurascans/build.gradle +++ b/src/en/asurascans/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Asura Scans' extClass = '.AsuraScans' - extVersionCode = 49 + extVersionCode = 48 } 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 5e33aa1fb..b9bab1174 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,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) { - 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() @@ -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 { - 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 { - 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>(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(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) + 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 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" 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 80bf04ed6..b89ab68da 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,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 = 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, -)