2022-09-30 10:31:37 +00:00
|
|
|
package eu.kanade.tachiyomi.extension.en.constellarscans
|
|
|
|
|
2023-01-20 15:12:49 +00:00
|
|
|
import android.graphics.Bitmap
|
|
|
|
import android.graphics.BitmapFactory
|
|
|
|
import android.graphics.Canvas
|
|
|
|
import android.graphics.ColorMatrix
|
|
|
|
import android.graphics.ColorMatrixColorFilter
|
|
|
|
import android.graphics.Paint
|
|
|
|
import android.graphics.Rect
|
2023-01-24 10:45:51 +00:00
|
|
|
import android.util.Log
|
2022-09-30 10:31:37 +00:00
|
|
|
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
|
2023-01-20 17:51:55 +00:00
|
|
|
import eu.kanade.tachiyomi.network.GET
|
2023-01-20 15:12:49 +00:00
|
|
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
|
|
|
import eu.kanade.tachiyomi.source.model.Page
|
2023-01-20 17:51:55 +00:00
|
|
|
import eu.kanade.tachiyomi.source.model.SChapter
|
|
|
|
import kotlinx.serialization.json.jsonArray
|
|
|
|
import kotlinx.serialization.json.jsonObject
|
|
|
|
import kotlinx.serialization.json.jsonPrimitive
|
2023-01-22 12:22:16 +00:00
|
|
|
import okhttp3.CacheControl
|
2023-01-21 21:53:02 +00:00
|
|
|
import okhttp3.Headers
|
2023-01-20 15:12:49 +00:00
|
|
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
2023-01-20 17:51:55 +00:00
|
|
|
import okhttp3.Request
|
2023-01-20 15:12:49 +00:00
|
|
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
|
|
import org.jsoup.nodes.Document
|
|
|
|
import java.io.ByteArrayOutputStream
|
|
|
|
import java.io.InputStream
|
2023-01-24 10:45:51 +00:00
|
|
|
import java.lang.IllegalArgumentException
|
2023-01-20 15:12:49 +00:00
|
|
|
import java.security.MessageDigest
|
2022-09-30 10:31:37 +00:00
|
|
|
|
|
|
|
class ConstellarScans : MangaThemesia("Constellar Scans", "https://constellarscans.com", "en") {
|
2023-01-20 15:12:49 +00:00
|
|
|
|
|
|
|
override val client = super.client.newBuilder()
|
2023-01-24 17:55:43 +00:00
|
|
|
.rateLimit(1, 3)
|
2023-01-20 15:12:49 +00:00
|
|
|
.addInterceptor { chain ->
|
|
|
|
val response = chain.proceed(chain.request())
|
|
|
|
|
|
|
|
val url = response.request.url
|
|
|
|
if (url.fragment?.contains(DESCRAMBLE) != true) {
|
|
|
|
return@addInterceptor response
|
|
|
|
}
|
|
|
|
|
|
|
|
val segments = url.pathSegments
|
|
|
|
val filenameWithoutExtension = segments.last().split(".")[0]
|
|
|
|
val fragment = segments[segments.lastIndex - 1]
|
|
|
|
val key = md5sum(fragment + filenameWithoutExtension)
|
|
|
|
|
|
|
|
val image = descrambleImage(response.body!!.byteStream(), key)
|
|
|
|
val body = image.toResponseBody("image/jpeg".toMediaTypeOrNull())
|
|
|
|
response.newBuilder()
|
|
|
|
.body(body)
|
|
|
|
.build()
|
2023-01-20 17:51:55 +00:00
|
|
|
}
|
|
|
|
.build()
|
2023-01-20 15:12:49 +00:00
|
|
|
|
2023-01-22 12:22:16 +00:00
|
|
|
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
2023-01-21 21:53:02 +00:00
|
|
|
.add("Referer", "$baseUrl/")
|
2023-01-22 12:22:16 +00:00
|
|
|
.add("Accept-Language", "en-US,en;q=0.9")
|
|
|
|
.add("DNT", "1")
|
|
|
|
.add("User-Agent", mobileUserAgent)
|
|
|
|
.add("Upgrade-Insecure-Requests", "1")
|
2023-01-21 21:53:02 +00:00
|
|
|
|
2022-09-30 10:31:37 +00:00
|
|
|
override val seriesStatusSelector = ".status"
|
2023-01-20 15:12:49 +00:00
|
|
|
|
2023-01-20 17:51:55 +00:00
|
|
|
private val mobileUserAgent by lazy {
|
|
|
|
val req = GET(UA_DB_URL)
|
2023-01-22 12:22:16 +00:00
|
|
|
val data = client.newCall(req).execute().body!!.use {
|
|
|
|
json.parseToJsonElement(it.string()).jsonArray
|
|
|
|
}.mapNotNull {
|
2023-01-24 10:45:51 +00:00
|
|
|
it.jsonObject["user-agent"]?.jsonPrimitive?.content?.takeIf { ua ->
|
|
|
|
ua.startsWith("Mozilla/5.0") &&
|
2023-01-22 12:22:16 +00:00
|
|
|
(
|
2023-01-24 10:45:51 +00:00
|
|
|
ua.contains("iPhone") &&
|
|
|
|
(ua.contains("FxiOS") || ua.contains("CriOS")) ||
|
|
|
|
ua.contains("Android") &&
|
|
|
|
(ua.contains("EdgA") || ua.contains("Chrome") || ua.contains("Firefox"))
|
2023-01-22 12:22:16 +00:00
|
|
|
)
|
2023-01-20 17:51:55 +00:00
|
|
|
}
|
|
|
|
}
|
2023-01-22 12:22:16 +00:00
|
|
|
data.random()
|
2023-01-20 17:51:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
override fun pageListRequest(chapter: SChapter): Request =
|
|
|
|
super.pageListRequest(chapter).newBuilder()
|
2023-01-22 12:22:16 +00:00
|
|
|
.header(
|
|
|
|
"Accept",
|
|
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
|
|
|
)
|
2023-01-21 21:53:02 +00:00
|
|
|
.header("Sec-Fetch-Site", "same-origin")
|
|
|
|
.header("Sec-Fetch-Mode", "navigate")
|
|
|
|
.header("Sec-Fetch-Dest", "document")
|
|
|
|
.header("Sec-Fetch-User", "?1")
|
2023-01-22 12:22:16 +00:00
|
|
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
2023-01-20 17:51:55 +00:00
|
|
|
.build()
|
|
|
|
|
2023-01-20 15:12:49 +00:00
|
|
|
override fun pageListParse(document: Document): List<Page> {
|
2023-01-24 10:45:51 +00:00
|
|
|
val tsData = TS_DATA_RE.find(document.select("script").html())?.groupValues?.get(1)
|
|
|
|
?: return super.pageListParse(document)
|
|
|
|
val descrambledData = descrambleString(tsData).trim()
|
|
|
|
|
2023-01-24 17:55:43 +00:00
|
|
|
// check if the object can be parsed with JSON, else assume it is encrypted
|
|
|
|
//
|
|
|
|
// done because constellarscans have shit code and would sometimes give us an invalid key
|
|
|
|
// for no reason
|
|
|
|
return try {
|
|
|
|
val parsedTsData = json.parseToJsonElement(descrambledData).jsonObject
|
|
|
|
val imageList = parsedTsData["sources"]!!.jsonArray[0].jsonObject["images"]!!.jsonArray
|
|
|
|
imageList.mapIndexed { idx, it ->
|
|
|
|
Page(idx, imageUrl = it.jsonPrimitive.content)
|
|
|
|
}
|
2023-01-24 10:45:51 +00:00
|
|
|
} catch (_: IllegalArgumentException) {
|
2023-01-24 17:55:43 +00:00
|
|
|
val match = DESCRAMBLING_KEY_RE.find(descrambledData)?.value
|
|
|
|
?: throw Exception("Did not receive valid decryption key. Try opening the chapter again.")
|
|
|
|
Log.d("constellarscans", "device-limited chapter: $match")
|
|
|
|
decodeDeviceLimitedChapter(match)
|
2023-01-20 15:12:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-22 12:22:16 +00:00
|
|
|
override fun imageRequest(page: Page): Request = super.imageRequest(page).newBuilder()
|
|
|
|
.header("Accept", "image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
|
|
|
.header("Sec-Fetch-Dest", "image")
|
|
|
|
.header("Sec-Fetch-Mode", "no-cors")
|
|
|
|
.header("Sec-Fetch-Site", "same-origin")
|
|
|
|
.build()
|
|
|
|
|
2023-01-24 10:45:51 +00:00
|
|
|
private fun descrambleString(input: String): String =
|
|
|
|
input.replace(NOT_DIGIT_RE, "")
|
|
|
|
.chunked(2)
|
|
|
|
.joinToString("") { (it.toInt() + 32).toChar().toString() }
|
2023-01-20 15:12:49 +00:00
|
|
|
|
2023-01-24 10:45:51 +00:00
|
|
|
private fun decodeDeviceLimitedChapter(fullKey: String): List<Page> {
|
|
|
|
if (!DESCRAMBLING_KEY_RE.matches(fullKey)) {
|
|
|
|
throw IllegalArgumentException("Did not receive suitable decryption key. Try opening the chapter again.")
|
|
|
|
}
|
2023-01-20 15:12:49 +00:00
|
|
|
|
|
|
|
val shiftBy = fullKey.substring(32..33).toInt(16)
|
|
|
|
val key = fullKey.substring(0..31) + fullKey.substring(34)
|
|
|
|
|
|
|
|
val fragmentAndImageCount = key.map {
|
|
|
|
var idx = LOOKUP_STRING_ALNUM.indexOf(it) - shiftBy
|
|
|
|
if (idx < 0) {
|
|
|
|
idx += LOOKUP_STRING_ALNUM.length
|
|
|
|
}
|
|
|
|
LOOKUP_STRING_ALNUM[idx]
|
|
|
|
}.joinToString("")
|
|
|
|
val fragment = fragmentAndImageCount.substring(0..31)
|
|
|
|
val imageCount = fragmentAndImageCount.substring(32).toInt()
|
|
|
|
|
|
|
|
val pages = mutableListOf<Page>()
|
|
|
|
for (i in 1..imageCount) {
|
2023-01-22 12:22:16 +00:00
|
|
|
val filename = i.toString().padStart(5, '0')
|
2023-01-20 15:12:49 +00:00
|
|
|
pages.add(
|
|
|
|
Page(
|
|
|
|
i,
|
2023-01-22 12:22:16 +00:00
|
|
|
imageUrl = "$encodedUploadsPath/$fragment/$filename.webp#$DESCRAMBLE"
|
2023-01-20 15:12:49 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
return pages
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun descrambleImage(image: InputStream, key: String): ByteArray {
|
|
|
|
val bitmap = BitmapFactory.decodeStream(image)
|
2023-01-20 15:49:09 +00:00
|
|
|
val invertingPaint = Paint().apply {
|
|
|
|
colorFilter = ColorMatrixColorFilter(
|
|
|
|
ColorMatrix(
|
|
|
|
floatArrayOf(
|
|
|
|
-1.0f, 0.0f, 0.0f, 0.0f, 255.0f,
|
|
|
|
0.0f, -1.0f, 0.0f, 0.0f, 255.0f,
|
|
|
|
0.0f, 0.0f, -1.0f, 0.0f, 255.0f,
|
|
|
|
0.0f, 0.0f, 0.0f, 1.0f, 0.0f
|
|
|
|
)
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
2023-01-20 15:12:49 +00:00
|
|
|
|
|
|
|
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
|
|
|
|
val canvas = Canvas(result)
|
|
|
|
|
|
|
|
val sectionCount = (key.last().code % 10) * 2 + 4
|
|
|
|
val remainder = bitmap.height % sectionCount
|
|
|
|
for (i in 0 until sectionCount) {
|
|
|
|
var sectionHeight = bitmap.height / sectionCount
|
|
|
|
var sy = bitmap.height - sectionHeight * (i + 1) - remainder
|
|
|
|
val dy = sectionHeight * i
|
|
|
|
|
|
|
|
if (i == sectionCount - 1) {
|
|
|
|
sectionHeight += remainder
|
|
|
|
} else {
|
|
|
|
sy += remainder
|
|
|
|
}
|
|
|
|
|
|
|
|
val sRect = Rect(0, sy, bitmap.width, sy + sectionHeight)
|
|
|
|
val dRect = Rect(0, dy, bitmap.width, dy + sectionHeight)
|
|
|
|
canvas.drawBitmap(bitmap, sRect, dRect, invertingPaint)
|
|
|
|
}
|
|
|
|
|
|
|
|
val output = ByteArrayOutputStream()
|
|
|
|
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
|
|
|
|
|
|
|
return output.toByteArray()
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun md5sum(input: String): String {
|
2023-01-20 15:49:09 +00:00
|
|
|
val md = MessageDigest.getInstance("MD5")
|
|
|
|
return md.digest(input.toByteArray())
|
2023-01-21 21:53:02 +00:00
|
|
|
.joinToString("") { "%02x".format(it) }
|
2023-01-20 15:12:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private val encodedUploadsPath = "$baseUrl/wp-content/uploads/encoded"
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
const val DESCRAMBLE = "descramble"
|
2023-01-22 12:22:16 +00:00
|
|
|
const val UA_DB_URL =
|
|
|
|
"https://cdn.jsdelivr.net/gh/mimmi20/browscap-helper@30a83c095688f40b9eaca0165a479c661e5a7fbe/tests/0002999.json"
|
2023-01-20 15:12:49 +00:00
|
|
|
const val LOOKUP_STRING_ALNUM =
|
|
|
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
|
|
val NOT_DIGIT_RE = Regex("""\D""")
|
|
|
|
|
2023-01-24 10:45:51 +00:00
|
|
|
// We know that `ts_reader.run` accepts a JSON object, which contains `{"`, or in Constellar's
|
|
|
|
// encoding scheme, 91 02.
|
|
|
|
val TS_DATA_RE = Regex(
|
|
|
|
"""['"]([\da-z]*?9[a-z]*?1[a-z]*?0[a-z]*?2[\da-z]+?)['"]""",
|
|
|
|
RegexOption.IGNORE_CASE
|
|
|
|
)
|
|
|
|
|
2023-01-20 15:12:49 +00:00
|
|
|
// The decoding algorithm looks for a hex number in 32..33, so we write our regex accordingly
|
2023-01-22 12:22:16 +00:00
|
|
|
val DESCRAMBLING_KEY_RE =
|
2023-01-24 10:45:51 +00:00
|
|
|
Regex("""[\da-z]{32}[\da-f]{2}[\da-z]+""", RegexOption.IGNORE_CASE)
|
2023-01-20 15:12:49 +00:00
|
|
|
}
|
2022-09-30 10:31:37 +00:00
|
|
|
}
|