diff --git a/src/zh/bilimanga/build.gradle b/src/zh/bilimanga/build.gradle new file mode 100644 index 000000000..edc296fcc --- /dev/null +++ b/src/zh/bilimanga/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'BiliManga' + extClass = '.BiliManga' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/bilimanga/res/mipmap-hdpi/ic_launcher.png b/src/zh/bilimanga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..bcf90b3aa Binary files /dev/null and b/src/zh/bilimanga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/bilimanga/res/mipmap-mdpi/ic_launcher.png b/src/zh/bilimanga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..f9b952e38 Binary files /dev/null and b/src/zh/bilimanga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/bilimanga/res/mipmap-xhdpi/ic_launcher.png b/src/zh/bilimanga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e399dcecd Binary files /dev/null and b/src/zh/bilimanga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/bilimanga/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/bilimanga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..9177b9ea5 Binary files /dev/null and b/src/zh/bilimanga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/bilimanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/bilimanga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..e8acafe96 Binary files /dev/null and b/src/zh/bilimanga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/BiliManga.kt b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/BiliManga.kt new file mode 100644 index 000000000..f29ddbf81 --- /dev/null +++ b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/BiliManga.kt @@ -0,0 +1,174 @@ +package eu.kanade.tachiyomi.extension.zh.bilimanga + +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.util.asJsoup +import keiyoushi.utils.getPreferencesLazy +import keiyoushi.utils.tryParse +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import java.text.SimpleDateFormat +import java.util.Locale + +class BiliManga : HttpSource(), ConfigurableSource { + + override val baseUrl = "https://www.bilimanga.net" + + override val lang = "zh" + + override val name = "Bilimanga.net" + + override val supportsLatest = true + + private val preferences by getPreferencesLazy() + + override val client = super.client.newBuilder() + .rateLimit(10, 10).addNetworkInterceptor(MangaInterceptor()).build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + .add("Accept-Language", "zh") + .add("Accept", "*/*") + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + preferencesInternal(screen.context).forEach(screen::addPreference) + } + + // Customize + + private val SManga.id get() = MANGA_ID_REGEX.find(url)!!.groups[1]!!.value + private fun String.toHalfWidthDigits(): String { + return this.map { if (it in '0'..'9') it - 65248 else it }.joinToString("") + } + + companion object { + const val PAGE_SIZE = 50 + val META_REGEX = Regex("連載|完結|收藏|推薦|热度") + val DATE_REGEX = Regex("\\d{4}-\\d{1,2}-\\d{1,2}") + val MANGA_ID_REGEX = Regex("/detail/(\\d+)\\.html") + val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.CHINESE) + } + + private fun getChapterUrlByContext(i: Int, els: Elements) = when (i) { + 0 -> "${els[1].attr("href")}#prev" + else -> "${els[i - 1].attr("href")}#next" + } + + // Popular Page + + override fun popularMangaRequest(page: Int): Request { + val suffix = preferences.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 { + val mangas = it.select(".book-layout").map { + SManga.create().apply { + setUrlWithoutDomain(it.absUrl("href")) + val img = it.selectFirst("img")!! + thumbnail_url = img.absUrl("data-src") + title = img.attr("alt") + } + } + MangasPage(mangas, mangas.size >= PAGE_SIZE) + } + + // Latest Page + + override fun latestUpdatesRequest(page: Int) = + GET("$baseUrl/top/lastupdate/$page.html", headers) + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + // Search Page + + override fun getFilterList() = buildFilterList() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder() + if (query.isNotBlank()) { + url.addPathSegment("search").addPathSegment("${query}_$page.html") + } else { + url.addPathSegment("top").addPathSegment(filters[1].toString()) + .addPathSegment("$page.html") + } + return GET(url.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + if (response.request.url.pathSegments.contains("detail")) { + return MangasPage(listOf(mangaDetailsParse(response)), false) + } + return popularMangaParse(response) + } + + // Manga Detail Page + + override fun mangaDetailsParse(response: Response) = SManga.create().apply { + val doc = response.asJsoup() + val meta = doc.selectFirst(".book-meta")!!.text().split("|") + val extra = meta.filterNot(META_REGEX::containsMatchIn) + val backupname = doc.selectFirst(".backupname")?.let { "漫畫別名:${it.text()}\n\n" } + url = doc.location() + title = doc.selectFirst(".book-title")!!.text() + thumbnail_url = doc.selectFirst(".book-cover")!!.attr("src") + description = backupname + doc.selectFirst("#bookSummary")?.text() + artist = doc.selectFirst(".authorname")?.text() + author = doc.selectFirst(".illname")?.text() ?: artist + status = when (meta.firstOrNull()) { + "連載" -> SManga.ONGOING + "完結" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + genre = (doc.select(".tag-small").map(Element::text) + extra).joinToString() + initialized = true + } + + // Catalog Page + + override fun chapterListRequest(manga: SManga) = + GET("$baseUrl/read/${manga.id}/catalog", headers) + + override fun chapterListParse(response: Response) = response.asJsoup().let { + val info = it.selectFirst(".chapter-sub-title")!!.text() + val date = DATE_FORMAT.tryParse(DATE_REGEX.find(info)?.value) + val elements = it.select(".chapter-li-a") + elements.mapIndexed { i, e -> + val url = e.absUrl("href").takeUnless("javascript:cid(1)"::equals) + SChapter.create().apply { + name = e.text().toHalfWidthDigits() + date_upload = date + setUrlWithoutDomain(url ?: getChapterUrlByContext(i, elements)) + } + }.reversed() + } + + // Manga View Page + + override fun pageListParse(response: Response) = response.asJsoup().let { + val images = it.select(".imagecontent") + check(images.size > 0) { + it.selectFirst("#acontentz")?.let { e -> + if ("電腦端" in e.text()) "章節不支持桌面電腦端瀏覽器顯示" else "漫畫可能已下架或需要登錄查看" + } ?: "章节鏈接错误" + } + images.mapIndexed { i, image -> + Page(i, imageUrl = image.attr("data-src")) + } + } + + // Image + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() +} diff --git a/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/Filters.kt b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/Filters.kt new file mode 100644 index 000000000..ad216b5e5 --- /dev/null +++ b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/Filters.kt @@ -0,0 +1,42 @@ +package eu.kanade.tachiyomi.extension.zh.bilimanga + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +fun buildFilterList() = FilterList( + Filter.Header("篩選條件(搜尋時無效)"), + RankFilter(), +) + +class RankFilter : Filter.Select( + "排行榜", + arrayOf( + "月點擊榜", + "周點擊榜", + "月推薦榜", + "周推薦榜", + "月鮮花榜", + "周鮮花榜", + "月雞蛋榜", + "周雞蛋榜", + "最新入庫", + "收藏榜", + "新書榜", + ), +) { + override fun toString(): String { + return arrayOf( + "monthvisit", + "weekvisit", + "monthvote", + "weekvote", + "monthflower", + "weekflower", + "monthegg", + "weekegg", + "postdate", + "goodnum", + "newhot", + )[state] + } +} diff --git a/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/MangaInterceptor.kt b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/MangaInterceptor.kt new file mode 100644 index 000000000..978cbc1c0 --- /dev/null +++ b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/MangaInterceptor.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.extension.zh.bilimanga + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import okio.GzipSource +import okio.buffer + +class MangaInterceptor : Interceptor { + + companion object { + val PREV_URL_REGEX = Regex("url_previous:'(.*?)'") + val NEXT_URL_REGEX = Regex("url_next:'(.*?)'") + val CHAPTER_ID_REGEX = Regex("/read/(\\d+)/(\\d+)\\.html") + } + + private fun regexOf(str: String?) = when (str) { + "prev" -> PREV_URL_REGEX + "next" -> NEXT_URL_REGEX + else -> null + } + + 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" + } + "next" -> { + val groups = CHAPTER_ID_REGEX.find(url.toString())?.groups + "/read/${groups?.get(1)?.value}/${groups?.get(2)?.value?.toInt()?.minus(1)}.html" + } + else -> "/read/0/0.html" + } + "?predict" + + override fun intercept(chain: Interceptor.Chain): Response { + val origin = chain.request() + regexOf(origin.url.fragment)?.let { + val response = chain.proceed(origin) + val html = GzipSource(response.body.source()).buffer().readUtf8() + val url = it.find(html)?.groups?.get(1)?.value?.plus("?match") + return response.newBuilder().code(302) + .header("Location", url ?: predictUrlByContext(origin.url)).build() + } + return chain.proceed(origin.newBuilder().addHeader("Cookie", "night=1").build()) + } +} diff --git a/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/Preferences.kt b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/Preferences.kt new file mode 100644 index 000000000..e763ba215 --- /dev/null +++ b/src/zh/bilimanga/src/eu/kanade/tachiyomi/extension/zh/bilimanga/Preferences.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.extension.zh.bilimanga + +import android.content.Context +import androidx.preference.ListPreference + +const val PREF_POPULAR_MANGA_DISPLAY = "POPULAR_MANGA_DISPLAY" + +fun preferencesInternal(context: Context) = arrayOf( + ListPreference(context).apply { + key = PREF_POPULAR_MANGA_DISPLAY + title = "熱門漫畫顯示内容" + summary = "%s" + entries = arrayOf( + "月点击榜", + "周点击榜", + "月推荐榜", + "周推荐榜", + "月鲜花榜", + "周鲜花榜", + "月鸡蛋榜", + "周鸡蛋榜", + "最新入库", + "收藏榜", + "新书榜", + ) + entryValues = arrayOf( + "/top/monthvisit/%d.html", + "/top/weekvisit/%d.html", + "/top/monthvote/%d.html", + "/top/weekvote/%d.html", + "/top/monthflower/%d.html", + "/top/weekflower/%d.html", + "/top/monthegg/%d.html", + "/top/weekegg/%d.html", + "/top/postdate/%d.html", + "/top/goodnum/%d.html", + "/top/newhot/%d.html", + ) + setDefaultValue("/top/weekvisit/%d.html") + }, +)