Add BoyLove (#12398)
This commit is contained in:
parent
a72e10d6a2
commit
db917760eb
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -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 |
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
@ -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')
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue