diff --git a/src/fr/japscan/AndroidManifest.xml b/src/fr/japscan/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/fr/japscan/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/fr/japscan/build.gradle b/src/fr/japscan/build.gradle new file mode 100644 index 000000000..852cb9fb8 --- /dev/null +++ b/src/fr/japscan/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Japscan' + extClass = '.Japscan' + extVersionCode = 44 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/fr/japscan/res/mipmap-hdpi/ic_launcher.png b/src/fr/japscan/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..4659fbb8c Binary files /dev/null and b/src/fr/japscan/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/fr/japscan/res/mipmap-mdpi/ic_launcher.png b/src/fr/japscan/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..19a5531b0 Binary files /dev/null and b/src/fr/japscan/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/fr/japscan/res/mipmap-xhdpi/ic_launcher.png b/src/fr/japscan/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7320f09eb Binary files /dev/null and b/src/fr/japscan/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/fr/japscan/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/japscan/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..52e248fa6 Binary files /dev/null and b/src/fr/japscan/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/fr/japscan/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/japscan/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..166cd3cfe Binary files /dev/null and b/src/fr/japscan/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/fr/japscan/src/eu/kanade/tachiyomi/extension/fr/japscan/Japscan.kt b/src/fr/japscan/src/eu/kanade/tachiyomi/extension/fr/japscan/Japscan.kt new file mode 100644 index 000000000..3638dc48c --- /dev/null +++ b/src/fr/japscan/src/eu/kanade/tachiyomi/extension/fr/japscan/Japscan.kt @@ -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().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 + + override fun fetchLatestUpdates(page: Int): Observable { + 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() + 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 { + 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( + """ + + """.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()) + + 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) : Filter.Select("Page #", arrayOf(0, *pages)) + + override fun getFilterList(): FilterList { + val totalPages = pageNumberDoc?.select("li.page-item:last-child a")?.text() + val pageList = mutableListOf() + 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 = 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() + } + } +}