diff --git a/src/ja/ciaoplus/build.gradle b/src/ja/ciaoplus/build.gradle new file mode 100644 index 000000000..a4fdf8920 --- /dev/null +++ b/src/ja/ciaoplus/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Ciao Plus' + extClass = '.CiaoPlus' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ja/ciaoplus/res/mipmap-hdpi/ic_launcher.png b/src/ja/ciaoplus/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..9bb75ab05 Binary files /dev/null and b/src/ja/ciaoplus/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ja/ciaoplus/res/mipmap-mdpi/ic_launcher.png b/src/ja/ciaoplus/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..bcde9a713 Binary files /dev/null and b/src/ja/ciaoplus/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ja/ciaoplus/res/mipmap-xhdpi/ic_launcher.png b/src/ja/ciaoplus/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..017552125 Binary files /dev/null and b/src/ja/ciaoplus/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ja/ciaoplus/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/ciaoplus/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..caa1de01a Binary files /dev/null and b/src/ja/ciaoplus/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ja/ciaoplus/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/ciaoplus/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..362fccaad Binary files /dev/null and b/src/ja/ciaoplus/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/CiaoPlus.kt b/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/CiaoPlus.kt new file mode 100644 index 000000000..10a7796d5 --- /dev/null +++ b/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/CiaoPlus.kt @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + genre = genreResult.genreList.joinToString { it.genreName } + } + } + } + } + + // Chapters + override fun chapterListRequest(manga: SManga): Request { + return mangaDetailsRequest(manga) + } + + override fun chapterListParse(response: Response): List { + val details = response.parseAs() + 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() + + return result.episodeList.map { it.toSChapter(resultIds.titleName, dateFormat) }.reversed() + } + + // Pages + /* + override fun fetchPageList(chapter: SChapter): Observable> { + 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 { + val apiResponse = response.parseAs() + 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 { + 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>) : + Filter.Select("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() +} diff --git a/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/Dto.kt b/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/Dto.kt new file mode 100644 index 000000000..38766e768 --- /dev/null +++ b/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/Dto.kt @@ -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, +) + +@Serializable +class RankingTitleId( + val id: Int, +) + +@Serializable +class TitleListResponse( + @SerialName("title_list") val titleList: List, +) + +@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>, +) + +@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, +) + +@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, +) + +@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, + @SerialName("episode_id_list") val episodeIdList: List, +) + +@Serializable +class ViewerApiResponse( + @SerialName("page_list") val pageList: List, + @SerialName("scramble_seed") val scrambleSeed: Long, + @SerialName("scramble_ver") val scrambleVer: Int, +) + +@Serializable +class GenreListResponse( + @SerialName("genre_list") val genreList: List, +) + +@Serializable +class GenreDetail( + @SerialName("genre_name") val genreName: String, +) diff --git a/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/ImageInterceptor.kt b/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/ImageInterceptor.kt new file mode 100644 index 000000000..ae253a67e --- /dev/null +++ b/src/ja/ciaoplus/src/eu/kanade/tachiyomi/extension/ja/ciaoplus/ImageInterceptor.kt @@ -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 { + var seed32 = seed.toUInt() + val pairs = mutableListOf>() + + 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()) + } +}