From 8983e8e6883415647397b50d06ac262d30b7c660 Mon Sep 17 00:00:00 2001 From: DitFranXX <45893338+DitFranXX@users.noreply.github.com> Date: Thu, 7 Feb 2019 00:55:27 +0900 Subject: [PATCH] Add MangaShow.Me (Korean source) (#775) Add MangaShow.Me (Korean source) --- src/ko/mangashowme/build.gradle | 16 ++ .../extension/ko/mangashowme/MSMFilters.kt | 193 ++++++++++++++ .../ko/mangashowme/MSMImageDecoder.kt | 166 ++++++++++++ .../extension/ko/mangashowme/MangaShowMe.kt | 238 ++++++++++++++++++ 4 files changed, 613 insertions(+) create mode 100644 src/ko/mangashowme/build.gradle create mode 100644 src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMFilters.kt create mode 100644 src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMImageDecoder.kt create mode 100644 src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MangaShowMe.kt diff --git a/src/ko/mangashowme/build.gradle b/src/ko/mangashowme/build.gradle new file mode 100644 index 000000000..4b5b4ffa9 --- /dev/null +++ b/src/ko/mangashowme/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: MangaShow.Me' + pkgNameSuffix = 'ko.mangashowme' + extClass = '.MangaShowMe' + extVersionCode = 1 + libVersion = '1.2' +} + +dependencies { + compileOnly project(':duktape-stub') +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMFilters.kt b/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMFilters.kt new file mode 100644 index 000000000..9e4776b47 --- /dev/null +++ b/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMFilters.kt @@ -0,0 +1,193 @@ +package eu.kanade.tachiyomi.extension.ko.mangashowme + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl +import okhttp3.Request + + +// TODO: Completely Implement/Update Filters(Genre/Artist). +// private class TextField(name: String, val key: String) : Filter.Text(name) +private class SearchCheckBox(val id: Int, name: String) : Filter.CheckBox(name) + +private class SearchFieldMatch : Filter.Select("Search Match", arrayOf("Not Set", "AND", "OR")) +private class SearchTagMatch : Filter.Select("Tag Match", arrayOf("AND", "OR")) +private class SearchGenresList(genres: List) : Filter.Group("Genres", genres) +private class SearchNamingList(naming: List) : Filter.Group("Naming", naming) +private class SearchStatusList(status: List) : Filter.Group("Status", status) + +private fun searchNaming() = listOf( + SearchCheckBox(0, "ㄱ"), + SearchCheckBox(1, "ㄲ"), + SearchCheckBox(2, "ㄴ"), + SearchCheckBox(3, "ㄷ"), + SearchCheckBox(4, "ㄸ"), + SearchCheckBox(5, "ㄹ"), + SearchCheckBox(6, "ㅁ"), + SearchCheckBox(7, "ㅂ"), + SearchCheckBox(8, "ㅃ"), + SearchCheckBox(9, "ㅅ"), + SearchCheckBox(10, "ㅆ"), + SearchCheckBox(11, "ㅇ"), + SearchCheckBox(12, "ㅈ"), + SearchCheckBox(13, "ㅉ"), + SearchCheckBox(14, "ㅊ"), + SearchCheckBox(15, "ㅋ"), + SearchCheckBox(16, "ㅌ"), + SearchCheckBox(17, "ㅍ"), + SearchCheckBox(18, "ㅎ"), + SearchCheckBox(19, "A-Z"), + SearchCheckBox(20, "0-9") +) + +private fun searchStatus() = listOf( + SearchCheckBox(0, "미분류"), + SearchCheckBox(1, "주간"), + SearchCheckBox(2, "격주"), + SearchCheckBox(3, "월간"), + SearchCheckBox(4, "격월/비정기"), + SearchCheckBox(5, "단편"), + SearchCheckBox(6, "단행본"), + SearchCheckBox(7, "완결") +) + +private fun searchGenres() = listOf( + SearchCheckBox(0, "17"), + SearchCheckBox(0, "BL"), + SearchCheckBox(0, "SF"), + SearchCheckBox(0, "TS"), + SearchCheckBox(0, "개그"), + SearchCheckBox(0, "게임"), + SearchCheckBox(0, "공포"), + SearchCheckBox(0, "도박"), + SearchCheckBox(0, "드라마"), + SearchCheckBox(0, "라노벨"), + SearchCheckBox(0, "러브코미디"), + SearchCheckBox(0, "로맨스"), + SearchCheckBox(0, "먹방"), + SearchCheckBox(0, "백합"), + SearchCheckBox(0, "붕탁"), + SearchCheckBox(0, "순정"), + SearchCheckBox(0, "스릴러"), + SearchCheckBox(0, "스포츠"), + SearchCheckBox(0, "시대"), + SearchCheckBox(0, "애니화"), + SearchCheckBox(0, "액션"), + SearchCheckBox(0, "역사"), + SearchCheckBox(0, "요리"), + SearchCheckBox(0, "음악"), + SearchCheckBox(0, "이세계"), + SearchCheckBox(0, "일상"), + SearchCheckBox(0, "전생"), + SearchCheckBox(0, "추리"), + SearchCheckBox(0, "판타지"), + SearchCheckBox(0, "학원"), + SearchCheckBox(0, "호러") +) + +fun getFilters() = FilterList( + SearchNamingList(searchNaming()), + SearchStatusList(searchStatus()), + SearchGenresList(searchGenres()), + Filter.Separator(), + SearchFieldMatch(), + SearchTagMatch() + //Filter.Separator(), + //TextField("Author/Artist (Accurate full name)", "author") +) + +fun searchComplexFilterMangaRequestBuilder(baseUrl: String, page: Int, query: String, filters: FilterList): Request { + // normal search function. + fun normalSearch(state: Int = 0): Request { + val url = HttpUrl.parse("$baseUrl/bbs/search.php?url=$baseUrl/bbs/search.php")!!.newBuilder() + + if (state > 0) { + url.addQueryParameter("sop", arrayOf("and", "or")[state - 1]) + } + + url.addQueryParameter("stx", query) + + if (page > 1) { + url.addQueryParameter("page", "${page - 1}") + } + + return GET(url.toString()) + } + + val nameFilter = mutableListOf() + val statusFilter = mutableListOf() + val genresFilter = mutableListOf() + var matchFieldFilter = 0 + var matchTagFilter = 1 + + filters.forEach { filter -> + when (filter) { + is SearchFieldMatch -> { + matchFieldFilter = filter.state + } + } + } + + filters.forEach { filter -> + when (filter) { + is SearchTagMatch -> { + if (filter.state > 0) { + matchTagFilter = filter.state + 1 + } + } + + is SearchNamingList -> { + filter.state.forEach { + if (it.state) { + nameFilter.add(it.id) + } + } + } + + is SearchStatusList -> { + filter.state.forEach { + if (it.state) { + statusFilter.add(it.id) + } + } + } + + is SearchGenresList -> { + filter.state.forEach { + if (it.state) { + genresFilter.add(it.name) + } + } + } + +// is TextField -> { +// if (type == 4 && filter.key == "author") { +// if (filter.key.length > 1) { +// return GET("$baseUrl/bbs/page.php?hid=manga_list&sfl=4&stx=${filter.state}") +// } +// } +// } + } + } + + // If Query is over 2 length, just go to normal search + if (query.length > 1) { + return normalSearch(matchFieldFilter) + } + + if (nameFilter.isEmpty() && statusFilter.isEmpty() && genresFilter.isEmpty()) { + return GET("$baseUrl/bbs/page.php?hid=manga_list") + } + + val url = HttpUrl.parse("$baseUrl/bbs/page.php?hid=manga_list")!!.newBuilder() + url.addQueryParameter("search_type", matchTagFilter.toString()) + url.addQueryParameter("_1", nameFilter.joinToString(",")) + url.addQueryParameter("_2", statusFilter.joinToString(",")) + url.addQueryParameter("_3", genresFilter.joinToString(",")) + if (page > 1) { + url.addQueryParameter("page", "${page - 1}") + } + + return GET(url.toString()) +} \ No newline at end of file diff --git a/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMImageDecoder.kt b/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMImageDecoder.kt new file mode 100644 index 000000000..ebc7cafd9 --- /dev/null +++ b/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MSMImageDecoder.kt @@ -0,0 +1,166 @@ +package eu.kanade.tachiyomi.extension.ko.mangashowme + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Rect +import eu.kanade.tachiyomi.network.GET +import okhttp3.* +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/* + * `v1` means url padding of image host. + * It's not need now, but it remains in this code for sometime. + */ + +internal class ImageDecoder(private val version: String, scripts: String) { + private val cnt = substringBetween(scripts, "var view_cnt = ", ";") + .toIntOrNull() ?: 0 + private val chapter = substringBetween(scripts, "var chapter = ", ";") + .toIntOrNull() ?: 0 + + fun request(url: String): String { + return when (version) { + "v1" -> decodeVersion1ImageUrl(cnt, chapter, url) + else -> url + } + } + + private fun decodeVersion1ImageUrl(cnt: Int, chapter: Int, url: String): String { + return HttpUrl.parse(url)!!.newBuilder() + .addQueryParameter("cnt", cnt.toString()) + .addQueryParameter("ch", chapter.toString()) + .addQueryParameter("ver", "v1") + .addQueryParameter("type", "ImageDecodeRequest") + .build()!!.toString() + } +} + + +internal class ImageDecoderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val req = chain.request() + val url = req.url().toString() + return if (url.contains("ImageDecodeRequest")) { + try { + val reqUrl = HttpUrl.parse(url)!! + + val viewCnt = reqUrl.queryParameter("cnt")!! + val version = reqUrl.queryParameter("ver")!! + val chapter = reqUrl.queryParameter("ch")!! + val imageUrl = url.split("?").first() + + val response = chain.proceed(GET(imageUrl)) + val res = response.body()!!.byteStream().use { + decodeImageRequest(version, chapter, viewCnt, it) + } + + val rb = ResponseBody.create(MediaType.parse("image/png"), res) + response.newBuilder().body(rb).build() + } catch (e: Exception) { + e.printStackTrace() + chain.proceed(req) + } + } else { + chain.proceed(req) + } + } + + /* + * `decodeV1ImageNative` is modified version of + * https://github.com/junheah/MangaViewAndroid/blob/master/app/src/main/java/ml/melun/mangaview/Downloader.java#L213-L245 + * + * MIT License + * + * Copyright (c) 2019 junheah + */ + private fun decodeV1ImageNative(input: Bitmap, chapter: Int, view_cnt: Int, half: Int = 0, CX: Int = MangaShowMe.V1_CX, CY: Int = MangaShowMe.V1_CY): Bitmap { + if (view_cnt == 0) return input + val viewCnt = view_cnt / 10 + + //decode image + val order = Array(CX * CY) { IntArray(2) } + val oSize = order.size - 1 + + for (i in 0..oSize) { + order[i][0] = i + order[i][1] = decoderRandom(chapter, viewCnt, i) + } + + java.util.Arrays.sort(order) { a, b -> java.lang.Double.compare(a[1].toDouble(), b[1].toDouble()) } + + //create new bitmap + val outputWidth = if (half == 0) input.width else input.width / 2 + val output = Bitmap.createBitmap(outputWidth, input.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + + val rowWidth = input.width / CX + val rowHeight = input.height / CY + + for (i in 0..oSize) { + val o = order[i] + val ox = i % CX + val oy = i / CX + val tx = o[0] % CX + val ty = o[0] / CX + val sx = if (half == 2) -input.width / 2 else 0 + + val srcX = ox * rowWidth + val srcY = oy * rowHeight + val destX = (tx * rowWidth) + sx + val destY = ty * rowHeight + + canvas.drawBitmap(input, + Rect(srcX, srcY, srcX + rowWidth, srcY + rowHeight), + Rect(destX, destY, destX + rowWidth, destY + rowHeight), + null) + } + + return output + } + + /* + * `decodeRandom` is modified version of + * https://github.com/junheah/MangaViewAndroid/blob/master/app/src/main/java/ml/melun/mangaview/Downloader.java#L213-L245 + * + * MIT License + * + * Copyright (c) 2019 junheah + */ + private fun decoderRandom(chapter: Int, view_cnt: Int, index: Int): Int { + if (chapter < 554714) { + val x = 10000 * Math.sin((view_cnt + index).toDouble()) + return Math.floor(100000 * (x - Math.floor(x))).toInt() + } + + val seed = view_cnt + index + 1 + val t = 100 * Math.sin((10 * seed).toDouble()) + val n = 1000 * Math.cos((13 * seed).toDouble()) + val a = 10000 * Math.tan((14 * seed).toDouble()) + + return (Math.floor(100 * (t - Math.floor(t))) + + Math.floor(1000 * (n - Math.floor(n))) + + Math.floor(10000 * (a - Math.floor(a)))).toInt() + } + + private fun decodeImageRequest(version: String, chapter: String, view_cnt: String, img: InputStream): ByteArray { + return when (version) { + "v1" -> decodeV1Image(chapter, view_cnt, img) + else -> img.readBytes() + } + } + + private fun decodeV1Image(chapter: String, view_cnt: String, img: InputStream): ByteArray { + val decoded = BitmapFactory.decodeStream(img) + val result = decodeV1ImageNative(decoded, chapter.toInt(), view_cnt.toInt()) + + val output = ByteArrayOutputStream() + result.compress(Bitmap.CompressFormat.PNG, 100, output) + return output.toByteArray() + } +} + +private fun substringBetween(target: String, prefix: String, suffix: String): String = { + target.substringAfter(prefix).substringBefore(suffix) +}() \ No newline at end of file diff --git a/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MangaShowMe.kt b/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MangaShowMe.kt new file mode 100644 index 000000000..565f4655a --- /dev/null +++ b/src/ko/mangashowme/src/eu/kanade/tachiyomi/extension/ko/mangashowme/MangaShowMe.kt @@ -0,0 +1,238 @@ +package eu.kanade.tachiyomi.extension.ko.mangashowme + +import android.annotation.SuppressLint +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONArray +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * MangaShow.Me Source + * + * PS. There's no Popular section. It's just a list of manga. Also not latest updates. + * `manga_list` returns latest 'added' manga. not a chapter updates. + **/ +class MangaShowMe : ParsedHttpSource() { + override val name = "MangaShow.Me" + override val baseUrl = "https://mangashow.me" + override val lang: String = "ko" + + // Latest updates currently returns duplicate manga as it separates manga into chapters + override val supportsLatest = false + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(ImageDecoderInterceptor()) + .addInterceptor { chain -> + val response = chain.proceed(chain.request()) + if (response.code() == 503) { + val body = response.body().toString() + if (body.contains("console.log(\"503\")") || body.contains("console.log('503')")) + throw Exception("Try again.\nServer returns 503 Service Unavailable.") + } + response + } + .build()!! + + //override fun popularMangaSelector() = "div.basic-post-gallery > div > div.post-row" + override fun popularMangaSelector() = "div.manga-list-gallery > div > div.post-row" + + override fun popularMangaFromElement(element: Element): SManga { + val linkElement = element.select("a") + val titleElement = element.select(".manga-subject > a").first() + + val manga = SManga.create() + manga.url = urlTitleEscape(linkElement.attr("href")) + manga.title = titleElement.text() + manga.thumbnail_url = urlFinder(element.select(".img-wrap-back").attr("style")) + return manga + } + + override fun popularMangaNextPageSelector() = "ul.pagination > li:not(.disabled)" + + // Do not add page parameter if page is 1 to prevent tracking. + override fun popularMangaRequest(page: Int) = GET("$baseUrl/bbs/page.php?hid=manga_list" + + if (page > 1) "&page=${page - 1}" else "") + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(popularMangaSelector()).map { element -> + popularMangaFromElement(element) + } + + val hasNextPage = try { + !document.select(popularMangaNextPageSelector()).last().hasClass("active") + } catch (_: Exception) { + false + } + + return MangasPage(mangas, hasNextPage) + } + + override fun searchMangaSelector() = popularMangaSelector() + override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) + override fun searchMangaNextPageSelector() = popularMangaSelector() + override fun searchMangaParse(response: Response) = popularMangaParse(response) + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = searchComplexFilterMangaRequestBuilder(baseUrl, page, query, filters) + + + override fun mangaDetailsParse(document: Document): SManga { + val info = document.select("div.left-info").first() + val thumbnailElement = info.select("div.manga-thumbnail").first() + val publishTypeText = thumbnailElement.select("a.publish_type").text() ?: "" + val authorText = thumbnailElement.select("a.author").text() ?: "" + val mangaLike = info.select("div.recommend > i.fa").first().text() ?: "0" + val mangaChaptersLike = mangaElementsSum(document.select("div.addedAt i.fa.fa-thumbs-up > span")) + val mangaComments = mangaElementsSum(document.select("div.addedAt i.fa.fa-comment > span")) + val genres = mutableListOf() + document.select("div.left-info > .manga-tags > a.tag").forEach { + genres.add(it.text()) + } + + val manga = SManga.create() + manga.title = info.select("div.red").text() + // They using background-image style tag for cover. extract url from style attribute. + manga.thumbnail_url = urlFinder(thumbnailElement.attr("style")) + // Only title and thumbnail are provided now. + // TODO: Implement description when site supports it. + manga.description = "\nMangaShow.Me doesn't provide manga description currently.\n" + + "\n\uD83D\uDCDD: ${if (publishTypeText.trim().isBlank()) "Unknown" else publishTypeText}" + + "\n\uD83D\uDCAC: $mangaComments" + + "\n👍: $mangaLike ($mangaChaptersLike)" + manga.author = authorText + manga.genre = genres.joinToString(", ") + manga.status = parseStatus(publishTypeText) + return manga + } + + private fun parseStatus(status: String) = when (status.trim()) { + "주간", "격주", "월간", "격월/비정기", "단행본" -> SManga.ONGOING + "단편", "완결" -> SManga.COMPLETED + // "미분류", "" -> SManga.UNKNOWN + else -> SManga.UNKNOWN + } + + private fun mangaElementsSum(element: Elements?): String { + if (element.isNullOrEmpty()) return "0" + return try { + String.format("%,d", element.map { + it.text().toInt() + }.sum()) + } catch (_: Exception) { + "0" + } + } + + override fun chapterListSelector() = "div.manga-detail-list > div.chapter-list > .slot" + + override fun chapterFromElement(element: Element): SChapter { + val linkElement = element.select("a") + val rawName = linkElement.select("div.title").last() + + val chapter = SChapter.create() + chapter.url = linkElement.attr("href") + chapter.chapter_number = parseChapterNumber(rawName.text()) + chapter.name = rawName.ownText().trim() + chapter.date_upload = parseChapterDate(element.select("div.addedAt").text().split(" ").first()) + return chapter + } + + private fun parseChapterNumber(name: String): Float { + try { + if (name.contains("[단편]")) return 1f + // `특별` means `Special`, so It can be buggy. so pad `편`(Chapter) to prevent false return + if (name.contains("번외") || name.contains("특별편")) return -2f + val regex = Regex("([0-9]+)(?:[-.]([0-9]+))?(?:화)") + val (ch_primal, ch_second) = regex.find(name)!!.destructured + return (ch_primal + if (ch_second.isBlank()) "" else ".$ch_second").toFloatOrNull() ?: -1f + } catch (e: Exception) { + e.printStackTrace() + return -1f + } + } + + @SuppressLint("SimpleDateFormat") + private fun parseChapterDate(date: String): Long { + val calendar = Calendar.getInstance() + + // MangaShow.Me doesn't provide uploaded year now(18/12/15). + // If received month is bigger then current month, set last year. + // TODO: Fix years due to lack of info. + return try { + val month = date.trim().split('-').first().toInt() + val currYear = calendar.get(Calendar.YEAR) + val year = if (month > calendar.get(Calendar.MONTH) + 1) // Before December now, // and Retrieved month is December == 2018. + currYear - 1 else currYear + SimpleDateFormat("yyyy-MM-dd").parse("$year-$date").time + } catch (e: Exception) { + e.printStackTrace() + 0 + } + } + + + // They are using full url in every links. + // There's possibility to using another domain for serve manga(s). Like marumaru. + override fun pageListRequest(chapter: SChapter) = GET(chapter.url, headers) + + override fun pageListParse(document: Document): List { + val pages = mutableListOf() + + try { + val element = document.select("div.col-md-9.at-col.at-main script") + val imageUrl = element.html().substringAfter("var img_list = [").substringBefore("];") + val imageUrls = JSONArray("[$imageUrl]") + val decoder = ImageDecoder("v1", element.html()) + + (0 until imageUrls.length()) + .map { imageUrls.getString(it) } + .forEach { pages.add(Page(pages.size, "", decoder.request(it))) } + } catch (e: Exception) { + e.printStackTrace() + } + + return pages + } + + + // Latest not supported + override fun latestUpdatesSelector() = throw UnsupportedOperationException("This method should not be called!") + override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException("This method should not be called!") + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException("This method should not be called!") + override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException("This method should not be called!") + + + //We are able to get the image URL directly from the page list + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("This method should not be called!") + + private fun urlFinder(style: String): String { + // val regex = Regex("(https?:)?//[-a-zA-Z0-9@:%._\\\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\\\+.~#?&/=]*)") + // return regex.find(style)!!.value + return style.substringAfter("background-image:url(").substringBefore(")") + } + + // Some title contains `&` and `#` which can cause a error. + private fun urlTitleEscape(title: String): String { + val url = title.split("&manga_name=") + return "${url[0]}&manga_name=" + + url[1].replace("&", "%26").replace("#", "%23") + } + + override fun getFilterList() = getFilters() + + companion object { + internal const val V1_CX = 5 + internal const val V1_CY = 5 + } +} \ No newline at end of file