Add ClipStudioReader (lib) (#11120)
* add clipstudioreader and drecomics * add firecross * webtoon DreComics and try find key in url * fixes * drecomics search and filters * firecross search/filters, csr epub viewer support * migrate to lib and xml parser * api * cleanup and dependency
@ -18,6 +18,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
|
||||
implementation(project(":core"))
|
||||
}
|
||||
|
||||
tasks.register("printDependentExtensions") {
|
||||
|
||||
3
lib/clipstudioreader/build.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.parser.Parser
|
||||
|
||||
abstract class ClipStudioReader : HttpSource() {
|
||||
override val client = super.client.newBuilder()
|
||||
.addInterceptor(Deobfuscator())
|
||||
.addInterceptor(ImageInterceptor())
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val requestUrl = response.request.url
|
||||
val contentId = requestUrl.queryParameter("c")
|
||||
|
||||
if (contentId != null) {
|
||||
// EPUB-based path
|
||||
val tokenUrl = "$baseUrl/api/tokens/viewer?content_id=$contentId".toHttpUrl()
|
||||
val tokenResponse = client.newCall(GET(tokenUrl, headers)).execute()
|
||||
val viewerToken = tokenResponse.parseAs<TokenResponse>().token
|
||||
|
||||
val metaUrl = "$baseUrl/api/contents/$contentId/meta".toHttpUrl()
|
||||
val apiHeaders = headersBuilder().add("Authorization", "Bearer $viewerToken").build()
|
||||
val metaResponse = client.newCall(GET(metaUrl, apiHeaders)).execute()
|
||||
val contentBaseUrl = metaResponse.parseAs<MetaResponse>().content.baseUrl
|
||||
|
||||
val preprocessUrl = "$contentBaseUrl/preprocess-settings.json"
|
||||
val obfuscationResponse = client.newCall(GET(preprocessUrl, headers)).execute()
|
||||
val obfuscationKey = obfuscationResponse.parseAs<PreprocessSettings>().obfuscateImageKey
|
||||
|
||||
val containerUrl = "$contentBaseUrl/META-INF/container.xml"
|
||||
val containerResponse = client.newCall(GET(containerUrl, headers)).execute()
|
||||
val containerDoc = Jsoup.parse(containerResponse.body.string(), containerUrl, Parser.xmlParser())
|
||||
val opfPath = containerDoc.selectFirst("*|rootfile")?.attr("full-path")
|
||||
?: throw Exception("Failed to find rootfile in container.xml")
|
||||
|
||||
val opfUrl = (contentBaseUrl.removeSuffix("/") + "/" + opfPath).toHttpUrl()
|
||||
val opfResponse = client.newCall(GET(opfUrl, headers)).execute()
|
||||
val opfDoc = opfResponse.asJsoup()
|
||||
|
||||
val imageManifestItems = opfDoc.select("*|item[media-type^=image/]")
|
||||
.sortedBy { it.attr("href") }
|
||||
if (imageManifestItems.isEmpty()) {
|
||||
throw Exception("No image pages found in the EPUB manifest")
|
||||
}
|
||||
|
||||
return imageManifestItems.mapIndexed { i, item ->
|
||||
val href = item.attr("href")
|
||||
?: throw Exception("Image item found with no href")
|
||||
val imageUrlBuilder = opfUrl.resolve(href)!!.newBuilder()
|
||||
obfuscationKey.let {
|
||||
imageUrlBuilder.addQueryParameter("obfuscateKey", it.toString())
|
||||
}
|
||||
Page(i, imageUrl = imageUrlBuilder.build().toString())
|
||||
}
|
||||
}
|
||||
|
||||
// param/cgi-based XML path
|
||||
// param/cgi in URL
|
||||
var authkey = requestUrl.queryParameter("param")?.replace(" ", "+")
|
||||
var endpoint = requestUrl.queryParameter("cgi")
|
||||
|
||||
// param/cgi in HTML
|
||||
if (authkey.isNullOrEmpty() || endpoint.isNullOrEmpty()) {
|
||||
val document = response.asJsoup()
|
||||
authkey = document.selectFirst("div#meta input[name=param]")?.attr("value")
|
||||
?: throw Exception("Could not find auth key")
|
||||
endpoint = document.selectFirst("div#meta input[name=cgi]")?.attr("value")
|
||||
?: throw Exception("Could not find endpoint")
|
||||
}
|
||||
|
||||
val viewerUrl = baseUrl.toHttpUrl().resolve(endpoint)
|
||||
?: throw Exception("Could not resolve endpoint URL: $endpoint")
|
||||
|
||||
val faceUrl = viewerUrl.newBuilder().apply {
|
||||
addQueryParameter("mode", MODE_DL_FACE_XML)
|
||||
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
|
||||
addQueryParameter("vm", "4")
|
||||
addQueryParameter("file", "face.xml")
|
||||
addQueryParameter("param", authkey)
|
||||
}.build()
|
||||
|
||||
val faceResponse = client.newCall(GET(faceUrl, headers)).execute()
|
||||
if (!faceResponse.isSuccessful) throw Exception("HTTP error ${faceResponse.code} while fetching face.xml")
|
||||
val faceData = faceResponse.use { parseFaceData(it.asJsoup()) }
|
||||
|
||||
return (0 until faceData.totalPages).map { i ->
|
||||
val pageFileName = i.toString().padStart(4, '0') + ".xml"
|
||||
val pageXmlUrl = viewerUrl.newBuilder().apply {
|
||||
addQueryParameter("mode", MODE_DL_PAGE_XML)
|
||||
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
|
||||
addQueryParameter("vm", "4")
|
||||
addQueryParameter("file", pageFileName)
|
||||
addQueryParameter("param", authkey)
|
||||
// Custom params
|
||||
addQueryParameter("csr_sw", faceData.scrambleWidth.toString())
|
||||
addQueryParameter("csr_sh", faceData.scrambleHeight.toString())
|
||||
}.build()
|
||||
Page(i, url = pageXmlUrl.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
val requestUrl = response.request.url
|
||||
val document = response.asJsoup()
|
||||
|
||||
val authkey = requestUrl.queryParameter("param")!!
|
||||
val scrambleGridW = requestUrl.queryParameter("csr_sw")!!
|
||||
val scrambleGridH = requestUrl.queryParameter("csr_sh")!!
|
||||
// Reconstruct endpoint without query params
|
||||
val endpointUrl = requestUrl.newBuilder().query(null).build()
|
||||
|
||||
val pageIndex = document.selectFirst("PageNo")?.text()?.toIntOrNull()
|
||||
?: throw Exception("Could not find PageNo")
|
||||
val scrambleArray = document.selectFirst("Scramble")?.text()
|
||||
val parts = document.select("Kind").mapNotNull {
|
||||
val type = it.text().toIntOrNull()
|
||||
val number = it.attr("No")
|
||||
val isScrambled = it.attr("scramble") == "1"
|
||||
if (type == null || number.isEmpty()) return@mapNotNull null
|
||||
|
||||
val partFileName = "${pageIndex.toString().padStart(4, '0')}_${number.padStart(4, '0')}.bin"
|
||||
PagePart(partFileName, type, isScrambled)
|
||||
}
|
||||
|
||||
val imagePart = parts.firstOrNull { it.type in SUPPORTED_IMAGE_TYPES }
|
||||
?: throw Exception("No supported image parts found for page")
|
||||
|
||||
val imageUrlBuilder = endpointUrl.newBuilder().apply {
|
||||
addQueryParameter("mode", imagePart.type.toString())
|
||||
addQueryParameter("file", imagePart.fileName)
|
||||
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
|
||||
addQueryParameter("param", authkey)
|
||||
}
|
||||
|
||||
if (imagePart.isScrambled && !scrambleArray.isNullOrEmpty()) {
|
||||
imageUrlBuilder.apply {
|
||||
addQueryParameter("scrambleArray", scrambleArray)
|
||||
addQueryParameter("scrambleGridW", scrambleGridW)
|
||||
addQueryParameter("scrambleGridH", scrambleGridH)
|
||||
}
|
||||
}
|
||||
return imageUrlBuilder.build().toString()
|
||||
}
|
||||
|
||||
private fun parseFaceData(document: Document): FaceData {
|
||||
val totalPages = document.selectFirst("TotalPage")?.text()?.toIntOrNull()
|
||||
val scrambleWidth = document.selectFirst("Scramble > Width")?.text()?.toIntOrNull()
|
||||
val scrambleHeight = document.selectFirst("Scramble > Height")?.text()?.toIntOrNull()
|
||||
return FaceData(totalPages!!, scrambleWidth!!, scrambleHeight!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MODE_DL_FACE_XML = "7"
|
||||
private const val MODE_DL_PAGE_XML = "8"
|
||||
private const val REQUEST_TYPE_FILE = "0"
|
||||
|
||||
private val SUPPORTED_IMAGE_TYPES = setOf(1, 2, 3) // JPEG, GIF, PNG
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
class Deobfuscator : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val keyStr = request.url.queryParameter("obfuscateKey")
|
||||
|
||||
if (keyStr.isNullOrEmpty()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val key = keyStr.toInt()
|
||||
val newUrl = request.url.newBuilder().removeAllQueryParameters("obfuscateKey").build()
|
||||
val newRequest = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(newRequest)
|
||||
if (!response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
val obfuscatedBytes = response.body.bytes()
|
||||
val deobfuscatedBytes = deobfuscate(obfuscatedBytes, key)
|
||||
val body = deobfuscatedBytes.toResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun deobfuscate(bytes: ByteArray, key: Int): ByteArray {
|
||||
val limit = minOf(bytes.size, 1024)
|
||||
for (i in 0 until limit) {
|
||||
bytes[i] = (bytes[i].toInt() xor key).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// XML
|
||||
class FaceData(
|
||||
val totalPages: Int,
|
||||
val scrambleWidth: Int,
|
||||
val scrambleHeight: Int,
|
||||
)
|
||||
|
||||
class PagePart(
|
||||
val fileName: String,
|
||||
val type: Int,
|
||||
val isScrambled: Boolean,
|
||||
)
|
||||
|
||||
// EPUB
|
||||
@Serializable
|
||||
class TokenResponse(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MetaResponse(
|
||||
val content: MetaContent,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MetaContent(
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PreprocessSettings(
|
||||
val obfuscateImageKey: Int,
|
||||
)
|
||||
@ -0,0 +1,81 @@
|
||||
package eu.kanade.tachiyomi.lib.clipstudioreader
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.floor
|
||||
|
||||
class ImageInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val url = request.url
|
||||
|
||||
val scrambleArray = url.queryParameter("scrambleArray")
|
||||
val scrambleGridW = url.queryParameter("scrambleGridW")?.toIntOrNull()
|
||||
val scrambleGridH = url.queryParameter("scrambleGridH")?.toIntOrNull()
|
||||
|
||||
if (scrambleArray.isNullOrEmpty() || scrambleGridW == null || scrambleGridH == null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val newUrl = url.newBuilder()
|
||||
.removeAllQueryParameters("scrambleArray")
|
||||
.removeAllQueryParameters("scrambleGridW")
|
||||
.removeAllQueryParameters("scrambleGridH")
|
||||
.build()
|
||||
val newRequest = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(newRequest)
|
||||
if (!response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
|
||||
val scrambleMapping = scrambleArray.split(',').map { it.toInt() }
|
||||
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
|
||||
val descrambledImg = unscrambleImage(scrambledImg, scrambleMapping, scrambleGridW, scrambleGridH)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
val body = output.toByteArray().toResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun unscrambleImage(
|
||||
image: Bitmap,
|
||||
scrambleMapping: List<Int>,
|
||||
gridWidth: Int,
|
||||
gridHeight: Int,
|
||||
): Bitmap {
|
||||
val descrambledImg = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(descrambledImg)
|
||||
|
||||
val pieceWidth = 8 * floor(floor(image.width.toFloat() / gridWidth) / 8).toInt()
|
||||
val pieceHeight = 8 * floor(floor(image.height.toFloat() / gridHeight) / 8).toInt()
|
||||
|
||||
if (scrambleMapping.size < gridWidth * gridHeight || image.width < 8 * gridWidth || image.height < 8 * gridHeight) {
|
||||
return image
|
||||
}
|
||||
|
||||
for (scrambleIndex in scrambleMapping.indices) {
|
||||
val destX = scrambleIndex % gridWidth * pieceWidth
|
||||
val destY = floor(scrambleIndex.toFloat() / gridWidth).toInt() * pieceHeight
|
||||
val destRect = Rect(destX, destY, destX + pieceWidth, destY + pieceHeight)
|
||||
|
||||
val sourcePieceIndex = scrambleMapping[scrambleIndex]
|
||||
val sourceX = sourcePieceIndex % gridWidth * pieceWidth
|
||||
val sourceY = floor(sourcePieceIndex.toFloat() / gridWidth).toInt() * pieceHeight
|
||||
val sourceRect = Rect(sourceX, sourceY, sourceX + pieceWidth, sourceY + pieceHeight)
|
||||
|
||||
canvas.drawBitmap(image, sourceRect, destRect, null)
|
||||
}
|
||||
return descrambledImg
|
||||
}
|
||||
}
|
||||
12
src/ja/drecomics/build.gradle
Normal file
@ -0,0 +1,12 @@
|
||||
ext {
|
||||
extName = "DRE Comics"
|
||||
extClass = ".DreComics"
|
||||
extVersionCode = 1
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:clipstudioreader"))
|
||||
}
|
||||
BIN
src/ja/drecomics/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/ja/drecomics/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/ja/drecomics/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
src/ja/drecomics/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/ja/drecomics/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,216 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.drecomics
|
||||
|
||||
import eu.kanade.tachiyomi.lib.clipstudioreader.ClipStudioReader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class DreComics : ClipStudioReader() {
|
||||
override val name = "DRE Comics"
|
||||
override val baseUrl = "https://drecom-media.jp"
|
||||
override val lang = "ja"
|
||||
override val supportsLatest = false
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy年MM月dd日", Locale.JAPAN)
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/drecomics/series", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select(".seriesList__item").map {
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(it.selectFirst("a")!!.absUrl("href"))
|
||||
title = it.selectFirst(".seriesList__text")!!.text()
|
||||
thumbnail_url = it.selectFirst("img")?.absUrl("src")
|
||||
}
|
||||
}
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("t", "2")
|
||||
addQueryParameter("page", page.toString())
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
addQueryParameter("q", query)
|
||||
}
|
||||
|
||||
var labelsAdded = false
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is LabelFilter -> filter.state.filter { it.state }.forEach {
|
||||
addQueryParameter("l[]", it.value)
|
||||
labelsAdded = true
|
||||
}
|
||||
|
||||
is GenreFilter -> filter.state.filter { it.state }.forEach {
|
||||
addQueryParameter("g[]", it.value)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelsAdded) {
|
||||
addQueryParameter("l[]", "3") // DRE Comics (Manga)
|
||||
addQueryParameter("l[]", "1") // DRE Studios (Webtoon)
|
||||
}
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("ul.seriesColumn__list li.seriesColumn__item").map {
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(it.selectFirst("a.seriesColumn__linkButton")!!.absUrl("href"))
|
||||
title = it.selectFirst("span.seriesColumn__title")!!.text()
|
||||
thumbnail_url = it.selectFirst("img.seriesColumn__img")?.absUrl("src")
|
||||
}
|
||||
}
|
||||
|
||||
val hasNextPage = document.selectFirst("a.pagination__link-next--active") != null
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
val isWebtoon = response.request.url.toString().contains("/drestudios")
|
||||
|
||||
return SManga.create().apply {
|
||||
if (isWebtoon) {
|
||||
title = document.selectFirst(".detailStudios_title span")!!.text()
|
||||
author = document.select(".detailStudios_author p").eachText().joinToString()
|
||||
description = document.selectFirst(".detailStudios_storySynopsis")?.text()
|
||||
genre = document.select(".seriesDetail__genreLink--studios").eachText().joinToString()
|
||||
thumbnail_url = document.selectFirst(".detailStudios_mainLeft img.img-fluid")?.absUrl("src")
|
||||
} else {
|
||||
title = document.selectFirst(".detailComics_title span")!!.text()
|
||||
author = document.select(".detailComics_authorsItem").eachText().joinToString()
|
||||
description = document.selectFirst(".detailComics_synopsis")?.text()
|
||||
genre = document.select(".detailComics_genreListItem").eachText().joinToString()
|
||||
thumbnail_url = document.selectFirst(".detailComicsSection .img-fluid")?.absUrl("src")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val isWebtoon = response.request.url.toString().contains("/drestudios")
|
||||
|
||||
if (isWebtoon) {
|
||||
return document.select(".detailStudios_ebookList_item").map {
|
||||
SChapter.create().apply {
|
||||
name = it.selectFirst(".detailStudios_ebookList_itemTitle")!!.text()
|
||||
setUrlWithoutDomain(it.absUrl("href"))
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
return document.select("div.ebookListItem:not(.disabled) a.ebookListItem_title").map {
|
||||
SChapter.create().apply {
|
||||
name = it.selectFirst(".ebookListItem_title")!!.text()
|
||||
setUrlWithoutDomain(it.selectFirst("a")!!.absUrl("href"))
|
||||
date_upload = dateFormat.tryParse(it.selectFirst(".ebookListItem_publishDate span")?.text()?.substringAfter("公開:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
return FilterList(
|
||||
LabelFilter(getLabelList()),
|
||||
GenreFilter(getGenreList()),
|
||||
)
|
||||
}
|
||||
|
||||
private class Label(name: String, val value: String) : Filter.CheckBox(name)
|
||||
private class LabelFilter(labels: List<Label>) : Filter.Group<Label>("Label", labels)
|
||||
|
||||
private class Genre(name: String, val value: String) : Filter.CheckBox(name)
|
||||
private class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Genre", genres)
|
||||
|
||||
private fun getLabelList(): List<Label> = listOf(
|
||||
Label("DREコミックス(コミック)", "3"),
|
||||
Label("DRE STUDIOS(webtoon)", "1"),
|
||||
)
|
||||
|
||||
private fun getGenreList(): List<Genre> = listOf(
|
||||
Genre("ファンタジー", "1"),
|
||||
Genre("バトル", "2"),
|
||||
Genre("恋愛", "3"),
|
||||
Genre("ラブコメ", "4"),
|
||||
Genre("SF", "5"),
|
||||
Genre("ミステリー", "6"),
|
||||
Genre("ホラー", "7"),
|
||||
Genre("シリアス", "34"),
|
||||
Genre("コメディ", "35"),
|
||||
Genre("業界", "57"),
|
||||
Genre("歴史", "53"),
|
||||
Genre("オカルト", "36"),
|
||||
Genre("アクション", "37"),
|
||||
Genre("もふもふ", "38"),
|
||||
Genre("サスペンス", "39"),
|
||||
Genre("異世界", "8"),
|
||||
Genre("異能", "9"),
|
||||
Genre("チート能力", "10"),
|
||||
Genre("タイムリープ", "40"),
|
||||
Genre("ゲーム世界", "12"),
|
||||
Genre("政治", "55"),
|
||||
Genre("経営", "56"),
|
||||
Genre("思想", "58"),
|
||||
Genre("ハーレム", "13"),
|
||||
Genre("聖女", "33"),
|
||||
Genre("追放", "15"),
|
||||
Genre("転生", "16"),
|
||||
Genre("転移", "17"),
|
||||
Genre("復讐", "18"),
|
||||
Genre("溺愛", "41"),
|
||||
Genre("悪役令嬢", "14"),
|
||||
Genre("王侯・貴族", "19"),
|
||||
Genre("婚約破棄", "42"),
|
||||
Genre("逆ハーレム", "43"),
|
||||
Genre("結婚", "44"),
|
||||
Genre("本格", "54"),
|
||||
Genre("日常", "21"),
|
||||
Genre("戦争", "45"),
|
||||
Genre("ドラマ", "30"),
|
||||
Genre("成り上がり", "47"),
|
||||
Genre("現代", "20"),
|
||||
Genre("学園", "22"),
|
||||
Genre("青春", "23"),
|
||||
Genre("お仕事", "24"),
|
||||
Genre("スローライフ", "25"),
|
||||
Genre("旅", "26"),
|
||||
Genre("子供", "27"),
|
||||
Genre("医療", "31"),
|
||||
Genre("グルメ", "28"),
|
||||
Genre("不良・ヤンキー", "29"),
|
||||
Genre("イケメン", "46"),
|
||||
Genre("メカ", "32"),
|
||||
Genre("魔王", "48"),
|
||||
Genre("人外", "49"),
|
||||
Genre("書き下ろし", "50"),
|
||||
Genre("エッセイ", "52"),
|
||||
Genre("コミカライズ", "51"),
|
||||
Genre("アニメ化", "62"),
|
||||
Genre("webtoon", "61"),
|
||||
Genre("受賞作", "63"),
|
||||
Genre("DREコミックス", "59"),
|
||||
Genre("DREコミックスF", "67"),
|
||||
Genre("完結", "65"),
|
||||
)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||
}
|
||||
12
src/ja/firecross/build.gradle
Normal file
@ -0,0 +1,12 @@
|
||||
ext {
|
||||
extName = "FireCross"
|
||||
extClass = ".FireCross"
|
||||
extVersionCode = 1
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:clipstudioreader"))
|
||||
}
|
||||
BIN
src/ja/firecross/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/ja/firecross/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src/ja/firecross/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/ja/firecross/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/ja/firecross/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
@ -0,0 +1,14 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.firecross
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class ChapterId(
|
||||
val token: String,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiResponse(
|
||||
val redirect: String,
|
||||
)
|
||||
@ -0,0 +1,175 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.firecross
|
||||
|
||||
import eu.kanade.tachiyomi.lib.clipstudioreader.ClipStudioReader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
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.util.asJsoup
|
||||
import keiyoushi.utils.firstInstance
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.toJsonString
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class FireCross : ClipStudioReader() {
|
||||
override val name = "FireCross"
|
||||
override val baseUrl = "https://firecross.jp"
|
||||
override val lang = "ja"
|
||||
override val supportsLatest = false
|
||||
|
||||
private val apiUrl = "$baseUrl/api"
|
||||
private val dateFormat = SimpleDateFormat("yyyy/M/d", Locale.JAPAN)
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/ebook/comics?sort=1&page=$page", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("ul.seriesList li.seriesList_item").map { element ->
|
||||
SManga.create().apply {
|
||||
element.selectFirst("a.seriesList_itemTitle")!!.let { a ->
|
||||
setUrlWithoutDomain(a.absUrl("href"))
|
||||
title = a.text()
|
||||
}
|
||||
thumbnail_url = element.selectFirst("img.series-list-img")?.absUrl("src")
|
||||
}
|
||||
}
|
||||
val hasNextPage = document.selectFirst("a.pagination-btn--next") != null
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("q", query)
|
||||
addQueryParameter("t", "1")
|
||||
addQueryParameter("distribution_episode", "1")
|
||||
addQueryParameter("page", page.toString())
|
||||
|
||||
filters.firstInstance<LabelFilter>().state.forEach { label ->
|
||||
if (label.state) {
|
||||
addQueryParameter("label[]", label.value)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
val mangas = document.select("ul.seriesList#search-result li.seriesList_item").map { element ->
|
||||
SManga.create().apply {
|
||||
title = element.selectFirst("a.seriesList_itemTitle")!!.text()
|
||||
thumbnail_url = element.selectFirst("img.series-list-img")?.absUrl("src")
|
||||
val webReadLink = element.select("a.btn-search-result").find { it.text() == "WEB読み" }
|
||||
setUrlWithoutDomain(webReadLink!!.absUrl("href"))
|
||||
}
|
||||
}
|
||||
val hasNextPage = document.selectFirst("nav.pagination a.pagination-btn--next") != null
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
return SManga.create().apply {
|
||||
title = document.selectFirst("h1.ebook-series-title")!!.text()
|
||||
author = document.select("ul.ebook-series-author li").joinToString { it.text() }
|
||||
artist = author
|
||||
description = document.selectFirst("p.ebook-series-synopsis")?.text()
|
||||
genre = document.select("div.book-genre a").joinToString { it.text() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
return document.select("div[js-tab-content][js-tab-episode] ul.shop-list li.shop-item--episode, ul.shop-list li.shop-item--episode").mapNotNull { element ->
|
||||
val info = element.selectFirst(".shop-item-info")!!
|
||||
val nameText = info.selectFirst("span.shop-item-info-name")?.text()!!
|
||||
val dateText = info.selectFirst("span.shop-item-info-release")?.text()?.substringAfter("公開:")
|
||||
val form = element.selectFirst("form[data-api=reader]")
|
||||
val rentalButton = element.selectFirst("button.btn-rental--both, button.btn-rental--coin, button[class*='btn-rental'], button[js-modal]")
|
||||
|
||||
SChapter.create().apply {
|
||||
name = nameText
|
||||
date_upload = dateFormat.tryParse(dateText)
|
||||
|
||||
when {
|
||||
form != null -> {
|
||||
val token = form.selectFirst("input[name=_token]")!!.attr("value")
|
||||
val ebookId = form.selectFirst("input[name=ebook_id]")!!.attr("value")
|
||||
url = ChapterId(token, ebookId).toJsonString()
|
||||
}
|
||||
|
||||
rentalButton != null -> {
|
||||
name = "🔒 $nameText"
|
||||
val rentalId = rentalButton.attr("data-id")
|
||||
url = "rental/$rentalId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
if (!chapter.url.startsWith("{")) {
|
||||
return Observable.error(Exception("This chapter is locked. Log in and purchase this chapter to read."))
|
||||
}
|
||||
|
||||
val chapterId = chapter.url.parseAs<ChapterId>()
|
||||
|
||||
val formBody = FormBody.Builder()
|
||||
.add("_token", chapterId.token)
|
||||
.add("ebook_id", chapterId.id)
|
||||
.build()
|
||||
|
||||
val apiHeaders = headers.newBuilder()
|
||||
.add("Accept", "application/json")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
val apiRequest = POST("$apiUrl/reader", apiHeaders, formBody)
|
||||
|
||||
return client.newCall(apiRequest).asObservable().map { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("API call failed with HTTP ${response.code}")
|
||||
}
|
||||
val redirectUrl = response.parseAs<ApiResponse>().redirect
|
||||
val viewerRequest = GET(redirectUrl, headers)
|
||||
val viewerResponse = client.newCall(viewerRequest).execute()
|
||||
super.pageListParse(viewerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private open class CheckBox(name: String, val value: String) : Filter.CheckBox(name)
|
||||
private class Label(name: String, value: String) : CheckBox(name, value)
|
||||
private class LabelFilter(labels: List<Label>) : Filter.Group<Label>("Labels", labels)
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
return FilterList(
|
||||
Filter.Header("NOTE: Novels only show images, not text."),
|
||||
LabelFilter(
|
||||
listOf(
|
||||
Label("HJ文庫 (Novel)", "1"),
|
||||
Label("HJノベルス (Novel)", "2"),
|
||||
Label("コミックファイア (Manga)", "3"),
|
||||
Label("HJコミックス (Manga)", "4"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Unsupported
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||
}
|
||||