diff --git a/src/fr/japscan/build.gradle b/src/fr/japscan/build.gradle new file mode 100644 index 000000000..946ef335a --- /dev/null +++ b/src/fr/japscan/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + extName = 'Japscan' + pkgNameSuffix = 'fr.japscan' + extClass = '.Japscan' + extVersionCode = 21 + libVersion = '1.2' +} + +dependencies { + implementation 'org.apache.commons:commons-lang3:3.8.1' +} + +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/res/web_hi_res_512.png b/src/fr/japscan/res/web_hi_res_512.png new file mode 100644 index 000000000..3af8c2771 Binary files /dev/null and b/src/fr/japscan/res/web_hi_res_512.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..60f3dfad8 --- /dev/null +++ b/src/fr/japscan/src/eu/kanade/tachiyomi/extension/fr/japscan/Japscan.kt @@ -0,0 +1,421 @@ +package eu.kanade.tachiyomi.extension.fr.japscan + +import android.annotation.SuppressLint +import android.app.Application +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.Canvas +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.support.v7.preference.ListPreference +import android.support.v7.preference.PreferenceScreen +import android.view.View +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.string +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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 java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.CountDownLatch +import okhttp3.FormBody +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import org.apache.commons.lang3.StringUtils +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class Japscan : ConfigurableSource, ParsedHttpSource() { + + override val id: Long = 11 + + override val name = "Japscan" + + override val baseUrl = "https://www.japscan.se" + + override val lang = "fr" + + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + @SuppressLint("SetJavaScriptEnabled") + override val client: OkHttpClient = network.cloudflareClient.newBuilder().addInterceptor { chain -> + val indicator = "&wvsc" + val cleanupjs = "var db=document.body,chl=db.children;for(db.appendChild(document.getElementsByTagName('CNV-VV')[0]);'CNV-VV'!=chl[0].tagName;)db.removeChild(chl[0]);for(var i of[].slice.call(chl[0].all_canvas)){i.style.maxWidth=(i.width+\"px\")}window.variable={w:chl[0].all_canvas[0].width,h:chl[0].all_canvas[0].height};" + val request = chain.request() + val url = request.url().toString() + + val newRequest = request.newBuilder() + .url(url.substringBefore(indicator)) + .build() + val response = chain.proceed(newRequest) + if (!url.endsWith(indicator)) return@addInterceptor response + // Webview screenshotting code + val handler = Handler(Looper.getMainLooper()) + val latch = CountDownLatch(1) + var webView: WebView? = null + var height = 0 + var width = 0 + + handler.post { + val webview = WebView(Injekt.get()) + webView = webview + webview.settings.javaScriptEnabled = true + webview.settings.domStorageEnabled = true + webview.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + + webview.webChromeClient = object : WebChromeClient() { + @SuppressLint("NewApi") + override fun onProgressChanged(view: WebView, progress: Int) { + if (progress == 100) { + view.evaluateJavascript(cleanupjs) { + if (it.contains('{')) { + val j = JsonParser().parse(it).asJsonObject + width = j["w"].asInt + height = j["h"].asInt + latch.countDown() + } else { + webview.loadUrl(url.replace("&wvsc", "")) + } + } + } + } + } + webview.loadUrl(url.replace("&wvsc", "")) + } + + latch.await() + + // webView!!.isDrawingCacheEnabled = true + + webView!!.measure(width + 100, height + 100) + webView!!.layout(0, 0, width + 100, height + 100) + Thread.sleep(350) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + var canvas = Canvas(bitmap) + webView!!.draw(canvas) + + // val bitmap: Bitmap = webView!!.drawingCache + val output = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + + val rb = ResponseBody.create(MediaType.parse("image/png"), output.toByteArray()) + response.newBuilder().body(rb).build() + }.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") + + // 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 > span" + override fun popularMangaFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("a").first().let { + manga.setUrlWithoutDomain(it.attr("href")) + manga.title = it.text() + + val s = StringUtils.stripAccents(it.text()) + .replace("[\\W]".toRegex(), "-") + .replace("[-]{2,}".toRegex(), "-") + .replace("^-|-$".toRegex(), "") + manga.thumbnail_url = "$baseUrl/imgs/mangas/$s.jpg".toLowerCase(Locale.ROOT) + } + return manga + } + + // Latest + override fun latestUpdatesRequest(page: Int): Request { + return GET(baseUrl, headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + val mangas = document.select(latestUpdatesSelector()) + .distinctBy { element -> element.select("a").attr("href") } + .map { element -> + latestUpdatesFromElement(element) + } + val hasNextPage = false + return MangasPage(mangas, hasNextPage) + } + + override fun latestUpdatesNextPageSelector(): String? = null + override fun latestUpdatesSelector() = "#chapters > div > h3.text-truncate" + override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) + + // Search + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isEmpty()) { + val uri = Uri.parse(baseUrl).buildUpon() + .appendPath("mangas") + filters.forEach { filter -> + when (filter) { + is TextField -> uri.appendPath(((page - 1) + filter.state.toInt()).toString()) + is PageList -> uri.appendPath(((page - 1) + filter.values[filter.state]).toString()) + } + } + return GET(uri.toString(), 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, a.result-link" + override fun searchMangaParse(response: Response): MangasPage { + if ("live-search" in response.request().url().toString()) { + val body = response.body()!!.string() + val json = JsonParser().parse(body).asJsonArray + val mangas = json.map { jsonElement -> + searchMangaFromJson(jsonElement) + } + + val hasNextPage = false + + return MangasPage(mangas, hasNextPage) + } else { + val document = response.asJsoup() + + val mangas = document.select(searchMangaSelector()).map { element -> + searchMangaFromElement(element) + } + + val hasNextPage = searchMangaNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + } + + override fun searchMangaFromElement(element: Element): SManga = 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(jsonElement: JsonElement): SManga = SManga.create().apply { + title = jsonElement["name"].string + url = jsonElement["url"].string + } + + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.select("div#main > .card > .card-body").first() + + val manga = SManga.create() + manga.thumbnail_url = "$baseUrl/${infoElement.select(".d-flex > div.m-2:eq(0) > img").attr("src")}" + + infoElement.select(".d-flex > div.m-2:eq(1) > p.mb-2").forEachIndexed { _, 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("> 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.select("a").first() + + 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.select("> span").text().trim().let { parseChapterDate(it) } + return chapter + } + + private fun parseChapterDate(date: String): Long { + return try { + dateFormat.parse(date)?.time ?: 0 + } catch (e: ParseException) { + 0L + } + } + + override fun pageListParse(document: Document): List { + return if (document.getElementsByTag("script").size> 12) { // scrambled images, webview screenshotting + document.getElementsByTag("option").mapIndexed { i, it -> Page(i, "", baseUrl + it.attr("value") + "&wvsc") } + } else { + // unscrambled images, check for single page + val zjsurl = document.getElementsByTag("script").first { it.attr("src").contains("zjs", ignoreCase = true) }.attr("src") + val zjs = client.newCall(GET(baseUrl + zjsurl, headers)).execute().body()!!.string() + if ((zjs.toLowerCase().split("new image").size - 1) == 1) { // single page, webview request dumping + val pagecount = document.getElementsByTag("option").size + val pages = ArrayList() + val handler = Handler(Looper.getMainLooper()) + val latch = CountDownLatch(1) + + val dummyimage = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val dummystream = ByteArrayOutputStream() + dummyimage.compress(Bitmap.CompressFormat.JPEG, 100, dummystream) + + handler.post { + val webview = WebView(Injekt.get()) + webview.settings.javaScriptEnabled = true + webview.settings.domStorageEnabled = true + webview.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + if (request.url.toString().startsWith("https://c.")) { + pages.add(Page(pages.size, "", request.url.toString())) + if (pages.size == pagecount) { latch.countDown() } + return WebResourceResponse("image/jpeg", "UTF-8", ByteArrayInputStream(dummystream.toByteArray())) + } + return super.shouldInterceptRequest(view, request) + } + } + webview.loadUrl(baseUrl + document.getElementsByTag("option").first().attr("value")) + } + latch.await() + return pages + } else { // page by page, just do webview screenshotting because it's easier + document.getElementsByTag("option").mapIndexed { i, it -> Page(i, "", baseUrl + it.attr("value") + "&wvsc") } + } + } + } + + 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) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val chapterListPref = 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) + } +}