mirror of
https://github.com/keiyoushi/extensions-source.git
synced 2024-11-25 11:42:47 +01:00
Add JapScan (again) (#510)
* Add JapScan (again) * remove unusued dep * fix search thumbnails
This commit is contained in:
parent
91413f5da8
commit
ca748d75f6
2
src/fr/japscan/AndroidManifest.xml
Normal file
2
src/fr/japscan/AndroidManifest.xml
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
7
src/fr/japscan/build.gradle
Normal file
7
src/fr/japscan/build.gradle
Normal file
@ -0,0 +1,7 @@
|
||||
ext {
|
||||
extName = 'Japscan'
|
||||
extClass = '.Japscan'
|
||||
extVersionCode = 44
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/fr/japscan/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/fr/japscan/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
BIN
src/fr/japscan/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/fr/japscan/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
BIN
src/fr/japscan/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/fr/japscan/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
BIN
src/fr/japscan/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/fr/japscan/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
BIN
src/fr/japscan/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/fr/japscan/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,405 @@
|
||||
package eu.kanade.tachiyomi.extension.fr.japscan
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
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.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Japscan : ConfigurableSource, ParsedHttpSource() {
|
||||
|
||||
override val id: Long = 11
|
||||
|
||||
override val name = "Japscan"
|
||||
|
||||
override val baseUrl = "https://www.japscan.lol"
|
||||
|
||||
override val lang = "fr"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1, 2)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
val dateFormat by lazy {
|
||||
SimpleDateFormat("dd MMM yyyy", Locale.US)
|
||||
}
|
||||
private const val SHOW_SPOILER_CHAPTERS_Title = "Les chapitres en Anglais ou non traduit sont upload en tant que \" Spoilers \" sur Japscan"
|
||||
private const val SHOW_SPOILER_CHAPTERS = "JAPSCAN_SPOILER_CHAPTERS"
|
||||
private val prefsEntries = arrayOf("Montrer uniquement les chapitres traduit en Français", "Montrer les chapitres spoiler")
|
||||
private val prefsEntryValues = arrayOf("hide", "show")
|
||||
}
|
||||
|
||||
private fun chapterListPref() = preferences.getString(SHOW_SPOILER_CHAPTERS, "hide")
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("referer", "$baseUrl/")
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/mangas/", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
pageNumberDoc = document
|
||||
|
||||
val mangas = document.select(popularMangaSelector()).map { element ->
|
||||
popularMangaFromElement(element)
|
||||
}
|
||||
val hasNextPage = false
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
|
||||
override fun popularMangaSelector() = "#top_mangas_week li"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select("a").first()!!.let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
manga.thumbnail_url = "$baseUrl/imgs/${it.attr("href").replace(Regex("/$"),".jpg").replace("manga","mangas")}".lowercase(Locale.ROOT)
|
||||
}
|
||||
return manga
|
||||
}
|
||||
|
||||
// Latest
|
||||
private lateinit var latestDirectory: List<Element>
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return if (page == 1) {
|
||||
client.newCall(latestUpdatesRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { latestUpdatesParse(it) }
|
||||
} else {
|
||||
Observable.just(parseLatestDirectory(page))
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET(baseUrl, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
latestDirectory = document.select(latestUpdatesSelector())
|
||||
.distinctBy { element -> element.select("a").attr("href") }
|
||||
|
||||
return parseLatestDirectory(1)
|
||||
}
|
||||
|
||||
private fun parseLatestDirectory(page: Int): MangasPage {
|
||||
val manga = mutableListOf<SManga>()
|
||||
val end = ((page * 24) - 1).let { if (it <= latestDirectory.lastIndex) it else latestDirectory.lastIndex }
|
||||
|
||||
for (i in (((page - 1) * 24)..end)) {
|
||||
manga.add(latestUpdatesFromElement(latestDirectory[i]))
|
||||
}
|
||||
|
||||
return MangasPage(manga, end < latestDirectory.lastIndex)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = "#chapters h3.mb-0"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String = throw UnsupportedOperationException()
|
||||
|
||||
// Search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isEmpty()) {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("mangas")
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is TextField -> addPathSegment(((page - 1) + filter.state.toInt()).toString())
|
||||
is PageList -> addPathSegment(((page - 1) + filter.values[filter.state]).toString())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
} else {
|
||||
val formBody = FormBody.Builder()
|
||||
.add("search", query)
|
||||
.build()
|
||||
val searchHeaders = headers.newBuilder()
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
return POST("$baseUrl/live-search/", searchHeaders, formBody)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector(): String = "li.page-item:last-child:not(li.active)"
|
||||
|
||||
override fun searchMangaSelector(): String = "div.card div.p-2"
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (response.request.url.pathSegments.first() == "live-search") {
|
||||
val jsonResult = json.parseToJsonElement(response.body.string()).jsonArray
|
||||
|
||||
val mangaList = jsonResult.map { jsonEl -> searchMangaFromJson(jsonEl.jsonObject) }
|
||||
|
||||
return MangasPage(mangaList, hasNextPage = false)
|
||||
}
|
||||
|
||||
val baseUrlHost = baseUrl.toHttpUrl().host
|
||||
val document = response.asJsoup()
|
||||
val manga = document
|
||||
.select(searchMangaSelector())
|
||||
.filter { it ->
|
||||
// Filter out ads masquerading as search results
|
||||
it.select("p a").attr("abs:href").toHttpUrl().host == baseUrlHost
|
||||
}
|
||||
.map(::searchMangaFromElement)
|
||||
val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
|
||||
|
||||
return MangasPage(manga, hasNextPage)
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
thumbnail_url = element.select("img").attr("abs:src")
|
||||
element.select("p a").let {
|
||||
title = it.text()
|
||||
url = it.attr("href")
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchMangaFromJson(jsonObj: JsonObject): SManga = SManga.create().apply {
|
||||
url = jsonObj["url"]!!.jsonPrimitive.content
|
||||
title = jsonObj["name"]!!.jsonPrimitive.content
|
||||
thumbnail_url = baseUrl + jsonObj["image"]!!.jsonPrimitive.content
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.selectFirst("#main .card-body")!!
|
||||
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url = infoElement.select("img").attr("abs:src")
|
||||
|
||||
val infoRows = infoElement.select(".row, .d-flex")
|
||||
infoRows.select("p").forEach { el ->
|
||||
when (el.select("span").text().trim()) {
|
||||
"Auteur(s):" -> manga.author = el.text().replace("Auteur(s):", "").trim()
|
||||
"Artiste(s):" -> manga.artist = el.text().replace("Artiste(s):", "").trim()
|
||||
"Genre(s):" -> manga.genre = el.text().replace("Genre(s):", "").trim()
|
||||
"Statut:" -> manga.status = el.text().replace("Statut:", "").trim().let {
|
||||
parseStatus(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
manga.description = infoElement.select("div:contains(Synopsis) + p").text().orEmpty()
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String) = when {
|
||||
status.contains("En Cours") -> SManga.ONGOING
|
||||
status.contains("Terminé") -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "#chapters_list > div.collapse > div.chapters_list" +
|
||||
if (chapterListPref() == "hide") { ":not(:has(.badge:contains(SPOILER),.badge:contains(RAW),.badge:contains(VUS)))" } else { "" }
|
||||
// JapScan sometimes uploads some "spoiler preview" chapters, containing 2 or 3 untranslated pictures taken from a raw. Sometimes they also upload full RAWs/US versions and replace them with a translation as soon as available.
|
||||
// Those have a span.badge "SPOILER" or "RAW". The additional pseudo selector makes sure to exclude these from the chapter list.
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val urlElement = element.selectFirst("a")!!
|
||||
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.ownText()
|
||||
// Using ownText() doesn't include childs' text, like "VUS" or "RAW" badges, in the chapter name.
|
||||
chapter.date_upload = element.selectFirst("span")!!.text().trim().let { parseChapterDate(it) }
|
||||
return chapter
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String) = runCatching {
|
||||
dateFormat.parse(date)!!.time
|
||||
}.getOrDefault(0L)
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val interfaceName = randomString()
|
||||
val zjsElement = document.selectFirst("script[src*=/zjs/]")
|
||||
?: throw Exception("ZJS not found")
|
||||
val dataElement = document.selectFirst("#data")
|
||||
?: throw Exception("Chapter data not found")
|
||||
val minDoc = Document.createShell(document.location())
|
||||
val minDocBody = minDoc.body()
|
||||
|
||||
minDocBody.appendChild(dataElement)
|
||||
minDocBody.append(
|
||||
"""
|
||||
<script>
|
||||
const _parse = JSON.parse;
|
||||
|
||||
JSON.parse = function(...args) {
|
||||
window.$interfaceName.passPayload(args[0]);
|
||||
return _parse(...args);
|
||||
};
|
||||
</script>
|
||||
""".trimIndent(),
|
||||
)
|
||||
minDocBody.appendChild(zjsElement)
|
||||
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
val latch = CountDownLatch(1)
|
||||
val jsInterface = JsInterface(latch)
|
||||
var webView: WebView? = null
|
||||
|
||||
handler.post {
|
||||
val innerWv = WebView(Injekt.get<Application>())
|
||||
|
||||
webView = innerWv
|
||||
innerWv.settings.javaScriptEnabled = true
|
||||
innerWv.settings.blockNetworkImage = true
|
||||
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||
innerWv.addJavascriptInterface(jsInterface, interfaceName)
|
||||
|
||||
innerWv.loadDataWithBaseURL(
|
||||
document.location(),
|
||||
minDoc.outerHtml(),
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
latch.await(5, TimeUnit.SECONDS)
|
||||
handler.post { webView?.destroy() }
|
||||
|
||||
if (latch.count == 1L) {
|
||||
throw Exception("Timed out decrypting image links")
|
||||
}
|
||||
|
||||
val baseUrlHost = baseUrl.toHttpUrl().host
|
||||
|
||||
return jsInterface
|
||||
.images
|
||||
.filterNot { it.toHttpUrl().host == baseUrlHost } // Pages not served through their CDN are probably ads
|
||||
.mapIndexed { i, url ->
|
||||
Page(i, imageUrl = url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = ""
|
||||
|
||||
// Filters
|
||||
private class TextField(name: String) : Filter.Text(name)
|
||||
|
||||
private class PageList(pages: Array<Int>) : Filter.Select<Int>("Page #", arrayOf(0, *pages))
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val totalPages = pageNumberDoc?.select("li.page-item:last-child a")?.text()
|
||||
val pageList = mutableListOf<Int>()
|
||||
return if (!totalPages.isNullOrEmpty()) {
|
||||
for (i in 0 until totalPages.toInt()) {
|
||||
pageList.add(i + 1)
|
||||
}
|
||||
FilterList(
|
||||
Filter.Header("Page alphabétique"),
|
||||
PageList(pageList.toTypedArray()),
|
||||
)
|
||||
} else {
|
||||
FilterList(
|
||||
Filter.Header("Page alphabétique"),
|
||||
TextField("Page #"),
|
||||
Filter.Header("Appuyez sur reset pour la liste"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var pageNumberDoc: Document? = null
|
||||
|
||||
// Prefs
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
val chapterListPref = androidx.preference.ListPreference(screen.context).apply {
|
||||
key = SHOW_SPOILER_CHAPTERS_Title
|
||||
title = SHOW_SPOILER_CHAPTERS_Title
|
||||
entries = prefsEntries
|
||||
entryValues = prefsEntryValues
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = this.findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(SHOW_SPOILER_CHAPTERS, entry).commit()
|
||||
}
|
||||
}
|
||||
screen.addPreference(chapterListPref)
|
||||
}
|
||||
|
||||
private fun randomString(length: Int = 10): String {
|
||||
val charPool = ('a'..'z') + ('A'..'Z')
|
||||
return List(length) { charPool.random() }.joinToString("")
|
||||
}
|
||||
|
||||
internal class JsInterface(private val latch: CountDownLatch) {
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
var images: List<String> = listOf()
|
||||
private set
|
||||
|
||||
@JavascriptInterface
|
||||
@Suppress("UNUSED")
|
||||
fun passPayload(rawData: String) {
|
||||
val data = json.parseToJsonElement(rawData).jsonObject
|
||||
|
||||
images = data["imagesLink"]!!.jsonArray.map { it.jsonPrimitive.content }
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user