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:
parent
648bb3c295
commit
672c54a8cc
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Scan-Manga'
|
||||
extClass = '.ScanManga'
|
||||
extVersionCode = 8
|
||||
extVersionCode = 9
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -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 ->
|
||||
return MangasPage(
|
||||
json.parseAs<MangaSearchDto>().title?.map {
|
||||
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
|
||||
title = it.nom_match
|
||||
setUrlWithoutDomain(it.url)
|
||||
thumbnail_url = "$baseImageUrl/${it.image}"
|
||||
}
|
||||
} ?: emptyList(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 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())
|
||||
}
|
||||
|
||||
return Regex("""["'](.*?zoneID.*?pageID.*?siteID.*?)["']""").findAll(docString).toList().mapIndexed { i, pageParam ->
|
||||
Page(i, document.location(), lelUrl + pageParam.groupValues[1])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
@ -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