Add CiaoPlus (#11480)
* add ciaoplus * use api and refactor * getMangaUrl
This commit is contained in:
parent
afbb0796d9
commit
5e88baecd1
8
src/ja/ciaoplus/build.gradle
Normal file
8
src/ja/ciaoplus/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'Ciao Plus'
|
||||
extClass = '.CiaoPlus'
|
||||
extVersionCode = 1
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
BIN
src/ja/ciaoplus/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/ja/ciaoplus/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
src/ja/ciaoplus/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/ja/ciaoplus/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/ja/ciaoplus/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/ja/ciaoplus/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
BIN
src/ja/ciaoplus/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/ja/ciaoplus/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
src/ja/ciaoplus/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/ja/ciaoplus/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@ -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()
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user