diff --git a/src/zh/picacomic/AndroidManifest.xml b/src/zh/picacomic/AndroidManifest.xml new file mode 100644 index 000000000..55dea899b --- /dev/null +++ b/src/zh/picacomic/AndroidManifest.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="eu.kanade.tachiyomi.extension" /> \ No newline at end of file diff --git a/src/zh/picacomic/build.gradle b/src/zh/picacomic/build.gradle new file mode 100644 index 000000000..37d159403 --- /dev/null +++ b/src/zh/picacomic/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Picacomic' + pkgNameSuffix = 'zh.picacomic' + extClass = '.Picacomic' + extVersionCode = 1 + isNsfw = true +} + +dependencies { + implementation 'com.auth0.android:jwtdecode:2.0.0' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/picacomic/res/mipmap-hdpi/ic_launcher.png b/src/zh/picacomic/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..92d7b4d9e Binary files /dev/null and b/src/zh/picacomic/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/picacomic/res/mipmap-mdpi/ic_launcher.png b/src/zh/picacomic/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..60b50dc05 Binary files /dev/null and b/src/zh/picacomic/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/picacomic/res/mipmap-xhdpi/ic_launcher.png b/src/zh/picacomic/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..cfcc8c7c2 Binary files /dev/null and b/src/zh/picacomic/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/picacomic/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/picacomic/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..504978b3c Binary files /dev/null and b/src/zh/picacomic/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/picacomic/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/picacomic/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..1257ff876 Binary files /dev/null and b/src/zh/picacomic/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/picacomic/res/web_hi_res_512.png b/src/zh/picacomic/res/web_hi_res_512.png new file mode 100644 index 000000000..3ef23ddf8 Binary files /dev/null and b/src/zh/picacomic/res/web_hi_res_512.png differ diff --git a/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/HmacSHA256.kt b/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/HmacSHA256.kt new file mode 100644 index 000000000..ff5fea19e --- /dev/null +++ b/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/HmacSHA256.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.extension.zh.picacomic + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +// copy from https://github.com/czp3009/picacomic-api +private const val algorithm = "HmacSHA256" + +private typealias MacResult = ByteArray + +internal fun hmacSHA256(key: String, data: String) = + Mac.getInstance(algorithm).apply { + init(SecretKeySpec(key.toByteArray(), algorithm)) + }.doFinal(data.toByteArray()) as MacResult + +@Suppress("SpellCheckingInspection") +private val hexTable = "0123456789abcdef".toCharArray() + +@OptIn(ExperimentalUnsignedTypes::class) +internal fun MacResult.convertToString() = buildString(size * 2) { + this@convertToString.forEach { + val value = it.toUByte().toInt() + append(hexTable[value ushr 4]) + append(hexTable[value and 0x0f]) + } +} diff --git a/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/PicaApiSchemas.kt b/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/PicaApiSchemas.kt new file mode 100644 index 000000000..7e1f195b7 --- /dev/null +++ b/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/PicaApiSchemas.kt @@ -0,0 +1,94 @@ +package eu.kanade.tachiyomi.extension.zh.picacomic + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class PicaLoginPayload( + val email: String, + val password: String, +) + +@Serializable +data class PicaSearchPayload( + val keyword: String, + val categories: List<String>, + val sort: String, +) + +@Serializable +data class PicaResponse( + val data: PicaData, +) + +@Serializable +data class PicaData( + // /comics/advanced-search: PicaSearchComics + // /comics/random: List<PicaSearchComic> + val comics: JsonElement? = null, + // /comics/comicId/eps?page= + val eps: PicaChapters? = null, + // /comics/comicId/order/chapterOrder/pages?page= + val pages: PicaPages? = null, + // /auth/sign-in + val token: String? = null, + // /comics/comicId + val comic: PicaSearchComic? = null, +) + +// /comics +@Serializable +data class PicaSearchComics( + val page: Int, + val pages: Int, + val docs: List<PicaSearchComic>, +) + +// /comics/advanced-search, /comics/random, /comics/leaderboard +@Serializable +data class PicaSearchComic( + val title: String, + val _id: String, + val thumb: PicaImage, + val finished: Boolean, + val categories: List<String>, + val update_at: String? = null, + val author: String? = null, + val artist: String? = null, + val description: String? = null, + val chineseTeam: String? = null, + val tags: List<String>? = null, +) + +@Serializable +data class PicaChapters( + val docs: List<PicaChapter>, + val page: Int, + val pages: Int, +) + +@Serializable +data class PicaChapter( + val _id: String, + val order: Int, + val title: String, + val updated_at: String, +) + +@Serializable +data class PicaPages( + val docs: List<PicaPage>, + val page: Int, + val pages: Int, +) + +@Serializable +data class PicaPage( + val media: PicaImage +) + +@Serializable +data class PicaImage( + val path: String, + val fileServer: String, +) diff --git a/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/Picacomic.kt b/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/Picacomic.kt new file mode 100644 index 000000000..f799db98f --- /dev/null +++ b/src/zh/picacomic/src/eu/kanade/tachiyomi/extension/zh/picacomic/Picacomic.kt @@ -0,0 +1,409 @@ +package eu.kanade.tachiyomi.extension.zh.picacomic + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import com.auth0.android.jwt.JWT +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.ConfigurableSource +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import okhttp3.Headers +import okhttp3.Headers.Companion.toHeaders +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.net.URLEncoder +import java.text.SimpleDateFormat +import java.util.Locale + +class Picacomic : HttpSource(), ConfigurableSource { + override val lang = "zh" + override val supportsLatest = true + override val name = "哔咔漫画" + override val baseUrl = "https://picaapi.picacomic.com" + + private val preferences: SharedPreferences = + Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) + + private val blocklist = preferences.getString("BLOCK_GENRES", "")!! + .split(',').map { it.trim() } + + private val basicHeaders = mapOf( + "api-key" to "C69BAF41DA5ABD1FFEDC6D2FEA56B", + "app-channel" to preferences.getString("APP_CHANNEL", "2")!!, + "app-version" to "2.2.1.3.3.4", + "app-uuid" to "defaultUuid", + "app-platform" to "android", + "app-build-version" to "44", + "User-Agent" to "okhttp/3.8.1", + "accept" to "application/vnd.picacomic.com.v1+json", + "image-quality" to preferences.getString("IMAGE_QUALITY", "high")!!, + "Content-Type" to "application/json; charset=UTF-8", // must be exactly matched! + ) + + private fun encrpt(url: String, time: Long, method: String, nonce: String): String { + val hmacSha256Key = "~d}\$Q7\$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn" + val apiKey = basicHeaders["api-key"] + val path = url.substringAfter("$baseUrl/") + val raw = "$path$time$nonce${method}$apiKey".toLowerCase(Locale.ROOT) + return hmacSHA256(hmacSha256Key, raw).convertToString() + } + + private val token: String by lazy { + var t: String = preferences.getString("TOKEN", "")!! + if (t.isEmpty() || JWT(t).isExpired(10)) { + val username = preferences.getString("USERNAME", "")!! + val password = preferences.getString("PASSWORD", "")!! + if (username.isEmpty() || password.isEmpty()) { + throw Exception("请在扩展设置界面输入用户名和密码") + } + + t = getToken(username, password) + preferences.edit().putString("TOKEN", t).apply() + } + t + } + + private fun picaHeaders(url: String, method: String = "GET"): Headers { + val time = System.currentTimeMillis() / 1000 + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + val nonce = (1..32).map { allowedChars.random() } + .joinToString("") + val signature = encrpt(url, time, method, nonce) + return basicHeaders.toMutableMap().apply { + put("time", time.toString()) + put("nonce", nonce) + put("signature", signature) + if (!url.endsWith("/auth/sign-in")) // avoid recursive call + put("authorization", token) + }.toHeaders() + } + + private val json = Json { ignoreUnknownKeys = true } + + private fun getToken(username: String, password: String): String { + val url = "$baseUrl/auth/sign-in" + val body = PicaLoginPayload(username, password) + .let { Json.encodeToString(it) } + .toRequestBody("application/json; charset=UTF-8".toMediaType()) + + val response = client.newCall( + POST(url, picaHeaders(url, "POST"), body) + ).execute() + + if (!response.isSuccessful) + throw Exception("登录失败") + return json.decodeFromString<PicaResponse>(response.body!!.string()).data.token!! + } + + override fun popularMangaRequest(page: Int): Request { + val url = "$baseUrl/comics?page=$page&s=dd" + return GET(url, picaHeaders(url)) + } + + // for /comics/random, /comics/leaderboard + private fun singlePageParse(response: Response): MangasPage { + val comics = json.decodeFromString<PicaResponse>(response.body!!.string()) + .data.comics!!.let { json.decodeFromJsonElement<List<PicaSearchComic>>(it) } + + val mangas = comics + .filter { !hitBlocklist(it) } + .map { comic -> + SManga.create().apply { + title = comic.title + author = comic.author + thumbnail_url = comic.thumb.let { + it.fileServer + "/static/" + it.path + } + url = "$baseUrl/comics/${comic._id}" + status = if (comic.finished) SManga.COMPLETED else SManga.ONGOING + } + } + + return MangasPage(mangas, response.request.url.toString().contains("/comics/random")) + } + + override fun popularMangaParse(response: Response) = searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int): Request { + val url = "$baseUrl/comics/random" + return GET(url, picaHeaders(url)) + } + + override fun latestUpdatesParse(response: Response): MangasPage = singlePageParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + var sort: String? = null + var category: String? = null + var rankPath: String? = null + + // parse filters + for (filter in filters) { + when (filter) { + is SortFilter -> sort = filter.toUriPart() + is CategoryFilter -> category = filter.toUriPart() + is RankFilter -> rankPath = filter.toUriPart() + else -> throw Exception("unknown filter found") + } + } + + // return comics from leaderboard + if (!rankPath.isNullOrEmpty()) + return GET("$baseUrl$rankPath", picaHeaders("$baseUrl$rankPath")) + + // return comics from some category or just sort + if (query.isEmpty()) { + var url = "$baseUrl/comics?page=$page&s=$sort" + if (!category.isNullOrEmpty()) + url += "&c=${URLEncoder.encode(category, "utf-8")}" + + return GET(url, picaHeaders(url)) + } + + // return comics from some search + // filters may be empty + val url = "$baseUrl/comics/advanced-search?page=$page" + + val body = PicaSearchPayload(query, emptyList(), sort ?: "dd") + .let { Json.encodeToString(it) } + .toRequestBody("application/json; charset=UTF-8".toMediaType()) + + return POST(url, picaHeaders(url, "POST"), body) + } + + private fun hitBlocklist(comic: PicaSearchComic): Boolean { + return (comic.tags ?: emptyList<String>() + comic.categories) + .map(String::trim) + .any { it in blocklist } + } + + override fun searchMangaParse(response: Response): MangasPage { + if (response.request.url.toString().contains("/comics/leaderboard".toRegex())) { + return singlePageParse(response) + } + + val comics = json.decodeFromString<PicaResponse>( + response.body!!.string() + ).data.comics!!.let { json.decodeFromJsonElement<PicaSearchComics>(it) } + + val mangas = comics.docs + .filter { !hitBlocklist(it) } + .map { comic -> + SManga.create().apply { + title = comic.title + author = comic.author + thumbnail_url = comic.thumb.let { "${it.fileServer}/static/${it.path}" } + url = "$baseUrl/comics/${comic._id}" + status = if (comic.finished) SManga.COMPLETED else SManga.ONGOING + } + } + + return MangasPage(mangas, comics.page < comics.pages) + } + + override fun mangaDetailsRequest(manga: SManga): Request = + GET(manga.url, picaHeaders(manga.url)) + + override fun mangaDetailsParse(response: Response): SManga { + val comic = json.decodeFromString<PicaResponse>( + response.body!!.string() + ).data.comic!! + + return SManga.create().apply { + title = comic.title + author = comic.author + description = comic.description + artist = comic.artist + genre = (comic.tags ?: emptyList<String>() + comic.categories) + .map(String::trim) + .distinct() + .joinToString(", ") + status = if (comic.finished) SManga.COMPLETED else SManga.ONGOING + } + } + + override fun chapterListRequest(manga: SManga): Request { + val url = "${manga.url}/eps?page=1" + return GET(url, picaHeaders(url)) + } + + override fun chapterListParse(response: Response): List<SChapter> { + val comicId = response.request.url.pathSegments[1] + + val eps = json.decodeFromString<PicaResponse>( + response.body!!.string() + ).data.eps!! + + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + + val ret = eps.docs.map { + SChapter.create().apply { + name = it.title + url = "$baseUrl/comics/$comicId/order/${it.order}" + date_upload = sdf.parse(it.updated_at)!!.time + } + }.toMutableList() + + if (eps.page < eps.pages) { + val nextUrl = response.request.url.newBuilder() + .setQueryParameter( + "page", (eps.page + 1).toString() + ).build().toString() + + val nextResponse = client.newCall(GET(nextUrl, picaHeaders(nextUrl))).execute() + ret += chapterListParse(nextResponse) + } + + return ret + } + + override fun pageListRequest(chapter: SChapter) = GET( + chapter.url + "/pages?page=1", + picaHeaders(chapter.url + "/pages?page=1") + ) + + override fun pageListParse(response: Response): List<Page> { + val pages = json.decodeFromString<PicaResponse>( + response.body!!.string() + ).data.pages!! + + val ret = pages.docs.mapIndexed { index, picaPage -> + val url = picaPage.media.let { "${it.fileServer}/static/${it.path}" } + Page(index, "", url) + }.toMutableList() + + if (pages.page < pages.pages) { + val nextUrl = response.request.url.newBuilder() + .setQueryParameter("page", (pages.page + 1).toString()) + .build().toString() + + val nextResponse = client.newCall(GET(nextUrl, picaHeaders(nextUrl))).execute() + ret += pageListParse(nextResponse) + } + return ret + } + + override fun imageUrlParse(response: Response): String { + TODO("Not yet implemented") + } + + override fun getFilterList() = FilterList( + SortFilter(), + CategoryFilter(), + RankFilter(), + ) + + private class SortFilter : UriPartFilter( + "排序", + arrayOf( + "新到旧" to "dd", + "旧到新" to "da", + "最多爱心" to "ld", + "最多绅士指名" to "vd", + ) + ) + + private class CategoryFilter : UriPartFilter( + "类型", + arrayOf("全部" to "") + arrayOf( + "大家都在看", "牛牛不哭", "那年今天", "官方都在看", + "嗶咔漢化", "全彩", "長篇", "同人", "短篇", "圓神領域", + "碧藍幻想", "CG雜圖", "純愛", "百合花園", "後宮閃光", "單行本", "姐姐系", + "妹妹系", "SM", "人妻", "NTR", "強暴", + "艦隊收藏", "Love Live", "SAO 刀劍神域", "Fate", + "東方", "禁書目錄", "Cosplay", + "英語 ENG", "生肉", "性轉換", "足の恋", "非人類", + "耽美花園", "偽娘哲學", "扶他樂園", "重口地帶", "歐美", "WEBTOON", + ).map { it to it }.toTypedArray() + ) + + private class RankFilter : UriPartFilter( + "榜单", + arrayOf( + Pair("无", ""), + Pair("过去24小时最热门", "/comics/leaderboard?tt=H24&ct=VC"), + Pair("过去7天最热门", "/comics/leaderboard?tt=D7&ct=VC"), + Pair("过去30天最热门", "/comics/leaderboard?tt=D30&ct=VC"), + ) + ) + + private open class UriPartFilter( + displayName: String, + val vals: Array<Pair<String, String>>, + defaultValue: Int = 0 + ) : + Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), defaultValue) { + open fun toUriPart() = vals[state].second + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + EditTextPreference(screen.context).apply { + key = "USERNAME" + title = "用户名" + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString("USERNAME", newValue as String).commit() + } + }.let(screen::addPreference) + + EditTextPreference(screen.context).apply { + key = "PASSWORD" + title = "密码" + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString("PASSWORD", newValue as String).commit() + } + }.let(screen::addPreference) + + EditTextPreference(screen.context).apply { + key = "BLOCK_GENRES" + title = "屏蔽词列表" + dialogTitle = "屏蔽词列表" + dialogMessage = "根据关键词过滤漫画,关键词之间用','分离。" + + "关键词分为分类和标签两种,在热门和最新中只能按分类过滤(即在filter的类型中出现的词)," + + "而在搜索中两者都可以" + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString("BLOCK_GENRES", newValue as String).commit() + } + }.let(screen::addPreference) + + ListPreference(screen.context).apply { + key = "IMAGE_QUALITY" + title = "图片质量" + entries = arrayOf("原图", "低", "中", "高") + entryValues = arrayOf("original", "low", "medium", "high") + setDefaultValue("original") + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(key, newValue as String).commit() + } + }.let(screen::addPreference) + + ListPreference(screen.context).apply { + key = "APP_CHANNEL" + title = "分流" + entries = arrayOf("1", "2", "3") + entryValues = entries + setDefaultValue("1") + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(key, newValue as String).commit() + } + }.let(screen::addPreference) + } +}