diff --git a/src/en/comicfans/build.gradle b/src/en/comicfans/build.gradle new file mode 100644 index 000000000..67db0ffdc --- /dev/null +++ b/src/en/comicfans/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Comic Fans' + extClass = '.ComicFans' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/comicfans/res/mipmap-hdpi/ic_launcher.png b/src/en/comicfans/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..28a5a3586 Binary files /dev/null and b/src/en/comicfans/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/comicfans/res/mipmap-mdpi/ic_launcher.png b/src/en/comicfans/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..405cb2ca8 Binary files /dev/null and b/src/en/comicfans/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/comicfans/res/mipmap-xhdpi/ic_launcher.png b/src/en/comicfans/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..497159191 Binary files /dev/null and b/src/en/comicfans/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/comicfans/res/mipmap-xxhdpi/ic_launcher.png b/src/en/comicfans/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..6bcd44765 Binary files /dev/null and b/src/en/comicfans/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/comicfans/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/comicfans/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..077729309 Binary files /dev/null and b/src/en/comicfans/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/ComicFans.kt b/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/ComicFans.kt new file mode 100644 index 000000000..c23be6157 --- /dev/null +++ b/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/ComicFans.kt @@ -0,0 +1,205 @@ +package eu.kanade.tachiyomi.extension.en.comicfans + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +class ComicFans : HttpSource() { + + override val name = "Comic Fans" + + override val baseUrl = "https://comicfans.io" + private val apiUrl = "https://api.comicfans.io/comic-backend/api/v1/content" + private val cdnUrl = "https://static.comicfans.io" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private fun apiHeadersBuilder() = headersBuilder().apply { + add("Accept", "*/*") + add("Host", apiUrl.toHttpUrl().host) + add("Origin", baseUrl) + add("site-domain", "www.${baseUrl.toHttpUrl().host}") + } + + private val apiHeaders by lazy { apiHeadersBuilder().build() } + + private val json: Json by injectLazy() + + // ============================== Popular =============================== + + override fun popularMangaRequest(page: Int): Request { + val body = buildJsonObject { + put("conditionJson", "{\"title\":\"You may also like\",\"maxSize\":15}") + put("pageNumber", page) + put("pageSize", 30) + }.let(json::encodeToString).toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + + val popularHeaders = apiHeadersBuilder().apply { + set("Accept", "application/json") + }.build() + + return POST("$apiUrl/books/custom/MostPopularLocal#$page", popularHeaders, body) + } + + override fun popularMangaParse(response: Response): MangasPage { + val data = response.parseAs>().data + val hasNextPage = response.request.url.fragment!!.toInt() < data.totalPages + + return MangasPage(data.list.map { it.toSManga(cdnUrl) }, hasNextPage) + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangaList = document.select( + "div:has(>.block-title-bar > .title:contains(New Updates))" + + "> .book-container > .book", + ).map { element -> + SManga.create().apply { + thumbnail_url = element.selectFirst("img")!!.attr("abs:src") + with(element.selectFirst(".book-name > a")!!) { + title = text() + setUrlWithoutDomain(attr("abs:href")) + } + } + } + + return MangasPage(mangaList, false) + } + // =============================== Search =============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$apiUrl/books".toHttpUrl().newBuilder().apply { + addQueryParameter("pageNumber", page.toString()) + addQueryParameter("pageSize", "20") + fragment(page.toString()) + + if (query.isNotBlank()) { + addPathSegment("search") + addQueryParameter("keyWord", query) + } else { + filters.getUriPart()?.let { + addQueryParameter("genre", it) + } + filters.getUriPart()?.let { + addQueryParameter("withinDay", it) + } + filters.getUriPart()?.let { + addQueryParameter("status", it) + } + } + }.build() + + return GET(url, apiHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage = + popularMangaParse(response) + + // =============================== Filters ============================== + + override fun getFilterList(): FilterList = FilterList( + Filter.Header("Text search ignores filters"), + Filter.Separator(), + GenreFilter(), + LastUpdateFilter(), + StatusFilter(), + ) + + // =========================== Manga Details ============================ + + override fun getMangaUrl(manga: SManga): String = baseUrl + manga.url + + override fun mangaDetailsRequest(manga: SManga): Request { + val bookId = manga.url.substringAfter("/comic/") + .substringBefore("-") + + return GET("$apiUrl/books/$bookId", apiHeaders) + } + + override fun mangaDetailsParse(response: Response): SManga { + return response.parseAs>().data.toSManga(cdnUrl) + } + + // ============================== Chapters ============================== + + override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url + + override fun chapterListRequest(manga: SManga): Request { + val bookId = manga.url.substringAfter("/comic/") + .substringBefore("-") + + return GET("$apiUrl/chapters/page?sortDirection=ASC&bookId=$bookId&pageNumber=1&pageSize=9999", apiHeaders) + } + + override fun chapterListParse(response: Response): List { + return response.parseAs>().data.list.mapIndexed { index, chapterDto -> + chapterDto.toSChapter(index + 1) + }.reversed() + } + + // =============================== Pages ================================ + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfter("/episode/") + .substringBefore("-") + + return GET("$apiUrl/chapters/$chapterId", apiHeaders) + } + + override fun pageListParse(response: Response): List { + return response.parseAs>().data.comicImageList.map { + Page(it.sortNum, imageUrl = "$cdnUrl/${it.imageUrl}") + } + } + + override fun imageRequest(page: Page): Request { + val imgHeaders = headersBuilder().apply { + add("Accept", "image/avif,image/webp,*/*") + add("Host", page.imageUrl!!.toHttpUrl().host) + }.build() + + return GET(page.imageUrl!!, imgHeaders) + } + + override fun imageUrlParse(response: Response): String = + throw UnsupportedOperationException() + + // ============================= Utilities ============================== + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } +} diff --git a/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/Dto.kt b/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/Dto.kt new file mode 100644 index 000000000..2e56b702f --- /dev/null +++ b/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/Dto.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.extension.en.comicfans + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable + +typealias ListDataDto = DataDto> + +@Serializable +class ListDto( + val totalPages: Int, + val list: List, +) + +@Serializable +class DataDto( + val data: T, +) + +@Serializable +class MangaDto( + val id: Int, + val title: String, + val coverImgUrl: String, + val status: Int, + val authorPseudonym: String? = null, + val synopsis: String? = null, +) { + fun toSManga(cdnUrl: String): SManga = SManga.create().apply { + title = this@MangaDto.title + thumbnail_url = "$cdnUrl/$coverImgUrl" + author = authorPseudonym + + url = buildString { + append("/comic/") + append(slugify(id, title)) + } + description = synopsis + status = when (this@MangaDto.status) { + 0 -> SManga.ONGOING + 1 -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + initialized = true + } +} + +@Serializable +class ChapterDto( + val id: Int, + val title: String, + val updateTime: Long? = null, +) { + fun toSChapter(index: Int): SChapter = SChapter.create().apply { + name = "Ch. $index - $title" + chapter_number = index.toFloat() + date_upload = updateTime ?: 0L + url = buildString { + append("/episode/") + append(slugify(id, title)) + } + } +} + +@Serializable +class PageDataDto( + val comicImageList: List, +) { + @Serializable + class PageDto( + val imageUrl: String, + val sortNum: Int, + ) +} + +private val symbolsRegex = Regex("\\W") +private val hyphenRegex = Regex("-{2,}") + +private fun slugify(id: Int, title: String): String = buildString { + append(id) + append("-") + append( + title.lowercase() + .replace(symbolsRegex, "-") + .replace(hyphenRegex, "-") + .removeSuffix("-") + .removePrefix("-"), + ) +} diff --git a/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/Filters.kt b/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/Filters.kt new file mode 100644 index 000000000..04616ee26 --- /dev/null +++ b/src/en/comicfans/src/eu/kanade/tachiyomi/extension/en/comicfans/Filters.kt @@ -0,0 +1,64 @@ +package eu.kanade.tachiyomi.extension.en.comicfans + +import eu.kanade.tachiyomi.source.model.Filter + +open class UriPartFilter(displayName: String, val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second +} + +inline fun List<*>.getUriPart(): String? = + (filterIsInstance().first() as UriPartFilter).toUriPart().takeIf { it.isNotEmpty() } + +class GenreFilter : UriPartFilter( + "Genre", + arrayOf( + Pair("All", ""), + Pair("BL", "1001"), + Pair("Fantasy", "1002"), + Pair("GL", "1003"), + Pair("CEO", "1004"), + Pair("Romance", "1005"), + Pair("Harem", "1006"), + Pair("Action", "1007"), + Pair("Teen", "1008"), + Pair("Adventure", "1009"), + Pair("Eastern", "1010"), + Pair("Comedy", "1011"), + Pair("Esports", "1012"), + Pair("Historical", "1013"), + Pair("Mystery", "1014"), + Pair("Modern", "1015"), + Pair("Urban", "1016"), + Pair("Wuxia", "1017"), + Pair("Suspense", "1018"), + Pair("Female Lead", "1019"), + Pair("Western Fantasy", "1020"), + Pair("Horror", "1022"), + Pair("Realistic Fiction", "1023"), + Pair("Cute", "1024"), + Pair("Campus", "1025"), + Pair("Sci-fi", "1026"), + Pair("History", "1027"), + ), +) + +class LastUpdateFilter : UriPartFilter( + "Last Update", + arrayOf( + Pair("All", ""), + Pair("Within 3 Days", "3"), + Pair("Within 7 Days", "7"), + Pair("Within 15 Days", "15"), + Pair("Within 30 Days", "30"), + ), +) + +class StatusFilter : UriPartFilter( + "Status", + arrayOf( + Pair("All", ""), + Pair("Ongoing", "0"), + Pair("Completed", "1"), + ), +)