diff --git a/src/ja/nicovideoseiga/AndroidManifest.xml b/src/ja/nicovideoseiga/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/ja/nicovideoseiga/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/ja/nicovideoseiga/build.gradle b/src/ja/nicovideoseiga/build.gradle new file mode 100644 index 000000000..0fe6c56ff --- /dev/null +++ b/src/ja/nicovideoseiga/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Nicovideo Seiga' + pkgNameSuffix = 'ja.nicovideoseiga' + extClass = '.NicovideoSeiga' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ja/nicovideoseiga/res/mipmap-hdpi/ic_launcher.png b/src/ja/nicovideoseiga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ba9fdae13 Binary files /dev/null and b/src/ja/nicovideoseiga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ja/nicovideoseiga/res/mipmap-mdpi/ic_launcher.png b/src/ja/nicovideoseiga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..0ae6d2715 Binary files /dev/null and b/src/ja/nicovideoseiga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ja/nicovideoseiga/res/mipmap-xhdpi/ic_launcher.png b/src/ja/nicovideoseiga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e6b807d5b Binary files /dev/null and b/src/ja/nicovideoseiga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ja/nicovideoseiga/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/nicovideoseiga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..00422f84d Binary files /dev/null and b/src/ja/nicovideoseiga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ja/nicovideoseiga/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/nicovideoseiga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..d3bfb9790 Binary files /dev/null and b/src/ja/nicovideoseiga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ja/nicovideoseiga/res/web_hi_res_512.png b/src/ja/nicovideoseiga/res/web_hi_res_512.png new file mode 100644 index 000000000..440ff4e31 Binary files /dev/null and b/src/ja/nicovideoseiga/res/web_hi_res_512.png differ diff --git a/src/ja/nicovideoseiga/src/eu/kanade/tachiyomi/extension/ja/nicovideoseiga/NicovideoSeiga.kt b/src/ja/nicovideoseiga/src/eu/kanade/tachiyomi/extension/ja/nicovideoseiga/NicovideoSeiga.kt new file mode 100644 index 000000000..35fc144b8 --- /dev/null +++ b/src/ja/nicovideoseiga/src/eu/kanade/tachiyomi/extension/ja/nicovideoseiga/NicovideoSeiga.kt @@ -0,0 +1,286 @@ +package eu.kanade.tachiyomi.extension.ja.nicovideoseiga + +import android.app.Application +import eu.kanade.tachiyomi.network.GET +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.HttpSource +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.jsoup.Jsoup +import uy.kohesive.injekt.injectLazy +import kotlin.experimental.xor + +class NicovideoSeiga : HttpSource() { + // Nicovideo Seiga contains illustrations, manga and books from Bookwalker. This extension will focus on manga only. + override val baseUrl: String = "https://seiga.nicovideo.jp" + override val lang: String = "ja" + override val name: String = "Nicovideo Seiga" + override val supportsLatest: Boolean = true + override val client: OkHttpClient = network.client.newBuilder() + .addInterceptor(::imageIntercept) + .build() + private val application: Application by injectLazy() + + override fun latestUpdatesParse(response: Response): MangasPage { + val currentPage = response.request.url.queryParameter("page")!!.toInt() + val doc = Jsoup.parse(response.body!!.string()) + val mangaCount = doc.select("#main_title > h2 > span").text().trim().dropLast(1).toInt() + val mangaPerPage = 20 + val mangaList = doc.select("#comic_list > ul > li") + val mangas = ArrayList() + for (manga in mangaList) { + val mangaElement = manga.select("div > .description > div > div") + mangas.add( + SManga.create().apply { + setUrlWithoutDomain( + baseUrl + mangaElement.select(".comic_icon > div > a").attr("href") + ) + title = mangaElement.select(".mg_body > .title > a").text() + // While the site does label who are the author and artists are, there is no formatting standard at all! + // It becomes impossible to parse the names and their specific roles + // So we are not going to process this at all + author = mangaElement.select(".mg_description_header > .mg_author > a").text() + // Nicovideo doesn't provide large thumbnails in their searches and manga listings unfortunately + // A larger thumbnail is only available after going into the details page + thumbnail_url = mangaElement.select(".comic_icon > div > a > img").attr("src") + val statusText = + mangaElement.select(".mg_description_header > .mg_icon > .content_status > span") + .text() + status = when (statusText) { + "連載" -> { + SManga.ONGOING + } + "完結" -> { + SManga.COMPLETED + } + else -> { + SManga.UNKNOWN + } + } + } + ) + } + return MangasPage(mangas, mangaCount - mangaPerPage * currentPage > 0) + } + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/manga/list?page=$page&sort=manga_updated") + + override fun popularMangaParse(response: Response): MangasPage = latestUpdatesParse(response) + + override fun popularMangaRequest(page: Int): Request = + GET("$baseUrl/manga/list?page=$page&sort=manga_view") + + override fun searchMangaParse(response: Response): MangasPage { + val currentPage = response.request.url.queryParameter("page")!!.toInt() + val doc = Jsoup.parse(response.body!!.string()) + val mangaCount = + doc.select("#mg_wrapper > div > div.header > div.header__result-summary").text().trim() + .split(":")[1].toInt() + val mangaPerPage = 20 + val mangaList = doc.select(".search_result__item") + val mangas = ArrayList() + for (manga in mangaList) { + mangas.add( + SManga.create().apply { + setUrlWithoutDomain( + baseUrl + manga.select(".search_result__item__thumbnail > a") + .attr("href") + ) + title = + manga.select(".search_result__item__info > .search_result__item__info--title > a") + .text().trim() + // While the site does label who the author and artists are, there is no formatting standard at all! + // It becomes impossible to parse the names and their specific roles + // So we are not going to process this at all + author = + manga.select(".search_result__item__info > .search_result__item__info--author") + .text() + // Nicovideo doesn't provide large thumbnails in their searches and manga listings unfortunately + // A larger thumbnail/cover art is only available after going into the chapter listings + thumbnail_url = manga.select(".search_result__item__thumbnail > a > img") + .attr("data-original") + } + ) + } + return MangasPage(mangas, mangaCount - mangaPerPage * currentPage > 0) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + GET("$baseUrl/manga/search/?q=$query&page=$page&sort=score") + + override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply { + val doc = Jsoup.parse(response.body!!.string()) + // The description is a mix of synopsis and news announcements + // This is just how mangakas use this site + description = + doc.select("#contents > div.mg_work_detail > div > div.row > div.description_text") + .text() + // A better larger cover art is available here + thumbnail_url = + doc.select("#contents > div.primaries > div.main_visual > a > img").attr("src") + val statusText = + doc.select("#contents > div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span") + .text() + status = when (statusText) { + "連載" -> { + SManga.ONGOING + } + "完結" -> { + SManga.COMPLETED + } + else -> { + SManga.UNKNOWN + } + } + } + + override fun chapterListParse(response: Response): List { + val doc = Jsoup.parse(response.body!!.string()) + val chapters = ArrayList() + val chapterList = doc.select("#episode_list > ul > li") + val mangaId = response.request.url.toUrl().toString().substringAfterLast('/').substringBefore('?') + val sharedPref = application.getSharedPreferences("source_${id}_time_found:$mangaId", 0) + val editor = sharedPref.edit() + // After logging in, any chapters bought should show up as well + // Users will need to refresh their chapter list after logging in + for (chapter in chapterList) { + chapters.add( + SChapter.create().apply { + // Unfortunately we cannot filter out promotional materials in the chapter list, + // nor we can determine the chapter number from the title + // That would require understanding the context of the title (See One Punch Man and Uzaki-chan for example) + // Unless we have a machine learning algorithm in place, it's simply not possible + name = chapter.select("div > div.description > div.title > a").text() + setUrlWithoutDomain( + baseUrl + chapter.select("div > div.description > div.title > a") + .attr("href") + ) + // The data-number attribute is the only way we can determine chapter orders, + // without that this extension would have been impossible to make + // Note: Promotional materials also count as "chapters" here, so auto tracking unfortunately does not work at all + chapter_number = chapter.select("div").attr("data-number").toFloat() + // We can't determine the upload date from the website + // Store date_upload when a chapter is found for the first time + val dateFound = System.currentTimeMillis() + if (!sharedPref.contains(chapter_number.toString())) { + editor.putLong(chapter_number.toString(), dateFound) + } + date_upload = sharedPref.getLong(chapter_number.toString(), dateFound) + } + ) + } + editor.apply() + chapters.sortByDescending { chapter -> chapter.chapter_number } + return chapters + } + + override fun pageListParse(response: Response): List { + val doc = Jsoup.parse(response.body!!.string()) + val pages = ArrayList() + // Nicovideo will refuse to serve any pages if the user has not logged in + if (!doc.select("#login_manga").isEmpty()) + throw SecurityException("Not logged in. Please login via WebView first") + val pageList = doc.select("#page_contents > li") + for (page in pageList) { + val pageNumber = page.attr("data-page-index").toInt() + val url = page.select("div > img").attr("data-original") + pages.add(Page(pageNumber, url, url)) + } + return pages + } + + override fun imageRequest(page: Page): Request { + // Headers are required to avoid cache miss from server side + val headers = headersBuilder() + .set("referer", "https://seiga.nicovideo.jp/") + .set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + .set("pragma", "no-cache") + .set("cache-control", "no-cache") + .set("accept-encoding", "gzip, deflate, br") + .set( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36" + ) + .set("sec-fetch-dest", "image") + .set("sec-fetch-mode", "no-cors") + .set("sec-fetch-site", "cross-site") + .set("sec-gpc", "1") + .build() + return GET(page.imageUrl!!, headers) + } + + override fun imageUrlParse(response: Response): String = + throw UnsupportedOperationException("Not used") + + private fun imageIntercept(chain: Interceptor.Chain): Response { + // Intercept requests for paid manga images only + // Manga images come from 2 sources + // drm.cdn.nicomanga.jp -> Paid manga (Encrypted) + // deliver.cdn.nicomanga.jp -> Free manga (Unencrypted) + val imageRegex = + Regex("https://drm.cdn.nicomanga.jp/image/([a-f0-9]+)_\\d{4}/\\d+p(\\.[a-z]+)?(\\?\\d+)?") + val match = imageRegex.find(chain.request().url.toUrl().toString()) + ?: return chain.proceed(chain.request()) + + // Decrypt the image + val key = match.destructured.component1() + val response = chain.proceed(chain.request()) + val encryptedImage = response.body!!.bytes() + val decryptedImage = decryptImage(key, encryptedImage) + + // Construct a new response + val contentType = response.header("Content-Type", "image/${getImageType(decryptedImage)}") + val body = decryptedImage.toResponseBody(contentType!!.toMediaTypeOrNull()) + return response.newBuilder().body(body).build() + } + + /** + * Paid images are xor encrypted in Nicovideo. + * The image url is displayed in the document in noscript environment + * It will look like the following: + * https://drm.cdn.nicomanga.jp/image/d952d4bc53ddcaafffb42d628239ebed4f66df0f_9477/12057916p.webp?1636382474 + * ^^^^^^^^^^^^^^^^ + * The encryption key is stored directly on the URL. Up there. Yes, it stops right there + * The key is then split into 8 separate bytes + * Then it cycles through each mini-key and xor with the encrypted image byte by byte + * key: d9 52 d4 ... af d9 52 ... + * xor + * e: ab cd ef ... 12 34 56 ... + * The result image is then base64 encoded loaded into the page using the data URI scheme + * There are additional checks to determine the image type, defaults to webp + */ + private fun decryptImage(key: String, image: ByteArray): ByteArray { + val keySet = IntArray(8) + for (i in 0..7) + keySet[i] = key.substring(2 * i).take(2).toInt(16) + for (i in image.indices) + image[i] = image[i] xor keySet[i % 8].toByte() + return image + } + + /** + * Determine the image type by looking at specific bytes for magic numbers + * This is also how Nicovideo does it + */ + private fun getImageType(image: ByteArray): String { + return if (image[0].toInt() == 0xff && image[1].toInt() == 0xd8 && image[image.size - 2].toInt() == 0xff && image[image.size - 1].toInt() == 0xd9) { + "jpeg" + } else if (image[0].toInt() == 0x89 && image[1].toInt() == 0x50 && image[2].toInt() == 0x4e && image[3].toInt() == 0x47) { + "png" + } else if (image[0].toInt() == 0x47 && image[1].toInt() == 0x49 && image[2].toInt() == 0x46 && image[3].toInt() == 0x38) { + "gif" + } else { + // It defaults to null in the site, but it's a webp image + "webp" + } + } +}