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