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
This commit is contained in:
manti 2025-10-21 08:36:09 +02:00 committed by Draff
parent b6f6b46a4f
commit 8c34cc1b5b
Signed by: Draff
GPG Key ID: E8A89F3211677653
21 changed files with 763 additions and 0 deletions

View File

@ -18,6 +18,7 @@ android {
dependencies {
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
implementation(project(":core"))
}
tasks.register("printDependentExtensions") {

View File

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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,
)

View File

@ -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
}
}

View 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"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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 STUDIOSwebtoon", "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()
}

View File

@ -0,0 +1,12 @@
ext {
extName = "FireCross"
extClass = ".FireCross"
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:clipstudioreader"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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,
)

View File

@ -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()
}