mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-21 03:31:23 +08:00
534 lines
25 KiB
Swift
534 lines
25 KiB
Swift
import Foundation
|
|
|
|
public struct RuleEngine: Sendable {
|
|
private let parser: RuleExpressionParser
|
|
private let jsonPath: JSONPathEvaluator
|
|
private let regex: RegexEvaluator
|
|
private let html: HTMLRuleEvaluator
|
|
private let javaScript: JavaScriptRuleEvaluator
|
|
|
|
public init(
|
|
parser: RuleExpressionParser = .init(),
|
|
jsonPath: JSONPathEvaluator = .init(),
|
|
regex: RegexEvaluator = .init(),
|
|
html: HTMLRuleEvaluator = .init(),
|
|
javaScript: JavaScriptRuleEvaluator = .init()
|
|
) {
|
|
self.parser = parser
|
|
self.jsonPath = jsonPath
|
|
self.regex = regex
|
|
self.html = html
|
|
self.javaScript = javaScript
|
|
}
|
|
|
|
public func evaluate(
|
|
_ rule: String,
|
|
in input: JSONValue,
|
|
listMode: Bool = false,
|
|
context: RuleExecutionContext = .init()
|
|
) async -> RuleEvaluation {
|
|
var context = context
|
|
if context.content == nil { context.content = input }
|
|
if let pipeline = splitJavaScriptPipeline(rule) {
|
|
return await evaluatePipeline(pipeline, input: input, listMode: listMode, context: context)
|
|
}
|
|
do {
|
|
let extracted = try extractPutRules(rule)
|
|
if !extracted.puts.isEmpty {
|
|
var nextContext = context
|
|
var diagnostics: [RuleDiagnostic] = []
|
|
for key in extracted.puts.keys.sorted() {
|
|
guard let valueRule = extracted.puts[key] else { continue }
|
|
let value = await evaluate(valueRule, in: input, context: nextContext)
|
|
diagnostics += value.diagnostics
|
|
nextContext.variables.merge(value.variables) { _, new in new }
|
|
nextContext.variables[key] = value.strings.joined(separator: "\n")
|
|
}
|
|
if extracted.rule.isEmpty {
|
|
return .init(values: [input], diagnostics: diagnostics, variables: nextContext.variables)
|
|
}
|
|
var result = await evaluate(extracted.rule, in: input, listMode: listMode, context: nextContext)
|
|
result.diagnostics = diagnostics + result.diagnostics
|
|
result.variables = nextContext.variables.merging(result.variables) { _, new in new }
|
|
return result
|
|
}
|
|
let rendered = try await renderDynamicRule(extracted.rule, result: input, context: context)
|
|
if rendered.captureLiteral {
|
|
return literalEvaluation(rendered.rule, context: rendered.context)
|
|
}
|
|
if rendered.context.variables != context.variables {
|
|
var result = await evaluatePrepared(rendered.rule, in: input, 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)
|
|
} catch let error as RuleEngineError {
|
|
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
|
} catch {
|
|
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
|
|
}
|
|
}
|
|
|
|
private func evaluatePrepared(
|
|
_ rule: String,
|
|
in input: JSONValue,
|
|
context: RuleExecutionContext
|
|
) async -> RuleEvaluation {
|
|
do {
|
|
let plan = try parser.parse(rule, inputLooksLikeJSON: true)
|
|
var results: [[JSONValue]] = []
|
|
var diagnostics: [RuleDiagnostic] = []
|
|
for branch in plan.branches {
|
|
do {
|
|
let values = try await evaluate(branch, in: input, context: context)
|
|
results.append(values)
|
|
if plan.combination == .fallback, !values.isEmpty { break }
|
|
} catch let error as RuleEngineError {
|
|
diagnostics.append(error.diagnostic)
|
|
results.append([])
|
|
} catch {
|
|
diagnostics.append(.init(
|
|
severity: .error,
|
|
code: "rule.unexpected",
|
|
message: error.localizedDescription,
|
|
rule: branch.expression
|
|
))
|
|
results.append([])
|
|
}
|
|
}
|
|
return .init(
|
|
values: combine(results, with: plan.combination),
|
|
diagnostics: diagnostics,
|
|
variables: context.variables
|
|
)
|
|
} catch let error as RuleEngineError {
|
|
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
|
} catch {
|
|
return .init(diagnostics: [.init(
|
|
severity: .error,
|
|
code: "rule.unexpected",
|
|
message: error.localizedDescription,
|
|
rule: rule
|
|
)], variables: context.variables)
|
|
}
|
|
}
|
|
|
|
public func evaluateJSONPath(_ rule: String, in input: JSONValue) throws -> [JSONValue] {
|
|
try jsonPath.evaluate(path: rule, in: input)
|
|
}
|
|
|
|
public func evaluate(
|
|
_ rule: String,
|
|
inText input: String,
|
|
baseURL: String = "",
|
|
listMode: Bool = false,
|
|
context: RuleExecutionContext = .init()
|
|
) async -> RuleEvaluation {
|
|
var context = context
|
|
if context.baseURL.isEmpty { context.baseURL = baseURL }
|
|
if context.content == nil { context.content = .string(input) }
|
|
if let pipeline = splitJavaScriptPipeline(rule) {
|
|
return await evaluatePipeline(pipeline, input: .string(input), listMode: listMode, context: context)
|
|
}
|
|
do {
|
|
let extracted = try extractPutRules(rule)
|
|
if !extracted.puts.isEmpty {
|
|
var nextContext = context
|
|
var diagnostics: [RuleDiagnostic] = []
|
|
for key in extracted.puts.keys.sorted() {
|
|
guard let valueRule = extracted.puts[key] else { continue }
|
|
let value = await evaluate(valueRule, inText: input, baseURL: baseURL, listMode: false, context: nextContext)
|
|
diagnostics += value.diagnostics
|
|
nextContext.variables.merge(value.variables) { _, new in new }
|
|
nextContext.variables[key] = value.strings.joined(separator: "\n")
|
|
}
|
|
if extracted.rule.isEmpty {
|
|
return .init(values: [.string(input)], diagnostics: diagnostics, variables: nextContext.variables)
|
|
}
|
|
var result = await evaluate(extracted.rule, inText: input, baseURL: baseURL, listMode: listMode, context: nextContext)
|
|
result.diagnostics = diagnostics + result.diagnostics
|
|
result.variables = nextContext.variables.merging(result.variables) { _, new in new }
|
|
return result
|
|
}
|
|
let rendered = try await renderDynamicRule(extracted.rule, result: .string(input), context: context)
|
|
if rendered.captureLiteral {
|
|
return literalEvaluation(rendered.rule, context: rendered.context)
|
|
}
|
|
if rendered.context.variables != context.variables {
|
|
var result = await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context)
|
|
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
|
|
return result
|
|
}
|
|
return await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context)
|
|
} catch let error as RuleEngineError {
|
|
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
|
} catch {
|
|
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
|
|
}
|
|
}
|
|
|
|
private func evaluatePrepared(
|
|
_ rule: String,
|
|
inText input: String,
|
|
baseURL: String,
|
|
listMode: Bool,
|
|
context: RuleExecutionContext
|
|
) async -> RuleEvaluation {
|
|
if rule.hasPrefix(":"), rule.count > 1 {
|
|
do {
|
|
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
|
|
guard !patterns.isEmpty else { return .init(variables: context.variables) }
|
|
var stage = input
|
|
var captures: [[String]] = []
|
|
for (index, pattern) in patterns.enumerated() {
|
|
captures = try regex.captureGroups(pattern: pattern, input: stage)
|
|
if index + 1 < patterns.count {
|
|
stage = captures.compactMap(\.first).joined()
|
|
}
|
|
}
|
|
return .init(
|
|
values: captures.map { .array($0.map(JSONValue.string)) },
|
|
variables: context.variables
|
|
)
|
|
} catch let error as RuleEngineError {
|
|
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
|
} catch {
|
|
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
|
|
}
|
|
}
|
|
do {
|
|
let plan = try parser.parse(rule, inputLooksLikeJSON: (try? JSONValue.decode(data: Data(input.utf8))) != nil)
|
|
var results: [[JSONValue]] = []
|
|
var diagnostics: [RuleDiagnostic] = []
|
|
for branch in plan.branches {
|
|
do {
|
|
let strings = try await evaluateTextBranch(
|
|
branch,
|
|
input: input,
|
|
baseURL: baseURL,
|
|
listMode: listMode,
|
|
context: context
|
|
)
|
|
results.append(strings.map(JSONValue.string))
|
|
if plan.combination == .fallback, !strings.isEmpty { break }
|
|
} catch let error as RuleEngineError {
|
|
diagnostics.append(error.diagnostic)
|
|
results.append([])
|
|
}
|
|
}
|
|
return .init(
|
|
values: combine(results, with: plan.combination),
|
|
diagnostics: diagnostics,
|
|
variables: context.variables
|
|
)
|
|
} catch let error as RuleEngineError {
|
|
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
|
} catch {
|
|
return .init(diagnostics: [.init(
|
|
severity: .error,
|
|
code: "rule.unexpected",
|
|
message: error.localizedDescription,
|
|
rule: rule
|
|
)], variables: context.variables)
|
|
}
|
|
}
|
|
|
|
private func evaluate(
|
|
_ branch: RuleBranch,
|
|
in input: JSONValue,
|
|
context: RuleExecutionContext
|
|
) async throws -> [JSONValue] {
|
|
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
|
|
))]
|
|
}
|
|
switch branch.mode {
|
|
case .jsonPath:
|
|
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
|
|
rawValues = try jsonPath.evaluate(path: path, in: input)
|
|
case .regex:
|
|
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
|
|
rawValues = matches.map { .array($0.map(JSONValue.string)) }
|
|
case .css:
|
|
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "CSS selector")
|
|
case .xpath:
|
|
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "XPath")
|
|
case .javaScript:
|
|
rawValues = [try await javaScript.evaluateAsync(
|
|
script: branch.expression,
|
|
result: input,
|
|
executionContext: context
|
|
).value]
|
|
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
|
|
))
|
|
}
|
|
}
|
|
|
|
private func evaluateTextBranch(
|
|
_ branch: RuleBranch,
|
|
input: String,
|
|
baseURL: String,
|
|
listMode: Bool,
|
|
context: RuleExecutionContext
|
|
) async throws -> [String] {
|
|
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
|
|
)]
|
|
}
|
|
switch branch.mode {
|
|
case .css:
|
|
raw = try listMode
|
|
? html.selectCSSFragments(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment)
|
|
: html.extractCSS(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment)
|
|
case .xpath:
|
|
raw = try listMode
|
|
? html.selectXPathFragments(branch.expression, html: input, isFragment: context.isHTMLFragment)
|
|
: html.extractXPath(branch.expression, html: input, isFragment: context.isHTMLFragment)
|
|
case .jsonPath:
|
|
let root: JSONValue
|
|
do {
|
|
root = try JSONValue.decode(data: Data(input.utf8))
|
|
} 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)
|
|
case .regex:
|
|
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
|
|
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
|
|
}
|
|
case .javaScript:
|
|
raw = [try await javaScript.evaluateAsync(
|
|
script: branch.expression,
|
|
result: .string(input),
|
|
executionContext: context
|
|
).value.stringValue]
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
private func combine(_ results: [[JSONValue]], with combination: RuleCombination) -> [JSONValue] {
|
|
switch combination {
|
|
case .single, .concatenate:
|
|
return results.flatMap { $0 }
|
|
case .fallback:
|
|
return results.first(where: { !$0.isEmpty }) ?? []
|
|
case .interleave:
|
|
guard let firstCount = results.first?.count else { return [] }
|
|
return (0 ..< firstCount).flatMap { index in
|
|
results.compactMap { $0.indices.contains(index) ? $0[index] : nil }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func evaluatePipeline(
|
|
_ pipeline: JavaScriptPipeline,
|
|
input: JSONValue,
|
|
listMode: Bool,
|
|
context: RuleExecutionContext
|
|
) async -> RuleEvaluation {
|
|
var execution = context
|
|
let initial: RuleEvaluation
|
|
if pipeline.prefix.isEmpty {
|
|
initial = .init(values: [input], variables: context.variables)
|
|
} else {
|
|
initial = await evaluateValue(pipeline.prefix, input: input, listMode: listMode, context: context)
|
|
}
|
|
execution.variables = initial.variables
|
|
var diagnostics = initial.diagnostics
|
|
let scriptInput: JSONValue
|
|
if pipeline.prefix.isEmpty {
|
|
scriptInput = input
|
|
} else if listMode {
|
|
if initial.values.count == 1, case .array = initial.values[0] {
|
|
scriptInput = initial.values[0]
|
|
} else {
|
|
scriptInput = .array(initial.values)
|
|
}
|
|
} else {
|
|
scriptInput = .string(initial.strings.joined(separator: "\n"))
|
|
}
|
|
do {
|
|
let rendered = try await renderDynamicRule(pipeline.script, result: scriptInput, context: execution)
|
|
let result = try await javaScript.evaluateAsync(
|
|
script: rendered.rule, result: scriptInput, executionContext: rendered.context
|
|
)
|
|
execution.variables = result.variables
|
|
guard !pipeline.suffix.isEmpty else {
|
|
return .init(values: result.value == .null ? [] : [result.value], diagnostics: diagnostics, variables: execution.variables)
|
|
}
|
|
execution.isHTMLFragment = false
|
|
var next = await evaluateValue(pipeline.suffix, input: result.value, listMode: listMode, context: execution)
|
|
next.diagnostics = diagnostics + next.diagnostics
|
|
return next
|
|
} catch let error as RuleEngineError {
|
|
diagnostics.append(error.diagnostic)
|
|
} catch {
|
|
diagnostics.append(.init(severity: .error, code: "rule.javascript", message: error.localizedDescription, rule: pipeline.script))
|
|
}
|
|
return .init(diagnostics: diagnostics, variables: execution.variables)
|
|
}
|
|
|
|
private func evaluateValue(_ rule: String, input: JSONValue, listMode: Bool, context: RuleExecutionContext) async -> RuleEvaluation {
|
|
if case .string(let text) = input {
|
|
return await evaluate(rule, inText: text, baseURL: context.baseURL, listMode: listMode, context: context)
|
|
}
|
|
return await evaluate(rule, in: input, listMode: listMode, context: context)
|
|
}
|
|
|
|
private func splitJavaScriptPipeline(_ rule: String) -> JavaScriptPipeline? {
|
|
guard let pattern = try? NSRegularExpression(pattern: #"<js>([\s\S]*?)</js>|@js:([\s\S]*)"#, options: .caseInsensitive),
|
|
let match = pattern.firstMatch(in: rule, range: NSRange(rule.startIndex..., in: rule)),
|
|
let full = Range(match.range, in: rule),
|
|
let script = Range(match.range(at: match.range(at: 2).location == NSNotFound ? 1 : 2), in: rule) else { return nil }
|
|
return .init(
|
|
prefix: String(rule[..<full.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines),
|
|
script: String(rule[script]),
|
|
suffix: String(rule[full.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
)
|
|
}
|
|
|
|
private func extractPutRules(_ source: String) throws -> (rule: String, puts: [String: String]) {
|
|
let expression = try NSRegularExpression(pattern: #"@put:(\{[^}]+?\})"#, options: .caseInsensitive)
|
|
let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source))
|
|
guard !matches.isEmpty else { return (source, [:]) }
|
|
var puts: [String: String] = [:]
|
|
var rule = source
|
|
for match in matches.reversed() {
|
|
guard let fullRange = Range(match.range(at: 0), in: source),
|
|
let jsonRange = Range(match.range(at: 1), in: source) else { continue }
|
|
if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(String(source[jsonRange]), quoteUnquotedValues: true) {
|
|
puts.merge(object.mapValues(\.stringValue)) { _, new in new }
|
|
}
|
|
if let currentRange = Range(match.range(at: 0), in: rule) {
|
|
rule.removeSubrange(currentRange)
|
|
} else {
|
|
rule = rule.replacingOccurrences(of: String(source[fullRange]), with: "")
|
|
}
|
|
}
|
|
return (rule.trimmingCharacters(in: .whitespacesAndNewlines), puts)
|
|
}
|
|
|
|
private func renderDynamicRule(
|
|
_ source: String,
|
|
result: JSONValue,
|
|
context: RuleExecutionContext
|
|
) async throws -> (rule: String, context: RuleExecutionContext, captureLiteral: Bool) {
|
|
var source = source
|
|
var captureLiteral = false
|
|
if case .array(let captures) = result {
|
|
let captureExpression = try NSRegularExpression(pattern: #"\$(\d{1,2})"#)
|
|
let captureEnd = source.range(of: "##")?.lowerBound ?? source.endIndex
|
|
let matches = captureExpression.matches(in: source, range: NSRange(source.startIndex..<captureEnd, in: source))
|
|
if !matches.isEmpty {
|
|
captureLiteral = true
|
|
for match in matches.reversed() {
|
|
guard let fullRange = Range(match.range(at: 0), in: source),
|
|
let indexRange = Range(match.range(at: 1), in: source),
|
|
let index = Int(source[indexRange]) else { continue }
|
|
source.replaceSubrange(fullRange, with: captures.indices.contains(index) ? captures[index].stringValue : "")
|
|
}
|
|
}
|
|
}
|
|
let expression = try NSRegularExpression(
|
|
pattern: #"@get:\{([^}]+?)\}|\{\{([\s\S]*?)\}\}"#,
|
|
options: .caseInsensitive
|
|
)
|
|
let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source))
|
|
guard !matches.isEmpty else { return (source, context, captureLiteral) }
|
|
let replacementStart = source.range(of: "##")?.lowerBound ?? source.endIndex
|
|
let selectorTemplate = source.hasPrefix("$.") || source.hasPrefix("$[")
|
|
if !selectorTemplate, let first = matches.first,
|
|
first.range.location < NSRange(source.startIndex..<replacementStart, in: source).length {
|
|
captureLiteral = true
|
|
}
|
|
var nextContext = context
|
|
var output = ""
|
|
var cursor = source.startIndex
|
|
for match in matches {
|
|
guard let fullRange = Range(match.range(at: 0), in: source) else { continue }
|
|
output += source[cursor..<fullRange.lowerBound]
|
|
if match.range(at: 1).location != NSNotFound,
|
|
let keyRange = Range(match.range(at: 1), in: source) {
|
|
let key = String(source[keyRange])
|
|
if key == "bookName", let book = nextContext.book {
|
|
output += book.name
|
|
} else if key == "title", let chapter = nextContext.chapter {
|
|
output += chapter.title
|
|
} else {
|
|
output += nextContext.variables[key] ?? ""
|
|
}
|
|
} else if let scriptRange = Range(match.range(at: 2), in: source) {
|
|
let expression = String(source[scriptRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if expression.hasPrefix("@") || expression.hasPrefix("$.") || expression.hasPrefix("$[") || expression.hasPrefix("//") {
|
|
captureLiteral = true
|
|
let nested = await evaluateValue(expression, input: context.content ?? result, listMode: false, context: nextContext)
|
|
if let error = nested.diagnostics.first(where: { $0.severity == .error }) {
|
|
throw RuleEngineError.malformedRule(rule: expression, detail: error.message)
|
|
}
|
|
output += nested.strings.joined(separator: "\n")
|
|
nextContext.variables = nested.variables
|
|
} else {
|
|
let evaluated = try await javaScript.evaluateAsync(
|
|
script: expression,
|
|
result: result,
|
|
executionContext: nextContext
|
|
)
|
|
output += evaluated.value.stringValue
|
|
nextContext.variables = evaluated.variables
|
|
}
|
|
}
|
|
cursor = fullRange.upperBound
|
|
}
|
|
output += source[cursor...]
|
|
return (output, nextContext, captureLiteral)
|
|
}
|
|
|
|
private func literalEvaluation(_ source: String, context: RuleExecutionContext) -> RuleEvaluation {
|
|
let components = source.components(separatedBy: "##")
|
|
var value = components.first ?? ""
|
|
if components.count > 1, !components[1].isEmpty {
|
|
value = (try? regex.replacing(
|
|
pattern: components[1],
|
|
in: value,
|
|
with: components.count > 2 ? components[2] : "",
|
|
firstOnly: components.count > 3
|
|
)) ?? value
|
|
}
|
|
return .init(values: [.string(value)], variables: context.variables)
|
|
}
|
|
|
|
private struct JavaScriptPipeline {
|
|
var prefix: String
|
|
var script: String
|
|
var suffix: String
|
|
}
|
|
}
|