第三方书源兼容

pull/21/head
fengyuecanzhu 3 years ago
parent d5c95e47b5
commit 618afc3249
  1. 365
      app/src/main/java/xyz/fycz/myreader/model/JSExtensions.kt
  2. 0
      app/src/main/java/xyz/fycz/myreader/model/third2/analyzeRule/JsExtensions.java
  3. 49
      app/src/main/java/xyz/fycz/myreader/model/third3/Exceptions.kt
  4. 173
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/AnalyzeByJSonPath.kt
  5. 492
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/AnalyzeByJSoup.kt
  6. 61
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/AnalyzeByRegex.kt
  7. 149
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/AnalyzeByXPath.kt
  8. 695
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/AnalyzeRule.kt
  9. 554
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/AnalyzeUrl.kt
  10. 655
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/JsExtensions.kt
  11. 603
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/QueryTTF.java
  12. 378
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/RuleAnalyzer.kt
  13. 17
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/RuleData.kt
  14. 13
      app/src/main/java/xyz/fycz/myreader/model/third3/analyzeRule/RuleDataInterface.kt
  15. 217
      app/src/main/java/xyz/fycz/myreader/model/third3/http/BackstageWebView.kt
  16. 187
      app/src/main/java/xyz/fycz/myreader/model/third3/http/OkHttpUtils.kt
  17. 5
      app/src/main/java/xyz/fycz/myreader/model/third3/http/RequestMethod.kt
  18. 184
      app/src/main/java/xyz/fycz/myreader/model/third3/http/SSLHelper.kt
  19. 78
      app/src/main/java/xyz/fycz/myreader/model/third3/http/StrResponse.kt
  20. 215
      app/src/main/java/xyz/fycz/myreader/model/third3/webBook/BookChapterList.kt
  21. 160
      app/src/main/java/xyz/fycz/myreader/model/third3/webBook/BookContent.kt
  22. 140
      app/src/main/java/xyz/fycz/myreader/model/third3/webBook/BookInfo.kt
  23. 244
      app/src/main/java/xyz/fycz/myreader/model/third3/webBook/BookList.kt
  24. 209
      app/src/main/java/xyz/fycz/myreader/model/third3/webBook/SearchModel.kt
  25. 345
      app/src/main/java/xyz/fycz/myreader/model/third3/webBook/WebBook.kt

@ -0,0 +1,365 @@
package xyz.fycz.myreader.util.help
import android.util.Base64
import android.util.Log
import androidx.annotation.Keep
import org.jsoup.Connection
import org.jsoup.Jsoup
import xyz.fycz.myreader.greendao.service.CookieStore
import xyz.fycz.myreader.model.third2.analyzeRule.AnalyzeUrl
import xyz.fycz.myreader.util.ZipUtils
import xyz.fycz.myreader.util.utils.*
import java.io.File
import java.io.IOException
import java.net.URLEncoder
import java.text.SimpleDateFormat
import java.util.*
/**
* @author fengyue
* @date 2021/5/15 19:26
*/
@Keep
@Suppress("unused")
interface JSExtensions {
/**
* js实现跨域访问,不能删
*/
fun ajax(urlStr: String?): String? {
return try {
val analyzeUrl = AnalyzeUrl(urlStr)
OkHttpUtils.getStrResponse(analyzeUrl).blockingFirst().body()
} catch (e: Exception) {
e.localizedMessage
}
}
/**
* js实现压缩文件解压
*/
fun unzipFile(zipPath: String): String {
if (zipPath.isEmpty()) return ""
val unzipPath = FileUtils.getCachePath() + File.separator + FileUtils.getNameExcludeExtension(zipPath)
FileUtils.deleteFile(unzipPath)
val zipFile = FileUtils.getFile(zipPath)
val unzipFolder = FileUtils.getFolder(unzipPath)
ZipUtils.unzipFile(zipFile, unzipFolder)
FileUtils.deleteFile(zipPath)
return unzipPath
}
/**
* js实现文件夹内所有文件读取
*/
fun getTxtInFolder(unzipPath: String): String {
if (unzipPath.isEmpty()) return ""
val unzipFolder = FileUtils.getFolder(unzipPath)
val contents = StringBuilder()
unzipFolder.listFiles().let {
if (it != null) {
for (f in it) {
val charsetName = FileUtils.getFileCharset(f)
contents.append(String(f.readBytes(), charset(charsetName)))
.append("\n")
}
contents.deleteCharAt(contents.length - 1)
}
}
FileUtils.deleteFile(unzipPath)
return contents.toString()
}
/**
* js实现重定向拦截,不能删
*/
@Throws(IOException::class)
operator fun get(urlStr: String?, headers: Map<String?, String?>?): Connection.Response? {
return Jsoup.connect(urlStr)
.sslSocketFactory(SSLSocketClient.createSSLSocketFactory())
.ignoreContentType(true)
.followRedirects(false)
.headers(headers)
.method(Connection.Method.GET)
.execute()
}
/**
* js实现重定向拦截,不能删
*/
@Throws(IOException::class)
fun post(
urlStr: String?,
body: String?,
headers: Map<String?, String?>?
): Connection.Response? {
return Jsoup.connect(urlStr)
.sslSocketFactory(SSLSocketClient.createSSLSocketFactory())
.ignoreContentType(true)
.followRedirects(false)
.requestBody(body)
.headers(headers)
.method(Connection.Method.POST)
.execute()
}
/**
*js实现读取cookie
*/
fun getCookie(tag: String, key: String? = null): String {
val cookie = CookieStore.getCookie(tag)
val cookieMap = CookieStore.cookieToMap(cookie)
return if (key != null) {
cookieMap[key] ?: ""
} else {
cookie
}
}
/**
* js实现解码,不能删
*/
fun base64Decode(str: String): String {
return EncoderUtils.base64Decode(str, Base64.NO_WRAP)
}
fun base64Decode(str: String, flags: Int): String {
return EncoderUtils.base64Decode(str, flags)
}
fun base64DecodeToByteArray(str: String?): ByteArray? {
if (str.isNullOrBlank()) {
return null
}
return Base64.decode(str, Base64.DEFAULT)
}
fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? {
if (str.isNullOrBlank()) {
return null
}
return Base64.decode(str, flags)
}
fun base64Encode(str: String): String? {
return EncoderUtils.base64Encode(str, Base64.NO_WRAP)
}
fun base64Encode(str: String, flags: Int): String? {
return EncoderUtils.base64Encode(str, flags)
}
fun md5Encode(str: String): String {
return MD5Utils.md5Encode(str)
}
fun md5Encode16(str: String): String {
return MD5Utils.md5Encode16(str)
}
/**
* 时间格式化
*/
fun timeFormat(time: Long): String {
val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm")
return sdf.format(Date(time))
}
/**
* utf8编码转gbk编码
*/
fun utf8ToGbk(str: String): String {
val utf8 = String(str.toByteArray(charset("UTF-8")))
val unicode = String(utf8.toByteArray(), charset("UTF-8"))
return String(unicode.toByteArray(charset("GBK")))
}
fun encodeURI(str: String): String {
return try {
URLEncoder.encode(str, "UTF-8")
} catch (e: Exception) {
""
}
}
fun encodeURI(str: String, enc: String): String {
return try {
URLEncoder.encode(str, enc)
} catch (e: Exception) {
""
}
}
fun htmlFormat(str: String): String {
return StringUtils.formatHtml(str)
}
/**
* 读取本地文件
*/
fun readFile(path: String): ByteArray {
return File(path).readBytes()
}
fun readTxtFile(path: String): String {
val f = File(path)
val charsetName = FileUtils.getFileCharset(f)
return String(f.readBytes(), charset(charsetName))
}
fun readTxtFile(path: String, charsetName: String): String {
return String(File(path).readBytes(), charset(charsetName))
}
/**
* 输出调试日志
*/
fun log(msg: String): String {
Log.d("JS", msg)
return msg
}
/**
* AES 解码为 ByteArray
* @param str 传入的AES加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesDecodeToByteArray(
str: String,
key: String,
transformation: String,
iv: String = ""
): ByteArray? {
return EncoderUtils.decryptAES(
data = str.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
}
/**
* AES 解码为 String
* @param str 传入的AES加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesDecodeToString(
str: String,
key: String,
transformation: String,
iv: String = ""
): String? {
return aesDecodeToByteArray(str, key, transformation, iv)?.let { String(it) }
}
/**
* 已经base64的AES 解码为 ByteArray
* @param str 传入的AES Base64加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesBase64DecodeToByteArray(
str: String,
key: String,
transformation: String,
iv: String = ""
): ByteArray? {
return EncoderUtils.decryptBase64AES(
data = str.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
}
/**
* 已经base64的AES 解码为 String
* @param str 传入的AES Base64加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesBase64DecodeToString(
str: String,
key: String,
transformation: String,
iv: String = ""
): String? {
return aesBase64DecodeToByteArray(str, key, transformation, iv)?.let { String(it) }
}
/**
* 加密aes为ByteArray
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToByteArray(
data: String, key: String, transformation: String,
iv: String = ""
): ByteArray? {
return EncoderUtils.encryptAES(
data.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
}
/**
* 加密aes为String
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToString(
data: String, key: String, transformation: String,
iv: String = ""
): String? {
return aesEncodeToByteArray(data, key, transformation, iv)?.let { String(it) }
}
/**
* 加密aes后Base64化的ByteArray
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToBase64ByteArray(
data: String, key: String, transformation: String,
iv: String = ""
): ByteArray? {
return EncoderUtils.encryptAES2Base64(
data.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
}
/**
* 加密aes后Base64化的String
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToBase64String(
data: String, key: String, transformation: String,
iv: String = ""
): String? {
return aesEncodeToBase64ByteArray(data, key, transformation, iv)?.let { String(it) }
}
}

@ -0,0 +1,49 @@
@file:Suppress("unused")
package io.legado.app.model
class AppException(msg: String) : Exception(msg)
/**
*
*/
class NoStackTraceException(msg: String) : Exception(msg) {
override fun fillInStackTrace(): Throwable {
return this
}
}
/**
* 目录为空
*/
class TocEmptyException(msg: String) : Exception(msg) {
override fun fillInStackTrace(): Throwable {
return this
}
}
/**
* 内容为空
*/
class ContentEmptyException(msg: String) : Exception(msg) {
override fun fillInStackTrace(): Throwable {
return this
}
}
/**
* 并发限制
*/
class ConcurrentException(msg: String, val waitTime: Int) : Exception(msg) {
override fun fillInStackTrace(): Throwable {
return this
}
}

@ -0,0 +1,173 @@
package io.legado.app.model.analyzeRule
import androidx.annotation.Keep
import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.ReadContext
import timber.log.Timber
import java.util.*
@Suppress("RegExpRedundantEscape")
@Keep
class AnalyzeByJSonPath(json: Any) {
companion object {
fun parse(json: Any): ReadContext {
return when (json) {
is ReadContext -> json
is String -> JsonPath.parse(json) //JsonPath.parse<String>(json)
else -> JsonPath.parse(json) //JsonPath.parse<Any>(json)
}
}
}
private var ctx: ReadContext = parse(json)
/**
* 改进解析方法
* 解决阅读&&||与jsonPath支持的&&||之间的冲突
* 解决{$.rule}形式规则可能匹配错误的问题旧规则用正则解析内容含}的json文本时用规则中的字段去匹配这种内容会匹配错误.现改用平衡嵌套方法解决这个问题
* */
fun getString(rule: String): String? {
if (rule.isEmpty()) return null
var result: String
val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡
val rules = ruleAnalyzes.splitRule("&&", "||")
if (rules.size == 1) {
ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器
result = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...}
if (result.isEmpty()) { //st为空,表明无成功替换的内嵌规则
try {
val ob = ctx.read<Any>(rule)
result = if (ob is List<*>) {
ob.joinToString("\n")
} else {
ob.toString()
}
} catch (e: Exception) {
Timber.e(e)
}
}
return result
} else {
val textList = arrayListOf<String>()
for (rl in rules) {
val temp = getString(rl)
if (!temp.isNullOrEmpty()) {
textList.add(temp)
if (ruleAnalyzes.elementsType == "||") {
break
}
}
}
return textList.joinToString("\n")
}
}
internal fun getStringList(rule: String): List<String> {
val result = ArrayList<String>()
if (rule.isEmpty()) return result
val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
if (rules.size == 1) {
ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器
val st = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...}
if (st.isEmpty()) { //st为空,表明无成功替换的内嵌规则
try {
val obj = ctx.read<Any>(rule)
if (obj is List<*>) {
for (o in obj) result.add(o.toString())
} else {
result.add(obj.toString())
}
} catch (e: Exception) {
Timber.e(e)
}
} else {
result.add(st)
}
return result
} else {
val results = ArrayList<List<String>>()
for (rl in rules) {
val temp = getStringList(rl)
if (temp.isNotEmpty()) {
results.add(temp)
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
break
}
}
}
if (results.size > 0) {
if ("%%" == ruleAnalyzes.elementsType) {
for (i in results[0].indices) {
for (temp in results) {
if (i < temp.size) {
result.add(temp[i])
}
}
}
} else {
for (temp in results) {
result.addAll(temp)
}
}
}
return result
}
}
internal fun getObject(rule: String): Any {
return ctx.read(rule)
}
internal fun getList(rule: String): ArrayList<Any>? {
val result = ArrayList<Any>()
if (rule.isEmpty()) return result
val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
if (rules.size == 1) {
ctx.let {
try {
return it.read<ArrayList<Any>>(rules[0])
} catch (e: Exception) {
Timber.e(e)
}
}
} else {
val results = ArrayList<ArrayList<*>>()
for (rl in rules) {
val temp = getList(rl)
if (temp != null && temp.isNotEmpty()) {
results.add(temp)
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
break
}
}
}
if (results.size > 0) {
if ("%%" == ruleAnalyzes.elementsType) {
for (i in 0 until results[0].size) {
for (temp in results) {
if (i < temp.size) {
temp[i]?.let { result.add(it) }
}
}
}
} else {
for (temp in results) {
result.addAll(temp)
}
}
}
}
return result
}
}

@ -0,0 +1,492 @@
package io.legado.app.model.analyzeRule
import androidx.annotation.Keep
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.select.Collector
import org.jsoup.select.Elements
import org.jsoup.select.Evaluator
import org.seimicrawler.xpath.JXNode
/**
* Created by GKF on 2018/1/25.
* 书源规则解析
*/
@Keep
class AnalyzeByJSoup(doc: Any) {
companion object {
fun parse(doc: Any): Element {
return when (doc) {
is Element -> doc
is JXNode -> if (doc.isElement) doc.asElement() else Jsoup.parse(doc.toString())
else -> Jsoup.parse(doc.toString())
}
}
}
private var element: Element = parse(doc)
/**
* 获取列表
*/
internal fun getElements(rule: String) = getElements(element, rule)
/**
* 合并内容列表,得到内容
*/
internal fun getString(ruleStr: String) =
if (ruleStr.isEmpty()) null
else getStringList(ruleStr).takeIf { it.isNotEmpty() }?.joinToString("\n")
/**
* 获取一个字符串
*/
internal fun getString0(ruleStr: String) =
getStringList(ruleStr).let { if (it.isEmpty()) "" else it[0] }
/**
* 获取所有内容列表
*/
internal fun getStringList(ruleStr: String): List<String> {
val textS = ArrayList<String>()
if (ruleStr.isEmpty()) return textS
//拆分规则
val sourceRule = SourceRule(ruleStr)
if (sourceRule.elementsRule.isEmpty()) {
textS.add(element.data() ?: "")
} else {
val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule)
val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%")
val results = ArrayList<List<String>>()
for (ruleStrX in ruleStrS) {
val temp: ArrayList<String>? =
if (sourceRule.isCss) {
val lastIndex = ruleStrX.lastIndexOf('@')
getResultLast(
element.select(ruleStrX.substring(0, lastIndex)),
ruleStrX.substring(lastIndex + 1)
)
} else {
getResultList(ruleStrX)
}
if (!temp.isNullOrEmpty()) {
results.add(temp)
if (ruleAnalyzes.elementsType == "||") break
}
}
if (results.size > 0) {
if ("%%" == ruleAnalyzes.elementsType) {
for (i in results[0].indices) {
for (temp in results) {
if (i < temp.size) {
textS.add(temp[i])
}
}
}
} else {
for (temp in results) {
textS.addAll(temp)
}
}
}
}
return textS
}
/**
* 获取Elements
*/
private fun getElements(temp: Element?, rule: String): Elements {
if (temp == null || rule.isEmpty()) return Elements()
val elements = Elements()
val sourceRule = SourceRule(rule)
val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule)
val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%")
val elementsList = ArrayList<Elements>()
if (sourceRule.isCss) {
for (ruleStr in ruleStrS) {
val tempS = temp.select(ruleStr)
elementsList.add(tempS)
if (tempS.size > 0 && ruleAnalyzes.elementsType == "||") {
break
}
}
} else {
for (ruleStr in ruleStrS) {
val rsRule = RuleAnalyzer(ruleStr)
rsRule.trim() // 修剪当前规则之前的"@"或者空白符
val rs = rsRule.splitRule("@")
val el = if (rs.size > 1) {
val el = Elements()
el.add(temp)
for (rl in rs) {
val es = Elements()
for (et in el) {
es.addAll(getElements(et, rl))
}
el.clear()
el.addAll(es)
}
el
} else ElementsSingle().getElementsSingle(temp, ruleStr)
elementsList.add(el)
if (el.size > 0 && ruleAnalyzes.elementsType == "||") {
break
}
}
}
if (elementsList.size > 0) {
if ("%%" == ruleAnalyzes.elementsType) {
for (i in 0 until elementsList[0].size) {
for (es in elementsList) {
if (i < es.size) {
elements.add(es[i])
}
}
}
} else {
for (es in elementsList) {
elements.addAll(es)
}
}
}
return elements
}
/**
* 获取内容列表
*/
private fun getResultList(ruleStr: String): ArrayList<String>? {
if (ruleStr.isEmpty()) return null
var elements = Elements()
elements.add(element)
val rule = RuleAnalyzer(ruleStr) //创建解析
rule.trim() //修建前置赘余符号
val rules = rule.splitRule("@") // 切割成列表
val last = rules.size - 1
for (i in 0 until last) {
val es = Elements()
for (elt in elements) {
es.addAll(ElementsSingle().getElementsSingle(elt, rules[i]))
}
elements.clear()
elements = es
}
return if (elements.isEmpty()) null else getResultLast(elements, rules[last])
}
/**
* 根据最后一个规则获取内容
*/
private fun getResultLast(elements: Elements, lastRule: String): ArrayList<String> {
val textS = ArrayList<String>()
when (lastRule) {
"text" -> for (element in elements) {
val text = element.text()
if (text.isNotEmpty()) {
textS.add(text)
}
}
"textNodes" -> for (element in elements) {
val tn = arrayListOf<String>()
val contentEs = element.textNodes()
for (item in contentEs) {
val text = item.text().trim { it <= ' ' }
if (text.isNotEmpty()) {
tn.add(text)
}
}
if (tn.isNotEmpty()) {
textS.add(tn.joinToString("\n"))
}
}
"ownText" -> for (element in elements) {
val text = element.ownText()
if (text.isNotEmpty()) {
textS.add(text)
}
}
"html" -> {
elements.select("script").remove()
elements.select("style").remove()
val html = elements.outerHtml()
if (html.isNotEmpty()) {
textS.add(html)
}
}
"all" -> textS.add(elements.outerHtml())
else -> for (element in elements) {
val url = element.attr(lastRule)
if (url.isBlank() || textS.contains(url)) continue
textS.add(url)
}
}
return textS
}
/**
* 1.支持阅读原有写法':'分隔索引!.表示筛选方式索引可为负数
* 例如 tag.div.-1:10:2 tag.div!0:3
*
* 2. 支持与jsonPath类似的[]索引写法
* 格式形如 [it,it] [!it,it] 其中[!开头表示筛选方式为排除it为单个索引或区间
* 区间格式为 start:end start:end:step其中start为0可省略end为-1可省略
* 索引区间两端及间隔都支持负数
* 例如 tag.div[-1, 3:-2:-10, 2]
* 特殊用法 tag.div[-1:0] 可在任意地方让列表反向
* */
@Suppress("UNCHECKED_CAST")
data class ElementsSingle(
var split: Char = '.',
var beforeRule: String = "",
val indexDefault: MutableList<Int> = mutableListOf(),
val indexes: MutableList<Any> = mutableListOf()
) {
/**
* 获取Elements按照一个规则
*/
fun getElementsSingle(temp: Element, rule: String): Elements {
findIndexSet(rule) //执行索引列表处理器
/**
* 获取所有元素
* */
var elements =
if (beforeRule.isEmpty()) temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同
else {
val rules = beforeRule.split(".")
when (rules[0]) {
"children" -> temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同
"class" -> temp.getElementsByClass(rules[1])
"tag" -> temp.getElementsByTag(rules[1])
"id" -> Collector.collect(Evaluator.Id(rules[1]), temp)
"text" -> temp.getElementsContainingOwnText(rules[1])
else -> temp.select(beforeRule)
}
}
val len = elements.size
val lastIndexes = (indexDefault.size - 1).takeIf { it != -1 } ?: indexes.size - 1
val indexSet = mutableSetOf<Int>()
/**
* 获取无重且不越界的索引集合
* */
if (indexes.isEmpty()) for (ix in lastIndexes downTo 0) { //indexes为空,表明是非[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序
val it = indexDefault[ix]
if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合
else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合
} else for (ix in lastIndexes downTo 0) { //indexes不空,表明是[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序
if (indexes[ix] is Triple<*, *, *>) { //区间
val (startX, endX, stepX) = indexes[ix] as Triple<Int?, Int?, Int> //还原储存时的类型
val start = if (startX == null) 0 //左端省略表示0
else if (startX >= 0) if (startX < len) startX else len - 1 //右端越界,设置为最大索引
else if (-startX <= len) len + startX /* 将负索引转正 */ else 0 //左端越界,设置为最小索引
val end = if (endX == null) len - 1 //右端省略表示 len - 1
else if (endX >= 0) if (endX < len) endX else len - 1 //右端越界,设置为最大索引
else if (-endX <= len) len + endX /* 将负索引转正 */ else 0 //左端越界,设置为最小索引
if (start == end || stepX >= len) { //两端相同,区间里只有一个数。或间隔过大,区间实际上仅有首位
indexSet.add(start)
continue
}
val step =
if (stepX > 0) stepX else if (-stepX < len) stepX + len else 1 //最小正数间隔为1
//将区间展开到集合中,允许列表反向。
indexSet.addAll(if (end > start) start..end step step else start downTo end step step)
} else {//单个索引
val it = indexes[ix] as Int //还原储存时的类型
if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合
else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合
}
}
/**
* 根据索引集合筛选元素
* */
if (split == '!') { //排除
for (pcInt in indexSet) elements[pcInt] = null
elements.removeAll(listOf(null)) //测试过,这样就行
} else if (split == '.') { //选择
val es = Elements()
for (pcInt in indexSet) es.add(elements[pcInt])
elements = es
}
return elements //返回筛选结果
}
private fun findIndexSet(rule: String) {
val rus = rule.trim { it <= ' ' }
var len = rus.length
var curInt: Int? //当前数字
var curMinus = false //当前数字是否为负
val curList = mutableListOf<Int?>() //当前数字区间
var l = "" //暂存数字字符串
val head = rus.last() == ']' //是否为常规索引写法
if (head) { //常规索引写法[index...]
len-- //跳过尾部']'
while (len-- >= 0) { //逆向遍历,可以无前置规则
var rl = rus[len]
if (rl == ' ') continue //跳过空格
if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出
else if (rl == '-') curMinus = true
else {
curInt =
if (l.isEmpty()) null else if (curMinus) -l.toInt() else l.toInt() //当前数字
when (rl) {
':' -> curList.add(curInt) //区间右端或区间间隔
else -> {
//为保证查找顺序,区间和单个索引都添加到同一集合
if (curList.isEmpty()) {
if (curInt == null) break //是jsoup选择器而非索引列表,跳出
indexes.add(curInt)
} else {
//列表最后压入的是区间右端,若列表有两位则最先压入的是间隔
indexes.add(
Triple(
curInt,
curList.last(),
if (curList.size == 2) curList.first() else 1
)
)
curList.clear() //重置临时列表,避免影响到下个区间的处理
}
if (rl == '!') {
split = '!'
do {
rl = rus[--len]
} while (len > 0 && rl == ' ')//跳过所有空格
}
if (rl == '[') {
beforeRule = rus.substring(0, len) //遇到索引边界,返回结果
return
}
if (rl != ',') break //非索引结构,跳出
}
}
l = "" //清空
curMinus = false //重置
}
}
} else while (len-- >= 0) { //阅读原本写法,逆向遍历,可以无前置规则
val rl = rus[len]
if (rl == ' ') continue //跳过空格
if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出
else if (rl == '-') curMinus = true
else {
if (rl == '!' || rl == '.' || rl == ':') { //分隔符或起始符
indexDefault.add(if (curMinus) -l.toInt() else l.toInt()) // 当前数字追加到列表
if (rl != ':') { //rl == '!' || rl == '.'
split = rl
beforeRule = rus.substring(0, len)
return
}
} else break //非索引结构,跳出循环
l = "" //清空
curMinus = false //重置
}
}
split = ' '
beforeRule = rus
}
}
internal inner class SourceRule(ruleStr: String) {
var isCss = false
var elementsRule: String = if (ruleStr.startsWith("@CSS:", true)) {
isCss = true
ruleStr.substring(5).trim { it <= ' ' }
} else {
ruleStr
}
}
}

@ -0,0 +1,61 @@
package io.legado.app.model.analyzeRule
import androidx.annotation.Keep
import java.util.*
import java.util.regex.Pattern
@Keep
object AnalyzeByRegex {
fun getElement(res: String, regs: Array<String>, index: Int = 0): List<String>? {
var vIndex = index
val resM = Pattern.compile(regs[vIndex]).matcher(res)
if (!resM.find()) {
return null
}
// 判断索引的规则是最后一个规则
return if (vIndex + 1 == regs.size) {
// 新建容器
val info = arrayListOf<String>()
for (groupIndex in 0..resM.groupCount()) {
info.add(resM.group(groupIndex)!!)
}
info
} else {
val result = StringBuilder()
do {
result.append(resM.group())
} while (resM.find())
getElement(result.toString(), regs, ++vIndex)
}
}
fun getElements(res: String, regs: Array<String>, index: Int = 0): List<List<String>> {
var vIndex = index
val resM = Pattern.compile(regs[vIndex]).matcher(res)
if (!resM.find()) {
return arrayListOf()
}
// 判断索引的规则是最后一个规则
if (vIndex + 1 == regs.size) {
// 创建书息缓存数组
val books = ArrayList<List<String>>()
// 提取列表
do {
// 新建容器
val info = arrayListOf<String>()
for (groupIndex in 0..resM.groupCount()) {
info.add(resM.group(groupIndex)!!)
}
books.add(info)
} while (resM.find())
return books
} else {
val result = StringBuilder()
do {
result.append(resM.group())
} while (resM.find())
return getElements(result.toString(), regs, ++vIndex)
}
}
}

@ -0,0 +1,149 @@
package io.legado.app.model.analyzeRule
import android.text.TextUtils
import androidx.annotation.Keep
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import org.seimicrawler.xpath.JXDocument
import org.seimicrawler.xpath.JXNode
import java.util.*
@Keep
class AnalyzeByXPath(doc: Any) {
private var jxNode: Any = parse(doc)
private fun parse(doc: Any): Any {
return when (doc) {
is JXNode -> if (doc.isElement) doc else strToJXDocument(doc.toString())
is Document -> JXDocument.create(doc)
is Element -> JXDocument.create(Elements(doc))
is Elements -> JXDocument.create(doc)
else -> strToJXDocument(doc.toString())
}
}
private fun strToJXDocument(html: String): JXDocument {
var html1 = html
if (html1.endsWith("</td>")) {
html1 = "<tr>${html1}</tr>"
}
if (html1.endsWith("</tr>") || html1.endsWith("</tbody>")) {
html1 = "<table>${html1}</table>"
}
return JXDocument.create(html1)
}
private fun getResult(xPath: String): List<JXNode>? {
val node = jxNode
return if (node is JXNode) {
node.sel(xPath)
} else {
(node as JXDocument).selN(xPath)
}
}
internal fun getElements(xPath: String): List<JXNode>? {
if (xPath.isEmpty()) return null
val jxNodes = ArrayList<JXNode>()
val ruleAnalyzes = RuleAnalyzer(xPath)
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
if (rules.size == 1) {
return getResult(rules[0])
} else {
val results = ArrayList<List<JXNode>>()
for (rl in rules) {
val temp = getElements(rl)
if (temp != null && temp.isNotEmpty()) {
results.add(temp)
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
break
}
}
}
if (results.size > 0) {
if ("%%" == ruleAnalyzes.elementsType) {
for (i in results[0].indices) {
for (temp in results) {
if (i < temp.size) {
jxNodes.add(temp[i])
}
}
}
} else {
for (temp in results) {
jxNodes.addAll(temp)
}
}
}
}
return jxNodes
}
internal fun getStringList(xPath: String): List<String> {
val result = ArrayList<String>()
val ruleAnalyzes = RuleAnalyzer(xPath)
val rules = ruleAnalyzes.splitRule("&&", "||", "%%")
if (rules.size == 1) {
getResult(xPath)?.map {
result.add(it.asString())
}
return result
} else {
val results = ArrayList<List<String>>()
for (rl in rules) {
val temp = getStringList(rl)
if (temp.isNotEmpty()) {
results.add(temp)
if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") {
break
}
}
}
if (results.size > 0) {
if ("%%" == ruleAnalyzes.elementsType) {
for (i in results[0].indices) {
for (temp in results) {
if (i < temp.size) {
result.add(temp[i])
}
}
}
} else {
for (temp in results) {
result.addAll(temp)
}
}
}
}
return result
}
fun getString(rule: String): String? {
val ruleAnalyzes = RuleAnalyzer(rule)
val rules = ruleAnalyzes.splitRule("&&", "||")
if (rules.size == 1) {
getResult(rule)?.let {
return TextUtils.join("\n", it)
}
return null
} else {
val textList = arrayListOf<String>()
for (rl in rules) {
val temp = getString(rl)
if (!temp.isNullOrEmpty()) {
textList.add(temp)
if (ruleAnalyzes.elementsType == "||") {
break
}
}
}
return textList.joinToString("\n")
}
}
}

@ -0,0 +1,695 @@
package io.legado.app.model.analyzeRule
import android.text.TextUtils
import androidx.annotation.Keep
import io.legado.app.constant.AppConst.SCRIPT_ENGINE
import io.legado.app.constant.AppPattern.JS_PATTERN
import io.legado.app.data.entities.BaseBook
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.BookChapter
import io.legado.app.help.CacheManager
import io.legado.app.help.JsExtensions
import io.legado.app.help.http.CookieStore
import io.legado.app.utils.*
import kotlinx.coroutines.runBlocking
import org.jsoup.nodes.Entities
import org.mozilla.javascript.NativeObject
import timber.log.Timber
import java.net.URL
import java.util.*
import java.util.regex.Pattern
import javax.script.SimpleBindings
import kotlin.collections.HashMap
/**
* 解析规则获取结果
*/
@Keep
@Suppress("unused", "RegExpRedundantEscape", "MemberVisibilityCanBePrivate")
class AnalyzeRule(
val ruleData: RuleDataInterface,
private val source: BaseSource? = null
) : JsExtensions {
var book = if (ruleData is BaseBook) ruleData else null
var chapter: BookChapter? = null
var nextChapterUrl: String? = null
var content: Any? = null
private set
var baseUrl: String? = null
private set
var redirectUrl: URL? = null
private set
private var isJSON: Boolean = false
private var isRegex: Boolean = false
private var analyzeByXPath: AnalyzeByXPath? = null
private var analyzeByJSoup: AnalyzeByJSoup? = null
private var analyzeByJSonPath: AnalyzeByJSonPath? = null
private var objectChangedXP = false
private var objectChangedJS = false
private var objectChangedJP = false
@JvmOverloads
fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule {
if (content == null) throw AssertionError("内容不可空(Content cannot be null)")
this.content = content
isJSON = content.toString().isJson()
setBaseUrl(baseUrl)
objectChangedXP = true
objectChangedJS = true
objectChangedJP = true
return this
}
fun setBaseUrl(baseUrl: String?): AnalyzeRule {
baseUrl?.let {
this.baseUrl = baseUrl
}
return this
}
fun setRedirectUrl(url: String): URL? {
try {
redirectUrl = URL(url)
} catch (e: Exception) {
log("URL($url) error\n${e.localizedMessage}")
}
return redirectUrl
}
/**
* 获取XPath解析类
*/
private fun getAnalyzeByXPath(o: Any): AnalyzeByXPath {
return if (o != content) {
AnalyzeByXPath(o)
} else {
if (analyzeByXPath == null || objectChangedXP) {
analyzeByXPath = AnalyzeByXPath(content!!)
objectChangedXP = false
}
analyzeByXPath!!
}
}
/**
* 获取JSOUP解析类
*/
private fun getAnalyzeByJSoup(o: Any): AnalyzeByJSoup {
return if (o != content) {
AnalyzeByJSoup(o)
} else {
if (analyzeByJSoup == null || objectChangedJS) {
analyzeByJSoup = AnalyzeByJSoup(content!!)
objectChangedJS = false
}
analyzeByJSoup!!
}
}
/**
* 获取JSON解析类
*/
private fun getAnalyzeByJSonPath(o: Any): AnalyzeByJSonPath {
return if (o != content) {
AnalyzeByJSonPath(o)
} else {
if (analyzeByJSonPath == null || objectChangedJP) {
analyzeByJSonPath = AnalyzeByJSonPath(content!!)
objectChangedJP = false
}
analyzeByJSonPath!!
}
}
/**
* 获取文本列表
*/
@JvmOverloads
fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List<String>? {
if (rule.isNullOrEmpty()) return null
val ruleList = splitSourceRule(rule, false)
return getStringList(ruleList, mContent, isUrl)
}
@JvmOverloads
fun getStringList(
ruleList: List<SourceRule>,
mContent: Any? = null,
isUrl: Boolean = false
): List<String>? {
var result: Any? = null
val content = mContent ?: this.content
if (content != null && ruleList.isNotEmpty()) {
result = content
if (content is NativeObject) {
result = content[ruleList[0].rule]?.toString()
} else {
for (sourceRule in ruleList) {
putRule(sourceRule.putMap)
sourceRule.makeUpRule(result)
result?.let {
if (sourceRule.rule.isNotEmpty()) {
result = when (sourceRule.mode) {
Mode.Js -> evalJS(sourceRule.rule, result)
Mode.Json -> getAnalyzeByJSonPath(it).getStringList(sourceRule.rule)
Mode.XPath -> getAnalyzeByXPath(it).getStringList(sourceRule.rule)
Mode.Default -> getAnalyzeByJSoup(it).getStringList(sourceRule.rule)
else -> sourceRule.rule
}
}
if (sourceRule.replaceRegex.isNotEmpty() && result is List<*>) {
val newList = ArrayList<String>()
for (item in result as List<*>) {
newList.add(replaceRegex(item.toString(), sourceRule))
}
result = newList
} else if (sourceRule.replaceRegex.isNotEmpty()) {
result = replaceRegex(result.toString(), sourceRule)
}
}
}
}
}
if (result == null) return null
if (result is String) {
result = (result as String).split("\n")
}
if (isUrl) {
val urlList = ArrayList<String>()
if (result is List<*>) {
for (url in result as List<*>) {
val absoluteURL = NetworkUtils.getAbsoluteURL(redirectUrl, url.toString())
if (absoluteURL.isNotEmpty() && !urlList.contains(absoluteURL)) {
urlList.add(absoluteURL)
}
}
}
return urlList
}
@Suppress("UNCHECKED_CAST")
return result as? List<String>
}
/**
* 获取文本
*/
@JvmOverloads
fun getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false): String {
if (TextUtils.isEmpty(ruleStr)) return ""
val ruleList = splitSourceRule(ruleStr)
return getString(ruleList, mContent, isUrl)
}
@JvmOverloads
fun getString(
ruleList: List<SourceRule>,
mContent: Any? = null,
isUrl: Boolean = false
): String {
var result: Any? = null
val content = mContent ?: this.content
if (content != null && ruleList.isNotEmpty()) {
result = content
if (result is NativeObject) {
result = result[ruleList[0].rule]?.toString()
} else {
for (sourceRule in ruleList) {
putRule(sourceRule.putMap)
sourceRule.makeUpRule(result)
result?.let {
if (sourceRule.rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
result = when (sourceRule.mode) {
Mode.Js -> evalJS(sourceRule.rule, it)
Mode.Json -> getAnalyzeByJSonPath(it).getString(sourceRule.rule)
Mode.XPath -> getAnalyzeByXPath(it).getString(sourceRule.rule)
Mode.Default -> if (isUrl) {
getAnalyzeByJSoup(it).getString0(sourceRule.rule)
} else {
getAnalyzeByJSoup(it).getString(sourceRule.rule)
}
else -> sourceRule.rule
}
}
if ((result != null) && sourceRule.replaceRegex.isNotEmpty()) {
result = replaceRegex(result.toString(), sourceRule)
}
}
}
}
}
if (result == null) result = ""
val str = kotlin.runCatching {
Entities.unescape(result.toString())
}.onFailure {
log("Entities.unescape() error\n${it.localizedMessage}")
}.getOrElse {
result.toString()
}
if (isUrl) {
return if (str.isBlank()) {
baseUrl ?: ""
} else {
NetworkUtils.getAbsoluteURL(redirectUrl, str)
}
}
return str
}
/**
* 获取Element
*/
fun getElement(ruleStr: String): Any? {
if (TextUtils.isEmpty(ruleStr)) return null
var result: Any? = null
val content = this.content
val ruleList = splitSourceRule(ruleStr, true)
if (content != null && ruleList.isNotEmpty()) {
result = content
for (sourceRule in ruleList) {
putRule(sourceRule.putMap)
sourceRule.makeUpRule(result)
result?.let {
result = when (sourceRule.mode) {
Mode.Regex -> AnalyzeByRegex.getElement(
result.toString(),
sourceRule.rule.splitNotBlank("&&")
)
Mode.Js -> evalJS(sourceRule.rule, it)
Mode.Json -> getAnalyzeByJSonPath(it).getObject(sourceRule.rule)
Mode.XPath -> getAnalyzeByXPath(it).getElements(sourceRule.rule)
else -> getAnalyzeByJSoup(it).getElements(sourceRule.rule)
}
if (sourceRule.replaceRegex.isNotEmpty()) {
result = replaceRegex(result.toString(), sourceRule)
}
}
}
}
return result
}
/**
* 获取列表
*/
@Suppress("UNCHECKED_CAST")
fun getElements(ruleStr: String): List<Any> {
var result: Any? = null
val content = this.content
val ruleList = splitSourceRule(ruleStr, true)
if (content != null && ruleList.isNotEmpty()) {
result = content
for (sourceRule in ruleList) {
putRule(sourceRule.putMap)
result?.let {
result = when (sourceRule.mode) {
Mode.Regex -> AnalyzeByRegex.getElements(
result.toString(),
sourceRule.rule.splitNotBlank("&&")
)
Mode.Js -> evalJS(sourceRule.rule, result)
Mode.Json -> getAnalyzeByJSonPath(it).getList(sourceRule.rule)
Mode.XPath -> getAnalyzeByXPath(it).getElements(sourceRule.rule)
else -> getAnalyzeByJSoup(it).getElements(sourceRule.rule)
}
if (sourceRule.replaceRegex.isNotEmpty()) {
result = replaceRegex(result.toString(), sourceRule)
}
}
}
}
result?.let {
return it as List<Any>
}
return ArrayList()
}
/**
* 保存变量
*/
private fun putRule(map: Map<String, String>) {
for ((key, value) in map) {
put(key, getString(value))
}
}
/**
* 分离put规则
*/
private fun splitPutRule(ruleStr: String, putMap: HashMap<String, String>): String {
var vRuleStr = ruleStr
val putMatcher = putPattern.matcher(vRuleStr)
while (putMatcher.find()) {
vRuleStr = vRuleStr.replace(putMatcher.group(), "")
val map = GSON.fromJsonObject<Map<String, String>>(putMatcher.group(1))
map?.let { putMap.putAll(map) }
}
return vRuleStr
}
/**
* 正则替换
*/
private fun replaceRegex(result: String, rule: SourceRule): String {
if (rule.replaceRegex.isEmpty()) return result
var vResult = result
vResult = if (rule.replaceFirst) {
kotlin.runCatching {
val pattern = Pattern.compile(rule.replaceRegex)
val matcher = pattern.matcher(vResult)
if (matcher.find()) {
matcher.group(0)!!.replaceFirst(rule.replaceRegex.toRegex(), rule.replacement)
} else {
""
}
}.getOrElse {
vResult.replaceFirst(rule.replaceRegex, rule.replacement)
}
} else {
kotlin.runCatching {
vResult.replace(rule.replaceRegex.toRegex(), rule.replacement)
}.getOrElse {
vResult.replace(rule.replaceRegex, rule.replacement)
}
}
return vResult
}
/**
* 分解规则生成规则列表
*/
fun splitSourceRule(ruleStr: String?, allInOne: Boolean = false): List<SourceRule> {
if (ruleStr.isNullOrEmpty()) return ArrayList<SourceRule>()
val ruleList = ArrayList<SourceRule>()
var mMode: Mode = Mode.Default
var start = 0
//仅首字符为:时为AllInOne,其实:与伪类选择器冲突,建议改成?更合理
if (allInOne && ruleStr.startsWith(":")) {
mMode = Mode.Regex
isRegex = true
start = 1
} else if (isRegex) {
mMode = Mode.Regex
}
var tmp: String
val jsMatcher = JS_PATTERN.matcher(ruleStr)
while (jsMatcher.find()) {
if (jsMatcher.start() > start) {
tmp = ruleStr.substring(start, jsMatcher.start()).trim { it <= ' ' }
if (tmp.isNotEmpty()) {
ruleList.add(SourceRule(tmp, mMode))
}
}
ruleList.add(SourceRule(jsMatcher.group(2) ?: jsMatcher.group(1), Mode.Js))
start = jsMatcher.end()
}
if (ruleStr.length > start) {
tmp = ruleStr.substring(start).trim { it <= ' ' }
if (tmp.isNotEmpty()) {
ruleList.add(SourceRule(tmp, mMode))
}
}
return ruleList
}
/**
* 规则类
*/
inner class SourceRule internal constructor(
ruleStr: String,
internal var mode: Mode = Mode.Default
) {
internal var rule: String
internal var replaceRegex = ""
internal var replacement = ""
internal var replaceFirst = false
internal val putMap = HashMap<String, String>()
private val ruleParam = ArrayList<String>()
private val ruleType = ArrayList<Int>()
private val getRuleType = -2
private val jsRuleType = -1
private val defaultRuleType = 0
init {
rule = when {
mode == Mode.Js || mode == Mode.Regex -> ruleStr
ruleStr.startsWith("@CSS:", true) -> {
mode = Mode.Default
ruleStr
}
ruleStr.startsWith("@@") -> {
mode = Mode.Default
ruleStr.substring(2)
}
ruleStr.startsWith("@XPath:", true) -> {
mode = Mode.XPath
ruleStr.substring(7)
}
ruleStr.startsWith("@Json:", true) -> {
mode = Mode.Json
ruleStr.substring(6)
}
isJSON || ruleStr.startsWith("$.") || ruleStr.startsWith("$[") -> {
mode = Mode.Json
ruleStr
}
ruleStr.startsWith("/") -> {//XPath特征很明显,无需配置单独的识别标头
mode = Mode.XPath
ruleStr
}
else -> ruleStr
}
//分离put
rule = splitPutRule(rule, putMap)
//@get,{{ }}, 拆分
var start = 0
var tmp: String
val evalMatcher = evalPattern.matcher(rule)
if (evalMatcher.find()) {
tmp = rule.substring(start, evalMatcher.start())
if (mode != Mode.Js && mode != Mode.Regex &&
(evalMatcher.start() == 0 || !tmp.contains("##"))
) {
mode = Mode.Regex
}
do {
if (evalMatcher.start() > start) {
tmp = rule.substring(start, evalMatcher.start())
splitRegex(tmp)
}
tmp = evalMatcher.group()
when {
tmp.startsWith("@get:", true) -> {
ruleType.add(getRuleType)
ruleParam.add(tmp.substring(6, tmp.lastIndex))
}
tmp.startsWith("{{") -> {
ruleType.add(jsRuleType)
ruleParam.add(tmp.substring(2, tmp.length - 2))
}
else -> {
splitRegex(tmp)
}
}
start = evalMatcher.end()
} while (evalMatcher.find())
}
if (rule.length > start) {
tmp = rule.substring(start)
splitRegex(tmp)
}
}
/**
* 拆分\$\d{1,2}
*/
private fun splitRegex(ruleStr: String) {
var start = 0
var tmp: String
val ruleStrArray = ruleStr.split("##")
val regexMatcher = regexPattern.matcher(ruleStrArray[0])
if (regexMatcher.find()) {
if (mode != Mode.Js && mode != Mode.Regex) {
mode = Mode.Regex
}
do {
if (regexMatcher.start() > start) {
tmp = ruleStr.substring(start, regexMatcher.start())
ruleType.add(defaultRuleType)
ruleParam.add(tmp)
}
tmp = regexMatcher.group()
ruleType.add(tmp.substring(1).toInt())
ruleParam.add(tmp)
start = regexMatcher.end()
} while (regexMatcher.find())
}
if (ruleStr.length > start) {
tmp = ruleStr.substring(start)
ruleType.add(defaultRuleType)
ruleParam.add(tmp)
}
}
/**
* 替换@get,{{ }}
*/
fun makeUpRule(result: Any?) {
val infoVal = StringBuilder()
if (ruleParam.isNotEmpty()) {
var index = ruleParam.size
while (index-- > 0) {
val regType = ruleType[index]
when {
regType > defaultRuleType -> {
@Suppress("UNCHECKED_CAST")
(result as? List<String?>)?.run {
if (this.size > regType) {
this[regType]?.let {
infoVal.insert(0, it)
}
}
} ?: infoVal.insert(0, ruleParam[index])
}
regType == jsRuleType -> {
if (isRule(ruleParam[index])) {
getString(arrayListOf(SourceRule(ruleParam[index]))).let {
infoVal.insert(0, it)
}
} else {
val jsEval: Any? = evalJS(ruleParam[index], result)
when {
jsEval == null -> Unit
jsEval is String -> infoVal.insert(0, jsEval)
jsEval is Double && jsEval % 1.0 == 0.0 -> infoVal.insert(
0,
String.format("%.0f", jsEval)
)
else -> infoVal.insert(0, jsEval.toString())
}
}
}
regType == getRuleType -> {
infoVal.insert(0, get(ruleParam[index]))
}
else -> infoVal.insert(0, ruleParam[index])
}
}
rule = infoVal.toString()
}
//分离正则表达式
val ruleStrS = rule.split("##")
rule = ruleStrS[0].trim()
if (ruleStrS.size > 1) {
replaceRegex = ruleStrS[1]
}
if (ruleStrS.size > 2) {
replacement = ruleStrS[2]
}
if (ruleStrS.size > 3) {
replaceFirst = true
}
}
private fun isRule(ruleStr: String): Boolean {
return ruleStr.startsWith('@') //js首个字符不可能是@,除非是装饰器,所以@开头规定为规则
|| ruleStr.startsWith("$.")
|| ruleStr.startsWith("$[")
|| ruleStr.startsWith("//")
}
}
enum class Mode {
XPath, Json, Default, Js, Regex
}
fun put(key: String, value: String): String {
chapter?.putVariable(key, value)
?: book?.putVariable(key, value)
?: ruleData.putVariable(key, value)
return value
}
fun get(key: String): String {
when (key) {
"bookName" -> book?.let {
return it.name
}
"title" -> chapter?.let {
return it.title
}
}
return chapter?.variableMap?.get(key)
?: book?.variableMap?.get(key)
?: ruleData.variableMap[key]
?: ""
}
/**
* 执行JS
*/
fun evalJS(jsStr: String, result: Any?): Any? {
val bindings = SimpleBindings()
bindings["java"] = this
bindings["cookie"] = CookieStore
bindings["cache"] = CacheManager
bindings["source"] = source
bindings["book"] = book
bindings["result"] = result
bindings["baseUrl"] = baseUrl
bindings["chapter"] = chapter
bindings["title"] = chapter?.title
bindings["src"] = content
bindings["nextChapterUrl"] = nextChapterUrl
return SCRIPT_ENGINE.eval(jsStr, bindings)
}
override fun getSource(): BaseSource? {
return source
}
/**
* js实现跨域访问,不能删
*/
override fun ajax(urlStr: String): String? {
return runBlocking {
kotlin.runCatching {
val analyzeUrl = AnalyzeUrl(urlStr, source = source, ruleData = book)
analyzeUrl.getStrResponseAwait().body
}.onFailure {
log("ajax(${urlStr}) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
it.msg
}
}
}
/**
* 章节数转数字
*/
fun toNumChapter(s: String?): String? {
s ?: return null
val matcher = titleNumPattern.matcher(s)
if (matcher.find()) {
return "${matcher.group(1)}${StringUtils.stringToInt(matcher.group(2))}${matcher.group(3)}"
}
return s
}
companion object {
private val putPattern = Pattern.compile("@put:(\\{[^}]+?\\})", Pattern.CASE_INSENSITIVE)
private val evalPattern =
Pattern.compile("@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}", Pattern.CASE_INSENSITIVE)
private val regexPattern = Pattern.compile("\\$\\d{1,2}")
private val titleNumPattern = Pattern.compile("(第)(.+?)(章)")
}
}

@ -0,0 +1,554 @@
package io.legado.app.model.analyzeRule
import android.annotation.SuppressLint
import androidx.annotation.Keep
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.LazyHeaders
import io.legado.app.constant.AppConst.SCRIPT_ENGINE
import io.legado.app.constant.AppConst.UA_NAME
import io.legado.app.constant.AppPattern.JS_PATTERN
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.help.AppConfig
import io.legado.app.help.CacheManager
import io.legado.app.help.JsExtensions
import io.legado.app.help.http.*
import io.legado.app.model.ConcurrentException
import io.legado.app.utils.*
import kotlinx.coroutines.runBlocking
import okhttp3.Response
import java.net.URLEncoder
import java.util.*
import java.util.regex.Pattern
import javax.script.SimpleBindings
import kotlin.collections.HashMap
/**
* Created by GKF on 2018/1/24.
* 搜索URL规则解析
*/
@Suppress("unused", "MemberVisibilityCanBePrivate")
@Keep
@SuppressLint("DefaultLocale")
class AnalyzeUrl(
val mUrl: String,
val key: String? = null,
val page: Int? = null,
val speakText: String? = null,
val speakSpeed: Int? = null,
var baseUrl: String = "",
private val source: BaseSource? = null,
private val ruleData: RuleDataInterface? = null,
private val chapter: BookChapter? = null,
headerMapF: Map<String, String>? = null,
) : JsExtensions {
companion object {
val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)")
private val pagePattern = Pattern.compile("<(.*?)>")
private val concurrentRecordMap = hashMapOf<String, ConcurrentRecord>()
}
var ruleUrl = ""
private set
var url: String = ""
private set
var body: String? = null
private set
var type: String? = null
private set
val headerMap = HashMap<String, String>()
private var urlNoQuery: String = ""
private var queryStr: String? = null
private val fieldMap = LinkedHashMap<String, String>()
private var charset: String? = null
private var method = RequestMethod.GET
private var proxy: String? = null
private var retry: Int = 0
private var useWebView: Boolean = false
private var webJs: String? = null
init {
val urlMatcher = paramPattern.matcher(baseUrl)
if (urlMatcher.find()) baseUrl = baseUrl.substring(0, urlMatcher.start())
headerMapF?.let {
headerMap.putAll(it)
if (it.containsKey("proxy")) {
proxy = it["proxy"]
headerMap.remove("proxy")
}
}
initUrl()
}
/**
* 处理url
*/
fun initUrl() {
ruleUrl = mUrl
//执行@js,<js></js>
analyzeJs()
//替换参数
replaceKeyPageJs()
//处理URL
analyzeUrl()
}
/**
* 执行@js,<js></js>
*/
private fun analyzeJs() {
var start = 0
var tmp: String
val jsMatcher = JS_PATTERN.matcher(ruleUrl)
while (jsMatcher.find()) {
if (jsMatcher.start() > start) {
tmp =
ruleUrl.substring(start, jsMatcher.start()).trim { it <= ' ' }
if (tmp.isNotEmpty()) {
ruleUrl = tmp.replace("@result", ruleUrl)
}
}
ruleUrl = evalJS(jsMatcher.group(2) ?: jsMatcher.group(1), ruleUrl) as String
start = jsMatcher.end()
}
if (ruleUrl.length > start) {
tmp = ruleUrl.substring(start).trim { it <= ' ' }
if (tmp.isNotEmpty()) {
ruleUrl = tmp.replace("@result", ruleUrl)
}
}
}
/**
* 替换关键字,页数,JS
*/
private fun replaceKeyPageJs() { //先替换内嵌规则再替换页数规则,避免内嵌规则中存在大于小于号时,规则被切错
//js
if (ruleUrl.contains("{{") && ruleUrl.contains("}}")) {
val analyze = RuleAnalyzer(ruleUrl) //创建解析
//替换所有内嵌{{js}}
val url = analyze.innerRule("{{", "}}") {
val jsEval = evalJS(it) ?: ""
when {
jsEval is String -> jsEval
jsEval is Double && jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
else -> jsEval.toString()
}
}
if (url.isNotEmpty()) ruleUrl = url
}
//page
page?.let {
val matcher = pagePattern.matcher(ruleUrl)
while (matcher.find()) {
val pages = matcher.group(1)!!.split(",")
ruleUrl = if (page < pages.size) { //pages[pages.size - 1]等同于pages.last()
ruleUrl.replace(matcher.group(), pages[page - 1].trim { it <= ' ' })
} else {
ruleUrl.replace(matcher.group(), pages.last().trim { it <= ' ' })
}
}
}
}
/**
* 解析Url
*/
private fun analyzeUrl() {
//replaceKeyPageJs已经替换掉额外内容,此处url是基础形式,可以直接切首个‘,’之前字符串。
val urlMatcher = paramPattern.matcher(ruleUrl)
val urlNoOption =
if (urlMatcher.find()) ruleUrl.substring(0, urlMatcher.start()) else ruleUrl
url = NetworkUtils.getAbsoluteURL(baseUrl, urlNoOption)
NetworkUtils.getBaseUrl(url)?.let {
baseUrl = it
}
if (urlNoOption.length != ruleUrl.length) {
GSON.fromJsonObject<UrlOption>(ruleUrl.substring(urlMatcher.end()))?.let { option ->
option.method?.let {
if (it.equals("POST", true)) method = RequestMethod.POST
}
option.headers?.let { headers ->
if (headers is Map<*, *>) {
headers.forEach { entry ->
headerMap[entry.key.toString()] = entry.value.toString()
}
} else if (headers is String) {
GSON.fromJsonObject<Map<String, String>>(headers)
?.let { headerMap.putAll(it) }
}
}
option.body?.let {
body = if (it is String) it else GSON.toJson(it)
}
type = option.type
charset = option.charset
retry = option.retry
useWebView = option.webView?.toString()?.isNotBlank() == true
webJs = option.webJs
option.js?.let { jsStr ->
evalJS(jsStr, url)?.toString()?.let {
url = it
}
}
}
}
headerMap[UA_NAME] ?: let {
headerMap[UA_NAME] = AppConfig.userAgent
}
urlNoQuery = url
when (method) {
RequestMethod.GET -> {
val pos = url.indexOf('?')
if (pos != -1) {
analyzeFields(url.substring(pos + 1))
urlNoQuery = url.substring(0, pos)
}
}
RequestMethod.POST -> body?.let {
if (!it.isJson()) {
analyzeFields(it)
}
}
}
}
/**
* 解析QueryMap
*/
private fun analyzeFields(fieldsTxt: String) {
queryStr = fieldsTxt
val queryS = fieldsTxt.splitNotBlank("&")
for (query in queryS) {
val queryM = query.splitNotBlank("=")
val value = if (queryM.size > 1) queryM[1] else ""
if (charset.isNullOrEmpty()) {
if (NetworkUtils.hasUrlEncoded(value)) {
fieldMap[queryM[0]] = value
} else {
fieldMap[queryM[0]] = URLEncoder.encode(value, "UTF-8")
}
} else if (charset == "escape") {
fieldMap[queryM[0]] = EncoderUtils.escape(value)
} else {
fieldMap[queryM[0]] = URLEncoder.encode(value, charset)
}
}
}
/**
* 执行JS
*/
fun evalJS(jsStr: String, result: Any? = null): Any? {
val bindings = SimpleBindings()
bindings["java"] = this
bindings["baseUrl"] = baseUrl
bindings["cookie"] = CookieStore
bindings["cache"] = CacheManager
bindings["page"] = page
bindings["key"] = key
bindings["speakText"] = speakText
bindings["speakSpeed"] = speakSpeed
bindings["book"] = ruleData as? Book
bindings["source"] = source
bindings["result"] = result
return SCRIPT_ENGINE.eval(jsStr, bindings)
}
fun put(key: String, value: String): String {
chapter?.putVariable(key, value)
?: ruleData?.putVariable(key, value)
return value
}
fun get(key: String): String {
when (key) {
"bookName" -> (ruleData as? Book)?.let {
return it.name
}
"title" -> chapter?.let {
return it.title
}
}
return chapter?.variableMap?.get(key)
?: ruleData?.variableMap?.get(key)
?: ""
}
/**
* 开始访问,并发判断
*/
private fun fetchStart(): ConcurrentRecord? {
source ?: return null
val concurrentRate = source.concurrentRate
if (concurrentRate.isNullOrEmpty()) {
return null
}
val rateIndex = concurrentRate.indexOf("/")
var fetchRecord = concurrentRecordMap[source.getKey()]
if (fetchRecord == null) {
fetchRecord = ConcurrentRecord(rateIndex > 0, System.currentTimeMillis(), 1)
concurrentRecordMap[source.getKey()] = fetchRecord
return fetchRecord
}
val waitTime: Int = synchronized(fetchRecord) {
try {
if (rateIndex == -1) {
if (fetchRecord.frequency > 0) {
return@synchronized concurrentRate.toInt()
}
val nextTime = fetchRecord.time + concurrentRate.toInt()
if (System.currentTimeMillis() >= nextTime) {
fetchRecord.time = System.currentTimeMillis()
fetchRecord.frequency = 1
return@synchronized 0
}
return@synchronized (nextTime - System.currentTimeMillis()).toInt()
} else {
val sj = concurrentRate.substring(rateIndex + 1)
val nextTime = fetchRecord.time + sj.toInt()
if (System.currentTimeMillis() >= nextTime) {
fetchRecord.time = System.currentTimeMillis()
fetchRecord.frequency = 1
return@synchronized 0
}
val cs = concurrentRate.substring(0, rateIndex)
if (fetchRecord.frequency > cs.toInt()) {
return@synchronized (nextTime - System.currentTimeMillis()).toInt()
} else {
fetchRecord.frequency = fetchRecord.frequency + 1
return@synchronized 0
}
}
} catch (e: Exception) {
return@synchronized 0
}
}
if (waitTime > 0) {
throw ConcurrentException("根据并发率还需等待${waitTime}毫秒才可以访问", waitTime = waitTime)
}
return fetchRecord
}
/**
* 访问结束
*/
private fun fetchEnd(concurrentRecord: ConcurrentRecord?) {
if (concurrentRecord != null && !concurrentRecord.concurrent) {
synchronized(concurrentRecord) {
concurrentRecord.frequency = concurrentRecord.frequency - 1
}
}
}
/**
* 访问网站,返回StrResponse
*/
suspend fun getStrResponseAwait(
jsStr: String? = null,
sourceRegex: String? = null,
useWebView: Boolean = true,
): StrResponse {
if (type != null) {
return StrResponse(url, StringUtils.byteToHexString(getByteArrayAwait()))
}
val concurrentRecord = fetchStart()
setCookie(source?.getKey())
val strResponse: StrResponse
if (this.useWebView && useWebView) {
strResponse = when (method) {
RequestMethod.POST -> {
val body = getProxyClient(proxy).newCallStrResponse(retry) {
addHeaders(headerMap)
url(urlNoQuery)
if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
postForm(fieldMap, true)
} else {
postJson(body)
}
}.body
BackstageWebView(
url = url,
html = body,
tag = source?.getKey(),
javaScript = webJs ?: jsStr,
sourceRegex = sourceRegex,
headerMap = headerMap
).getStrResponse()
}
else -> BackstageWebView(
url = url,
tag = source?.getKey(),
javaScript = webJs ?: jsStr,
sourceRegex = sourceRegex,
headerMap = headerMap
).getStrResponse()
}
} else {
strResponse = getProxyClient(proxy).newCallStrResponse(retry) {
addHeaders(headerMap)
when (method) {
RequestMethod.POST -> {
url(urlNoQuery)
if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
postForm(fieldMap, true)
} else {
postJson(body)
}
}
else -> get(urlNoQuery, fieldMap, true)
}
}
}
fetchEnd(concurrentRecord)
return strResponse
}
@JvmOverloads
fun getStrResponse(
jsStr: String? = null,
sourceRegex: String? = null,
useWebView: Boolean = true,
): StrResponse {
return runBlocking {
getStrResponseAwait(jsStr, sourceRegex, useWebView)
}
}
/**
* 访问网站,返回Response
*/
suspend fun getResponseAwait(): Response {
val concurrentRecord = fetchStart()
setCookie(source?.getKey())
@Suppress("BlockingMethodInNonBlockingContext")
val response = getProxyClient(proxy).newCallResponse(retry) {
addHeaders(headerMap)
when (method) {
RequestMethod.POST -> {
url(urlNoQuery)
if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
postForm(fieldMap, true)
} else {
postJson(body)
}
}
else -> get(urlNoQuery, fieldMap, true)
}
}
fetchEnd(concurrentRecord)
return response
}
fun getResponse(): Response {
return runBlocking {
getResponseAwait()
}
}
/**
* 访问网站,返回ByteArray
*/
suspend fun getByteArrayAwait(): ByteArray {
val concurrentRecord = fetchStart()
setCookie(source?.getKey())
@Suppress("BlockingMethodInNonBlockingContext")
val byteArray = getProxyClient(proxy).newCallResponseBody(retry) {
addHeaders(headerMap)
when (method) {
RequestMethod.POST -> {
url(urlNoQuery)
if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
postForm(fieldMap, true)
} else {
postJson(body)
}
}
else -> get(urlNoQuery, fieldMap, true)
}
}.bytes()
fetchEnd(concurrentRecord)
return byteArray
}
fun getByteArray(): ByteArray {
return runBlocking {
getByteArrayAwait()
}
}
/**
* 上传文件
*/
suspend fun upload(fileName: String, file: Any, contentType: String): StrResponse {
return getProxyClient(proxy).newCallStrResponse(retry) {
url(urlNoQuery)
val bodyMap = GSON.fromJsonObject<HashMap<String, Any>>(body)!!
bodyMap.forEach { entry ->
if (entry.value.toString() == "fileRequest") {
bodyMap[entry.key] = mapOf(
Pair("fileName", fileName),
Pair("file", file),
Pair("contentType", contentType)
)
}
}
postMultipart(type, bodyMap)
}
}
private fun setCookie(tag: String?) {
if (tag != null) {
val cookie = CookieStore.getCookie(tag)
if (cookie.isNotEmpty()) {
val cookieMap = CookieStore.cookieToMap(cookie)
val customCookieMap = CookieStore.cookieToMap(headerMap["Cookie"] ?: "")
cookieMap.putAll(customCookieMap)
val newCookie = CookieStore.mapToCookie(cookieMap)
newCookie?.let {
headerMap.put("Cookie", it)
}
}
}
}
fun getGlideUrl(): GlideUrl {
val headers = LazyHeaders.Builder()
headerMap.forEach { (key, value) ->
headers.addHeader(key, value)
}
return GlideUrl(url, headers.build())
}
fun getUserAgent(): String {
return headerMap[UA_NAME] ?: AppConfig.userAgent
}
fun isPost(): Boolean {
return method == RequestMethod.POST
}
override fun getSource(): BaseSource? {
return source
}
data class UrlOption(
val method: String?,
val charset: String?,
val headers: Any?,
val body: Any?,
val type: String?,
val js: String?,
val retry: Int = 0,
val webView: Any?,
val webJs: String?,
)
data class ConcurrentRecord(
val concurrent: Boolean,
var time: Long,
var frequency: Int
)
}

@ -0,0 +1,655 @@
package io.legado.app.help
import android.net.Uri
import android.util.Base64
import androidx.annotation.Keep
import io.legado.app.BuildConfig
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppConst.dateFormat
import io.legado.app.data.entities.BaseSource
import io.legado.app.help.http.*
import io.legado.app.model.Debug
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.utils.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.jsoup.Connection
import org.jsoup.Jsoup
import splitties.init.appCtx
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.URLEncoder
import java.nio.charset.Charset
import java.text.SimpleDateFormat
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/**
* js扩展类, 在js中通过java变量调用
* 所有对于文件的读写删操作都是相对路径,只能操作阅读缓存内的文件
* /android/data/{package}/cache/...
*/
@Keep
@Suppress("unused")
interface JsExtensions {
fun getSource(): BaseSource?
/**
* 访问网络,返回String
*/
fun ajax(urlStr: String): String? {
return runBlocking {
kotlin.runCatching {
val analyzeUrl = AnalyzeUrl(urlStr, source = getSource())
analyzeUrl.getStrResponseAwait().body
}.onFailure {
log("ajax(${urlStr}) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
it.msg
}
}
}
/**
* 并发访问网络
*/
fun ajaxAll(urlList: Array<String>): Array<StrResponse?> {
return runBlocking {
val asyncArray = Array(urlList.size) {
async(IO) {
val url = urlList[it]
val analyzeUrl = AnalyzeUrl(url, source = getSource())
analyzeUrl.getStrResponseAwait()
}
}
val resArray = Array<StrResponse?>(urlList.size) {
asyncArray[it].await()
}
resArray
}
}
/**
* 访问网络,返回Response<String>
*/
fun connect(urlStr: String): StrResponse {
return runBlocking {
val analyzeUrl = AnalyzeUrl(urlStr, source = getSource())
kotlin.runCatching {
analyzeUrl.getStrResponseAwait()
}.onFailure {
log("connect(${urlStr}) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
StrResponse(analyzeUrl.url, it.localizedMessage)
}
}
}
fun connect(urlStr: String, header: String?): StrResponse {
return runBlocking {
val headerMap = GSON.fromJsonObject<Map<String, String>>(header)
val analyzeUrl = AnalyzeUrl(urlStr, headerMapF = headerMap, source = getSource())
kotlin.runCatching {
analyzeUrl.getStrResponseAwait()
}.onFailure {
log("ajax($urlStr,$header) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
StrResponse(analyzeUrl.url, it.localizedMessage)
}
}
}
/**
* 使用webView访问网络
* @param html 直接用webView载入的html, 如果html为空直接访问url
* @param url html内如果有相对路径的资源不传入url访问不了
* @param js 用来取返回值的js语句, 没有就返回整个源代码
* @return 返回js获取的内容
*/
fun webView(html: String?, url: String?, js: String?): String? {
return runBlocking {
BackstageWebView(
url = url,
html = html,
javaScript = js
).getStrResponse().body
}
}
/**
* 实现16进制字符串转文件
* @param content 需要转成文件的16进制字符串
* @param url 通过url里的参数来判断文件类型
* @return 相对路径
*/
fun downloadFile(content: String, url: String): String {
val type = AnalyzeUrl(url, source = getSource()).type ?: return ""
val zipPath = FileUtils.getPath(
FileUtils.createFolderIfNotExist(FileUtils.getCachePath()),
"${MD5Utils.md5Encode16(url)}.${type}"
)
FileUtils.deleteFile(zipPath)
val zipFile = FileUtils.createFileIfNotExist(zipPath)
StringUtils.hexStringToByte(content).let {
if (it.isNotEmpty()) {
zipFile.writeBytes(it)
}
}
return zipPath.substring(FileUtils.getCachePath().length)
}
/**
* js实现重定向拦截,网络访问get
*/
fun get(urlStr: String, headers: Map<String, String>): Connection.Response {
return Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.ignoreContentType(true)
.followRedirects(false)
.headers(headers)
.method(Connection.Method.GET)
.execute()
}
/**
* 网络访问post
*/
fun post(urlStr: String, body: String, headers: Map<String, String>): Connection.Response {
return Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.ignoreContentType(true)
.followRedirects(false)
.requestBody(body)
.headers(headers)
.method(Connection.Method.POST)
.execute()
}
/**
*js实现读取cookie
*/
fun getCookie(tag: String, key: String? = null): String {
val cookie = CookieStore.getCookie(tag)
val cookieMap = CookieStore.cookieToMap(cookie)
return if (key != null) {
cookieMap[key] ?: ""
} else {
cookie
}
}
/**
* js实现解码,不能删
*/
fun base64Decode(str: String): String {
return EncoderUtils.base64Decode(str, Base64.NO_WRAP)
}
fun base64Decode(str: String, flags: Int): String {
return EncoderUtils.base64Decode(str, flags)
}
fun base64DecodeToByteArray(str: String?): ByteArray? {
if (str.isNullOrBlank()) {
return null
}
return Base64.decode(str, Base64.DEFAULT)
}
fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? {
if (str.isNullOrBlank()) {
return null
}
return Base64.decode(str, flags)
}
fun base64Encode(str: String): String? {
return EncoderUtils.base64Encode(str, Base64.NO_WRAP)
}
fun base64Encode(str: String, flags: Int): String? {
return EncoderUtils.base64Encode(str, flags)
}
fun md5Encode(str: String): String {
return MD5Utils.md5Encode(str)
}
fun md5Encode16(str: String): String {
return MD5Utils.md5Encode16(str)
}
/**
* 格式化时间
*/
fun timeFormatUTC(time: Long, format: String, sh: Int): String? {
val utc = SimpleTimeZone(sh, "UTC")
return SimpleDateFormat(format, Locale.getDefault()).run {
timeZone = utc
format(Date(time))
}
}
/**
* 时间格式化
*/
fun timeFormat(time: Long): String {
return dateFormat.format(Date(time))
}
/**
* utf8编码转gbk编码
*/
fun utf8ToGbk(str: String): String {
val utf8 = String(str.toByteArray(charset("UTF-8")))
val unicode = String(utf8.toByteArray(), charset("UTF-8"))
return String(unicode.toByteArray(charset("GBK")))
}
fun encodeURI(str: String): String {
return try {
URLEncoder.encode(str, "UTF-8")
} catch (e: Exception) {
""
}
}
fun encodeURI(str: String, enc: String): String {
return try {
URLEncoder.encode(str, enc)
} catch (e: Exception) {
""
}
}
fun htmlFormat(str: String): String {
return HtmlFormatter.formatKeepImg(str)
}
//****************文件操作******************//
/**
* 获取本地文件
* @param path 相对路径
* @return File
*/
fun getFile(path: String): File {
val cachePath = appCtx.externalCache.absolutePath
val aPath = if (path.startsWith(File.separator)) {
cachePath + path
} else {
cachePath + File.separator + path
}
return File(aPath)
}
fun readFile(path: String): ByteArray? {
val file = getFile(path)
if (file.exists()) {
return file.readBytes()
}
return null
}
fun readTxtFile(path: String): String {
val file = getFile(path)
if (file.exists()) {
val charsetName = EncodingDetect.getEncode(file)
return String(file.readBytes(), charset(charsetName))
}
return ""
}
fun readTxtFile(path: String, charsetName: String): String {
val file = getFile(path)
if (file.exists()) {
return String(file.readBytes(), charset(charsetName))
}
return ""
}
/**
* 删除本地文件
*/
fun deleteFile(path: String) {
val file = getFile(path)
FileUtils.delete(file, true)
}
/**
* js实现压缩文件解压
* @param zipPath 相对路径
* @return 相对路径
*/
fun unzipFile(zipPath: String): String {
if (zipPath.isEmpty()) return ""
val unzipPath = FileUtils.getPath(
FileUtils.createFolderIfNotExist(FileUtils.getCachePath()),
FileUtils.getNameExcludeExtension(zipPath)
)
FileUtils.deleteFile(unzipPath)
val zipFile = getFile(zipPath)
val unzipFolder = FileUtils.createFolderIfNotExist(unzipPath)
ZipUtils.unzipFile(zipFile, unzipFolder)
FileUtils.deleteFile(zipFile.absolutePath)
return unzipPath.substring(FileUtils.getCachePath().length)
}
/**
* js实现文件夹内所有文件读取
*/
fun getTxtInFolder(unzipPath: String): String {
if (unzipPath.isEmpty()) return ""
val unzipFolder = getFile(unzipPath)
val contents = StringBuilder()
unzipFolder.listFiles().let {
if (it != null) {
for (f in it) {
val charsetName = EncodingDetect.getEncode(f)
contents.append(String(f.readBytes(), charset(charsetName)))
.append("\n")
}
contents.deleteCharAt(contents.length - 1)
}
}
FileUtils.deleteFile(unzipFolder.absolutePath)
return contents.toString()
}
/**
* 获取网络zip文件里面的数据
* @param url zip文件的链接或十六进制字符串
* @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据
*/
fun getZipStringContent(url: String, path: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName))
}
fun getZipStringContent(url: String, path: String, charsetName: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName))
}
/**
* 获取网络zip文件里面的数据
* @param url zip文件的链接或十六进制字符串
* @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据
*/
fun getZipByteArrayContent(url: String, path: String): ByteArray? {
val bytes = if (url.startsWith("http://") || url.startsWith("https://")) {
runBlocking {
return@runBlocking okHttpClient.newCallResponseBody { url(url) }.bytes()
}
} else {
StringUtils.hexStringToByte(url)
}
val bos = ByteArrayOutputStream()
val zis = ZipInputStream(ByteArrayInputStream(bytes))
var entry: ZipEntry? = zis.nextEntry
while (entry != null) {
if (entry.name.equals(path)) {
zis.use { it.copyTo(bos) }
return bos.toByteArray()
}
entry = zis.nextEntry
}
log("getZipContent 未发现内容")
return null
}
//******************文件操作************************//
/**
* 解析字体,返回字体解析类
*/
fun queryBase64TTF(base64: String?): QueryTTF? {
base64DecodeToByteArray(base64)?.let {
return QueryTTF(it)
}
return null
}
/**
* 返回字体解析类
* @param str 支持url,本地文件,base64,自动判断,自动缓存
*/
fun queryTTF(str: String?): QueryTTF? {
str ?: return null
val key = md5Encode16(str)
var qTTF = CacheManager.getQueryTTF(key)
if (qTTF != null) return qTTF
val font: ByteArray? = when {
str.isAbsUrl() -> runBlocking {
var x = CacheManager.getByteArray(key)
if (x == null) {
x = okHttpClient.newCallResponseBody { url(str) }.bytes()
x.let {
CacheManager.put(key, it)
}
}
return@runBlocking x
}
str.isContentScheme() -> Uri.parse(str).readBytes(appCtx)
str.startsWith("/storage") -> File(str).readBytes()
else -> base64DecodeToByteArray(str)
}
font ?: return null
qTTF = QueryTTF(font)
CacheManager.put(key, qTTF)
return qTTF
}
/**
* @param text 包含错误字体的内容
* @param font1 错误的字体
* @param font2 正确的字体
*/
fun replaceFont(
text: String,
font1: QueryTTF?,
font2: QueryTTF?
): String {
if (font1 == null || font2 == null) return text
val contentArray = text.toCharArray()
contentArray.forEachIndexed { index, s ->
val oldCode = s.code
if (font1.inLimit(s)) {
val glyf = font1.getGlyfByCode(oldCode)
val code = font2.getCodeByGlyf(glyf)
if (code != 0) {
contentArray[index] = code.toChar()
}
}
}
return contentArray.joinToString("")
}
/**
* 输出调试日志
*/
fun log(msg: String): String {
getSource()?.let {
Debug.log(it.getKey(), msg)
} ?: Debug.log(msg)
if (BuildConfig.DEBUG) {
Timber.d(msg)
}
return msg
}
/**
* 生成UUID
*/
fun randomUUID(): String {
return UUID.randomUUID().toString()
}
/**
* AES 解码为 ByteArray
* @param str 传入的AES加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesDecodeToByteArray(
str: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.decryptAES(
data = str.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesDecodeToByteArrayERROR")
null
}
}
/**
* AES 解码为 String
* @param str 传入的AES加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesDecodeToString(
str: String, key: String, transformation: String, iv: String
): String? {
return aesDecodeToByteArray(str, key, transformation, iv)?.let { String(it) }
}
/**
* 已经base64的AES 解码为 ByteArray
* @param str 传入的AES Base64加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesBase64DecodeToByteArray(
str: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.decryptBase64AES(
str.encodeToByteArray(),
key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesDecodeToByteArrayERROR")
null
}
}
/**
* 已经base64的AES 解码为 String
* @param str 传入的AES Base64加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesBase64DecodeToString(
str: String, key: String, transformation: String, iv: String
): String? {
return aesBase64DecodeToByteArray(str, key, transformation, iv)?.let { String(it) }
}
/**
* 加密aes为ByteArray
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToByteArray(
data: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.encryptAES(
data.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesEncodeToByteArrayERROR")
null
}
}
/**
* 加密aes为String
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToString(
data: String, key: String, transformation: String, iv: String
): String? {
return aesEncodeToByteArray(data, key, transformation, iv)?.let { String(it) }
}
/**
* 加密aes后Base64化的ByteArray
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToBase64ByteArray(
data: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.encryptAES2Base64(
data.encodeToByteArray(),
key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesEncodeToBase64ByteArrayERROR")
null
}
}
/**
* 加密aes后Base64化的String
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToBase64String(
data: String, key: String, transformation: String, iv: String
): String? {
return aesEncodeToBase64ByteArray(data, key, transformation, iv)?.let { String(it) }
}
fun android(): String {
return AppConst.androidId
}
}

@ -0,0 +1,603 @@
package io.legado.app.model.analyzeRule;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.lang3.tuple.Triple;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@SuppressWarnings({"FieldCanBeLocal", "StatementWithEmptyBody", "unused"})
public class QueryTTF {
private static class Header {
public int majorVersion;
public int minorVersion;
public int numOfTables;
public int searchRange;
public int entrySelector;
public int rangeShift;
}
private static class Directory {
public String tag; // table name
public int checkSum; // Check sum
public int offset; // Offset from beginning of file
public int length; // length of the table in bytes
}
private static class NameLayout {
public int format;
public int count;
public int stringOffset;
public List<NameRecord> records = new LinkedList<>();
}
private static class NameRecord {
public int platformID; // 平台标识符<0:Unicode, 1:Mac, 2:ISO, 3:Windows, 4:Custom>
public int encodingID; // 编码标识符
public int languageID; // 语言标识符
public int nameID; // 名称标识符
public int length; // 名称字符串的长度
public int offset; // 名称字符串相对于stringOffset的字节偏移量
}
private static class HeadLayout {
public int majorVersion;
public int minorVersion;
public int fontRevision;
public int checkSumAdjustment;
public int magicNumber;
public int flags;
public int unitsPerEm;
public long created;
public long modified;
public short xMin;
public short yMin;
public short xMax;
public short yMax;
public int macStyle;
public int lowestRecPPEM;
public short fontDirectionHint;
public short indexToLocFormat; // <0:loca是2字节数组, 1:loca是4字节数组>
public short glyphDataFormat;
}
private static class MaxpLayout {
public int majorVersion;
public int minorVersion;
public int numGlyphs; // 字体中的字形数量
public int maxPoints;
public int maxContours;
public int maxCompositePoints;
public int maxCompositeContours;
public int maxZones;
public int maxTwilightPoints;
public int maxStorage;
public int maxFunctionDefs;
public int maxInstructionDefs;
public int maxStackElements;
public int maxSizeOfInstructions;
public int maxComponentElements;
public int maxComponentDepth;
}
private static class CmapLayout {
public int version;
public int numTables;
public List<CmapRecord> records = new LinkedList<>();
public Map<Integer, CmapFormat> tables = new HashMap<>();
}
private static class CmapRecord {
public int platformID;
public int encodingID;
public int offset;
}
private static class CmapFormat {
public int format;
public int length;
public int language;
public byte[] glyphIdArray;
}
private static class CmapFormat4 extends CmapFormat {
public int segCountX2;
public int searchRange;
public int entrySelector;
public int rangeShift;
public int[] endCode;
public int reservedPad;
public int[] startCode;
public short[] idDelta;
public int[] idRangeOffset;
public int[] glyphIdArray;
}
private static class CmapFormat6 extends CmapFormat {
public int firstCode;
public int entryCount;
public int[] glyphIdArray;
}
private static class CmapFormat12 extends CmapFormat {
public int reserved;
public int length;
public int language;
public int numGroups;
public List<Triple<Integer, Integer, Integer>> groups;
}
private static class GlyfLayout {
public short numberOfContours; // 非负值为简单字型,负值为符合字型
public short xMin;
public short yMin;
public short xMax;
public short yMax;
public int[] endPtsOfContours; // length=numberOfContours
public int instructionLength;
public byte[] instructions; // length=instructionLength
public byte[] flags;
public short[] xCoordinates; // length = flags.length
public short[] yCoordinates; // length = flags.length
}
private static class ByteArrayReader {
public int index;
public byte[] buffer;
public ByteArrayReader(byte[] buffer, int index) {
this.buffer = buffer;
this.index = index;
}
public long ReadUIntX(long len) {
long result = 0;
for (long i = 0; i < len; ++i) {
result <<= 8;
result |= buffer[index++] & 0xFF;
}
return result;
}
public long ReadUInt64() {
return ReadUIntX(8);
}
public int ReadUInt32() {
return (int) ReadUIntX(4);
}
public int ReadUInt16() {
return (int) ReadUIntX(2);
}
public short ReadInt16() {
return (short) ReadUIntX(2);
}
public short ReadUInt8() {
return (short) ReadUIntX(1);
}
public String ReadStrings(int len, Charset charset) {
byte[] result = len > 0 ? new byte[len] : null;
for (int i = 0; i < len; ++i) result[i] = buffer[index++];
return new String(result, charset);
}
public byte GetByte() {
return buffer[index++];
}
public byte[] GetBytes(int len) {
byte[] result = len > 0 ? new byte[len] : null;
for (int i = 0; i < len; ++i) result[i] = buffer[index++];
return result;
}
public int[] GetUInt16Array(int len) {
int[] result = len > 0 ? new int[len] : null;
for (int i = 0; i < len; ++i) result[i] = ReadUInt16();
return result;
}
public short[] GetInt16Array(int len) {
short[] result = len > 0 ? new short[len] : null;
for (int i = 0; i < len; ++i) result[i] = ReadInt16();
return result;
}
}
private final ByteArrayReader fontReader;
private final Header fileHeader = new Header();
private final List<Directory> directorys = new LinkedList<>();
private final NameLayout name = new NameLayout();
private final HeadLayout head = new HeadLayout();
private final MaxpLayout maxp = new MaxpLayout();
private final List<Integer> loca = new LinkedList<>();
private final CmapLayout Cmap = new CmapLayout();
private final List<GlyfLayout> glyf = new LinkedList<>();
@SuppressWarnings("unchecked")
private final Pair<Integer, Integer>[] pps = new Pair[]{
Pair.of(3, 10),
Pair.of(0, 4),
Pair.of(3, 1),
Pair.of(1, 0),
Pair.of(0, 3),
Pair.of(0, 1)
};
public final Map<Integer, String> codeToGlyph = new HashMap<>();
public final Map<String, Integer> glyphToCode = new HashMap<>();
private int limitMix = 0;
private int limitMax = 0;
/**
* 构造函数
*
* @param buffer 传入TTF字体二进制数组
*/
public QueryTTF(byte[] buffer) {
fontReader = new ByteArrayReader(buffer, 0);
// 获取文件头
fileHeader.majorVersion = fontReader.ReadUInt16();
fileHeader.minorVersion = fontReader.ReadUInt16();
fileHeader.numOfTables = fontReader.ReadUInt16();
fileHeader.searchRange = fontReader.ReadUInt16();
fileHeader.entrySelector = fontReader.ReadUInt16();
fileHeader.rangeShift = fontReader.ReadUInt16();
// 获取目录
for (int i = 0; i < fileHeader.numOfTables; ++i) {
Directory d = new Directory();
d.tag = fontReader.ReadStrings(4, StandardCharsets.US_ASCII);
d.checkSum = fontReader.ReadUInt32();
d.offset = fontReader.ReadUInt32();
d.length = fontReader.ReadUInt32();
directorys.add(d);
}
// 解析表 name (字体信息,包含版权、名称、作者等...)
for (Directory Temp : directorys) {
if (Temp.tag.equals("name")) {
fontReader.index = Temp.offset;
name.format = fontReader.ReadUInt16();
name.count = fontReader.ReadUInt16();
name.stringOffset = fontReader.ReadUInt16();
for (int i = 0; i < name.count; ++i) {
NameRecord record = new NameRecord();
record.platformID = fontReader.ReadUInt16();
record.encodingID = fontReader.ReadUInt16();
record.languageID = fontReader.ReadUInt16();
record.nameID = fontReader.ReadUInt16();
record.length = fontReader.ReadUInt16();
record.offset = fontReader.ReadUInt16();
name.records.add(record);
}
}
}
// 解析表 head (获取 head.indexToLocFormat)
for (Directory Temp : directorys) {
if (Temp.tag.equals("head")) {
fontReader.index = Temp.offset;
head.majorVersion = fontReader.ReadUInt16();
head.minorVersion = fontReader.ReadUInt16();
head.fontRevision = fontReader.ReadUInt32();
head.checkSumAdjustment = fontReader.ReadUInt32();
head.magicNumber = fontReader.ReadUInt32();
head.flags = fontReader.ReadUInt16();
head.unitsPerEm = fontReader.ReadUInt16();
head.created = fontReader.ReadUInt64();
head.modified = fontReader.ReadUInt64();
head.xMin = fontReader.ReadInt16();
head.yMin = fontReader.ReadInt16();
head.xMax = fontReader.ReadInt16();
head.yMax = fontReader.ReadInt16();
head.macStyle = fontReader.ReadUInt16();
head.lowestRecPPEM = fontReader.ReadUInt16();
head.fontDirectionHint = fontReader.ReadInt16();
head.indexToLocFormat = fontReader.ReadInt16();
head.glyphDataFormat = fontReader.ReadInt16();
}
}
// 解析表 maxp (获取 maxp.numGlyphs)
for (Directory Temp : directorys) {
if (Temp.tag.equals("maxp")) {
fontReader.index = Temp.offset;
maxp.majorVersion = fontReader.ReadUInt16();
maxp.minorVersion = fontReader.ReadUInt16();
maxp.numGlyphs = fontReader.ReadUInt16();
maxp.maxPoints = fontReader.ReadUInt16();
maxp.maxContours = fontReader.ReadUInt16();
maxp.maxCompositePoints = fontReader.ReadUInt16();
maxp.maxCompositeContours = fontReader.ReadUInt16();
maxp.maxZones = fontReader.ReadUInt16();
maxp.maxTwilightPoints = fontReader.ReadUInt16();
maxp.maxStorage = fontReader.ReadUInt16();
maxp.maxFunctionDefs = fontReader.ReadUInt16();
maxp.maxInstructionDefs = fontReader.ReadUInt16();
maxp.maxStackElements = fontReader.ReadUInt16();
maxp.maxSizeOfInstructions = fontReader.ReadUInt16();
maxp.maxComponentElements = fontReader.ReadUInt16();
maxp.maxComponentDepth = fontReader.ReadUInt16();
}
}
// 解析表 loca (轮廓数据偏移地址表)
for (Directory Temp : directorys) {
if (Temp.tag.equals("loca")) {
fontReader.index = Temp.offset;
int offset = head.indexToLocFormat == 0 ? 2 : 4;
for (long i = 0; i < Temp.length; i += offset) {
loca.add(offset == 2 ? fontReader.ReadUInt16() << 1 : fontReader.ReadUInt32());
}
}
}
// 解析表 cmap (Unicode编码轮廓索引对照表)
for (Directory Temp : directorys) {
if (Temp.tag.equals("cmap")) {
fontReader.index = Temp.offset;
Cmap.version = fontReader.ReadUInt16();
Cmap.numTables = fontReader.ReadUInt16();
for (int i = 0; i < Cmap.numTables; ++i) {
CmapRecord record = new CmapRecord();
record.platformID = fontReader.ReadUInt16();
record.encodingID = fontReader.ReadUInt16();
record.offset = fontReader.ReadUInt32();
Cmap.records.add(record);
}
for (int i = 0; i < Cmap.numTables; ++i) {
int fmtOffset = Cmap.records.get(i).offset;
fontReader.index = Temp.offset + fmtOffset;
int EndIndex = fontReader.index;
int format = fontReader.ReadUInt16();
if (Cmap.tables.containsKey(fmtOffset)) continue;
if (format == 0) {
CmapFormat f = new CmapFormat();
f.format = format;
f.length = fontReader.ReadUInt16();
f.language = fontReader.ReadUInt16();
f.glyphIdArray = fontReader.GetBytes(f.length - 6);
Cmap.tables.put(fmtOffset, f);
} else if (format == 4) {
CmapFormat4 f = new CmapFormat4();
f.format = format;
f.length = fontReader.ReadUInt16();
f.language = fontReader.ReadUInt16();
f.segCountX2 = fontReader.ReadUInt16();
int segCount = f.segCountX2 >> 1;
f.searchRange = fontReader.ReadUInt16();
f.entrySelector = fontReader.ReadUInt16();
f.rangeShift = fontReader.ReadUInt16();
f.endCode = fontReader.GetUInt16Array(segCount);
f.reservedPad = fontReader.ReadUInt16();
f.startCode = fontReader.GetUInt16Array(segCount);
f.idDelta = fontReader.GetInt16Array(segCount);
f.idRangeOffset = fontReader.GetUInt16Array(segCount);
f.glyphIdArray = fontReader.GetUInt16Array((EndIndex + f.length - fontReader.index) >> 1);
Cmap.tables.put(fmtOffset, f);
} else if (format == 6) {
CmapFormat6 f = new CmapFormat6();
f.format = format;
f.length = fontReader.ReadUInt16();
f.language = fontReader.ReadUInt16();
f.firstCode = fontReader.ReadUInt16();
f.entryCount = fontReader.ReadUInt16();
f.glyphIdArray = fontReader.GetUInt16Array(f.entryCount);
Cmap.tables.put(fmtOffset, f);
} else if (format == 12) {
CmapFormat12 f = new CmapFormat12();
f.format = format;
f.reserved = fontReader.ReadUInt16();
f.length = fontReader.ReadUInt32();
f.language = fontReader.ReadUInt32();
f.numGroups = fontReader.ReadUInt32();
f.groups = new ArrayList<>(f.numGroups);
for (int n = 0; n < f.numGroups; ++n) {
f.groups.add(Triple.of(fontReader.ReadUInt32(), fontReader.ReadUInt32(), fontReader.ReadUInt32()));
}
Cmap.tables.put(fmtOffset, f);
}
}
}
}
// 解析表 glyf (字体轮廓数据表)
for (Directory Temp : directorys) {
if (Temp.tag.equals("glyf")) {
fontReader.index = Temp.offset;
for (int i = 0; i < maxp.numGlyphs; ++i) {
fontReader.index = Temp.offset + loca.get(i);
short numberOfContours = fontReader.ReadInt16();
if (numberOfContours > 0) {
GlyfLayout g = new GlyfLayout();
g.numberOfContours = numberOfContours;
g.xMin = fontReader.ReadInt16();
g.yMin = fontReader.ReadInt16();
g.xMax = fontReader.ReadInt16();
g.yMax = fontReader.ReadInt16();
g.endPtsOfContours = fontReader.GetUInt16Array(numberOfContours);
g.instructionLength = fontReader.ReadUInt16();
g.instructions = fontReader.GetBytes(g.instructionLength);
int flagLength = g.endPtsOfContours[g.endPtsOfContours.length - 1] + 1;
// 获取轮廓点描述标志
g.flags = new byte[flagLength];
for (int n = 0; n < flagLength; ++n) {
g.flags[n] = fontReader.GetByte();
if ((g.flags[n] & 0x08) != 0x00) {
for (int m = fontReader.ReadUInt8(); m > 0; --m) {
g.flags[++n] = g.flags[n - 1];
}
}
}
// 获取轮廓点描述x轴相对值
g.xCoordinates = new short[flagLength];
for (int n = 0; n < flagLength; ++n) {
short same = (short) ((g.flags[n] & 0x10) != 0 ? 1 : -1);
if ((g.flags[n] & 0x02) != 0) {
g.xCoordinates[n] = (short) (same * fontReader.ReadUInt8());
} else {
g.xCoordinates[n] = same == 1 ? (short) 0 : fontReader.ReadInt16();
}
}
// 获取轮廓点描述y轴相对值
g.yCoordinates = new short[flagLength];
for (int n = 0; n < flagLength; ++n) {
short same = (short) ((g.flags[n] & 0x20) != 0 ? 1 : -1);
if ((g.flags[n] & 0x04) != 0) {
g.yCoordinates[n] = (short) (same * fontReader.ReadUInt8());
} else {
g.yCoordinates[n] = same == 1 ? (short) 0 : fontReader.ReadInt16();
}
}
/* 相对坐标转绝对坐标
for (int n = 1; n < flagLength; ++n) {
xCoordinates[n] += xCoordinates[n - 1];
yCoordinates[n] += yCoordinates[n - 1];
}*/
glyf.add(g);
} else {
// 复合字体暂未使用
}
}
}
}
// 建立Unicode&Glyph双向表
for (int key = 0; key < 130000; ++key) {
if (key == 0xFF) key = 0x3400;
int gid = getGlyfIndex(key);
if (gid == 0) continue;
StringBuilder sb = new StringBuilder();
// 字型数据转String,方便存HashMap
for (short b : glyf.get(gid).xCoordinates) sb.append(b);
for (short b : glyf.get(gid).yCoordinates) sb.append(b);
String val = sb.toString();
if (limitMix == 0) limitMix = key;
limitMax = key;
codeToGlyph.put(key, val);
if (glyphToCode.containsKey(val)) continue;
glyphToCode.put(val, key);
}
}
/**
* 获取字体信息 (1=字体名称)
*
* @param nameId 传入十进制字体信息索引
* @return 返回查询结果字符串
*/
public String getNameById(int nameId) {
for (Directory Temp : directorys) {
if (!Temp.tag.equals("name")) continue;
fontReader.index = Temp.offset;
break;
}
for (NameRecord record : name.records) {
if (record.nameID != nameId) continue;
fontReader.index += name.stringOffset + record.offset;
return fontReader.ReadStrings(record.length, record.platformID == 1 ? StandardCharsets.UTF_8 : StandardCharsets.UTF_16BE);
}
return "error";
}
/**
* 使用Unicode值查找轮廓索引
*
* @param code 传入Unicode十进制值
* @return 返回十进制轮廓索引
*/
private int getGlyfIndex(int code) {
if (code == 0) return 0;
int fmtKey = 0;
for (Pair<Integer, Integer> item : pps) {
for (CmapRecord record : Cmap.records) {
if ((item.getLeft() == record.platformID) && (item.getRight() == record.encodingID)) {
fmtKey = record.offset;
break;
}
}
if (fmtKey > 0) break;
}
if (fmtKey == 0) return 0;
int glyfID = 0;
CmapFormat table = Cmap.tables.get(fmtKey);
assert table != null;
int fmt = table.format;
if (fmt == 0) {
if (code < table.glyphIdArray.length) glyfID = table.glyphIdArray[code] & 0xFF;
} else if (fmt == 4) {
CmapFormat4 tab = (CmapFormat4) table;
if (code > tab.endCode[tab.endCode.length - 1]) return 0;
// 二分法查找数值索引
int start = 0, middle, end = tab.endCode.length - 1;
while (start + 1 < end) {
middle = (start + end) / 2;
if (tab.endCode[middle] <= code) start = middle;
else end = middle;
}
if (tab.endCode[start] < code) ++start;
if (code < tab.startCode[start]) return 0;
if (tab.idRangeOffset[start] != 0) {
glyfID = tab.glyphIdArray[code - tab.startCode[start] + (tab.idRangeOffset[start] >> 1) - (tab.idRangeOffset.length - start)];
} else glyfID = code + tab.idDelta[start];
glyfID &= 0xFFFF;
} else if (fmt == 6) {
CmapFormat6 tab = (CmapFormat6) table;
int index = code - tab.firstCode;
if (index < 0 || index >= tab.glyphIdArray.length) glyfID = 0;
else glyfID = tab.glyphIdArray[index];
} else if (fmt == 12) {
CmapFormat12 tab = (CmapFormat12) table;
if (code > tab.groups.get(tab.numGroups - 1).getMiddle()) return 0;
// 二分法查找数值索引
int start = 0, middle, end = tab.numGroups - 1;
while (start + 1 < end) {
middle = (start + end) / 2;
if (tab.groups.get(middle).getLeft() <= code) start = middle;
else end = middle;
}
if (tab.groups.get(start).getLeft() <= code && code <= tab.groups.get(start).getMiddle()) {
glyfID = tab.groups.get(start).getRight() + code - tab.groups.get(start).getLeft();
}
}
return glyfID;
}
/**
* 判断Unicode值是否在字体范围内
*
* @param code 传入Unicode十进制值
* @return 返回bool查询结果
*/
public boolean inLimit(char code) {
return (limitMix <= code) && (code < limitMax);
}
/**
* 使用Unicode值获取轮廓数据
*
* @param key 传入Unicode十进制值
* @return 返回轮廓数组的String值
*/
public String getGlyfByCode(int key) {
return codeToGlyph.getOrDefault(key, "");
}
/**
* 使用轮廓数据获取Unicode值
*
* @param val 传入轮廓数组的String值
* @return 返回Unicode十进制值
*/
public int getCodeByGlyf(String val) {
//noinspection ConstantConditions
return glyphToCode.getOrDefault(val, 0);
}
}

@ -0,0 +1,378 @@
package io.legado.app.model.analyzeRule
//通用的规则切分处理
class RuleAnalyzer(data: String, code: Boolean = false) {
private var queue: String = data //被处理字符串
private var pos = 0 //当前处理到的位置
private var start = 0 //当前处理字段的开始
private var startX = 0 //当前规则的开始
private var rule = ArrayList<String>() //分割出的规则列表
private var step: Int = 0 //分割字符的长度
var elementsType = "" //当前分割字符串
fun trim() { // 修剪当前规则之前的"@"或者空白符
if (queue[pos] == '@' || queue[pos] < '!') { //在while里重复设置start和startX会拖慢执行速度,所以先来个判断是否存在需要修剪的字段,最后再一次性设置start和startX
pos++
while (queue[pos] == '@' || queue[pos] < '!') pos++
start = pos //开始点推移
startX = pos //规则起始点推移
}
}
//将pos重置为0,方便复用
fun reSetPos() {
pos = 0
startX = 0
}
/**
* 从剩余字串中拉出一个字符串直到但不包括匹配序列
* @param seq 查找的字符串 **区分大小写**
* @return 是否找到相应字段
*/
private fun consumeTo(seq: String): Boolean {
start = pos //将处理到的位置设置为规则起点
val offset = queue.indexOf(seq, pos)
return if (offset != -1) {
pos = offset
true
} else false
}
/**
* 从剩余字串中拉出一个字符串直到但不包括匹配序列匹配参数列表中一项即为匹配或剩余字串用完
* @param seq 匹配字符串序列
* @return 成功返回true并设置间隔失败则直接返回fasle
*/
private fun consumeToAny(vararg seq: String): Boolean {
var pos = pos //声明新变量记录匹配位置,不更改类本身的位置
while (pos != queue.length) {
for (s in seq) {
if (queue.regionMatches(pos, s, 0, s.length)) {
step = s.length //间隔数
this.pos = pos //匹配成功, 同步处理位置到类
return true //匹配就返回 true
}
}
pos++ //逐个试探
}
return false
}
/**
* 从剩余字串中拉出一个字符串直到但不包括匹配序列匹配参数列表中一项即为匹配或剩余字串用完
* @param seq 匹配字符序列
* @return 返回匹配位置
*/
private fun findToAny(vararg seq: Char): Int {
var pos = pos //声明新变量记录匹配位置,不更改类本身的位置
while (pos != queue.length) {
for (s in seq) if (queue[pos] == s) return pos //匹配则返回位置
pos++ //逐个试探
}
return -1
}
/**
* 拉出一个非内嵌代码平衡组存在转义文本
*/
private fun chompCodeBalanced(open: Char, close: Char): Boolean {
var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos
var depth = 0 //嵌套深度
var otherDepth = 0 //其他对称符合嵌套深度
var inSingleQuote = false //单引号
var inDoubleQuote = false //双引号
do {
if (pos == queue.length) break
val c = queue[pos++]
if (c != ESC) { //非转义字符
if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号
else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号
if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环
if (c == '[') depth++ //开始嵌套一层
else if (c == ']') depth-- //闭合一层嵌套
else if (depth == 0) {
//处于默认嵌套中的非默认字符不需要平衡,仅depth为0时默认嵌套全部闭合,此字符才进行嵌套
if (c == open) otherDepth++
else if (c == close) otherDepth--
}
} else pos++
} while (depth > 0 || otherDepth > 0) //拉出一个平衡字串
return if (depth > 0 || otherDepth > 0) false else {
this.pos = pos //同步位置
true
}
}
/**
* 拉出一个规则平衡组经过仔细测试xpath和jsoup中引号内转义字符无效
*/
private fun chompRuleBalanced(open: Char, close: Char): Boolean {
var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos
var depth = 0 //嵌套深度
var inSingleQuote = false //单引号
var inDoubleQuote = false //双引号
do {
if (pos == queue.length) break
val c = queue[pos++]
if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号
else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号
if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环
else if (c == '\\') { //不在引号中的转义字符才将下个字符转义
pos++
continue
}
if (c == open) depth++ //开始嵌套一层
else if (c == close) depth-- //闭合一层嵌套
} while (depth > 0) //拉出一个平衡字串
return if (depth > 0) false else {
this.pos = pos //同步位置
true
}
}
/**
* 不用正则,不到最后不切片也不用中间变量存储,只在序列中标记当前查找字段的开头结尾,到返回时才切片,高效快速准确切割规则
* 解决jsonPath自带的"&&""||"与阅读的规则冲突,以及规则正则或字符串中包含"&&""||""%%""@"导致的冲突
*/
tailrec fun splitRule(vararg split: String): ArrayList<String> { //首段匹配,elementsType为空
if (split.size == 1) {
elementsType = split[0] //设置分割字串
return if (!consumeTo(elementsType)) {
rule += queue.substring(startX)
rule
} else {
step = elementsType.length //设置分隔符长度
splitRule()
} //递归匹配
} else if (!consumeToAny(* split)) { //未找到分隔符
rule += queue.substring(startX)
return rule
}
val end = pos //记录分隔位置
pos = start //重回开始,启动另一种查找
do {
val st = findToAny('[', '(') //查找筛选器位置
if (st == -1) {
rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
elementsType = queue.substring(end, end + step) //设置组合类型
pos = end + step //跳过分隔符
while (consumeTo(elementsType)) { //循环切分规则压入数组
rule += queue.substring(start, pos)
pos += step //跳过分隔符
}
rule += queue.substring(pos) //将剩余字段压入数组末尾
return rule
}
if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组
rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
elementsType = queue.substring(end, end + step) //设置组合类型
pos = end + step //跳过分隔符
while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组
rule += queue.substring(start, pos)
pos += step //跳过分隔符
}
return if (pos > st) {
startX = start
splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配
} else { //执行到此,证明后面再无分隔字符
rule += queue.substring(pos) //将剩余字段压入数组末尾
rule
}
}
pos = st //位置推移到筛选器处
val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符
if (!chompBalanced(queue[pos], next)) throw Error(
queue.substring(0, start) + "后未平衡"
) //拉出一个筛选器,不平衡则报错
} while (end > pos)
start = pos //设置开始查找筛选器位置的起始位置
return splitRule(* split) //递归调用首段匹配
}
@JvmName("splitRuleNext")
private tailrec fun splitRule(): ArrayList<String> { //二段匹配被调用,elementsType非空(已在首段赋值),直接按elementsType查找,比首段采用的方式更快
val end = pos //记录分隔位置
pos = start //重回开始,启动另一种查找
do {
val st = findToAny('[', '(') //查找筛选器位置
if (st == -1) {
rule += arrayOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
pos = end + step //跳过分隔符
while (consumeTo(elementsType)) { //循环切分规则压入数组
rule += queue.substring(start, pos)
pos += step //跳过分隔符
}
rule += queue.substring(pos) //将剩余字段压入数组末尾
return rule
}
if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组
rule += arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组
pos = end + step //跳过分隔符
while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组
rule += queue.substring(start, pos)
pos += step //跳过分隔符
}
return if (pos > st) {
startX = start
splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配
} else { //执行到此,证明后面再无分隔字符
rule += queue.substring(pos) //将剩余字段压入数组末尾
rule
}
}
pos = st //位置推移到筛选器处
val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符
if (!chompBalanced(queue[pos], next)) throw Error(
queue.substring(0, start) + "后未平衡"
) //拉出一个筛选器,不平衡则报错
} while (end > pos)
start = pos //设置开始查找筛选器位置的起始位置
return if (!consumeTo(elementsType)) {
rule += queue.substring(startX)
rule
} else splitRule() //递归匹配
}
/**
* 替换内嵌规则
* @param inner 起始标志,{$.
* @param startStep 不属于规则部分的前置字符长度{$.{不属于规则的组成部分故startStep为1
* @param endStep 不属于规则部分的后置字符长度
* @param fr 查找到内嵌规则时用于解析的函数
*
* */
fun innerRule(
inner: String,
startStep: Int = 1,
endStep: Int = 1,
fr: (String) -> String?
): String {
val st = StringBuilder()
while (consumeTo(inner)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true
val posPre = pos //记录consumeTo匹配位置
if (chompCodeBalanced('{', '}')) {
val frv = fr(queue.substring(posPre + startStep, pos - endStep))
if (!frv.isNullOrEmpty()) {
st.append(queue.substring(startX, posPre) + frv) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串
startX = pos //记录下次规则起点
continue //获取内容成功,继续选择下个内嵌规则
}
}
pos += inner.length //拉出字段不平衡,inner只是个普通字串,跳到此inner后继续匹配
}
return if (startX == 0) "" else st.apply {
append(queue.substring(startX))
}.toString()
}
/**
* 替换内嵌规则
* @param fr 查找到内嵌规则时用于解析的函数
*
* */
fun innerRule(
startStr: String,
endStr: String,
fr: (String) -> String?
): String {
val st = StringBuilder()
while (consumeTo(startStr)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true
pos += startStr.length //跳过开始字符串
val posPre = pos //记录consumeTo匹配位置
if (consumeTo(endStr)) {
val frv = fr(queue.substring(posPre, pos))
st.append(
queue.substring(
startX,
posPre - startStr.length
) + frv
) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串
pos += endStr.length //跳过结束字符串
startX = pos //记录下次规则起点
}
}
return if (startX == 0) queue else st.apply {
append(queue.substring(startX))
}.toString()
}
//设置平衡组函数,json或JavaScript时设置成chompCodeBalanced,否则为chompRuleBalanced
val chompBalanced = if (code) ::chompCodeBalanced else ::chompRuleBalanced
companion object {
/**
* 转义字符
*/
private const val ESC = '\\'
}
}

@ -0,0 +1,17 @@
package io.legado.app.model.analyzeRule
class RuleData : RuleDataInterface {
override val variableMap by lazy {
hashMapOf<String, String>()
}
override fun putVariable(key: String, value: String?) {
if (value != null) {
variableMap[key] = value
} else {
variableMap.remove(key)
}
}
}

@ -0,0 +1,13 @@
package io.legado.app.model.analyzeRule
interface RuleDataInterface {
val variableMap: HashMap<String, String>
fun putVariable(key: String, value: String?)
fun getVariable(key: String): String? {
return variableMap[key]
}
}

@ -0,0 +1,217 @@
package io.legado.app.help.http
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.util.AndroidRuntimeException
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import io.legado.app.constant.AppConst
import io.legado.app.model.NoStackTraceException
import io.legado.app.utils.runOnUI
import kotlinx.coroutines.*
import org.apache.commons.text.StringEscapeUtils
import splitties.init.appCtx
import java.lang.ref.WeakReference
import kotlin.coroutines.resume
/**
* 后台webView
*/
class BackstageWebView(
private val url: String? = null,
private val html: String? = null,
private val encode: String? = null,
private val tag: String? = null,
private val headerMap: Map<String, String>? = null,
private val sourceRegex: String? = null,
private val javaScript: String? = null,
) {
private val mHandler = Handler(Looper.getMainLooper())
private var callback: Callback? = null
private var mWebView: WebView? = null
suspend fun getStrResponse(): StrResponse = suspendCancellableCoroutine { block ->
block.invokeOnCancellation {
runOnUI {
destroy()
}
}
callback = object : BackstageWebView.Callback() {
override fun onResult(response: StrResponse) {
if (!block.isCompleted)
block.resume(response)
}
override fun onError(error: Throwable) {
if (!block.isCompleted)
block.cancel(error)
}
}
runOnUI {
try {
load()
} catch (error: Throwable) {
block.cancel(error)
}
}
}
private fun getEncoding(): String {
return encode ?: "utf-8"
}
@Throws(AndroidRuntimeException::class)
private fun load() {
val webView = createWebView()
mWebView = webView
try {
when {
!html.isNullOrEmpty() -> if (url.isNullOrEmpty()) {
webView.loadData(html, "text/html", getEncoding())
} else {
webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url)
}
else -> if (headerMap == null) {
webView.loadUrl(url!!)
} else {
webView.loadUrl(url!!, headerMap)
}
}
} catch (e: Exception) {
callback?.onError(e)
}
}
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
private fun createWebView(): WebView {
val webView = WebView(appCtx)
val settings = webView.settings
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.blockNetworkImage = true
settings.userAgentString = headerMap?.get(AppConst.UA_NAME)
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
if (sourceRegex.isNullOrEmpty()) {
webView.webViewClient = HtmlWebViewClient()
} else {
webView.webViewClient = SnifferWebClient()
}
return webView
}
private fun destroy() {
mWebView?.destroy()
mWebView = null
}
private fun getJs(): String {
javaScript?.let {
if (it.isNotEmpty()) {
return it
}
}
return JS
}
private fun setCookie(url: String) {
tag?.let {
val cookie = CookieManager.getInstance().getCookie(url)
CookieStore.setCookie(it, cookie)
}
}
private inner class HtmlWebViewClient : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
setCookie(url)
val runnable = EvalJsRunnable(view, url, getJs())
mHandler.postDelayed(runnable, 1000)
}
}
private inner class EvalJsRunnable(
webView: WebView,
private val url: String,
private val mJavaScript: String
) : Runnable {
var retry = 0
private val mWebView: WeakReference<WebView> = WeakReference(webView)
override fun run() {
mWebView.get()?.evaluateJavascript(mJavaScript) {
if (it.isNotEmpty() && it != "null") {
val content = StringEscapeUtils.unescapeJson(it)
.replace("^\"|\"$".toRegex(), "")
try {
val response = StrResponse(url, content)
callback?.onResult(response)
} catch (e: Exception) {
callback?.onError(e)
}
mHandler.removeCallbacks(this)
destroy()
return@evaluateJavascript
}
if (retry > 30) {
callback?.onError(NoStackTraceException("js执行超时"))
mHandler.removeCallbacks(this)
destroy()
return@evaluateJavascript
}
retry++
mHandler.removeCallbacks(this)
mHandler.postDelayed(this, 1000)
}
}
}
private inner class SnifferWebClient : WebViewClient() {
override fun onLoadResource(view: WebView, resUrl: String) {
sourceRegex?.let {
if (resUrl.matches(it.toRegex())) {
try {
val response = StrResponse(url!!, resUrl)
callback?.onResult(response)
} catch (e: Exception) {
callback?.onError(e)
}
destroy()
}
}
}
override fun onPageFinished(webView: WebView, url: String) {
setCookie(url)
val js = javaScript
if (!js.isNullOrEmpty()) {
val runnable = LoadJsRunnable(webView, javaScript)
mHandler.postDelayed(runnable, 1000L)
}
}
}
private class LoadJsRunnable(
webView: WebView,
private val mJavaScript: String?
) : Runnable {
private val mWebView: WeakReference<WebView> = WeakReference(webView)
override fun run() {
mWebView.get()?.loadUrl("javascript:${mJavaScript ?: ""}")
}
}
companion object {
const val JS = "document.documentElement.outerHTML"
}
abstract class Callback {
abstract fun onResult(response: StrResponse)
abstract fun onError(error: Throwable)
}
}

@ -0,0 +1,187 @@
package io.legado.app.help.http
import io.legado.app.constant.AppConst
import io.legado.app.help.AppConfig
import io.legado.app.utils.EncodingDetect
import io.legado.app.utils.GSON
import io.legado.app.utils.UTF8BOMFighter
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
import java.nio.charset.Charset
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
suspend fun OkHttpClient.newCallResponse(
retry: Int = 0,
builder: Request.Builder.() -> Unit
): Response {
return withContext(IO) {
val requestBuilder = Request.Builder()
requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent)
requestBuilder.apply(builder)
var response: Response? = null
for (i in 0..retry) {
response = this@newCallResponse.newCall(requestBuilder.build()).await()
if (response.isSuccessful) {
return@withContext response
}
}
return@withContext response!!
}
}
suspend fun OkHttpClient.newCallResponseBody(
retry: Int = 0,
builder: Request.Builder.() -> Unit
): ResponseBody {
return withContext(IO) {
val requestBuilder = Request.Builder()
requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent)
requestBuilder.apply(builder)
var response: Response? = null
for (i in 0..retry) {
response = this@newCallResponseBody.newCall(requestBuilder.build()).await()
if (response.isSuccessful) {
return@withContext response.body!!
}
}
return@withContext response!!.body ?: throw IOException(response.message)
}
}
suspend fun OkHttpClient.newCallStrResponse(
retry: Int = 0,
builder: Request.Builder.() -> Unit
): StrResponse {
return withContext(IO) {
val requestBuilder = Request.Builder()
requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent)
requestBuilder.apply(builder)
var response: Response? = null
for (i in 0..retry) {
response = this@newCallStrResponse.newCall(requestBuilder.build()).await()
if (response.isSuccessful) {
return@withContext StrResponse(response, response.body!!.text())
}
}
return@withContext StrResponse(response!!, response.body?.text() ?: response.message)
}
}
suspend fun Call.await(): Response = suspendCancellableCoroutine { block ->
block.invokeOnCancellation {
cancel()
}
enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
block.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
block.resume(response)
}
})
}
fun ResponseBody.text(encode: String? = null): String {
val responseBytes = UTF8BOMFighter.removeUTF8BOM(bytes())
var charsetName: String? = encode
charsetName?.let {
return String(responseBytes, Charset.forName(charsetName))
}
//根据http头判断
contentType()?.charset()?.let {
return String(responseBytes, it)
}
//根据内容判断
charsetName = EncodingDetect.getHtmlEncode(responseBytes)
return String(responseBytes, Charset.forName(charsetName))
}
fun Request.Builder.addHeaders(headers: Map<String, String>) {
headers.forEach {
if (it.key == AppConst.UA_NAME) {
//防止userAgent重复
removeHeader(AppConst.UA_NAME)
}
addHeader(it.key, it.value)
}
}
fun Request.Builder.get(url: String, queryMap: Map<String, String>, encoded: Boolean = false) {
val httpBuilder = url.toHttpUrl().newBuilder()
queryMap.forEach {
if (encoded) {
httpBuilder.addEncodedQueryParameter(it.key, it.value)
} else {
httpBuilder.addQueryParameter(it.key, it.value)
}
}
url(httpBuilder.build())
}
fun Request.Builder.postForm(form: Map<String, String>, encoded: Boolean = false) {
val formBody = FormBody.Builder()
form.forEach {
if (encoded) {
formBody.addEncoded(it.key, it.value)
} else {
formBody.add(it.key, it.value)
}
}
post(formBody.build())
}
fun Request.Builder.postMultipart(type: String?, form: Map<String, Any>) {
val multipartBody = MultipartBody.Builder()
type?.let {
multipartBody.setType(type.toMediaType())
}
form.forEach {
when (val value = it.value) {
is Map<*, *> -> {
val fileName = value["fileName"] as String
val file = value["file"]
val mediaType = (value["contentType"] as? String)?.toMediaType()
val requestBody = when (file) {
is File -> {
file.asRequestBody(mediaType)
}
is ByteArray -> {
file.toRequestBody(mediaType)
}
is String -> {
file.toRequestBody(mediaType)
}
else -> {
GSON.toJson(file).toRequestBody(mediaType)
}
}
multipartBody.addFormDataPart(it.key, fileName, requestBody)
}
else -> multipartBody.addFormDataPart(it.key, it.value.toString())
}
}
post(multipartBody.build())
}
fun Request.Builder.postJson(json: String?) {
json?.let {
val requestBody = json.toRequestBody("application/json; charset=UTF-8".toMediaType())
post(requestBody)
}
}

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

@ -0,0 +1,184 @@
package io.legado.app.help.http
import android.annotation.SuppressLint
import timber.log.Timber
import java.io.IOException
import java.io.InputStream
import java.security.KeyManagementException
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.*
@Suppress("unused")
object SSLHelper {
/**
* 为了解决客户端不信任服务器数字证书的问题
* 网络上大部分的解决方案都是让客户端不对证书做任何检查
* 这是一种有很大安全漏洞的办法
*/
val unsafeTrustManager: X509TrustManager =
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
@Throws(CertificateException::class)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
//do nothing,接受任意客户端证书
}
@SuppressLint("TrustAllX509TrustManager")
@Throws(CertificateException::class)
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
//do nothing,接受任意客户端证书
}
override fun getAcceptedIssuers(): Array<X509Certificate> {
return arrayOf()
}
}
val unsafeSSLSocketFactory: SSLSocketFactory by lazy {
try {
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(unsafeTrustManager), SecureRandom())
sslContext.socketFactory
} catch (e: Exception) {
throw RuntimeException(e)
}
}
/**
* 此类是用于主机名验证的基接口 在握手期间如果 URL 的主机名和服务器的标识主机名不匹配
* 则验证机制可以回调此接口的实现程序来确定是否应该允许此连接策略可以是基于证书的或依赖于其他验证方案
* 当验证 URL 主机名使用的默认规则失败时使用这些回调如果主机名是可接受的则返回 true
*/
val unsafeHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true }
class SSLParams {
lateinit var sSLSocketFactory: SSLSocketFactory
lateinit var trustManager: X509TrustManager
}
/**
* https单向认证
* 可以额外配置信任服务端的证书策略否则默认是按CA证书去验证的若不是CA可信任的证书则无法通过验证
*/
fun getSslSocketFactory(trustManager: X509TrustManager): SSLParams? {
return getSslSocketFactoryBase(trustManager, null, null)
}
/**
* https单向认证
* 用含有服务端公钥的证书校验服务端证书
*/
fun getSslSocketFactory(vararg certificates: InputStream): SSLParams? {
return getSslSocketFactoryBase(null, null, null, *certificates)
}
/**
* https双向认证
* bksFile password -> 客户端使用bks证书校验服务端证书
* certificates -> 用含有服务端公钥的证书校验服务端证书
*/
fun getSslSocketFactory(
bksFile: InputStream,
password: String,
vararg certificates: InputStream
): SSLParams? {
return getSslSocketFactoryBase(null, bksFile, password, *certificates)
}
/**
* https双向认证
* bksFile password -> 客户端使用bks证书校验服务端证书
* X509TrustManager -> 如果需要自己校验那么可以自己实现相关校验如果不需要自己校验那么传null即可
*/
fun getSslSocketFactory(
bksFile: InputStream,
password: String,
trustManager: X509TrustManager
): SSLParams? {
return getSslSocketFactoryBase(trustManager, bksFile, password)
}
private fun getSslSocketFactoryBase(
trustManager: X509TrustManager?,
bksFile: InputStream?,
password: String?,
vararg certificates: InputStream
): SSLParams? {
val sslParams = SSLParams()
try {
val keyManagers = prepareKeyManager(bksFile, password)
val trustManagers = prepareTrustManager(*certificates)
val manager: X509TrustManager = trustManager ?: chooseTrustManager(trustManagers)
// 创建TLS类型的SSLContext对象, that uses our TrustManager
val sslContext = SSLContext.getInstance("TLS")
// 用上面得到的trustManagers初始化SSLContext,这样sslContext就会信任keyStore中的证书
// 第一个参数是授权的密钥管理器,用来授权验证,比如授权自签名的证书验证。第二个是被授权的证书管理器,用来验证服务器端的证书
sslContext.init(keyManagers, arrayOf<TrustManager>(manager), null)
// 通过sslContext获取SSLSocketFactory对象
sslParams.sSLSocketFactory = sslContext.socketFactory
sslParams.trustManager = manager
return sslParams
} catch (e: NoSuchAlgorithmException) {
Timber.e(e)
} catch (e: KeyManagementException) {
Timber.e(e)
}
return null
}
private fun prepareKeyManager(bksFile: InputStream?, password: String?): Array<KeyManager>? {
try {
if (bksFile == null || password == null) return null
val clientKeyStore = KeyStore.getInstance("BKS")
clientKeyStore.load(bksFile, password.toCharArray())
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(clientKeyStore, password.toCharArray())
return kmf.keyManagers
} catch (e: Exception) {
Timber.e(e)
}
return null
}
private fun prepareTrustManager(vararg certificates: InputStream): Array<TrustManager> {
val certificateFactory = CertificateFactory.getInstance("X.509")
// 创建一个默认类型的KeyStore,存储我们信任的证书
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null)
for ((index, certStream) in certificates.withIndex()) {
val certificateAlias = index.toString()
// 证书工厂根据证书文件的流生成证书 cert
val cert = certificateFactory.generateCertificate(certStream)
// 将 cert 作为可信证书放入到keyStore中
keyStore.setCertificateEntry(certificateAlias, cert)
try {
certStream.close()
} catch (e: IOException) {
Timber.e(e)
}
}
//我们创建一个默认类型的TrustManagerFactory
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
//用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书
tmf.init(keyStore)
//通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书
return tmf.trustManagers
}
private fun chooseTrustManager(trustManagers: Array<TrustManager>): X509TrustManager {
for (trustManager in trustManagers) {
if (trustManager is X509TrustManager) {
return trustManager
}
}
throw NullPointerException()
}
}

@ -0,0 +1,78 @@
package io.legado.app.help.http
import okhttp3.*
import okhttp3.Response.Builder
/**
* An HTTP response.
*/
@Suppress("unused", "MemberVisibilityCanBePrivate")
class StrResponse {
var raw: Response
private set
var body: String? = null
private set
var errorBody: ResponseBody? = null
private set
constructor(rawResponse: Response, body: String?) {
this.raw = rawResponse
this.body = body
}
constructor(url: String, body: String?) {
val request = try {
Request.Builder().url(url).build()
} catch (e: Exception) {
Request.Builder().url("http://localhost/").build()
}
raw = Builder()
.code(200)
.message("OK")
.protocol(Protocol.HTTP_1_1)
.request(request)
.build()
this.body = body
}
constructor(rawResponse: Response, errorBody: ResponseBody?) {
this.raw = rawResponse
this.errorBody = errorBody
}
fun raw() = raw
fun url(): String {
raw.networkResponse?.let {
return it.request.url.toString()
}
return raw.request.url.toString()
}
val url: String get() = url()
fun body() = body
fun code(): Int {
return raw.code
}
fun message(): String {
return raw.message
}
fun headers(): Headers {
return raw.headers
}
fun isSuccessful(): Boolean = raw.isSuccessful
fun errorBody(): ResponseBody? {
return errorBody
}
override fun toString(): String {
return raw.toString()
}
}

@ -0,0 +1,215 @@
package io.legado.app.model.webBook
import android.text.TextUtils
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.rule.TocRule
import io.legado.app.model.Debug
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.TocEmptyException
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import splitties.init.appCtx
/**
* 获取目录
*/
object BookChapterList {
private val falseRegex = "\\s*(?i)(null|false|0)\\s*".toRegex()
suspend fun analyzeChapterList(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
redirectUrl: String,
baseUrl: String,
body: String?
): List<BookChapter> {
body ?: throw NoStackTraceException(
appCtx.getString(R.string.error_get_web_content, baseUrl)
)
val chapterList = ArrayList<BookChapter>()
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
Debug.log(bookSource.bookSourceUrl, body, state = 30)
val tocRule = bookSource.getTocRule()
val nextUrlList = arrayListOf(baseUrl)
var reverse = false
var listRule = tocRule.chapterList ?: ""
if (listRule.startsWith("-")) {
reverse = true
listRule = listRule.substring(1)
}
if (listRule.startsWith("+")) {
listRule = listRule.substring(1)
}
var chapterData =
analyzeChapterList(
scope, book, baseUrl, redirectUrl, body,
tocRule, listRule, bookSource, log = true
)
chapterList.addAll(chapterData.first)
when (chapterData.second.size) {
0 -> Unit
1 -> {
var nextUrl = chapterData.second[0]
while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
nextUrlList.add(nextUrl)
AnalyzeUrl(
mUrl = nextUrl,
source = bookSource,
ruleData = book,
headerMapF = bookSource.getHeaderMap()
).getStrResponseAwait().body?.let { nextBody ->
chapterData = analyzeChapterList(
scope, book, nextUrl, nextUrl,
nextBody, tocRule, listRule, bookSource
)
nextUrl = chapterData.second.firstOrNull() ?: ""
chapterList.addAll(chapterData.first)
}
}
Debug.log(bookSource.bookSourceUrl, "◇目录总页数:${nextUrlList.size}")
}
else -> {
Debug.log(bookSource.bookSourceUrl, "◇并发解析目录,总页数:${chapterData.second.size}")
withContext(IO) {
val asyncArray = Array(chapterData.second.size) {
async(IO) {
val urlStr = chapterData.second[it]
val analyzeUrl = AnalyzeUrl(
mUrl = urlStr,
source = bookSource,
ruleData = book,
headerMapF = bookSource.getHeaderMap()
)
val res = analyzeUrl.getStrResponseAwait()
analyzeChapterList(
this, book, urlStr, res.url,
res.body!!, tocRule, listRule, bookSource, false
).first
}
}
asyncArray.forEach { coroutine ->
chapterList.addAll(coroutine.await())
}
}
}
}
if (chapterList.isEmpty()) {
throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty))
}
//去重
if (!reverse) {
chapterList.reverse()
}
val lh = LinkedHashSet(chapterList)
val list = ArrayList(lh)
if (!book.getReverseToc()) {
list.reverse()
}
Debug.log(book.origin, "◇目录总数:${list.size}")
list.forEachIndexed { index, bookChapter ->
bookChapter.index = index
}
book.latestChapterTitle = list.last().title
book.durChapterTitle =
list.getOrNull(book.durChapterIndex)?.title ?: book.latestChapterTitle
if (book.totalChapterNum < list.size) {
book.lastCheckCount = list.size - book.totalChapterNum
book.latestChapterTime = System.currentTimeMillis()
}
book.lastCheckTime = System.currentTimeMillis()
book.totalChapterNum = list.size
return list
}
private fun analyzeChapterList(
scope: CoroutineScope,
book: Book,
baseUrl: String,
redirectUrl: String,
body: String,
tocRule: TocRule,
listRule: String,
bookSource: BookSource,
getNextUrl: Boolean = true,
log: Boolean = false
): Pair<List<BookChapter>, List<String>> {
val analyzeRule = AnalyzeRule(book, bookSource)
analyzeRule.setContent(body).setBaseUrl(baseUrl)
analyzeRule.setRedirectUrl(redirectUrl)
//获取目录列表
val chapterList = arrayListOf<BookChapter>()
Debug.log(bookSource.bookSourceUrl, "┌获取目录列表", log)
val elements = analyzeRule.getElements(listRule)
Debug.log(bookSource.bookSourceUrl, "└列表大小:${elements.size}", log)
//获取下一页链接
val nextUrlList = arrayListOf<String>()
val nextTocRule = tocRule.nextTocUrl
if (getNextUrl && !nextTocRule.isNullOrEmpty()) {
Debug.log(bookSource.bookSourceUrl, "┌获取目录下一页列表", log)
analyzeRule.getStringList(nextTocRule, isUrl = true)?.let {
for (item in it) {
if (item != baseUrl) {
nextUrlList.add(item)
}
}
}
Debug.log(
bookSource.bookSourceUrl,
"" + TextUtils.join("\n", nextUrlList),
log
)
}
scope.ensureActive()
if (elements.isNotEmpty()) {
Debug.log(bookSource.bookSourceUrl, "┌解析目录列表", log)
val nameRule = analyzeRule.splitSourceRule(tocRule.chapterName)
val urlRule = analyzeRule.splitSourceRule(tocRule.chapterUrl)
val vipRule = analyzeRule.splitSourceRule(tocRule.isVip)
val payRule = analyzeRule.splitSourceRule(tocRule.isPay)
val upTimeRule = analyzeRule.splitSourceRule(tocRule.updateTime)
elements.forEachIndexed { index, item ->
scope.ensureActive()
analyzeRule.setContent(item)
val bookChapter = BookChapter(bookUrl = book.bookUrl, baseUrl = baseUrl)
analyzeRule.chapter = bookChapter
bookChapter.title = analyzeRule.getString(nameRule)
bookChapter.url = analyzeRule.getString(urlRule)
bookChapter.tag = analyzeRule.getString(upTimeRule)
if (bookChapter.url.isEmpty()) {
bookChapter.url = baseUrl
Debug.log(bookSource.bookSourceUrl, "目录${index}未获取到url,使用baseUrl替代")
}
if (bookChapter.title.isNotEmpty()) {
val isVip = analyzeRule.getString(vipRule)
val isPay = analyzeRule.getString(payRule)
if (isVip.isNotEmpty() && !isVip.matches(falseRegex)) {
bookChapter.isVip = true
}
if (isPay.isNotEmpty() && !isPay.matches(falseRegex)) {
bookChapter.isPay = true
}
chapterList.add(bookChapter)
}
}
Debug.log(bookSource.bookSourceUrl, "└目录列表解析完成", log)
Debug.log(bookSource.bookSourceUrl, "┌获取首章名称", log)
Debug.log(bookSource.bookSourceUrl, "${chapterList[0].title}", log)
Debug.log(bookSource.bookSourceUrl, "┌获取首章链接", log)
Debug.log(bookSource.bookSourceUrl, "${chapterList[0].url}", log)
Debug.log(bookSource.bookSourceUrl, "┌获取首章信息", log)
Debug.log(bookSource.bookSourceUrl, "${chapterList[0].tag}", log)
}
return Pair(chapterList, nextUrlList)
}
}

@ -0,0 +1,160 @@
package io.legado.app.model.webBook
import io.legado.app.R
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.rule.ContentRule
import io.legado.app.help.BookHelp
import io.legado.app.model.ContentEmptyException
import io.legado.app.model.Debug
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.HtmlFormatter
import io.legado.app.utils.NetworkUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import splitties.init.appCtx
/**
* 获取正文
*/
object BookContent {
@Throws(Exception::class)
suspend fun analyzeContent(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
redirectUrl: String,
baseUrl: String,
body: String?,
nextChapterUrl: String? = null
): String {
body ?: throw NoStackTraceException(
appCtx.getString(R.string.error_get_web_content, baseUrl)
)
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
Debug.log(bookSource.bookSourceUrl, body, state = 40)
val mNextChapterUrl = if (!nextChapterUrl.isNullOrEmpty()) {
nextChapterUrl
} else {
appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url
}
val content = StringBuilder()
val nextUrlList = arrayListOf(baseUrl)
val contentRule = bookSource.getContentRule()
val analyzeRule = AnalyzeRule(book, bookSource).setContent(body, baseUrl)
analyzeRule.setRedirectUrl(baseUrl)
analyzeRule.nextChapterUrl = mNextChapterUrl
scope.ensureActive()
var contentData = analyzeContent(
book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl
)
content.append(contentData.first)
if (contentData.second.size == 1) {
var nextUrl = contentData.second[0]
while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
if (!mNextChapterUrl.isNullOrEmpty()
&& NetworkUtils.getAbsoluteURL(baseUrl, nextUrl)
== NetworkUtils.getAbsoluteURL(baseUrl, mNextChapterUrl)
) break
nextUrlList.add(nextUrl)
scope.ensureActive()
val res = AnalyzeUrl(
mUrl = nextUrl,
source = bookSource,
ruleData = book,
headerMapF = bookSource.getHeaderMap()
).getStrResponseAwait()
res.body?.let { nextBody ->
contentData = analyzeContent(
book, nextUrl, res.url, nextBody, contentRule,
bookChapter, bookSource, mNextChapterUrl, false
)
nextUrl =
if (contentData.second.isNotEmpty()) contentData.second[0] else ""
content.append("\n").append(contentData.first)
}
}
Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}")
} else if (contentData.second.size > 1) {
Debug.log(bookSource.bookSourceUrl, "◇并发解析目录,总页数:${contentData.second.size}")
withContext(IO) {
val asyncArray = Array(contentData.second.size) {
async(IO) {
val urlStr = contentData.second[it]
val analyzeUrl = AnalyzeUrl(
mUrl = urlStr,
source = bookSource,
ruleData = book,
headerMapF = bookSource.getHeaderMap()
)
val res = analyzeUrl.getStrResponseAwait()
analyzeContent(
book, urlStr, res.url, res.body!!, contentRule,
bookChapter, bookSource, mNextChapterUrl, false
).first
}
}
asyncArray.forEach { coroutine ->
scope.ensureActive()
content.append("\n").append(coroutine.await())
}
}
}
var contentStr = content.toString()
val replaceRegex = contentRule.replaceRegex
if (!replaceRegex.isNullOrEmpty()) {
contentStr = analyzeRule.getString(replaceRegex, contentStr)
}
Debug.log(bookSource.bookSourceUrl, "┌获取章节名称")
Debug.log(bookSource.bookSourceUrl, "${bookChapter.title}")
Debug.log(bookSource.bookSourceUrl, "┌获取正文内容")
Debug.log(bookSource.bookSourceUrl, "\n$contentStr")
if (contentStr.isBlank()) {
throw ContentEmptyException("内容为空")
}
BookHelp.saveContent(bookSource, book, bookChapter, contentStr)
return contentStr
}
@Throws(Exception::class)
private fun analyzeContent(
book: Book,
baseUrl: String,
redirectUrl: String,
body: String,
contentRule: ContentRule,
chapter: BookChapter,
bookSource: BookSource,
nextChapterUrl: String?,
printLog: Boolean = true
): Pair<String, List<String>> {
val analyzeRule = AnalyzeRule(book, bookSource)
analyzeRule.setContent(body, baseUrl)
val rUrl = analyzeRule.setRedirectUrl(redirectUrl)
analyzeRule.nextChapterUrl = nextChapterUrl
val nextUrlList = arrayListOf<String>()
analyzeRule.chapter = chapter
//获取正文
var content = analyzeRule.getString(contentRule.content)
content = HtmlFormatter.formatKeepImg(content, rUrl)
//获取下一页链接
val nextUrlRule = contentRule.nextContentUrl
if (!nextUrlRule.isNullOrEmpty()) {
Debug.log(bookSource.bookSourceUrl, "┌获取正文下一页链接", printLog)
analyzeRule.getStringList(nextUrlRule, isUrl = true)?.let {
nextUrlList.addAll(it)
}
Debug.log(bookSource.bookSourceUrl, "" + nextUrlList.joinToString(""), printLog)
}
return Pair(content, nextUrlList)
}
}

@ -0,0 +1,140 @@
package io.legado.app.model.webBook
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.help.BookHelp
import io.legado.app.model.Debug
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.utils.HtmlFormatter
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.StringUtils.wordCountFormat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ensureActive
import splitties.init.appCtx
/**
* 获取详情
*/
object BookInfo {
@Throws(Exception::class)
fun analyzeBookInfo(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
redirectUrl: String,
baseUrl: String,
body: String?,
canReName: Boolean,
) {
body ?: throw NoStackTraceException(
appCtx.getString(R.string.error_get_web_content, baseUrl)
)
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
Debug.log(bookSource.bookSourceUrl, body, state = 20)
val analyzeRule = AnalyzeRule(book, bookSource)
analyzeRule.setContent(body).setBaseUrl(baseUrl)
analyzeRule.setRedirectUrl(redirectUrl)
analyzeBookInfo(scope, book, body, analyzeRule, bookSource, baseUrl, redirectUrl, canReName)
}
fun analyzeBookInfo(
scope: CoroutineScope,
book: Book,
body: String,
analyzeRule: AnalyzeRule,
bookSource: BookSource,
baseUrl: String,
redirectUrl: String,
canReName: Boolean,
) {
val infoRule = bookSource.getBookInfoRule()
infoRule.init?.let {
if (it.isNotBlank()) {
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "≡执行详情页初始化规则")
analyzeRule.setContent(analyzeRule.getElement(it))
}
}
val mCanReName = canReName && !infoRule.canReName.isNullOrBlank()
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取书名")
BookHelp.formatBookName(analyzeRule.getString(infoRule.name)).let {
if (it.isNotEmpty() && (mCanReName || book.name.isEmpty())) {
book.name = it
}
Debug.log(bookSource.bookSourceUrl, "${it}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取作者")
BookHelp.formatBookAuthor(analyzeRule.getString(infoRule.author)).let {
if (it.isNotEmpty() && (mCanReName || book.author.isEmpty())) {
book.author = it
}
Debug.log(bookSource.bookSourceUrl, "${it}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取分类")
try {
analyzeRule.getStringList(infoRule.kind)
?.joinToString(",")
?.let {
if (it.isNotEmpty()) book.kind = it
}
Debug.log(bookSource.bookSourceUrl, "${book.kind}")
} catch (e: Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取字数")
try {
wordCountFormat(analyzeRule.getString(infoRule.wordCount)).let {
if (it.isNotEmpty()) book.wordCount = it
}
Debug.log(bookSource.bookSourceUrl, "${book.wordCount}")
} catch (e: Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取最新章节")
try {
analyzeRule.getString(infoRule.lastChapter).let {
if (it.isNotEmpty()) book.latestChapterTitle = it
}
Debug.log(bookSource.bookSourceUrl, "${book.latestChapterTitle}")
} catch (e: Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取简介")
try {
analyzeRule.getString(infoRule.intro).let {
if (it.isNotEmpty()) book.intro = HtmlFormatter.format(it)
}
Debug.log(bookSource.bookSourceUrl, "${book.intro}")
} catch (e: Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取封面链接")
try {
analyzeRule.getString(infoRule.coverUrl).let {
if (it.isNotEmpty()) book.coverUrl = NetworkUtils.getAbsoluteURL(baseUrl, it)
}
Debug.log(bookSource.bookSourceUrl, "${book.coverUrl}")
} catch (e: Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}")
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取目录链接")
book.tocUrl = analyzeRule.getString(infoRule.tocUrl, isUrl = true)
if (book.tocUrl.isEmpty()) book.tocUrl = redirectUrl
if (book.tocUrl == redirectUrl) {
book.tocHtml = body
}
Debug.log(bookSource.bookSourceUrl, "${book.tocUrl}")
}
}

@ -0,0 +1,244 @@
package io.legado.app.model.webBook
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.BookListRule
import io.legado.app.help.BookHelp
import io.legado.app.model.Debug
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.HtmlFormatter
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.StringUtils.wordCountFormat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ensureActive
import splitties.init.appCtx
/**
* 获取书籍列表
*/
object BookList {
@Throws(Exception::class)
fun analyzeBookList(
scope: CoroutineScope,
bookSource: BookSource,
variableBook: SearchBook,
analyzeUrl: AnalyzeUrl,
baseUrl: String,
body: String?,
isSearch: Boolean = true,
): ArrayList<SearchBook> {
body ?: throw NoStackTraceException(
appCtx.getString(
R.string.error_get_web_content,
analyzeUrl.ruleUrl
)
)
val bookList = ArrayList<SearchBook>()
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${analyzeUrl.ruleUrl}")
Debug.log(bookSource.bookSourceUrl, body, state = 10)
val analyzeRule = AnalyzeRule(variableBook, bookSource)
analyzeRule.setContent(body).setBaseUrl(baseUrl)
analyzeRule.setRedirectUrl(baseUrl)
bookSource.bookUrlPattern?.let {
scope.ensureActive()
if (baseUrl.matches(it.toRegex())) {
Debug.log(bookSource.bookSourceUrl, "≡链接为详情页")
getInfoItem(
scope, bookSource, analyzeRule, analyzeUrl, body, baseUrl, variableBook.variable
)?.let { searchBook ->
searchBook.infoHtml = body
bookList.add(searchBook)
}
return bookList
}
}
val collections: List<Any>
var reverse = false
val bookListRule: BookListRule = when {
isSearch -> bookSource.getSearchRule()
bookSource.getExploreRule().bookList.isNullOrBlank() -> bookSource.getSearchRule()
else -> bookSource.getExploreRule()
}
var ruleList: String = bookListRule.bookList ?: ""
if (ruleList.startsWith("-")) {
reverse = true
ruleList = ruleList.substring(1)
}
if (ruleList.startsWith("+")) {
ruleList = ruleList.substring(1)
}
Debug.log(bookSource.bookSourceUrl, "┌获取书籍列表")
collections = analyzeRule.getElements(ruleList)
scope.ensureActive()
if (collections.isEmpty() && bookSource.bookUrlPattern.isNullOrEmpty()) {
Debug.log(bookSource.bookSourceUrl, "└列表为空,按详情页解析")
getInfoItem(
scope, bookSource, analyzeRule, analyzeUrl, body, baseUrl, variableBook.variable
)?.let { searchBook ->
searchBook.infoHtml = body
bookList.add(searchBook)
}
} else {
val ruleName = analyzeRule.splitSourceRule(bookListRule.name)
val ruleBookUrl = analyzeRule.splitSourceRule(bookListRule.bookUrl)
val ruleAuthor = analyzeRule.splitSourceRule(bookListRule.author)
val ruleCoverUrl = analyzeRule.splitSourceRule(bookListRule.coverUrl)
val ruleIntro = analyzeRule.splitSourceRule(bookListRule.intro)
val ruleKind = analyzeRule.splitSourceRule(bookListRule.kind)
val ruleLastChapter = analyzeRule.splitSourceRule(bookListRule.lastChapter)
val ruleWordCount = analyzeRule.splitSourceRule(bookListRule.wordCount)
Debug.log(bookSource.bookSourceUrl, "└列表大小:${collections.size}")
for ((index, item) in collections.withIndex()) {
getSearchItem(
scope, bookSource, analyzeRule, item, baseUrl, variableBook.variable,
index == 0,
ruleName = ruleName,
ruleBookUrl = ruleBookUrl,
ruleAuthor = ruleAuthor,
ruleCoverUrl = ruleCoverUrl,
ruleIntro = ruleIntro,
ruleKind = ruleKind,
ruleLastChapter = ruleLastChapter,
ruleWordCount = ruleWordCount
)?.let { searchBook ->
if (baseUrl == searchBook.bookUrl) {
searchBook.infoHtml = body
}
bookList.add(searchBook)
}
}
if (reverse) {
bookList.reverse()
}
}
return bookList
}
@Throws(Exception::class)
private fun getInfoItem(
scope: CoroutineScope,
bookSource: BookSource,
analyzeRule: AnalyzeRule,
analyzeUrl: AnalyzeUrl,
body: String,
baseUrl: String,
variable: String?
): SearchBook? {
val book = Book(variable = variable)
book.bookUrl = analyzeUrl.ruleUrl
book.origin = bookSource.bookSourceUrl
book.originName = bookSource.bookSourceName
book.originOrder = bookSource.customOrder
book.type = bookSource.bookSourceType
analyzeRule.book = book
BookInfo.analyzeBookInfo(
scope,
book,
body,
analyzeRule,
bookSource,
baseUrl,
baseUrl,
false
)
if (book.name.isNotBlank()) {
return book.toSearchBook()
}
return null
}
@Throws(Exception::class)
private fun getSearchItem(
scope: CoroutineScope,
bookSource: BookSource,
analyzeRule: AnalyzeRule,
item: Any,
baseUrl: String,
variable: String?,
log: Boolean,
ruleName: List<AnalyzeRule.SourceRule>,
ruleBookUrl: List<AnalyzeRule.SourceRule>,
ruleAuthor: List<AnalyzeRule.SourceRule>,
ruleKind: List<AnalyzeRule.SourceRule>,
ruleCoverUrl: List<AnalyzeRule.SourceRule>,
ruleWordCount: List<AnalyzeRule.SourceRule>,
ruleIntro: List<AnalyzeRule.SourceRule>,
ruleLastChapter: List<AnalyzeRule.SourceRule>
): SearchBook? {
val searchBook = SearchBook(variable = variable)
searchBook.origin = bookSource.bookSourceUrl
searchBook.originName = bookSource.bookSourceName
searchBook.type = bookSource.bookSourceType
searchBook.originOrder = bookSource.customOrder
analyzeRule.book = searchBook
analyzeRule.setContent(item)
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取书名", log)
searchBook.name = BookHelp.formatBookName(analyzeRule.getString(ruleName))
Debug.log(bookSource.bookSourceUrl, "${searchBook.name}", log)
if (searchBook.name.isNotEmpty()) {
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取作者", log)
searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor))
Debug.log(bookSource.bookSourceUrl, "${searchBook.author}", log)
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取分类", log)
try {
searchBook.kind = analyzeRule.getStringList(ruleKind)?.joinToString(",")
Debug.log(bookSource.bookSourceUrl, "${searchBook.kind}", log)
} catch (e: Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}", log)
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取字数", log)
try {
searchBook.wordCount = wordCountFormat(analyzeRule.getString(ruleWordCount))
Debug.log(bookSource.bookSourceUrl, "${searchBook.wordCount}", log)
} catch (e: java.lang.Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}", log)
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取最新章节", log)
try {
searchBook.latestChapterTitle = analyzeRule.getString(ruleLastChapter)
Debug.log(bookSource.bookSourceUrl, "${searchBook.latestChapterTitle}", log)
} catch (e: java.lang.Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}", log)
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取简介", log)
try {
searchBook.intro = HtmlFormatter.format(analyzeRule.getString(ruleIntro))
Debug.log(bookSource.bookSourceUrl, "${searchBook.intro}", log)
} catch (e: java.lang.Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}", log)
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取封面链接", log)
try {
analyzeRule.getString(ruleCoverUrl).let {
if (it.isNotEmpty()) searchBook.coverUrl =
NetworkUtils.getAbsoluteURL(baseUrl, it)
}
Debug.log(bookSource.bookSourceUrl, "${searchBook.coverUrl}", log)
} catch (e: java.lang.Exception) {
Debug.log(bookSource.bookSourceUrl, "${e.localizedMessage}", log)
}
scope.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取详情页链接", log)
searchBook.bookUrl = analyzeRule.getString(ruleBookUrl, isUrl = true)
if (searchBook.bookUrl.isEmpty()) {
searchBook.bookUrl = baseUrl
}
Debug.log(bookSource.bookSourceUrl, "${searchBook.bookUrl}", log)
return searchBook
}
return null
}
}

@ -0,0 +1,209 @@
package io.legado.app.model.webBook
import io.legado.app.constant.AppConst
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.AppConfig
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.isActive
import splitties.init.appCtx
import java.util.concurrent.Executors
import kotlin.math.min
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
val threadCount = AppConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var mSearchId = 0L
private var searchPage = 1
private var searchKey: String = ""
private var tasks = CompositeCoroutine()
private var bookSourceList = arrayListOf<BookSource>()
private var searchBooks = arrayListOf<SearchBook>()
@Volatile
private var searchIndex = -1
private fun initSearchPool() {
searchPool?.close()
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
}
fun search(searchId: Long, key: String) {
callBack.onSearchStart()
if (searchId != mSearchId) {
if (key.isEmpty()) {
callBack.onSearchCancel()
return
} else {
this.searchKey = key
}
if (mSearchId != 0L) {
close()
}
initSearchPool()
mSearchId = searchId
searchPage = 1
val searchGroup = appCtx.getPrefString("searchGroup") ?: ""
bookSourceList.clear()
if (searchGroup.isBlank()) {
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
val sources = appDb.bookSourceDao.getEnabledByGroup(searchGroup)
if (sources.isEmpty()) {
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
bookSourceList.addAll(sources)
}
}
} else {
searchPage++
}
searchIndex = -1
for (i in 0 until threadCount) {
search(searchId)
}
}
@Synchronized
private fun search(searchId: Long) {
if (searchIndex >= bookSourceList.lastIndex) {
return
}
searchIndex++
val source = bookSourceList[searchIndex]
searchPool?.let { searchPool ->
val task = WebBook.searchBook(
scope,
source,
searchKey,
searchPage,
context = searchPool
).timeout(30000L)
.onSuccess(searchPool) {
onSuccess(searchId, it)
}
.onFinally(searchPool) {
onFinally(searchId)
}
tasks.add(task)
}
}
@Synchronized
private fun onSuccess(searchId: Long, items: ArrayList<SearchBook>) {
if (searchId == mSearchId) {
appDb.searchBookDao.insert(*items.toTypedArray())
val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch)
mergeItems(scope, items, precision)
callBack.onSearchSuccess(searchBooks)
}
}
@Synchronized
private fun onFinally(searchId: Long) {
if (searchIndex < bookSourceList.lastIndex) {
search(searchId)
} else {
searchIndex++
}
if (searchIndex >= bookSourceList.lastIndex
+ min(bookSourceList.size, threadCount)
) {
callBack.onSearchFinish()
}
}
private fun mergeItems(scope: CoroutineScope, newDataS: List<SearchBook>, precision: Boolean) {
if (newDataS.isNotEmpty()) {
val copyData = ArrayList(searchBooks)
val equalData = arrayListOf<SearchBook>()
val containsData = arrayListOf<SearchBook>()
val otherData = arrayListOf<SearchBook>()
copyData.forEach {
if (!scope.isActive) return
if (it.name == searchKey || it.author == searchKey) {
equalData.add(it)
} else if (it.name.contains(searchKey) || it.author.contains(searchKey)) {
containsData.add(it)
} else {
otherData.add(it)
}
}
newDataS.forEach { nBook ->
if (!scope.isActive) return
if (nBook.name == searchKey || nBook.author == searchKey) {
var hasSame = false
equalData.forEach { pBook ->
if (!scope.isActive) return
if (pBook.name == nBook.name && pBook.author == nBook.author) {
pBook.addOrigin(nBook.origin)
hasSame = true
}
}
if (!hasSame) {
equalData.add(nBook)
}
} else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) {
var hasSame = false
containsData.forEach { pBook ->
if (!scope.isActive) return
if (pBook.name == nBook.name && pBook.author == nBook.author) {
pBook.addOrigin(nBook.origin)
hasSame = true
}
}
if (!hasSame) {
containsData.add(nBook)
}
} else if (!precision) {
var hasSame = false
otherData.forEach { pBook ->
if (!scope.isActive) return
if (pBook.name == nBook.name && pBook.author == nBook.author) {
pBook.addOrigin(nBook.origin)
hasSame = true
}
}
if (!hasSame) {
otherData.add(nBook)
}
}
}
if (!scope.isActive) return
equalData.sortByDescending { it.origins.size }
equalData.addAll(containsData.sortedByDescending { it.origins.size })
if (!precision) {
equalData.addAll(otherData)
}
searchBooks = equalData
}
}
fun cancelSearch() {
close()
callBack.onSearchCancel()
}
fun close() {
tasks.clear()
searchPool?.close()
searchPool = null
mSearchId = 0L
}
interface CallBack {
fun onSearchStart()
fun onSearchSuccess(searchBooks: ArrayList<SearchBook>)
fun onSearchFinish()
fun onSearchCancel()
}
}

@ -0,0 +1,345 @@
package io.legado.app.model.webBook
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.StrResponse
import io.legado.app.model.Debug
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlin.coroutines.CoroutineContext
@Suppress("MemberVisibilityCanBePrivate")
object WebBook {
/**
* 搜索
*/
fun searchBook(
scope: CoroutineScope,
bookSource: BookSource,
key: String,
page: Int? = 1,
context: CoroutineContext = Dispatchers.IO,
): Coroutine<ArrayList<SearchBook>> {
return Coroutine.async(scope, context) {
searchBookAwait(scope, bookSource, key, page)
}
}
suspend fun searchBookAwait(
scope: CoroutineScope,
bookSource: BookSource,
key: String,
page: Int? = 1,
): ArrayList<SearchBook> {
val variableBook = SearchBook()
bookSource.searchUrl?.let { searchUrl ->
val analyzeUrl = AnalyzeUrl(
mUrl = searchUrl,
key = key,
page = page,
baseUrl = bookSource.bookSourceUrl,
headerMapF = bookSource.getHeaderMap(true),
source = bookSource,
ruleData = variableBook,
)
var res = analyzeUrl.getStrResponseAwait()
//检测书源是否已登录
bookSource.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) {
res = analyzeUrl.evalJS(checkJs, res) as StrResponse
}
}
return BookList.analyzeBookList(
scope,
bookSource,
variableBook,
analyzeUrl,
res.url,
res.body,
true
)
}
return arrayListOf()
}
/**
* 发现
*/
fun exploreBook(
scope: CoroutineScope,
bookSource: BookSource,
url: String,
page: Int? = 1,
context: CoroutineContext = Dispatchers.IO,
): Coroutine<List<SearchBook>> {
return Coroutine.async(scope, context) {
exploreBookAwait(scope, bookSource, url, page)
}
}
suspend fun exploreBookAwait(
scope: CoroutineScope,
bookSource: BookSource,
url: String,
page: Int? = 1,
): ArrayList<SearchBook> {
val variableBook = SearchBook()
val analyzeUrl = AnalyzeUrl(
mUrl = url,
page = page,
baseUrl = bookSource.bookSourceUrl,
source = bookSource,
ruleData = variableBook,
headerMapF = bookSource.getHeaderMap(true)
)
var res = analyzeUrl.getStrResponseAwait()
//检测书源是否已登录
bookSource.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) {
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
}
}
return BookList.analyzeBookList(
scope,
bookSource,
variableBook,
analyzeUrl,
res.url,
res.body,
false
)
}
/**
* 书籍信息
*/
fun getBookInfo(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
context: CoroutineContext = Dispatchers.IO,
canReName: Boolean = true,
): Coroutine<Book> {
return Coroutine.async(scope, context) {
getBookInfoAwait(scope, bookSource, book, canReName)
}
}
suspend fun getBookInfoAwait(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
canReName: Boolean = true,
): Book {
book.type = bookSource.bookSourceType
if (!book.infoHtml.isNullOrEmpty()) {
BookInfo.analyzeBookInfo(
scope,
bookSource,
book,
book.bookUrl,
book.bookUrl,
book.infoHtml,
canReName
)
} else {
val analyzeUrl = AnalyzeUrl(
mUrl = book.bookUrl,
baseUrl = bookSource.bookSourceUrl,
source = bookSource,
ruleData = book,
headerMapF = bookSource.getHeaderMap(true)
)
var res = analyzeUrl.getStrResponseAwait()
//检测书源是否已登录
bookSource.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) {
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
}
}
BookInfo.analyzeBookInfo(
scope,
bookSource,
book,
book.bookUrl,
res.url,
res.body,
canReName
)
}
return book
}
/**
* 目录
*/
fun getChapterList(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
context: CoroutineContext = Dispatchers.IO
): Coroutine<List<BookChapter>> {
return Coroutine.async(scope, context) {
getChapterListAwait(scope, bookSource, book)
}
}
suspend fun getChapterListAwait(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
): List<BookChapter> {
book.type = bookSource.bookSourceType
return if (book.bookUrl == book.tocUrl && !book.tocHtml.isNullOrEmpty()) {
BookChapterList.analyzeChapterList(
scope,
bookSource,
book,
book.tocUrl,
book.tocUrl,
book.tocHtml
)
} else {
val analyzeUrl = AnalyzeUrl(
mUrl = book.tocUrl,
baseUrl = book.bookUrl,
source = bookSource,
ruleData = book,
headerMapF = bookSource.getHeaderMap(true)
)
var res = analyzeUrl.getStrResponseAwait()
//检测书源是否已登录
bookSource.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) {
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
}
}
BookChapterList.analyzeChapterList(
scope,
bookSource,
book,
book.tocUrl,
res.url,
res.body
)
}
}
/**
* 章节内容
*/
fun getContent(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
nextChapterUrl: String? = null,
context: CoroutineContext = Dispatchers.IO
): Coroutine<String> {
return Coroutine.async(scope, context) {
getContentAwait(scope, bookSource, book, bookChapter, nextChapterUrl)
}
}
suspend fun getContentAwait(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
nextChapterUrl: String? = null
): String {
if (bookSource.getContentRule().content.isNullOrEmpty()) {
Debug.log(bookSource.bookSourceUrl, "⇒正文规则为空,使用章节链接:${bookChapter.url}")
return bookChapter.url
}
return if (bookChapter.url == book.bookUrl && !book.tocHtml.isNullOrEmpty()) {
BookContent.analyzeContent(
scope,
bookSource,
book,
bookChapter,
bookChapter.getAbsoluteURL(),
bookChapter.getAbsoluteURL(),
book.tocHtml,
nextChapterUrl
)
} else {
val analyzeUrl = AnalyzeUrl(
mUrl = bookChapter.getAbsoluteURL(),
baseUrl = book.tocUrl,
source = bookSource,
ruleData = book,
chapter = bookChapter,
headerMapF = bookSource.getHeaderMap(true)
)
var res = analyzeUrl.getStrResponseAwait(
jsStr = bookSource.getContentRule().webJs,
sourceRegex = bookSource.getContentRule().sourceRegex
)
//检测书源是否已登录
bookSource.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) {
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
}
}
BookContent.analyzeContent(
scope,
bookSource,
book,
bookChapter,
bookChapter.getAbsoluteURL(),
res.url,
res.body,
nextChapterUrl
)
}
}
/**
* 精准搜索
*/
fun preciseSearch(
scope: CoroutineScope,
bookSources: List<BookSource>,
name: String,
author: String,
context: CoroutineContext = Dispatchers.IO,
): Coroutine<Pair<BookSource, Book>> {
return Coroutine.async(scope, context) {
preciseSearchAwait(scope, bookSources, name, author)
?: throw NoStackTraceException("没有搜索到<$name>$author")
}
}
suspend fun preciseSearchAwait(
scope: CoroutineScope,
bookSources: List<BookSource>,
name: String,
author: String
): Pair<BookSource, Book>? {
bookSources.forEach { source ->
kotlin.runCatching {
if (!scope.isActive) return null
searchBookAwait(scope, source, name).firstOrNull {
it.name == name && it.author == author
}?.let { searchBook ->
if (!scope.isActive) return null
var book = searchBook.toBook()
if (book.tocUrl.isBlank()) {
book = getBookInfoAwait(scope, source, book)
}
return Pair(source, book)
}
}
}
return null
}
}
Loading…
Cancel
Save