From 99f58ad3f1c38c957f9dd2cdf5dfcdfb3fa3bdd4 Mon Sep 17 00:00:00 2001
From: AlphaBoom <30779939+AlphaBoom@users.noreply.github.com>
Date: Tue, 18 Mar 2025 21:31:19 +0900
Subject: [PATCH] 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>
---
 src/zh/yidan/build.gradle                     |   2 +-
 .../tachiyomi/extension/zh/yidan/Dto.kt       | 120 +++---
 .../tachiyomi/extension/zh/yidan/Filters.kt   |  94 ++---
 .../tachiyomi/extension/zh/yidan/Yidan.kt     | 389 ++++++++++++++----
 4 files changed, 422 insertions(+), 183 deletions(-)

diff --git a/src/zh/yidan/build.gradle b/src/zh/yidan/build.gradle
index 8448d7211..2eaad67c4 100644
--- a/src/zh/yidan/build.gradle
+++ b/src/zh/yidan/build.gradle
@@ -1,7 +1,7 @@
 ext {
     extName = 'Yidan Girl'
     extClass = '.Yidan'
-    extVersionCode = 4
+    extVersionCode = 5
     isNsfw = true
 }
 
diff --git a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt
index ad7a94464..fe5306b11 100644
--- a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt
+++ b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Dto.kt
@@ -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)
diff --git a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt
index bc48edef1..39a18fe24 100644
--- a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt
+++ b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Filters.kt
@@ -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,
+    ),
 )
diff --git a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt
index df189829a..e64f6aae4 100644
--- a/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt
+++ b/src/zh/yidan/src/eu/kanade/tachiyomi/extension/zh/yidan/Yidan.kt
@@ -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
     }
 }