feat(src/zh): Add New source Xfani (#114)

This commit is contained in:
Dark25 2024-10-18 22:27:03 +02:00 committed by GitHub
parent f4517efa8d
commit 0eb277f0d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 340 additions and 0 deletions

View file

@ -0,0 +1,7 @@
ext {
extName = 'Xfani'
extClass = '.Xfani'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
abstract class SelectFilter(name: String, private val options: Array<Pair<String, String>>) :
AnimeFilter.Select<String>(name, options.map { it.first }.toTypedArray()) {
val selected
get() = options[state].second
}
abstract class TagFilter(name: String, values: Array<String>) :
SelectFilter(
name,
values.mapIndexed { index, s ->
if (index == 0) {
s to ""
} else {
s to s
}
}.toTypedArray(),
)
class TypeFilter(
kv: Array<Pair<String, String>> = arrayOf(
"连载新番" to "1",
"完结旧番" to "2",
"剧场版" to "3",
),
) : SelectFilter("频道", kv)
class ClassFilter(
tags: Array<String> = arrayOf(
"全部",
"搞笑",
"原创",
"轻小说改",
"恋爱",
"百合",
"漫改",
),
) : TagFilter("类型", tags)
class VersionFilter(
tags: Array<String> = arrayOf(
"全部",
"BD",
"OVA",
"SP",
"OAD",
),
) : TagFilter("版本", tags)
class LetterFilter(
tags: Array<String> = "ABCDEFGHIJKLMNOPQRSTUYWXYZ".map { it.toString() }.toMutableList()
.also { it.add("0-9") }.toTypedArray(),
) : TagFilter("字母", tags)
class SortFilter(
kv: Array<Pair<String, String>> = arrayOf(
"按最新" to "time",
"按热门" to "hits",
"按评分" to "score",
),
) : SelectFilter("排序", kv)

View file

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani
import java.security.MessageDigest
private const val UID = "DCC147D11943AF75"
internal fun generateKey(time: Long): String {
return "DS${time}$UID".md5()
}
internal fun String.md5(): String {
val md = MessageDigest.getInstance("MD5")
val digest = md.digest(this.toByteArray())
return digest.joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
}

View file

@ -0,0 +1,212 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class Xfani : AnimeHttpSource(), ConfigurableAnimeSource {
override val baseUrl: String
get() = "https://dick.xfani.com"
override val lang: String
get() = "zh"
override val name: String
get() = "稀饭动漫"
override val supportsLatest: Boolean
get() = true
private val json by injectLazy<Json>()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val numberRegex = Regex("\\d+")
private val selectedVideoSource
get() = preferences.getString(PREF_KEY_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE)!!.toInt()
override fun animeDetailsParse(response: Response): SAnime = SAnime.create()
override fun episodeListParse(response: Response): List<SEpisode> {
val jsoup = response.asJsoup()
val result = jsoup.select("ul.anthology-list-play.size")
val episodeList = if (result.size > selectedVideoSource) {
result[selectedVideoSource]
} else {
result[0]
}.select("li > a")
return episodeList.map {
SEpisode.create().apply {
name = it.text()
url = it.attr("href")
episode_number = numberRegex.find(name)?.value?.toFloat() ?: -1F
}
}
}
override fun videoListParse(response: Response): List<Video> {
val script = response.asJsoup().select("script:containsData(player_aaaa)").first()!!.data()
val info = script.substringAfter("player_aaaa=").let { json.parseToJsonElement(it) }
val url = info.jsonObject["url"]!!.jsonPrimitive.content
return listOf(Video(url, "SingleFile", videoUrl = url))
}
override fun latestUpdatesParse(response: Response): AnimesPage {
return vodListToAnimePageList(response)
}
override fun latestUpdatesRequest(page: Int): Request =
searchAnimeRequest(page, "", AnimeFilterList())
override fun popularAnimeParse(response: Response): AnimesPage {
return vodListToAnimePageList(response)
}
override fun popularAnimeRequest(page: Int): Request =
searchAnimeRequest(page, "", AnimeFilterList(SortFilter().apply { state = 1 }))
private fun vodListToAnimePageList(response: Response): AnimesPage {
val vodResponse = json.decodeFromString<VodResponse>(response.body.string())
val animeList = vodResponse.list.map {
SAnime.create().apply {
url = "/bangumi/${it.vodId}.html"
thumbnail_url = it.vodPicThumb.ifEmpty { it.vodPic }
title = it.vodName
author = it.vodActor
description = it.vodBlurb
genre = it.vodClass
}
}
return AnimesPage(
animeList,
animeList.isNotEmpty() && vodResponse.page * vodResponse.limit < vodResponse.total,
)
}
override fun searchAnimeParse(response: Response): AnimesPage {
val jsoup = response.asJsoup()
val items = jsoup.select("div.public-list-box.search-box.flex.rel")
val animeList = items.map { item ->
SAnime.create().apply {
title = item.select(".thumb-txt").text()
url = item.select("div.left.public-list-bj a.public-list-exp").attr("href")
thumbnail_url =
item.select("div.left.public-list-bj img[data-src]").attr("data-src")
author = item.select("div.thumb-actor").text().removeSuffix("/")
artist = item.select("div.thumb-director").text().removeSuffix("/")
description = item.select(".thumb-blurb").text()
genre = item.select("div.thumb-else").text()
val statusString = item.select("div.left.public-list-bj .public-list-prb").text()
status = STATUS_STR_MAPPING.getOrElse(statusString) { SAnime.ONGOING }
}
}
val tip = jsoup.select("div.pages div.page-tip").text()
return AnimesPage(animeList, tip.isNotEmpty() && hasMorePage(tip))
}
private fun hasMorePage(tip: String): Boolean {
val pageIndicator = tip.substringAfter("当前").substringBefore("")
val numbers = pageIndicator.split("/")
return numbers.size == 2 && numbers[0] != numbers[1]
}
override fun getFilterList(): AnimeFilterList {
return AnimeFilterList(
AnimeFilter.Header("设置筛选后搜索关键字搜索会失效"),
TypeFilter(),
ClassFilter(),
VersionFilter(),
LetterFilter(),
SortFilter(),
)
}
private fun doSearch(page: Int, query: String): Request {
val url = baseUrl.toHttpUrl().newBuilder()
if (page <= 1) {
url.addPathSegment("search.html")
.addQueryParameter("wd", query)
} else {
url.addPathSegments("search/wd/")
.addPathSegment(query)
.addPathSegments("page/$page.html")
}
return GET(url.build())
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
if (query.isNotBlank()) {
return doSearch(page, query)
}
val url = baseUrl.toHttpUrl().newBuilder()
.addPathSegments("index.php/api/vod")
.build()
val time = System.currentTimeMillis() / 1000
val formBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("page", "$page")
.addFormDataPart("time", "$time")
.addFormDataPart("key", generateKey(time))
filters.forEach { filter ->
when (filter) {
is TypeFilter -> formBody.addFormDataPart("type", filter.selected)
is ClassFilter -> formBody.addFormDataPart("class", filter.selected)
is VersionFilter -> formBody.addFormDataPart("version", filter.selected)
is LetterFilter -> formBody.addFormDataPart("letter", filter.selected)
is SortFilter -> formBody.addFormDataPart("by", filter.selected)
else -> {}
}
}
if (filters.filterIsInstance<TypeFilter>().isEmpty()) {
formBody.addFormDataPart("type", "1")
}
return POST(url.toString(), body = formBody.build())
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
screen.addPreference(
ListPreference(screen.context).apply {
key = PREF_KEY_VIDEO_SOURCE
title = "请设置首选视频源线路"
entries = arrayOf("主线-1", "主线-2", "备用-1")
entryValues = arrayOf("0", "1", "2")
setDefaultValue(DEFAULT_VIDEO_SOURCE)
summary = "当前选择:${entries[selectedVideoSource]}"
setOnPreferenceChangeListener { _, newValue ->
summary = "当前选择 ${entries[(newValue as String).toInt()]}"
true
}
},
)
}
companion object {
const val PREF_KEY_VIDEO_SOURCE = "PREF_KEY_VIDEO_SOURCE"
const val DEFAULT_VIDEO_SOURCE = "0"
val STATUS_STR_MAPPING = mapOf(
"已完结" to SAnime.COMPLETED,
)
}
}

View file

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VodInfo(
@SerialName("vod_id")
val vodId: Int,
@SerialName("vod_level")
val vodLevel: Int = 0,
@SerialName("vod_name")
val vodName: String,
@SerialName("vod_pic")
val vodPic: String,
@SerialName("vod_pic_thumb")
val vodPicThumb: String = "",
@SerialName("vod_tag")
val vodTag: String = "",
@SerialName("vod_class")
val vodClass: String,
@SerialName("vod_remarks")
val vodRemarks: String,
@SerialName("vod_serial")
val vodSerial: String,
@SerialName("vod_sub")
val vodSub: String,
@SerialName("vod_actor")
val vodActor: String,
@SerialName("vod_blurb")
val vodBlurb: String,
)
@Serializable
data class VodResponse(
val page: Int,
@SerialName("pagecount")
val pageCount: Int,
val limit: Int,
val total: Int,
val list: List<VodInfo>,
)