Add Picacomic (#9566)

* Create extension: picacomic

* Picacomic: fix nsfw setting

* Picacomic: remove unnecessary lines in build.gradle

* Picacomic: add code source of HmacSHA256.kt

* Picacomic: use kotlinx.serialization instead of org.json

* Update src/zh/picacomic/build.gradle

Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>

* Picacomic: fix some compile problems

* Picacomic: add RankFilter

Co-authored-by: tarczf <>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
This commit is contained in:
tarczf 2021-10-26 21:22:26 +08:00 committed by GitHub
parent ebb387c8e1
commit 7ec5b66189
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 549 additions and 0 deletions

View File

@ -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" />

View File

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View File

@ -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])
}
}

View File

@ -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,
)

View File

@ -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)
}
}