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 { ext {
extName = 'Scan-Manga' extName = 'Scan-Manga'
extClass = '.ScanManga' extClass = '.ScanManga'
extVersionCode = 8 extVersionCode = 9
isNsfw = true isNsfw = true
} }

View File

@ -1,217 +1,244 @@
package eu.kanade.tachiyomi.extension.fr.scanmanga package eu.kanade.tachiyomi.extension.fr.scanmanga
import android.util.Base64
import eu.kanade.tachiyomi.network.GET 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.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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 eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json import keiyoushi.utils.parseAs
import kotlinx.serialization.json.jsonArray import okhttp3.CookieJar
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import java.util.zip.Inflater
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() {
class ScanManga : HttpSource() {
override val name = "Scan-Manga" 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 lang = "fr"
override val supportsLatest = true 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() 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 // Popular
override fun popularMangaRequest(page: Int): Request { 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 { title = titleElement.text()
return SManga.create().apply { setUrlWithoutDomain(titleElement.attr("href"))
title = element.select("img").attr("title") thumbnail_url = element.selectFirst("img")?.attr("data-original")
setUrlWithoutDomain(element.attr("href")) }
thumbnail_url = element.select("img").attr("data-original")
} }
}
override fun popularMangaNextPageSelector(): String? = null return MangasPage(mangas, false)
}
// Latest // Latest
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers)
return 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 { val mangas = document.select("#content_news .publi").map { element ->
return SManga.create().apply { SManga.create().apply {
title = element.select("a.nom_manga").text() val mangaElement = element.selectFirst("a.l_manga")!!
setUrlWithoutDomain(element.select("a.nom_manga").attr("href"))
/*thumbnail_url = element.select(".logo_manga img").let { title = mangaElement.text()
if (it.hasAttr("data-original")) setUrlWithoutDomain(mangaElement.attr("href"))
it.attr("data-original") else it.attr("src")
}*/ thumbnail_url = element.selectFirst("img")?.attr("src")
// Better not use it, width is too large, which results in terrible image }
} }
}
override fun latestUpdatesNextPageSelector(): String? = null return MangasPage(mangas, false)
}
// Search // 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 { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val searchHeaders = headersBuilder() val url = "$baseUrl/api/search/quick.json"
.add("Referer", "$baseUrl/scanlation/liste_series.html") .toHttpUrl().newBuilder()
.add("x-requested-with", "XMLHttpRequest") .addQueryParameter("term", query)
.build()
.toString()
val newHeaders = headers.newBuilder()
.add("Content-type", "application/json; charset=UTF-8")
.build() .build()
return GET("$baseUrl/scanlation/scan.data.json", searchHeaders) return GET(url, newHeaders)
} }
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun searchMangaParse(response: Response): MangasPage {
return client.newCall(searchMangaRequest(page, query, filters)) val json = response.body.string()
.asObservableSuccess() if (json == "[]") { return MangasPage(emptyList(), false) }
.map { response ->
searchMangaParse(response, query)
}
}
private fun searchMangaParse(response: Response, query: String): MangasPage { return MangasPage(
return MangasPage(parseMangaFromJson(response).mangas.filter { it.title.contains(query, ignoreCase = true) }, false) json.parseAs<MangaSearchDto>().title?.map {
} SManga.create().apply {
title = it.nom_match
private fun parseMangaFromJson(response: Response): MangasPage { setUrlWithoutDomain(it.url)
val jsonRaw = response.body.string() thumbnail_url = "$baseImageUrl/${it.image}"
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
}
} }
status = entry.value.jsonArray[3].jsonPrimitive.content.let { } ?: emptyList(),
when { false,
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)
} }
override fun searchMangaSelector() = throw UnsupportedOperationException()
// Details // Details
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { override fun mangaDetailsParse(response: Response): SManga {
title = document.select("h2[itemprop=\"name\"]").text() val document = response.asJsoup()
author = document.select("li[itemprop=\"author\"] a").joinToString { it.text() }
description = document.select("p[itemprop=\"description\"]").text() return SManga.create().apply {
thumbnail_url = document.select(".contenu_fiche_technique .image_manga img").attr("src") 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 // Chapters
override fun chapterListSelector() = throw UnsupportedOperationException()
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() 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 { SChapter.create().apply {
name = it.text() name = if (!extraTitle.isNullOrEmpty()) "$chapterName - $extraTitle" else chapterName
setUrlWithoutDomain(it.attr("href")) setUrlWithoutDomain(linkEl.absUrl("href"))
scanlator = document.select("li[itemprop=\"translator\"] a").joinToString { it.text() }
} }
} }
} }
// Pages // Pages
override fun pageListParse(document: Document): List<Page> { private fun decodeHunter(obfuscatedJs: String): String {
val docString = document.toString() 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) val interval = intervalStr.toInt()
if (lelUrl == null) { val option = optionStr.toInt()
lelUrl = Regex("""['"](http.*?le[il].scan-manga.com.*)['"]""").find(docString)?.groupValues?.get(1) 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 -> return buildString {
Page(i, document.location(), lelUrl + pageParam.groupValues[1]) 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 { override fun imageRequest(page: Page): Request {
val imgHeaders = headersBuilder() val imgHeaders = headers.newBuilder()
.add("Referer", page.url) .add("Origin", baseUrl)
.build() .build()
return GET(page.imageUrl!!, imgHeaders) 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,
)