Remove Bilibili Manga and Kuaikanmanhua (#10255)
@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".zh.bilibilimanga.BilibiliUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
|
||||
<data android:host="manga.bilibili.com" />
|
||||
|
||||
<data android:pathPattern="/detail/mc..*" />
|
||||
<data android:pathPattern="/m/detail/mc..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
@ -1,46 +0,0 @@
|
||||
# Bilibili
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
- [Why are some chapters missing?](#why-are-some-chapters-missing)
|
||||
- [Guides](#Guides)
|
||||
- [Reading already paid chapters](#reading-already-paid-chapters)
|
||||
|
||||
Don't find the question you are looking for? Go check out our general FAQs and Guides
|
||||
over at [Extension FAQ] or [Getting Started].
|
||||
|
||||
[Extension FAQ]: https://tachiyomi.org/help/faq/#extensions
|
||||
[Getting Started]: https://tachiyomi.org/help/guides/getting-started/#installation
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why are some chapters missing?
|
||||
|
||||
Bilibili now have series with paid chapters. These will be filtered out from
|
||||
the chapter list by default if you didn't buy it before or if you're not signed in.
|
||||
To sign in with your existing account, follow the guide available above.
|
||||
|
||||
## Guides
|
||||
|
||||
### Reading already paid chapters
|
||||
|
||||
The **Bilibili Comics** sources allows the reading of paid chapters in your account.
|
||||
Follow the following steps to be able to sign in and get access to them:
|
||||
|
||||
1. Open the popular or latest section of the source.
|
||||
2. Open the WebView by clicking the button with a globe icon.
|
||||
3. Do the login with your existing account *(read the observations section)*.
|
||||
4. Close the WebView and refresh the chapter list of the titles
|
||||
you want to read the already paid chapters.
|
||||
|
||||
#### Observations
|
||||
|
||||
- Sign in with your Google account is not supported due to WebView restrictions
|
||||
access that Google have. **You need to have a simple account in order to be able
|
||||
to login via WebView**.
|
||||
- You may sometime face the *"Failed to refresh the token"* error. To fix it,
|
||||
you just need to open the WebView, await for the website to completely load.
|
||||
After that, you can close the WebView and try again.
|
||||
- The extension **will not** bypass any payment requirement. You still do need
|
||||
to buy the chapters you want to read or wait until they become available and
|
||||
added to your account.
|
@ -1,7 +0,0 @@
|
||||
ext {
|
||||
extName = 'BILIBILI MANGA'
|
||||
extClass = '.BilibiliManga'
|
||||
extVersionCode = 13
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 8.5 KiB |
Before Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 34 KiB |
@ -1,520 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
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.getPreferencesLazy
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.jsoup.Jsoup
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
abstract class Bilibili(
|
||||
override val name: String,
|
||||
final override val baseUrl: String,
|
||||
final override val lang: String,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::expiredImageTokenIntercept)
|
||||
.addInterceptor(::decryptImageIntercept)
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 1)
|
||||
.rateLimitHost(CDN_URL.toHttpUrl(), 2)
|
||||
.rateLimitHost(MODIFIED_CDN_URL.toHttpUrl(), 2)
|
||||
.rateLimitHost(COVER_CDN_URL.toHttpUrl(), 2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = Headers.Builder()
|
||||
.add("Accept", ACCEPT_JSON)
|
||||
.add("Origin", baseUrl)
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
protected open val intl by lazy { BilibiliIntl(lang) }
|
||||
|
||||
private val apiLang: String = when (lang) {
|
||||
BilibiliIntl.SIMPLIFIED_CHINESE -> "cn"
|
||||
else -> lang
|
||||
}
|
||||
|
||||
protected open val defaultPopularSort: Int = 0
|
||||
|
||||
protected open val defaultLatestSort: Int = 1
|
||||
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
protected val json: Json by injectLazy()
|
||||
|
||||
protected open val signedIn: Boolean = false
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = searchMangaRequest(
|
||||
page = page,
|
||||
query = "",
|
||||
filters = FilterList(
|
||||
SortFilter("", getAllSortOptions(), defaultPopularSort),
|
||||
),
|
||||
)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest(
|
||||
page = page,
|
||||
query = "",
|
||||
filters = FilterList(
|
||||
SortFilter("", getAllSortOptions(), defaultLatestSort),
|
||||
),
|
||||
)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
ID_SEARCH_PATTERN.matchEntire(query)?.let {
|
||||
val (id) = it.destructured
|
||||
val temporaryManga = SManga.create().apply { url = "/detail/mc$id" }
|
||||
return mangaDetailsRequest(temporaryManga)
|
||||
}
|
||||
|
||||
val price = filters.firstInstanceOrNull<PriceFilter>()?.state ?: 0
|
||||
|
||||
val jsonPayload = buildJsonObject {
|
||||
put("area_id", filters.firstInstanceOrNull<AreaFilter>()?.selected?.id ?: -1)
|
||||
put("is_finish", filters.firstInstanceOrNull<StatusFilter>()?.state?.minus(1) ?: -1)
|
||||
put("is_free", if (price == 0) -1 else price)
|
||||
put("order", filters.firstInstanceOrNull<SortFilter>()?.selected?.id ?: 0)
|
||||
put("page_num", page)
|
||||
put("page_size", if (query.isBlank()) POPULAR_PER_PAGE else SEARCH_PER_PAGE)
|
||||
put("style_id", filters.firstInstanceOrNull<GenreFilter>()?.selected?.id ?: -1)
|
||||
put("style_prefer", "[]")
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
put("need_shield_prefer", true)
|
||||
put("key_word", query)
|
||||
}
|
||||
}
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val refererUrl = if (query.isBlank()) {
|
||||
"$baseUrl/genre"
|
||||
} else {
|
||||
"$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("keyword", query)
|
||||
.toString()
|
||||
}
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", refererUrl)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/".toHttpUrl().newBuilder()
|
||||
.addPathSegment(if (query.isBlank()) "ClassPage" else "Search")
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val requestUrl = response.request.url.toString()
|
||||
if (requestUrl.contains("ComicDetail")) {
|
||||
val comic = mangaDetailsParse(response)
|
||||
return MangasPage(listOf(comic), hasNextPage = false)
|
||||
}
|
||||
|
||||
if (requestUrl.contains("ClassPage")) {
|
||||
val result = response.parseAs<List<BilibiliComicDto>>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return MangasPage(emptyList(), hasNextPage = false)
|
||||
}
|
||||
|
||||
val comicList = result.data!!.map(::searchMangaFromObject)
|
||||
val hasNextPage = comicList.size == POPULAR_PER_PAGE
|
||||
|
||||
return MangasPage(comicList, hasNextPage)
|
||||
}
|
||||
|
||||
val result = response.parseAs<BilibiliSearchDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return MangasPage(emptyList(), hasNextPage = false)
|
||||
}
|
||||
|
||||
val comicList = result.data!!.list.map(::searchMangaFromObject)
|
||||
val hasNextPage = comicList.size == SEARCH_PER_PAGE
|
||||
|
||||
return MangasPage(comicList, hasNextPage)
|
||||
}
|
||||
|
||||
private fun searchMangaFromObject(comic: BilibiliComicDto): SManga = SManga.create().apply {
|
||||
title = Jsoup.parse(comic.title).text()
|
||||
thumbnail_url = comic.verticalCover + THUMBNAIL_RESOLUTION
|
||||
|
||||
val comicId = if (comic.id == 0) comic.seasonId else comic.id
|
||||
url = "/detail/mc$comicId"
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String = baseUrl + manga.url
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val comicId = manga.url.substringAfterLast("/mc").toInt()
|
||||
|
||||
val jsonPayload = buildJsonObject { put("comic_id", comicId) }
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", baseUrl + manga.url)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/ComicDetail".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
|
||||
val comic = response.parseAs<BilibiliComicDto>().data!!
|
||||
|
||||
title = comic.title
|
||||
author = comic.authorName.joinToString()
|
||||
genre = comic.styles.joinToString()
|
||||
status = when {
|
||||
comic.isFinish == 1 -> SManga.COMPLETED
|
||||
comic.isOnHiatus -> SManga.ON_HIATUS
|
||||
else -> SManga.ONGOING
|
||||
}
|
||||
description = buildString {
|
||||
if (comic.hasPaidChapters && !signedIn) {
|
||||
append("${intl.hasPaidChaptersWarning(comic.paidChaptersCount)}\n\n")
|
||||
}
|
||||
|
||||
append(comic.classicLines)
|
||||
|
||||
if (comic.updateWeekdays.isNotEmpty() && status == SManga.ONGOING) {
|
||||
append("\n\n${intl.informationTitle}:")
|
||||
append("\n• ${intl.getUpdateDays(comic.updateWeekdays)}")
|
||||
}
|
||||
}
|
||||
thumbnail_url = comic.verticalCover
|
||||
url = "/detail/mc" + comic.id
|
||||
}
|
||||
|
||||
// Chapters are available in the same url of the manga details.
|
||||
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = response.parseAs<BilibiliComicDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return result.data!!.episodeList.map { ep -> chapterFromObject(ep, result.data.id) }
|
||||
}
|
||||
|
||||
protected open fun chapterFromObject(
|
||||
episode: BilibiliEpisodeDto,
|
||||
comicId: Int,
|
||||
isUnlocked: Boolean = false,
|
||||
): SChapter = SChapter.create().apply {
|
||||
name = buildString {
|
||||
if (episode.isPaid && !isUnlocked) {
|
||||
append("$EMOJI_LOCKED ")
|
||||
}
|
||||
|
||||
append(episode.shortTitle)
|
||||
|
||||
if (episode.title.isNotBlank()) {
|
||||
append(" - ${episode.title}")
|
||||
}
|
||||
}
|
||||
date_upload = episode.publicationTime.toDate()
|
||||
url = "/mc$comicId/${episode.id}"
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request = imageIndexRequest(chapter.url, "")
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> = imageIndexParse(response)
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
protected open fun imageIndexRequest(chapterUrl: String, credential: String): Request {
|
||||
val chapterId = chapterUrl.substringAfterLast("/").toInt()
|
||||
|
||||
val jsonPayload = buildJsonObject {
|
||||
put("credential", credential)
|
||||
put("ep_id", chapterId)
|
||||
}
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", baseUrl + chapterUrl)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/GetImageIndex".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
protected open fun imageIndexParse(response: Response): List<Page> {
|
||||
val result = response.parseAs<BilibiliReader>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val imageQuality = preferences.chapterImageQuality
|
||||
val imageFormat = preferences.chapterImageFormat
|
||||
|
||||
val imageUrls = result.data!!.images.map { it.url(imageQuality, imageFormat) }
|
||||
val imageTokenRequest = imageTokenRequest(imageUrls)
|
||||
val imageTokenResponse = client.newCall(imageTokenRequest).execute()
|
||||
val imageTokenResult = imageTokenResponse.parseAs<List<BilibiliPageDto>>()
|
||||
return imageTokenResult.data!!.zip(imageUrls).mapIndexed { i, pair ->
|
||||
Page(i, pair.second, pair.first.imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun imageTokenRequest(urls: List<String>): Request {
|
||||
val jsonPayload = buildJsonObject {
|
||||
put("urls", json.encodeToString(urls))
|
||||
}
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/ImageToken".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, headers, requestBody)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String = ""
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val imageQualityPref = ListPreference(screen.context).apply {
|
||||
key = "${IMAGE_QUALITY_PREF_KEY}_$lang"
|
||||
title = intl.imageQualityPrefTitle
|
||||
entries = intl.imageQualityPrefEntries
|
||||
entryValues = IMAGE_QUALITY_PREF_ENTRY_VALUES
|
||||
setDefaultValue(IMAGE_QUALITY_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
}
|
||||
|
||||
val imageFormatPref = ListPreference(screen.context).apply {
|
||||
key = "${IMAGE_FORMAT_PREF_KEY}_$lang"
|
||||
title = intl.imageFormatPrefTitle
|
||||
entries = IMAGE_FORMAT_PREF_ENTRIES
|
||||
entryValues = IMAGE_FORMAT_PREF_ENTRY_VALUES
|
||||
setDefaultValue(IMAGE_FORMAT_PREF_DEFAULT_VALUE)
|
||||
summary = "%s"
|
||||
}
|
||||
|
||||
screen.addPreference(imageQualityPref)
|
||||
screen.addPreference(imageFormatPref)
|
||||
}
|
||||
|
||||
abstract fun getAllGenres(): Array<BilibiliTag>
|
||||
|
||||
protected open fun getAllAreas(): Array<BilibiliTag> = emptyArray()
|
||||
|
||||
protected open fun getAllSortOptions(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag(intl.sortInterest, 0),
|
||||
BilibiliTag(intl.sortUpdated, 4),
|
||||
)
|
||||
|
||||
protected open fun getAllStatus(): Array<String> =
|
||||
arrayOf(intl.statusAll, intl.statusOngoing, intl.statusComplete)
|
||||
|
||||
protected open fun getAllPrices(): Array<String> = emptyArray()
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val allAreas = getAllAreas()
|
||||
val allPrices = getAllPrices()
|
||||
|
||||
val filters = listOfNotNull(
|
||||
StatusFilter(intl.statusLabel, getAllStatus()),
|
||||
SortFilter(intl.sortLabel, getAllSortOptions(), defaultPopularSort),
|
||||
PriceFilter(intl.priceLabel, getAllPrices()).takeIf { allPrices.isNotEmpty() },
|
||||
GenreFilter(intl.genreLabel, getAllGenres()),
|
||||
AreaFilter(intl.areaLabel, allAreas).takeIf { allAreas.isNotEmpty() },
|
||||
)
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return super.imageRequest(page).newBuilder().tag(TAG_IMAGE_REQUEST)
|
||||
.tag(TagImagePath::class.java, TagImagePath(page.url)).build()
|
||||
}
|
||||
|
||||
private fun decryptImageIntercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful && request.tag() == TAG_IMAGE_REQUEST) {
|
||||
if (response.body.contentType()?.type == "image") {
|
||||
return response
|
||||
}
|
||||
val cpx = request.url.queryParameter("cpx")
|
||||
val iv = Base64.decode(cpx, Base64.DEFAULT).copyOfRange(60, 76)
|
||||
val allBytes = response.body.bytes()
|
||||
val size =
|
||||
ByteBuffer.wrap(allBytes.copyOfRange(1, 5)).order(ByteOrder.BIG_ENDIAN).getInt()
|
||||
val data = allBytes.copyOfRange(5, 5 + size)
|
||||
val key = allBytes.copyOfRange(5 + size, allBytes.size)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), ivSpec)
|
||||
val encryptedSize = 20 * 1024 + 16
|
||||
val decryptedSegment = cipher.doFinal(data, 0, encryptedSize.coerceAtMost(data.size))
|
||||
val decryptedData = if (encryptedSize < data.size) {
|
||||
// append remaining data
|
||||
decryptedSegment + data.copyOfRange(encryptedSize, data.size)
|
||||
} else {
|
||||
decryptedSegment
|
||||
}
|
||||
val imageExtension = request.url.encodedPath.substringAfterLast(".", "jpg")
|
||||
return response.newBuilder()
|
||||
.body(decryptedData.toResponseBody("image/$imageExtension".toMediaType())).build()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun expiredImageTokenIntercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
// Get a new image token if the current one expired.
|
||||
if (response.code == 400 && request.tag() == TAG_IMAGE_REQUEST) {
|
||||
val imagePath = request.tag(TagImagePath::class)
|
||||
if (imagePath?.path.isNullOrEmpty()) {
|
||||
return response
|
||||
}
|
||||
response.close()
|
||||
val imageTokenRequest = imageTokenRequest(listOf(imagePath!!.path))
|
||||
val imageTokenResponse = chain.proceed(imageTokenRequest)
|
||||
val imageTokenResult = imageTokenResponse.parseAs<List<BilibiliPageDto>>()
|
||||
imageTokenResponse.close()
|
||||
|
||||
val newPage = imageTokenResult.data!!.first()
|
||||
val newPageUrl = newPage.imageUrl
|
||||
|
||||
val newRequest = imageRequest(Page(0, imagePath.path, newPageUrl))
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private val SharedPreferences.chapterImageQuality
|
||||
get() = when (
|
||||
getString(
|
||||
"${IMAGE_QUALITY_PREF_KEY}_$lang",
|
||||
IMAGE_QUALITY_PREF_DEFAULT_VALUE,
|
||||
)!!
|
||||
) {
|
||||
"hd" -> "1600w"
|
||||
"sd" -> "1000w"
|
||||
"low" -> "800w_50q"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
private val SharedPreferences.chapterImageFormat
|
||||
get() = getString("${IMAGE_FORMAT_PREF_KEY}_$lang", IMAGE_FORMAT_PREF_DEFAULT_VALUE)!!
|
||||
|
||||
private inline fun <reified R> List<*>.firstInstanceOrNull(): R? = firstOrNull { it is R } as? R
|
||||
|
||||
protected open fun HttpUrl.Builder.addCommonParameters(): HttpUrl.Builder = apply {
|
||||
if (name == "BILIBILI COMICS") {
|
||||
addQueryParameter("lang", apiLang)
|
||||
addQueryParameter("sys_lang", apiLang)
|
||||
}
|
||||
|
||||
addQueryParameter("device", "pc")
|
||||
addQueryParameter("platform", "web")
|
||||
}
|
||||
|
||||
protected inline fun <reified T> Response.parseAs(): BilibiliResultDto<T> = use {
|
||||
json.decodeFromString(it.body.string())
|
||||
}
|
||||
|
||||
private fun String.toDate(): Long {
|
||||
return runCatching { DATE_FORMATTER.parse(this)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
private class TagImagePath(val path: String)
|
||||
|
||||
companion object {
|
||||
const val CDN_URL = "https://manga.hdslb.com"
|
||||
const val MODIFIED_CDN_URL = "https://mangaup.hdslb.com"
|
||||
const val COVER_CDN_URL = "https://i0.hdslb.com"
|
||||
|
||||
const val API_COMIC_V1_COMIC_ENDPOINT = "twirp/comic.v1.Comic"
|
||||
|
||||
private const val ACCEPT_JSON = "application/json, text/plain, */*"
|
||||
private const val TAG_IMAGE_REQUEST = "tag_image_request"
|
||||
|
||||
val JSON_MEDIA_TYPE = "application/json;charset=UTF-8".toMediaType()
|
||||
|
||||
private const val POPULAR_PER_PAGE = 18
|
||||
private const val SEARCH_PER_PAGE = 9
|
||||
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
private val ID_SEARCH_PATTERN = "^${PREFIX_ID_SEARCH}mc(\\d+)$".toRegex()
|
||||
|
||||
private const val IMAGE_QUALITY_PREF_KEY = "chapterImageQuality"
|
||||
private val IMAGE_QUALITY_PREF_ENTRY_VALUES = arrayOf("raw", "hd", "sd", "low")
|
||||
private val IMAGE_QUALITY_PREF_DEFAULT_VALUE = IMAGE_QUALITY_PREF_ENTRY_VALUES[1]
|
||||
|
||||
private const val IMAGE_FORMAT_PREF_KEY = "chapterImageFormat"
|
||||
private val IMAGE_FORMAT_PREF_ENTRIES = arrayOf("JPG", "WEBP", "PNG")
|
||||
private val IMAGE_FORMAT_PREF_ENTRY_VALUES = arrayOf("jpg", "webp", "png")
|
||||
private val IMAGE_FORMAT_PREF_DEFAULT_VALUE = IMAGE_FORMAT_PREF_ENTRY_VALUES[0]
|
||||
|
||||
const val THUMBNAIL_RESOLUTION = "@512w.jpg"
|
||||
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
|
||||
}
|
||||
|
||||
private const val EMOJI_LOCKED = "\uD83D\uDD12"
|
||||
const val EMOJI_WARNING = "\u26A0\uFE0F"
|
||||
}
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BilibiliResultDto<T>(
|
||||
val code: Int = 0,
|
||||
val data: T? = null,
|
||||
@SerialName("msg") val message: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliSearchDto(
|
||||
val list: List<BilibiliComicDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliComicDto(
|
||||
@SerialName("author_name") val authorName: List<String> = emptyList(),
|
||||
@SerialName("classic_lines") val classicLines: String = "",
|
||||
@SerialName("comic_id") val comicId: Int = 0,
|
||||
@SerialName("ep_list") val episodeList: List<BilibiliEpisodeDto> = emptyList(),
|
||||
val id: Int = 0,
|
||||
@SerialName("is_finish") val isFinish: Int = 0,
|
||||
@SerialName("temp_stop_update") val isOnHiatus: Boolean = false,
|
||||
@SerialName("season_id") val seasonId: Int = 0,
|
||||
val styles: List<String> = emptyList(),
|
||||
val title: String,
|
||||
@SerialName("update_weekday") val updateWeekdays: List<Int> = emptyList(),
|
||||
@SerialName("vertical_cover") val verticalCover: String = "",
|
||||
) {
|
||||
val hasPaidChapters: Boolean
|
||||
get() = paidChaptersCount > 0
|
||||
|
||||
val paidChaptersCount: Int
|
||||
get() = episodeList.filter { it.isPaid }.size
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliEpisodeDto(
|
||||
val id: Int,
|
||||
@SerialName("is_in_free") val isInFree: Boolean,
|
||||
@SerialName("is_locked") val isLocked: Boolean,
|
||||
@SerialName("pay_gold") val payGold: Int,
|
||||
@SerialName("pay_mode") val payMode: Int,
|
||||
@SerialName("pub_time") val publicationTime: String,
|
||||
@SerialName("short_title") val shortTitle: String,
|
||||
val title: String,
|
||||
) {
|
||||
val isPaid = payMode == 1 && payGold > 0
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliReader(
|
||||
val images: List<BilibiliImageDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliImageDto(
|
||||
val path: String,
|
||||
@SerialName("x") val width: Int,
|
||||
@SerialName("y") val height: Int,
|
||||
) {
|
||||
|
||||
fun url(quality: String, format: String): String {
|
||||
val imageWidth = if (quality == "raw") "${width}w" else quality
|
||||
|
||||
return "$path@$imageWidth.$format"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliPageDto(
|
||||
val token: String,
|
||||
val url: String,
|
||||
@SerialName("complete_url")
|
||||
val completeUrl: String,
|
||||
) {
|
||||
val imageUrl: String
|
||||
get() = completeUrl.ifEmpty { "$url?token=$token" }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BilibiliAccessTokenCookie(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val area: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliAccessToken(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliUserEpisodes(
|
||||
@SerialName("unlocked_eps") val unlockedEpisodes: List<BilibiliUnlockedEpisode>? = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliUnlockedEpisode(
|
||||
@SerialName("ep_id") val id: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliGetCredential(
|
||||
@SerialName("comic_id") val comicId: Int,
|
||||
@SerialName("ep_id") val episodeId: Int,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BilibiliCredential(
|
||||
val credential: String,
|
||||
)
|
@ -1,29 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
data class BilibiliTag(val name: String, val id: Int) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
open class EnhancedSelect<T>(name: String, values: Array<T>, state: Int = 0) :
|
||||
Filter.Select<T>(name, values, state) {
|
||||
|
||||
val selected: T?
|
||||
get() = values.getOrNull(state)
|
||||
}
|
||||
|
||||
class GenreFilter(label: String, genres: Array<BilibiliTag>) :
|
||||
EnhancedSelect<BilibiliTag>(label, genres)
|
||||
|
||||
class AreaFilter(label: String, genres: Array<BilibiliTag>) :
|
||||
EnhancedSelect<BilibiliTag>(label, genres)
|
||||
|
||||
class SortFilter(label: String, options: Array<BilibiliTag>, state: Int = 0) :
|
||||
EnhancedSelect<BilibiliTag>(label, options, state)
|
||||
|
||||
class StatusFilter(label: String, statuses: Array<String>) :
|
||||
Filter.Select<String>(label, statuses)
|
||||
|
||||
class PriceFilter(label: String, prices: Array<String>) :
|
||||
Filter.Select<String>(label, prices)
|
@ -1,226 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import java.text.DateFormatSymbols
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BilibiliIntl(private val lang: String) {
|
||||
|
||||
private val locale by lazy { Locale.forLanguageTag(lang) }
|
||||
|
||||
private val dateFormatSymbols by lazy { DateFormatSymbols(locale) }
|
||||
|
||||
private val numberFormat by lazy { NumberFormat.getInstance(locale) }
|
||||
|
||||
val statusLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "进度"
|
||||
SPANISH -> "Estado"
|
||||
else -> "Status"
|
||||
}
|
||||
|
||||
val sortLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "排序"
|
||||
INDONESIAN -> "Urutkan dengan"
|
||||
SPANISH -> "Ordenar por"
|
||||
else -> "Sort by"
|
||||
}
|
||||
|
||||
val genreLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "题材"
|
||||
SPANISH -> "Género"
|
||||
else -> "Genre"
|
||||
}
|
||||
|
||||
val areaLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "地区"
|
||||
else -> "Area"
|
||||
}
|
||||
|
||||
val priceLabel: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "收费"
|
||||
INDONESIAN -> "Harga"
|
||||
SPANISH -> "Precio"
|
||||
else -> "Price"
|
||||
}
|
||||
|
||||
fun hasPaidChaptersWarning(chapterCount: Int): String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE ->
|
||||
"${Bilibili.EMOJI_WARNING} 此漫画有 ${chapterCount.localized} 个付费章节,已在目录中隐藏。" +
|
||||
"如果你已购买,请在 WebView 登录并刷新目录,即可阅读已购章节。"
|
||||
SPANISH ->
|
||||
"${Bilibili.EMOJI_WARNING} ADVERTENCIA: Esta serie tiene ${chapterCount.localized} " +
|
||||
"capítulos pagos que fueron filtrados de la lista de capítulos. Si ya has " +
|
||||
"desbloqueado y tiene alguno en su cuenta, inicie sesión en WebView y " +
|
||||
"actualice la lista de capítulos para leerlos."
|
||||
else ->
|
||||
"${Bilibili.EMOJI_WARNING} WARNING: This series has ${chapterCount.localized} paid " +
|
||||
"chapters. If you have any unlocked in your account then sign in through WebView " +
|
||||
"to be able to read them."
|
||||
}
|
||||
|
||||
val imageQualityPrefTitle: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "章节图片质量"
|
||||
INDONESIAN -> "Kualitas gambar"
|
||||
SPANISH -> "Calidad de imagen del capítulo"
|
||||
else -> "Chapter image quality"
|
||||
}
|
||||
|
||||
val imageQualityPrefEntries: Array<String> = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> arrayOf("原图", "高清 (1600w)", "标清 (1000w)", "低清 (800w)")
|
||||
else -> arrayOf("Raw", "HD (1600w)", "SD (1000w)", "Low (800w)")
|
||||
}
|
||||
|
||||
val imageFormatPrefTitle: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "章节图片格式"
|
||||
INDONESIAN -> "Format gambar"
|
||||
SPANISH -> "Formato de la imagen del capítulo"
|
||||
else -> "Chapter image format"
|
||||
}
|
||||
|
||||
val sortInterest: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "为你推荐"
|
||||
INDONESIAN -> "Kamu Mungkin Suka"
|
||||
SPANISH -> "Sugerencia"
|
||||
else -> "Interest"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val sortPopular: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "人气推荐"
|
||||
INDONESIAN -> "Populer"
|
||||
SPANISH -> "Popularidad"
|
||||
FRENCH -> "Préférences"
|
||||
else -> "Popular"
|
||||
}
|
||||
|
||||
val sortUpdated: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "更新时间"
|
||||
INDONESIAN -> "Terbaru"
|
||||
SPANISH -> "Actualización"
|
||||
FRENCH -> "Récent"
|
||||
else -> "Updated"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val sortAdded: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "上架时间"
|
||||
else -> "Added"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val sortFollowers: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "追漫人数"
|
||||
else -> "Followers count"
|
||||
}
|
||||
|
||||
val statusAll: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "全部"
|
||||
INDONESIAN -> "Semua"
|
||||
SPANISH -> "Todos"
|
||||
FRENCH -> "Tout"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
val statusOngoing: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "连载中"
|
||||
INDONESIAN -> "Berlangsung"
|
||||
SPANISH -> "En curso"
|
||||
FRENCH -> "En cours"
|
||||
else -> "Ongoing"
|
||||
}
|
||||
|
||||
val statusComplete: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "已完结"
|
||||
INDONESIAN -> "Tamat"
|
||||
SPANISH -> "Finalizado"
|
||||
FRENCH -> "Complet"
|
||||
else -> "Completed"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val priceAll: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "全部"
|
||||
INDONESIAN -> "Semua"
|
||||
SPANISH -> "Todos"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val priceFree: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "免费"
|
||||
INDONESIAN -> "Bebas"
|
||||
SPANISH -> "Gratis"
|
||||
else -> "Free"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val pricePaid: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "付费"
|
||||
INDONESIAN -> "Dibayar"
|
||||
SPANISH -> "Pago"
|
||||
else -> "Paid"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliManga
|
||||
val priceWaitForFree: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "等就免费"
|
||||
else -> "Wait for free"
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliComics
|
||||
val failedToRefreshToken: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "无法刷新令牌。请打开 WebView 修正错误。"
|
||||
SPANISH -> "Error al actualizar el token. Abra el WebView para solucionar este error."
|
||||
else -> "Failed to refresh the token. Open the WebView to fix this error."
|
||||
}
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliComics
|
||||
val failedToGetCredential: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "无法获取阅读章节所需的凭证。"
|
||||
SPANISH -> "Erro al obtener la credencial para leer el capítulo."
|
||||
else -> "Failed to get the credential to read the chapter."
|
||||
}
|
||||
|
||||
val informationTitle: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "信息"
|
||||
SPANISH -> "Información"
|
||||
else -> "Information"
|
||||
}
|
||||
|
||||
private val updatesDaily: String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "每日更新"
|
||||
SPANISH -> "Actualizaciones diarias"
|
||||
else -> "Updates daily"
|
||||
}
|
||||
|
||||
private fun updatesEvery(days: String): String = when (lang) {
|
||||
CHINESE, SIMPLIFIED_CHINESE -> "${days}更新"
|
||||
SPANISH -> "Actualizaciones todos los $days"
|
||||
else -> "Updates every $days"
|
||||
}
|
||||
|
||||
fun getUpdateDays(dayIndexes: List<Int>): String {
|
||||
val shortWeekDays = dateFormatSymbols.shortWeekdays.filterNot(String::isBlank)
|
||||
if (dayIndexes.size == shortWeekDays.size) return updatesDaily
|
||||
val shortWeekDaysUpperCased = shortWeekDays.map {
|
||||
it.replaceFirstChar { char -> char.uppercase(locale) }
|
||||
}
|
||||
|
||||
val days = dayIndexes.joinToString { shortWeekDaysUpperCased[it] }
|
||||
return updatesEvery(days)
|
||||
}
|
||||
|
||||
private val Int.localized: String
|
||||
get() = numberFormat.format(this)
|
||||
|
||||
companion object {
|
||||
const val CHINESE = "zh"
|
||||
const val INDONESIAN = "id"
|
||||
const val SIMPLIFIED_CHINESE = "zh-Hans"
|
||||
const val SPANISH = "es"
|
||||
const val FRENCH = "fr"
|
||||
|
||||
@Suppress("UNUSED") // In BilibiliComics
|
||||
const val ENGLISH = "en"
|
||||
}
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Response
|
||||
|
||||
class BilibiliManga : Bilibili(
|
||||
"哔哩哔哩漫画",
|
||||
"https://manga.bilibili.com",
|
||||
BilibiliIntl.SIMPLIFIED_CHINESE,
|
||||
) {
|
||||
|
||||
override val id: Long = 3561131545129718586
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", DEFAULT_USER_AGENT)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = response.parseAs<BilibiliComicDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val data = result.data!!
|
||||
val id = data.id
|
||||
return data.episodeList.mapNotNull { episode ->
|
||||
if (episode.isInFree || !episode.isLocked) {
|
||||
chapterFromObject(episode, id)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val defaultPopularSort: Int = 0
|
||||
|
||||
override val defaultLatestSort: Int = 1
|
||||
|
||||
override fun getAllSortOptions(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag(intl.sortPopular, 0),
|
||||
BilibiliTag(intl.sortUpdated, 1),
|
||||
BilibiliTag(intl.sortFollowers, 2),
|
||||
BilibiliTag(intl.sortAdded, 3),
|
||||
)
|
||||
|
||||
override fun getAllPrices(): Array<String> =
|
||||
arrayOf(intl.priceAll, intl.priceFree, intl.pricePaid, intl.priceWaitForFree)
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("全部", -1),
|
||||
BilibiliTag("竞技", 1034),
|
||||
BilibiliTag("冒险", 1013),
|
||||
BilibiliTag("热血", 999),
|
||||
BilibiliTag("搞笑", 994),
|
||||
BilibiliTag("恋爱", 995),
|
||||
BilibiliTag("少女", 1026),
|
||||
BilibiliTag("日常", 1020),
|
||||
BilibiliTag("校园", 1001),
|
||||
BilibiliTag("治愈", 1007),
|
||||
BilibiliTag("古风", 997),
|
||||
BilibiliTag("玄幻", 1016),
|
||||
BilibiliTag("奇幻", 998),
|
||||
BilibiliTag("惊奇", 996),
|
||||
BilibiliTag("悬疑", 1023),
|
||||
BilibiliTag("都市", 1002),
|
||||
BilibiliTag("剧情", 1030),
|
||||
BilibiliTag("总裁", 1004),
|
||||
BilibiliTag("科幻", 1015),
|
||||
BilibiliTag("正能量", 1028),
|
||||
)
|
||||
|
||||
override fun getAllAreas(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("全部", -1),
|
||||
BilibiliTag("大陆", 1),
|
||||
BilibiliTag("日本", 2),
|
||||
BilibiliTag("韩国", 6),
|
||||
BilibiliTag("其他", 5),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts https://www.bilibilicomics.com/detail/xxx intents and redirects them to
|
||||
* the main tachiyomi process. The idea is to not install the intent filter unless
|
||||
* you have this extension installed, but still let the main tachiyomi app control
|
||||
* things.
|
||||
*
|
||||
* Main goal was to make it easier to open manga in Tachiyomi in spite of the DDoS blocking
|
||||
* the usual search screen from working.
|
||||
*/
|
||||
class BilibiliUrlActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
// Mobile site of https://manga.bilibili.com starts with path "m"
|
||||
val titleId = if (pathSegments[0] == "m") pathSegments[2] else pathSegments[1]
|
||||
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", Bilibili.PREFIX_ID_SEARCH + titleId)
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("BilibiliUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("BilibiliUrlActivity", "Could not parse URI from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".zh.kuaikanmanhua.KuaikanmanhuaUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:host="*.kuaikanmanhua.com" />
|
||||
<data android:host="kuaikanmanhua.com" />
|
||||
|
||||
<data
|
||||
android:host="m.kuaikanmanhua.com"
|
||||
android:pathPattern="/mobile/..*"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:pathPattern="/web/topic/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
@ -1,7 +0,0 @@
|
||||
ext {
|
||||
extName = 'Kuaikanmanhua'
|
||||
extClass = '.Kuaikanmanhua'
|
||||
extVersionCode = 10
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 22 KiB |
@ -1,285 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.kuaikanmanhua
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class Kuaikanmanhua : HttpSource() {
|
||||
|
||||
override val name = "快看漫画"
|
||||
|
||||
override val id: Long = 8099870292642776005
|
||||
|
||||
override val baseUrl = "https://www.kuaikanmanhua.com"
|
||||
|
||||
override val lang = "zh"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
private val apiUrl = "https://api.kkmh.com"
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$apiUrl/v1/topic_new/lists/get_by_tag?tag=0&since=${(page - 1) * 10}", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val body = response.body.string()
|
||||
val jsonList = json.parseToJsonElement(body).jsonObject["data"]!!
|
||||
.jsonObject["topics"]!!
|
||||
.jsonArray
|
||||
return parseMangaJsonArray(jsonList)
|
||||
}
|
||||
|
||||
private fun parseMangaJsonArray(jsonList: JsonArray, isSearch: Boolean = false): MangasPage {
|
||||
val mangaList = jsonList.map {
|
||||
val mangaObj = it.jsonObject
|
||||
|
||||
SManga.create().apply {
|
||||
title = mangaObj["title"]!!.jsonPrimitive.content
|
||||
thumbnail_url = mangaObj["vertical_image_url"]!!.jsonPrimitive.content
|
||||
url = "/web/topic/" + mangaObj["id"]!!.jsonPrimitive.int
|
||||
}
|
||||
}
|
||||
|
||||
// KKMH does not have pages when you search
|
||||
return MangasPage(mangaList, hasNextPage = mangaList.size > 9 && !isSearch)
|
||||
}
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$apiUrl/v1/topic_new/lists/get_by_tag?tag=19&since=${(page - 1) * 10}", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||
|
||||
// Search
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith(TOPIC_ID_SEARCH_PREFIX)) {
|
||||
val newQuery = query.removePrefix(TOPIC_ID_SEARCH_PREFIX)
|
||||
return client.newCall(GET("$apiUrl/v1/topics/$newQuery"))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/web/topic/$newQuery"
|
||||
MangasPage(listOf(details), false)
|
||||
}
|
||||
}
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return if (query.isNotEmpty()) {
|
||||
GET("$apiUrl/v1/search/topic?q=$query&size=18", headers)
|
||||
} else {
|
||||
lateinit var genre: String
|
||||
lateinit var status: String
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreFilter -> {
|
||||
genre = filter.toUriPart()
|
||||
}
|
||||
is StatusFilter -> {
|
||||
status = filter.toUriPart()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
GET("$apiUrl/v1/search/by_tag?since=${(page - 1) * 10}&tag=$genre&sort=1&query_category=%7B%22update_status%22:$status%7D")
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val body = response.body.string()
|
||||
val jsonObj = json.parseToJsonElement(body).jsonObject["data"]!!.jsonObject
|
||||
if (jsonObj["hit"] != null) {
|
||||
return parseMangaJsonArray(jsonObj["hit"]!!.jsonArray, true)
|
||||
}
|
||||
|
||||
return parseMangaJsonArray(jsonObj["topics"]!!.jsonArray, false)
|
||||
}
|
||||
|
||||
// Details
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
// Convert the stored url to one that works with the api
|
||||
val newUrl = apiUrl + "/v1/topics/" + manga.url.trimEnd('/').substringAfterLast("/")
|
||||
val response = client.newCall(GET(newUrl)).execute()
|
||||
val sManga = mangaDetailsParse(response).apply { initialized = true }
|
||||
return Observable.just(sManga)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
|
||||
val data = json.parseToJsonElement(response.body.string())
|
||||
.jsonObject["data"]!!
|
||||
.jsonObject
|
||||
|
||||
title = data["title"]!!.jsonPrimitive.content
|
||||
thumbnail_url = data["vertical_image_url"]!!.jsonPrimitive.content
|
||||
author = data["user"]!!.jsonObject["nickname"]!!.jsonPrimitive.content
|
||||
description = data["description"]!!.jsonPrimitive.content
|
||||
status = data["update_status_code"]!!.jsonPrimitive.int
|
||||
}
|
||||
|
||||
// Chapters & Pages
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
val newUrl = apiUrl + "/v1/topics/" + manga.url.trimEnd('/').substringAfterLast("/")
|
||||
val response = client.newCall(GET(newUrl)).execute()
|
||||
val chapters = chapterListParse(response)
|
||||
return Observable.just(chapters)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val data = json.parseToJsonElement(response.body.string())
|
||||
.jsonObject["data"]!!
|
||||
.jsonObject
|
||||
val chaptersJson = data["comics"]!!.jsonArray
|
||||
val chapters = mutableListOf<SChapter>()
|
||||
|
||||
for (i in 0 until chaptersJson.size) {
|
||||
val obj = chaptersJson[i].jsonObject
|
||||
chapters.add(
|
||||
SChapter.create().apply {
|
||||
url = "/web/comic/" + obj["id"]!!.jsonPrimitive.content
|
||||
name = obj["title"]!!.jsonPrimitive.content +
|
||||
if (!obj["can_view"]!!.jsonPrimitive.boolean) {
|
||||
" \uD83D\uDD12"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
date_upload = obj["created_at"]!!.jsonPrimitive.long * 1000
|
||||
},
|
||||
)
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
val request = client.newCall(pageListRequest(chapter)).execute()
|
||||
return Observable.just(pageListParse(request))
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
// if (chapter.name.endsWith("🔒")) {
|
||||
// throw Exception("[此章节为付费内容]")
|
||||
// }
|
||||
return GET(baseUrl + chapter.url)
|
||||
}
|
||||
|
||||
private val fixJson: (MatchResult) -> CharSequence = {
|
||||
match: MatchResult ->
|
||||
val str = match.value
|
||||
val out = str[0] + "\"" + str.subSequence(1, str.length - 1) + "\"" + str[str.length - 1]
|
||||
out
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
val script = document.selectFirst("script:containsData(comicImages)")!!.data()
|
||||
val images = script.substringAfter("comicImages:")
|
||||
.substringBefore(",is_vip_exclusive")
|
||||
.replace("""(:([^\[\{\"]+?)[\},])""".toRegex(), fixJson)
|
||||
.replace("""([,{]([^\[\{\"]+?)[\}:])""".toRegex(), fixJson)
|
||||
.let { json.parseToJsonElement(it).jsonArray }
|
||||
val variable = script.substringAfter("(function(")
|
||||
.substringBefore("){")
|
||||
.split(",")
|
||||
val value = script.substringAfterLast("}}(")
|
||||
.substringBefore("));")
|
||||
.split(",")
|
||||
|
||||
return images.mapIndexed { index, jsonEl ->
|
||||
val urlVar = jsonEl.jsonObject["url"]!!.jsonPrimitive.content
|
||||
val imageUrl = value[variable.indexOf(urlVar)]
|
||||
.replace("\\u002F", "/")
|
||||
.replace("\"", "")
|
||||
|
||||
Page(index, "", imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
Filter.Header("注意:不影響按標題搜索"),
|
||||
StatusFilter(),
|
||||
GenreFilter(),
|
||||
)
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
private class GenreFilter : UriPartFilter(
|
||||
"题材",
|
||||
arrayOf(
|
||||
Pair("全部", "0"),
|
||||
Pair("恋爱", "20"),
|
||||
Pair("古风", "46"),
|
||||
Pair("校园", "47"),
|
||||
Pair("奇幻", "22"),
|
||||
Pair("大女主", "77"),
|
||||
Pair("治愈", "27"),
|
||||
Pair("总裁", "52"),
|
||||
Pair("完结", "40"),
|
||||
Pair("唯美", "58"),
|
||||
Pair("日漫", "57"),
|
||||
Pair("韩漫", "60"),
|
||||
Pair("穿越", "80"),
|
||||
Pair("正能量", "54"),
|
||||
Pair("灵异", "32"),
|
||||
Pair("爆笑", "24"),
|
||||
Pair("都市", "48"),
|
||||
Pair("萌系", "62"),
|
||||
Pair("玄幻", "63"),
|
||||
Pair("日常", "19"),
|
||||
Pair("投稿", "76"),
|
||||
),
|
||||
)
|
||||
|
||||
private class StatusFilter : UriPartFilter(
|
||||
"类别",
|
||||
arrayOf(
|
||||
Pair("全部", "1"),
|
||||
Pair("连载中", "2"),
|
||||
Pair("已完结", "3"),
|
||||
),
|
||||
)
|
||||
|
||||
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TOPIC_ID_SEARCH_PREFIX = "topic:"
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.kuaikanmanhua
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class KuaikanmanhuaUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val host = intent?.data?.host
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val id = when (host) {
|
||||
"m.kuaikanmanhua.com" -> pathSegments[1]
|
||||
else -> pathSegments[2]
|
||||
}
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${Kuaikanmanhua.TOPIC_ID_SEARCH_PREFIX}$id")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("KkmhUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("KkmhUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|