Explorar o código

fix: sigma的一些特性 @luoyacheng

HapeLee hai 3 meses
pai
achega
82e38f2efc

+ 2 - 0
app/src/main/java/io/legado/app/constant/AppPattern.kt

@@ -6,6 +6,8 @@ import java.util.regex.Pattern
 object AppPattern {
 object AppPattern {
     val JS_PATTERN: Pattern =
     val JS_PATTERN: Pattern =
         Pattern.compile("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE)
         Pattern.compile("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE)
+    val WebJS_PATTERN: Pattern =
+        Pattern.compile("@webjs:([\\w\\W]{5,})", Pattern.CASE_INSENSITIVE)
     val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
     val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
 
 
     //匹配格式化后的图片格式
     //匹配格式化后的图片格式

+ 46 - 4
app/src/main/java/io/legado/app/help/http/BackstageWebView.kt

@@ -13,9 +13,18 @@ import android.webkit.WebSettings
 import android.webkit.WebView
 import android.webkit.WebView
 import android.webkit.WebViewClient
 import android.webkit.WebViewClient
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.BaseSource
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.CacheManager
+import io.legado.app.help.WebCacheManager
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.help.webView.WebJsExtensions
+import io.legado.app.help.webView.WebJsExtensions.Companion.getInjectionString
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameCache
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameSource
 import io.legado.app.utils.runOnUI
 import io.legado.app.utils.runOnUI
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Runnable
 import kotlinx.coroutines.Runnable
@@ -43,13 +52,17 @@ class BackstageWebView(
     private val overrideUrlRegex: String? = null,
     private val overrideUrlRegex: String? = null,
     private val javaScript: String? = null,
     private val javaScript: String? = null,
     private val delayTime: Long = 0,
     private val delayTime: Long = 0,
+    private val cacheFirst: Boolean = false,
+    private val timeout: Long? = null,
+    private val result: String? = null,
+    private val isRule: Boolean = false,
 ) {
 ) {
 
 
     private val mHandler = Handler(Looper.getMainLooper())
     private val mHandler = Handler(Looper.getMainLooper())
     private var callback: Callback? = null
     private var callback: Callback? = null
     private var mWebView: WebView? = null
     private var mWebView: WebView? = null
 
 
-    suspend fun getStrResponse(): StrResponse = withTimeout(60000L) {
+    suspend fun getStrResponse(): StrResponse = withTimeout(timeout ?: 60000L) {
         suspendCancellableCoroutine { block ->
         suspendCancellableCoroutine { block ->
             block.invokeOnCancellation {
             block.invokeOnCancellation {
                 runOnUI {
                 runOnUI {
@@ -88,9 +101,22 @@ class BackstageWebView(
         mWebView = webView
         mWebView = webView
         try {
         try {
             when {
             when {
-                !html.isNullOrEmpty() -> if (url.isNullOrEmpty()) {
+                !html.isNullOrEmpty() -> if (url.isNullOrEmpty() && !isRule) {
                     webView.loadData(html, "text/html", getEncoding())
                     webView.loadData(html, "text/html", getEncoding())
                 } else {
                 } else {
+                    if (isRule) {
+                        webView.addJavascriptInterface(WebCacheManager, nameCache)
+                        tag?.let { key ->
+                            appDb.bookSourceDao.getBookSource(key)?.let { source ->
+                                webView.addJavascriptInterface(source as BaseSource, nameSource)
+                                webView.addJavascriptInterface(
+                                    WebJsExtensions(source, null, webView),
+                                    nameJava,
+                                )
+                            }
+                        }
+                    }
+                    result?.let { CacheManager.put("webview_result", it) }
                     webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url)
                     webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url)
                 }
                 }
 
 
@@ -114,6 +140,11 @@ class BackstageWebView(
         settings.blockNetworkImage = true
         settings.blockNetworkImage = true
         settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent
         settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent
         settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
         settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+        settings.cacheMode = if (cacheFirst) {
+            WebSettings.LOAD_CACHE_ELSE_NETWORK
+        } else {
+            WebSettings.LOAD_DEFAULT
+        }
         if (sourceRegex.isNullOrBlank() && overrideUrlRegex.isNullOrBlank()) {
         if (sourceRegex.isNullOrBlank() && overrideUrlRegex.isNullOrBlank()) {
             webView.webViewClient = HtmlWebViewClient()
             webView.webViewClient = HtmlWebViewClient()
         } else {
         } else {
@@ -164,6 +195,12 @@ class BackstageWebView(
 
 
         override fun onPageFinished(view: WebView, url: String) {
         override fun onPageFinished(view: WebView, url: String) {
             setCookie(url)
             setCookie(url)
+            result?.let {
+                view.evaluateJavascript(
+                    "window.result = $nameCache.getFromMemory('webview_result')",
+                    null,
+                )
+            }
             if (runnable == null) {
             if (runnable == null) {
                 runnable = EvalJsRunnable(view, url, getJs())
                 runnable = EvalJsRunnable(view, url, getJs())
             }
             }
@@ -183,10 +220,15 @@ class BackstageWebView(
         private inner class EvalJsRunnable(
         private inner class EvalJsRunnable(
             webView: WebView,
             webView: WebView,
             private val url: String,
             private val url: String,
-            private val mJavaScript: String
+            mJavaScript: String
         ) : Runnable {
         ) : Runnable {
             var retry = 0
             var retry = 0
             private val mWebView: WeakReference<WebView> = WeakReference(webView)
             private val mWebView: WeakReference<WebView> = WeakReference(webView)
+            private val mJavaScript = if (isRule) {
+                "$getInjectionString\n$mJavaScript"
+            } else {
+                mJavaScript
+            }
             override fun run() {
             override fun run() {
                 mWebView.get()?.evaluateJavascript(mJavaScript) {
                 mWebView.get()?.evaluateJavascript(mJavaScript) {
                     handleResult(it)
                     handleResult(it)
@@ -331,4 +373,4 @@ class BackstageWebView(
         abstract fun onResult(response: StrResponse)
         abstract fun onResult(response: StrResponse)
         abstract fun onError(error: Throwable)
         abstract fun onError(error: Throwable)
     }
     }
-}
+}

+ 2 - 2
app/src/main/java/io/legado/app/help/http/RequestMethod.kt

@@ -1,5 +1,5 @@
 package io.legado.app.help.http
 package io.legado.app.help.http
 
 
 enum class RequestMethod {
 enum class RequestMethod {
-    GET, POST
-}
+    GET, POST, HEAD
+}

+ 56 - 1
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -2,10 +2,12 @@ package io.legado.app.model.analyzeRule
 
 
 import android.text.TextUtils
 import android.text.TextUtils
 import androidx.annotation.Keep
 import androidx.annotation.Keep
+import com.google.gson.internal.LinkedTreeMap
 import com.script.CompiledScript
 import com.script.CompiledScript
 import com.script.buildScriptBindings
 import com.script.buildScriptBindings
 import com.script.rhino.RhinoScriptEngine
 import com.script.rhino.RhinoScriptEngine
 import io.legado.app.constant.AppPattern.JS_PATTERN
 import io.legado.app.constant.AppPattern.JS_PATTERN
+import io.legado.app.constant.AppPattern.WebJS_PATTERN
 import io.legado.app.data.entities.BaseBook
 import io.legado.app.data.entities.BaseBook
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
@@ -15,6 +17,7 @@ import io.legado.app.data.entities.RssArticle
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.CacheManager
 import io.legado.app.help.JsExtensions
 import io.legado.app.help.JsExtensions
+import io.legado.app.help.http.BackstageWebView
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.source.getShareScope
 import io.legado.app.help.source.getShareScope
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
@@ -22,10 +25,12 @@ import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSONStrict
 import io.legado.app.utils.GSONStrict
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getOrPutLimit
 import io.legado.app.utils.getOrPutLimit
 import io.legado.app.utils.isDataUrl
 import io.legado.app.utils.isDataUrl
 import io.legado.app.utils.isJson
 import io.legado.app.utils.isJson
+import io.legado.app.utils.isMainThread
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.stackTraceStr
 import io.legado.app.utils.stackTraceStr
@@ -163,6 +168,23 @@ class AnalyzeRule(
         }
         }
     }
     }
 
 
+    private fun getWebJsResult(jsStr: String, result: Any): String {
+        check(!isMainThread) { "webJs must be called on a background thread" }
+        return runBlocking {
+            BackstageWebView(
+                url = baseUrl,
+                html = content.toString(),
+                javaScript = jsStr,
+                headerMap = getSource()?.getHeaderMap(true),
+                tag = getSource()?.getKey(),
+                cacheFirst = true,
+                timeout = 10000,
+                result = GSON.toJson(result),
+                isRule = true,
+            ).getStrResponse().body.orEmpty()
+        }
+    }
+
     /**
     /**
      * 获取文本列表
      * 获取文本列表
      */
      */
@@ -203,6 +225,8 @@ class AnalyzeRule(
                         result = replaceRegex(result.toString(), sourceRule)
                         result = replaceRegex(result.toString(), sourceRule)
                     }
                     }
                 }
                 }
+            } else if (result is LinkedTreeMap<*, *>) {
+                result = result[ruleList.first().rule]
             } else {
             } else {
                 for (sourceRule in ruleList) {
                 for (sourceRule in ruleList) {
                     putRule(sourceRule.putMap)
                     putRule(sourceRule.putMap)
@@ -211,6 +235,9 @@ class AnalyzeRule(
                     val rule = sourceRule.rule
                     val rule = sourceRule.rule
                     if (rule.isNotEmpty()) {
                     if (rule.isNotEmpty()) {
                         result = when (sourceRule.mode) {
                         result = when (sourceRule.mode) {
+                            Mode.WebJs -> getWebJsResult(rule, result).let {
+                                GSON.fromJsonArray<String>(it).getOrNull() ?: it
+                            }
                             Mode.Js -> evalJS(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule)
                             Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule)
@@ -290,6 +317,8 @@ class AnalyzeRule(
                 }?.let {
                 }?.let {
                     replaceRegex(it, sourceRule)
                     replaceRegex(it, sourceRule)
                 }
                 }
+            } else if (result is LinkedTreeMap<*, *>) {
+                result = result[ruleList.first().rule]?.toString()
             } else {
             } else {
                 for (sourceRule in ruleList) {
                 for (sourceRule in ruleList) {
                     putRule(sourceRule.putMap)
                     putRule(sourceRule.putMap)
@@ -298,6 +327,7 @@ class AnalyzeRule(
                     val rule = sourceRule.rule
                     val rule = sourceRule.rule
                     if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
                     if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
                         result = when (sourceRule.mode) {
                         result = when (sourceRule.mode) {
+                            Mode.WebJs -> getWebJsResult(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Json -> getAnalyzeByJSonPath(result).getString(rule)
                             Mode.Json -> getAnalyzeByJSonPath(result).getString(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getString(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getString(rule)
@@ -354,6 +384,10 @@ class AnalyzeRule(
                         rule.splitNotBlank("&&")
                         rule.splitNotBlank("&&")
                     )
                     )
 
 
+                    Mode.WebJs -> GSON.fromJsonObject<Map<String, Any?>>(
+                        getWebJsResult(rule, result)
+                    ).getOrNull()
+
                     Mode.Js -> evalJS(rule, result)
                     Mode.Js -> evalJS(rule, result)
                     Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule)
                     Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
@@ -387,6 +421,10 @@ class AnalyzeRule(
                         rule.splitNotBlank("&&")
                         rule.splitNotBlank("&&")
                     )
                     )
 
 
+                    Mode.WebJs -> GSON.fromJsonArray<Map<String, Any?>>(
+                        getWebJsResult(rule, result)
+                    ).getOrNull()
+
                     Mode.Js -> evalJS(rule, result)
                     Mode.Js -> evalJS(rule, result)
                     Mode.Json -> getAnalyzeByJSonPath(result).getList(rule)
                     Mode.Json -> getAnalyzeByJSonPath(result).getList(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
@@ -515,6 +553,18 @@ class AnalyzeRule(
             start = jsMatcher.end()
             start = jsMatcher.end()
         }
         }
 
 
+        val webJsMatcher = WebJS_PATTERN.matcher(ruleStr)
+        while (webJsMatcher.find()) {
+            if (webJsMatcher.start() > start) {
+                tmp = ruleStr.substring(start, webJsMatcher.start()).trim { it <= ' ' }
+                if (tmp.isNotEmpty()) {
+                    ruleList.add(SourceRule(tmp, mMode))
+                }
+            }
+            ruleList.add(SourceRule(webJsMatcher.group(1) ?: "", Mode.WebJs))
+            start = webJsMatcher.end()
+        }
+
         if (ruleStr.length > start) {
         if (ruleStr.length > start) {
             tmp = ruleStr.substring(start).trim { it <= ' ' }
             tmp = ruleStr.substring(start).trim { it <= ' ' }
             if (tmp.isNotEmpty()) {
             if (tmp.isNotEmpty()) {
@@ -738,7 +788,7 @@ class AnalyzeRule(
     }
     }
 
 
     enum class Mode {
     enum class Mode {
-        XPath, Json, Default, Js, Regex
+        XPath, Json, Default, Js, Regex, WebJs
     }
     }
 
 
     /**
     /**
@@ -826,6 +876,10 @@ class AnalyzeRule(
      * js实现跨域访问,不能删
      * js实现跨域访问,不能删
      */
      */
     override fun ajax(url: Any): String? {
     override fun ajax(url: Any): String? {
+        return ajax(url, null)
+    }
+
+    override fun ajax(url: Any, callTimeout: Long?): String? {
         val urlStr = if (url is List<*>) {
         val urlStr = if (url is List<*>) {
             url.firstOrNull().toString()
             url.firstOrNull().toString()
         } else {
         } else {
@@ -835,6 +889,7 @@ class AnalyzeRule(
             urlStr,
             urlStr,
             source = source,
             source = source,
             ruleData = book,
             ruleData = book,
+            callTimeout = callTimeout,
             coroutineContext = coroutineContext
             coroutineContext = coroutineContext
         )
         )
         return kotlin.runCatching {
         return kotlin.runCatching {

+ 18 - 2
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt

@@ -15,8 +15,8 @@ import io.legado.app.constant.AppConst.UA_NAME
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
-import io.legado.app.exception.NoStackTraceException
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.CacheManager
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.JsExtensions
 import io.legado.app.help.JsExtensions
@@ -239,7 +239,11 @@ class AnalyzeUrl(
             }
             }
             urlOption?.let { option ->
             urlOption?.let { option ->
                 option.getMethod()?.let {
                 option.getMethod()?.let {
-                    if (it.equals("POST", true)) method = RequestMethod.POST
+                    method = when (it.uppercase()) {
+                        "POST" -> RequestMethod.POST
+                        "HEAD" -> RequestMethod.HEAD
+                        else -> RequestMethod.GET
+                    }
                 }
                 }
                 option.getHeaderMap()?.forEach { entry ->
                 option.getHeaderMap()?.forEach { entry ->
                     headerMap[entry.key.toString()] = entry.value.toString()
                     headerMap[entry.key.toString()] = entry.value.toString()
@@ -278,6 +282,8 @@ class AnalyzeUrl(
                     analyzeFields(it)
                     analyzeFields(it)
                 }
                 }
             }
             }
+
+            RequestMethod.HEAD -> Unit
         }
         }
     }
     }
 
 
@@ -485,6 +491,11 @@ class AnalyzeUrl(
                             }
                             }
                         }
                         }
 
 
+                        RequestMethod.HEAD -> {
+                            get(urlNoQuery, encodedQuery)
+                            head()
+                        }
+
                         else -> get(urlNoQuery, encodedQuery)
                         else -> get(urlNoQuery, encodedQuery)
                     }
                     }
                 }.let {
                 }.let {
@@ -559,6 +570,11 @@ class AnalyzeUrl(
                         }
                         }
                     }
                     }
 
 
+                    RequestMethod.HEAD -> {
+                        get(urlNoQuery, encodedQuery)
+                        head()
+                    }
+
                     else -> get(urlNoQuery, encodedQuery)
                     else -> get(urlNoQuery, encodedQuery)
                 }
                 }
             }
             }

+ 10 - 10
app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt

@@ -13,7 +13,6 @@ import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.TocEmptyException
 import io.legado.app.exception.TocEmptyException
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.simulatedTotalChapterNum
-import io.legado.app.help.config.AppConfig
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
@@ -164,7 +163,7 @@ object BookChapterList {
             list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
             list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
                 .getDisplayTitle(replaceRules, book.getUseReplaceRule())
                 .getDisplayTitle(replaceRules, book.getUseReplaceRule())
         coroutineContext.ensureActive()
         coroutineContext.ensureActive()
-        getWordCount(list, book)
+        preserveChapterMetadata(list, book)
         return list
         return list
     }
     }
 
 
@@ -270,17 +269,18 @@ object BookChapterList {
         return Pair(chapterList, nextUrlList)
         return Pair(chapterList, nextUrlList)
     }
     }
 
 
-    private fun getWordCount(list: ArrayList<BookChapter>, book: Book) {
-        if (!AppConfig.tocCountWords) {
-            return
-        }
+    private fun preserveChapterMetadata(list: ArrayList<BookChapter>, book: Book) {
         val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
         val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
         if (chapterList.isNotEmpty()) {
         if (chapterList.isNotEmpty()) {
-            val map = chapterList.associateBy({ it.getFileName() }, { it.wordCount })
+            val map = chapterList.associateBy(
+                keySelector = { it.getFileName() },
+                valueTransform = { Triple(it.wordCount, it.variable, it.reviewImg) },
+            )
             for (bookChapter in list) {
             for (bookChapter in list) {
-                val wordCount = map[bookChapter.getFileName()]
-                if (wordCount != null) {
-                    bookChapter.wordCount = wordCount
+                map[bookChapter.getFileName()]?.let { (wordCount, variable, reviewImg) ->
+                    wordCount?.let { bookChapter.wordCount = it }
+                    variable?.let { bookChapter.variable = it }
+                    reviewImg?.let { bookChapter.reviewImg = it }
                 }
                 }
             }
             }
         }
         }

+ 21 - 7
app/src/main/java/io/legado/app/model/webBook/BookContent.kt

@@ -11,6 +11,7 @@ import io.legado.app.exception.ContentEmptyException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isAudio
+import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.help.book.isVideo
 import io.legado.app.help.book.isVideo
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
@@ -137,6 +138,26 @@ object BookContent {
                 appendContent(it)
                 appendContent(it)
             }
             }
         }
         }
+        contentRule.subContent?.takeIf { it.isNotBlank() }?.let { rule ->
+            val rawSubContent = analyzeRule.getString(rule, unescape = false).trim()
+            val subContent = if (rawSubContent.startsWith("http", ignoreCase = true)) {
+                AnalyzeUrl(
+                    mUrl = rawSubContent,
+                    source = bookSource,
+                    ruleData = book,
+                    coroutineContext = coroutineContext,
+                ).getStrResponseAwait().body.orEmpty()
+            } else {
+                rawSubContent
+            }
+            if (subContent.isNotBlank()) {
+                if (book.isOnLineTxt) {
+                    appendContent(subContent)
+                } else if (book.isAudio) {
+                    bookChapter.putVariable("lyric", subContent)
+                }
+            }
+        }
         var contentStr = contentBuilder.toString()
         var contentStr = contentBuilder.toString()
         val titleRule = contentRule.title //先正文再章节名称
         val titleRule = contentRule.title //先正文再章节名称
         if (!titleRule.isNullOrBlank()) {
         if (!titleRule.isNullOrBlank()) {
@@ -225,13 +246,6 @@ object BookContent {
                 content = content.replace(placeholder, originalContent)
                 content = content.replace(placeholder, originalContent)
             }
             }
         }
         }
-        //获取副文(歌词等)
-        contentRule.subContent?.takeIf { it.isNotBlank() }?.let {
-            val subContent = analyzeRule.getString(it, unescape = false)
-            if (subContent.isNotBlank()) {
-                chapter.putVariable("lyric", subContent)
-            }
-        }
         //获取下一页链接
         //获取下一页链接
         if (getNextPageUrl) {
         if (getNextPageUrl) {
             val nextUrlRule = contentRule.nextContentUrl
             val nextUrlRule = contentRule.nextContentUrl

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -69,6 +69,7 @@ data class BookInfoBookUi(
 data class BookInfoSourceUi(
 data class BookInfoSourceUi(
     val sourceUrl: String,
     val sourceUrl: String,
     val hasLogin: Boolean,
     val hasLogin: Boolean,
+    val hasCustomButton: Boolean,
 )
 )
 
 
 sealed interface BookInfoSheet {
 sealed interface BookInfoSheet {
@@ -217,9 +218,11 @@ sealed interface BookInfoCallbackAction {
     data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction
     data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction
     data class CopyText(val text: String) : BookInfoCallbackAction
     data class CopyText(val text: String) : BookInfoCallbackAction
     data object ClearCache : BookInfoCallbackAction
     data object ClearCache : BookInfoCallbackAction
+    data object None : BookInfoCallbackAction
 }
 }
 
 
 enum class BookInfoMenuAction {
 enum class BookInfoMenuAction {
+    CustomButton,
     Edit,
     Edit,
     Share,
     Share,
     Upload,
     Upload,

+ 4 - 3
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -14,14 +14,12 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocal
-import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
@@ -29,6 +27,7 @@ import io.legado.app.ui.book.manga.ReadMangaActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
+import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.widget.dialog.VariableDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
@@ -247,6 +246,8 @@ private fun runSourceCallback(
             BookInfoCallbackAction.ClearCache -> {
             BookInfoCallbackAction.ClearCache -> {
                 viewModel.clearCache()
                 viewModel.clearCache()
             }
             }
+
+            BookInfoCallbackAction.None -> Unit
         }
         }
     }
     }
 }
 }

+ 7 - 1
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -76,7 +76,6 @@ import coil.size.Size
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.BookType
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
-import io.legado.app.model.BookCover as BookCoverModel
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.main.homepage.modules.BannerModule
@@ -121,6 +120,7 @@ import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import org.koin.compose.koinInject
 import org.koin.compose.koinInject
 import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
 import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
+import io.legado.app.model.BookCover as BookCoverModel
 import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
 import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
 
 
 @OptIn(ExperimentalMaterial3Api::class)
 @OptIn(ExperimentalMaterial3Api::class)
@@ -725,6 +725,12 @@ private fun BookInfoOverflowMenu(
 ) {
 ) {
     val book = state.book
     val book = state.book
     RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
     RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+        if (state.bookSource?.hasCustomButton == true) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.custom_button),
+                onClick = { onMenuAction(BookInfoMenuAction.CustomButton) }
+            )
+        }
         if (state.inBookshelf) {
         if (state.inBookshelf) {
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.edit),
                 text = stringResource(R.string.edit),

+ 9 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -1125,6 +1125,14 @@ class BookInfoViewModel(
     private fun handleMenuAction(action: BookInfoMenuAction) {
     private fun handleMenuAction(action: BookInfoMenuAction) {
         val book = currentBook ?: return
         val book = currentBook ?: return
         when (action) {
         when (action) {
+            BookInfoMenuAction.CustomButton -> emitEffect(
+                BookInfoEffect.RunSourceCallback(
+                    event = SourceCallBack.CLICK_CUSTOM_BUTTON,
+                    source = bookSource,
+                    book = book.uiCopy(),
+                    action = BookInfoCallbackAction.None,
+                )
+            )
             BookInfoMenuAction.Edit -> openEdit()
             BookInfoMenuAction.Edit -> openEdit()
             BookInfoMenuAction.Share -> {
             BookInfoMenuAction.Share -> {
                 val bookJson = GSON.toJson(book)
                 val bookJson = GSON.toJson(book)
@@ -1528,6 +1536,7 @@ class BookInfoViewModel(
         return BookInfoSourceUi(
         return BookInfoSourceUi(
             sourceUrl = bookSourceUrl,
             sourceUrl = bookSourceUrl,
             hasLogin = !loginUrl.isNullOrBlank(),
             hasLogin = !loginUrl.isNullOrBlank(),
+            hasCustomButton = customButton,
         )
         )
     }
     }
 
 

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -468,6 +468,7 @@ sealed interface ReadBookIntent {
     data object OpenBookInfo : ReadBookIntent
     data object OpenBookInfo : ReadBookIntent
     data object OpenChapterList : ReadBookIntent
     data object OpenChapterList : ReadBookIntent
     data object OpenChapterUrl : ReadBookIntent
     data object OpenChapterUrl : ReadBookIntent
+    data class SourceCustomButton(val longClick: Boolean) : ReadBookIntent
     data object ToggleReadUrlInBrowser : ReadBookIntent
     data object ToggleReadUrlInBrowser : ReadBookIntent
 
 
     // Content edit
     // Content edit
@@ -772,6 +773,13 @@ sealed interface ReadBookEffect {
         val html: String? = null,
         val html: String? = null,
     ) : ReadBookEffect
     ) : ReadBookEffect
 
 
+    data class RunSourceCustomButton(
+        val event: String,
+        val source: BookSource,
+        val book: Book,
+        val chapter: BookChapter?,
+    ) : ReadBookEffect
+
     // Menu actions that need Activity
     // Menu actions that need Activity
     data object MenuChangeSource : ReadBookEffect
     data object MenuChangeSource : ReadBookEffect
     data object MenuBookChangeSource : ReadBookEffect
     data object MenuBookChangeSource : ReadBookEffect

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -1030,6 +1030,7 @@ class ReadBookController(
             is ReadBookEffect.OpenSearchActivity,
             is ReadBookEffect.OpenSearchActivity,
             is ReadBookEffect.ShowLogin,
             is ReadBookEffect.ShowLogin,
             is ReadBookEffect.OpenWebView,
             is ReadBookEffect.OpenWebView,
+            is ReadBookEffect.RunSourceCustomButton,
             is ReadBookEffect.MenuSettingReplace,
             is ReadBookEffect.MenuSettingReplace,
             is ReadBookEffect.TextActionReplace,
             is ReadBookEffect.TextActionReplace,
             is ReadBookEffect.OpenReplaceEditor,
             is ReadBookEffect.OpenReplaceEditor,

+ 61 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -1066,6 +1066,14 @@ private fun MenuTitleBar(
                     verticalAlignment = Alignment.CenterVertically,
                     verticalAlignment = Alignment.CenterVertically,
                 ) {
                 ) {
                     if (!state.isLocalBook) {
                     if (!state.isLocalBook) {
+                        if (state.bookSource?.customButton == true) {
+                            SourceCustomActionButton(
+                                state = state,
+                                colors = colors,
+                                onIntent = onIntent,
+                                backdrop = backdrop,
+                            )
+                        }
                         SourceActionButton(
                         SourceActionButton(
                             state = state,
                             state = state,
                             colors = colors,
                             colors = colors,
@@ -1475,6 +1483,41 @@ private fun MenuTitleBarMergedGlassButton(
             ) {
             ) {
             // SwapHoriz - change source
             // SwapHoriz - change source
             if (!state.isLocalBook) {
             if (!state.isLocalBook) {
+                if (state.bookSource?.customButton == true) {
+                    val customButtonDescription = stringResource(R.string.custom_button)
+                    Box(
+                        contentAlignment = Alignment.Center,
+                        modifier = Modifier
+                            .size(40.dp)
+                            .combinedClickable(
+                                indication = null,
+                                interactionSource = remember { MutableInteractionSource() },
+                                role = Role.Button,
+                                onClick = { onIntent(ReadBookIntent.SourceCustomButton(false)) },
+                                onLongClick = { onIntent(ReadBookIntent.SourceCustomButton(true)) },
+                            )
+                            .semantics {
+                                contentDescription = customButtonDescription
+                                role = Role.Button
+                            },
+                    ) {
+                        Icon(
+                            imageVector = Icons.Default.Build,
+                            contentDescription = null,
+                            tint = tint,
+                            modifier = Modifier.size(20.dp),
+                        )
+                    }
+
+                    Box(
+                        modifier = Modifier
+                            .width(1.dp)
+                            .height(20.dp)
+                            .background(tint.copy(alpha = 0.15f))
+                            .clearAndSetSemantics { }
+                    )
+                }
+
                 val changeSourceDescription = stringResource(R.string.change_origin)
                 val changeSourceDescription = stringResource(R.string.change_origin)
                 Box(
                 Box(
                     contentAlignment = Alignment.Center,
                     contentAlignment = Alignment.Center,
@@ -1710,6 +1753,24 @@ private fun MenuTitleBarMergedGlassButton(
     }
     }
 }
 }
 
 
+@Composable
+private fun SourceCustomActionButton(
+    state: ReadBookUiState,
+    colors: ReadMenuColors,
+    onIntent: (ReadBookIntent) -> Unit,
+    backdrop: Backdrop?,
+) {
+    MenuTitleGlassButton(
+        onClick = { onIntent(ReadBookIntent.SourceCustomButton(false)) },
+        onLongClick = { onIntent(ReadBookIntent.SourceCustomButton(true)) },
+        icon = Icons.Default.Build,
+        contentDescription = stringResource(R.string.custom_button),
+        state = state,
+        colors = colors,
+        backdrop = backdrop,
+    )
+}
+
 @Composable
 @Composable
 private fun SourceActionButton(
 private fun SourceActionButton(
     state: ReadBookUiState,
     state: ReadBookUiState,

+ 14 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -12,6 +12,7 @@ import android.widget.FrameLayout
 import android.widget.ImageView
 import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
@@ -40,6 +41,7 @@ import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.help.IntentHelp
 import io.legado.app.help.IntentHelp
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
+import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.ReadView
@@ -325,6 +327,18 @@ fun ReadBookRouteScreen(
                                     }
                                     }
                                 )
                                 )
                             }
                             }
+                            is ReadBookEffect.RunSourceCustomButton -> {
+                                (context as? AppCompatActivity)?.let { activity ->
+                                    SourceCallBack.callBackBtn(
+                                        activity,
+                                        effect.event,
+                                        effect.source,
+                                        effect.book,
+                                        effect.chapter,
+                                        BookType.text,
+                                    )
+                                }
+                            }
                             is ReadBookEffect.OpenSearchActivity -> {
                             is ReadBookEffect.OpenSearchActivity -> {
                                 onOpenSearch(effect.word, effect.bookUrl)
                                 onOpenSearch(effect.word, effect.bookUrl)
                             }
                             }

+ 19 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -582,6 +582,7 @@ class ReadBookViewModel(
                 }
                 }
             }
             }
             is ReadBookIntent.OpenChapterUrl -> openChapterUrl()
             is ReadBookIntent.OpenChapterUrl -> openChapterUrl()
+            is ReadBookIntent.SourceCustomButton -> runSourceCustomButton(intent.longClick)
             is ReadBookIntent.ToggleReadUrlInBrowser -> toggleReadUrlInBrowser()
             is ReadBookIntent.ToggleReadUrlInBrowser -> toggleReadUrlInBrowser()
             is ReadBookIntent.OpenContentEdit -> openContentEdit()
             is ReadBookIntent.OpenContentEdit -> openContentEdit()
             is ReadBookIntent.LoadContentEdit -> loadContentEdit()
             is ReadBookIntent.LoadContentEdit -> loadContentEdit()
@@ -5756,6 +5757,24 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
+    private fun runSourceCustomButton(longClick: Boolean) {
+        val source = ReadBook.bookSource?.takeIf { it.customButton } ?: return
+        val book = ReadBook.book ?: return
+        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+        _effects.tryEmit(
+            ReadBookEffect.RunSourceCustomButton(
+                event = if (longClick) {
+                    SourceCallBack.LONG_CLICK_CUSTOM_BUTTON
+                } else {
+                    SourceCallBack.CLICK_CUSTOM_BUTTON
+                },
+                source = source,
+                book = book,
+                chapter = chapter,
+            )
+        )
+    }
+
     private fun toggleReadUrlInBrowser() {
     private fun toggleReadUrlInBrowser() {
         viewModelScope.launch {
         viewModelScope.launch {
             val current = localPreferencesRepository
             val current = localPreferencesRepository