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