Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/zh/bilimanga/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ plugins {

keiyoushi {
name = "BiliManga"
versionCode = 11
versionCode = 12
contentWarning = ContentWarning.MIXED
libVersion = "1.4"
libVersion = "1.6"

source {
name = "嗶哩漫畫"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
package eu.kanade.tachiyomi.extension.zh.bilimanga

import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
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 eu.kanade.tachiyomi.source.model.SMangaUpdate
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.annotation.Source
import keiyoushi.network.get
import keiyoushi.network.rateLimit
import keiyoushi.source.KeiSource
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.tryParse
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import kotlinx.serialization.json.JsonElement
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.OkHttpClient
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
Expand All @@ -26,26 +33,25 @@ import kotlin.time.Duration.Companion.seconds

@Source
abstract class BiliManga :
HttpSource(),
KeiSource(),
ConfigurableSource {

override val supportsLatest = true

private val pref by getPreferencesLazy()

override fun setupPreferenceScreen(screen: PreferenceScreen) {
preferencesInternal(screen.context, pref).forEach(screen::addPreference)
}

override val client = super.client.newBuilder()
.addNetworkInterceptor(MangaInterceptor())
.also {
val split = pref.getString(PREF_RATE_LIMIT, "10/10")!!.split("/")
it.rateLimit(split[0].toInt(), split[1].toInt().seconds)
}
.build()
override fun OkHttpClient.Builder.configureClient() = apply {
addInterceptor(ChapterInterceptor())
val split = pref.getString(PREF_RATE_LIMIT, "10/10")!!.split("/")
rateLimit(split[0].toInt(), split[1].toInt().seconds)
}

override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/").add("Accept-Language", "zh").add("Accept", "*/*")
override fun Headers.Builder.configureHeaders() = apply {
add("Accept-Language", "zh")
add("Accept", "*/*")
}

// Customize

Expand Down Expand Up @@ -108,14 +114,7 @@ abstract class BiliManga :
else -> "${els[i - 1].attr("href")}#next"
}

// Popular Page

override fun popularMangaRequest(page: Int): Request {
val suffix = pref.getString(PREF_POPULAR_MANGA_DISPLAY, "/top/weekvisit/%d.html")!!
return GET(baseUrl + String.format(suffix, page), headers)
}

override fun popularMangaParse(response: Response) = response.asJsoup().let { doc ->
private fun mangaPageParse(response: Response) = response.asJsoup().let { doc ->
val mangas = doc.select(".book-layout").map {
SManga.create().apply {
setUrlWithoutDomain(it.absUrl("href"))
Expand Down Expand Up @@ -143,89 +142,122 @@ abstract class BiliManga :
MangasPage(mangas, hasNextPage)
}

// Latest Page
private fun Response.parseManga() = SManga.create().apply {
val doc = asJsoup()
doc.selectFirst(".aui-ver-form")?.let { throw Exception(it.text()) }
val meta = doc.select(".book-meta em").map(Element::text)
val (main, extra) = meta.partition(META_REGEX::containsMatchIn)
setUrlWithoutDomain(doc.location())
title = doc.selectFirst(".book-title")!!.text()
thumbnail_url = doc.selectFirst(".book-cover")!!.attr("src")
description = buildDescription(doc)
artist = doc.selectFirst(".authorname")?.text()
author = doc.selectFirst(".illname")?.text() ?: artist
status = when (main.lastOrNull()) {
"連載中" -> SManga.ONGOING
"已完結" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
genre = (doc.select(".tag-small").map(Element::text) + extra).joinToString()
initialized = true
}

// Popular Page

override suspend fun getPopularManga(page: Int): MangasPage {
val suffix = pref.getString(PREF_POPULAR_MANGA_DISPLAY, "/top/weekvisit/%d.html")!!
return mangaPageParse(client.get(baseUrl + String.format(suffix, page)))
}

override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/top/lastupdate/$page.html", headers)
// Latest Page

override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
override suspend fun getLatestUpdates(page: Int): MangasPage = mangaPageParse(client.get("$baseUrl/top/lastupdate/$page.html"))

// Search Page

override fun getFilterList() = buildFilterList()
override fun getFilterList(data: JsonElement?) = buildFilterList()

override suspend fun getMangaByUrl(url: HttpUrl): SManga? {
if (!MANGA_ID_REGEX.matches(url.encodedPath)) return null
return client.get(url).parseManga()
}

// /lastupdate_1_1_1_1_1_1_1_1_0_2026_2027.html
// /${Sort}_${Theme}_${Status}_${Anime}_${Region}_${Type}_${Time}_${Novel}_${page}_0_${Year}_${Award}.html
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.startsWith(baseUrl) && MANGA_ID_REGEX.matches(query.substring(baseUrl.length))) return GET(query, headers)
override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage {
val url = baseUrl.toHttpUrl().newBuilder()
if (query.isNotBlank()) {
url.addPathSegment("search").addPathSegment("${query}_$page.html")
} else {
url.addPathSegment("filter")
.addPathSegment("${filters[5]}_${filters[1]}_${filters[9]}_${filters[6]}_${filters[3]}_${filters[2]}_${filters[10]}_${filters[7]}_${page}_0_${filters[4]}_${filters[8]}.html")
}
return GET(url.build(), headers)
}

override fun searchMangaParse(response: Response): MangasPage {
val response = client.get(url.build())
if (response.request.url.pathSegments.contains("detail")) {
return MangasPage(listOf(mangaDetailsParse(response)), false)
return MangasPage(listOf(response.parseManga()), false)
}
return popularMangaParse(response)
return mangaPageParse(response)
}

// Manga Detail Page

override fun mangaDetailsParse(response: Response) = SManga.create().apply {
val doc = response.asJsoup()
doc.selectFirst(".aui-ver-form")?.let { throw Exception(it.text()) }
val meta = doc.select(".book-meta em").map(Element::text)
val (main, extra) = meta.partition(META_REGEX::containsMatchIn)
setUrlWithoutDomain(doc.location())
title = doc.selectFirst(".book-title")!!.text()
thumbnail_url = doc.selectFirst(".book-cover")!!.attr("src")
description = buildDescription(doc)
artist = doc.selectFirst(".authorname")?.text()
author = doc.selectFirst(".illname")?.text() ?: artist
status = when (main.lastOrNull()) {
"連載中" -> SManga.ONGOING
"已完結" -> SManga.COMPLETED
else -> SManga.UNKNOWN
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url

override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url

override suspend fun fetchMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
) = supervisorScope {
val asyncManga = if (fetchDetails) {
async { client.get(baseUrl + manga.url).parseManga() }
} else {
CompletableDeferred(manga)
}
genre = (doc.select(".tag-small").map(Element::text) + extra).joinToString()
initialized = true
}

// Catalog Page
val asyncChapters = if (fetchChapters) {
async {
val doc = client.get("$baseUrl/read/${manga.id}/catalog").asJsoup()
val info = doc.selectFirst(".chapter-sub-title")!!.text()
val title = doc.selectFirst(".book-title")!!.text()
val date = DATE_FORMAT.tryParse(DATE_REGEX.find(info)?.value)
doc.select(".catalog-volume").takeIf(Elements::isNotEmpty)?.flatMap {
val bar = it.selectFirst(".chapter-bar")!!.text().substring(title.length + 1)
val volume = if (bar.first().isDigit()) "Vol.$bar" else bar.toHalfWidthDigits()
it.select(".chapter-li-a").mapToChapter(date, volume)
}?.reversed() ?: doc.select(".chapter-li-a").mapToChapter(date).reversed()
}
} else {
CompletableDeferred(chapters)
}

override fun chapterListRequest(manga: SManga) = GET("$baseUrl/read/${manga.id}/catalog", headers)
SMangaUpdate(asyncManga.await(), asyncChapters.await())
}

override fun chapterListParse(response: Response) = response.asJsoup().let { resp ->
val info = resp.selectFirst(".chapter-sub-title")!!.text()
val title = resp.selectFirst(".book-title")!!.text()
val date = DATE_FORMAT.tryParse(DATE_REGEX.find(info)?.value)
resp.select(".catalog-volume").takeIf(Elements::isNotEmpty)?.flatMap {
val bar = it.selectFirst(".chapter-bar")!!.text().substring(title.length + 1)
val volume = if (bar.first().isDigit()) "Vol.$bar" else bar.toHalfWidthDigits()
it.select(".chapter-li-a").mapToChapter(date, volume)
} ?: resp.select(".chapter-li-a").mapToChapter(date)
}.reversed()
override suspend fun fetchRelatedMangaList(manga: SManga): List<SManga> {
val doc = client.get(getMangaUrl(manga)).asJsoup()
return doc.select("#book-friend-list-container").last()?.select(".module-slide-a")?.map {
SManga.create().apply {
setUrlWithoutDomain(it.absUrl("href"))
title = it.selectFirst(".module-slide-caption")!!.text()
thumbnail_url = it.selectFirst(".module-slide-img")!!.attr("data-src")
}
} ?: emptyList()
}

// Manga View Page

override fun pageListParse(response: Response) = response.asJsoup().let { doc ->
val images = doc.select(".imagecontent")
check(images.isNotEmpty()) {
doc.selectFirst("#acontentz")?.let {
if ("電腦端" in it.text()) "不支持電腦端查看,請在高級設置中更換移動端UA標識" else "漫畫可能已下架或需要足夠的權限"
} ?: "章节鏈接错误"
}
images.mapIndexed { i, image ->
Page(i, imageUrl = image.attr("data-src"))
override suspend fun getPageList(chapter: SChapter): List<Page> {
val response = client.get(baseUrl + chapter.url)
return response.asJsoup().let { doc ->
val images = doc.select(".imagecontent")
check(images.isNotEmpty()) {
doc.selectFirst("#acontentz")?.let {
if ("電腦端" in it.text()) "不支持電腦端查看,請在高級設置中更換移動端UA標識" else "漫畫可能已下架或需要足夠的權限"
} ?: "章节鏈接错误"
}
images.mapIndexed { i, img -> Page(i, imageUrl = img.attr("data-src")) }
}
}

// Image

override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response

class MangaInterceptor : Interceptor {
class ChapterInterceptor : Interceptor {

companion object {
val PREV_URL_REGEX = Regex("url_previous:'(.*?)'")
Expand All @@ -21,12 +21,12 @@ class MangaInterceptor : Interceptor {
private fun predictUrlByContext(url: HttpUrl) = when (url.fragment) {
"prev" -> {
val groups = CHAPTER_ID_REGEX.find(url.toString())?.groups
"/read/${groups?.get(1)?.value}/${groups?.get(2)?.value?.toInt()?.plus(1)}.html"
"/read/${groups?.get(1)?.value}/${groups?.get(2)?.value?.toInt()?.minus(1)}.html"
}

"next" -> {
val groups = CHAPTER_ID_REGEX.find(url.toString())?.groups
"/read/${groups?.get(1)?.value}/${groups?.get(2)?.value?.toInt()?.minus(1)}.html"
"/read/${groups?.get(1)?.value}/${groups?.get(2)?.value?.toInt()?.plus(1)}.html"
}

else -> "/read/0/0.html"
Expand All @@ -35,10 +35,10 @@ class MangaInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val origin = chain.request()
regexOf(origin.url.fragment)?.let {
val response = chain.proceed(origin.newBuilder().removeHeader("Accept-Encoding").build())
val url = it.find(response.body.string())?.groups?.get(1)?.value
return response.newBuilder().code(302)
.header("Location", url ?: predictUrlByContext(origin.url)).build()
val response = chain.proceed(origin)
val path = it.find(response.body.string())?.groups?.get(1)?.value ?: predictUrlByContext(origin.url)
val url = origin.url.newBuilder().encodedPath(path.replace(".", "_2.")).build()
return chain.proceed(origin.newBuilder().url(url).build())
}
return chain.proceed(origin.newBuilder().addHeader("Cookie", "night=1").build())
}
Expand Down