parent
d5c95e47b5
commit
618afc3249
@ -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…
Reference in new issue