Add CiaoPlus (#11480)

* add ciaoplus

* use api and refactor

* getMangaUrl
This commit is contained in:
manti 2025-11-12 06:37:54 +01:00 committed by Draff
parent afbb0796d9
commit 5e88baecd1
Signed by: Draff
GPG Key ID: E8A89F3211677653
9 changed files with 603 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'Ciao Plus'
extClass = '.CiaoPlus'
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,338 @@
package eu.kanade.tachiyomi.extension.ja.ciaoplus
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 keiyoushi.utils.firstInstance
import keiyoushi.utils.parseAs
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import okio.ByteString.Companion.encodeUtf8
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
class CiaoPlus : HttpSource() {
override val name = "Ciao Plus"
override val baseUrl = "https://ciao.shogakukan.co.jp"
override val lang = "ja"
override val supportsLatest = true
private val apiUrl = "https://api.ciao.shogakukan.co.jp"
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.JAPAN)
private val latestRequestDateFormat = SimpleDateFormat("yyyyMMdd", Locale.JAPAN)
private val latestResponseDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.JAPAN)
private val pageLimit = 25
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(ImageInterceptor())
.build()
// Popular
override fun popularMangaRequest(page: Int): Request {
val offset = (page - 1) * pageLimit
val url = apiUrl.toHttpUrl().newBuilder()
.addPathSegments("ranking/all")
.addQueryParameter("platform", "3")
.addQueryParameter("ranking_id", "1")
.addQueryParameter("offset", offset.toString())
.addQueryParameter("limit", "51")
.addQueryParameter("is_top", "0")
.build()
return hashedGet(url)
}
override fun popularMangaParse(response: Response): MangasPage {
val requestUrl = response.request.url.toString()
val rankingResult = response.parseAs<RankingApiResponse>()
val titleIds = rankingResult.rankingTitleList.map { it.id.toString().padStart(5, '0') }
if (titleIds.isEmpty()) {
return MangasPage(emptyList(), false)
}
val hasNextPage = titleIds.size > pageLimit
val mangaIdsToFetch = if (hasNextPage) titleIds.dropLast(1) else titleIds
val detailsUrl = apiUrl.toHttpUrl().newBuilder()
.addPathSegments("title/list")
.addQueryParameter("platform", "3")
.addQueryParameter("title_id_list", mangaIdsToFetch.joinToString(","))
.build()
val detailsRequest = hashedGet(detailsUrl)
val detailsResponse = client.newCall(detailsRequest).execute()
if (!detailsResponse.isSuccessful) {
throw Exception("Failed to fetch title details: ${detailsResponse.code} - ${detailsResponse.body.string()}")
}
val detailsResult = detailsResponse.parseAs<TitleListResponse>()
val mangas = detailsResult.titleList.map { it.toSManga() }
if (requestUrl.contains("/genre/")) {
return MangasPage(mangas.reversed(), hasNextPage)
}
return MangasPage(mangas, hasNextPage)
}
// Latest
override fun latestUpdatesRequest(page: Int): Request {
val calendar = GregorianCalendar(TimeZone.getTimeZone("Asia/Tokyo")).apply {
time = Date()
add(Calendar.DAY_OF_MONTH, -(page - 1))
}
val dateString = latestRequestDateFormat.format(calendar.time)
val url = apiUrl.toHttpUrl().newBuilder()
.addPathSegments("web/title/ids")
.addQueryParameter("updated_at", dateString)
.addQueryParameter("platform", "3")
.build()
return hashedGet(url)
}
override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<LatestTitleListResponse>()
val today = GregorianCalendar(TimeZone.getTimeZone("Asia/Tokyo")).time
val mangas = result.updateEpisodeTitles
.filterKeys {
if (it.startsWith("2099")) return@filterKeys false
val entryDate = latestResponseDateFormat.parse(it)
!entryDate!!.after(today)
}
.flatMap { it.value }
.distinctBy { it.titleId }
.map { it.toSManga() }
return MangasPage(mangas, false)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotBlank()) {
val url = apiUrl.toHttpUrl().newBuilder()
.addPathSegments("search/title")
.addQueryParameter("keyword", query)
.addQueryParameter("limit", "99999")
.addQueryParameter("platform", "3")
.build()
return hashedGet(url)
}
val genreFilter = filters.firstInstance<GenreFilter>()
val uriPart = genreFilter.toUriPart()
val url = if (uriPart.startsWith("/genre/")) {
val genreId = uriPart.substringAfter("/genre/")
apiUrl.toHttpUrl().newBuilder()
.addPathSegments("search/title")
.addQueryParameter("platform", "3")
.addQueryParameter("genre_id", genreId)
.addQueryParameter("limit", "99999")
.build()
} else {
val rankingId = uriPart.substringAfter("/ranking/")
val offset = (page - 1) * pageLimit
apiUrl.toHttpUrl().newBuilder()
.addPathSegments("ranking/all")
.addQueryParameter("platform", "3")
.addQueryParameter("ranking_id", rankingId)
.addQueryParameter("offset", offset.toString())
.addQueryParameter("limit", "51")
.addQueryParameter("is_top", "0")
.build()
}
return hashedGet(url)
}
override fun searchMangaParse(response: Response): MangasPage {
val requestUrl = response.request.url.toString()
if (requestUrl.contains("/search/title")) {
val result = response.parseAs<TitleListResponse>()
val mangas = result.titleList.map { it.toSManga() }
return MangasPage(mangas, false)
}
return popularMangaParse(response)
}
// Details
override fun getMangaUrl(manga: SManga): String {
return baseUrl + manga.url
}
override fun mangaDetailsRequest(manga: SManga): Request {
val titleId = manga.url.substringAfter("/title/")
val url = apiUrl.toHttpUrl().newBuilder()
.addPathSegments("title/list")
.addQueryParameter("platform", "3")
.addQueryParameter("title_id_list", titleId)
.build()
return hashedGet(url)
}
override fun mangaDetailsParse(response: Response): SManga {
val details = response.parseAs<DetailResponse>()
val result = details.webTitle.first()
return SManga.create().apply {
title = result.titleName
author = result.authorText
description = result.introductionText
if (result.genreIdList.isNotEmpty()) {
val genreApiUrl = apiUrl.toHttpUrl().newBuilder()
.addPathSegments("genre/list")
.addQueryParameter("platform", "3")
.addQueryParameter("genre_id_list", result.genreIdList.joinToString(","))
.build()
val genreRequest = hashedGet(genreApiUrl)
val genreResponse = client.newCall(genreRequest).execute()
if (genreResponse.isSuccessful) {
val genreResult = genreResponse.parseAs<GenreListResponse>()
genre = genreResult.genreList.joinToString { it.genreName }
}
}
}
}
// Chapters
override fun chapterListRequest(manga: SManga): Request {
return mangaDetailsRequest(manga)
}
override fun chapterListParse(response: Response): List<SChapter> {
val details = response.parseAs<DetailResponse>()
val resultIds = details.webTitle.first()
val episodeIds = resultIds.episodeIdList.map { it.toString() }
if (episodeIds.isEmpty()) {
return emptyList()
}
val formBody = FormBody.Builder()
.add("platform", "3")
.add("episode_id_list", episodeIds.joinToString(","))
.build()
val params = (0 until formBody.size).associate { formBody.name(it) to formBody.value(it) }
val hash = generateHash(params)
val postHeaders = headersBuilder()
.add("Origin", baseUrl)
.add("x-bambi-is-crawler", "false")
.add("x-bambi-hash", hash)
.build()
val apiRequest = POST("$apiUrl/episode/list", postHeaders, formBody)
val apiResponse = client.newCall(apiRequest).execute()
if (!apiResponse.isSuccessful) {
throw Exception("API request failed with code ${apiResponse.code}: ${apiResponse.body.string()}")
}
val result = apiResponse.parseAs<EpisodeListResponse>()
return result.episodeList.map { it.toSChapter(resultIds.titleName, dateFormat) }.reversed()
}
// Pages
/*
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter))
.asObservable()
.map { response ->
if (!response.isSuccessful) {
if (response.code == 400) {
throw Exception("This chapter is locked. Log in via WebView and rent or purchase this chapter to read.")
}
throw Exception("HTTP error ${response.code}")
}
pageListParse(response)
}
}
*/
override fun pageListParse(response: Response): List<Page> {
val apiResponse = response.parseAs<ViewerApiResponse>()
val seed = apiResponse.scrambleSeed
val ver = apiResponse.scrambleVer
val fragment = if (ver == 2) "scramble_seed_v2" else "scramble_seed"
return apiResponse.pageList.mapIndexed { index, imageUrl ->
Page(index, imageUrl = "$imageUrl#$fragment=$seed")
}
}
override fun pageListRequest(chapter: SChapter): Request {
val episodeId = chapter.url.substringAfter("episode/")
val url = "$apiUrl/web/episode/viewer".toHttpUrl().newBuilder()
.addQueryParameter("platform", "3")
.addQueryParameter("episode_id", episodeId)
.build()
return hashedGet(url)
}
private fun generateHash(params: Map<String, String>): String {
val paramStrings = params.toSortedMap().map { (key, value) ->
getHashedParam(key, value)
}
val joinedParams = paramStrings.joinToString(",")
val hash1 = joinedParams.encodeUtf8().sha256().hex()
return hash1.encodeUtf8().sha512().hex()
}
private fun getHashedParam(key: String, value: String): String {
val keyHash = key.encodeUtf8().sha256().hex()
val valueHash = value.encodeUtf8().sha512().hex()
return "${keyHash}_$valueHash"
}
private fun hashedGet(url: HttpUrl): Request {
val queryParams = url.queryParameterNames.associateWith { url.queryParameter(it)!! }
val hash = generateHash(queryParams)
val newHeaders = headersBuilder()
.add("x-bambi-hash", hash)
.build()
return GET(url, newHeaders)
}
override fun getFilterList() = FilterList(
Filter.Header("NOTE: Search query will ignore genre filter"),
GenreFilter(getGenreList()),
)
private class GenreFilter(private val genres: Array<Pair<String, String>>) :
Filter.Select<String>("Filter by", genres.map { it.first }.toTypedArray()) {
fun toUriPart() = genres[state].second
}
private fun getGenreList() = arrayOf(
Pair("(ランキング) まんが総合", "/ranking/1"),
Pair("(ランキング) 急上昇", "/ranking/2"),
Pair("(ランキング) 読切", "/ranking/3"),
Pair("(ランキング) ラブ", "/ranking/4"),
Pair("(ランキング) ホラー・ミステリー", "/ranking/5"),
Pair("(ランキング) ファンタジー", "/ranking/6"),
Pair("(ランキング) ギャグ・エッセイ", "/ranking/7"),
Pair("ギャグ・エッセイ", "/genre/1"),
Pair("ラブ", "/genre/2"),
Pair("ホラー・ミステリー", "/genre/3"),
Pair("家族", "/genre/4"),
Pair("青春・学園", "/genre/5"),
Pair("友情", "/genre/6"),
Pair("ファンタジー", "/genre/7"),
Pair("ドリーム・サクセス", "/genre/8"),
Pair("異世界", "/genre/9"),
)
// Unsupported
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
}

View File

@ -0,0 +1,130 @@
package eu.kanade.tachiyomi.extension.ja.ciaoplus
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.tryParse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
@Serializable
class RankingApiResponse(
@SerialName("ranking_title_list") val rankingTitleList: List<RankingTitleId>,
)
@Serializable
class RankingTitleId(
val id: Int,
)
@Serializable
class TitleListResponse(
@SerialName("title_list") val titleList: List<TitleDetail>,
)
@Serializable
class TitleDetail(
@SerialName("title_id") private val titleId: Int,
@SerialName("title_name") private val titleName: String,
@SerialName("thumbnail_image_url") private val thumbnailImageUrl: String?,
) {
fun toSManga(): SManga = SManga.create().apply {
val paddedId = titleId.toString().padStart(5, '0')
url = "/comics/title/$paddedId"
title = titleName
thumbnail_url = thumbnailImageUrl
}
}
@Serializable
class LatestTitleListResponse(
@SerialName("update_episode_titles") val updateEpisodeTitles: Map<String, List<LatestTitleDetail>>,
)
@Serializable
class LatestTitleDetail(
@SerialName("title_id") val titleId: Int,
@SerialName("title_name") private val titleName: String,
@SerialName("thumbnail_image") private val thumbnailImageUrl: String?,
) {
fun toSManga(): SManga = SManga.create().apply {
val paddedId = titleId.toString().padStart(5, '0')
url = "/comics/title/$paddedId"
title = titleName
thumbnail_url = thumbnailImageUrl
}
}
@Serializable
class EpisodeListResponse(
@SerialName("episode_list") val episodeList: List<Episode>,
)
@Serializable
class Episode(
@SerialName("episode_id") private val episodeId: Int,
@SerialName("episode_name") private val episodeName: String,
private val index: Int,
@SerialName("start_time") private val startTime: String,
private val point: Int,
@SerialName("title_id") private val titleId: Int,
private val badge: Int,
@SerialName("rental_finish_time") private val rentalFinishTime: String? = null,
) {
fun toSChapter(mangaTitle: String, dateFormat: SimpleDateFormat): SChapter = SChapter.create().apply {
val paddedId = titleId.toString().padStart(5, '0')
url = "/comics/title/$paddedId/episode/$episodeId"
val originalChapterName = episodeName.trim()
val chapterName = if (originalChapterName.startsWith(mangaTitle.trim())) {
// If entry title is in chapter name, that part of the chapter name is missing, so index is added here to the name
"【第${index}話】 $originalChapterName"
} else {
originalChapterName
}
// It is possible to read paid chapters even though you have to purchase them on the website, so leaving this here in case they change it
/*
name = if (point > 0 && badge != 3 && rentalFinishTime == null) {
"🔒 $chapterName"
} else {
chapterName
}
*/
name = chapterName
chapter_number = index.toFloat()
date_upload = dateFormat.tryParse(startTime)
}
}
@Serializable
class DetailResponse(
@SerialName("title_list") val webTitle: List<WebTitle>,
)
@Serializable
class WebTitle(
@SerialName("title_name") val titleName: String,
@SerialName("author_text") val authorText: String,
@SerialName("introduction_text") val introductionText: String,
@SerialName("genre_id_list") val genreIdList: List<Int>,
@SerialName("episode_id_list") val episodeIdList: List<Int>,
)
@Serializable
class ViewerApiResponse(
@SerialName("page_list") val pageList: List<String>,
@SerialName("scramble_seed") val scrambleSeed: Long,
@SerialName("scramble_ver") val scrambleVer: Int,
)
@Serializable
class GenreListResponse(
@SerialName("genre_list") val genreList: List<GenreDetail>,
)
@Serializable
class GenreDetail(
@SerialName("genre_name") val genreName: String,
)

View File

@ -0,0 +1,127 @@
package eu.kanade.tachiyomi.extension.ja.ciaoplus
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
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayOutputStream
class ImageInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val fragment = request.url.fragment
if (fragment.isNullOrEmpty()) {
return chain.proceed(request)
}
val (seed, version) = when {
fragment.startsWith("scramble_seed_v2=") -> {
Pair(fragment.substringAfter("scramble_seed_v2=").toLong(), 2)
}
fragment.startsWith("scramble_seed=") -> {
Pair(fragment.substringAfter("scramble_seed=").toLong(), 1)
}
else -> return chain.proceed(request)
}
val response = chain.proceed(request)
val descrambledBody = descrambleImage(response.body, seed, version)
return response.newBuilder().body(descrambledBody).build()
}
private class Coord(val x: Int, val y: Int)
private class CoordPair(val source: Coord, val dest: Coord)
private fun xorshift32(seed: UInt): UInt {
var n = seed
n = n xor (n shl 13)
n = n xor (n shr 17)
n = n xor (n shl 5)
return n
}
private fun getUnscrambledCoords(seed: Long): List<CoordPair> {
var seed32 = seed.toUInt()
val pairs = mutableListOf<Pair<UInt, Int>>()
for (i in 0 until 16) {
seed32 = xorshift32(seed32)
pairs.add(seed32 to i)
}
pairs.sortBy { it.first }
val sortedVal = pairs.map { it.second }
return sortedVal.mapIndexed { i, e ->
CoordPair(
source = Coord(x = e % 4, y = e / 4),
dest = Coord(x = i % 4, y = i / 4),
)
}
}
private fun descrambleImage(responseBody: ResponseBody, seed: Long, version: Int): ResponseBody {
val unscrambledCoords = getUnscrambledCoords(seed)
val originalBitmap = BitmapFactory.decodeStream(responseBody.byteStream())
?: throw Exception("Failed to decode image stream")
val originalWidth = originalBitmap.width
val originalHeight = originalBitmap.height
val descrambledBitmap = Bitmap.createBitmap(originalWidth, originalHeight, originalBitmap.config)
val canvas = Canvas(descrambledBitmap)
val (tileWidth, tileHeight) = when (version) {
2 -> {
val getTile = { size: Int -> (size / 32) * 8 }
Pair(getTile(originalWidth), getTile(originalHeight))
}
else -> {
val getTile = { size: Int -> (size / 8 * 8) / 4 }
Pair(getTile(originalWidth), getTile(originalHeight))
}
}
unscrambledCoords.forEach { coord ->
val sx = coord.source.x * tileWidth
val sy = coord.source.y * tileHeight
val dx = coord.dest.x * tileWidth
val dy = coord.dest.y * tileHeight
val srcRect = Rect(sx, sy, sx + tileWidth, sy + tileHeight)
val destRect = Rect(dx, dy, dx + tileWidth, dy + tileHeight)
canvas.drawBitmap(originalBitmap, srcRect, destRect, null)
}
if (version == 2) {
val processedWidth = tileWidth * 4
val processedHeight = tileHeight * 4
if (originalWidth > processedWidth) {
val srcRect = Rect(processedWidth, 0, originalWidth, originalHeight)
val destRect = Rect(processedWidth, 0, originalWidth, originalHeight)
canvas.drawBitmap(originalBitmap, srcRect, destRect, null)
}
if (originalHeight > processedHeight) {
val srcRect = Rect(0, processedHeight, processedWidth, originalHeight)
val destRect = Rect(0, processedHeight, processedWidth, originalHeight)
canvas.drawBitmap(originalBitmap, srcRect, destRect, null)
}
}
originalBitmap.recycle()
val outputStream = ByteArrayOutputStream()
descrambledBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
descrambledBitmap.recycle()
return outputStream.toByteArray().toResponseBody("image/jpeg".toMediaType())
}
}