Fix Scanmanga (#11149)

Partial revert of "Remove some dead sources (#11133)"

This revert only the source Scan-Manga, who still exists but is blocked outside of France.

This reverts commit 7c8903e84c3a080f77b1401f531e65f70eb6c1f7.
This commit is contained in:
Romain 2025-10-20 17:37:03 +02:00 committed by Draff
parent bacd296e4e
commit 3404731442
Signed by: Draff
GPG Key ID: E8A89F3211677653
9 changed files with 298 additions and 0 deletions

1
src/fr/scanmanga/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
local.properties

View File

@ -0,0 +1,8 @@
ext {
extName = 'Scan-Manga'
extClass = '.ScanManga'
extVersionCode = 12
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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<MangaSearchDto>().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<SChapter> {
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<UrlPayload>()
}
override fun pageListParse(response: Response): List<Page> {
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)
}
}

View File

@ -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<String, Page>,
) {
fun generateImageUrls(): List<Pair<Int, String>> {
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<MangaItemDto>?,
)
@Serializable
class MangaItemDto(
val nom_match: String,
val url: String,
val image: String,
)