diff --git a/src/vi/ariverse/build.gradle.kts b/src/vi/ariverse/build.gradle.kts index 2c20f201ac4..01028822156 100644 --- a/src/vi/ariverse/build.gradle.kts +++ b/src/vi/ariverse/build.gradle.kts @@ -6,9 +6,9 @@ plugins { keiyoushi { name = "Ariverse" - versionCode = 53 + versionCode = 54 contentWarning = ContentWarning.MIXED - libVersion = "1.4" + libVersion = "1.6" source { lang = "vi" @@ -17,4 +17,8 @@ keiyoushi { } id = 4480433466073326866 } + + deeplink { + path("/comic/story/..*") + } } diff --git a/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Ariverse.kt b/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Ariverse.kt index 55d6f94f1aa..721af81f5a0 100644 --- a/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Ariverse.kt +++ b/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Ariverse.kt @@ -1,198 +1,113 @@ package eu.kanade.tachiyomi.extension.vi.ariverse -import android.annotation.SuppressLint -import android.app.Application import android.content.SharedPreferences -import android.os.Handler -import android.os.Looper -import android.webkit.JavascriptInterface -import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebViewClient import androidx.preference.CheckBoxPreference import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.ConfigurableSource 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.source.model.SMangaUpdate import keiyoushi.annotation.Source +import keiyoushi.network.get import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource +import keiyoushi.utils.array +import keiyoushi.utils.get +import keiyoushi.utils.getLocalStorage import keiyoushi.utils.getPreferences import keiyoushi.utils.parseAs -import keiyoushi.utils.tryParse +import keiyoushi.utils.string +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response +import okhttp3.OkHttpClient import org.jsoup.Jsoup -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit +import kotlin.time.Instant @Source abstract class Ariverse : - HttpSource(), + KeiSource(), ConfigurableSource { private val apiUrl get() = baseUrl.replace("https://", "https://be.") + "/api/v1" private val imageUrl get() = baseUrl.replace("https://", "https://img.") - override val supportsLatest = true - private val preferences: SharedPreferences = getPreferences() - private var cachedAuthToken: String? = null - - private val webViewUserAgent: String? by lazy { - runCatching { WebSettings.getDefaultUserAgent(Injekt.get()) } - .getOrNull() - ?.let { it.replace(WEBVIEW_TOKEN_REGEX, ")") } - ?.takeIf { it.isNotBlank() } - } - - override val client = network.client.newBuilder() - .addInterceptor(authInterceptor()) - .rateLimit(3) - .build() - - override fun headersBuilder() = super.headersBuilder() - .add("Referer", "$baseUrl/") - .add("Accept", "application/json") - .apply { webViewUserAgent?.let { set("User-Agent", it) } } - - private fun apiHeaders() = headersBuilder().apply { - authToken?.let { set("Authorization", "Bearer $it") } - }.build() - private val allowR18 get() = preferences.getBoolean("pref_r18", false) - // ============================== Auth ==================================== + // ============================== Client ================================ - private val authToken: String? - @Synchronized - get() = cachedAuthToken - ?: getTokenFromWebView()?.also { cachedAuthToken = it } - - @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") - private fun getTokenFromWebView(): String? { - val handler = Handler(Looper.getMainLooper()) - val latch = CountDownLatch(1) - val interfaceName = randomJavascriptInterfaceName() - val bridge = TokenBridge(latch) - var webView: WebView? = null - - handler.post { - webView = WebView(Injekt.get()).apply { - with(settings) { - javaScriptEnabled = true - domStorageEnabled = true - databaseEnabled = true - blockNetworkImage = true - webViewUserAgent?.let { userAgentString = it } - } - addJavascriptInterface(bridge, interfaceName) - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - view?.evaluateJavascript( - """ - (function() { - try { - var token = localStorage.getItem('token'); - window['$interfaceName'].onToken(token || ''); - } catch(e) { - window['$interfaceName'].onToken(''); - } - })(); - """.trimIndent(), - null, - ) - } - } - loadDataWithBaseURL(baseUrl, " ", "text/html", "UTF-8", null) - } - } - - latch.await(10, TimeUnit.SECONDS) - - handler.post { - webView?.removeJavascriptInterface(interfaceName) - webView?.destroy() - } - - return bridge.token?.takeIf { it.isNotBlank() } + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = apply { + addInterceptor(authInterceptor()) + rateLimit(3) } - private class TokenBridge(private val latch: CountDownLatch) { - @Volatile - var token: String? = null + // ============================== Auth ==================================== - @JavascriptInterface - fun onToken(value: String?) { - token = value - latch.countDown() - } + private var cachedAuthToken: String? = null + private var authChecked = false + + private suspend fun loadAuthToken() { + if (authChecked) return + authChecked = true + cachedAuthToken = getLocalStorage(baseUrl, "token") + ?.takeIf { it.isNotBlank() } } - private fun authInterceptor() = Interceptor { chain -> + private fun authInterceptor() = okhttp3.Interceptor { chain -> val original = chain.request() - val token = authToken - - val request = if (token != null) { - original.newBuilder() - .header("Authorization", "Bearer $token") - .build() - } else { - original - } - + val request = original.newBuilder().apply { + cachedAuthToken?.let { header("Authorization", "Bearer $it") } + if (original.url.encodedPath.startsWith("/api/")) { + header("Accept", "application/json") + } + }.build() chain.proceed(request) } - private fun randomJavascriptInterfaceName(): String { - val pool = ('a'..'z') + ('A'..'Z') - return (1..(10..20).random()) - .map { pool.random() } - .joinToString("") - } - // ============================== Popular =============================== - override fun popularMangaRequest(page: Int): Request { + override suspend fun getPopularManga(page: Int): MangasPage { + loadAuthToken() val url = "$apiUrl/stories/hot".toHttpUrl().newBuilder() .addQueryParameter("type", "comic") .addQueryParameter("is_18_plus", if (allowR18) "1" else "0") .addQueryParameter("limit", "50") .addQueryParameter("period", "week") .build() - return GET(url, apiHeaders()) + val result = client.get(url).parseAs() + + val mangas = result.data.map { createManga(it) } + + return MangasPage(mangas, false) } - override fun popularMangaParse(response: Response): MangasPage { - val result = response.parseAs() + private fun createManga(story: Story): SManga = SManga.create().apply { + setUrlWithoutDomain("/comic/story/${story.slug}") + title = story.title + thumbnail_url = story.coverPath?.let { resolveCoverUrl(it) } + } - val mangas = result.data.map { story -> - SManga.create().apply { - url = "/comic/story/${story.slug}" - title = story.title - thumbnail_url = story.coverPath?.let { resolveCoverUrl(it) } - } + private fun resolveCoverUrl(coverPath: String): String { + if (coverPath.startsWith("http://") || coverPath.startsWith("https://")) { + return coverPath } - - return MangasPage(mangas, false) + return "$imageUrl/${coverPath.replace("\\", "/")}" } // ============================== Latest ================================ - override fun latestUpdatesRequest(page: Int): Request { + override suspend fun getLatestUpdates(page: Int): MangasPage { + loadAuthToken() val url = "$apiUrl/stories".toHttpUrl().newBuilder() .addQueryParameter("type", "comic") .addQueryParameter("is_18_plus", if (allowR18) "1" else "0") @@ -201,14 +116,18 @@ abstract class Ariverse : .addQueryParameter("per_page", "50") .addQueryParameter("page", page.toString()) .build() - return GET(url, apiHeaders()) + val result = client.get(url).parseAs() + return parseMangaPage(result) } - override fun latestUpdatesParse(response: Response): MangasPage = parseMangaPage(response) - // ============================== Search ================================ - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + override suspend fun getSearchMangaList( + page: Int, + query: String, + filters: FilterList, + ): MangasPage { + loadAuthToken() val url = "$apiUrl/stories".toHttpUrl().newBuilder().apply { addQueryParameter("type", "comic") addQueryParameter("is_18_plus", if (allowR18) "1" else "0") @@ -241,44 +160,27 @@ abstract class Ariverse : } }.build() - return GET(url, apiHeaders()) + val result = client.get(url).parseAs() + return parseMangaPage(result) } - override fun searchMangaParse(response: Response): MangasPage = parseMangaPage(response) - - private fun parseMangaPage(response: Response): MangasPage { - val result = response.parseAs() - - val mangas = result.data.map { story -> - SManga.create().apply { - url = "/comic/story/${story.slug}" - title = story.title - thumbnail_url = story.coverPath?.let { resolveCoverUrl(it) } - } - } + private fun parseMangaPage(result: StoryListResponse): MangasPage { + val mangas = result.data.map { createManga(it) } val hasNextPage = result.currentPage < result.lastPage return MangasPage(mangas, hasNextPage) } - // ============================== Filters =============================== - - override fun getFilterList(): FilterList = getFilters() + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + loadAuthToken() + val slug = url.pathSegments.lastOrNull() ?: return null - // ============================== Details =============================== - - override fun mangaDetailsRequest(manga: SManga): Request { - val slug = manga.url.substringAfterLast("/") - val url = "$apiUrl/stories/$slug".toHttpUrl() - return GET(url, apiHeaders()) - } - - override fun mangaDetailsParse(response: Response): SManga { - val story = response.parseAs().data + val story = client.get("$apiUrl/stories/$slug") + .parseAs().data return SManga.create().apply { - url = "/comic/story/${story.slug}" + setUrlWithoutDomain("/comic/story/$slug") title = story.title thumbnail_url = story.coverPath?.let { resolveCoverUrl(it) } author = story.author @@ -286,24 +188,32 @@ abstract class Ariverse : description = story.description?.let { parseDescription(it) } genre = story.genres?.joinToString { it.name } status = parseStatus(story.status) + initialized = true } } - private fun resolveCoverUrl(coverPath: String): String { - if (coverPath.startsWith("http://") || coverPath.startsWith("https://")) { - return coverPath - } - return "$imageUrl/${coverPath.replace("\\", "/")}" + // ============================== Filters =============================== + + override val supportsFilterFetching get() = true + + override suspend fun fetchFilterData(): JsonElement = client.get("$apiUrl/genres").parseAs() + + override fun getFilterList(data: JsonElement?): FilterList { + val genres = data["data"]?.array + ?: return buildGenreFilter(null) + return buildGenreFilter(genres) } + // ============================== Details + Chapters ==================== + private fun parseDescription(html: String): String { val normalized = html - .replace(BR_TAG_REGEX, "\n") + .replace(brTagRegex, "\n") .replace(" ", " ") return Jsoup.parse(normalized).wholeText() - .replace(HORIZONTAL_SPACE_REGEX, " ") - .replace(MULTI_NEWLINE_REGEX, "\n") + .replace(horizontalSpaceRegex, " ") + .replace(multiNewlineRegex, "\n") } private fun parseStatus(status: String?): Int = when { @@ -313,44 +223,88 @@ abstract class Ariverse : else -> SManga.UNKNOWN } - // ============================== Chapters ============================== + override fun getMangaUrl(manga: SManga): String = baseUrl + manga.url + + override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url - override fun chapterListRequest(manga: SManga): Request { + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate { + loadAuthToken() val slug = manga.url.substringAfterLast("/") - val url = "$apiUrl/stories/$slug/chapters".toHttpUrl() - return GET(url, apiHeaders()) - } - override fun chapterListParse(response: Response): List { - val chapterData = response.parseAs().data - - return chapterData.chapters - .sortedByDescending { it.number } - .map { chapter -> - SChapter.create().apply { - url = "/comic/story/${chapterData.story.slug}/${chapter.slug}" - name = chapter.title - chapter_number = chapter.number.toFloat() - date_upload = chapter.publishedAt?.let { DATE_FORMAT.tryParse(it) } ?: 0L + val updatedManga: SManga + val updatedChapters: List + + coroutineScope { + val mangaDeferred = if (fetchDetails) { + async { + val story = client.get("$apiUrl/stories/$slug") + .parseAs().data + + SManga.create().apply { + setUrlWithoutDomain("/comic/story/${story.slug}") + title = story.title + thumbnail_url = story.coverPath?.let { resolveCoverUrl(it) } + author = story.author + artist = story.artist + description = story.description?.let { parseDescription(it) } + genre = story.genres?.joinToString { it.name } + status = parseStatus(story.status) + memo = buildJsonObject { + story.genres?.firstOrNull()?.slug?.let { put("genreSlug", JsonPrimitive(it)) } + } + } + } + } else { + null + } + + val chaptersDeferred = if (fetchChapters) { + async { + val chapterData = client.get("$apiUrl/stories/$slug/chapters") + .parseAs().data + + chapterData.chapters + .sortedByDescending { it.number } + .map { chapter -> + SChapter.create().apply { + setUrlWithoutDomain("/comic/story/${chapterData.story.slug}/${chapter.slug}") + name = chapter.title ?: "${chapter.number.toInt()}" + chapter_number = chapter.number.toFloat() + date_upload = chapter.publishedAt?.let { Instant.parseOrNull(it)?.toEpochMilliseconds() } ?: 0L + } + } } + } else { + null } + + updatedManga = mangaDeferred?.await() ?: manga + updatedChapters = chaptersDeferred?.await() ?: chapters + } + + return SMangaUpdate( + manga = updatedManga, + chapters = updatedChapters, + ) } // ============================== Pages ================================= - override fun pageListRequest(chapter: SChapter): Request { + override suspend fun getPageList(chapter: SChapter): List { + loadAuthToken() val parts = chapter.url.trim('/').split("/") val storySlug = parts.getOrElse(2) { "" } val chapterSlug = parts.getOrElse(3) { "" } val url = "$apiUrl/stories/$storySlug/chapters/$chapterSlug".toHttpUrl() - return GET(url, apiHeaders()) - } - - override fun pageListParse(response: Response): List { - val chapterDetail = response.parseAs().data + val chapterDetail = client.get(url).parseAs().data if (chapterDetail.contentLocked) { - throw Exception(LOGIN_WEBVIEW_MESSAGE) + throw Exception(loginWebviewMessage) } val content = chapterDetail.content.orEmpty() @@ -364,7 +318,28 @@ abstract class Ariverse : } } - override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + // ============================== Related =============================== + + override suspend fun fetchRelatedMangaList(manga: SManga): List { + val slug = manga.url.substringAfterLast("/") + val genreSlug = manga.memo["genreSlug"]?.string + ?: client.get("$apiUrl/stories/$slug") + .parseAs().data.genres?.firstOrNull()?.slug + ?: return emptyList() + + val result = client.get( + "$apiUrl/stories".toHttpUrl().newBuilder() + .addQueryParameter("type", "comic") + .addQueryParameter("genre", genreSlug) + .addQueryParameter("per_page", "12") + .addQueryParameter("page", "1") + .build(), + ).parseAs() + + return result.data + .filter { it.slug != slug } + .map { createManga(it) } + } // ============================== Settings ============================== @@ -377,19 +352,9 @@ abstract class Ariverse : }.let(screen::addPreference) } - companion object { - private val BR_TAG_REGEX = Regex("""""", RegexOption.IGNORE_CASE) - private val HORIZONTAL_SPACE_REGEX = Regex("[\\t\\x0B\\f\\r ]+") - private val MULTI_NEWLINE_REGEX = Regex("\\n{2,}") + private val brTagRegex = Regex("""""", RegexOption.IGNORE_CASE) + private val horizontalSpaceRegex = Regex("[\\t\\x0B\\f\\r ]+") + private val multiNewlineRegex = Regex("\\n{2,}") - private val DATE_FORMAT by lazy { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply { - timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh") - } - } - - private val WEBVIEW_TOKEN_REGEX = Regex(""";\s*wv\)""") - - private const val LOGIN_WEBVIEW_MESSAGE = "Vui lòng đăng nhập vào tài khoản phù hợp qua Webview để đọc chương này" - } + private val loginWebviewMessage = "Vui lòng đăng nhập vào tài khoản phù hợp qua Webview để đọc chương này" } diff --git a/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Dto.kt b/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Dto.kt index d6cd0132c9d..84010a79aab 100644 --- a/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Dto.kt +++ b/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Dto.kt @@ -98,7 +98,7 @@ class Chapter( @SerialName("story_id") val storyId: Int, val volume: Int? = null, val number: Double, - val title: String, + val title: String? = null, val slug: String, @SerialName("is_public") val isPublic: Boolean = true, @SerialName("published_at") val publishedAt: String? = null, @@ -115,7 +115,7 @@ class ChapterDetail( @SerialName("story_id") val storyId: Int, val volume: Int? = null, val number: Double, - val title: String, + val title: String? = null, val slug: String, @SerialName("is_public") val isPublic: Boolean = true, @SerialName("published_at") val publishedAt: String? = null, diff --git a/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Filters.kt b/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Filters.kt index 0c80223a419..3bef636a67c 100644 --- a/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Filters.kt +++ b/src/vi/ariverse/src/eu/kanade/tachiyomi/extension/vi/ariverse/Filters.kt @@ -2,6 +2,10 @@ package eu.kanade.tachiyomi.extension.vi.ariverse import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList +import keiyoushi.utils.string +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject class GenreFilter( name: String, @@ -58,40 +62,19 @@ class SortFilter : } } -private val GENRE_LIST = listOf( - "18+" to "18", - "Âu Cổ" to "au-co", - "Cách biệt tuổi tác" to "cach-biet-tuoi-tac", - "Chiếm hữu" to "chiem-huu", - "Cổ trang" to "co-trang", - "Drama" to "drama", - "Fantasy" to "fantasy", - "Girl Crush" to "girl-crush", - "Hài hước" to "hai-huoc", - "Học đường" to "hoc-duong", - "Horror" to "horror", - "Idol" to "idol", - "Mafia" to "mafia", - "Manhua" to "manhua", - "Manhwa" to "manhwa", - "Mọt sách" to "mot-sach", - "Mùa hè và biển cả" to "mua-he-va-bien-ca", - "Người lớn" to "nguoi-lon", - "Nhẹ nhàng" to "nhe-nhang", - "One-shot" to "one-shot", - "Quan hệ Chủ-Tớ" to "quan-he-chu-to", - "Thanh Xuân Mùa Hạ" to "thanh-xuan-mua-ha", - "Tình tay ba" to "tinh-tay-ba", - "Tội phạm" to "toi-pham", - "Trả thù" to "tra-thu", - "Truyện Tranh" to "truyen-tranh", - "Tuyết Đầu Mùa" to "tuyet-dau-mua", - "Văn phòng" to "van-phong", - "Xã hội đen" to "xa-hoi-den", -) +fun buildGenreFilter(data: JsonElement?): FilterList { + val genres = (data as? JsonArray) + ?.mapNotNull { + val obj = it.jsonObject + val name = obj["name"]?.string ?: return@mapNotNull null + val slug = obj["slug"]?.string ?: return@mapNotNull null + name to slug + } + .orEmpty() -fun getFilters(): FilterList = FilterList( - GenreFilter("Genre", GENRE_LIST), - StatusFilter(), - SortFilter(), -) + return FilterList( + GenreFilter("Genre", genres), + StatusFilter(), + SortFilter(), + ) +}