mirror of
https://github.com/keiyoushi/extensions-source.git
synced 2024-11-22 02:12:42 +01:00
Add Manga Toshokan Z (#3346)
* working basic function without known problem for now * add filter and some changes * add logo * Apply suggestions from code review Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * fix bugs for manga id published by registered user and change search manga request to use api with page option instead --------- Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
parent
85393c74c4
commit
9f524ee265
11
src/ja/mangatoshokanz/build.gradle
Normal file
11
src/ja/mangatoshokanz/build.gradle
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'Manga Toshokan Z'
|
||||||
|
extClass = '.MangaToshokanZ'
|
||||||
|
extVersionCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(':lib:cryptoaes'))
|
||||||
|
}
|
BIN
src/ja/mangatoshokanz/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/ja/mangatoshokanz/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
BIN
src/ja/mangatoshokanz/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/ja/mangatoshokanz/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
src/ja/mangatoshokanz/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/ja/mangatoshokanz/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
src/ja/mangatoshokanz/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/ja/mangatoshokanz/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
BIN
src/ja/mangatoshokanz/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/ja/mangatoshokanz/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
@ -0,0 +1,76 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.ja.mangatoshokanz
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.security.KeyPair
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.spec.RSAKeyGenParameterSpec
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
internal fun getKeys(): KeyPair {
|
||||||
|
return KeyPairGenerator.getInstance("RSA").run {
|
||||||
|
initialize(RSAKeyGenParameterSpec(512, RSAKeyGenParameterSpec.F4))
|
||||||
|
generateKeyPair()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PublicKey.toPem(): String {
|
||||||
|
val base64Encoded = Base64.encodeToString(encoded, Base64.DEFAULT)
|
||||||
|
|
||||||
|
return StringBuilder("-----BEGIN PUBLIC KEY-----")
|
||||||
|
.appendLine()
|
||||||
|
.append(base64Encoded)
|
||||||
|
.append("-----END PUBLIC KEY-----")
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Response.decryptPages(privateKey: PrivateKey): Decrypted {
|
||||||
|
val encrypted = json.decodeFromString<Encrypted>(body.string())
|
||||||
|
|
||||||
|
val biDecoded = Base64.decode(encrypted.bi, Base64.DEFAULT)
|
||||||
|
val ekDecoded = Base64.decode(encrypted.ek, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val ekDecrypted = Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
|
||||||
|
init(Cipher.DECRYPT_MODE, privateKey)
|
||||||
|
doFinal(ekDecoded)
|
||||||
|
}
|
||||||
|
val dataDecrypted = CryptoAES.decrypt(encrypted.data, ekDecrypted, biDecoded)
|
||||||
|
|
||||||
|
return json.decodeFromString<Decrypted>(dataDecrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private class Encrypted(
|
||||||
|
val bi: String,
|
||||||
|
val ek: String,
|
||||||
|
val data: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class Decrypted(
|
||||||
|
@SerialName("Images")
|
||||||
|
val images: List<Image>,
|
||||||
|
@SerialName("Location")
|
||||||
|
val location: Location,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
internal class Image(
|
||||||
|
val file: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class Location(
|
||||||
|
val base: String,
|
||||||
|
val st: String,
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,338 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.ja.mangatoshokanz
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
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.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.lang.StringBuilder
|
||||||
|
import java.security.KeyPair
|
||||||
|
|
||||||
|
class MangaToshokanZ : HttpSource() {
|
||||||
|
override val lang = "ja"
|
||||||
|
override val supportsLatest = true
|
||||||
|
override val name = "マンガ図書館Z"
|
||||||
|
override val baseUrl = "https://www.mangaz.com"
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.addNetworkInterceptor(::r18Interceptor)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
// author/illustrator name might just show blank if language not set to japan
|
||||||
|
.add("cookie", "_LANG_=ja")
|
||||||
|
|
||||||
|
private val keys: KeyPair by lazy {
|
||||||
|
getKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _serial by lazy {
|
||||||
|
getSerial()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isR18 = false
|
||||||
|
|
||||||
|
private fun r18Interceptor(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
|
||||||
|
// open access to R18 section
|
||||||
|
if (request.url.host == "r18.mangaz.com" && isR18.not()) {
|
||||||
|
val url = "https://r18.mangaz.com/attention/r18/yes"
|
||||||
|
|
||||||
|
val r18Request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.head()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
isR18 = true
|
||||||
|
client.newCall(r18Request).execute().close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/ranking/views", headers)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val mangas = response.toMangas(".itemList")
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
val header = headers.newBuilder()
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("title/addpage_renewal")
|
||||||
|
.addQueryParameter("type", "official")
|
||||||
|
.addQueryParameter("sort", "new")
|
||||||
|
.addQueryParameter("page", page.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, header)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val mangas = response.toMangas("body")
|
||||||
|
return MangasPage(mangas, mangas.size == 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val header = headers.newBuilder()
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("title/addpage_renewal")
|
||||||
|
.addQueryParameter("query", query)
|
||||||
|
.addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is Category -> {
|
||||||
|
if (filter.state != 0) {
|
||||||
|
url.addQueryParameter("category", categories[filter.state].lowercase())
|
||||||
|
}
|
||||||
|
if (filter.state == 5) {
|
||||||
|
url.host("r18.mangaz.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Sort -> {
|
||||||
|
url.addQueryParameter("sort", sortBy[filter.state].lowercase())
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url.build(), header)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
|
||||||
|
|
||||||
|
private fun Response.toMangas(selector: String): List<SManga> {
|
||||||
|
return asJsoup().selectFirst(selector)!!.children().filter { child ->
|
||||||
|
child.`is`("li")
|
||||||
|
}.filterNot { li ->
|
||||||
|
// discard manga that in the middle of asking for license progress, it can't be read
|
||||||
|
li.selectFirst(".iconConsent") != null
|
||||||
|
}.map { li ->
|
||||||
|
SManga.create().apply {
|
||||||
|
val a = li.selectFirst("h4 > a")!!
|
||||||
|
url = a.attr("href").substringAfterLast("/")
|
||||||
|
title = a.text()
|
||||||
|
|
||||||
|
thumbnail_url = li.selectFirst("a > img")!!.attr("data-src").ifBlank {
|
||||||
|
li.selectFirst("a > img")!!.attr("src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(Category(), Sort())
|
||||||
|
|
||||||
|
private class Category : Filter.Select<String>("Category", categories)
|
||||||
|
|
||||||
|
private class Sort : Filter.Select<String>("Sort", sortBy)
|
||||||
|
|
||||||
|
// in this manga details section we use book/detail/id since it have tags over series/detail/id
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
// normally manga published by the website has the same id in it's series and book
|
||||||
|
// example: https://www.mangaz.com/series/detail/202371 (series)
|
||||||
|
// https://www.mangaz.com/book/detail/202371 (book)
|
||||||
|
// strangely manga published by registered user has different id in it's series and book
|
||||||
|
// example: https://www.mangaz.com/series/detail/224931 (series)
|
||||||
|
// https://www.mangaz.com/book/detail/224932 (book)
|
||||||
|
|
||||||
|
// so in here we want the id from the manga thumbnail url since it contain the book id
|
||||||
|
// instead of manga url that contain series id which used for the chapter section later
|
||||||
|
// example: https://www.mangaz.com/series/detail/224931 (manga url)
|
||||||
|
// https://books.j-comi.jp/Books/224/224932/thumb160_1713230205.jpg (thumbnail url)
|
||||||
|
val bookId = manga.thumbnail_url!!.substringBeforeLast("/").substringAfterLast("/")
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("book/detail")
|
||||||
|
.addPathSegment(bookId)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
document.select(".detailAuthor > li").forEach { li ->
|
||||||
|
when {
|
||||||
|
li.ownText().contains("者") || li.ownText().contains("原作") -> {
|
||||||
|
if (author.isNullOrEmpty()) {
|
||||||
|
author = li.child(0).text()
|
||||||
|
} else {
|
||||||
|
author += ", ${li.child(0).text()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li.ownText().contains("作画") || li.ownText().contains("マンガ") -> {
|
||||||
|
if (artist.isNullOrEmpty()) {
|
||||||
|
artist = li.child(0).text()
|
||||||
|
} else {
|
||||||
|
artist += ", ${li.child(0).text()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
description = document.selectFirst(".wordbreak")?.text()
|
||||||
|
genre = document.select(".inductionTags a").joinToString { it.text() }
|
||||||
|
status = when {
|
||||||
|
document.selectFirst("p.iconContinues") != null -> SManga.ONGOING
|
||||||
|
document.selectFirst("p.iconEnd") != null -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want series/detail/id over book/detail/id in here since book/detail/id have problem
|
||||||
|
// where if the name of the chapter become too long the end become ellipsis (...)
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("series/detail")
|
||||||
|
.addPathSegment(manga.url)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
|
||||||
|
// if it's single chapter, it will be redirected back to book/detail/id
|
||||||
|
if (response.request.url.pathSegments.first() == "book") {
|
||||||
|
return listOf(
|
||||||
|
SChapter.create().apply {
|
||||||
|
name = document.selectFirst(".GA4_booktitle")!!.text()
|
||||||
|
url = document.baseUri().substringAfterLast("/")
|
||||||
|
chapter_number = 1f
|
||||||
|
date_upload = 0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it's multiple chapters
|
||||||
|
return document.select(".itemList li").reversed().mapIndexed { i, li ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
name = li.selectFirst(".title")!!.text()
|
||||||
|
url = li.selectFirst("a")!!.attr("href").substringAfterLast("/")
|
||||||
|
chapter_number = i.toFloat()
|
||||||
|
date_upload = 0
|
||||||
|
}
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val ticket = getTicket(chapter.url)
|
||||||
|
val pem = keys.public.toPem()
|
||||||
|
|
||||||
|
val url = virgoBuilder()
|
||||||
|
.addPathSegment("docx")
|
||||||
|
.addPathSegment(chapter.url.plus(".json"))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val header = headers.newBuilder()
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.add("Cookie", "virgo!__ticket=$ticket")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("__serial", _serial)
|
||||||
|
.add("__ticket", ticket)
|
||||||
|
.add("pub", pem)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return POST(url.toString(), header, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTicket(chapterId: String): String {
|
||||||
|
val ticketUrl = virgoBuilder()
|
||||||
|
.addPathSegments("view")
|
||||||
|
.addPathSegment(chapterId)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val ticketRequest = Request.Builder()
|
||||||
|
.url(ticketUrl)
|
||||||
|
.headers(headers)
|
||||||
|
.head()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.newCall(ticketRequest).execute().close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw Exception("Fail to retrieve ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.cookieJar.loadForRequest(ticketUrl).find { cookie ->
|
||||||
|
cookie.name == "virgo!__ticket"
|
||||||
|
}?.value ?: throw Exception("Fail to retrieve ticket from cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSerial(): String {
|
||||||
|
val url = virgoBuilder()
|
||||||
|
.addPathSegment("app.js")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = try {
|
||||||
|
client.newCall(GET(url, headers)).execute()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw Exception("Fail to retrieve serial")
|
||||||
|
}
|
||||||
|
|
||||||
|
val appJsString = response.body.string()
|
||||||
|
return appJsString.substringAfter("__serial = \"").substringBefore("\";")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun virgoBuilder(): HttpUrl.Builder {
|
||||||
|
return baseUrl.toHttpUrl().newBuilder()
|
||||||
|
.host("vw.mangaz.com")
|
||||||
|
.addPathSegment("virgo")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val decrypted = response.decryptPages(keys.private)
|
||||||
|
|
||||||
|
return decrypted.images.mapIndexed { i, image ->
|
||||||
|
val imageUrl = StringBuilder(decrypted.location.base)
|
||||||
|
.append(decrypted.location.st)
|
||||||
|
.append(image.file.substringBefore("."))
|
||||||
|
.append(".jpg")
|
||||||
|
|
||||||
|
Page(i, imageUrl = imageUrl.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val categories = arrayOf(
|
||||||
|
"All",
|
||||||
|
"Mens",
|
||||||
|
"Womens",
|
||||||
|
"TL",
|
||||||
|
"BL",
|
||||||
|
"R18",
|
||||||
|
)
|
||||||
|
private val sortBy = arrayOf(
|
||||||
|
"Popular",
|
||||||
|
"New",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user