diff --git a/src/ja/comicfuz/build.gradle b/src/ja/comicfuz/build.gradle new file mode 100644 index 000000000..2bac3a57c --- /dev/null +++ b/src/ja/comicfuz/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'COMIC FUZ' + extClass = '.ComicFuz' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ja/comicfuz/res/mipmap-hdpi/ic_launcher.png b/src/ja/comicfuz/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..127f87a0c Binary files /dev/null and b/src/ja/comicfuz/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ja/comicfuz/res/mipmap-mdpi/ic_launcher.png b/src/ja/comicfuz/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..8080b98fc Binary files /dev/null and b/src/ja/comicfuz/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ja/comicfuz/res/mipmap-xhdpi/ic_launcher.png b/src/ja/comicfuz/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..339577f59 Binary files /dev/null and b/src/ja/comicfuz/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ja/comicfuz/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/comicfuz/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..b4e7c5534 Binary files /dev/null and b/src/ja/comicfuz/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ja/comicfuz/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/comicfuz/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..2f738da5d Binary files /dev/null and b/src/ja/comicfuz/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/ComicFuz.kt b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/ComicFuz.kt new file mode 100644 index 000000000..9b6646ba3 --- /dev/null +++ b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/ComicFuz.kt @@ -0,0 +1,222 @@ +package eu.kanade.tachiyomi.extension.ja.comicfuz + +import eu.kanade.tachiyomi.network.POST +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 kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.IOException + +class ComicFuz : HttpSource() { + + override val name = "COMIC FUZ" + + private val domain = "comic-fuz.com" + override val baseUrl = "https://$domain" + private val apiUrl = "https://api.$domain/v1" + private val cdnUrl = "https://img.$domain" + + override val lang = "ja" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .addInterceptor(ImageInterceptor) + .addNetworkInterceptor { chain -> + val response = chain.proceed(chain.request()) + + if (!response.isSuccessful) { + val exception = when (response.code) { + 401 -> "Unauthorized" + 402 -> "Payment Required" + else -> "HTTP error ${response.code}" + } + + throw IOException(exception) + } + + return@addNetworkInterceptor response + } + .build() + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + .set("Origin", baseUrl) + + override fun popularMangaRequest(page: Int): Request { + return searchMangaRequest(page, "", getFilterList()) + } + + override fun popularMangaParse(response: Response): MangasPage { + return searchMangaParse(response) + } + + override fun latestUpdatesRequest(page: Int): Request { + val payload = DayOfWeekRequest( + deviceInfo = DeviceInfo( + deviceType = DeviceType.BROWSER, + ), + dayOfWeek = DayOfWeek.today(), + ).toRequestBody() + + return POST("$apiUrl/mangas_by_day_of_week", headers, payload) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val data = response.parseAs() + val entries = data.mangas.map { + it.toSManga(cdnUrl) + } + + return MangasPage(entries, false) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val tag = filters.filterIsInstance().first() + + return if (query.isNotBlank() || tag.selected == null) { + val payload = SearchRequest( + deviceInfo = DeviceInfo( + deviceType = DeviceType.BROWSER, + ), + query = query.trim(), + pageIndexOfMangas = page, + pageIndexOfBooks = 1, + ).toRequestBody() + + POST("$apiUrl/search#$page", headers, payload) + } else { + val payload = MangaListRequest( + deviceInfo = DeviceInfo( + deviceType = DeviceType.BROWSER, + ), + tagId = tag.selected!!, + ).toRequestBody() + + POST("$apiUrl/manga_list", headers, payload) + } + } + + override fun searchMangaParse(response: Response): MangasPage { + return if (response.request.url.pathSegments.last() == "search") { + val data = response.parseAs() + val page = response.request.url.fragment!!.toInt() + val entries = data.mangas.map { + it.toSManga(cdnUrl) + } + + MangasPage(entries, data.pageCountOfMangas > page) + } else { + val data = response.parseAs() + val entries = data.mangas.map { + it.toSManga(cdnUrl) + } + + return MangasPage(entries, false) + } + } + + override fun getFilterList() = getFilters() + + override fun mangaDetailsRequest(manga: SManga): Request { + val payload = MangaDetailsRequest( + deviceInfo = DeviceInfo( + deviceType = DeviceType.BROWSER, + ), + mangaId = manga.url.substringAfterLast("/").toInt(), + ).toRequestBody() + + return POST("$apiUrl/manga_detail", headers, payload) + } + + override fun getMangaUrl(manga: SManga): String { + return "$baseUrl${manga.url}" + } + + override fun mangaDetailsParse(response: Response): SManga { + val data = response.parseAs() + + return data.toSManga(cdnUrl) + } + + override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List { + val data = response.parseAs() + + return data.chapterGroups.flatMap { group -> + group.chapters.map { chapter -> + chapter.toSChapter() + } + } + } + + override fun getChapterUrl(chapter: SChapter): String { + return "$baseUrl${chapter.url}" + } + + override fun pageListRequest(chapter: SChapter): Request { + val payload = MangaViewerRequest( + deviceInfo = DeviceInfo( + deviceType = DeviceType.BROWSER, + ), + chapterId = chapter.url.substringAfterLast("/").toInt(), + useTicket = false, + consumePoint = UserPoint( + event = 0, + paid = 0, + ), + viewerMode = ViewerMode( + imageQuality = ImageQuality.HIGH, + ), + ).toRequestBody() + + return POST("$apiUrl/manga_viewer", headers, payload) + } + + override fun pageListParse(response: Response): List { + val data = response.parseAs() + + val pages = data.pages + .filter { it.image?.isExtraPage == false } + .mapNotNull { it.image } + + return pages.mapIndexed { idx, page -> + Page( + index = idx, + imageUrl = if (page.encryptionKey.isEmpty() && page.iv.isEmpty()) { + cdnUrl + page.imageUrl + } else { + "$cdnUrl${page.imageUrl}".toHttpUrl().newBuilder() + .addQueryParameter("key", page.encryptionKey) + .addQueryParameter("iv", page.iv) + .toString() + }, + ) + } + } + + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } + + private inline fun Response.parseAs(): T { + return ProtoBuf.decodeFromByteArray(body.bytes()) + } + + private inline fun T.toRequestBody(): RequestBody { + return ProtoBuf.encodeToByteArray(this) + .toRequestBody("application/protobuf".toMediaType()) + } +} diff --git a/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/Dto.kt b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/Dto.kt new file mode 100644 index 000000000..4311bb342 --- /dev/null +++ b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/Dto.kt @@ -0,0 +1,110 @@ +package eu.kanade.tachiyomi.extension.ja.comicfuz + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class MangaListResponse( + @ProtoNumber(1) val mangas: List, +) + +@Serializable +class SearchResponse( + @ProtoNumber(2) val mangas: List, + @ProtoNumber(6) val pageCountOfMangas: Int = 0, +) + +@Serializable +class Manga( + @ProtoNumber(1) private val id: Int, + @ProtoNumber(2) private val title: String, + @ProtoNumber(4) private val cover: String, + @ProtoNumber(14) private val description: String, +) { + fun toSManga(cdnUrl: String): SManga = SManga.create().apply { + url = "/manga/$id" + title = this@Manga.title + thumbnail_url = cdnUrl + cover + description = this@Manga.description + } +} + +@Serializable +class MangaDetailsResponse( + @ProtoNumber(2) private val manga: Manga, + @ProtoNumber(3) val chapterGroups: List, + @ProtoNumber(4) private val authors: List, + @ProtoNumber(7) private val tags: List, +) { + fun toSManga(cdnUrl: String) = manga.toSManga(cdnUrl).apply { + genre = tags.joinToString { it.name } + author = authors.joinToString { it.author.name } + } +} + +@Serializable +class Author( + @ProtoNumber(1) val author: Name, +) + +@Serializable +class Name( + @ProtoNumber(2) val name: String, +) + +@Serializable +class ChapterGroup( + @ProtoNumber(2) val chapters: List, +) + +@Serializable +class Chapter( + @ProtoNumber(1) private val id: Int, + @ProtoNumber(2) private val title: String, + @ProtoNumber(5) private val points: Point, + @ProtoNumber(8) private val date: String = "", +) { + fun toSChapter() = SChapter.create().apply { + url = "/manga/viewer/$id" + name = if (points.amount > 0) { + "\uD83D\uDD12 $title" // lock emoji + } else { + title + } + date_upload = try { + dateFormat.parse(date)!!.time + } catch (_: ParseException) { + 0L + } + } +} + +private val dateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ENGLISH) + +@Serializable +class Point( + @ProtoNumber(2) val amount: Int = 0, +) + +@Serializable +class MangaViewerResponse( + @ProtoNumber(3) val pages: List, +) + +@Serializable +class ViewerPage( + @ProtoNumber(1) val image: Image? = null, +) + +@Serializable +class Image( + @ProtoNumber(1) val imageUrl: String, + @ProtoNumber(3) val iv: String = "", + @ProtoNumber(4) val encryptionKey: String = "", + @ProtoNumber(7) val isExtraPage: Boolean = false, +) diff --git a/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/Filters.kt b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/Filters.kt new file mode 100644 index 000000000..6dabfa984 --- /dev/null +++ b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/Filters.kt @@ -0,0 +1,78 @@ +package eu.kanade.tachiyomi.extension.ja.comicfuz + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +fun getFilters() = FilterList( + TagFilter(), + Filter.Separator(), + Filter.Header("Doesn't work with text search"), +) + +class TagFilter : Filter.Select("Tags", tags.map { it.name }.toTypedArray()) { + val selected get() = when (state) { + 0 -> null + else -> tags[state].id + } +} + +class Tag( + val id: Int, + val name: String, +) + +private val tags = listOf( + Tag(-1, ""), + Tag(7, "日曜日"), + Tag(12, "オリジナル"), + Tag(38, "グルメ"), + Tag(138, "FUZコミックス"), + Tag(288, "広告で人気の作品"), + Tag(462, "オリジナル作品の最新話が無料化!"), + Tag(540, "ギャグ・コメディ"), + Tag(552, "日常"), + Tag(23, "学園"), + Tag(26, "SF・ファンタジー"), + Tag(29, "恋愛"), + Tag(13, "男性向け"), + Tag(549, "百合"), + Tag(41, "お仕事・趣味"), + Tag(56, "週刊漫画TIMES"), + Tag(150, "芳文社コミックス"), + Tag(537, "スポーツ"), + Tag(68, "まんがタイムきららフォワード"), + Tag(141, "まんがタイムKRコミックス"), + Tag(291, "新規連載作品"), + Tag(204, "まんがタイムオリジナル"), + Tag(6, "土曜日"), + Tag(1274, "6/3発売 FUZオリジナル作品新刊"), + Tag(2, "火曜日"), + Tag(14, "女性向け"), + Tag(44, "バトル・アクション"), + Tag(47, "ミステリー・サスペンス"), + Tag(83, "BL"), + Tag(32, "メディア化"), + Tag(50, "歴史・時代"), + Tag(20, "4コマ"), + Tag(147, "まんがタイムコミックス"), + Tag(5, "金曜日"), + Tag(543, "異世界"), + Tag(35, "ヒューマンドラマ"), + Tag(65, "まんがタイムきららキャラット"), + Tag(4, "木曜日"), + Tag(59, "まんがタイムきらら"), + Tag(153, "ラバココミックス"), + Tag(201, "まんがタイム"), + Tag(3, "水曜日"), + Tag(62, "まんがタイムきららMAX"), + Tag(17, "読切"), + Tag(1, "月曜日"), + Tag(74, "ゆるキャン△"), + Tag(207, "コミックトレイル"), + Tag(77, "城下町のダンデライオン"), + Tag(156, "トレイルコミックス"), + Tag(198, "まんがホーム"), + Tag(71, "魔法少女まどか☆マギカ"), + Tag(177, "花音コミックス"), + Tag(1175, "価格改定対象作品"), +) diff --git a/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/ImageInterceptor.kt b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/ImageInterceptor.kt new file mode 100644 index 000000000..f3aac944e --- /dev/null +++ b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/ImageInterceptor.kt @@ -0,0 +1,52 @@ +package eu.kanade.tachiyomi.extension.ja.comicfuz + +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object ImageInterceptor : Interceptor { + private val mediaType = "image/jpeg".toMediaType() + + private inline val AES: Cipher + get() = Cipher.getInstance("AES/CBC/PKCS7Padding") + + override fun intercept(chain: Interceptor.Chain): Response { + val url = chain.request().url + val key = url.queryParameter("key") + ?: return chain.proceed(chain.request()) + val iv = url.queryParameter("iv")!! + + val response = chain.proceed( + chain.request().newBuilder().url( + url.newBuilder() + .removeAllQueryParameters("key") + .removeAllQueryParameters("iv") + .build(), + ).build(), + ) + + val body = response.body.bytes() + .decode(key.decodeHex(), iv.decodeHex()) + + return response.newBuilder() + .body(body) + .build() + } + + private fun ByteArray.decode(key: ByteArray, iv: ByteArray) = AES.let { + it.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + it.doFinal(this).toResponseBody(mediaType) + } + + private fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } +} diff --git a/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/PayloadDto.kt b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/PayloadDto.kt new file mode 100644 index 000000000..8bf37bed2 --- /dev/null +++ b/src/ja/comicfuz/src/eu/kanade/tachiyomi/extension/ja/comicfuz/PayloadDto.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.extension.ja.comicfuz + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import java.util.Calendar + +@Serializable +class DeviceInfo( + @ProtoNumber(3) private val deviceType: DeviceType, +) + +enum class DeviceType { + IOS, + ANDROID, + BROWSER, +} + +@Serializable +class DayOfWeekRequest( + @ProtoNumber(1) private val deviceInfo: DeviceInfo, + @ProtoNumber(2) private val dayOfWeek: DayOfWeek, +) + +enum class DayOfWeek(private val dayNum: Int) { + ALL(0), + MONDAY(1), + TUESDAY(2), + WEDNESDAY(3), + THURSDAY(4), + FRIDAY(5), + SATURDAY(6), + SUNDAY(7), + ; + + companion object { + fun today(): DayOfWeek { + val calendar = Calendar.getInstance() + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + val adjustedDayOfWeek = if (dayOfWeek == Calendar.SUNDAY) 7 else dayOfWeek - 1 + + return values().first { it.dayNum == adjustedDayOfWeek } + } + } +} + +@Serializable +class SearchRequest( + @ProtoNumber(1) private val deviceInfo: DeviceInfo, + @ProtoNumber(2) private val query: String, + @ProtoNumber(3) private val pageIndexOfMangas: Int, + @ProtoNumber(4) private val pageIndexOfBooks: Int, +) + +@Serializable +class MangaListRequest( + @ProtoNumber(1) private val deviceInfo: DeviceInfo, + @ProtoNumber(2) private val tagId: Int, +) + +@Serializable +class MangaDetailsRequest( + @ProtoNumber(1) private val deviceInfo: DeviceInfo, + @ProtoNumber(2) private val mangaId: Int, +) + +@Serializable +class MangaViewerRequest( + @ProtoNumber(1) private val deviceInfo: DeviceInfo, + @ProtoNumber(2) private val chapterId: Int, + @ProtoNumber(3) private val useTicket: Boolean, + @ProtoNumber(4) private val consumePoint: UserPoint, + @ProtoNumber(5) private val viewerMode: ViewerMode, +) + +@Serializable +class UserPoint( + @ProtoNumber(1) private val event: Int, + @ProtoNumber(2) private val paid: Int, +) + +@Serializable +class ViewerMode( + @ProtoNumber(1) private val imageQuality: ImageQuality, +) + +enum class ImageQuality { + NORMAL, + HIGH, +}