add Comicfuz (#3600)

* ComicFuz

* points info

* search and payed chapter indicator

* save full urls

* client side cache with search and latest

* final cleanup and icons

* image quality and isNsfw

* tag search
This commit is contained in:
AwkwardPeak7 2024-06-21 13:47:26 +05:00 committed by Draff
parent 47b60ed24d
commit 02ddcb00e6
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
11 changed files with 559 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'COMIC FUZ'
extClass = '.ComicFuz'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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<MangaListResponse>()
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<TagFilter>().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<SearchResponse>()
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<MangaListResponse>()
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<MangaDetailsResponse>()
return data.toSManga(cdnUrl)
}
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val data = response.parseAs<MangaDetailsResponse>()
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<Page> {
val data = response.parseAs<MangaViewerResponse>()
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 <reified T> Response.parseAs(): T {
return ProtoBuf.decodeFromByteArray(body.bytes())
}
private inline fun <reified T : Any> T.toRequestBody(): RequestBody {
return ProtoBuf.encodeToByteArray(this)
.toRequestBody("application/protobuf".toMediaType())
}
}

View File

@ -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<Manga>,
)
@Serializable
class SearchResponse(
@ProtoNumber(2) val mangas: List<Manga>,
@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<ChapterGroup>,
@ProtoNumber(4) private val authors: List<Author>,
@ProtoNumber(7) private val tags: List<Name>,
) {
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<Chapter>,
)
@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<ViewerPage>,
)
@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,
)

View File

@ -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<String>("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, "価格改定対象作品"),
)

View File

@ -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()
}
}

View File

@ -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,
}