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:
parent
47b60ed24d
commit
02ddcb00e6
|
@ -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 |
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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, "価格改定対象作品"),
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
Loading…
Reference in New Issue