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:
parent
bacd296e4e
commit
3404731442
1
src/fr/scanmanga/.gitignore
vendored
Normal file
1
src/fr/scanmanga/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
local.properties
|
||||
8
src/fr/scanmanga/build.gradle
Normal file
8
src/fr/scanmanga/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'Scan-Manga'
|
||||
extClass = '.ScanManga'
|
||||
extVersionCode = 12
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/fr/scanmanga/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/fr/scanmanga/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/fr/scanmanga/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/fr/scanmanga/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/fr/scanmanga/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/fr/scanmanga/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/fr/scanmanga/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/fr/scanmanga/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
src/fr/scanmanga/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/fr/scanmanga/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user