Update scanmanga (#10135)

* First Commit ScanManga

* Increase extVersionCode

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/fr/scanmanga/src/eu/kanade/tachiyomi/extension/fr/scanmanga/ScanManga.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Step one (not working)

* My last attempt

* Update popular, latest, and search functions

* Much more trial and error later

* This is it. It's working :)))

* Cleaned from debuggers

* Apply suggestions from  stevenyomi

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>

* More suggestions

* Convert to HttpSource

* Added base image url at the top

* Only disable cookies when absolutely needed.

---------

Co-authored-by: osamu00 <osamu.kozu@gmail.com>
Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>
This commit is contained in:
Romain 2025-08-16 15:18:27 +02:00 committed by Draff
parent 648bb3c295
commit 672c54a8cc
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 217 additions and 149 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Scan-Manga'
extClass = '.ScanManga'
extVersionCode = 8
extVersionCode = 9
isNsfw = true
}

View File

@ -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<MangasPage> {
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<MangaSearchDto>().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<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()
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<Page> {
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<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
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)

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,
)