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

支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
Aminark
2026-10-24 00:58:11 +08:00
parent 3322fd71db
commit 67bc212ffd
58 changed files with 2331 additions and 244 deletions
+10 -4
View File
@@ -115,9 +115,9 @@ public struct ContentSanitizer: Sendable {
var diagnostics: [RuleDiagnostic] = []
for rule in ordered where rule.scopeTitle && applies(rule, to: book) {
let result = apply(rule, to: title)
let result = apply(rule, to: title, book: book, chapter: chapter)
diagnostics += result.diagnostics
if result.value != title {
if result.value != title, !result.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
title = result.value
effective.append(rule.id)
}
@@ -146,7 +146,7 @@ public struct ContentSanitizer: Sendable {
}
for rule in ordered where rule.scopeContent && applies(rule, to: book) {
let result = apply(rule, to: body)
let result = apply(rule, to: body, book: book, chapter: chapter)
diagnostics += result.diagnostics
if result.value != body {
body = result.value
@@ -172,10 +172,16 @@ public struct ContentSanitizer: Sendable {
return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin))
}
private func apply(_ rule: ReplaceRule, to input: String) -> (value: String, diagnostics: [RuleDiagnostic]) {
private func apply(_ rule: ReplaceRule, to input: String, book: Book, chapter: BookChapter) -> (value: String, diagnostics: [RuleDiagnostic]) {
guard !rule.pattern.isEmpty else { return (input, []) }
do {
if rule.isRegex {
if rule.replacement.hasPrefix("@js:") {
let evaluate = JavaScriptRuleEvaluator().replacementEvaluator(
script: String(rule.replacement.dropFirst(4)), book: book, chapter: chapter)
return (try regex.replacingStrict(pattern: rule.pattern, in: input,
timeoutMilliseconds: rule.timeoutMillisecond, replacement: evaluate), [])
}
return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
}
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), [])
+399
View File
@@ -0,0 +1,399 @@
import Foundation
import ImageIO
import SwiftSoup
import ZIPFoundation
public enum EPUBError: Error, LocalizedError, Sendable {
case invalidArchive, invalidPackage, missingResource(String), encrypted, resourceTooLarge, invalidChapter
public var errorDescription: String? {
switch self {
case .invalidArchive: "EPUB 文件已损坏或不是有效的压缩包"
case .invalidPackage: "EPUB 缺少有效的书籍信息或阅读顺序"
case .missingResource(let path): "EPUB 中缺少文件:\(path)"
case .encrypted: "此 EPUB 的正文或图片已加密,暂不支持读取"
case .resourceTooLarge: "EPUB 单个章节或资源过大,无法安全读取"
case .invalidChapter: "EPUB 章节或目录锚点无效,请重新打开书籍"
}
}
}
public struct EPUBIllustration: Identifiable, Sendable {
public let id: Int
public let caption: String
public let data: Data
}
/// Reads resources on demand without unpacking untrusted archive paths to disk.
/// Navigation follows the spine; EPUB 2 NCX and EPUB 3 nav supply chapter labels/anchors.
struct EPUBBook {
private struct Item {
let id: String
let path: String
let mediaType: String
let properties: Set<String>
}
private struct Reference {
let path: String
let fragment: String?
let title: String
}
private let archive: Archive
private let entries: [String: Entry]
private let package: EPUBXMLNode
private let packagePath: String
private let manifest: [Item]
private let spine: [Item]
private let encryptedPaths: Set<String>
private static let resourceLimit = 32 * 1_024 * 1_024
init(url: URL) throws {
let archive: Archive
do { archive = try Archive(url: url, accessMode: .read) }
catch { throw EPUBError.invalidArchive }
var entries: [String: Entry] = [:]
for entry in archive {
guard entries.count < 50_000 else { throw EPUBError.resourceTooLarge }
// Directories and symlinks are never used as book resources.
guard entry.type == .file else { continue }
guard Self.safePath(entry.path), entries[entry.path] == nil else { throw EPUBError.invalidArchive }
entries[entry.path] = entry
}
self.archive = archive
self.entries = entries
let container = try EPUBXMLNode.parse(Self.read("META-INF/container.xml", archive: archive, entries: entries))
guard let rootfile = container.descendants("rootfile").first,
let fullPath = rootfile.attributes["full-path"],
let path = Self.resolve(fullPath, relativeTo: "")?.path else { throw EPUBError.invalidPackage }
packagePath = path
package = try EPUBXMLNode.parse(Self.read(path, archive: archive, entries: entries))
guard package.name == "package", let manifestNode = package.children.first(where: { $0.name == "manifest" }),
let spineNode = package.children.first(where: { $0.name == "spine" }) else { throw EPUBError.invalidPackage }
manifest = manifestNode.children.compactMap { node in
guard node.name == "item", let id = node.attributes["id"], let href = node.attributes["href"],
let ref = Self.resolve(href, relativeTo: path) else { return nil }
return Item(id: id, path: ref.path, mediaType: node.attributes["media-type"] ?? "",
properties: Set((node.attributes["properties"] ?? "").split(whereSeparator: \.isWhitespace).map(String.init)))
}
let items = manifest
let readable = spineNode.children.filter { $0.name == "itemref" && $0.attributes["linear"] != "no" }
spine = try readable.map { node in
guard let item = items.first(where: { $0.id == node.attributes["idref"] }),
["application/xhtml+xml", "text/html"].contains(item.mediaType), entries[item.path] != nil else {
throw EPUBError.invalidPackage
}
return item
}
guard !spine.isEmpty else { throw EPUBError.invalidPackage }
if entries["META-INF/encryption.xml"] != nil {
let encryption = try EPUBXMLNode.parse(Self.read("META-INF/encryption.xml", archive: archive, entries: entries))
encryptedPaths = Set(encryption.descendants("CipherReference").compactMap {
$0.attributes["URI"].flatMap { Self.resolve($0, relativeTo: "")?.path }
})
} else { encryptedPaths = [] }
// Font obfuscation is common and does not prevent text reading.
guard !spine.contains(where: { encryptedPaths.contains($0.path) }) else { throw EPUBError.encrypted }
}
func report(url: URL, originalName: String) throws -> LocalBookImportReport {
let metadata = package.children.first { $0.name == "metadata" }
let title = metadata?.descendants("title").first?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let authors = metadata?.descendants("creator").map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
var book = Book(bookUrl: url.absoluteString, name: title.isEmpty ? (originalName as NSString).deletingPathExtension : title,
author: authors.joined(separator: "、"), origin: "local")
book.originName = originalName
book.intro = MetadataTextSanitizer().clean(metadata?.descendants("description").first?.text)
book.canUpdate = false
var configuration = BookReadConfig()
configuration.useReplaceRule = false
book.readConfig = configuration
let coverID = metadata?.descendants("meta").first { $0.attributes["name"] == "cover" }?.attributes["content"]
if let cover = manifest.first(where: { $0.properties.contains("cover-image") }) ?? manifest.first(where: { $0.id == coverID }),
["image/jpeg", "image/png", "image/gif", "image/webp"].contains(cover.mediaType),
let entry = entries[cover.path], entry.uncompressedSize <= 2 * 1_024 * 1_024,
let data = try? read(cover.path), let thumbnail = Self.coverThumbnail(data) {
// A bounded embedded thumbnail survives file renames, backups and cache eviction.
book.coverUrl = "data:image/png;base64,\(thumbnail.base64EncodedString())"
}
let chapters = try chapters(book: book)
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
func chapters(book: Book) throws -> [BookChapter] {
let references = navigation()
var chapters: [BookChapter] = []
for item in spine where !item.properties.contains("nav") {
let document = try document(item.path)
guard let body = document.body() else { continue }
let elements = try body.getAllElements().array()
var positions: [String: Int] = [:]
for (index, element) in elements.enumerated() {
for key in [element.id(), (try? element.attr("name")) ?? ""] where !key.isEmpty && positions[key] == nil {
positions[key] = index
}
}
var seen = Set<String>()
let refs = references.filter { $0.path == item.path }
.filter { ref in
guard ref.fragment == nil || positions[ref.fragment!] != nil else { return false }
return seen.insert(ref.fragment ?? "").inserted
}
.sorted { (positions[$0.fragment ?? ""] ?? -1) < (positions[$1.fragment ?? ""] ?? -1) }
let documentTitle = try document.title().trimmingCharacters(in: .whitespacesAndNewlines)
let heading = try body.select("h1,h2,h3").first()?.text() ?? ""
let fallback = !documentTitle.isEmpty ? documentTitle : (!heading.isEmpty ? heading : "第\(chapters.count + 1)章")
var boundaries = refs
if boundaries.isEmpty {
boundaries = [Reference(path: item.path, fragment: nil, title: fallback)]
} else if let first = boundaries.first, let fragment = first.fragment {
let prefix = try render(body: body, from: nil, to: fragment).text
if !prefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
boundaries.insert(Reference(path: item.path, fragment: nil, title: fallback), at: 0)
}
}
for (index, ref) in boundaries.enumerated() {
var chapter = BookChapter(url: Self.referenceURL(path: item.path, fragment: ref.fragment),
title: ref.title.isEmpty ? fallback : ref.title, bookUrl: book.bookUrl, index: chapters.count)
chapter.resourceUrl = item.path
chapter.startFragmentId = ref.fragment
chapter.endFragmentId = boundaries.indices.contains(index + 1) ? boundaries[index + 1].fragment : nil
chapters.append(chapter)
}
}
guard !chapters.isEmpty else { throw EPUBError.invalidPackage }
return chapters
}
func content(chapter: BookChapter) throws -> ChapterContentReport {
let (body, _) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
return .init(title: chapter.title, content: value.text, pagesVisited: 1)
}
func illustrations(chapter: BookChapter) throws -> [EPUBIllustration] {
let (body, path) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
var totalBytes = 0
return try value.images.enumerated().map { index, image in
guard let ref = Self.resolve(image.src, relativeTo: path),
let item = manifest.first(where: { $0.path == ref.path }), item.mediaType.hasPrefix("image/") else {
throw EPUBError.missingResource(image.src)
}
let data = try read(ref.path)
totalBytes += data.count
guard totalBytes <= Self.resourceLimit else { throw EPUBError.resourceTooLarge }
return EPUBIllustration(id: index + 1, caption: image.alt, data: data)
}
}
private func body(chapter: BookChapter) throws -> (Element, String) {
guard let path = chapter.resourceUrl, spine.contains(where: { $0.path == path }),
let body = try document(path).body() else { throw EPUBError.invalidChapter }
return (body, path)
}
private func document(_ path: String) throws -> Document {
let data = try read(path)
let text: String
if data.starts(with: [0xff, 0xfe]) || data.starts(with: [0xfe, 0xff]), let utf16 = String(data: data, encoding: .utf16) {
text = utf16
} else if let utf8 = String(data: data, encoding: .utf8) { text = utf8 }
else { throw EPUBError.invalidPackage }
return try SwiftSoup.parse(text)
}
private func navigation() -> [Reference] {
if let nav = manifest.first(where: { $0.properties.contains("nav") }), let doc = try? document(nav.path),
let nodes = try? doc.select("nav").array() {
let toc = nodes.first { node in
let type = (try? node.attr("epub:type")) ?? ""
return type.split(whereSeparator: \.isWhitespace).contains("toc") || (try? node.attr("role")) == "doc-toc"
}
if let links = try? toc?.select("a[href]").array() {
let refs = links.compactMap { element -> Reference? in
guard let href = try? element.attr("href"), let ref = Self.resolve(href, relativeTo: nav.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment, title: (try? element.text()) ?? "")
}
if !refs.isEmpty { return refs }
}
}
let tocID = package.children.first { $0.name == "spine" }?.attributes["toc"]
if let ncx = manifest.first(where: { $0.id == tocID }) ?? manifest.first(where: { $0.mediaType == "application/x-dtbncx+xml" }),
let data = try? read(ncx.path), let root = try? EPUBXMLNode.parse(data) {
return root.descendants("navPoint").compactMap { node in
guard let href = node.children.first(where: { $0.name == "content" })?.attributes["src"],
let ref = Self.resolve(href, relativeTo: ncx.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment,
title: node.children.first(where: { $0.name == "navLabel" })?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
}
}
return []
}
/// Walk DOM boundaries rather than slicing serialized HTML: anchors can be nested
/// in different parents, and all text after the end anchor belongs to the next chapter.
private func render(body: Element, from start: String?, to end: String?) throws -> (text: String, images: [(src: String, alt: String)]) {
var active = start == nil
var foundStart = start == nil
var foundEnd = end == nil
var finished = false
var output = ""
var images: [(src: String, alt: String)] = []
let blocks: Set<String> = ["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "tr", "hr"]
func visit(_ node: Node, depth: Int) throws {
guard depth < 512 else { throw EPUBError.invalidPackage }
guard !finished else { return }
guard let element = node as? Element else {
if active, let text = node as? TextNode {
output += text.getWholeText().replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
}
return
}
let id = element.id()
let name = try element.attr("name")
if let end, id == end || name == end { active = false; finished = true; foundEnd = true; return }
if let start, id == start || name == start { active = true; foundStart = true }
let tag = element.tagName().lowercased()
let style = try element.attr("style").lowercased().replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression)
if ["script", "style", "noscript", "rt", "rp", "title"].contains(tag) || element.hasAttr("hidden") || style.contains("display:none") { return }
if active, blocks.contains(tag) || tag == "br" { output += "\n" }
if active, tag == "img" || tag == "image" {
var src = try element.attr(tag == "img" ? "src" : "xlink:href")
if src.isEmpty, tag == "image" { src = try element.attr("href") }
let alt = try element.attr("alt")
if !src.isEmpty {
images.append((src, alt))
output += "\n【插图 \(images.count)\(alt.isEmpty ? "" : ":" + alt)】\n"
}
}
for child in element.getChildNodes() { try visit(child, depth: depth + 1) }
if active, blocks.contains(tag) { output += "\n" }
if active, tag == "td" || tag == "th" { output += " " }
}
try visit(body, depth: 0)
guard foundStart && foundEnd else { throw EPUBError.invalidChapter }
let text = output.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: "\n")
return (text, images)
}
private func read(_ path: String) throws -> Data {
guard !encryptedPaths.contains(path) else { throw EPUBError.encrypted }
return try Self.read(path, archive: archive, entries: entries)
}
private static func read(_ path: String, archive: Archive, entries: [String: Entry]) throws -> Data {
guard let entry = entries[path] else { throw EPUBError.missingResource(path) }
guard entry.uncompressedSize <= resourceLimit else { throw EPUBError.resourceTooLarge }
var result = Data()
let checksum = try archive.extract(entry) { chunk in
guard result.count + chunk.count <= resourceLimit else { throw EPUBError.resourceTooLarge }
result.append(chunk)
}
guard checksum == entry.checksum, result.count == entry.uncompressedSize else { throw EPUBError.invalidArchive }
return result
}
private static func coverThumbnail(_ data: Data) -> Data? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return nil }
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func safePath(_ path: String) -> Bool {
!path.isEmpty && !path.hasPrefix("/") && !path.contains("\\") && !path.contains("\0") && !path.split(separator: "/").contains("..")
}
private static func resolve(_ href: String, relativeTo base: String) -> (path: String, fragment: String?)? {
guard !href.hasPrefix("/"), !href.contains("\\"),
URLComponents(string: href)?.scheme == nil else { return nil }
let parts = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
guard let pathPart = String(parts[0]).removingPercentEncoding else { return nil }
var components = pathPart.isEmpty ? base.split(separator: "/").map(String.init) : base.split(separator: "/").dropLast().map(String.init)
if !pathPart.isEmpty {
for part in pathPart.split(separator: "/") {
if part == "." { continue }
if part == ".." { guard !components.isEmpty else { return nil }; components.removeLast() }
else { components.append(String(part)) }
}
}
let path = components.joined(separator: "/")
guard safePath(path), !pathPart.hasPrefix("/") else { return nil }
let fragment = parts.count > 1 ? String(parts[1]).removingPercentEncoding : nil
return (path, fragment?.isEmpty == false ? fragment : nil)
}
private static func referenceURL(path: String, fragment: String?) -> String {
var url = URLComponents()
url.scheme = "epub"
url.host = "book"
url.path = "/" + path
url.fragment = fragment
return url.string ?? path
}
}
/// Small namespace-independent XML tree for package metadata and navigation.
private final class EPUBXMLNode: NSObject, XMLParserDelegate {
var name = ""
var attributes: [String: String] = [:]
var children: [EPUBXMLNode] = []
var text = ""
private var stack: [EPUBXMLNode] = []
private var count = 0
func descendants(_ name: String) -> [EPUBXMLNode] {
children.flatMap { ($0.name == name ? [$0] : []) + $0.descendants(name) }
}
static func parse(_ data: Data) throws -> EPUBXMLNode {
let delegate = EPUBXMLNode()
let parser = XMLParser(data: data)
parser.shouldResolveExternalEntities = false
parser.delegate = delegate
guard parser.parse(), let root = delegate.children.first else { throw EPUBError.invalidPackage }
return root
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
count += 1
guard stack.count < 128, count <= 50_000 else { parser.abortParsing(); return }
let node = EPUBXMLNode()
node.name = elementName.split(separator: ":").last.map(String.init) ?? elementName
node.attributes = attributeDict
(stack.last ?? self).children.append(node)
stack.append(node)
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
for node in stack {
guard node.text.utf8.count + string.utf8.count <= 4 * 1_024 * 1_024 else { parser.abortParsing(); return }
node.text += string
}
}
func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
if let value = String(data: CDATABlock, encoding: .utf8) { self.parser(parser, foundCharacters: value) }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
_ = stack.popLast()
}
}
@@ -10,7 +10,7 @@ public enum LocalBookError: Error, LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书"
case .unsupportedFormat: "当前支持 TXT 和 EPUB 本地书"
case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失"
@@ -104,6 +104,49 @@ public struct LocalBookService: Sendable {
LocalBookService(tocRules: TxtTocRuleDefaults.load())
}
public static func supports(_ url: URL) -> Bool {
url.isFileURL && ["txt", "epub"].contains(url.pathExtension.lowercased())
}
public func openFile(at url: URL) throws -> LocalBookImportReport {
guard Self.supports(url) else { throw LocalBookError.unsupportedFormat }
if url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).report(url: url, originalName: url.lastPathComponent)
}
return try openTextFile(at: url)
}
public func importFile(at source: URL, into directory: URL) throws -> LocalBookImportReport {
guard Self.supports(source) else { throw LocalBookError.unsupportedFormat }
try validateFile(source)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(UUID().uuidString + "." + source.pathExtension.lowercased())
try FileManager.default.copyItem(at: source, to: destination)
do {
if source.pathExtension.lowercased() == "epub" {
return try EPUBBook(url: destination).report(url: destination, originalName: source.lastPathComponent)
}
return try textFileReport(data: Data(contentsOf: destination, options: .mappedIfSafe), sourceURL: source, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func loadIllustrations(book: Book, chapter: BookChapter) throws -> [EPUBIllustration] {
guard let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" else { return [] }
try validateFile(url)
return try EPUBBook(url: url).illustrations(chapter: chapter)
}
private func validateFile(_ url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
}
public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
@@ -181,6 +224,10 @@ public struct LocalBookService: Sendable {
}
public func loadChapters(book: Book) throws -> ChapterListReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try .init(chapters: EPUBBook(url: url).chapters(book: book))
}
let decoded = try read(book)
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
@@ -201,6 +248,10 @@ public struct LocalBookService: Sendable {
}
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).content(chapter: chapter)
}
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
@@ -32,7 +32,7 @@ public struct StagedLocalFile: Sendable {
public actor LocalFileService {
public let directory: URL
public init(directory: URL) { self.directory = directory.standardizedFileURL }
public init(directory: URL) { self.directory = URL(fileURLWithPath: directory.path, isDirectory: true).standardizedFileURL }
public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
@@ -41,13 +41,13 @@ public actor LocalFileService {
options: [.skipsHiddenFiles]
).compactMap { url in
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey])
guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil }
guard values.isRegularFile == true, values.isSymbolicLink != true, LocalBookService.supports(url) else { return nil }
return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate)
}.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending }
}
public func importFile(from source: URL) throws -> URL {
guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
guard LocalBookService.supports(source) else { throw LocalBookError.unsupportedFormat }
let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -55,7 +55,7 @@ public actor LocalFileService {
var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2
while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt")
target = directory.appendingPathComponent("\(stem) (\(suffix)).\(source.pathExtension)")
suffix += 1
}
try FileManager.default.copyItem(at: source, to: target)
@@ -69,7 +69,8 @@ public actor LocalFileService {
!trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else {
throw LocalFileError.invalidName
}
let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt"
let suffix = "." + source.pathExtension
let filename = trimmed.lowercased().hasSuffix(suffix.lowercased()) ? trimmed : trimmed + suffix
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source }
@@ -83,7 +84,7 @@ public actor LocalFileService {
guard FileManager.default.fileExists(atPath: source.path) else { return nil }
let trash = directory.appendingPathComponent(".Trash", isDirectory: true)
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
let staged = trash.appendingPathComponent(UUID().uuidString + ".txt")
let staged = trash.appendingPathComponent(UUID().uuidString + "." + source.pathExtension)
try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged)
}
@@ -104,7 +105,7 @@ public actor LocalFileService {
}
private func validate(_ url: URL, allowMissing: Bool = false) throws {
guard url.isFileURL, url.pathExtension.lowercased() == "txt",
guard url.isFileURL, LocalBookService.supports(url),
url.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary
@@ -131,7 +132,7 @@ public actor BookFileExporter {
}
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
let target = try destination(filename: filename)
let target = try destination(filename: filename, extension: source.pathExtension.lowercased())
do {
try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0)
@@ -178,11 +179,11 @@ public actor BookFileExporter {
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
}
private func destination(filename: String) throws -> URL {
private func destination(filename: String, extension fileExtension: String = "txt") throws -> URL {
let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_")
let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt")
let stem = String(safe.replacingOccurrences(of: "." + fileExtension, with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + "." + fileExtension)
}
}
@@ -548,17 +548,32 @@ public struct LegadoURLBuilder: Sendable {
case "{", ",":
output.append(character)
expectingKey = true
case ":":
output.append(character)
case ":", "=":
// Gson also accepts = and => between an object key and value.
// Quoted strings are consumed above, so Cookie/URL values stay intact.
output.append(":")
if character == "=", peek() == ">" { index += 1 }
expectingKey = false
if quoteUnquotedValues {
do {
var start = index
while start < characters.count, characters[start].isWhitespace { start += 1 }
if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) {
var end = start
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
output.append(escapedJSONString(String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)))
index = end
if quoteUnquotedValues {
// @put values are rules, including unquoted JSONPath expressions.
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
} else {
while end < characters.count, !characters[end].isWhitespace,
![",", "}", "]", "{", "[", ":", ";", "=", "/", "#", "\\"].contains(characters[end]) { end += 1 }
}
if end > start {
let token = String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
let literal = token.lowercased()
let isLiteral = ["true", "false", "null"].contains(literal)
|| token.range(of: #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"#, options: .regularExpression) != nil
output.append(!quoteUnquotedValues && isLiteral ? literal : escapedJSONString(token))
index = end
}
}
}
case "}", "]":
@@ -580,7 +595,7 @@ public struct LegadoURLBuilder: Sendable {
while peekPosition < characters.count, characters[peekPosition].isWhitespace {
peekPosition += 1
}
if peekPosition < characters.count, characters[peekPosition] == ":" {
if peekPosition < characters.count, [":", "="].contains(characters[peekPosition]) {
output.removeLast()
output.append(escapedJSONString(name))
index = position
@@ -611,7 +626,7 @@ public struct LegadoURLBuilder: Sendable {
let parsed: JSONValue
if case .string(let source) = value {
do {
parsed = try JSONValue.decode(data: Data(source.utf8))
parsed = try Self.decodeLenientJSON(source)
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
}
@@ -84,6 +84,13 @@ public struct RSSRuleParser: Sendable {
private func flatten(_ values: [JSONValue]) -> [JSONValue] {
values.flatMap { value in
if case .array(let items) = value { return items }
// Text rules serialize selected JSON arrays. Expand them before
// evaluating article fields, just as for an already parsed array.
if case .string(let text) = value,
text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("["),
let items = try? JSONDecoder().decode([JSONValue].self, from: Data(text.utf8)) {
return items
}
return [value]
}
}
@@ -17,6 +17,48 @@ public struct JSONPathEvaluator: Sendable {
public init() {}
// Legado's JSON rules also allow literal templates such as /book/{$.id}.
// Balance braces and quoted strings instead of stopping at the first }.
func evaluateRule(_ rule: String, in root: JSONValue) throws -> [JSONValue] {
var cursor = rule.startIndex
var output = ""
var foundTemplate = false
while let opening = rule.range(of: "{$.", range: cursor..<rule.endIndex) {
foundTemplate = true
output += rule[cursor..<opening.lowerBound]
let start = rule.index(after: opening.lowerBound)
var end = start
var depth = 1
var quote: Character?
var escaped = false
while end < rule.endIndex {
let character = rule[end]
if escaped { escaped = false }
else if character == "\\" { escaped = true }
else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" { quote = character }
else if character == "{" { depth += 1 }
else if character == "}" {
depth -= 1
if depth == 0 { break }
}
end = rule.index(after: end)
}
guard end < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: rule, detail: "JSONPath 模板大括号未闭合")
}
let values = try evaluate(path: String(rule[start..<end]), in: root)
output += RuleEvaluation(values: values).strings.joined(separator: "\n")
cursor = rule.index(after: end)
}
if foundTemplate {
output += rule[cursor...]
return [.string(output)]
}
return try evaluate(path: rule.hasPrefix("$") ? rule : "$." + rule, in: root)
}
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path)
var current = [root]
@@ -59,7 +101,10 @@ public struct JSONPathEvaluator: Sendable {
guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
}
if rule[next] == "." {
if rule[next] == "[" {
// Legacy sources use $.[*] as well as $[*].
index = next
} else if rule[next] == "." {
index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector))
@@ -84,7 +84,8 @@ public extension JSONValue {
if value is NSNull { return .null }
if let value = value as? JSONValue { return value }
if let value = value as? String { return .string(value) }
if let value = value as? Bool { return .bool(value) }
// NSNumber(0/1) also casts to Bool. Inspect its CF type first so
// JavaScript page arithmetic and numeric IDs remain numbers.
if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue)
@@ -208,6 +208,33 @@ public struct JavaScriptRuleEvaluator: Sendable {
script: String,
result: JSONValue,
executionContext: RuleExecutionContext
) throws -> JavaScriptRuleResult {
try evaluate(script: script, result: result, executionContext: executionContext,
virtualMachine: JSVirtualMachine(), stringifyResult: false)
}
/// A rule shares java.get/put storage, but each match gets fresh JS globals.
/// Keep this closure on the synchronous replacement call's thread.
func replacementEvaluator(script: String, book: Book, chapter: BookChapter) -> (String) throws -> String {
var virtualMachine: JSVirtualMachine?
var executionContext = RuleExecutionContext(book: book, chapter: chapter)
return { match in
if virtualMachine == nil { virtualMachine = JSVirtualMachine() }
let result = try autoreleasepool {
try evaluate(script: script, result: .string(match), executionContext: executionContext,
virtualMachine: virtualMachine!, stringifyResult: true)
}
executionContext.variables = result.variables
return result.value.stringValue
}
}
private func evaluate(
script: String,
result: JSONValue,
executionContext: RuleExecutionContext,
virtualMachine: JSVirtualMachine,
stringifyResult: Bool
) throws -> JavaScriptRuleResult {
guard script.utf8.count <= maximumScriptLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限")
@@ -215,8 +242,7 @@ public struct JavaScriptRuleEvaluator: Sendable {
try rejectUnboundedExecution(script)
let box = JSVariableBox(executionContext.variables)
let vm = JSVirtualMachine()
guard let context = JSContext(virtualMachine: vm) else {
guard let context = JSContext(virtualMachine: virtualMachine) else {
throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文")
}
var exceptionMessage: String?
@@ -271,7 +297,28 @@ public struct JavaScriptRuleEvaluator: Sendable {
let normalized = evaluated.flatMap {
context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0])
}
let value = JSONValue.bridge(normalized?.toObject())
let value: JSONValue
if stringifyResult {
// Rhino unwraps undefined to null before RegexExtensions.kt calls
// toString(). Preserve strings directly, rather than JSON encoding.
if let evaluated, !evaluated.isUndefined, !evaluated.isNull {
let text = evaluated.toString() ?? "null"
if evaluated.isNumber {
// The reference runs Rhino in interpreted mode, yielding JVM Doubles.
let number = evaluated.toDouble()
value = .string(number.isFinite ? String(number) : text)
} else {
value = .string(text)
}
} else {
value = .string("null")
}
} else {
value = JSONValue.bridge(normalized?.toObject())
}
if let exceptionMessage {
throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)")
}
guard value.stringValue.utf8.count <= maximumResultLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限")
}
@@ -974,53 +1021,48 @@ public struct JavaScriptRuleEvaluator: Sendable {
return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext)
}
let plan = try RuleExpressionParser().parse(trimmedRule)
let jsonContent: JSONValue?
if case .string(let text) = content { jsonContent = try? JSONValue.decode(data: Data(text.utf8)) }
else { jsonContent = content }
let plan = try RuleExpressionParser().parse(trimmedRule, inputLooksLikeJSON: jsonContent != nil)
var branchResults: [[String]] = []
for branch in plan.branches {
var values: [String]
switch branch.mode {
case .css:
values = try HTMLRuleEvaluator().extractCSS(
branch.expression,
html: content.stringValue,
baseURL: executionContext.baseURL,
isFragment: executionContext.isHTMLFragment
)
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression
let normalizedPath = path.hasPrefix("$") ? path : "$" + path
values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json)
.map(\.stringValue)
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
if let pattern = branch.replacePattern, !pattern.isEmpty {
values = try values.map {
try RegexEvaluator().replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
if branch.expression.isEmpty {
values = [content.stringValue]
} else {
switch branch.mode {
case .css:
values = try HTMLRuleEvaluator().extractCSS(
branch.expression,
html: content.stringValue,
baseURL: executionContext.baseURL,
isFragment: executionContext.isHTMLFragment
)
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
values = RuleEvaluation(values: try JSONPathEvaluator().evaluateRule(branch.expression, in: json)).strings
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
}
if plan.combination == .fallback, values.joined(separator: "\n").isEmpty { values = [] }
branchResults.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
}
@@ -1037,7 +1079,11 @@ public struct JavaScriptRuleEvaluator: Sendable {
branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil }
}
}
return resolveJavaScriptRuleURL(combined.joined(separator: "\n"), enabled: isURL, context: executionContext)
var result = combined.joined(separator: "\n")
if let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty {
result = try RegexEvaluator().replacing(pattern: pattern, in: result, with: branch.replacement, firstOnly: branch.replaceFirst)
}
return resolveJavaScriptRuleURL(result, enabled: isURL, context: executionContext)
}
private func resolveJavaScriptRuleURL(
@@ -112,6 +112,25 @@ public struct RegexEvaluator: Sendable {
public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> String {
try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
expression.replacementString(for: match, in: input, offset: 0, template: replacement)
}
}
/// Legado's JS replacement still expands capture references, while quoting
/// backslashes returned by the script (quoteReplacementJs in the reference).
func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64 = 3_000,
replacement: (String) throws -> String) throws -> String {
let original = input as NSString
return try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
let template = try replacement(original.substring(with: match.range))
.replacingOccurrences(of: "\\", with: "\\\\")
return expression.replacementString(for: match, in: input, offset: 0, template: template)
}
}
private func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64,
replacement: (NSRegularExpression, NSTextCheckingResult) throws -> String) throws -> String {
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
@@ -145,30 +164,51 @@ public struct RegexEvaluator: Sendable {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset))
let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement)
output += untouched + replaced
let replaced = try replacement(expression, match)
guard DispatchTime.now().uptimeNanoseconds < deadline else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += untouched + replaced
}
output += original.substring(from: offset)
let tail = original.substring(from: offset)
guard outputLength + tail.utf16.count <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += tail
return output
}
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"#
guard let expression = try? NSRegularExpression(pattern: risky) else { return false }
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in
guard let innerRange = Range(match.range(at: 1), in: pattern),
let outerRange = Range(match.range(at: 2), in: pattern) else { return true }
let inner = String(pattern[innerRange])
let outer = String(pattern[outerRange])
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false }
if !pattern.contains("(?s"),
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false }
return true
// Reject clear cases such as (a+)+ and (\w*)*. A '+' inside a character
// class/escape is not a quantifier, and bounded or delimited repeated
// groups are not proof of catastrophic backtracking. Other expressions
// remain protected by enumerateMatches' progress/deadline checks.
let atom = #"^(?:\?:)?(?:\\[dDsSwW]|\\[^pPkKgG0-9]|\[(?:\\.|[^\]\\])*\]|[^\[\]()\\?*+{}|^$])(?:[+*]|\{[0-9]+,\})\??$"#
guard let repeatedAtom = try? NSRegularExpression(pattern: atom),
let unbounded = try? NSRegularExpression(pattern: #"^(?:[+*]|\{[0-9]+,\})(?!\+)"#) else { return false }
var groups: [String.Index] = []
var classDepth = 0
var escaped = false
for index in pattern.indices {
let character = pattern[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if character == "[" { classDepth += 1; continue }
if character == "]", classDepth > 0 { classDepth -= 1; continue }
guard classDepth == 0 else { continue }
if character == "(" { groups.append(pattern.index(after: index)) }
if character == ")", let start = groups.popLast() {
let inner = String(pattern[start..<index])
let suffix = String(pattern[pattern.index(after: index)...])
if repeatedAtom.firstMatch(in: inner, range: NSRange(inner.startIndex..., in: inner)) != nil,
unbounded.firstMatch(in: suffix, range: NSRange(suffix.startIndex..., in: suffix)) != nil { return true }
}
}
return false
}
}
+33 -34
View File
@@ -57,11 +57,11 @@ public struct RuleEngine: Sendable {
return literalEvaluation(rendered.rule, context: rendered.context)
}
if rendered.context.variables != context.variables {
var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
var result = await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result
}
return await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
return await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
@@ -72,6 +72,7 @@ public struct RuleEngine: Sendable {
private func evaluatePrepared(
_ rule: String,
in input: JSONValue,
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
do {
@@ -80,7 +81,8 @@ public struct RuleEngine: Sendable {
var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches {
do {
let values = try await evaluate(branch, in: input, context: context)
var values = try await evaluate(branch, in: input, context: context)
if plan.combination == .fallback, isEmpty(values, listMode: listMode) { values = [] }
results.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
} catch let error as RuleEngineError {
@@ -97,7 +99,7 @@ public struct RuleEngine: Sendable {
}
}
return .init(
values: combine(results, with: plan.combination),
values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics,
variables: context.variables
)
@@ -174,6 +176,11 @@ public struct RuleEngine: Sendable {
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
if let root = try? JSONValue.decode(data: Data(input.utf8)),
let plan = try? parser.parse(rule, inputLooksLikeJSON: true),
plan.branches.allSatisfy({ $0.mode == .jsonPath }) {
return await evaluatePrepared(rule, in: root, listMode: listMode, context: context)
}
if rule.hasPrefix(":"), rule.count > 1 {
do {
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
@@ -217,7 +224,7 @@ public struct RuleEngine: Sendable {
}
}
return .init(
values: combine(results, with: plan.combination),
values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics,
variables: context.variables
)
@@ -241,15 +248,11 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation()
let rawValues: [JSONValue]
if branch.expression.isEmpty {
return try [JSONValue.string(regex.replacing(
pattern: branch.replacePattern ?? "", in: input.stringValue,
with: branch.replacement, firstOnly: branch.replaceFirst
))]
return [input]
}
switch branch.mode {
case .jsonPath:
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
rawValues = try jsonPath.evaluate(path: path, in: input)
rawValues = try jsonPath.evaluateRule(branch.expression, in: input)
case .regex:
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
rawValues = matches.map { .array($0.map(JSONValue.string)) }
@@ -266,15 +269,7 @@ public struct RuleEngine: Sendable {
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues }
return try rawValues.map {
.string(try regex.replacing(
pattern: pattern,
in: $0.stringValue,
with: branch.replacement,
firstOnly: branch.replaceFirst
))
}
return rawValues
}
private func evaluateTextBranch(
@@ -287,10 +282,7 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation()
let raw: [String]
if branch.expression.isEmpty {
return try [regex.replacing(
pattern: branch.replacePattern ?? "", in: input,
with: branch.replacement, firstOnly: branch.replaceFirst
)]
return [input]
}
switch branch.mode {
case .css:
@@ -308,8 +300,7 @@ public struct RuleEngine: Sendable {
} catch {
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
raw = try jsonPath.evaluateRule(branch.expression, in: root).map(\.stringValue)
case .regex:
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
@@ -323,14 +314,22 @@ public struct RuleEngine: Sendable {
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw }
return try raw.map {
try regex.replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
)
return raw
}
private func isEmpty(_ values: [JSONValue], listMode: Bool) -> Bool {
let result = RuleEvaluation(values: values)
return listMode
? result.listItems(rule: "").filter { $0 != .null }.isEmpty
: result.strings.joined(separator: "\n").isEmpty
}
private func applyingReplacement(to values: [JSONValue], plan: RulePlan, listMode: Bool) throws -> [JSONValue] {
guard let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty else { return values }
let strings = RuleEvaluation(values: values).strings
let inputs = listMode ? strings : [strings.joined(separator: "\n")]
return try inputs.map {
.string(try regex.replacing(pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst))
}
}
@@ -52,8 +52,12 @@ public struct RuleExpressionParser: Sendable {
guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
}
let split = try splitAtTopLevel(normalized)
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) }
// ## belongs to the whole selector stage. Regex/replacement text may
// contain quotes, brackets and && that are not selector operators.
let replacementStart = normalized.range(of: "##")?.lowerBound ?? normalized.endIndex
let split = try splitAtTopLevel(String(normalized[..<replacementStart]))
let suffix = String(normalized[replacementStart...])
let branches = try split.parts.map { try parseBranch($0 + suffix, inputLooksLikeJSON: inputLooksLikeJSON) }
return .init(source: source, combination: split.combination, branches: branches)
}