feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -0,0 +1,312 @@
import Foundation
import Kanna
import SwiftSoup
public struct HTMLRuleEvaluator: Sendable {
private enum IndexItem {
case index(Int)
case range(Int?, Int?, Int)
}
private struct IndexedSelector {
var selector: String
var excludes: Bool
var items: [IndexItem]
}
public init() {}
public func selectCSSFragments(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] {
do {
let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment)
let stages = splitChain(rule).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return try selectElements(stages: stages, from: root).map { try $0.outerHtml() }
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error))
}
}
public func extractCSS(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] {
do {
let parsed = try parseCSSExtraction(rule)
let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment)
let elements = try selectElements(stages: parsed.selectors, from: root)
if parsed.output == "html" {
for element in elements {
try element.select("script").remove()
try element.select("style").remove()
}
let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n")
return value.isEmpty ? [] : [value]
}
if parsed.output == "all" {
let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n")
return value.isEmpty ? [] : [value]
}
var seenAttributes: Set<String> = []
return try elements.compactMap { element in
let value: String
switch parsed.output {
case "text": value = try element.text()
case "ownText": value = element.ownText()
case "textNodes":
value = element.textNodes().map { $0.text().trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
default:
value = try element.attr(parsed.output)
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
seenAttributes.insert(value).inserted else { return nil }
}
return value.isEmpty ? nil : value
}
} catch let error as RuleEngineError {
throw error
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error))
}
}
public func selectXPathFragments(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] {
try xpathResult(rule, html: html, isFragment: isFragment).compactMap(\.toHTML)
}
public func extractXPath(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] {
let result = try xpathResult(rule, html: html, isFragment: isFragment)
switch result {
case .none:
return []
case .NodeSet:
return result.compactMap { node in
let value = node.text ?? node.content ?? ""
return value.isEmpty ? nil : value
}
case .Bool(let value):
return [String(value)]
case .Number(let value):
return [value.rounded() == value ? String(format: "%.0f", value) : String(value)]
case .String(let value):
return value.isEmpty ? [] : [value]
}
}
private func xpathResult(_ rule: String, html: String, isFragment: Bool) throws -> XPathObject {
let document = try parseKanna(html, rule: rule)
if isFragment, let element = document.at_xpath("/html/body/*[1]") {
return element.xpath(rule)
}
return document.xpath(rule)
}
private func parseKanna(_ html: String, rule: String) throws -> any HTMLDocument {
do {
return try Kanna.HTML(html: html, encoding: .utf8)
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "HTML 解析失败:\(error.localizedDescription)")
}
}
private func parseCSSExtraction(_ rule: String) throws -> (selectors: [String], output: String) {
let components = splitChain(rule)
guard components.count > 1 else {
let selector = rule.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selector.isEmpty else {
throw RuleEngineError.malformedRule(rule: rule, detail: "CSS selector 不能为空")
}
if selector.range(of: #"^[\w:-]+$"#, options: .regularExpression) != nil {
return ([], selector)
}
return ([selector], "text")
}
var selectors = components.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let output = components.last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
while selectors.first?.isEmpty == true { selectors.removeFirst() }
guard selectors.allSatisfy({ !$0.isEmpty }), !output.isEmpty else {
throw RuleEngineError.malformedRule(rule: rule, detail: "CSS 取值规则必须是 selector@text 或 selector@attribute")
}
return (selectors, output)
}
private func cssFailureDetail(_ error: Error) -> String {
if case SwiftSoup.Exception.Error(_, let message) = error {
return "CSS 规则执行失败:\(message)"
}
return "CSS 规则执行失败:\(error.localizedDescription)"
}
private func splitChain(_ rule: String) -> [String] {
var parts: [String] = []
var quote: Character?
var escaped = false
var squareDepth = 0
var roundDepth = 0
var start = rule.startIndex
for index in rule.indices {
let character = rule[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if let activeQuote = quote {
if character == activeQuote { quote = nil }
continue
}
if character == "\"" || character == "'" { quote = character; continue }
if character == "[" { squareDepth += 1 }
if character == "]" { squareDepth -= 1 }
if character == "(" { roundDepth += 1 }
if character == ")" { roundDepth -= 1 }
if character == "@", squareDepth == 0, roundDepth == 0 {
parts.append(String(rule[start..<index]))
start = rule.index(after: index)
}
}
parts.append(String(rule[start...]))
return parts
}
private func parseRoot(_ html: String, baseURL: String, isFragment: Bool) throws -> Element {
guard isFragment else { return try SwiftSoup.parse(html, baseURL) }
if html.range(of: #"^\s*(?:<!doctype\b|<html\b)"#, options: [.regularExpression, .caseInsensitive]) != nil {
return try SwiftSoup.parse(html, baseURL)
}
// Reconstruct the selected element, not a new document. In particular,
// parsing a standalone tr/td as a document discards its table structure.
let tag = html.range(of: #"^\s*<([A-Za-z][A-Za-z0-9:-]*)"#, options: .regularExpression)
.map { String(html[$0]).trimmingCharacters(in: .whitespacesAndNewlines).dropFirst().lowercased() } ?? ""
let parent: String
switch tag {
case "tr": parent = "tbody"
case "td", "th": parent = "tr"
case "tbody", "thead", "tfoot", "caption", "colgroup": parent = "table"
case "col": parent = "colgroup"
case "option", "optgroup": parent = "select"
default: parent = "body"
}
let container = try Element(Tag.valueOf(parent), baseURL)
let nodes = try Parser.parseFragment(html, container, Array(baseURL.utf8))
let elements = nodes.compactMap { $0 as? Element }
if elements.count == 1, let element = elements.first { return element }
return try SwiftSoup.parseBodyFragment(html, baseURL)
}
private func selectElements(stages: [String], from document: Element) throws -> [Element] {
var current: [Element] = [document]
for stage in stages {
var selected: [Element] = []
let parsed = parseIndexedSelector(stage)
for parent in current {
let candidates: [Element]
switch parsed.selector {
case "", "children": candidates = parent.children().array()
default: candidates = try parent.select(cssSelector(parsed.selector)).array()
}
selected += applyIndex(parsed, to: candidates)
}
current = selected
}
return current
}
private func cssSelector(_ legacy: String) -> String {
if legacy.hasPrefix("class.") { return "." + legacy.dropFirst(6) }
if legacy.hasPrefix("tag.") { return String(legacy.dropFirst(4)) }
if legacy.hasPrefix("id.") { return "#" + legacy.dropFirst(3) }
if legacy.hasPrefix("text.") {
let value = String(legacy.dropFirst(5)).replacingOccurrences(of: "\"", with: "\\\"")
return ":containsOwn(\(value))"
}
return legacy
}
private func parseIndexedSelector(_ source: String) -> IndexedSelector {
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasSuffix("]"), let open = value.lastIndex(of: "[") {
var content = String(value[value.index(after: open)..<value.index(before: value.endIndex)])
.trimmingCharacters(in: .whitespacesAndNewlines)
let allowed = CharacterSet(charactersIn: "!-0123456789:, ")
if !content.isEmpty, content.unicodeScalars.allSatisfy(allowed.contains) {
let excludes = content.hasPrefix("!")
if excludes { content.removeFirst() }
let items = content.split(separator: ",", omittingEmptySubsequences: false).compactMap(parseIndexItem)
if !items.isEmpty {
return .init(
selector: String(value[..<open]).trimmingCharacters(in: .whitespacesAndNewlines),
excludes: excludes,
items: items
)
}
}
}
for delimiter in ["!", "."] {
guard let marker = value.lastIndex(of: Character(delimiter)) else { continue }
let suffix = String(value[value.index(after: marker)...])
let values = suffix.split(separator: ":", omittingEmptySubsequences: false).compactMap {
Int($0.trimmingCharacters(in: .whitespaces))
}
if !suffix.isEmpty, values.count == suffix.split(separator: ":", omittingEmptySubsequences: false).count {
return .init(
selector: String(value[..<marker]),
excludes: delimiter == "!",
items: values.map(IndexItem.index)
)
}
}
return .init(selector: value, excludes: false, items: [])
}
private func parseIndexItem(_ source: Substring) -> IndexItem? {
let values = source.split(separator: ":", omittingEmptySubsequences: false)
if values.count == 1, let index = Int(values[0].trimmingCharacters(in: .whitespaces)) {
return .index(index)
}
guard (2...3).contains(values.count) else { return nil }
let start = values[0].isEmpty ? nil : Int(values[0].trimmingCharacters(in: .whitespaces))
let end = values[1].isEmpty ? nil : Int(values[1].trimmingCharacters(in: .whitespaces))
let step = values.count == 3 ? Int(values[2].trimmingCharacters(in: .whitespaces)) ?? 1 : 1
return step == 0 ? nil : .range(start, end, step)
}
private func applyIndex(_ selector: IndexedSelector, to elements: [Element]) -> [Element] {
guard !selector.items.isEmpty, !elements.isEmpty else { return elements }
var indexes: [Int] = []
func append(_ index: Int) {
let resolved = index < 0 ? elements.count + index : index
if elements.indices.contains(resolved), !indexes.contains(resolved) { indexes.append(resolved) }
}
for item in selector.items {
switch item {
case .index(let index): append(index)
case .range(let rawStart, let rawEnd, let rawStep):
var start = rawStart ?? 0
var end = rawEnd ?? (elements.count - 1)
if start < 0 { start += elements.count }
if end < 0 { end += elements.count }
if (start < 0 && end < 0) || (start >= elements.count && end >= elements.count) {
continue
}
start = min(max(start, 0), elements.count - 1)
end = min(max(end, 0), elements.count - 1)
let direction = end >= start ? 1 : -1
let normalizedStep: Int
if rawStep > 0 {
normalizedStep = rawStep
} else if -rawStep < elements.count {
normalizedStep = rawStep + elements.count
} else {
normalizedStep = 1
}
let step = max(normalizedStep, 1) * direction
var cursor = start
while direction > 0 ? cursor <= end : cursor >= end {
append(cursor)
cursor += step
}
}
}
if selector.excludes {
let excluded = Set(indexes)
return elements.enumerated().compactMap { excluded.contains($0.offset) ? nil : $0.element }
}
return indexes.map { elements[$0] }
}
}
@@ -0,0 +1,496 @@
import Foundation
public struct JSONPathEvaluator: Sendable {
private enum Selector: Equatable {
case key(String)
case index(Int)
case wildcard
}
private enum Segment: Equatable {
case child(Selector)
case recursive(Selector)
case union([Selector])
case slice(start: Int?, end: Int?, step: Int)
case filter(String)
}
public init() {}
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path)
var current = [root]
for segment in segments {
try Task.checkCancellation()
switch segment {
case .child(let selector):
current = current.flatMap { select(selector, from: $0) }
case .recursive(let selector):
current = current.flatMap { recursivelySelect(selector, from: $0) }
case .union(let selectors):
current = current.flatMap { node in selectors.flatMap { select($0, from: node) } }
case .slice(let start, let end, let step):
current = current.flatMap { slice($0, start: start, end: end, step: step) }
case .filter(let predicate):
var filtered: [JSONValue] = []
for node in current {
guard case .array(let values) = node else { continue }
for value in values where try matches(predicate, candidate: value, root: root) {
filtered.append(value)
}
}
current = filtered
}
}
return current
}
private func parse(_ path: String) throws -> [Segment] {
let rule = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard rule.first == "$" else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 必须以 $ 开始")
}
var segments: [Segment] = []
var index = rule.index(after: rule.startIndex)
while index < rule.endIndex {
let character = rule[index]
if character == "." {
let next = rule.index(after: index)
guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
}
if rule[next] == "." {
index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector))
index = parsed.next
} else {
index = next
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.child(parsed.selector))
index = parsed.next
}
} else if character == "[" {
let parsed = try parseBracket(rule, from: index, original: path)
segments.append(parsed.segment)
index = parsed.next
} else {
throw RuleEngineError.malformedRule(
rule: path,
detail: "JSONPath 在 \(rule.distance(from: rule.startIndex, to: index)) 处包含意外字符"
)
}
}
return segments
}
private func parseDotSelector(
_ rule: String,
from start: String.Index,
original: String
) throws -> (selector: Selector, next: String.Index) {
if rule[start] == "*" {
return (.wildcard, rule.index(after: start))
}
var end = start
while end < rule.endIndex, rule[end] != ".", rule[end] != "[" {
end = rule.index(after: end)
}
let key = String(rule[start..<end])
guard !key.isEmpty else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 属性名不能为空")
}
return (.key(key), end)
}
private func parseBracket(
_ rule: String,
from open: String.Index,
original: String
) throws -> (segment: Segment, next: String.Index) {
var index = rule.index(after: open)
var quote: Character?
var escaped = false
var depth = 1
while index < rule.endIndex {
let character = rule[index]
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
guard depth == 0 else {
index = rule.index(after: index)
continue
}
let content = String(rule[rule.index(after: open)..<index])
.trimmingCharacters(in: .whitespacesAndNewlines)
let segment = try parseBracketContent(content, original: original)
return (segment, rule.index(after: index))
}
index = rule.index(after: index)
}
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号未闭合")
}
private func parseBracketContent(_ content: String, original: String) throws -> Segment {
guard !content.isEmpty else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号不能为空")
}
if content.hasPrefix("?(") && content.hasSuffix(")") {
let predicate = String(content.dropFirst(2).dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
guard !predicate.isEmpty else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath filter 不能为空")
}
return .filter(predicate)
}
if content == "*" { return .child(.wildcard) }
if content.contains(":") {
let values = content.split(separator: ":", omittingEmptySubsequences: false)
guard (2 ... 3).contains(values.count),
let step = values.count == 3 ? Int(values[2]) : 1,
step != 0 else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath slice 格式无效")
}
return .slice(
start: values[0].isEmpty ? nil : Int(values[0]),
end: values[1].isEmpty ? nil : Int(values[1]),
step: step
)
}
let parts = splitCommaSeparated(content)
let selectors = try parts.map { try parseSelector($0, original: original) }
return selectors.count == 1 ? .child(selectors[0]) : .union(selectors)
}
private func splitCommaSeparated(_ content: String) -> [String] {
var parts: [String] = []
var quote: Character?
var escaped = false
var start = content.startIndex
var index = start
while index < content.endIndex {
let character = content[index]
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 == "," {
parts.append(String(content[start..<index]).trimmingCharacters(in: .whitespaces))
start = content.index(after: index)
}
index = content.index(after: index)
}
parts.append(String(content[start...]).trimmingCharacters(in: .whitespaces))
return parts
}
private func parseSelector(_ source: String, original: String) throws -> Selector {
if source == "*" { return .wildcard }
if let index = Int(source) { return .index(index) }
if source.count >= 2,
let first = source.first,
first == "\"" || first == "'",
source.last == first {
let key = String(source.dropFirst().dropLast())
.replacingOccurrences(of: "\\\(first)", with: String(first))
.replacingOccurrences(of: "\\\\", with: "\\")
return .key(key)
}
throw RuleEngineError.malformedRule(rule: original, detail: "不支持的 JSONPath 选择器:\(source)")
}
private func select(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
switch (selector, value) {
case (.key(let key), .object(let object)):
return object[key].map { [$0] } ?? []
case (.index(let index), .array(let array)):
let resolved = index >= 0 ? index : array.count + index
return array.indices.contains(resolved) ? [array[resolved]] : []
case (.wildcard, .object(let object)):
return object.keys.sorted().compactMap { object[$0] }
case (.wildcard, .array(let array)):
return array
default:
return []
}
}
private func recursivelySelect(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
var result = select(selector, from: value)
switch value {
case .object(let object):
for key in object.keys.sorted() {
if let child = object[key] { result += recursivelySelect(selector, from: child) }
}
case .array(let array):
for child in array { result += recursivelySelect(selector, from: child) }
default:
break
}
return result
}
private func slice(_ value: JSONValue, start: Int?, end: Int?, step: Int) -> [JSONValue] {
guard case .array(let array) = value, !array.isEmpty else { return [] }
let count = array.count
let defaultStart = step > 0 ? 0 : count - 1
let defaultEnd = step > 0 ? count : -1
var cursor = normalize(start ?? defaultStart, count: count, allowEnd: true)
let boundary = end == nil && step < 0 ? -1 : normalize(end ?? defaultEnd, count: count, allowEnd: true)
var result: [JSONValue] = []
while step > 0 ? cursor < boundary : cursor > boundary {
if array.indices.contains(cursor) { result.append(array[cursor]) }
cursor += step
}
return result
}
private func normalize(_ index: Int, count: Int, allowEnd: Bool) -> Int {
let value = index < 0 ? count + index : index
return min(max(value, 0), allowEnd ? count : count - 1)
}
private func matches(_ source: String, candidate: JSONValue, root: JSONValue) throws -> Bool {
let predicate = stripOuterParentheses(source.trimmingCharacters(in: .whitespacesAndNewlines))
if let parts = splitLogical(predicate, operator: "||"), parts.count > 1 {
for part in parts where try matches(part, candidate: candidate, root: root) { return true }
return false
}
if let parts = splitLogical(predicate, operator: "&&"), parts.count > 1 {
for part in parts where !(try matches(part, candidate: candidate, root: root)) { return false }
return true
}
if predicate.hasPrefix("!") && !predicate.hasPrefix("!=") {
return try !matches(String(predicate.dropFirst()), candidate: candidate, root: root)
}
if let comparison = findComparison(predicate) {
let lhs = try resolveOperand(String(predicate[..<comparison.range.lowerBound]), candidate: candidate, root: root)
let rhs = try resolveOperand(String(predicate[comparison.range.upperBound...]), candidate: candidate, root: root)
guard !lhs.isEmpty, !rhs.isEmpty else { return false }
return try lhs.contains { left in
try rhs.contains { right in try compare(left, right, operation: comparison.operation) }
}
}
let values = try resolveOperand(predicate, candidate: candidate, root: root)
return values.contains(where: isTruthy)
}
private func splitLogical(_ source: String, operator operation: String) -> [String]? {
var parts: [String] = []
var quote: Character?
var regex = false
var escaped = false
var roundDepth = 0
var squareDepth = 0
var start = source.startIndex
var index = start
while index < source.endIndex {
let character = source[index]
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 == "/" {
regex.toggle()
} else if !regex {
if character == "(" { roundDepth += 1 }
if character == ")" { roundDepth -= 1 }
if character == "[" { squareDepth += 1 }
if character == "]" { squareDepth -= 1 }
if roundDepth == 0, squareDepth == 0, source[index...].hasPrefix(operation) {
parts.append(String(source[start..<index]).trimmingCharacters(in: .whitespaces))
index = source.index(index, offsetBy: operation.count)
start = index
continue
}
}
index = source.index(after: index)
}
guard !parts.isEmpty else { return nil }
parts.append(String(source[start...]).trimmingCharacters(in: .whitespaces))
return parts
}
private func stripOuterParentheses(_ source: String) -> String {
var value = source
while value.first == "(", value.last == ")", outerParenthesesEncloseAll(value) {
value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value
}
private func outerParenthesesEncloseAll(_ source: String) -> Bool {
var depth = 0
var quote: Character?
var escaped = false
for index in source.indices {
let character = source[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if let activeQuote = quote {
if character == activeQuote { quote = nil }
continue
}
if character == "\"" || character == "'" { quote = character; continue }
if character == "(" { depth += 1 }
if character == ")" {
depth -= 1
if depth == 0 && index != source.index(before: source.endIndex) { return false }
}
}
return depth == 0
}
private func findComparison(_ source: String) -> (range: Range<String.Index>, operation: String)? {
let operations = ["==", "!=", ">=", "<=", "=~", ">", "<"]
var quote: Character?
var escaped = false
var roundDepth = 0
var squareDepth = 0
var index = source.startIndex
while index < source.endIndex {
let character = source[index]
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 == "(" { roundDepth += 1 }
if character == ")" { roundDepth -= 1 }
if character == "[" { squareDepth += 1 }
if character == "]" { squareDepth -= 1 }
if roundDepth == 0, squareDepth == 0 {
for operation in operations where source[index...].hasPrefix(operation) {
return (index..<source.index(index, offsetBy: operation.count), operation)
}
}
}
index = source.index(after: index)
}
return nil
}
private func resolveOperand(_ source: String, candidate: JSONValue, root: JSONValue) throws -> [JSONValue] {
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "JSONPath filter 比较值不能为空")
}
if value.first == "@" || value.first == "$" {
return try resolvePath(value, base: value.first == "@" ? candidate : root)
}
if value == "true" { return [.bool(true)] }
if value == "false" { return [.bool(false)] }
if value == "null" { return [.null] }
if let number = Double(value) { return [.number(number)] }
if let first = value.first, (first == "\"" || first == "'"), value.last == first {
let inner = String(value.dropFirst().dropLast())
.replacingOccurrences(of: "\\\(first)", with: String(first))
.replacingOccurrences(of: "\\n", with: "\n")
.replacingOccurrences(of: "\\\\", with: "\\")
return [.string(inner)]
}
if value.first == "/", value.dropFirst().lastIndex(of: "/") != nil {
return [.string(value)]
}
throw RuleEngineError.malformedRule(rule: source, detail: "不支持的 JSONPath filter 操作数:\(value)")
}
private func resolvePath(_ path: String, base: JSONValue) throws -> [JSONValue] {
var current = [base]
var index = path.index(after: path.startIndex)
while index < path.endIndex {
if path[index] == "." {
index = path.index(after: index)
var end = index
while end < path.endIndex, path[end] != ".", path[end] != "[" { end = path.index(after: end) }
let key = String(path[index..<end])
guard !key.isEmpty else { throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径属性为空") }
current = current.flatMap { select(.key(key), from: $0) }
index = end
} else if path[index] == "[" {
guard let close = path[index...].firstIndex(of: "]") else {
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径方括号未闭合")
}
let token = String(path[path.index(after: index)..<close]).trimmingCharacters(in: .whitespaces)
let selector = try parseSelector(token, original: path)
current = current.flatMap { select(selector, from: $0) }
index = path.index(after: close)
} else {
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径包含意外字符")
}
}
return current
}
private func compare(_ lhs: JSONValue, _ rhs: JSONValue, operation: String) throws -> Bool {
if operation == "=~" {
let literal = rhs.stringValue
guard literal.first == "/", let closing = literal.dropFirst().lastIndex(of: "/") else {
throw RuleEngineError.malformedRule(rule: literal, detail: "filter 正则必须写成 /pattern/flags")
}
let pattern = String(literal[literal.index(after: literal.startIndex)..<closing])
guard pattern.count <= 1_024 else {
throw RuleEngineError.unsupportedRule(rule: literal, feature: "过长 JSONPath filter 正则")
}
let flags = literal[literal.index(after: closing)...]
let options: NSRegularExpression.Options = flags.contains("i") ? .caseInsensitive : []
let regex = try NSRegularExpression(pattern: pattern, options: options)
let range = NSRange(lhs.stringValue.startIndex..., in: lhs.stringValue)
return regex.firstMatch(in: lhs.stringValue, range: range) != nil
}
if operation == "==" || operation == "!=" {
let equal: Bool
switch (lhs, rhs) {
case (.number(let left), .number(let right)): equal = left == right
case (.bool(let left), .bool(let right)): equal = left == right
case (.null, .null): equal = true
default: equal = lhs.stringValue == rhs.stringValue
}
return operation == "==" ? equal : !equal
}
let ordering: ComparisonResult
if case .number(let left) = lhs, case .number(let right) = rhs {
ordering = left < right ? .orderedAscending : left > right ? .orderedDescending : .orderedSame
} else {
ordering = lhs.stringValue.compare(rhs.stringValue, options: .numeric)
}
return switch operation {
case ">": ordering == .orderedDescending
case ">=": ordering != .orderedAscending
case "<": ordering == .orderedAscending
case "<=": ordering != .orderedDescending
default: false
}
}
private func isTruthy(_ value: JSONValue) -> Bool {
switch value {
case .null: false
case .bool(let value): value
case .number(let value): value != 0
case .string(let value): !value.isEmpty
case .array(let value): !value.isEmpty
case .object(let value): !value.isEmpty
}
}
}
@@ -0,0 +1,102 @@
import Foundation
public enum JSONValue: Codable, Hashable, Sendable {
case object([String: JSONValue])
case array([JSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
self = .object(try container.decode([String: JSONValue].self))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .object(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
case .null: try container.encodeNil()
}
}
public static func decode(data: Data) throws -> JSONValue {
try JSONDecoder().decode(JSONValue.self, from: data)
}
public var stringValue: String {
switch self {
case .string(let value): return value
case .number(let value):
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
case .bool(let value): return String(value)
case .null: return ""
case .object, .array:
guard let data = try? JSONEncoder().encode(self) else { return "" }
return String(decoding: data, as: UTF8.self)
}
}
public subscript(key: String) -> JSONValue? {
guard case .object(let value) = self else { return nil }
return value[key]
}
public subscript(index: Int) -> JSONValue? {
guard case .array(let value) = self else { return nil }
let resolved = index >= 0 ? index : value.count + index
guard value.indices.contains(resolved) else { return nil }
return value[resolved]
}
}
public extension JSONValue {
var foundationObject: Any {
switch self {
case .object(let value): value.mapValues(\.foundationObject)
case .array(let value): value.map(\.foundationObject)
case .string(let value): value
case .number(let value): value
case .bool(let value): value
case .null: NSNull()
}
}
static func bridge(_ value: Any?) -> JSONValue {
guard let value else { return .null }
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) }
if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue)
}
if let value = value as? [Any] { return .array(value.map(bridge)) }
if let value = value as? [String: Any] { return .object(value.mapValues(bridge)) }
if let value = value as? NSDictionary {
var object: [String: JSONValue] = [:]
for (key, item) in value { object[String(describing: key)] = bridge(item) }
return .object(object)
}
if let value = value as? NSArray { return .array(value.map(bridge)) }
return .string(String(describing: value))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
import Foundation
public struct RegexLimits: Sendable {
public var maximumInputLength: Int
public var maximumPatternLength: Int
public var maximumMatches: Int
public init(
maximumInputLength: Int = 2_000_000,
maximumPatternLength: Int = 8_192,
maximumMatches: Int = 10_000
) {
self.maximumInputLength = maximumInputLength
self.maximumPatternLength = maximumPatternLength
self.maximumMatches = maximumMatches
}
}
public struct RegexEvaluator: Sendable {
public var limits: RegexLimits
public init(limits: RegexLimits = .init()) {
self.limits = limits
}
public func captureGroups(pattern: String, input: String) throws -> [[String]] {
guard pattern.count <= limits.maximumPatternLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制")
}
guard input.utf16.count <= limits.maximumInputLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制")
}
guard !hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词")
}
let expression: NSRegularExpression
do {
expression = try NSRegularExpression(pattern: pattern)
} catch {
throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription)
}
let range = NSRange(input.startIndex..<input.endIndex, in: input)
var captures: [[String]] = []
let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000
var timedOut = false
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
if DispatchTime.now().uptimeNanoseconds >= deadline {
timedOut = true
stop.pointee = true
return
}
guard let match else { return }
if captures.count >= limits.maximumMatches {
stop.pointee = true
return
}
captures.append((0 ..< match.numberOfRanges).map { group in
let range = match.range(at: group)
guard range.location != NSNotFound, let swiftRange = Range(range, in: input) else { return "" }
return String(input[swiftRange])
})
}
if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") }
if captures.count >= limits.maximumMatches {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制")
}
return captures
}
public func replacing(
pattern: String,
in input: String,
with replacement: String,
firstOnly: Bool
) throws -> String {
guard pattern.count <= limits.maximumPatternLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制")
}
guard input.utf16.count <= limits.maximumInputLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制")
}
guard !hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词")
}
let expression: NSRegularExpression
do {
expression = try NSRegularExpression(pattern: pattern)
} catch {
if firstOnly { return replacement }
return input.replacingOccurrences(of: pattern, with: replacement)
}
let range = NSRange(input.startIndex..<input.endIndex, in: input)
if firstOnly {
let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000
var first: NSTextCheckingResult?
var timedOut = false
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
if DispatchTime.now().uptimeNanoseconds >= deadline {
timedOut = true
stop.pointee = true
} else if let match {
first = match
stop.pointee = true
}
}
if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") }
guard let first else { return "" }
return expression.replacementString(for: first, in: input, offset: 0, template: replacement)
}
return try replacingStrict(pattern: pattern, in: input, with: replacement)
}
public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> String {
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
}
let expression: NSRegularExpression
do { expression = try NSRegularExpression(pattern: pattern) }
catch { throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription) }
let milliseconds = timeoutMilliseconds <= 0 ? 3_000 : min(timeoutMilliseconds, 60_000)
let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(milliseconds) * 1_000_000
let range = NSRange(location: 0, length: input.utf16.count)
var matches: [NSTextCheckingResult] = []
var timedOut = false
var exceeded = false
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
if DispatchTime.now().uptimeNanoseconds >= deadline {
timedOut = true
stop.pointee = true
} else if let match {
if matches.count >= limits.maximumMatches { exceeded = true; stop.pointee = true }
else { matches.append(match) }
}
}
guard !timedOut else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") }
guard !exceeded else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制") }
let original = input as NSString
var output = ""
var offset = 0
var outputLength = 0
for match in matches {
guard DispatchTime.now().uptimeNanoseconds < deadline else {
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
outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
}
output += original.substring(from: offset)
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
}
}
}
@@ -0,0 +1,103 @@
import Foundation
public struct RuleDiagnostic: Codable, Hashable, Identifiable, Sendable {
public enum Severity: String, Codable, Sendable {
case warning
case error
}
public var severity: Severity
public var code: String
public var message: String
public var rule: String
public var id: String { "\(severity.rawValue):\(code):\(rule):\(message)" }
public init(severity: Severity, code: String, message: String, rule: String) {
self.severity = severity
self.code = code
self.message = message
self.rule = rule
}
}
public enum RuleEngineError: Error, Equatable, LocalizedError, Sendable {
case malformedRule(rule: String, detail: String)
case unsupportedRule(rule: String, feature: String)
case invalidInput(rule: String, expected: String)
case resourceLimit(rule: String, detail: String)
public var code: String {
switch self {
case .malformedRule: "rule.malformed"
case .unsupportedRule: "rule.unsupported"
case .invalidInput: "rule.invalid_input"
case .resourceLimit: "rule.resource_limit"
}
}
public var errorDescription: String? {
switch self {
case .malformedRule(_, let detail),
.invalidInput(_, let detail),
.resourceLimit(_, let detail):
detail
case .unsupportedRule(_, let feature):
"当前规则后端尚不支持:\(feature)"
}
}
public var rule: String {
switch self {
case .malformedRule(let rule, _),
.unsupportedRule(let rule, _),
.invalidInput(let rule, _),
.resourceLimit(let rule, _):
rule
}
}
public var diagnostic: RuleDiagnostic {
.init(severity: .error, code: code, message: localizedDescription, rule: rule)
}
}
public struct RuleEvaluation: Sendable {
public var values: [JSONValue]
public var diagnostics: [RuleDiagnostic]
public var variables: [String: String]
public init(
values: [JSONValue] = [],
diagnostics: [RuleDiagnostic] = [],
variables: [String: String] = [:]
) {
self.values = values
self.diagnostics = diagnostics
self.variables = variables
}
public var strings: [String] {
values.flatMap { value in
if case .array(let items) = value { return items.map(\.stringValue) }
return [value.stringValue]
}
}
func listItems(rule: String) -> [JSONValue] {
if rule.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(":") { return values }
return values.flatMap { value in
if case .array(let items) = value { return items }
return [value]
}
}
public var firstString: String? { strings.first(where: { !$0.isEmpty }) }
public var leadingString: String? {
guard let value = strings.first, !value.isEmpty else { return nil }
return value
}
public var joinedString: String? {
let value = strings.joined(separator: "\n")
return value.isEmpty ? nil : value
}
}
@@ -0,0 +1,533 @@
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
}
}
@@ -0,0 +1,189 @@
import Foundation
public enum RuleMode: String, Codable, Sendable {
case css
case xpath
case jsonPath
case regex
case javaScript
case webJavaScript
}
public enum RuleCombination: String, Codable, Sendable {
case single
case concatenate = "&&"
case fallback = "||"
case interleave = "%%"
}
public struct RuleBranch: Codable, Hashable, Sendable {
public var mode: RuleMode
public var expression: String
public var replacePattern: String?
public var replacement: String
public var replaceFirst: Bool
public init(
mode: RuleMode,
expression: String,
replacePattern: String? = nil,
replacement: String = "",
replaceFirst: Bool = false
) {
self.mode = mode
self.expression = expression
self.replacePattern = replacePattern
self.replacement = replacement
self.replaceFirst = replaceFirst
}
}
public struct RulePlan: Codable, Hashable, Sendable {
public var source: String
public var combination: RuleCombination
public var branches: [RuleBranch]
}
public struct RuleExpressionParser: Sendable {
public init() {}
public func parse(_ source: String, inputLooksLikeJSON: Bool = false) throws -> RulePlan {
let normalized = source.trimmingCharacters(in: .newlines)
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) }
return .init(source: source, combination: split.combination, branches: branches)
}
private func parseBranch(_ source: String, inputLooksLikeJSON: Bool) throws -> RuleBranch {
var value = String(source.drop(while: { $0.isWhitespace }))
var mode: RuleMode
if value.lowercased().hasPrefix("@css:") {
mode = .css
value.removeFirst(5)
} else if value.hasPrefix("@@") {
mode = .css
value.removeFirst(2)
} else if value.lowercased().hasPrefix("@xpath:") {
mode = .xpath
value.removeFirst(7)
} else if value.lowercased().hasPrefix("@json:") {
mode = .jsonPath
value.removeFirst(6)
} else if value.lowercased().hasPrefix("@js:") {
mode = .javaScript
value.removeFirst(4)
} else if value.lowercased().hasPrefix("<js>") && value.lowercased().hasSuffix("</js>") {
mode = .javaScript
value = String(value.dropFirst(4).dropLast(5))
} else if value.lowercased().hasPrefix("<webjs>") && value.lowercased().hasSuffix("</webjs>") {
mode = .webJavaScript
value = String(value.dropFirst(7).dropLast(8))
} else if value.hasPrefix(":") {
mode = .regex
value.removeFirst()
} else if value.hasPrefix("$.") || value.hasPrefix("$[") || inputLooksLikeJSON {
mode = .jsonPath
} else if value.hasPrefix("/") || value.hasPrefix("./") {
mode = .xpath
} else {
mode = .css
}
let replacement = value.components(separatedBy: "##")
let expression = replacement[0].trimmingCharacters(in: .whitespacesAndNewlines)
guard !expression.isEmpty || replacement.count > 1 else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则主体不能为空")
}
return .init(
mode: mode,
expression: expression,
replacePattern: replacement.count > 1 ? replacement[1] : nil,
replacement: replacement.count > 2 ? replacement[2] : "",
replaceFirst: replacement.count > 3
)
}
private func splitAtTopLevel(_ source: String) throws -> (parts: [String], combination: RuleCombination) {
var quote: Character?
var escaped = false
var roundDepth = 0
var squareDepth = 0
var curlyDepth = 0
var found: RuleCombination?
var parts: [String] = []
var partStart = source.startIndex
var index = source.startIndex
while index < source.endIndex {
let character = source[index]
if escaped {
escaped = false
index = source.index(after: index)
continue
}
if character == "\\" {
escaped = true
index = source.index(after: index)
continue
}
if let activeQuote = quote {
if character == activeQuote { quote = nil }
index = source.index(after: index)
continue
}
if character == "\"" || character == "'" {
quote = character
index = source.index(after: index)
continue
}
switch character {
case "(": roundDepth += 1
case ")": roundDepth -= 1
case "[": squareDepth += 1
case "]": squareDepth -= 1
case "{": curlyDepth += 1
case "}": curlyDepth -= 1
default: break
}
if roundDepth < 0 || squareDepth < 0 || curlyDepth < 0 {
throw RuleEngineError.malformedRule(rule: source, detail: "规则括号未平衡")
}
if roundDepth == 0, squareDepth == 0, curlyDepth == 0 {
let remainder = source[index...]
let candidate: RuleCombination? = if remainder.hasPrefix("&&") {
.concatenate
} else if remainder.hasPrefix("||") {
.fallback
} else if remainder.hasPrefix("%%") {
.interleave
} else {
nil
}
if let candidate {
if found == nil { found = candidate }
if found == candidate {
parts.append(String(source[partStart..<index]))
index = source.index(index, offsetBy: 2)
partStart = index
continue
}
}
}
index = source.index(after: index)
}
guard quote == nil, roundDepth == 0, squareDepth == 0, curlyDepth == 0 else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则引号或括号未闭合")
}
if let found {
parts.append(String(source[partStart...]))
guard parts.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else {
throw RuleEngineError.malformedRule(rule: source, detail: "组合规则存在空分支")
}
return (parts, found)
}
return ([source], .single)
}
}