Add BoyLove (#12398)

This commit is contained in:
stevenyomi 2022-07-02 07:07:04 +08:00 committed by GitHub
parent a72e10d6a2
commit db917760eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 355 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,13 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'BoyLove'
pkgNameSuffix = 'zh.boylove'
extClass = '.BoyLove'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,162 @@
package eu.kanade.tachiyomi.extension.zh.boylove
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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 eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.concurrent.thread
// 支持站点,不要添加屏蔽广告选项,何况广告本来就不多
class BoyLove : HttpSource(), ConfigurableSource {
override val name = "香香腐宅"
override val lang = "zh"
override val supportsLatest = true
private val json: Json by injectLazy()
private val preferences: SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
override val baseUrl = "https://" + preferences.getString(MIRROR_PREF, "0")!!.toInt()
.coerceIn(0, MIRRORS.size - 1).let { MIRRORS[it] }
override val client = network.client.newBuilder()
.addInterceptor(NonblockingRateLimiter(2))
.build()
override fun popularMangaRequest(page: Int): Request =
GET("$baseUrl/home/api/getpage/tp/1-topest-${page - 1}", headers)
override fun popularMangaParse(response: Response): MangasPage {
val listPage: ListPageDto<MangaDto> = response.parseAs()
val mangas = listPage.list.map { it.toSManga() }
return MangasPage(mangas, !listPage.lastPage)
}
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/home/Api/getDailyUpdate.html?widx=4&page=${page - 1}&limit=10", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val mangas = response.parseAs<List<MangaDto>>().map { it.toSManga() }
return MangasPage(mangas, mangas.size >= 10)
}
private fun textSearchRequest(page: Int, query: String): Request =
GET("$baseUrl/home/api/searchk?keyword=$query&type=1&pageNo=$page", headers)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return if (query.isNotBlank()) {
textSearchRequest(page, query)
} else {
GET("$baseUrl/home/api/cate/tp/${parseFilters(page, filters)}", headers)
}
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
// for WebView
override fun mangaDetailsRequest(manga: SManga): Request =
GET("$baseUrl/home/book/index/id/${manga.url}")
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
client.newCall(textSearchRequest(1, manga.title)).asObservableSuccess().map { response ->
val id = manga.url.toInt()
response.parseAs<ListPageDto<MangaDto>>().list.find { it.id == id }!!.toSManga()
}
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException("Not used.")
override fun chapterListRequest(manga: SManga): Request =
GET("$baseUrl/home/api/chapter_list/tp/${manga.url}-0-0-10", headers)
override fun chapterListParse(response: Response): List<SChapter> =
response.parseAs<ListPageDto<ChapterDto>>().list.map { it.toSChapter() }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
chapter.url.substringAfter(':').ifEmpty {
return Observable.just(emptyList())
}.split(',').mapIndexed { i, url ->
Page(i, imageUrl = url.toImageUrl())
}.let { Observable.just(it) }
override fun pageListParse(response: Response) = throw UnsupportedOperationException("Not used.")
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
private inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromStream<ResultDto<T>>(body!!.byteStream()).result
}
private var genres: Array<String> = emptyArray()
private var isFetchingGenres = false
override fun getFilterList(): FilterList {
val genreFilter = if (genres.isEmpty()) {
if (!isFetchingGenres) fetchGenres()
Filter.Header("点击“重置”尝试刷新标签列表")
} else {
GenreFilter(genres)
}
return FilterList(
Filter.Header("分类筛选(搜索文本时无效)"),
StatusFilter(),
TypeFilter(),
genreFilter,
// SortFilter(), // useless
)
}
private fun fetchGenres() {
isFetchingGenres = true
thread {
try {
val request = client.newCall(GET("$baseUrl/home/book/cate.html", headers))
val document = request.execute().asJsoup()
genres = document.select("ul[data-str=tag] > li[class] > a")
.map { it.ownText() }.toTypedArray()
} catch (e: Throwable) {
isFetchingGenres = false
Log.e("BoyLove", "failed to fetch genres", e)
}
}
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = MIRROR_PREF
title = "镜像网址"
summary = "选择要使用的镜像网址,重启生效"
entries = MIRRORS_DESC
entryValues = MIRROR_INDICES
setDefaultValue("0")
}.let { screen.addPreference(it) }
}
companion object {
private const val MIRROR_PREF = "MIRROR"
// redirect URL: https://fuhouse.club/bl
private val MIRRORS = arrayOf("boylove.live", "boylove3.cc", "boylove.cc")
private val MIRRORS_DESC = arrayOf("boylove.live (大陆地区)", "boylove3.cc", "boylove.cc (非大陆地区)")
private val MIRROR_INDICES = arrayOf("0", "1", "2")
}
}

View File

@ -0,0 +1,71 @@
package eu.kanade.tachiyomi.extension.zh.boylove
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import org.jsoup.select.Evaluator
import java.text.SimpleDateFormat
import java.util.Locale
internal const val IMAGE_HOST = "https://blcnimghost1.cc" // 也有 2
@Serializable
class MangaDto(
val id: Int,
val title: String,
val image: String,
val auther: String,
val desc: String,
val mhstatus: Int,
val keyword: String,
) {
fun toSManga() = SManga.create().apply {
url = id.toString()
title = this@MangaDto.title
author = auther
description = desc
genre = keyword.replace(",", ", ")
status = when (mhstatus) {
0 -> SManga.ONGOING
1 -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
thumbnail_url = image.toImageUrl()
initialized = true
}
}
fun String.toImageUrl() = if (startsWith("http")) this else "$IMAGE_HOST$this"
@Serializable
class ChapterDto(
val id: Int,
val title: String,
val manhua_id: Int,
val update_time: String,
val content: String?,
val imagelist: String,
) {
fun toSChapter() = SChapter.create().apply {
url = "$manhua_id/$id:${getImages()}"
name = title.trim()
date_upload = dateFormat.parse(update_time)?.time ?: 0
}
private fun getImages(): String {
return imagelist.ifEmpty {
if (content == null) return ""
Jsoup.parse(content).select(Evaluator.Tag("img"))
.joinToString(",") { it.attr("src") }
}
}
}
private val dateFormat by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) }
@Serializable
class ListPageDto<T>(val lastPage: Boolean, val list: List<T>)
@Serializable
class ResultDto<T>(val result: T)

View File

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.extension.zh.boylove
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
/*
* 1-0-2-1-1-0-1-0
* [0] cate, useless
* [1] tag(genre), 0=all
* [2] done(status), 2=all, 0=ongoing, 1=completed
* [3] order(sort), 1=normal
* [4] page, index from 1
* [5] type, 0=all, 1=清水, 2=有肉
* [6] 1=manga, 2=novel, else=manga
* [7] vip, 0=default, useless
*/
internal fun parseFilters(page: Int, filters: FilterList): String {
var status = '2'
var type = '0'
var genre = "0"
var sort = '1'
for (filter in filters) {
when (filter) {
is StatusFilter -> status = STATUS_KEYS[filter.state]
is TypeFilter -> type = TYPE_KEYS[filter.state]
is GenreFilter -> if (filter.state > 0) genre = filter.values[filter.state]
is SortFilter -> sort = SORT_KEYS[filter.state]
else -> {}
}
}
return "1-$genre-$status-$sort-$page-$type-1-0"
}
internal class StatusFilter : Filter.Select<String>("状态", STATUS_NAMES)
private val STATUS_NAMES = arrayOf("全部", "连载中", "已完结")
private val STATUS_KEYS = arrayOf('2', '0', '1')
internal class TypeFilter : Filter.Select<String>("类型", TYPE_NAMES)
private val TYPE_NAMES = arrayOf("全部", "清水", "有肉")
private val TYPE_KEYS = arrayOf('0', '1', '2')
internal class GenreFilter(names: Array<String>) : Filter.Select<String>("标签", names)
internal class SortFilter : Filter.Select<String>("排序", SORT_NAMES)
private val SORT_NAMES = arrayOf("顺序", "类似排行榜")
private val SORT_KEYS = arrayOf('1', '2')

View File

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.extension.zh.boylove
import android.os.SystemClock
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
// See https://github.com/tachiyomiorg/tachiyomi/pull/7389
internal class NonblockingRateLimiter(
private val permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS,
) : Interceptor {
private val requestQueue = ArrayList<Long>(permits)
private val rateLimitMillis = unit.toMillis(period)
override fun intercept(chain: Interceptor.Chain): Response {
// Ignore canceled calls, otherwise they would jam the queue
if (chain.call().isCanceled()) {
throw IOException()
}
synchronized(requestQueue) {
val now = SystemClock.elapsedRealtime()
val waitTime = if (requestQueue.size < permits) {
0
} else {
val oldestReq = requestQueue[0]
val newestReq = requestQueue[permits - 1]
if (newestReq - oldestReq > rateLimitMillis) {
0
} else {
oldestReq + rateLimitMillis - now // Remaining time
}
}
// Final check
if (chain.call().isCanceled()) {
throw IOException()
}
if (requestQueue.size == permits) {
requestQueue.removeAt(0)
}
if (waitTime > 0) {
requestQueue.add(now + waitTime)
Thread.sleep(waitTime) // Sleep inside synchronized to pause queued requests
} else {
requestQueue.add(now)
}
}
return chain.proceed(chain.request())
}
}