mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user