feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性

支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
Aminark
2026-10-24 00:58:11 +08:00
parent 3322fd71db
commit 67bc212ffd
58 changed files with 2331 additions and 244 deletions
@@ -17,6 +17,48 @@ public struct JSONPathEvaluator: Sendable {
public init() {}
// Legado's JSON rules also allow literal templates such as /book/{$.id}.
// Balance braces and quoted strings instead of stopping at the first }.
func evaluateRule(_ rule: String, in root: JSONValue) throws -> [JSONValue] {
var cursor = rule.startIndex
var output = ""
var foundTemplate = false
while let opening = rule.range(of: "{$.", range: cursor..<rule.endIndex) {
foundTemplate = true
output += rule[cursor..<opening.lowerBound]
let start = rule.index(after: opening.lowerBound)
var end = start
var depth = 1
var quote: Character?
var escaped = false
while end < rule.endIndex {
let character = rule[end]
if escaped { escaped = false }
else if character == "\\" { escaped = true }
else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" { quote = character }
else if character == "{" { depth += 1 }
else if character == "}" {
depth -= 1
if depth == 0 { break }
}
end = rule.index(after: end)
}
guard end < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: rule, detail: "JSONPath 模板大括号未闭合")
}
let values = try evaluate(path: String(rule[start..<end]), in: root)
output += RuleEvaluation(values: values).strings.joined(separator: "\n")
cursor = rule.index(after: end)
}
if foundTemplate {
output += rule[cursor...]
return [.string(output)]
}
return try evaluate(path: rule.hasPrefix("$") ? rule : "$." + rule, in: root)
}
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path)
var current = [root]
@@ -59,7 +101,10 @@ public struct JSONPathEvaluator: Sendable {
guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
}
if rule[next] == "." {
if rule[next] == "[" {
// Legacy sources use $.[*] as well as $[*].
index = next
} else if rule[next] == "." {
index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector))
@@ -84,7 +84,8 @@ public extension JSONValue {
if value is NSNull { return .null }
if let value = value as? JSONValue { return value }
if let value = value as? String { return .string(value) }
if let value = value as? Bool { return .bool(value) }
// NSNumber(0/1) also casts to Bool. Inspect its CF type first so
// JavaScript page arithmetic and numeric IDs remain numbers.
if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue)
@@ -208,6 +208,33 @@ public struct JavaScriptRuleEvaluator: Sendable {
script: String,
result: JSONValue,
executionContext: RuleExecutionContext
) throws -> JavaScriptRuleResult {
try evaluate(script: script, result: result, executionContext: executionContext,
virtualMachine: JSVirtualMachine(), stringifyResult: false)
}
/// A rule shares java.get/put storage, but each match gets fresh JS globals.
/// Keep this closure on the synchronous replacement call's thread.
func replacementEvaluator(script: String, book: Book, chapter: BookChapter) -> (String) throws -> String {
var virtualMachine: JSVirtualMachine?
var executionContext = RuleExecutionContext(book: book, chapter: chapter)
return { match in
if virtualMachine == nil { virtualMachine = JSVirtualMachine() }
let result = try autoreleasepool {
try evaluate(script: script, result: .string(match), executionContext: executionContext,
virtualMachine: virtualMachine!, stringifyResult: true)
}
executionContext.variables = result.variables
return result.value.stringValue
}
}
private func evaluate(
script: String,
result: JSONValue,
executionContext: RuleExecutionContext,
virtualMachine: JSVirtualMachine,
stringifyResult: Bool
) throws -> JavaScriptRuleResult {
guard script.utf8.count <= maximumScriptLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限")
@@ -215,8 +242,7 @@ public struct JavaScriptRuleEvaluator: Sendable {
try rejectUnboundedExecution(script)
let box = JSVariableBox(executionContext.variables)
let vm = JSVirtualMachine()
guard let context = JSContext(virtualMachine: vm) else {
guard let context = JSContext(virtualMachine: virtualMachine) else {
throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文")
}
var exceptionMessage: String?
@@ -271,7 +297,28 @@ public struct JavaScriptRuleEvaluator: Sendable {
let normalized = evaluated.flatMap {
context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0])
}
let value = JSONValue.bridge(normalized?.toObject())
let value: JSONValue
if stringifyResult {
// Rhino unwraps undefined to null before RegexExtensions.kt calls
// toString(). Preserve strings directly, rather than JSON encoding.
if let evaluated, !evaluated.isUndefined, !evaluated.isNull {
let text = evaluated.toString() ?? "null"
if evaluated.isNumber {
// The reference runs Rhino in interpreted mode, yielding JVM Doubles.
let number = evaluated.toDouble()
value = .string(number.isFinite ? String(number) : text)
} else {
value = .string(text)
}
} else {
value = .string("null")
}
} else {
value = JSONValue.bridge(normalized?.toObject())
}
if let exceptionMessage {
throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)")
}
guard value.stringValue.utf8.count <= maximumResultLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限")
}
@@ -974,53 +1021,48 @@ public struct JavaScriptRuleEvaluator: Sendable {
return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext)
}
let plan = try RuleExpressionParser().parse(trimmedRule)
let jsonContent: JSONValue?
if case .string(let text) = content { jsonContent = try? JSONValue.decode(data: Data(text.utf8)) }
else { jsonContent = content }
let plan = try RuleExpressionParser().parse(trimmedRule, inputLooksLikeJSON: jsonContent != nil)
var branchResults: [[String]] = []
for branch in plan.branches {
var values: [String]
switch branch.mode {
case .css:
values = try HTMLRuleEvaluator().extractCSS(
branch.expression,
html: content.stringValue,
baseURL: executionContext.baseURL,
isFragment: executionContext.isHTMLFragment
)
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression
let normalizedPath = path.hasPrefix("$") ? path : "$" + path
values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json)
.map(\.stringValue)
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
if let pattern = branch.replacePattern, !pattern.isEmpty {
values = try values.map {
try RegexEvaluator().replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
if branch.expression.isEmpty {
values = [content.stringValue]
} else {
switch branch.mode {
case .css:
values = try HTMLRuleEvaluator().extractCSS(
branch.expression,
html: content.stringValue,
baseURL: executionContext.baseURL,
isFragment: executionContext.isHTMLFragment
)
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
values = RuleEvaluation(values: try JSONPathEvaluator().evaluateRule(branch.expression, in: json)).strings
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
}
if plan.combination == .fallback, values.joined(separator: "\n").isEmpty { values = [] }
branchResults.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
}
@@ -1037,7 +1079,11 @@ public struct JavaScriptRuleEvaluator: Sendable {
branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil }
}
}
return resolveJavaScriptRuleURL(combined.joined(separator: "\n"), enabled: isURL, context: executionContext)
var result = combined.joined(separator: "\n")
if let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty {
result = try RegexEvaluator().replacing(pattern: pattern, in: result, with: branch.replacement, firstOnly: branch.replaceFirst)
}
return resolveJavaScriptRuleURL(result, enabled: isURL, context: executionContext)
}
private func resolveJavaScriptRuleURL(
@@ -112,6 +112,25 @@ public struct RegexEvaluator: Sendable {
public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> String {
try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
expression.replacementString(for: match, in: input, offset: 0, template: replacement)
}
}
/// Legado's JS replacement still expands capture references, while quoting
/// backslashes returned by the script (quoteReplacementJs in the reference).
func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64 = 3_000,
replacement: (String) throws -> String) throws -> String {
let original = input as NSString
return try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
let template = try replacement(original.substring(with: match.range))
.replacingOccurrences(of: "\\", with: "\\\\")
return expression.replacementString(for: match, in: input, offset: 0, template: template)
}
}
private func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64,
replacement: (NSRegularExpression, NSTextCheckingResult) throws -> String) throws -> String {
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
@@ -145,30 +164,51 @@ public struct RegexEvaluator: Sendable {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset))
let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement)
output += untouched + replaced
let replaced = try replacement(expression, match)
guard DispatchTime.now().uptimeNanoseconds < deadline else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += untouched + replaced
}
output += original.substring(from: offset)
let tail = original.substring(from: offset)
guard outputLength + tail.utf16.count <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += tail
return output
}
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"#
guard let expression = try? NSRegularExpression(pattern: risky) else { return false }
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in
guard let innerRange = Range(match.range(at: 1), in: pattern),
let outerRange = Range(match.range(at: 2), in: pattern) else { return true }
let inner = String(pattern[innerRange])
let outer = String(pattern[outerRange])
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false }
if !pattern.contains("(?s"),
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false }
return true
// Reject clear cases such as (a+)+ and (\w*)*. A '+' inside a character
// class/escape is not a quantifier, and bounded or delimited repeated
// groups are not proof of catastrophic backtracking. Other expressions
// remain protected by enumerateMatches' progress/deadline checks.
let atom = #"^(?:\?:)?(?:\\[dDsSwW]|\\[^pPkKgG0-9]|\[(?:\\.|[^\]\\])*\]|[^\[\]()\\?*+{}|^$])(?:[+*]|\{[0-9]+,\})\??$"#
guard let repeatedAtom = try? NSRegularExpression(pattern: atom),
let unbounded = try? NSRegularExpression(pattern: #"^(?:[+*]|\{[0-9]+,\})(?!\+)"#) else { return false }
var groups: [String.Index] = []
var classDepth = 0
var escaped = false
for index in pattern.indices {
let character = pattern[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if character == "[" { classDepth += 1; continue }
if character == "]", classDepth > 0 { classDepth -= 1; continue }
guard classDepth == 0 else { continue }
if character == "(" { groups.append(pattern.index(after: index)) }
if character == ")", let start = groups.popLast() {
let inner = String(pattern[start..<index])
let suffix = String(pattern[pattern.index(after: index)...])
if repeatedAtom.firstMatch(in: inner, range: NSRange(inner.startIndex..., in: inner)) != nil,
unbounded.firstMatch(in: suffix, range: NSRange(suffix.startIndex..., in: suffix)) != nil { return true }
}
}
return false
}
}
+33 -34
View File
@@ -57,11 +57,11 @@ public struct RuleEngine: Sendable {
return literalEvaluation(rendered.rule, context: rendered.context)
}
if rendered.context.variables != context.variables {
var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
var result = await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result
}
return await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
return await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
@@ -72,6 +72,7 @@ public struct RuleEngine: Sendable {
private func evaluatePrepared(
_ rule: String,
in input: JSONValue,
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
do {
@@ -80,7 +81,8 @@ public struct RuleEngine: Sendable {
var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches {
do {
let values = try await evaluate(branch, in: input, context: context)
var values = try await evaluate(branch, in: input, context: context)
if plan.combination == .fallback, isEmpty(values, listMode: listMode) { values = [] }
results.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
} catch let error as RuleEngineError {
@@ -97,7 +99,7 @@ public struct RuleEngine: Sendable {
}
}
return .init(
values: combine(results, with: plan.combination),
values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics,
variables: context.variables
)
@@ -174,6 +176,11 @@ public struct RuleEngine: Sendable {
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
if let root = try? JSONValue.decode(data: Data(input.utf8)),
let plan = try? parser.parse(rule, inputLooksLikeJSON: true),
plan.branches.allSatisfy({ $0.mode == .jsonPath }) {
return await evaluatePrepared(rule, in: root, listMode: listMode, context: context)
}
if rule.hasPrefix(":"), rule.count > 1 {
do {
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
@@ -217,7 +224,7 @@ public struct RuleEngine: Sendable {
}
}
return .init(
values: combine(results, with: plan.combination),
values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics,
variables: context.variables
)
@@ -241,15 +248,11 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation()
let rawValues: [JSONValue]
if branch.expression.isEmpty {
return try [JSONValue.string(regex.replacing(
pattern: branch.replacePattern ?? "", in: input.stringValue,
with: branch.replacement, firstOnly: branch.replaceFirst
))]
return [input]
}
switch branch.mode {
case .jsonPath:
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
rawValues = try jsonPath.evaluate(path: path, in: input)
rawValues = try jsonPath.evaluateRule(branch.expression, in: input)
case .regex:
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
rawValues = matches.map { .array($0.map(JSONValue.string)) }
@@ -266,15 +269,7 @@ public struct RuleEngine: Sendable {
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues }
return try rawValues.map {
.string(try regex.replacing(
pattern: pattern,
in: $0.stringValue,
with: branch.replacement,
firstOnly: branch.replaceFirst
))
}
return rawValues
}
private func evaluateTextBranch(
@@ -287,10 +282,7 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation()
let raw: [String]
if branch.expression.isEmpty {
return try [regex.replacing(
pattern: branch.replacePattern ?? "", in: input,
with: branch.replacement, firstOnly: branch.replaceFirst
)]
return [input]
}
switch branch.mode {
case .css:
@@ -308,8 +300,7 @@ public struct RuleEngine: Sendable {
} catch {
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
raw = try jsonPath.evaluateRule(branch.expression, in: root).map(\.stringValue)
case .regex:
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
@@ -323,14 +314,22 @@ public struct RuleEngine: Sendable {
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw }
return try raw.map {
try regex.replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
)
return raw
}
private func isEmpty(_ values: [JSONValue], listMode: Bool) -> Bool {
let result = RuleEvaluation(values: values)
return listMode
? result.listItems(rule: "").filter { $0 != .null }.isEmpty
: result.strings.joined(separator: "\n").isEmpty
}
private func applyingReplacement(to values: [JSONValue], plan: RulePlan, listMode: Bool) throws -> [JSONValue] {
guard let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty else { return values }
let strings = RuleEvaluation(values: values).strings
let inputs = listMode ? strings : [strings.joined(separator: "\n")]
return try inputs.map {
.string(try regex.replacing(pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst))
}
}
@@ -52,8 +52,12 @@ public struct RuleExpressionParser: Sendable {
guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
}
let split = try splitAtTopLevel(normalized)
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) }
// ## belongs to the whole selector stage. Regex/replacement text may
// contain quotes, brackets and && that are not selector operators.
let replacementStart = normalized.range(of: "##")?.lowerBound ?? normalized.endIndex
let split = try splitAtTopLevel(String(normalized[..<replacementStart]))
let suffix = String(normalized[replacementStart...])
let branches = try split.parts.map { try parseBranch($0 + suffix, inputLooksLikeJSON: inputLooksLikeJSON) }
return .init(source: source, combination: split.combination, branches: branches)
}