diff --git a/src/id/mikoroku/build.gradle.kts b/src/id/mikoroku/build.gradle.kts index 52aa6ca3f8e..325b3a11d3d 100644 --- a/src/id/mikoroku/build.gradle.kts +++ b/src/id/mikoroku/build.gradle.kts @@ -6,13 +6,18 @@ plugins { keiyoushi { name = "MikoRoku" - versionCode = 6 + versionCode = 7 contentWarning = ContentWarning.MIXED - libVersion = "1.4" - theme = "zeistmanga" + libVersion = "1.6" source { lang = "id" - baseUrl = "https://www.mikoroku.com" + baseUrl = "https://mikoroku.com" + id = 8593493873810750465L + } + + deeplink { + path("/detail") + path("/detail.html") } } diff --git a/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/Dto.kt b/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/Dto.kt new file mode 100644 index 00000000000..81ae0a73b45 --- /dev/null +++ b/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/Dto.kt @@ -0,0 +1,194 @@ +package eu.kanade.tachiyomi.extension.id.mikoroku + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.util.Locale +import kotlin.time.Instant + +@Serializable +class BloggerFeedResponse( + val feed: BloggerFeed, +) + +@Serializable +class BloggerEntryResponse( + val entry: BloggerEntry, +) + +@Serializable +class BloggerFeed( + val entry: List = emptyList(), +) + +@Serializable +class BloggerEntry( + private val id: BloggerText, + private val published: BloggerText? = null, + private val updated: BloggerText? = null, + private val category: List = emptyList(), + private val title: BloggerText, + private val content: BloggerText? = null, + private val link: List = emptyList(), + @SerialName("media\$thumbnail") private val thumbnail: BloggerThumbnail? = null, +) { + val slug: String + get() = title.value.toMikoRokuSlug() + + fun hasCategory(value: String): Boolean = category.any { it.term.equals(value, ignoreCase = true) } + + fun isChapterFor(mangaTitle: String): Boolean { + val match = CHAPTER_TITLE_REGEX.matchEntire(title.value.trim()) ?: return false + return hasCategory("Chapter") && + match.groupValues[1].toMikoRokuSlug() == mangaTitle.toMikoRokuSlug() + } + + fun toSManga(): SManga { + val mangaTitle = title.value + val mangaSlug = slug + val coverUrl = coverUrl() + require(mangaTitle.isNotEmpty()) { "Manga title is missing" } + require(mangaSlug.isNotEmpty()) { "Manga slug is missing for $mangaTitle" } + + return SManga.create().apply { + url = mangaSlug + title = mangaTitle + thumbnail_url = coverUrl + } + } + + fun toSMangaDetails(mangaSlug: String): SManga { + val document = contentDocument() + val info = document.extraInfo() + val synopsis = document.selectFirst("#synopsis")?.text()?.takeIf(String::isNotEmpty) + val altTitle = info["alt"]?.takeIf(String::isNotEmpty) + + return toSManga().apply { + url = mangaSlug + thumbnail_url = document.selectFirst(".separator a[href]") + ?.attr("href") + ?.toBloggerImageSize("s500") + ?: thumbnail_url + author = info["author"] + artist = info["artist"] + genre = info["genre"] + status = category.toStatus() + description = buildList { + altTitle?.let { add("Judul alternatif: $it") } + synopsis?.let(::add) + }.joinToString("\n\n").takeIf(String::isNotEmpty) + } + } + + fun toSChapter(): SChapter? { + val chapterNumber = CHAPTER_TITLE_REGEX.matchEntire(title.value.trim())?.groupValues?.get(2) + ?: return null + val alternateUrl = link.firstOrNull { it.rel == "alternate" }?.href?.toHttpUrlOrNull() + ?: return null + val postId = id.value.substringAfterLast(".post-").takeIf(String::isNotEmpty) + ?: return null + + return SChapter.create().apply { + url = "${alternateUrl.encodedPath}#$postId" + name = "Chapter $chapterNumber" + chapter_number = chapterNumber.toFloatOrNull() ?: -1F + date_upload = Instant.parseOrNull(updated?.value ?: published?.value.orEmpty()) + ?.toEpochMilliseconds() + ?: 0L + } + } + + fun pageUrls(): List = contentDocument() + .select("img") + .mapNotNull { image -> + sequenceOf("data-src", "data-original", "src") + .map(image::attr) + .firstOrNull(String::isNotEmpty) + ?.toHttpUrlOrNull() + } + .filterNot { url -> + val fileName = url.pathSegments.lastOrNull()?.lowercase(Locale.ROOT).orEmpty() + NON_PAGE_IMAGE_NAMES.any(fileName::contains) + } + .map { it.toString().toBloggerImageSize("s0") } + .distinct() + + private fun coverUrl(): String? { + val thumbnailUrl = thumbnail?.url + if (!thumbnailUrl.isNullOrEmpty()) return thumbnailUrl.toBloggerImageSize("s500") + + return contentDocument().selectFirst("img") + ?.attr("src") + ?.takeIf(String::isNotEmpty) + ?.toBloggerImageSize("s500") + } + + private fun contentDocument(): Document = Jsoup.parseBodyFragment(content?.value.orEmpty(), "https://mikoroku.com/") +} + +@Serializable +class BloggerText( + @SerialName("\$t") val value: String, +) + +@Serializable +class BloggerCategory( + val term: String, +) + +@Serializable +class BloggerLink( + val rel: String, + val href: String, +) + +@Serializable +class BloggerThumbnail( + val url: String, +) + +private fun Document.extraInfo(): Map = select("#extra-info .info-item") + .mapNotNull { item -> + val key = item.ownText() + .substringBefore(':') + .trim() + .lowercase(Locale.ROOT) + .takeIf(String::isNotEmpty) + ?: return@mapNotNull null + val value = item.selectFirst(".info-value")?.text()?.takeIf(String::isNotEmpty) + ?: return@mapNotNull null + key to value + } + .toMap() + +private fun List.toStatus(): Int { + val labels = map { it.term.lowercase(Locale.ROOT) }.toSet() + return when { + "ongoing" in labels -> SManga.ONGOING + "completed" in labels -> SManga.COMPLETED + "hiatus" in labels -> SManga.ON_HIATUS + "dropped" in labels || "canceled" in labels -> SManga.CANCELLED + else -> SManga.UNKNOWN + } +} + +private fun String.toMikoRokuSlug(): String = lowercase(Locale.ROOT) + .replace(SLUG_INVALID_CHARS_REGEX, "") + .replace(SLUG_WHITESPACE_REGEX, "-") + .replace(SLUG_DASHES_REGEX, "-") + .trim('-') + +private fun String.toBloggerImageSize(size: String): String = replace(BLOGGER_PATH_SIZE_REGEX, "/$size/") + .replace(BLOGGER_QUERY_SIZE_REGEX, "=$size") + +private val CHAPTER_TITLE_REGEX = Regex("""(?i)^(.+?)\s+chapter\s*(\d+(?:\.\d+)?)$""") +private val SLUG_INVALID_CHARS_REGEX = Regex("""[^a-z0-9\s-]""") +private val SLUG_WHITESPACE_REGEX = Regex("""\s+""") +private val SLUG_DASHES_REGEX = Regex("""-+""") +private val BLOGGER_PATH_SIZE_REGEX = Regex("""/s\d+(?:-[a-z0-9-]+)?/""", RegexOption.IGNORE_CASE) +private val BLOGGER_QUERY_SIZE_REGEX = Regex("""=s\d+(?:-[a-z0-9-]+)?$""", RegexOption.IGNORE_CASE) +private val NON_PAGE_IMAGE_NAMES = listOf("credit", "logo", "banner", "avatar", "placeholder", "thumbnail") diff --git a/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/MikoRoku.kt b/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/MikoRoku.kt index 4813e412820..1b856af81aa 100644 --- a/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/MikoRoku.kt +++ b/src/id/mikoroku/src/eu/kanade/tachiyomi/extension/id/mikoroku/MikoRoku.kt @@ -1,91 +1,163 @@ package eu.kanade.tachiyomi.extension.id.mikoroku -import eu.kanade.tachiyomi.multisrc.zeistmanga.Genre -import eu.kanade.tachiyomi.multisrc.zeistmanga.Status -import eu.kanade.tachiyomi.multisrc.zeistmanga.ZeistManga +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.util.asJsoup +import eu.kanade.tachiyomi.source.model.SMangaUpdate import keiyoushi.annotation.Source -import okhttp3.Response +import keiyoushi.network.get +import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource +import keiyoushi.utils.parseAs +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient @Source -abstract class MikoRoku : ZeistManga() { - - override fun popularMangaRequest(page: Int) = latestUpdatesRequest(page) - override fun popularMangaParse(response: Response) = searchMangaParse(response) - - override val hasFilters = true - override val hasLanguageFilter = false - override val hasTypeFilter = false - - override fun getStatusList() = listOf( - Status("Semua", ""), - Status("Ongoing", "Ongoing"), - Status("Completed", "Completed"), - Status("Hiatus", "Hiatus"), - Status("Dropped", "Dropped"), - ) - - override fun getGenreList() = listOf( - Genre("Action", "Action"), - Genre("Adventure", "Adventure"), - Genre("Comedy", "Comedy"), - Genre("Dark Fantasy", "Dark Fantasy"), - Genre("Drama", "Drama"), - Genre("Fantasy", "Fantasy"), - Genre("Historical", "Historical"), - Genre("Horror", "Horror"), - Genre("Isekai", "Isekai"), - Genre("Magic", "Magic"), - Genre("Mecha", "Mecha"), - Genre("Military", "Military"), - Genre("Mystery", "Mystery"), - Genre("Psychological", "Psychological"), - Genre("Romance", "Romance"), - Genre("School Life", "School Life"), - Genre("Sci-Fi", "Sci-Fi"), - Genre("Seinen", "Seinen"), - Genre("Shounen", "Shounen"), - Genre("Slice of Life", "Slice of Life"), - Genre("Supernatural", "Supernatural"), - Genre("Survival", "Survival"), - Genre("Tragedy", "Tragedy"), - ) - - override fun mangaDetailsParse(response: Response): SManga { - val document = response.asJsoup() - val header = document.selectFirst("header[itemprop=mainEntity]") - ?: document.selectFirst("header.bg-white")!! - - return SManga.create().apply { - thumbnail_url = header.selectFirst("img.thumb")?.attr("abs:src") - title = header.selectFirst("h1[itemprop=name]")?.text()!! - status = parseStatus(header.selectFirst("span[data-status]")?.text()!!) - description = document.selectFirst("#synopsis")?.ownText()?.trim() - author = document.select("#extra-info .y6x11p") - .firstOrNull { it.ownText().contains("Author", ignoreCase = true) } - ?.selectFirst("span.dt")?.text() +abstract class MikoRoku : KeiSource() { + + override fun OkHttpClient.Builder.configureClient() = rateLimit(4) { + it.host == MANGA_FEED_HOST || it.host == CHAPTER_FEED_HOST + } + + override suspend fun getPopularManga(page: Int): MangasPage = getMangaPage(page) + + override suspend fun getLatestUpdates(page: Int): MangasPage = getMangaPage(page) + + override suspend fun getSearchMangaList( + page: Int, + query: String, + filters: FilterList, + ): MangasPage = getMangaPage(page, query.takeIf(String::isNotEmpty)) + + private suspend fun getMangaPage(page: Int, query: String? = null): MangasPage { + val startIndex = (page - 1) * MANGA_PAGE_SIZE + 1 + val response = client.get( + feedUrl( + MANGA_FEED_URL, + startIndex = startIndex, + maxResults = MANGA_PAGE_SIZE, + query = query, + ), + ).parseAs() + val mangas = response.feed.entry.map { it.toSManga() } + val hasNextPage = response.feed.entry.size == MANGA_PAGE_SIZE + + return MangasPage(mangas, hasNextPage) + } + + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + if (url.host != baseUrl.toHttpUrl().host) return null + if (url.pathSegments.lastOrNull() !in setOf("detail", "detail.html")) return null + + val slug = url.queryParameter("slug")?.takeIf(String::isNotEmpty) ?: return null + return getMangaEntry(slug.replace('-', ' '), slug)?.toSMangaDetails(slug) + } + + override fun getMangaUrl(manga: SManga): String = baseUrl.toHttpUrl().newBuilder() + .addPathSegment("detail") + .addQueryParameter("slug", manga.url) + .build() + .toString() + + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = coroutineScope { + val mangaAsync = async { + if (fetchDetails) { + getMangaEntry(manga.title, manga.url)?.toSMangaDetails(manga.url) + ?: throw Exception("Manga not found: ${manga.title}") + } else { + manga + } } + + val chaptersAsync = async { + if (fetchChapters) { + getChapterList(manga.title) + } else { + chapters + } + } + + SMangaUpdate(mangaAsync.await(), chaptersAsync.await()) } - override val chapterCategory: String = "Chapter" - - override fun pageListParse(response: Response): List { - val document = response.asJsoup() - val images = when { - document.selectFirst("div.check-box") != null -> - document.select("div.check-box div.separator img[src]") - document.selectFirst("div[data=imageProtection]") != null -> - document.select("div[data=imageProtection] div.separator img[src]") - document.selectFirst("#post-body div.separator") != null -> - document.select("#post-body div.separator img[src]") - else -> - document.select(".post-body div.separator img[src]") + private suspend fun getMangaEntry(title: String, slug: String): BloggerEntry? { + val response = client.get( + feedUrl( + MANGA_FEED_URL, + startIndex = 1, + maxResults = DETAIL_SEARCH_SIZE, + query = title, + ), + ).parseAs() + + return response.feed.entry.firstOrNull { it.slug == slug } + } + + private suspend fun getChapterList(mangaTitle: String): List { + val response = client.get( + feedUrl( + CHAPTER_FEED_URL, + startIndex = 1, + maxResults = MAX_CHAPTER_RESULTS, + query = mangaTitle, + ), + ).parseAs() + + return response.feed.entry + .filter { entry -> entry.isChapterFor(mangaTitle) } + .mapNotNull { entry -> entry.toSChapter() } + .distinctBy { chapter -> chapter.chapter_number } + .sortedByDescending { chapter -> chapter.chapter_number } + } + + override fun getChapterUrl(chapter: SChapter): String = CHAPTER_WEB_URL.toHttpUrl().resolve(chapter.url.substringBefore('#'))!!.toString() + + override suspend fun getPageList(chapter: SChapter): List { + val postId = chapter.url.substringAfterLast('#').takeIf(String::isNotEmpty) + ?: return emptyList() + val url = "$CHAPTER_FEED_URL/$postId".toHttpUrl().newBuilder() + .addQueryParameter("alt", "json") + .build() + val entry = client.get(url).parseAs().entry + + return entry.pageUrls().mapIndexed { index, imageUrl -> + Page(index, imageUrl = imageUrl) } + } - return images.mapIndexed { index, img -> - Page(index, imageUrl = img.attr("abs:src")) + private fun feedUrl( + endpoint: String, + startIndex: Int, + maxResults: Int, + query: String?, + ): HttpUrl = endpoint.toHttpUrl().newBuilder() + .addQueryParameter("alt", "json") + .addQueryParameter("orderby", "updated") + .addQueryParameter("start-index", startIndex.toString()) + .addQueryParameter("max-results", maxResults.toString()) + .apply { + query?.let { addQueryParameter("q", it) } } + .build() + + companion object { + private const val MANGA_FEED_HOST = "www.mikoroku.top" + private const val CHAPTER_FEED_HOST = "www.mikodrive.my.id" + private const val MANGA_FEED_URL = "https://$MANGA_FEED_HOST/feeds/posts/default" + private const val CHAPTER_FEED_URL = "https://$CHAPTER_FEED_HOST/feeds/posts/default" + private const val CHAPTER_WEB_URL = "https://$CHAPTER_FEED_HOST/" + private const val MANGA_PAGE_SIZE = 20 + private const val DETAIL_SEARCH_SIZE = 20 + private const val MAX_CHAPTER_RESULTS = 500 } }