From f8845b1fb9fde4bd1029975de07000d4a98ca6cf Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 14 Nov 2022 01:43:40 +0600 Subject: [PATCH] Webnovel.com: Use API (#14168) * Webnovel.com: Use API * Show migration message instead of bumping "versionId" --- src/en/webnovel/build.gradle | 3 +- .../extension/en/webnovel/Webnovel.kt | 431 +++++++++++------- .../extension/en/webnovel/WebnovelDto.kt | 79 ++++ .../extension/en/webnovel/WebnovelFilter.kt | 63 +++ 4 files changed, 418 insertions(+), 158 deletions(-) create mode 100644 src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelDto.kt create mode 100644 src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelFilter.kt diff --git a/src/en/webnovel/build.gradle b/src/en/webnovel/build.gradle index 9596c1cbc..906a9e87e 100644 --- a/src/en/webnovel/build.gradle +++ b/src/en/webnovel/build.gradle @@ -1,11 +1,12 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' ext { extName = 'Webnovel.com' pkgNameSuffix = 'en.webnovel' extClass = '.Webnovel' - extVersionCode = 4 + extVersionCode = 5 } apply from: "$rootDir/common.gradle" diff --git a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/Webnovel.kt b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/Webnovel.kt index 5f4baed10..bc1b78adc 100644 --- a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/Webnovel.kt +++ b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/Webnovel.kt @@ -1,220 +1,337 @@ package eu.kanade.tachiyomi.extension.en.webnovel import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.Filter 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.ParsedHttpSource -import okhttp3.Headers +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.io.IOException import java.util.Calendar -import java.util.Locale +import java.util.Date -class Webnovel : ParsedHttpSource() { +class Webnovel : HttpSource() { override val name = "Webnovel.com" override val baseUrl = "https://www.webnovel.com" + private val baseApiUrl = "$baseUrl$BASE_API_ENDPOINT" + + private val baseCoverURl = baseUrl.replace("www", "img") + + private val baseCdnUrl = baseUrl.replace("www", "comic-image") + override val lang = "en" override val supportsLatest = true override val client: OkHttpClient = network.cloudflareClient + .newBuilder() + .addNetworkInterceptor(::csrfTokenInterceptor) + .addNetworkInterceptor(::expiredImageUrlInterceptor) + .build() - private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) + private val json: Json by injectLazy() - override fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0 ") - add("Referer", baseUrl) - } + // Popular + override fun popularMangaRequest(page: Int): Request = searchMangaRequest( + page = page, + query = "", + filters = FilterList( + SortByFilter(default = 1) + ) + ) - // popular - override fun popularMangaRequest(page: Int) = GET("$baseUrl/category/0_comic_page$page", headers) + override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response) - override fun popularMangaSelector() = "a.g_thumb, div.j_bookList .g_book_item a:has(img)" + // Latest + override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest( + page = page, + query = "", + filters = FilterList( + SortByFilter(default = 5) + ) + ) - override fun popularMangaFromElement(element: Element): SManga { - val manga = SManga.create() - manga.url = element.attr("abs:href").substringAfter(baseUrl) - manga.title = element.attr("title") - manga.thumbnail_url = element.select("img").attr("abs:data-original") - return manga - } + override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response) - override fun popularMangaNextPageSelector() = "[rel=next]" - - // latest - override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/category/0_comic_page$page?orderBy=5", headers) - - override fun latestUpdatesSelector() = popularMangaSelector() - - override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) - - override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() - - // search + // Search override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val filters = if (filters.isEmpty()) getFilterList() else filters - val genre = filters.findInstance<GenreList>()?.toUriPart() - val order = filters.findInstance<OrderByFilter>()?.toUriPart() - val status = filters.findInstance<StatusFilter>()?.toUriPart() + if (query.isNotBlank()) { + val url = "$baseApiUrl$QUERY_SEARCH_PATH?type=manga&pageIndex=$page".toHttpUrl() + .newBuilder() + .addQueryParameter("keywords", query) + .toString() - return when { - query!!.isNotEmpty() -> GET("$baseUrl/search?keywords=$query&type=2&pageIndex=$page", headers) - else -> GET("$baseUrl/category/$genre" + "_comic_page1?&orderBy=$order&bookStatus=$status") + return GET(url, headers) } + val sort = filters.firstInstanceOrNull<SortByFilter>()?.selectedValue.orEmpty() + val contentStatus = filters.firstInstanceOrNull<ContentStatusFilter>()?.selectedValue.orEmpty() + val genre = filters.firstInstanceOrNull<GenreFilter>()?.selectedValue.orEmpty() + + val url = "$baseApiUrl$FILTER_SEARCH_PATH?categoryType=2&pageIndex=$page" + + "&categoryId=$genre&bookStatus=$contentStatus&orderBy=$sort" + + return GET(url, headers) } - override fun searchMangaSelector() = popularMangaSelector() + override fun searchMangaParse(response: Response): MangasPage { + val browseResponseDto = if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) { + response.checkAndParseAs<QuerySearchResponseDto>().browseResponse + } else { + // Due to the previous line this automatically parses as "BrowseResponseDto" + response.checkAndParseAs() + } - override fun searchMangaFromElement(element: Element): SManga { - val manga = SManga.create() - manga.url = element.attr("abs:href").substringAfter(baseUrl) - manga.title = element.attr("title") - manga.thumbnail_url = element.select("img").attr("abs:src") - return manga + val manga = browseResponseDto.items.map { + SManga.create().apply { + title = it.name + url = it.id + thumbnail_url = getCoverUrl(it.id) + } + } + + return MangasPage(manga, browseResponseDto.isLast == 0) } - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + // Manga details + // TODO: Cleanup this block when ext-lib 1.4 is released + override fun mangaDetailsRequest(manga: SManga): Request { + return GET("$baseUrl/comic/${manga.getId}", headers) + } - // manga details - override fun mangaDetailsParse(document: Document) = SManga.create().apply { - thumbnail_url = document.select("i.g_thumb img:first-child").attr("abs:src") - title = document.select("h1").text() - description = document.select(".j_synopsis p").text() + override fun fetchMangaDetails(manga: SManga): Observable<SManga> { + return client.newCall(internalMangaDetailsRequest(manga)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response) + } + } + + private fun internalMangaDetailsRequest(manga: SManga): Request { + return GET("$baseApiUrl/comic/getComicDetailPage?comicId=${manga.getId}", headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val comic = response.checkAndParseAs<ComicDetailInfoResponseDto>().info + return SManga.create().apply { + title = comic.name + url = comic.id + thumbnail_url = getCoverUrl(comic.id) + author = comic.authorName + description = comic.description + genre = comic.categoryName + status = when (comic.actionStatus) { + 1 -> SManga.ONGOING + 2 -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } } // chapters - override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url + "/catalog", headers) - - override fun chapterListSelector() = ".volume-item li a" - - override fun chapterFromElement(element: Element) = SChapter.create().apply { - setUrlWithoutDomain(element.attr("href")) - name = if (element.select("svg").hasAttr("class")) { "\uD83D\uDD12 " } else { "" } + - element.attr("title") - date_upload = parseChapterDate(element.select(".oh small").text()) + override fun chapterListRequest(manga: SManga): Request { + return GET("$baseApiUrl/comic/getChapterList?comicId=${manga.getId}", headers) } override fun chapterListParse(response: Response): List<SChapter> { - return super.chapterListParse(response).reversed() + val chapterList = response.checkAndParseAs<ComicChapterListDto>() + val comic = chapterList.comicInfo + val chapters = chapterList.comicChapters.reversed().asSequence() + + val updateTimes = chapters.map { it.publishTime.toDate() } + val filteredChapters = chapters + // You can pay to get some chapter earlier than others. This privilege is divided into some tiers + // We check if user has same tier unlocked as chapter's. + .filter { it.userLevel == it.chapterLevel } + + // When new privileged chapter is released oldest privileged chapter becomes normal one (in most cases) + // but since those normal chapter retain the original upload time we improvise. (This isn't optimal but meh) + return filteredChapters.zip(updateTimes) { chapter, updateTime -> + val namePrefix = when { + chapter.isPremium && !chapter.isAccessibleByUser -> "\uD83D\uDD12 " + else -> "" + } + SChapter.create().apply { + name = namePrefix + chapter.name + url = "${comic.id}:${chapter.id}" + date_upload = updateTime + } + }.toList() } - fun parseChapterDate(date: String): Long { - return if (date.contains("ago")) { - val value = date.split(' ')[0].toInt() - when { - "min" in date -> Calendar.getInstance().apply { - add(Calendar.MINUTE, value * -1) - }.timeInMillis - "hour" in date -> Calendar.getInstance().apply { - add(Calendar.HOUR_OF_DAY, value * -1) - }.timeInMillis - "day" in date -> Calendar.getInstance().apply { - add(Calendar.DATE, value * -1) - }.timeInMillis - "week" in date -> Calendar.getInstance().apply { - add(Calendar.DATE, value * 7 * -1) - }.timeInMillis - "month" in date -> Calendar.getInstance().apply { - add(Calendar.MONTH, value * -1) - }.timeInMillis - "year" in date -> Calendar.getInstance().apply { - add(Calendar.YEAR, value * -1) - }.timeInMillis - else -> { - 0L - } - } - } else { - try { - dateFormat.parse(date)?.time ?: 0 - } catch (_: Exception) { - 0L - } + private val ComicChapterDto.isPremium get() = isVip != 0 || price != 0 + // This can mean the chapter is free or user has paid to unlock it (check with [isPremium] for this case) + private val ComicChapterDto.isAccessibleByUser get() = isAuth == 1 + + private fun String.toDate(): Long { + if (contains("now", ignoreCase = true)) return Date().time + + val number = DIGIT_REGEX.find(this)?.value?.toIntOrNull() ?: return 0 + val cal = Calendar.getInstance() + + return when { + contains("yr") -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + contains("mth") -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + contains("d") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + contains("h") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + contains("min") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + else -> 0 } } - // pages - override fun pageListParse(document: Document): List<Page> { - return document.select("#comicPageContainer img").mapIndexed { i, element -> - Page(i, "", element.attr("data-original")) + // Pages + // TODO: Cleanup this block when ext-lib 1.4 is released + override fun pageListRequest(chapter: SChapter): Request { + val (comicId, chapterId) = chapter.getMangaAndChapterId + return GET("$baseUrl/comic/$comicId/$chapterId", headers) + } + + override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { + return client.newCall(internalPageListRequest(chapter)) + .asObservableSuccess() + .map { response -> + pageListParse(response) + } + } + + private fun internalPageListRequest(chapter: SChapter): Request { + val (comicId, chapterId) = chapter.getMangaAndChapterId + return pageListRequest(comicId, chapterId) + } + + private fun pageListRequest(comicId: String, chapterId: String): Request { + return GET("$baseApiUrl/comic/getContent?comicId=$comicId&chapterId=$chapterId") + } + + // LinkedHashMap with a capacity of 25. When exceeding the capacity the oldest entry is removed. + private val chapterPageCache = object : LinkedHashMap<Long, List<ChapterPageDto>>() { + + override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, List<ChapterPageDto>>?): Boolean { + return size > 25 } } - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not Used") + override fun pageListParse(response: Response): List<Page> { + val chapterContent = response.checkAndParseAs<ChapterContentResponseDto>().chapterContent + chapterPageCache[chapterContent.chapterId] = chapterContent.chapterPage + return chapterContent.chapterPage.mapIndexed { i, page -> Page(i, imageUrl = page.url) } + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not Used") - // filter override fun getFilterList() = FilterList( Filter.Header("NOTE: Ignored if using text search!"), Filter.Separator(), - StatusFilter(), - OrderByFilter(), - GenreList() + ContentStatusFilter(), + SortByFilter(), + GenreFilter() ) - private class StatusFilter : UriPartFilter( - "Status", - arrayOf( - Pair("0", "All"), - Pair("1", "Ongoing"), - Pair("2", "Completed") - ) - ) + private val SManga.getId: String + get() { + if (url.toLongOrNull() == null) throw Exception(MIGRATE_MESSAGE) + return url + } - private class OrderByFilter : UriPartFilter( - "Order By", - arrayOf( - Pair("1", "Default"), - Pair("1", "Popular"), - Pair("2", "Recommendation"), - Pair("3", "Collection"), - Pair("4", "Rates"), - Pair("5", "Updated") - ) - ) - private class GenreList : UriPartFilter( - "Select Genre", - arrayOf( - Pair("0", "All"), - Pair("60002", "Action"), - Pair("60014", "Adventure"), - Pair("60011", "Comedy"), - Pair("60009", "Cooking"), - Pair("60027", "Diabolical"), - Pair("60024", "Drama"), - Pair("60006", "Eastern"), - Pair("60022", "Fantasy"), - Pair("60017", "Harem"), - Pair("60018", "History"), - Pair("60015", "Horror"), - Pair("60013", "Inspiring"), - Pair("60029", "LGBT+"), - Pair("60016", "Magic"), - Pair("60008", "Mystery"), - Pair("60003", "Romance"), - Pair("60007", "School"), - Pair("60004", "Sci-fi"), - Pair("60019", "Slice of Life"), - Pair("60023", "Sports"), - Pair("60012", "Transmigration"), - Pair("60005", "Urban"), - Pair("60010", "Wuxia") - ) - ) + private val SChapter.getMangaAndChapterId: Pair<String, String> + get() { + val (comicId, chapterId) = url.split(":") + if (listOf(comicId, chapterId).any { it.toLongOrNull() == null }) throw Exception(MIGRATE_MESSAGE) + return comicId to chapterId + } - private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) : - Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray()) { - fun toUriPart() = vals[state].first + private fun getCoverUrl(comicId: String): String { + return "$baseCoverURl/bookcover/$comicId/0/600.jpg" } - private inline fun <reified T> Iterable<*>.findInstance() = find { it is T } as? T + private fun csrfTokenInterceptor(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val originalRequestUrl = originalRequest.url + if (!originalRequestUrl.toString().contains(BASE_API_ENDPOINT)) return chain.proceed(originalRequest) + + val csrfToken = originalRequest.header("cookie") + ?.takeIf { csrfTokenName in it } + ?.substringAfter("$csrfTokenName=") + ?.substringBefore(";") + ?: throw IOException("'$csrfTokenName' cookie not found.\nOpen in webview to set it.") + + val newUrl = originalRequestUrl.newBuilder() + .addQueryParameter(csrfTokenName, csrfToken) + .build() + + val newRequest = originalRequest.newBuilder().url(newUrl).build() + return chain.proceed(newRequest) + } + + private fun expiredImageUrlInterceptor(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val originalRequestUrl = originalRequest.url + + // If original request is not a page url or the url is still valid we just continue with og request + if (!originalRequestUrl.toString().contains(baseCdnUrl) || isPageUrlStillValid(originalRequestUrl)) + return chain.proceed(originalRequest) + + val (_, comicId, chapterId, pageFileName) = originalRequest.url.pathSegments + + // Page url is not valid anymore so we check if cache has updated one + val pageId = pageFileName.substringBefore("!") + val cachedPageUrl = chapterPageCache[chapterId.toLong()]?.firstOrNull { it.id == pageId }?.url + if (cachedPageUrl != null && isPageUrlStillValid(cachedPageUrl.toHttpUrl())) return chain.proceed(originalRequest) + + // Time to get it from api + val pageListResponse = chain.proceed(pageListRequest(comicId, chapterId)) + val chapterContent = pageListResponse.checkAndParseAs<ChapterContentResponseDto>().chapterContent + pageListResponse.close() + chapterPageCache[chapterContent.chapterId] = chapterContent.chapterPage + + val newPageUrl = chapterContent.chapterPage.firstOrNull { it.id == pageId }?.url?.toHttpUrl() + ?: throw IOException("Couldn't regenerate expired image url") + + val newRequest = originalRequest.newBuilder().url(newPageUrl).build() + return chain.proceed(newRequest) + } + + private fun isPageUrlStillValid(imageUrl: HttpUrl): Boolean { + val urlGenerationTime = imageUrl.queryParameter("t")?.toLongOrNull() + ?: throw IOException("Parameter 't' missing from page url or isn't a long") + + // Urls are valid for 10 minutes after generation. We check for 9min and 30s just to be safe + return (Date().time / 1000) - urlGenerationTime <= 570 + } + + private inline fun <reified T> Response.checkAndParseAs(): T = use { + val parsed = json.decodeFromString<ResponseDto<T>>(it.body?.string().orEmpty()) + if (parsed.code != 0) error("Error ${parsed.code}: ${parsed.msg}") + requireNotNull(parsed.data) { "Response data is null" } + } + + private inline fun <reified T> List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T + + companion object { + private const val BASE_API_ENDPOINT = "/go/pcm" + + private const val QUERY_SEARCH_PATH = "/search/result" + private const val FILTER_SEARCH_PATH = "/category/categoryAjax" + + private const val MIGRATE_MESSAGE = "Migrate this entry from \"Webnovel.com\" to \"Webnovel.com\" to update url" + + private val DIGIT_REGEX = "(\\d+)".toRegex() + + private const val csrfTokenName = "_csrfToken" + } } diff --git a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelDto.kt b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelDto.kt new file mode 100644 index 000000000..91016a241 --- /dev/null +++ b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelDto.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.extension.en.webnovel + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +data class ResponseDto<T>( + val code: Int, + val data: T?, + val msg: String, +) + +@Serializable +data class QuerySearchResponseDto( + @SerialName("comicInfo") val browseResponse: BrowseResponseDto, +) + +@Serializable +data class BrowseResponseDto( + val isLast: Int, + @JsonNames("comicItems") val items: List<ComicInfoDto>, +) + +@Serializable +data class ComicInfoDto( + @JsonNames("bookId", "comicId") val id: String, + @JsonNames("bookName", "comicName") val name: String, +) + +@Serializable +data class ComicDetailInfoResponseDto( + @SerialName("comicInfo") val info: ComicDetailInfoDto +) + +@Serializable +data class ComicDetailInfoDto( + @SerialName("comicId") val id: String, + @SerialName("comicName") val name: String, + val actionStatus: Int, + val authorName: String, + val categoryName: String, + val description: String +) + +@Serializable +data class ComicChapterListDto( + val comicInfo: ComicInfoDto, + val comicChapters: List<ComicChapterDto> +) + +@Serializable +data class ComicChapterDto( + @SerialName("chapterId") val id: String, + @SerialName("chapterName") val name: String, + val publishTime: String, + val price: Int, + val isVip: Int, + val isAuth: Int, + val chapterLevel: Int, + val userLevel: Int, +) + +@Serializable +data class ChapterContentResponseDto( + @SerialName("chapterInfo") val chapterContent: ChapterContentDto +) + +@Serializable +data class ChapterContentDto( + val chapterId: Long, + val chapterPage: List<ChapterPageDto> +) + +@Serializable +data class ChapterPageDto( + @SerialName("pageId") val id: String, + val url: String +) diff --git a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelFilter.kt b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelFilter.kt new file mode 100644 index 000000000..90f4cd49f --- /dev/null +++ b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebnovelFilter.kt @@ -0,0 +1,63 @@ +package eu.kanade.tachiyomi.extension.en.webnovel + +import eu.kanade.tachiyomi.source.model.Filter + +data class FilterOption(val displayName: String, val value: String) + +open class EnhancedSelect(name: String, private val _values: List<FilterOption>, state: Int = 0) : + Filter.Select<String>(name, _values.map { it.displayName }.toTypedArray(), state) { + + val selectedValue: String? + get() = _values.getOrNull(state)?.value +} + +class SortByFilter(default: Int = 1) : EnhancedSelect( + "Sort By", + listOf( + FilterOption("Popular", "1"), + FilterOption("Recommended", "2"), + FilterOption("Most collections", "3"), + FilterOption("Rating", "4"), + FilterOption("Time updated", "5"), + ), + default - 1 +) + +class ContentStatusFilter : EnhancedSelect( + "Content status", + listOf( + FilterOption("All", "0"), + FilterOption("Ongoing", "1"), + FilterOption("Completed", "2"), + ) +) + +class GenreFilter : EnhancedSelect( + "Genre", + listOf( + FilterOption("All", "0"), + FilterOption("Action", "60002"), + FilterOption("Adventure", "60014"), + FilterOption("Comedy", "60011"), + FilterOption("Cooking", "60009"), + FilterOption("Diabolical", "60027"), + FilterOption("Drama", "60024"), + FilterOption("Eastern", "60006"), + FilterOption("Fantasy", "60022"), + FilterOption("Harem", "60017"), + FilterOption("History", "60018"), + FilterOption("Horror", "60015"), + FilterOption("Inspiring", "60013"), + FilterOption("LGBT+", "60029"), + FilterOption("Magic", "60016"), + FilterOption("Mystery", "60008"), + FilterOption("Romance", "60003"), + FilterOption("School", "60007"), + FilterOption("Sci-fi", "60004"), + FilterOption("Slice of Life", "60019"), + FilterOption("Sports", "60023"), + FilterOption("Transmigration", "60012"), + FilterOption("Urban", "60005"), + FilterOption("Wuxia", "60010"), + ) +)