diff --git a/src/fr/scanmanga/build.gradle b/src/fr/scanmanga/build.gradle index 6a3b13c30..98ed30515 100644 --- a/src/fr/scanmanga/build.gradle +++ b/src/fr/scanmanga/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Scan-Manga' extClass = '.ScanManga' - extVersionCode = 8 + extVersionCode = 9 isNsfw = true } 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 index 232134263..d6480f905 100644 --- 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 @@ -1,217 +1,244 @@ package eu.kanade.tachiyomi.extension.fr.scanmanga +import android.util.Base64 import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.asObservableSuccess +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.ParsedHttpSource +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.asJsoup -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import keiyoushi.utils.parseAs +import okhttp3.CookieJar import okhttp3.Headers -import okhttp3.OkHttpClient +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import org.jsoup.parser.Parser -import rx.Observable -import uy.kohesive.injekt.injectLazy -import kotlin.random.Random - -class ScanManga : ParsedHttpSource() { +import java.util.zip.Inflater +class ScanManga : HttpSource() { override val name = "Scan-Manga" - override val baseUrl = "https://www.scan-manga.com" + 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 val client: OkHttpClient = network.cloudflareClient.newBuilder() - .addNetworkInterceptor { chain -> - val originalCookies = chain.request().header("Cookie") ?: "" - val newReq = chain - .request() - .newBuilder() - .header("Cookie", "$originalCookies; _ga=GA1.2.${shuffle("123456789")}.${System.currentTimeMillis() / 1000}") - .build() - chain.proceed(newReq) - }.build() - - private val json: Json by injectLazy() - override fun headersBuilder(): Headers.Builder = super.headersBuilder() - .add("Accept-Language", "fr-FR") + .add("Accept-Language", "fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3") + .set("User-Agent", "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36") // Popular override fun popularMangaRequest(page: Int): Request { - return GET("$baseUrl/TOP-Manga-Webtoon-22.html", headers) + return GET("$baseUrl/TOP-Manga-Webtoon-36.html", headers) } - override fun popularMangaSelector() = "div.image_manga a[href]" + 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")!! - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - title = element.select("img").attr("title") - setUrlWithoutDomain(element.attr("href")) - thumbnail_url = element.select("img").attr("data-original") + title = titleElement.text() + setUrlWithoutDomain(titleElement.attr("href")) + thumbnail_url = element.selectFirst("img")?.attr("data-original") + } } - } - override fun popularMangaNextPageSelector(): String? = null + return MangasPage(mangas, false) + } // Latest - override fun latestUpdatesRequest(page: Int): Request { - return GET(baseUrl, headers) - } + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers) - override fun latestUpdatesSelector() = "#content_news .listing" + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() - override fun latestUpdatesFromElement(element: Element): SManga { - return SManga.create().apply { - title = element.select("a.nom_manga").text() - setUrlWithoutDomain(element.select("a.nom_manga").attr("href")) - /*thumbnail_url = element.select(".logo_manga img").let { - if (it.hasAttr("data-original")) - it.attr("data-original") else it.attr("src") - }*/ - // Better not use it, width is too large, which results in terrible image + 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") + } } - } - override fun latestUpdatesNextPageSelector(): String? = null + return MangasPage(mangas, false) + } // Search - override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException() - - override fun searchMangaNextPageSelector() = throw UnsupportedOperationException() - - override fun searchMangaParse(response: Response): MangasPage = parseMangaFromJson(response) - - private fun shuffle(s: String?): String { - val result = StringBuffer(s!!) - var n = result.length - while (n > 1) { - val randomPoint: Int = Random.nextInt(n) - val randomChar = result[randomPoint] - result.setCharAt(n - 1, randomChar) - n-- - } - return result.toString() - } - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val searchHeaders = headersBuilder() - .add("Referer", "$baseUrl/scanlation/liste_series.html") - .add("x-requested-with", "XMLHttpRequest") + 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("$baseUrl/scanlation/scan.data.json", searchHeaders) + return GET(url, newHeaders) } - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - return client.newCall(searchMangaRequest(page, query, filters)) - .asObservableSuccess() - .map { response -> - searchMangaParse(response, query) - } - } + override fun searchMangaParse(response: Response): MangasPage { + val json = response.body.string() + if (json == "[]") { return MangasPage(emptyList(), false) } - private fun searchMangaParse(response: Response, query: String): MangasPage { - return MangasPage(parseMangaFromJson(response).mangas.filter { it.title.contains(query, ignoreCase = true) }, false) - } - - private fun parseMangaFromJson(response: Response): MangasPage { - val jsonRaw = response.body.string() - - if (jsonRaw.isEmpty()) { - return MangasPage(emptyList(), hasNextPage = false) - } - - val jsonObj = json.parseToJsonElement(jsonRaw).jsonObject - - val mangaList = jsonObj.entries.map { entry -> - SManga.create().apply { - title = Parser.unescapeEntities(entry.key, false) - genre = entry.value.jsonArray[2].jsonPrimitive.content.let { - when { - it.contains("0") -> "Shōnen" - it.contains("1") -> "Shōjo" - it.contains("2") -> "Seinen" - it.contains("3") -> "Josei" - else -> null - } + return MangasPage( + json.parseAs().title?.map { + SManga.create().apply { + title = it.nom_match + setUrlWithoutDomain(it.url) + thumbnail_url = "$baseImageUrl/${it.image}" } - status = entry.value.jsonArray[3].jsonPrimitive.content.let { - when { - it.contains("0") -> SManga.ONGOING // En cours - it.contains("1") -> SManga.ONGOING // En pause - it.contains("2") -> SManga.COMPLETED // Terminé - it.contains("3") -> SManga.COMPLETED // One shot - else -> SManga.UNKNOWN - } - } - url = "/" + entry.value.jsonArray[0].jsonPrimitive.content + "/" + - entry.value.jsonArray[1].jsonPrimitive.content + ".html" - } - } - - return MangasPage(mangaList, hasNextPage = false) + } ?: emptyList(), + false, + ) } - override fun searchMangaSelector() = throw UnsupportedOperationException() - // Details - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - title = document.select("h2[itemprop=\"name\"]").text() - author = document.select("li[itemprop=\"author\"] a").joinToString { it.text() } - description = document.select("p[itemprop=\"description\"]").text() - thumbnail_url = document.select(".contenu_fiche_technique .image_manga img").attr("src") + 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 chapterListSelector() = throw UnsupportedOperationException() - - override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() - 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() - return document.select("div.texte_volume_manga ul li.chapitre div.chapitre_nom a").map { SChapter.create().apply { - name = it.text() - setUrlWithoutDomain(it.attr("href")) - scanlator = document.select("li[itemprop=\"translator\"] a").joinToString { it.text() } + name = if (!extraTitle.isNullOrEmpty()) "$chapterName - $extraTitle" else chapterName + setUrlWithoutDomain(linkEl.absUrl("href")) } } } // Pages - override fun pageListParse(document: Document): List { - val docString = document.toString() + 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") - var lelUrl = Regex("""['"](http.*?scanmanga.eu.*)['"]""").find(docString)?.groupValues?.get(1) - if (lelUrl == null) { - lelUrl = Regex("""['"](http.*?le[il].scan-manga.com.*)['"]""").find(docString)?.groupValues?.get(1) - } + 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 Regex("""["'](.*?zoneID.*?pageID.*?siteID.*?)["']""").findAll(docString).toList().mapIndexed { i, pageParam -> - Page(i, document.location(), lelUrl + pageParam.groupValues[1]) + 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()) + } } } - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() + 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 + + val chapterInfoRegex = Regex("""const idc = (\d+)""") + val (chapterId) = chapterInfoRegex.find(packedScript)!!.destructured + + 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 = headersBuilder() - .add("Referer", page.url) + 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, +)