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
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import LegadoCore
// Opt-in only: point LEGADO_SOURCE_PROBE_FILE at a downloaded JSON source pack.
// No remote source or response is included in the application or normal test run.
struct BookSourceRemoteProbe {
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"] != nil))
func sampleReadingPipelines() async throws {
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"])
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
var reports: [BookSourceDebugReport] = []
let transport = ProbeTransport()
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
for source in sources {
var report = await BookSourceDebugger(transport: transport).run(source: source, keyword: keyword)
report.contentPreview = nil
reports.append(report)
print("SOURCE_CHAIN \(source.bookSourceName) " + String(decoding: try JSONEncoder().encode(report), as: UTF8.self))
}
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_OUTPUT"] {
try JSONEncoder().encode(reports).write(to: URL(fileURLWithPath: output))
try JSONEncoder().encode(await transport.records()).write(to: URL(fileURLWithPath: output + ".requests.json"))
}
#expect(reports.count == sources.count)
}
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] != nil))
func sampleSearches() async throws {
guard let path = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] else { return }
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
let transport = ProbeTransport()
let service = BookSearchService(transport: transport, networkPolicy: .init(), history: .init())
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
let page = Int(ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_PAGE"] ?? "1") ?? 1
var rows: [[String: JSONValue]] = []
for await outcome in service.searchOutcomes(keyword: keyword, page: page, sources: sources, maximumConcurrentSources: 3, includeDisabled: true) {
let source = try #require(sources.first { $0.id == outcome.sourceID })
let row: [String: JSONValue] = [
"name": .string(source.bookSourceName), "source": .string(source.id),
"count": .number(Double(outcome.books.count)),
"books": .array(outcome.books.prefix(3).map { .object([
"name": .string($0.name), "url": .string($0.bookURL), "author": .string($0.author),
"cover": .string($0.coverURL ?? ""), "wordCount": .string($0.wordCount ?? "")
]) }),
"diagnostics": .array(outcome.diagnostics.map { .object(["code": .string($0.code), "message": .string($0.message), "rule": .string($0.rule)]) })
]
rows.append(row)
print("SOURCE_PROBE " + JSONValue.object(row).stringValue)
}
let report = JSONValue.object(["keyword": .string(keyword), "page": .number(Double(page)), "outcomes": .array(rows.map(JSONValue.object)), "requests": .array(await transport.records())])
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_OUTPUT"] {
try JSONEncoder().encode(report).write(to: URL(fileURLWithPath: output))
}
#expect(rows.count == Set(sources.map(\.id)).count)
}
}
private actor ProbeTransport: HTTPTransport {
private var values: [JSONValue] = []
private let transport = URLSessionHTTPTransport()
func data(for request: URLRequest) async throws -> HTTPResponse {
do {
let response = try await transport.data(for: request)
var record: [String: JSONValue] = [
"url": .string(request.url?.absoluteString ?? ""),
"finalURL": .string(response.finalURL.absoluteString),
"status": .number(Double(response.statusCode)),
"bytes": .number(Double(response.data.count)),
"preview": .string(String(response.decodedText.prefix(240)))
]
if let directory = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_SNAPSHOT_DIR"] {
let folder = URL(fileURLWithPath: directory, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let file = folder.appendingPathComponent(UUID().uuidString + ".response")
try response.data.write(to: file)
record["snapshot"] = .string(file.path)
}
values.append(.object(record))
return response
} catch {
values.append(.object(["url": .string(request.url?.absoluteString ?? ""), "error": .string(error.localizedDescription)]))
throw error
}
}
func records() -> [JSONValue] { values }
}
+193
View File
@@ -0,0 +1,193 @@
import Foundation
import Testing
import ZIPFoundation
@testable import LegadoCore
struct EPUBBookTests {
private let service = LocalBookService()
private let png = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg==")!
private func archive(_ files: [(String, Data)]) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("示例.EPUB")
let archive = try Archive(url: url, accessMode: .create)
for (path, data) in files {
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: path == "mimetype" ? .none : .deflate) { position, size in
data.subdata(in: Int(position)..<Int(position) + size)
}
}
return url
}
private func fixture(epub3: Bool = true, navigation: Bool = true, extras: [(String, Data)] = [], chapter: String? = nil) throws -> URL {
let navItem = epub3
? #"<item id="nav" href="目录/nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>"#
: #"<item id="nav" href="目录/toc.ncx" media-type="application/x-dtbncx+xml"/>"#
let package = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="\(epub3 ? "3.0" : "2.0")">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>测试书籍</dc:title><dc:creator>作者甲</dc:creator><dc:creator>作者乙</dc:creator>
<dc:description>&lt;p&gt;测试简介 &amp;amp; 内容&lt;/p&gt;</dc:description>
<meta name="cover" content="cover"/>
</metadata>
<manifest>
<item id="last" href="Text/end.xhtml" media-type="application/xhtml+xml"/>
\(navigation ? navItem : "")
<item id="main" href="Text/中文%20章节.xhtml" media-type="application/xhtml+xml"/>
<item id="intro" href="Text/intro.xhtml" media-type="application/xhtml+xml"/>
<item id="cover" href="Images/封面.png" media-type="image/png" properties="cover-image"/>
<item id="extra" href="Text/extra.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="nav"><itemref idref="intro"/><itemref idref="main"/><itemref idref="extra"/><itemref idref="last"/></spine>
</package>
"""
let body = chapter ?? """
<html><head><title>正文</title><style>不要显示样式</style></head><body>
<p>锚点前的引言</p><section><h1 id="one">第一章</h1><p>甲 &amp; 乙<em>相遇</em>。</p>
<script>不要显示脚本</script><p style="display: none">隐藏内容</p>
<p><ruby>字<rt>zi</rt><rp>(zi)</rp></ruby>形</p><img src="../Images/封面.png" alt="风景"/></section>
<section><div><h1 id="two">第二章</h1></div><p>第二章正文</p></section>
</body></html>
"""
let nav = """
<html xmlns:epub="http://www.idpf.org/2007/ops"><body>
<nav epub:type="landmarks"><a href="../Text/end.xhtml">错误地标</a></nav>
<nav epub:type="toc"><ol><li><span>第一卷</span><ol>
<li><a href="../Text/中文%20章节.xhtml#one">第一章</a></li>
<li><a href="../Text/中文%20章节.xhtml#two">第二章</a></li></ol></li>
<li><a href="../Text/end.xhtml">尾声</a></li></ol></nav></body></html>
"""
let ncx = """
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap>
<navPoint><navLabel><text>第一卷</text></navLabel>
<navPoint><navLabel><text>第一章</text></navLabel><content src="../Text/中文%20章节.xhtml#one"/></navPoint>
<navPoint><navLabel><text>第二章</text></navLabel><content src="../Text/中文%20章节.xhtml#two"/></navPoint>
</navPoint><navPoint><navLabel><text>尾声</text></navLabel><content src="../Text/end.xhtml"/></navPoint>
</navMap></ncx>
"""
var files: [(String, Data)] = [
("mimetype", Data("application/epub+zip".utf8)),
("META-INF/container.xml", Data(#"<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>"#.utf8)),
("OPS/book.opf", Data(package.utf8)),
("OPS/Text/intro.xhtml", Data("<html><head><title>序言</title></head><body><p>开篇内容</p></body></html>".utf8)),
("OPS/Text/中文 章节.xhtml", Data(body.utf8)),
("OPS/Text/extra.xhtml", Data("<html><body><h1>未列入目录</h1><p>不应遗漏正文</p></body></html>".utf8)),
("OPS/Text/end.xhtml", Data("<html><body><p>完结正文</p></body></html>".utf8)),
("OPS/Images/封面.png", png)
]
if navigation { files.append((epub3 ? "OPS/目录/nav.xhtml" : "OPS/目录/toc.ncx", Data((epub3 ? nav : ncx).utf8))) }
return try archive(files + extras)
}
@Test(arguments: [true, false]) func readsEPUB2And3MetadataSpineAnchorsAndIllustrations(epub3: Bool) throws {
let url = try fixture(epub3: epub3)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.book.name == "测试书籍")
#expect(report.book.author == "作者甲、作者乙")
#expect(report.book.intro == "测试简介 & 内容")
#expect(report.book.coverUrl?.hasPrefix("data:image/png;base64,") == true)
#expect(report.book.readConfig?.useReplaceRule == false)
#expect(!report.book.canUpdate)
#expect(report.chapters.map(\.title) == ["序言", "正文", "第一章", "第二章", "未列入目录", "尾声"])
#expect(report.chapters.map(\.index) == Array(0..<6))
#expect(Set(report.chapters.map(\.id)).count == 6)
#expect(try service.loadChapters(book: report.book).chapters == report.chapters)
let contents = try report.chapters.map { try service.loadContent(book: report.book, chapter: $0).content }
#expect(contents[0] == "开篇内容")
#expect(contents[1] == "锚点前的引言")
#expect(contents[2] == "第一章\n甲 & 乙相遇。\n字形\n【插图 1:风景】")
#expect(contents[3] == "第二章\n第二章正文")
#expect(contents[4].contains("不应遗漏正文"))
#expect(contents[5] == "完结正文")
let images = try service.loadIllustrations(book: report.book, chapter: report.chapters[2])
#expect(images.count == 1 && images.first?.data == png && images.first?.caption == "风景")
#expect(try service.loadIllustrations(book: report.book, chapter: report.chapters[3]).isEmpty)
}
@Test func fallsBackToSpineWithoutNavigationAndDoesNotUseManifestOrder() throws {
let url = try fixture(navigation: false)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.chapters.count == 4)
#expect(report.chapters.first?.title == "序言")
let body = try service.loadContent(book: report.book, chapter: report.chapters[1]).content
#expect(body.contains("锚点前的引言") && body.contains("第一章") && body.contains("第二章正文"))
}
@Test func importsCopyPreservingMetadataAndOriginalFile() throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.importFile(at: url, into: url.deletingLastPathComponent().appendingPathComponent("managed"))
let destination = try #require(URL(string: report.book.bookUrl))
#expect(destination != url && destination.pathExtension == "epub")
#expect(report.book.originName == "示例.EPUB")
#expect(try Data(contentsOf: destination) == Data(contentsOf: url))
#expect(try service.loadContent(book: report.book, chapter: report.chapters[2]).content.contains("甲 & 乙"))
}
@Test func fileManagementPreservesEPUBOnRenameExportAndRemoval() async throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let root = url.deletingLastPathComponent()
let files = LocalFileService(directory: root.appendingPathComponent("managed"))
let first = try await files.importFile(from: url)
let second = try await files.importFile(from: url)
#expect(second.lastPathComponent == "示例 (2).EPUB")
let renamed = try await files.rename(first, to: "新名字")
#expect(renamed.pathExtension == "EPUB")
#expect(try await files.list().count == 2)
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"))
let export = try await exporter.originalFile(renamed, filename: "测试书籍.epub")
#expect(export.url.lastPathComponent == "测试书籍.epub")
#expect(try Data(contentsOf: export.url) == Data(contentsOf: url))
let staged = try #require(try await files.stageRemoval(renamed))
try await files.restore(staged)
#expect(try service.openFile(at: renamed).book.name == "测试书籍")
let removed = try #require(try await files.stageRemoval(renamed))
try await files.finishRemoval(removed)
#expect(try await files.list().count == 1)
}
@Test func rejectsCorruptionTraversalEncryptionAndOversizeWithoutLeavingImportedFiles() throws {
let good = try fixture()
defer { try? FileManager.default.removeItem(at: good.deletingLastPathComponent()) }
#expect(throws: LocalBookError.self) { try LocalBookService(maximumFileSize: 1).openFile(at: good) }
let broken = good.deletingLastPathComponent().appendingPathComponent("损坏.epub")
try Data("不是压缩包".utf8).write(to: broken)
let managed = good.deletingLastPathComponent().appendingPathComponent("managed")
#expect(throws: EPUBError.self) { try service.importFile(at: broken, into: managed) }
#expect(try FileManager.default.contentsOfDirectory(atPath: managed.path).isEmpty)
let malicious = try fixture(extras: [("../escape", Data("outside".utf8))])
defer { try? FileManager.default.removeItem(at: malicious.deletingLastPathComponent()) }
#expect(throws: EPUBError.self) { try service.openFile(at: malicious) }
let encrypted = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Text/中文%20章节.xhtml"/></CipherData></EncryptedData></encryption>"#.utf8))])
defer { try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) }
#expect(throws: EPUBError.self) { try service.openFile(at: encrypted) }
let fontOnly = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Fonts/font.otf"/></CipherData></EncryptedData></encryption>"#.utf8))])
defer { try? FileManager.default.removeItem(at: fontOnly.deletingLastPathComponent()) }
#expect(try service.openFile(at: fontOnly).chapters.count == 6)
}
@Test func rejectsForgedChapterResourceAndMissingAnchors() throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
var chapter = report.chapters[2]
chapter.resourceUrl = "../../outside"
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
chapter = report.chapters[2]
chapter.startFragmentId = "missing"
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
}
@Test func ignoresBadNavigationAnchorsAndOrdersValidAnchorsByDocumentPosition() throws {
let url = try fixture(chapter: "<html><body><h1 id='two'>第二章</h1><p>先读</p><h1 id='one'>第一章</h1><p>后读</p></body></html>")
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.chapters.map(\.title) == ["序言", "第二章", "第一章", "未列入目录", "尾声"])
#expect(try service.loadContent(book: report.book, chapter: report.chapters[1]).content == "第二章\n先读")
}
}
@@ -393,6 +393,20 @@ struct LegadoDatabaseTests {
#expect(report.result.nextURL == "https://rss.example/list?page=1")
}
@Test func rssJSONListWithoutWildcardExtractsIndividualArticles() async throws {
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "JSON 订阅")
source.ruleArticles = "$.items"
source.ruleTitle = "$.title"
source.ruleLink = "$.link"
let report = try await RSSRuleParser().parse(
sortName: "全部", sortURL: "https://rss.example/feed", redirectURL: "https://rss.example/feed",
body: #"{"items":[{"title":"第一篇","link":"/one"},{"title":"第二篇","link":"/two"}]}"#,
source: source
)
#expect(report.result.articles.map(\.title) == ["第一篇", "第二篇"])
#expect(report.result.articles.map(\.link) == ["https://rss.example/one", "https://rss.example/two"])
}
@Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
let importer = RSSSourceImporter()
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
@@ -0,0 +1,141 @@
import Foundation
import Testing
@testable import LegadoCore
struct ReplaceRuleExecutionTests {
private func process(_ content: String, rules: [ReplaceRule], title: String = "第十二章") -> ContentProcessingReport {
let book = Book(bookUrl: "book", name: "测试书", origin: "https://source.test")
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 11)
return ContentSanitizer().process(book: book, chapter: chapter, content: content, rules: rules,
removeDuplicateTitle: false, resegment: false)
}
@Test func evaluatesJavaScriptForEveryMatchIncludingZeroWidthMatches() {
let width = ReplaceRule(id: 1, name: "全角", pattern: "[A-Z0-9]",
replacement: "@js:String.fromCharCode(result.charCodeAt(0) - 65248)")
var punctuation = ReplaceRule(id: 2, name: "标点", pattern: #"(?<=说道)(?=“)|\.{3,}"#,
replacement: #"@js:if (result === '') { ':' } else { '…' }"#)
punctuation.sortOrder = 1
let result = process("ABC123\n他说道“等待...”", rules: [punctuation, width])
#expect(result.content == "ABC123\n他说道:“等待…”")
#expect(result.effectiveRuleIDs == [1, 2])
#expect(result.diagnostics.isEmpty)
}
@Test func bindsBookAndChapterAndSharesOnlyJavaVariablesBetweenMatches() {
let rule = ReplaceRule(id: 1, name: "上下文", pattern: "[ab]", replacement: """
@js:n = typeof n === 'undefined' ? 1 : n + 1;
java.put('count', String(Number(java.get('count') || 0) + 1));
[book.name, book.getName(), chapter.title, chapter.getIndex(), result, n, java.get('count')].join(':')
""")
let report = process("a b", rules: [rule])
#expect(report.content == "测试书:测试书:第十二章:11:a:1:1 测试书:测试书:第十二章:11:b:1:2")
#expect(report.diagnostics.isEmpty)
// A new application of the same rule must not inherit another chapter's variables.
#expect(process("a", rules: [rule]).content == "测试书:测试书:第十二章:11:a:1:1")
}
@Test(arguments: [
(#"'$1'"#, "x"),
(#"String.fromCharCode(92) + 'n'"#, #"\n"#),
("null", "null"),
("undefined", "null"),
("['a', 'b']", "a,b"),
("123", "123.0"),
("false", "false")
])
func convertsScriptCompletionAndPreservesReplacementEscaping(_ sample: (String, String)) {
let rule = ReplaceRule(id: 1, name: "返回值", pattern: "(x)", replacement: "@js:" + sample.0)
let report = process("x", rules: [rule])
#expect(report.content == sample.1)
#expect(report.diagnostics.isEmpty)
}
@Test func failedScriptDiscardsAllPartialReplacementsAndLaterRulesStillRun() {
let failing = ReplaceRule(id: 1, name: "失败", pattern: "[ab]",
replacement: "@js:if (result === 'b') { throw new Error('bad match') }; 'changed'")
var following = ReplaceRule(id: 2, name: "后续", pattern: "tail", replacement: "后续成功")
following.sortOrder = 1
let report = process("ab tail", rules: [failing, following])
#expect(report.content == "ab 后续成功")
#expect(report.effectiveRuleIDs == [2])
#expect(report.diagnostics.count == 1)
#expect(report.diagnostics.first?.code == "rule.malformed")
}
@Test func skipsDisabledExcludedAndUnmatchedScriptsAndKeepsLiteralReplacementsLiteral() {
var excluded = ReplaceRule(id: 1, name: "排除", pattern: "x", replacement: "@js:throw new Error('excluded')")
excluded.excludeScope = "测试书"
var disabled = excluded
disabled.id = 2
disabled.excludeScope = nil
disabled.isEnabled = false
let unmatched = ReplaceRule(id: 3, name: "未匹配", pattern: "z", replacement: "@js:throw new Error('unmatched')")
var literal = ReplaceRule(id: 4, name: "普通文本", pattern: "x", replacement: "@js:'literal'")
literal.isRegex = false
let report = process("x", rules: [excluded, disabled, unmatched, literal])
#expect(report.content == "@js:'literal'")
#expect(report.diagnostics.isEmpty)
}
@Test func preservesLastNonblankTitleButAllowsDeletingBody() {
var rename = ReplaceRule(id: 1, name: "标题", pattern: "十二", replacement: "12")
rename.scopeTitle = true
rename.scopeContent = false
var erase = ReplaceRule(id: 2, name: "空白", pattern: "^.+$", replacement: "@js:'  \\n'")
erase.scopeTitle = true
erase.sortOrder = 1
let report = process("正文", rules: [erase, rename])
#expect(report.title == "第12章")
#expect(report.content == "  \n")
#expect(report.effectiveRuleIDs == [1, 2])
erase.replacement = ""
erase.scopeContent = false
let titleOnly = process("正文", rules: [erase])
#expect(titleOnly.title == "第十二章")
#expect(titleOnly.effectiveRuleIDs.isEmpty)
}
@Test(arguments: [
(#"(?:\n[^\n]+){0,15}$"#, "正文\nPS:说明\n感谢支持", "正文"),
(#"^(?:[^\n\w]\w+){0,2}"#, ",第一章,开始\n正文", "\n正文"),
(#""((?:[\w,。]|‘\w+’|\b[%\+\-])+)["]"#, ""你好,世界。"", "“你好,世界。”"),
(#"(?:[+*]|\\)+"#, #"+*\"#, "")
])
func acceptsBoundedDelimitedAndEscapedQuantifiers(_ sample: (String, String, String)) throws {
let replacement = sample.0.hasPrefix(""") ? "“$1”" : ""
#expect(try RegexEvaluator().replacingStrict(pattern: sample.0, in: sample.1, with: replacement) == sample.2)
}
@Test(arguments: ["(a+)+$", #"(\w*)*"#, "(?:[ab]+){2,}"])
func retainsChecksForClearlyNestedUnboundedRepetition(_ pattern: String) {
#expect(throws: RuleEngineError.self) {
try RegexEvaluator().replacingStrict(pattern: pattern, in: "aaa!", with: "changed")
}
}
@Test func dynamicReplacementRespectsMatchOutputAndWholeOperationTimeLimits() throws {
let limited = RegexEvaluator(limits: .init(maximumInputLength: 4, maximumMatches: 2))
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "aaa", replacement: { _ in "b" })
}
#expect(try limited.replacingStrict(pattern: "a", in: "aa", replacement: { _ in "b" }) == "bb")
// Include untouched tail in the output limit, not only replaced spans.
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "abcd", replacement: { _ in "1234567" })
}
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "a", timeoutMilliseconds: 1, replacement: { _ in
Thread.sleep(forTimeInterval: 0.02)
return "b"
})
}
}
@Test func rejectsKnownUnboundedJavaScriptAndReportsErrorWithoutChangingInput() {
let rule = ReplaceRule(id: 1, name: "循环", pattern: "x", replacement: "@js:while(true){}")
let report = process("x", rules: [rule])
#expect(report.content == "x")
#expect(report.diagnostics.first?.code == "rule.resource_limit")
}
}
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import LegadoCore
// The payloads and host below are synthetic and never use network access.
struct SampledRequestRegressionTests {
@Test func equalsSeparatedSourceHeaderReachesSearchTransport() async throws {
var source = source()
source.header = #"{"type"="app"}"#
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
#expect(request.value(forHTTPHeaderField: "type") == "app")
#expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "keyword=%E6%96%97%E7%BD%97%E5%A4%A7%E9%99%86&page=0")
}
@Test func stringEncodedLenientHeadersReachSearchTransport() async throws {
var source = source()
source.searchUrl = #"/search?word={{key}},{"headers":"{os:'pc'}"}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
#expect(request.value(forHTTPHeaderField: "os") == "pc")
}
@Test func unquotedKeywordInObjectBodyPreservesJSONValueTypes() async throws {
var source = source()
source.searchUrl = #"/search,{"method":"POST","body":{"keyword":{{key}},"page_index":{{page}},"page_size":20,"enabled":true,"cursor":null}}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
let body = try JSONValue.decode(data: #require(request.httpBody))
#expect(body["keyword"] == .string("斗罗大陆"))
#expect(body["page_index"] == .number(1))
#expect(body["page_size"] == .number(20))
#expect(body["enabled"] == .bool(true))
#expect(body["cursor"] == .null)
#expect(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("application/json") == true)
}
@Test func lenientSeparatorsDoNotRewriteQuotedCookieOrURLValues() throws {
let value = try LegadoURLBuilder.decodeLenientJSON(#"{type=>app, 'Cookie':'token=a=b; mode=pc', referer:'https://fixture.invalid/?a=b', retry:2, enabled:false, empty:null}"#)
#expect(value["type"] == .string("app"))
#expect(value["Cookie"] == .string("token=a=b; mode=pc"))
#expect(value["referer"] == .string("https://fixture.invalid/?a=b"))
#expect(value["retry"] == .number(2))
#expect(value["enabled"] == .bool(false))
#expect(value["empty"] == .null)
}
@Test func unquotedWebViewFlagUsedByAudioSourceParses() throws {
let request = try LegadoURLBuilder().prepare("https://request.fixture.invalid/audio,{webView:“true”}")
#expect(request.useWebView)
}
@Test func lenientValuesKeepCommentsAndPutRuleExpressionsWorking() throws {
let options = try LegadoURLBuilder.decodeLenientJSON("{retry: 2 /* comment */, enabled: FALSE, word: 小说}")
#expect(options["retry"] == .number(2))
#expect(options["enabled"] == .bool(false))
#expect(options["word"] == .string("小说"))
let put = try LegadoURLBuilder.decodeLenientJSON("{id:$.books[0].id, name:name}", quoteUnquotedValues: true)
#expect(put["id"] == .string("$.books[0].id"))
#expect(put["name"] == .string("name"))
#expect(throws: (any Error).self) { try LegadoURLBuilder.decodeLenientJSON("{broken:}") }
}
private func source() -> BookSource {
var source = BookSource(bookSourceUrl: "https://request.fixture.invalid", bookSourceName: "请求兼容测试")
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
return source
}
}
private actor SampledRequestTransport: HTTPTransport {
private var values: [URLRequest] = []
func data(for request: URLRequest) async throws -> HTTPResponse {
values.append(request)
return .init(data: Data(#"{"books":[{"name":"测试书","url":"/book/42"}]}"#.utf8),
statusCode: 200, headers: [:], finalURL: try #require(request.url))
}
func requests() -> [URLRequest] { values }
}
@@ -0,0 +1,104 @@
import Foundation
import Testing
@testable import LegadoCore
// All responses and hosts are synthetic; these tests never contact source websites.
struct SampledSourceRegressionTests {
@Test func JavaScriptNumbersZeroAndOneRemainNumeric() throws {
let evaluator = JavaScriptRuleEvaluator()
let value = try evaluator.evaluate(script: "({zero: 0, one: 1, yes: true, no: false, list: [0, 1, true]})", result: .null, executionContext: .init()).value
#expect(value["zero"] == .number(0))
#expect(value["one"] == .number(1))
#expect(value["yes"] == .bool(true))
#expect(value["no"] == .bool(false))
#expect(value["list"] == .array([.number(0), .number(1), .bool(true)]))
}
@Test func JavaScriptGetStringUsesTheSameJSONTemplateAndReplacementSemantics() async throws {
let input = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试小说","alternate":"备用小说"}"#.utf8))
let value = await RuleEngine().evaluate("@js:java.getString('/book/{$.id}') + ':' + java.getString('$.name||$.alternate##小说')", in: input)
#expect(value.diagnostics.isEmpty)
#expect(value.strings == ["/book/42:测试"])
}
@Test func legacyJSONTemplatesKeepEachSearchBookURLDistinct() async throws {
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 模板")
source.searchUrl = "/search?word={{key}}"
source.ruleSearch = .init(bookList: "data.bookList", name: "name", bookUrl: "/pc/book/{$.id}/catalog")
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
#"{"data":{"bookList":[{"id":42,"name":"测试甲"},{"id":43,"name":"测试乙"}]}}"#
)).search(keyword: "测试", page: 1, source: source)
#expect(outcome.diagnostics.isEmpty)
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/pc/book/42/catalog", "https://fixture.invalid/pc/book/43/catalog"])
}
@Test func legacyJSONRootWildcardAndRecursiveURLTemplate() async throws {
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 列表")
source.searchUrl = "/search?word={{key}}"
source.ruleSearch = .init(bookList: "$.[*]", name: "$..name", bookUrl: "https://fixture.invalid{$..url}")
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
#"[{"name":"测试甲","url":"/book/42"},{"name":"测试乙","url":"/book/43"}]"#
)).search(keyword: "测试", page: 1, source: source)
#expect(outcome.diagnostics.isEmpty)
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/book/42", "https://fixture.invalid/book/43"])
}
@Test func legacyJSONTemplatesRespectQuotedBracesAndReplacement() async throws {
let input = try JSONValue.decode(data: Data(#"{"id":42,"words":1200,"meta":{"label}":"玄幻"},"tags":["甲","乙"]}"#.utf8))
let value = await RuleEngine().evaluate(#"{$.meta['label}']}/{$.words}字/{$.tags[*]}##1200##1.2千"#, in: input)
#expect(value.diagnostics.isEmpty)
#expect(value.strings == ["玄幻/1.2千字/甲\n乙"])
let fromText = await RuleEngine().evaluate("/book/{$.id}", inText: input.stringValue)
#expect(fromText.diagnostics.isEmpty)
#expect(fromText.strings == ["/book/42"])
}
@Test func JSONFallbackSkipsEmptyArraysAndEmptyNames() async throws {
let input = try JSONValue.decode(data: Data(#"{"empty":[],"books":[{"name":"备用书"}],"name":"","alternate":"备用书","zero":0}"#.utf8))
for useText in [false, true] {
let list = if useText {
await RuleEngine().evaluate("$.empty||$.books", inText: input.stringValue, listMode: true)
} else {
await RuleEngine().evaluate("$.empty||$.books", in: input, listMode: true)
}
#expect(list.diagnostics.isEmpty)
#expect(list.listItems(rule: "$.empty||$.books").count == 1)
let name = await RuleEngine().evaluate("$.name||$.alternate", in: input)
#expect(name.strings == ["备用书"])
let zero = await RuleEngine().evaluate("$.zero||$.alternate", in: input)
#expect(zero.strings == ["0"])
}
}
@Test func replacementAppliesToTheCombinedSelectorResult() async throws {
// Search names in the sampled fallback sources and multi-part metadata
// use one trailing ## expression for the entire selector combination.
let input = try JSONValue.decode(data: Data(#"{"name":"测试小说","fallback":"备用小说","a":"A1","b":"B2"}"#.utf8))
let fallback = await RuleEngine().evaluate("$.name||$.fallback##小说", in: input)
#expect(fallback.strings == ["测试"])
let combined = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input)
#expect(combined.strings.joined(separator: "\n") == "A\nB")
let anchored = await RuleEngine().evaluate(#"$.a&&$.b##^A1\nB2$##整段"#, in: input)
#expect(anchored.strings == ["整段"])
let list = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input, listMode: true)
#expect(list.strings == ["A", "B"])
let html = await RuleEngine().evaluate("h1@text||h2@text##小说", inText: "<h1>测试小说</h1>")
#expect(html.strings == ["测试"])
}
@Test func replacementTextIsNotParsedAsSelectorSyntax() async {
let result = await RuleEngine().evaluate(#"h1@text##["']|[()]"#, inText: "<h1>书名(测试)'</h1>")
#expect(result.diagnostics.isEmpty)
#expect(result.strings == ["书名测试"])
let replacement = await RuleEngine().evaluate("h1@text##书##A&&B", inText: "<h1>书</h1>")
#expect(replacement.diagnostics.isEmpty)
#expect(replacement.strings == ["A&&B"])
}
}
private struct SampledResponseTransport: HTTPTransport {
let body: String
func data(for request: URLRequest) async throws -> HTTPResponse {
.init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: try #require(request.url))
}
}