diff --git a/src/fr/scanmanga/.gitignore b/src/fr/scanmanga/.gitignore new file mode 100644 index 000000000..639cdd514 --- /dev/null +++ b/src/fr/scanmanga/.gitignore @@ -0,0 +1 @@ +local.properties diff --git a/src/fr/scanmanga/build.gradle b/src/fr/scanmanga/build.gradle new file mode 100644 index 000000000..23aee9935 --- /dev/null +++ b/src/fr/scanmanga/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Scan-Manga' + extClass = '.ScanManga' + extVersionCode = 12 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/fr/scanmanga/res/mipmap-hdpi/ic_launcher.png b/src/fr/scanmanga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0983fdf43 Binary files /dev/null and b/src/fr/scanmanga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/fr/scanmanga/res/mipmap-mdpi/ic_launcher.png b/src/fr/scanmanga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..978a9f71c Binary files /dev/null and b/src/fr/scanmanga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/fr/scanmanga/res/mipmap-xhdpi/ic_launcher.png b/src/fr/scanmanga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..5d716298d Binary files /dev/null and b/src/fr/scanmanga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/fr/scanmanga/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/scanmanga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..9f545740a Binary files /dev/null and b/src/fr/scanmanga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/fr/scanmanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/scanmanga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..9f26c3dd3 Binary files /dev/null and b/src/fr/scanmanga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt b/src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt new file mode 100644 index 000000000..a63c7a78f --- /dev/null +++ b/src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt @@ -0,0 +1,248 @@ +package eu.kanade.tachiyomi.extension.fr.scanmanga + +import android.util.Base64 +import eu.kanade.tachiyomi.network.GET +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 eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.parseAs +import okhttp3.CookieJar +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.util.zip.Inflater + +class ScanManga : HttpSource() { + override val name = "Scan-Manga" + + override val baseUrl = "https://m.scan-manga.com" + private val baseImageUrl = "https://static.scan-manga.com/img/manga" + + override val lang = "fr" + + override val supportsLatest = true + + override fun headersBuilder(): Headers.Builder = super.headersBuilder() + .add("Accept-Language", "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7") + .set("User-Agent", "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Mobile Safari/537.36") + + // Popular + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/TOP-Manga-Webtoon-36.html", headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + val mangas = response.asJsoup().select("#carouselTOPContainer > div.top").map { element -> + SManga.create().apply { + val titleElement = element.selectFirst("a.atop")!! + + title = titleElement.text() + setUrlWithoutDomain(titleElement.attr("href")) + thumbnail_url = element.selectFirst("img")?.attr("data-original") + } + } + + return MangasPage(mangas, false) + } + + // Latest + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select("#content_news .publi").map { element -> + SManga.create().apply { + val mangaElement = element.selectFirst("a.l_manga")!! + + title = mangaElement.text() + setUrlWithoutDomain(mangaElement.attr("href")) + + thumbnail_url = element.selectFirst("img")?.attr("src") + } + } + + return MangasPage(mangas, false) + } + + // Search + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/api/search/quick.json" + .toHttpUrl().newBuilder() + .addQueryParameter("term", query) + .build() + .toString() + + val newHeaders = headers.newBuilder() + .add("Content-type", "application/json; charset=UTF-8") + .build() + + return GET(url, newHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage { + val json = response.body.string() + if (json == "[]") { return MangasPage(emptyList(), false) } + + return MangasPage( + json.parseAs().title?.map { + SManga.create().apply { + title = it.nom_match + setUrlWithoutDomain(it.url) + thumbnail_url = "$baseImageUrl/${it.image}" + } + } ?: emptyList(), + false, + ) + } + + // Details + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + + return SManga.create().apply { + title = document.select("h1.main_title[itemprop=name]").text() + author = document.select("div[itemprop=author]").text() + description = document.selectFirst("div.titres_desc[itemprop=description]")?.text() + genre = document.selectFirst("div.titres_souspart span[itemprop=genre]")?.text() + + val statutText = document.selectFirst("div.titres_souspart")?.ownText() + status = when { + statutText?.contains("En cours", ignoreCase = true) == true -> SManga.ONGOING + statutText?.contains("Terminé", ignoreCase = true) == true -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + thumbnail_url = document.select("div.full_img_serie img[itemprop=image]").attr("src") + } + } + + // Chapters + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return document.select("div.chapt_m").map { element -> + val linkEl = element.selectFirst("td.publimg span.i a")!! + val titleEl = element.selectFirst("td.publititle") + + val chapterName = linkEl.text() + val extraTitle = titleEl?.text() + + SChapter.create().apply { + name = if (!extraTitle.isNullOrEmpty()) "$chapterName - $extraTitle" else chapterName + setUrlWithoutDomain(linkEl.absUrl("href")) + } + } + } + + // Pages + private fun decodeHunter(obfuscatedJs: String): String { + val regex = Regex("""eval\(function\(h,u,n,t,e,r\)\{.*?\}\("([^"]+)",\d+,"([^"]+)",(\d+),(\d+),\d+\)\)""") + val (encoded, mask, intervalStr, optionStr) = regex.find(obfuscatedJs)?.destructured + ?: error("Failed to match obfuscation pattern: $obfuscatedJs") + + val interval = intervalStr.toInt() + val option = optionStr.toInt() + val delimiter = mask[option] + val tokens = encoded.split(delimiter).filter { it.isNotEmpty() } + val reversedMap = mask.withIndex().associate { it.value to it.index } + + return buildString { + for (token in tokens) { + // Reverse the hashIt() operation: convert masked characters back to digits + val digitString = token.map { c -> + reversedMap[c]?.toString() ?: error("Invalid masked character: $c") + }.joinToString("") + + // Convert from base `option` to decimal + val number = digitString.toIntOrNull(option) + ?: error("Failed to parse token: $digitString as base $option") + + // Reverse the shift done during encodeIt() + val originalCharCode = number - interval + + append(originalCharCode.toChar()) + } + } + } + + private fun dataAPI(data: String, idc: Int): UrlPayload { + // Step 1: Base64 decode the input + val compressedBytes = Base64.decode(data, Base64.NO_WRAP or Base64.NO_PADDING) + + // Step 2: Inflate (zlib decompress) + val inflater = Inflater() + inflater.setInput(compressedBytes) + val outputBuffer = ByteArray(512 * 1024) // 512 KB buffer, should be more than enough + val decompressedLength = inflater.inflate(outputBuffer) + inflater.end() + + val inflated = String(outputBuffer, 0, decompressedLength) + + // Step 3: Remove trailing hex string and reverse + val hexIdc = idc.toString(16) + val cleaned = inflated.removeSuffix(hexIdc) + val reversed = cleaned.reversed() + + // Step 4: Base64 decode and parse JSON + val finalJsonStr = String(Base64.decode(reversed, Base64.DEFAULT)) + + return finalJsonStr.parseAs() + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + val packedScript = document.selectFirst("script:containsData(h,u,n,t,e,r)")!!.data() + + val unpackedScript = decodeHunter(packedScript) + val parametersRegex = Regex("""sml = '([^']+)';\n?.*var sme = '([^']+)'""") + + val (sml, sme) = parametersRegex.find(unpackedScript)?.destructured + ?: error("Failed to extract parameters from script.") + + val chapterInfoRegex = Regex("""const idc = (\d+)""") + val (chapterId) = chapterInfoRegex.find(packedScript)?.destructured + ?: error("Failed to extract chapter ID.") + + val mediaType = "application/json; charset=UTF-8".toMediaType() + val requestBody = """{"a":"$sme","b":"$sml"}""" + + val documentUrl = document.baseUri().toHttpUrl() + + val pageListRequest = POST( + "$baseUrl/api/lel/$chapterId.json", + headers.newBuilder() + .add("Origin", "${documentUrl.scheme}://${documentUrl.host}") + .add("Referer", documentUrl.toString()) + .add("Token", "yf") + .build(), + requestBody.toRequestBody(mediaType), + ) + + val lelResponse = client.newBuilder().cookieJar(CookieJar.NO_COOKIES).build() + .newCall(pageListRequest).execute().use { response -> + if (!response.isSuccessful) { error("Unexpected error while fetching lel.") } + dataAPI(response.body.string(), chapterId.toInt()) + } + + return lelResponse.generateImageUrls().map { Page(it.first, imageUrl = it.second) } + } + + // Page + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + override fun imageRequest(page: Page): Request { + val imgHeaders = headers.newBuilder() + .add("Origin", baseUrl) + .build() + + return GET(page.imageUrl!!, imgHeaders) + } +} diff --git a/src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanMangaDto.kt b/src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanMangaDto.kt new file mode 100644 index 000000000..234af3a8c --- /dev/null +++ b/src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanMangaDto.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.extension.fr.scanmanga + +import kotlinx.serialization.Serializable + +@Serializable +class Page( + val f: String, // filename + val e: String, // extension +) + +@Serializable +class UrlPayload( + private val dN: String, + private val s: String, + private val v: String, + private val c: String, + private val p: Map, +) { + fun generateImageUrls(): List> { + val baseUrl = "https://$dN/$s/$v/$c" + return p.entries + .mapNotNull { (key, page) -> + key.toIntOrNull()?.let { pageIndex -> + pageIndex to "$baseUrl/${page.f}.${page.e}" + } + } + .sortedBy { it.first } // sort by page index + } +} + +@Serializable +class MangaSearchDto( + val title: List?, +) + +@Serializable +class MangaItemDto( + val nom_match: String, + val url: String, + val image: String, +)