mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 21:01:22 +08:00
feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。 修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。 更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
@@ -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), [])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user