diff --git a/src/en/flamecomics/build.gradle b/src/en/flamecomics/build.gradle index 1e8929ec4..2d2042a40 100644 --- a/src/en/flamecomics/build.gradle +++ b/src/en/flamecomics/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'Flame Comics' extClass = '.FlameComics' - themePkg = 'mangathemesia' - baseUrl = 'https://flamecomics.xyz' - overrideVersionCode = 4 + extVersionCode = 35 } apply from: "$rootDir/common.gradle" diff --git a/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComics.kt b/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComics.kt index fdd39ad82..606c57ce5 100644 --- a/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComics.kt +++ b/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComics.kt @@ -4,62 +4,323 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Rect -import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia +import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.Protocol +import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.nodes.Document +import uy.kohesive.injekt.injectLazy import java.io.ByteArrayOutputStream -class FlameComics : MangaThemesia( - "Flame Comics", - "https://flamecomics.xyz", - "en", - mangaUrlDirectory = "/series", -) { +class FlameComics : HttpSource() { + override val name = "Flame Comics" + override val lang = "en" + override val supportsLatest = true + override val versionId: Int = 2 + override val baseUrl = "https://flamecomics.xyz" + private val cdn = "https://cdn.flamecomics.xyz" - // Flame Scans -> Flame Comics - override val id = 6350607071566689772 + private val json: Json by injectLazy() override val client = super.client.newBuilder() .rateLimit(2, 7) + .addInterceptor(::buildIdOutdatedInterceptor) .addInterceptor(::composedImageIntercept) .build() - override val pageSelector = "div#readerarea img:not(noscript img)[class*=wp-image]" + private val removeSpecialCharsregex = Regex("[^A-Za-z0-9 ]") - // Split Image Fixer Start - private val composedSelector: String = "#readerarea div.figure_container div.composed_figure" + private fun dataApiReqBuilder() = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("_next") + addPathSegment("data") + addPathSegment(buildId) + } - override fun pageListParse(document: Document): List { - val hasSplitImages = document - .select(composedSelector) - .firstOrNull() != null + private fun imageApiUrlBuilder(dataUrl: String) = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("_next") + addPathSegment("image") + }.build().toString() + "?url=$dataUrl" - if (!hasSplitImages) { - return super.pageListParse(document) + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + GET( + dataApiReqBuilder().apply { + addPathSegment("browse.json") + fragment("$page&${removeSpecialCharsregex.replace(query.lowercase(), "")}") + }.build(), + headers, + ) + + override fun popularMangaRequest(page: Int): Request = + GET( + dataApiReqBuilder().apply { + addPathSegment("browse.json") + fragment("$page") + }.build(), + headers, + ) + + override fun latestUpdatesRequest(page: Int): Request = GET( + dataApiReqBuilder().apply { + addPathSegment("index.json") + }.build(), + headers, + ) + + override fun searchMangaParse(response: Response): MangasPage = + mangaParse(response) { seriesList -> + val query = response.request.url.fragment!!.split("&")[1] + seriesList.filter { series -> + val titles = mutableListOf(series.title) + if (series.altTitles != null) { + titles += json.decodeFromString>(series.altTitles) + } + titles.any { title -> + removeSpecialCharsregex.replace( + query.lowercase(), + "", + ) in removeSpecialCharsregex.replace( + title.lowercase(), + "", + ) + } + } } - return document.select("#readerarea p:has(img), $composedSelector").toList() - .filter { - it.select("img").all { imgEl -> - imgEl.attr("abs:src").isNullOrEmpty().not() + override fun latestUpdatesParse(response: Response): MangasPage { + val latestData = json.decodeFromString(response.body.string()) + return MangasPage( + latestData.pageProps.latestEntries.blocks[0].series.map { seriesData -> + SManga.create().apply { + title = seriesData.title + setUrlWithoutDomain( + dataApiReqBuilder().apply { + val seriesID = + seriesData.series_id + addPathSegment("series") + addPathSegment("$seriesID.json") + addQueryParameter("id", seriesData.series_id.toString()) + }.build().toString(), + ) + thumbnail_url = imageApiUrlBuilder( + cdn.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment(seriesData.series_id.toString()) + addPathSegment(seriesData.cover) + }.build() + .toString() + "&w=640&q=75", // for some reason they don`t include the ? + ) } - } - .mapIndexed { i, el -> - if (el.tagName() == "p") { - Page(i, "", el.select("img").attr("abs:src")) - } else { - val imageUrls = el.select("img") - .joinToString("|") { it.attr("abs:src") } + }, + false, + ) + } - Page(i, document.location(), imageUrls + COMPOSED_SUFFIX) + override fun popularMangaParse(response: Response): MangasPage = + mangaParse(response) { list -> list.sortedByDescending { it.views } } + + private fun mangaParse( + response: Response, + transform: (List) -> List, + ): MangasPage { + val searchedSeriesData = + json.decodeFromString(response.body.string()).pageProps.series + + val page = if (!response.request.url.fragment?.contains("&")!!) { + response.request.url.fragment!!.toInt() + } else { + response.request.url.fragment!!.split("&")[0].toInt() + } + + val manga = transform(searchedSeriesData).map { seriesData -> + SManga.create().apply { + title = seriesData.title + setUrlWithoutDomain( + baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment(seriesData.series_id.toString()) + }.build().toString(), + ) + thumbnail_url = imageApiUrlBuilder( + cdn.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment(seriesData.series_id.toString()) + addPathSegment(seriesData.cover) + }.build() + .toString() + "&w=640&q=75", // for some reason they don`t include the ? + ) + } + } + var lastPage = page * 20 + if (lastPage > manga.size) { + lastPage = manga.size + } + if (lastPage < 0) lastPage = 0 + return MangasPage(manga.subList((page - 1) * 20, lastPage), lastPage < manga.size) + } + + override fun mangaDetailsRequest(manga: SManga): Request = GET( + dataApiReqBuilder().apply { + val seriesID = + ("$baseUrl/${manga.url}").toHttpUrl().pathSegments.last() + addPathSegment("series") + addPathSegment("$seriesID.json") + addQueryParameter("id", seriesID) + }.build(), + headers, + ) + + override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga) + + override fun getMangaUrl(manga: SManga): String = "$baseUrl/${manga.url}" + + override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply { + val seriesData = + json.decodeFromString(response.body.string()).pageProps.series + title = seriesData.title + thumbnail_url = imageApiUrlBuilder( + cdn.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment(seriesData.series_id.toString()) + addPathSegment(seriesData.cover) + }.build().toString() + "&w=640&q=75", + ) + description = seriesData.description + author = seriesData.author + status = when (seriesData.status.lowercase()) { + "ongoing" -> SManga.ONGOING + "dropped" -> SManga.CANCELLED + "hiatus" -> SManga.ON_HIATUS + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + + override fun chapterListParse(response: Response): List { + val mangaPageData = json.decodeFromString(response.body.string()) + return mangaPageData.pageProps.chapters.map { chapter -> + SChapter.create().apply { + setUrlWithoutDomain( + baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment(chapter.series_id.toString()) + addPathSegment(chapter.token) + }.build().toString(), + ) + chapter_number = chapter.chapter.toFloat() + date_upload = chapter.release_date * 1000 + name = buildString { + append("Chapter ${chapter.chapter.toInt()} ") + append(chapter.title ?: "") } } + } + } + + override fun pageListRequest(chapter: SChapter): Request = GET( + dataApiReqBuilder().apply { + val seriesID = ("$baseUrl/${chapter.url}").toHttpUrl().pathSegments[2] + val token = ("$baseUrl/${chapter.url}").toHttpUrl().pathSegments[3] + addPathSegment("series") + addPathSegment(seriesID) + addPathSegment("$token.json") + addQueryParameter("id", seriesID) + addQueryParameter("token", token) + }.build(), + headers, + ) + + override fun getChapterUrl(chapter: SChapter): String = "$baseUrl/${chapter.url}" + + override fun pageListParse(response: Response): List { + val chapter = + json.decodeFromString(response.body.string()).pageProps.chapter + return chapter.images.mapIndexed { idx, page -> + Page( + idx, + imageUrl = imageApiUrlBuilder( + cdn.toHttpUrl().newBuilder().apply { + addPathSegment("series") + addPathSegment(chapter.series_id.toString()) + addPathSegment(chapter.token) + addPathSegment(page.name) + addQueryParameter( + chapter.release_date.toString(), + value = null, + ) + addQueryParameter("w", "1920") + addQueryParameter("q", "100") + }.build().toString(), + ), + ) + } + } + + override fun imageUrlParse(response: Response): String = "" + + private fun fetchBuildId(document: Document? = null): String { + val realDocument = document + ?: client.newCall(GET(baseUrl, headers)).execute().use { it.asJsoup() } + + val nextData = realDocument.selectFirst("script#__NEXT_DATA__")?.data() + ?: throw Exception("Failed to find __NEXT_DATA__") + + val dto = json.decodeFromString(nextData) + return dto.buildId + } + + private var buildId = "" + get() { + if (field == "") { + field = fetchBuildId() + } + return field + } + + private fun buildIdOutdatedInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + if ( + response.code == 404 && + request.url.run { + host == baseUrl.removePrefix("https://") && + pathSegments.getOrNull(0) == "_next" && + pathSegments.getOrNull(1) == "data" && + fragment != "DO_NOT_RETRY" + } && + response.header("Content-Type")?.contains("text/html") != false + ) { + // The 404 page should have the current buildId + val document = response.asJsoup() + buildId = fetchBuildId(document) + + // Redo request with new buildId + val url = request.url.newBuilder() + .setPathSegment(2, buildId) + .fragment("DO_NOT_RETRY") + .build() + val newRequest = request.newBuilder() + .url(url) + .build() + + return chain.proceed(newRequest) + } + + return response } private fun composedImageIntercept(chain: Interceptor.Chain): Response { diff --git a/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComicsDto.kt b/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComicsDto.kt new file mode 100644 index 000000000..508c74f42 --- /dev/null +++ b/src/en/flamecomics/src/eu/kanade/tachiyomi/extension/en/flamecomics/FlameComicsDto.kt @@ -0,0 +1,103 @@ +package eu.kanade.tachiyomi.extension.en.flamecomics + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +class NewBuildID( + val buildId: String, +) + +@Serializable +class MangaPageData( + val pageProps: PageProps, +) { + @Serializable + class PageProps( + val chapters: List, + val series: Series, + ) +} + +@Serializable +class SearchPageData( + val pageProps: PageProps, +) { + @Serializable + class PageProps( + val series: List, + ) +} + +@Serializable +class LatestPageData( + val pageProps: PageProps, +) { + @Serializable + class PageProps( + val latestEntries: LatestEntries, + ) { + @Serializable + class LatestEntries( + val blocks: List, + ) { + @Serializable + class Block( + val series: List, + ) + } + } +} + +@Serializable +class ChapterPageData( + val pageProps: PageProps, +) { + @Serializable + class PageProps( + val chapter: Chapter, + ) +} + +@Serializable +class Series( + val title: String, + val altTitles: String?, + val description: String, + val cover: String, + val author: String?, + val status: String, + val series_id: Int, + val views: Int?, +) + +@Serializable +class Chapter( + val chapter: Double, + val title: String?, + val release_date: Long, + val series_id: Int, + val token: String, + @Serializable(with = KeysToListSerializer::class) + val images: List, +) + +@Serializable +class Page( + val name: String, +) + +class KeysToListSerializer : KSerializer> { + private val listSer = MapSerializer(String.serializer(), Page.serializer()) + override val descriptor: SerialDescriptor = listSer.descriptor + override fun deserialize(decoder: Decoder): List { + return listSer.deserialize(decoder).flatMap { k -> listOf(k.value) } + } + + override fun serialize(encoder: Encoder, value: List) {} +}