mirror of
https://github.com/keiyoushi/extensions-source.git
synced 2024-11-25 03:33:24 +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