diff --git a/src/ja/magazinepocket/build.gradle b/src/ja/magazinepocket/build.gradle index 57d270764..3216b4e3e 100644 --- a/src/ja/magazinepocket/build.gradle +++ b/src/ja/magazinepocket/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'Magazine Pocket' extClass = '.MagazinePocket' - themePkg = 'gigaviewer' - baseUrl = 'https://pocket.shonenmagazine.com' - overrideVersionCode = 0 + extVersionCode = 9 isNsfw = false } diff --git a/src/ja/magazinepocket/res/mipmap-hdpi/ic_launcher.png b/src/ja/magazinepocket/res/mipmap-hdpi/ic_launcher.png index 71b561beb..c3f697b97 100644 Binary files a/src/ja/magazinepocket/res/mipmap-hdpi/ic_launcher.png and b/src/ja/magazinepocket/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ja/magazinepocket/res/mipmap-mdpi/ic_launcher.png b/src/ja/magazinepocket/res/mipmap-mdpi/ic_launcher.png index cda8755b7..12bb9eabf 100644 Binary files a/src/ja/magazinepocket/res/mipmap-mdpi/ic_launcher.png and b/src/ja/magazinepocket/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ja/magazinepocket/res/mipmap-xhdpi/ic_launcher.png b/src/ja/magazinepocket/res/mipmap-xhdpi/ic_launcher.png index 095694514..83fdca250 100644 Binary files a/src/ja/magazinepocket/res/mipmap-xhdpi/ic_launcher.png and b/src/ja/magazinepocket/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ja/magazinepocket/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/magazinepocket/res/mipmap-xxhdpi/ic_launcher.png index 91dd77058..25485e24c 100644 Binary files a/src/ja/magazinepocket/res/mipmap-xxhdpi/ic_launcher.png and b/src/ja/magazinepocket/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ja/magazinepocket/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/magazinepocket/res/mipmap-xxxhdpi/ic_launcher.png index 8424d2dc4..bf8203b88 100644 Binary files a/src/ja/magazinepocket/res/mipmap-xxxhdpi/ic_launcher.png and b/src/ja/magazinepocket/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/Dto.kt b/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/Dto.kt new file mode 100644 index 000000000..13e636954 --- /dev/null +++ b/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/Dto.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.extension.ja.magazinepocket + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class RankingApiResponse( + @SerialName("ranking_title_list") val rankingTitleList: List, +) + +@Serializable +class RankingTitleId( + val id: Int, +) + +@Serializable +class TitleListResponse( + @SerialName("title_list") val titleList: List, +) + +@Serializable +class TitleDetail( + @SerialName("title_id") val titleId: Int, + @SerialName("title_name") val titleName: String, + @SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null, +) + +@Serializable +class LatestTitleListResponse( + @SerialName("title_list") val titleList: List, +) + +@Serializable +class LatestTitleDetail( + @SerialName("title_id") val titleId: Int, + @SerialName("title_name") val titleName: String, + @SerialName("thumbnail_rect_image_url") val thumbnailImageUrl: String? = null, +) + +@Serializable +class EpisodeListResponse( + @SerialName("episode_list") val episodeList: List, +) + +@Serializable +class Episode( + @SerialName("episode_id") val episodeId: Int, + @SerialName("episode_name") val episodeName: String, + @SerialName("start_time") val startTime: String, + val point: Int, + @SerialName("title_id") val titleId: Int, + val badge: Int, + @SerialName("rental_finish_time") val rentalFinishTime: String? = null, +) + +@Serializable +class ViewerApiResponse( + @SerialName("page_list") val pageList: List, + @SerialName("scramble_seed") val scrambleSeed: Long, +) + +@Serializable +class SearchApiResponse( + @SerialName("search_title_list") val searchTitleList: List, +) + +@Serializable +class SearchTitleDetail( + @SerialName("title_id") val titleId: Int, + @SerialName("title_name") val titleName: String, + @SerialName("banner_image_url") val bannerImageUrl: String? = null, +) diff --git a/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/ImageInterceptor.kt b/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/ImageInterceptor.kt new file mode 100644 index 000000000..22901bbc0 --- /dev/null +++ b/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/ImageInterceptor.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.extension.ja.magazinepocket + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Rect +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.ByteArrayOutputStream + +class ImageInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val fragment = request.url.fragment + if (fragment.isNullOrEmpty() || !fragment.startsWith("scramble_seed=")) { + return chain.proceed(request) + } + + val seed = fragment.substringAfter("scramble_seed=").toLong() + val response = chain.proceed(request) + val descrambledBody = descrambleImage(response.body, seed) + + return response.newBuilder().body(descrambledBody).build() + } + + private class Coord(val x: Int, val y: Int) + + private class CoordPair(val source: Coord, val dest: Coord) + + private fun xorshift32(seed: UInt): UInt { + var n = seed + n = n xor (n shl 13) + n = n xor (n shr 17) + n = n xor (n shl 5) + return n + } + + private fun getUnscrambledCoords(seed: Long): List { + var seed32 = seed.toUInt() + val pairs = mutableListOf>() + + for (i in 0 until 16) { + seed32 = xorshift32(seed32) + pairs.add(seed32 to i) + } + + pairs.sortBy { it.first } + val sortedVal = pairs.map { it.second } + + return sortedVal.mapIndexed { i, e -> + CoordPair( + source = Coord(x = e % 4, y = e / 4), + dest = Coord(x = i % 4, y = i / 4), + ) + } + } + + private fun descrambleImage(responseBody: ResponseBody, seed: Long): ResponseBody { + val unscrambledCoords = getUnscrambledCoords(seed) + val originalBitmap = BitmapFactory.decodeStream(responseBody.byteStream()) + ?: throw Exception("Failed to decode image stream") + + val originalWidth = originalBitmap.width + val originalHeight = originalBitmap.height + + val descrambledBitmap = Bitmap.createBitmap(originalWidth, originalHeight, originalBitmap.config) + val canvas = Canvas(descrambledBitmap) + + val getTileDimension = { size: Int -> (size / 8 * 8) / 4 } + val tileWidth = getTileDimension(originalWidth) + val tileHeight = getTileDimension(originalHeight) + + unscrambledCoords.forEach { coord -> + val sx = coord.source.x * tileWidth + val sy = coord.source.y * tileHeight + val dx = coord.dest.x * tileWidth + val dy = coord.dest.y * tileHeight + + val srcRect = Rect(sx, sy, sx + tileWidth, sy + tileHeight) + val destRect = Rect(dx, dy, dx + tileWidth, dy + tileHeight) + + canvas.drawBitmap(originalBitmap, srcRect, destRect, null) + } + originalBitmap.recycle() + + val outputStream = ByteArrayOutputStream() + descrambledBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + descrambledBitmap.recycle() + + return outputStream.toByteArray().toResponseBody("image/jpeg".toMediaType()) + } +} diff --git a/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/MagazinePocket.kt b/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/MagazinePocket.kt index 581ae2733..0ef35f98f 100644 --- a/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/MagazinePocket.kt +++ b/src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/MagazinePocket.kt @@ -1,36 +1,454 @@ package eu.kanade.tachiyomi.extension.ja.magazinepocket -import eu.kanade.tachiyomi.multisrc.gigaviewer.GigaViewer +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +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 okhttp3.OkHttpClient -import org.jsoup.nodes.Element +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.firstInstance +import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import okio.ByteString.Companion.encodeUtf8 +import rx.Observable +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.Locale +import java.util.TimeZone -class MagazinePocket : GigaViewer( - "Magazine Pocket", - "https://pocket.shonenmagazine.com", - "ja", - "https://cdn-img.pocket.shonenmagazine.com/public/page", -) { +class MagazinePocket : HttpSource() { + override val name = "Magazine Pocket" + override val baseUrl = "https://pocket.shonenmagazine.com" + override val lang = "ja" + override val supportsLatest = true + override val versionId = 2 - override val client: OkHttpClient = super.client.newBuilder() - .addInterceptor(::imageIntercept) + private val apiUrl = "https://api.pocket.shonenmagazine.com" + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.JAPAN) + private val pageLimit = 25 + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + override val client = network.cloudflareClient.newBuilder() + .addInterceptor(ImageInterceptor()) .build() - override val publisher: String = "講談社" - - override fun popularMangaSelector(): String = "ul.daily-series li.daily-series-item > a" - - override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { - title = element.selectFirst("h4.daily-series-title")!!.text() - thumbnail_url = element.selectFirst("div.daily-series-thumb img")!!.attr("data-src") - setUrlWithoutDomain(element.attr("href")!!) + // Popular + override fun popularMangaRequest(page: Int): Request { + val offset = (page - 1) * pageLimit + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("ranking/all") + .addQueryParameter("platform", "3") + .addQueryParameter("ranking_id", "30") + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("limit", "26") + .build() + return hashedGet(url) } - override fun latestUpdatesSelector(): String = "section.daily.$dayOfWeek " + popularMangaSelector() + override fun popularMangaParse(response: Response): MangasPage { + val rankingResult = response.parseAs() + val titleIds = rankingResult.rankingTitleList.map { it.id.toString().padStart(5, '0') } + if (titleIds.isEmpty()) { + return MangasPage(emptyList(), false) + } - override fun getCollections(): List = listOf( - Collection("マガポケ連載一覧", ""), - Collection("週刊少年マガジン連載一覧", "smaga"), - Collection("別冊少年マガジン連載一覧", "bmaga"), + val hasNextPage = titleIds.size > pageLimit + val mangaIdsToFetch = if (hasNextPage) titleIds.dropLast(1) else titleIds + val detailsUrl = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("title/list") + .addQueryParameter("platform", "3") + .addQueryParameter("title_id_list", mangaIdsToFetch.joinToString(",")) + .build() + + val detailsRequest = hashedGet(detailsUrl) + val detailsResponse = client.newCall(detailsRequest).execute() + if (!detailsResponse.isSuccessful) { + throw Exception("Failed to fetch title details: ${detailsResponse.code} - ${detailsResponse.body.string()}") + } + + val detailsResult = detailsResponse.parseAs() + val mangas = detailsResult.titleList.map { manga -> + SManga.create().apply { + val paddedId = manga.titleId.toString().padStart(5, '0') + url = "/title/$paddedId" + title = manga.titleName + thumbnail_url = manga.thumbnailImageUrl + } + } + return MangasPage(mangas, hasNextPage) + } + + // Latest + override fun latestUpdatesRequest(page: Int): Request { + val calendar = GregorianCalendar(TimeZone.getTimeZone("Asia/Tokyo")).apply { + time = Date() + add(Calendar.DAY_OF_MONTH, -(page - 1)) + } + + val dateString = buildString { + append(calendar.get(Calendar.YEAR)) + append('-') + append((calendar.get(Calendar.MONTH) + 1).toString().padStart(2, '0')) + append('-') + append(calendar.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')) + } + + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("web/top/updated/title") + .addQueryParameter("base_date", dateString) + .addQueryParameter("platform", "3") + .build() + return hashedGet(url) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val result = response.parseAs() + val manga = result.titleList.map { manga -> + SManga.create().apply { + val paddedId = manga.titleId.toString().padStart(5, '0') + url = "/title/$paddedId" + title = manga.titleName + thumbnail_url = manga.thumbnailImageUrl + } + } + return MangasPage(manga, true) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotBlank()) { + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("web/search/title") + .addQueryParameter("keyword", query) + .addQueryParameter("limit", "99999") + .addQueryParameter("platform", "3") + .build() + return hashedGet(url) + } + + val genreFilter = filters.firstInstance() + val uriPart = genreFilter.toUriPart() + if (uriPart.startsWith("/search/genre/")) { + val url = baseUrl.toHttpUrl().newBuilder() + .addPathSegments(uriPart.removePrefix("/")) + .build() + return GET(url, headers) + } + + val rankingId = uriPart.substringAfter("/ranking/") + val offset = (page - 1) * pageLimit + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("ranking/all") + .addQueryParameter("platform", "3") + .addQueryParameter("ranking_id", rankingId) + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("limit", "26") + .build() + return hashedGet(url) + } + + override fun searchMangaParse(response: Response): MangasPage { + val requestUrl = response.request.url.toString() + if (requestUrl.contains("/web/search/")) { + val result = response.parseAs() + val mangas = result.searchTitleList.map { manga -> + SManga.create().apply { + val paddedId = manga.titleId.toString().padStart(5, '0') + url = "/title/$paddedId" + title = manga.titleName + thumbnail_url = manga.bannerImageUrl + } + } + return MangasPage(mangas.reversed(), false) + } + + if (response.request.url.toString().contains("/search/genre/")) { + val document = response.asJsoup() + val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data() + ?: return MangasPage(emptyList(), false) + + val rootArray = nuxtData.parseAs() + fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()] + + val genreResultObject = rootArray.firstOrNull { it is JsonObject && "search_title_list" in it.jsonObject } + ?: return MangasPage(emptyList(), false) + + val mangaRefs = resolve(genreResultObject.jsonObject["search_title_list"]!!).jsonArray + + val mangas = mangaRefs.map { ref -> + val mangaObject = resolve(ref).jsonObject + val id = resolve(mangaObject["title_id"]!!).jsonPrimitive + SManga.create().apply { + val paddedId = id.toString().padStart(5, '0') + url = "/title/$paddedId" + title = resolve(mangaObject["title_name"]!!).jsonPrimitive.content + thumbnail_url = mangaObject["banner_image_url"]?.let { resolve(it).jsonPrimitive.content } + } + } + return MangasPage(mangas, false) + } + return popularMangaParse(response) + } + + // Details + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data() + ?: throw Exception("Could not find Nuxt data") + val rootArray = nuxtData.parseAs() + + fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()] + + val titleDetailsObject = rootArray + .filterIsInstance() + .findLast { "title_name" in it && "author_text" in it && "introduction_text" in it && "genre_id_list" in it } + ?.jsonObject + + val genreMap = buildMap { + rootArray.forEach { element -> + if (element is JsonObject && element.jsonObject.containsKey("genre_id")) { + val genreObject = element.jsonObject + val id = resolve(genreObject["genre_id"]!!).jsonPrimitive.content.toInt() + val name = resolve(genreObject["genre_name"]!!).jsonPrimitive.content + put(id, name) + } + } + } + + return SManga.create().apply { + title = resolve(titleDetailsObject?.get("title_name")!!).jsonPrimitive.content + author = resolve(titleDetailsObject["author_text"]!!).jsonPrimitive.content + description = resolve(titleDetailsObject["introduction_text"]!!).jsonPrimitive.content + val genreIdRefs = resolve(titleDetailsObject["genre_id_list"]!!).jsonArray + val genreIds = genreIdRefs.map { resolve(it).jsonPrimitive.content.toInt() } + genre = genreIds.mapNotNull { genreMap[it] }.joinToString() + } + } + + // Chapters + override fun chapterListRequest(manga: SManga): Request { + return GET(baseUrl + manga.url, headers) + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data() + ?: throw Exception("Could not find Nuxt data") + + val rootArray = nuxtData.parseAs() + fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()] + + val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("episode_id_list") }.jsonObject + val episodeIdRefs = resolve(titleDetailsObject["episode_id_list"]!!).jsonArray + val episodeIds = episodeIdRefs.map { resolve(it).jsonPrimitive.content } + + val formBody = FormBody.Builder() + .add("platform", "3") + .add("episode_id_list", episodeIds.joinToString(",")) + .build() + + val params = (0 until formBody.size).associate { formBody.name(it) to formBody.value(it) } + val hash = generateHash(params) + + val postHeaders = headersBuilder() + .add("Origin", baseUrl) + .add("x-manga-is-crawler", "false") + .add("x-manga-hash", hash) + .build() + + val apiRequest = POST("$apiUrl/episode/list", postHeaders, formBody) + val apiResponse = client.newCall(apiRequest).execute() + + if (!apiResponse.isSuccessful) { + throw Exception("API request failed with code ${apiResponse.code}: ${apiResponse.body.string()}") + } + + val result = apiResponse.parseAs() + + return result.episodeList.map { chapter -> + SChapter.create().apply { + val paddedId = chapter.titleId.toString().padStart(5, '0') + url = "/title/$paddedId/episode/${chapter.episodeId}" + name = if (chapter.point > 0 && chapter.badge != 3 && chapter.rentalFinishTime == null) { + "🔒 ${chapter.episodeName}" + } else { + chapter.episodeName + } + date_upload = dateFormat.tryParse(chapter.startTime) + } + }.reversed() + } + + // Pages + override fun fetchPageList(chapter: SChapter): Observable> { + return client.newCall(pageListRequest(chapter)) + .asObservable() + .map { response -> + if (!response.isSuccessful) { + if (response.code == 400) { + throw Exception("This chapter is locked. Log in via WebView and rent or purchase this chapter to read.") + } + throw Exception("HTTP error ${response.code}") + } + pageListParse(response) + } + } + + override fun pageListParse(response: Response): List { + val apiResponse = response.parseAs() + val seed = apiResponse.scrambleSeed + return apiResponse.pageList.mapIndexed { index, imageUrl -> + Page(index, imageUrl = "$imageUrl#scramble_seed=$seed") + } + } + + override fun pageListRequest(chapter: SChapter): Request { + val episodeId = chapter.url.substringAfter("episode/") + val url = "$apiUrl/web/episode/viewer".toHttpUrl().newBuilder() + .addQueryParameter("platform", "3") + .addQueryParameter("episode_id", episodeId) + .build() + return hashedGet(url) + } + + private fun generateHash(params: Map, birthday: String = "", expires: String = ""): String { + val paramStrings = params.toSortedMap().map { (key, value) -> + getHashedParam(key, value) + } + + val joinedParams = paramStrings.joinToString(",") + val hash1 = joinedParams.encodeUtf8().sha256().hex() + val cookieHash = getHashedParam(birthday, expires) + val finalString = "$hash1$cookieHash" + return finalString.encodeUtf8().sha512().hex() + } + + private fun getHashedParam(key: String, value: String): String { + val keyHash = key.encodeUtf8().sha256().hex() + val valueHash = value.encodeUtf8().sha512().hex() + return "${keyHash}_$valueHash" + } + + private fun hashedGet(url: HttpUrl): Request { + val queryParams = url.queryParameterNames.associateWith { url.queryParameter(it)!! } + val hash = generateHash(queryParams) + val newHeaders = headersBuilder() + .add("x-manga-hash", hash) + .build() + return GET(url, newHeaders) + } + + override fun getFilterList() = FilterList( + Filter.Header("NOTE: Search query will ignore genre filter"), + GenreFilter(getGenreList()), ) + + private class GenreFilter(private val genres: Array>) : + Filter.Select("Filter by", genres.map { it.first }.toTypedArray()) { + fun toUriPart() = genres[state].second + } + + private fun getGenreList() = arrayOf( + Pair("(ランキング) すべて", "/ranking/30"), + Pair("(ランキング) まずはコレ!", "/ranking/2"), + Pair("(ランキング) オリジナル", "/ranking/1"), + Pair("(ランキング) 新作", "/ranking/31"), + Pair("(ランキング) アクション", "/ranking/21"), + Pair("(ランキング) スポーツ", "/ranking/22"), + Pair("(ランキング) 恋愛", "/ranking/23"), + Pair("(ランキング) 異世界", "/ranking/24"), + Pair("(ランキング) サスペンス", "/ranking/25"), + Pair("(ランキング) 裏社会・ヤンキー", "/ranking/26"), + Pair("(ランキング) ドラマ", "/ranking/27"), + Pair("(ランキング) ファンタジー", "/ranking/28"), + Pair("(ランキング) 日常", "/ranking/29"), + Pair("恋愛・ラブコメ", "/search/genre/1"), + Pair("ホラー・ミステリー・サスペンス", "/search/genre/2"), + Pair("ギャグ・コメディー・日常", "/search/genre/3"), + Pair("SF・ファンタジー", "/search/genre/4"), + Pair("スポーツ", "/search/genre/5"), + Pair("ヒューマンドラマ", "/search/genre/6"), + Pair("裏社会・アングラ・ヤンキー", "/search/genre/7"), + Pair("アクション・バトル", "/search/genre/8"), + Pair("異世界・異能力", "/search/genre/9"), + Pair("読切", "/search/genre/10"), + Pair("MGP", "/search/genre/11"), + Pair("第98回新人漫画賞", "/search/genre/12"), + Pair("第99回新人漫画賞", "/search/genre/13"), + Pair("第100回新人漫画賞", "/search/genre/14"), + Pair("第101回新人漫画賞", "/search/genre/15"), + Pair("第102回新人漫画賞", "/search/genre/16"), + Pair("第103回新人漫画賞", "/search/genre/17"), + Pair("第104回新人漫画賞", "/search/genre/18"), + Pair("第105回新人漫画賞", "/search/genre/19"), + Pair("第106回新人漫画賞", "/search/genre/20"), + Pair("第107回新人漫画賞", "/search/genre/21"), + Pair("第108回新人漫画賞", "/search/genre/22"), + Pair("第109回新人漫画賞", "/search/genre/23"), + Pair("第110回新人漫画賞", "/search/genre/24"), + Pair("2023真夏の読み切り15連弾", "/search/genre/25"), + Pair("マガジンライズ", "/search/genre/26"), + Pair("第111回新人漫画賞", "/search/genre/27"), + Pair("少女/女性", "/search/genre/28"), + Pair("新人漫画大賞", "/search/genre/29"), + Pair("第75回新人漫画賞", "/search/genre/30"), + Pair("第79回新人漫画賞", "/search/genre/31"), + Pair("第85回新人漫画賞", "/search/genre/32"), + Pair("第88回新人漫画賞", "/search/genre/33"), + Pair("第89回新人漫画賞", "/search/genre/34"), + Pair("第91回新人漫画賞", "/search/genre/35"), + Pair("第92回新人漫画賞", "/search/genre/36"), + Pair("第94回新人漫画賞", "/search/genre/37"), + Pair("第95回新人漫画賞", "/search/genre/38"), + Pair("第96回新人漫画賞", "/search/genre/39"), + Pair("第97回新人漫画賞", "/search/genre/40"), + Pair("第112回新人漫画賞", "/search/genre/41"), + Pair("第113回新人漫画大賞", "/search/genre/42"), + Pair("サッカー", "/search/genre/43"), + Pair("テニス", "/search/genre/44"), + Pair("バスケ", "/search/genre/45"), + Pair("格闘技", "/search/genre/46"), + Pair("野球", "/search/genre/47"), + Pair("女性向け異世界", "/search/genre/48"), + Pair("アニメ化", "/search/genre/49"), + Pair("実写化", "/search/genre/50"), + Pair("車・バイク", "/search/genre/51"), + Pair("グルメ・料理", "/search/genre/52"), + Pair("医療", "/search/genre/53"), + Pair("頭脳戦", "/search/genre/54"), + Pair("サバイバル", "/search/genre/55"), + Pair("復讐劇", "/search/genre/56"), + Pair("70~80年代", "/search/genre/57"), + Pair("90年代", "/search/genre/58"), + Pair("金田一シリーズ", "/search/genre/59"), + Pair("第114回新人漫画大賞", "/search/genre/60"), + Pair("連載獲得ダービー", "/search/genre/61"), + Pair("有名漫画賞", "/search/genre/62"), + Pair("探偵・警察", "/search/genre/63"), + Pair("歴史・時代", "/search/genre/64"), + Pair("不倫・浮気", "/search/genre/65"), + Pair("犬・猫", "/search/genre/66"), + ) + + // Unsupported + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() }