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