Yidan: Rewrite and rework the extension. (#8091)

* Yidan: Rewrite and rework the extension.

* Yidan: Remove logs.

* Yidan: Lint?

* Yidan: Apply review suggestions

Co-Authored-By: Vetle Ledaal <13540478+vetleledaal@users.noreply.github.com>

* Apply review suggestions

Co-Authored-By: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: Vetle Ledaal <13540478+vetleledaal@users.noreply.github.com>
Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
AlphaBoom 2025-03-18 21:31:19 +09:00 committed by Draff
parent 98f7d48324
commit 99f58ad3f1
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
4 changed files with 422 additions and 183 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Yidan Girl'
extClass = '.Yidan'
extVersionCode = 4
extVersionCode = 5
isNsfw = true
}

View File

@ -1,62 +1,90 @@
package eu.kanade.tachiyomi.extension.zh.yidan
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jsoup.nodes.Entities
@Serializable
class MangaDto(
private val title: String,
private val mhcate: String?,
private val cateids: String?,
private val author: String?,
private val summary: String?,
private val coverPic: String?,
private val id: Int,
class ComicFetchRequest(
val column: String,
val page: Int,
val limit: Int,
)
@Serializable
class ComicDetailRequest(
val comicId: String,
val userId: String,
val limit: Int = 5,
)
@Serializable
class ChapterContentRequest(
val chapterId: String,
val userId: String,
val type: Int = 1,
)
@Serializable
class KeywordSearchRequest(
val key: String,
val type: Int = 1,
)
@Serializable
class FilterRequest(
val page: Int,
val limit: Int,
val categoryId: Int,
val orderType: Int,
val overType: Int,
) {
fun toSManga(baseUrl: String) = SManga.create().apply {
url = id.toString()
title = this@MangaDto.title
author = this@MangaDto.author
description = summary?.trim()
genre = when {
cateids.isNullOrEmpty() -> null
else -> cateids.split(",").joinToString { GENRES[it.toInt()] }
}
status = when {
mhcate.isNullOrEmpty() -> SManga.ONGOING
"5" in mhcate.split(",") -> SManga.COMPLETED
else -> SManga.ONGOING
}
thumbnail_url = if (coverPic?.startsWith("http") == true) coverPic else baseUrl + coverPic
initialized = true
@SerialName("updated_recent")
val updatedRecent: Int? = if (orderType == 3) {
1
} else {
null
}
}
@Serializable
class ChapterDto(
private val createTime: Long,
private val mhid: String,
private val title: String,
private val jiNo: Int,
) {
fun toSChapter() = SChapter.create().apply {
url = "$mhid/$jiNo"
name = Entities.unescape(title)
date_upload = createTime * 1000L
}
}
class CommonResponse<T>(val result: T)
@Serializable
class PageListDto(private val pics: String) {
val images get() = pics.split(",")
}
class RecordResult(val records: List<Record>, val total: Int)
@Serializable
class ListingDto(val list: List<MangaDto>, private val total: String) {
val totalCount get() = total.toInt()
}
class FilterResult(val list: List<Record>, val total: Int)
@Serializable
class ResponseDto<T>(val data: T)
class Record(
val id: Long,
val novelTitle: String,
val imgUrl: String,
)
@Serializable
class ComicInfoResult(val comic: Comic, val chapterList: List<Chapter>)
@Serializable
class Comic(
val id: Long,
val novelTitle: String,
val author: String,
val tags: String,
val bigImgUrl: String,
val introduction: String,
val overType: Int,
)
@Serializable
class Chapter(
val id: Long,
val chapterName: String,
val createTime: String,
)
@Serializable
class ChapterContentResult(val content: List<Content>)
@Serializable
class Content(val url: String)

View File

@ -1,54 +1,54 @@
package eu.kanade.tachiyomi.extension.zh.yidan
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import okhttp3.HttpUrl
fun getFilterListInternal() = FilterList(ListingFilter(), GenreFilter())
fun parseFilters(filters: FilterList, builder: HttpUrl.Builder) {
for (filter in filters) when (filter) {
is ListingFilter -> {
if (filter.state > 0) {
builder.addEncodedQueryParameter("mhcate", LISTING_VALUES[filter.state].toString())
}
}
is GenreFilter -> {
if (filter.state > 0) {
builder.addEncodedQueryParameter("cateid", String.format("%02d", filter.state))
}
}
else -> {}
}
open class PairFilter(name: String, private val pairs: List<Pair<String, Int>>) :
Filter.Select<String>(name, pairs.map { it.first }.toTypedArray()) {
val selected: Int
get() = pairs[state].second
}
class ListingFilter : Filter.Select<String>("分类", LISTINGS)
val LISTINGS = arrayOf("全部", "排行榜", "新作", "完结漫", "分类0", "分类1", "分类3", "分类7")
val LISTING_VALUES = arrayOf(0, 2, 4, 5, 0, 1, 3, 7)
class GenreFilter : Filter.Select<String>("标签", GENRES)
val GENRES = arrayOf(
"全部",
"短漫", // 01
"甜漫", // 02
"强强", // 03
"年下攻", // 04
"诱受", // 05
"骨科", // 06
"调教", // 07
"健气受", // 08
"ABO", // 09
"重生/重逢", // 10
"财阀", // 11
"校园", // 12
"女王受", // 13
"NP/SM", // 14
"韩国榜单", // 15
"高H", // 16
"架空", // 17
"娱乐圈", // 18
"办公室", // 19
"青梅竹马", // 20
class CategoryFilter : PairFilter(
"分类",
listOf(
"青梅竹马" to 18,
"办公室" to 19,
"娱乐圈" to 20,
"高H" to 21,
"韩国版单" to 22,
"NP/SM" to 23,
"校园" to 24,
"财阀" to 25,
"重生/重逢" to 26,
"ABO" to 27,
"调教" to 28,
"骨科" to 29,
"诱受" to 30,
"年下攻" to 31,
"强强" to 32,
"甜漫" to 33,
"短漫" to 34,
"女王受" to 35,
"健气受" to 36,
"架空" to 37,
),
)
class StatusFilter : PairFilter(
"状态",
listOf(
"全部" to 0,
"连载" to 1,
"完结" to 2,
),
)
class SortFilter : PairFilter(
"排序",
listOf(
"最新上架" to 0,
"推荐" to 1,
"一周人气" to 2,
"最近更新" to 3,
),
)

View File

@ -1,9 +1,19 @@
package eu.kanade.tachiyomi.extension.zh.yidan
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
@ -11,125 +21,326 @@ 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 keiyoushi.utils.firstInstance
import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import okhttp3.ResponseBody.Companion.asResponseBody
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class Yidan : HttpSource(), ConfigurableSource {
override val name get() = "一耽女孩"
override val lang get() = "zh"
override val supportsLatest get() = true
override val baseUrl: String
init {
val mirrors = MIRRORS
val index = getPreferences()
.getString(MIRROR_PREF, "0")!!.toInt().coerceAtMost(mirrors.size - 1)
baseUrl = "https://" + mirrors[index]
private val apiUrl = "https://yd-api.hangtech.cn"
override val baseUrl: String = getPreferences().run {
val customBaseUrl = getString(PREF_KEY_CUSTOM_HOST, "")
if (customBaseUrl.isNullOrEmpty()) {
val mirrors = MIRRORS
val index = getPreferences()
.getString(PREF_KEY_MIRROR, "0")!!.toInt().coerceAtMost(mirrors.size - 1)
"https://${mirrors[index]}"
} else {
customBaseUrl.removeSuffix("/")
}
}
override fun headersBuilder() = Headers.Builder()
.add("User-Agent", System.getProperty("http.agent")!!)
override val client: OkHttpClient = network.cloudflareClient.newBuilder().addInterceptor { chain ->
val request = chain.request()
val response = chain.proceed(request)
val requestUrl = request.url.toString()
if (requestUrl.contains("images/mhtp/yidan")) {
// remove first two bytes for image response
val ext = requestUrl.substringAfterLast(".", "png")
response.newBuilder().body(
response.body.source().apply { skip(2) }
.asResponseBody("image/$ext".toMediaType()),
).build()
} else {
response
}
}.build()
private val json: Json by injectLazy()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
override fun popularMangaRequest(page: Int) =
GET("$baseUrl/prod-api/app-api/vv/mh-list/page?mhcate=2&pageSize=50&pageNo=$page", headers)
override fun popularMangaRequest(page: Int) = POST(
"$baseUrl/api/getByComicByRow",
headers,
ComicFetchRequest("29", page, PAGE_SIZE).toJsonRequestBody(),
)
override fun popularMangaParse(response: Response): MangasPage {
val listing: ListingDto = response.parseAs()
val mangas = listing.list.map { it.toSManga(baseUrl) }
val hasNextPage = run {
val url = response.request.url
val pageSize = url.queryParameter("pageSize")!!.toInt()
val pageNumber = url.queryParameter("pageNo")!!.toInt()
pageSize * pageNumber < listing.totalCount
}
return MangasPage(mangas, hasNextPage)
val records = response.parseAs<CommonResponse<RecordResult>>().result.records
return createMangasPage(records)
}
override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/prod-api/app-api/vv/mh-list/page?mhcate=4&pageSize=50&pageNo=$page", headers)
override fun latestUpdatesRequest(page: Int) = POST(
"$baseUrl/api/getByComicByRow",
headers,
ComicFetchRequest("34", page, PAGE_SIZE).toJsonRequestBody(),
)
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
private fun searchByKeyword(page: Int, query: String): Request {
return POST(
"$apiUrl/api/searchNovel",
headers,
KeywordSearchRequest(query).toJsonRequestBody(),
)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/prod-api/app-api/vv/mh-list/page".toHttpUrl().newBuilder()
.apply { if (query.isNotBlank()) addQueryParameter("word", query) }
.apply { parseFilters(filters, this) }
.addEncodedQueryParameter("pageSize", "50")
.addEncodedQueryParameter("pageNo", page.toString())
.build()
return Request.Builder().url(url).headers(headers).build()
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
// for WebView
override fun mangaDetailsRequest(manga: SManga) =
GET("$baseUrl/#/pages/detail/detail?id=${manga.url}")
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
val request = GET("$baseUrl/prod-api/app-api/vv/mh-list/get?id=${manga.url}", headers)
return client.newCall(request).asObservableSuccess().map { mangaDetailsParse(it) }
}
override fun mangaDetailsParse(response: Response) =
response.parseAs<MangaDto>().toSManga(baseUrl)
override fun chapterListRequest(manga: SManga) =
GET("$baseUrl/prod-api/app-api/vv/mh-episodes/list?mhid=${manga.url}", headers)
override fun chapterListParse(response: Response) =
response.parseAs<List<ChapterDto>>().map { it.toSChapter() }
// for WebView
override fun pageListRequest(chapter: SChapter): Request {
val (mangaId, chapterIndex) = chapter.url.split("/")
return GET("$baseUrl/#/pages/read/read?no=$chapterIndex&id=$mangaId")
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val (mangaId, chapterIndex) = chapter.url.split("/")
val url = "$baseUrl/prod-api/app-api/vv/mh-episodes/get?jiNo=$chapterIndex&mhid=$mangaId"
return client.newCall(GET(url, headers)).asObservableSuccess().map { pageListParse(it) }
}
override fun pageListParse(response: Response) =
response.parseAs<PageListDto>().images.mapIndexed { index, url ->
val imageUrl = if (url.startsWith("http")) url else baseUrl + url
Page(index, imageUrl = imageUrl)
if (query.isNotEmpty()) {
return searchByKeyword(page, query)
}
return POST(
"$apiUrl/api/getByComicCategoryId",
headers,
FilterRequest(
page = page,
limit = PAGE_SIZE,
categoryId = filters.firstInstance<CategoryFilter>().selected,
orderType = filters.firstInstance<SortFilter>().selected,
overType = filters.firstInstance<StatusFilter>().selected,
).toJsonRequestBody(),
)
}
override fun searchMangaParse(response: Response): MangasPage {
val searchByKeyword = response.request.url.toString().contains("searchNovel")
val records = when {
searchByKeyword -> response.parseAs<CommonResponse<List<Record>>>().result
else -> response.parseAs<CommonResponse<FilterResult>>().result.list
}
return createMangasPage(records, paginated = !searchByKeyword)
}
private fun createMangasPage(records: List<Record>, paginated: Boolean = true): MangasPage {
return MangasPage(
records.map {
SManga.create().apply {
url = "${it.id}"
title = it.novelTitle
thumbnail_url = it.imgUrl
}
},
paginated && records.size >= PAGE_SIZE,
)
}
override fun getMangaUrl(manga: SManga): String {
return "$baseUrl/pages/comic/info".toHttpUrl().newBuilder()
.addQueryParameter("id", manga.url)
.toString()
}
override fun mangaDetailsRequest(manga: SManga) = chapterListRequest(manga)
override fun mangaDetailsParse(response: Response): SManga {
val comic = response.parseAs<CommonResponse<ComicInfoResult>>().result.comic
return SManga.create().apply {
url = "${comic.id}"
title = comic.novelTitle
thumbnail_url = comic.bigImgUrl
genre = comic.tags
author = comic.author
description = comic.introduction
status = when (comic.overType) {
1 -> SManga.ONGOING
2 -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
override fun getChapterUrl(chapter: SChapter): String {
return "$baseUrl/pages/comic/content".toHttpUrl().newBuilder()
.addQueryParameter("f", "1")
.addQueryParameter("s", chapter.chapter_number.toInt().toString())
.toString()
}
override fun chapterListRequest(manga: SManga) = withUserId { userId ->
POST(
"$apiUrl/api/getComicInfo",
headers,
ComicDetailRequest(manga.url, userId).toJsonRequestBody(),
)
}
override fun chapterListParse(response: Response): List<SChapter> {
val chapterList = response.parseAs<CommonResponse<ComicInfoResult>>().result.chapterList
return chapterList.mapIndexed { index, chapter ->
SChapter.create().apply {
url = "${chapter.id}"
name = chapter.chapterName
date_upload = dateFormat.tryParse(chapter.createTime)
// used to get the real chapter url
chapter_number = index.toFloat()
}
}.reversed()
}
override fun pageListRequest(chapter: SChapter): Request = withUserId { userId ->
POST(
"$apiUrl/api/getComicChapter",
headers,
ChapterContentRequest(chapter.url, userId).toJsonRequestBody(),
)
}
override fun pageListParse(response: Response): List<Page> {
val contentList = response.parseAs<CommonResponse<ChapterContentResult>>().result.content
return contentList.mapIndexed { index, content ->
Page(index, imageUrl = content.url)
}
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
private inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromStream<ResponseDto<T>>(body.byteStream()).data
}
override fun getFilterList() = getFilterListInternal()
override fun getFilterList() = FilterList(
SortFilter(),
StatusFilter(),
CategoryFilter(),
)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
val mirrors = MIRRORS
key = MIRROR_PREF
title = "镜像网址(重启生效)"
summary = "%s"
entries = mirrors
entryValues = Array(mirrors.size, Int::toString)
setDefaultValue("0")
}.let(screen::addPreference)
screen.apply {
addPreference(
ListPreference(context).apply {
val mirrors = MIRRORS
key = PREF_KEY_MIRROR
title = "镜像网址(重启生效)"
summary = "%s"
entries = mirrors
entryValues = Array(mirrors.size, Int::toString)
setDefaultValue("0")
},
)
addPreference(
EditTextPreference(context).apply {
key = PREF_KEY_CUSTOM_HOST
val customUrl = this@Yidan.getPreferences().getString(PREF_KEY_CUSTOM_HOST, "")
title = "自定义网址:$customUrl"
summary =
"请点击后输入自定义网址例如https://yidan1.club如果不需要自定义时请设置为空"
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(context, "重启应用后生效", Toast.LENGTH_LONG).show()
true
}
},
)
}
}
//region utils functions
private lateinit var _userId: String
@MainThread
private fun WebView.readUserId(block: (userId: String) -> Unit) {
val script = "javascript:localStorage['uc']"
evaluateJavascript(script) { uc ->
if (uc.isNotEmpty() && uc != "null" && uc != "undefined") {
block(uc.removeSurrounding("'").removeSurrounding("\""))
}
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun <T> withUserId(block: (userId: String) -> T): T {
return if (this::_userId.isInitialized) {
block(_userId)
} else {
val mainHandler = Handler(Looper.getMainLooper())
var latch = CountDownLatch(1)
var webView: WebView? = null
mainHandler.post {
webView = WebView(Injekt.get<Application>()).apply {
with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
blockNetworkImage = true
}
}
webView?.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.readUserId {
_userId = it
latch.countDown()
}
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
// wait the auto register request
if (request?.url?.encodedPath?.contains("api/regUser") == true) {
latch.countDown()
}
return super.shouldInterceptRequest(view, request)
}
}
webView?.loadUrl(baseUrl)
}
latch.await(15, TimeUnit.SECONDS)
if (!this::_userId.isInitialized) {
latch = CountDownLatch(1)
mainHandler.postDelayed(
{
webView?.readUserId {
_userId = it
latch.countDown()
}
},
500L,
)
latch.await(5, TimeUnit.SECONDS)
}
mainHandler.post {
webView?.apply {
stopLoading()
destroy()
}
webView = null
}
if (!this::_userId.isInitialized) {
throw Exception("无法自动获取UserId请先尝试通过内置WebView进入网站")
}
block(_userId)
}
}
private inline fun <reified T : Any> T.toJsonRequestBody(): RequestBody =
json.encodeToString(this)
.toRequestBody("application/json".toMediaType())
//endregion
companion object {
private const val MIRROR_PREF = "MIRROR"
private val MIRRORS get() = arrayOf("yidan1.club", "yidan22.club", "yidan10.club", "yidan9.club")
private const val PREF_KEY_MIRROR = "MIRROR"
private const val PREF_KEY_CUSTOM_HOST = "CUSTOM_HOST"
private val MIRRORS = arrayOf("yidan1.club", "yidan22.club", "yidan10.club", "yidan9.club")
private const val PAGE_SIZE = 16
}
}