diff --git a/src/zh/creativecomic/build.gradle b/src/zh/creativecomic/build.gradle new file mode 100644 index 000000000..0c161e9c0 --- /dev/null +++ b/src/zh/creativecomic/build.gradle @@ -0,0 +1,11 @@ +ext { + extName = 'Creative Comic Collection' + extClass = '.Creativecomic' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(':lib:cryptoaes')) +} diff --git a/src/zh/creativecomic/res/mipmap-hdpi/ic_launcher.png b/src/zh/creativecomic/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..df85f8864 Binary files /dev/null and b/src/zh/creativecomic/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/creativecomic/res/mipmap-mdpi/ic_launcher.png b/src/zh/creativecomic/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..799a68aa3 Binary files /dev/null and b/src/zh/creativecomic/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/creativecomic/res/mipmap-xhdpi/ic_launcher.png b/src/zh/creativecomic/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..82af3e33b Binary files /dev/null and b/src/zh/creativecomic/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/creativecomic/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/creativecomic/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..3609e3cdb Binary files /dev/null and b/src/zh/creativecomic/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/creativecomic/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/creativecomic/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..5cbc257f1 Binary files /dev/null and b/src/zh/creativecomic/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/creativecomic/src/eu/kanade/tachiyomi/extension/zh/creativecomic/Creativecomic.kt b/src/zh/creativecomic/src/eu/kanade/tachiyomi/extension/zh/creativecomic/Creativecomic.kt new file mode 100644 index 000000000..8593540e9 --- /dev/null +++ b/src/zh/creativecomic/src/eu/kanade/tachiyomi/extension/zh/creativecomic/Creativecomic.kt @@ -0,0 +1,244 @@ +package eu.kanade.tachiyomi.extension.zh.creativecomic + +import android.annotation.SuppressLint +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.webkit.WebView +import android.webkit.WebViewClient +import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES +import eu.kanade.tachiyomi.network.GET +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.parseAs +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import uy.kohesive.injekt.injectLazy +import java.security.MessageDigest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class Creativecomic : HttpSource() { + override val name: String = "CCC追漫台" + override val lang: String = "zh-Hant" + override val supportsLatest: Boolean = true + override val baseUrl: String = "https://www.creative-comic.tw" + private val apiUrl = "https://api.creative-comic.tw" + private var _pageKey: ByteArray? = null + private var _pageIv: ByteArray? = null + private var _token: String? = null + private val context: Application by injectLazy() + private val handler by lazy { Handler(Looper.getMainLooper()) } + + @SuppressLint("SetJavaScriptEnabled") + fun getToken(): String { + _token?.also { return it } + val latch = CountDownLatch(1) + handler.post { + val webview = WebView(context) + with(webview.settings) { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + blockNetworkImage = true + } + webview.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + view!!.evaluateJavascript("window.localStorage.getItem('accessToken')") { token -> + webview.stopLoading() + webview.destroy() + _token = token.removeSurrounding("\"") + latch.countDown() + } + } + } + webview.loadDataWithBaseURL("$baseUrl/", " ", "text/html", null, null) + } + latch.await(10, TimeUnit.SECONDS) + return _token!! + } + + private fun getApiHeaders(): Headers { + val token = getToken() + if (token == "null") { + return headersBuilder() + .add("device: web_desktop") + .add("uuid: null") + .build() + } + + // Check token expiration + val claims = token.substringAfter(".").substringBefore(".") + val decoded = Base64.decode(claims, Base64.DEFAULT).decodeToString() + val expiration = decoded.parseAs().exp + val now = System.currentTimeMillis() / 1000 + if (now > expiration) throw Exception("token过期,请到WebView重新登录") + + return headersBuilder() + .add("device: web_desktop") + .add("Authorization: Bearer $token") + .build() + } + + private fun getPageKeyIv(): Pair { + _pageIv?.also { return Pair(_pageKey!!, _pageIv!!) } + val token = (getToken().takeUnless { it == "null" } ?: "freeforccc2020reading").toByteArray() + val md = MessageDigest.getInstance("SHA-512") + val digest = md.digest(token) + _pageKey = digest.sliceArray(0..31) + _pageIv = _pageKey!!.sliceArray(15..30) + return Pair(_pageKey!!, _pageIv!!) + } + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .addInterceptor(::authIntercept) + .build() + + private fun authIntercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + val url = request.url.toString() + if (!url.startsWith("https://storage.googleapis.com/ccc-www/fs/chapter_content/encrypt/")) { + return response + } + + val (key, iv) = request.url.fragment!!.split(":") + val keyBytes = key.hexStringToByteArray() + val ivBytes = iv.hexStringToByteArray() + val cipherBytes = response.body.bytes() + val cipher = Cipher.getInstance("AES/CBC/PKCS7PADDING") + val keySpec = SecretKeySpec(keyBytes, "AES") + cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(ivBytes)) + val data = cipher.doFinal(cipherBytes).toString(Charsets.UTF_8) + + val image = Base64.decode(data.substringAfter("base64,"), Base64.DEFAULT) + val mediaType = data.substringAfter("data:").substringBefore(";").toMediaType() + val body = image.toResponseBody(mediaType) + return response.newBuilder().body(body).build() + } + + // Popular + + override fun popularMangaRequest(page: Int): Request { + return GET("$apiUrl/book?page=$page&rows_per_page=24&sort_by=like_count&class=2", getApiHeaders()) + } + + override fun popularMangaParse(response: Response): MangasPage { + val data = response.parseAs().data + val total = data.total + val page = response.request.url.queryParameter("page")!!.toInt() + val rowsPerPage = response.request.url.queryParameter("rows_per_page")!!.toInt() + val hasNextPage = total > page * rowsPerPage + val mangas = data.data.map { + it.toSManga() + } + return MangasPage(mangas, hasNextPage) + } + + // Latest + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$apiUrl/book?page=$page&rows_per_page=24&sort_by=updated_at&class=2", getApiHeaders()) + } + + override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) + + // Search + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = apiUrl.toHttpUrl().newBuilder().apply { + encodedPath("/book") + addQueryParameter("page", page.toString()) + addQueryParameter("rows_per_page", "12") + addQueryParameter("keyword", query) + addQueryParameter("category", "all") + addQueryParameter("sort_by", "updated_at") + addQueryParameter("class", "2") + }.build() + return GET(url, getApiHeaders()) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + // Details + + override fun mangaDetailsRequest(manga: SManga): Request { + return GET("$apiUrl/book/${manga.url}/info", getApiHeaders()) + } + + override fun mangaDetailsParse(response: Response): SManga { + return response.parseAs().data.toSManga() + } + + // Chapters + + override fun chapterListRequest(manga: SManga): Request { + return GET("$apiUrl/book/${manga.url}/chapter", getApiHeaders()) + } + + override fun chapterListParse(response: Response): List { + return response.parseAs().data.chapters.map { + it.toSChapter() + }.reversed() + } + + // Pages + + override fun pageListRequest(chapter: SChapter): Request { + return GET("$apiUrl/book/chapter/${chapter.url}", getApiHeaders()) + } + + override fun pageListParse(response: Response): List { + return response.parseAs().data.chapter.proportion.mapIndexed { index, it -> + Page(index, it.id.toString()) + } + } + + override fun imageUrlRequest(page: Page): Request { + return GET("$apiUrl/book/chapter/image/${page.url}", getApiHeaders()) + } + + override fun imageUrlParse(response: Response): String { + val encryptedKey = response.parseAs().data.key + val (pageKey, pageIv) = getPageKeyIv() + val decryptedKey = CryptoAES.decrypt(encryptedKey, pageKey, pageIv) + val id = response.request.url.encodedPathSegments.last() + return "https://storage.googleapis.com/ccc-www/fs/chapter_content/encrypt/$id/2#$decryptedKey" + } + + override fun getMangaUrl(manga: SManga): String { + return "$baseUrl/zh/book/${manga.url}/content" + } + + override fun getChapterUrl(chapter: SChapter): String { + return "$baseUrl/zh/reader_comic/${chapter.url}" + } + + private fun String.hexStringToByteArray(): ByteArray { + val len = length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ( + (Character.digit(this[i], 16) shl 4) + + Character.digit(this[i + 1], 16) + ).toByte() + i += 2 + } + return data + } +} diff --git a/src/zh/creativecomic/src/eu/kanade/tachiyomi/extension/zh/creativecomic/CreativecomicDto.kt b/src/zh/creativecomic/src/eu/kanade/tachiyomi/extension/zh/creativecomic/CreativecomicDto.kt new file mode 100644 index 000000000..e20f4eb2f --- /dev/null +++ b/src/zh/creativecomic/src/eu/kanade/tachiyomi/extension/zh/creativecomic/CreativecomicDto.kt @@ -0,0 +1,108 @@ +package eu.kanade.tachiyomi.extension.zh.creativecomic + +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 +import java.util.Locale + +@Serializable +class PopularResponseDto(val data: PopularDto) + +@Serializable +class PopularDto(val total: Int, val data: List) + +@Serializable +class MangaDto( + private val id: Int, + private val name: String, + private val image1: String, +) { + fun toSManga() = SManga.create().apply { + url = id.toString() + title = name + thumbnail_url = image1 + } +} + +@Serializable +class DetailsResponseDto(val data: DetailsDto) + +@Serializable +class DetailsDto( + private val name: String, + private val description: String, + private val image1: String, + private val author: List, + private val type: GenreDto, + private val tags: List, + private val completed: Int, +) { + fun toSManga() = SManga.create().apply { + title = name + thumbnail_url = image1 + author = this@DetailsDto.author.joinToString { it.name } + description = this@DetailsDto.description + genre = "${type.name}, ${tags.joinToString{ it.name }}" + status = if (completed == 1) SManga.COMPLETED else SManga.ONGOING + } +} + +@Serializable +class GenreDto(val name: String) + +@Serializable +class AuthorDto(val name: String) + +@Serializable +class ChapterListResponseDto(val data: ChapterListDataDto) + +@Serializable +class ChapterListDataDto(val chapters: List) + +@Serializable +class ChapterDto( + private val id: Int, + private val name: String, + @SerialName("vol_name") private val volName: String, + @SerialName("is_free") private val isFree: Int, + @SerialName("is_buy") private val isBuy: Int, + @SerialName("is_rent") private val isRent: Int, + @SerialName("sales_plan") private val salesPlan: Int, + @SerialName("online_at") private val onlineAt: String, +) { + fun toSChapter() = SChapter.create().apply { + url = id.toString() + // Prepend lock emoji to name if locked + val isReadable = isFree == 1 || isBuy == 1 || isRent == 1 || salesPlan == 0 + name = (if (isReadable) "" else "\uD83D\uDD12") + "$volName ${this@ChapterDto.name}" + date_upload = dateFormat.tryParse(onlineAt) + } +} + +@Serializable +class PageListResponseDto(val data: PageListDataDto) + +@Serializable +class PageListDataDto(val chapter: PageListChapterDto) + +@Serializable +class PageListChapterDto(val proportion: List) + +@Serializable +class PageDto(val id: Int) + +@Serializable +class ImageUrlResponseDto(val data: ImageUrlDto) + +@Serializable +class ImageUrlDto(val key: String) + +private val dateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd hh:mm:ss", Locale.ENGLISH) +} + +@Serializable +class JWTClaims(val exp: Int)