feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -0,0 +1,78 @@
import Foundation
public enum AddBookURLError: LocalizedError, Equatable {
case invalidURL
case sourceNotFound
case detailFailed
public var errorDescription: String? {
switch self {
case .invalidURL: "书籍地址格式不对"
case .sourceNotFound: "未找到匹配书源"
case .detailFailed: "匹配书源未能解析书籍详情"
}
}
}
public struct AddBookURLResult: Sendable {
public var source: BookSource
public var book: Book
public var diagnostics: [RuleDiagnostic]
}
public struct AddBookURLService: Sendable {
private let detailService: NetworkBookService
public init(detailService: NetworkBookService = .init()) { self.detailService = detailService }
public func resolve(bookURL: String, sources: [BookSource]) async throws -> AddBookURLResult {
let parsed = Self.parse(bookURL)
guard let url = URL(string: parsed.url), let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), let host = url.host else {
throw AddBookURLError.invalidURL
}
let baseURL = "\(scheme)://\(host)" + (url.port.map { ":\($0)" } ?? "")
let enabled = sources.filter(\.enabled).sorted { $0.customOrder < $1.customOrder }
var candidates: [BookSource] = []
if let origin = parsed.origin, let source = sources.first(where: { $0.bookSourceUrl == origin }) { candidates.append(source) }
if let source = enabled.first(where: { $0.bookSourceUrl == baseURL }) { candidates.append(source) }
for source in enabled where source.bookUrlPattern?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && source.bookUrlPattern != "NONE" {
guard let rule = source.bookUrlPattern, let expression = try? NSRegularExpression(pattern: rule) else { continue }
let range = NSRange(parsed.url.startIndex..., in: parsed.url)
if let match = expression.firstMatch(in: parsed.url, range: range), match.range == range { candidates.append(source) }
}
var seen = Set<String>()
for source in candidates where seen.insert(source.bookSourceUrl).inserted {
let searchBook = SearchBook(bookURL: bookURL, origin: source.bookSourceUrl, originName: source.bookSourceName, type: source.bookSourceType, name: "")
let detail = await detailService.loadDetail(searchBook: searchBook, source: source)
if !detail.book.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .init(source: source, book: detail.book, diagnostics: detail.diagnostics)
}
}
if candidates.isEmpty { throw AddBookURLError.sourceNotFound }
throw AddBookURLError.detailFailed
}
public static func matchingSource(bookURL: String, sources: [BookSource]) -> BookSource? {
let parsed = parse(bookURL)
if let origin = parsed.origin, let source = sources.first(where: { $0.bookSourceUrl == origin }) { return source }
guard let url = URL(string: parsed.url), let scheme = url.scheme, let host = url.host else { return nil }
let baseURL = "\(scheme)://\(host)" + (url.port.map { ":\($0)" } ?? "")
let enabled = sources.filter(\.enabled).sorted { $0.customOrder < $1.customOrder }
if let source = enabled.first(where: { $0.bookSourceUrl == baseURL }) { return source }
return enabled.first { source in
guard let rule = source.bookUrlPattern?.trimmingCharacters(in: .whitespacesAndNewlines), !rule.isEmpty, rule != "NONE",
let expression = try? NSRegularExpression(pattern: rule) else { return false }
let range = NSRange(parsed.url.startIndex..., in: parsed.url)
return expression.firstMatch(in: parsed.url, range: range)?.range == range
}
}
private static func parse(_ value: String) -> (url: String, origin: String?) {
guard let expression = try? NSRegularExpression(pattern: #"\s*,\s*(?=\{)"#),
let match = expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
let range = Range(match.range, in: value) else { return (value, nil) }
let url = String(value[..<range.lowerBound])
let option = String(value[range.upperBound...])
guard let data = option.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (url, nil) }
return (url, object["origin"] as? String ?? object["bookSourceUrl"] as? String)
}
}
@@ -0,0 +1,283 @@
import Foundation
public protocol BookSourceSearching: Sendable {
func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport
func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome>
}
extension BookSourceSearching {
public func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome> {
AsyncStream { continuation in
let task = Task {
let report = await search(keyword: keyword, page: 1, sources: sources)
for outcome in report.outcomes {
guard !Task.isCancelled else { break }
continuation.yield(outcome)
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
extension BookSearchService: BookSourceSearching {
public func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome> {
searchOutcomes(keyword: keyword, sources: sources)
}
}
public protocol BookDetailLoading: Sendable {
func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport
}
extension NetworkBookService: BookDetailLoading {}
public struct BookSourceCandidate: Identifiable, Sendable, Equatable {
public var book: SearchBook
public var score: Int
public var id: String { book.origin + "|" + book.bookURL }
public init(book: SearchBook, score: Int) {
self.book = book
self.score = score
}
}
public struct BookSourceSwitchReport: Sendable {
public var book: Book
public var chapters: [BookChapter]
public var diagnostics: [RuleDiagnostic]
public var exactChapterMatch: Bool
public var verifiedContent: ChapterContentReport?
public init(book: Book, chapters: [BookChapter], diagnostics: [RuleDiagnostic] = [], exactChapterMatch: Bool, verifiedContent: ChapterContentReport? = nil) {
self.book = book
self.chapters = chapters
self.diagnostics = diagnostics
self.exactChapterMatch = exactChapterMatch
self.verifiedContent = verifiedContent
}
}
public struct AvailableBookSource: Sendable {
public var report: BookSourceSwitchReport
public var source: BookSource
public init(report: BookSourceSwitchReport, source: BookSource) {
self.report = report
self.source = source
}
}
public struct BookSourceProgressMapper: Sendable {
public init() {}
public func migrate(current: Book, replacement: Book, chapters: [BookChapter], now: Int64) -> BookSourceSwitchReport {
var migrated = replacement
migrated.customTag = current.customTag
migrated.customCoverUrl = current.customCoverUrl
migrated.customIntro = current.customIntro
migrated.group = current.group
migrated.order = current.order
migrated.canUpdate = current.canUpdate
migrated.readConfig = current.readConfig
migrated.syncTime = current.syncTime
migrated.lastCheckCount = 0
migrated.lastCheckTime = now
migrated.latestChapterTime = now
migrated.totalChapterNum = chapters.count
migrated.latestChapterTitle = chapters.last(where: { !$0.isVolume })?.title ?? chapters.last?.title
let oldTitle = current.durChapterTitle ?? ""
let normalizedOldTitle = normalize(oldTitle)
let exactIndex = chapters.firstIndex { chapter in
chapter.title == oldTitle || (!normalizedOldTitle.isEmpty && normalize(chapter.title) == normalizedOldTitle)
}
let resolvedIndex: Int
if let exactIndex {
resolvedIndex = exactIndex
migrated.durChapterPos = current.durChapterPos
} else if !chapters.isEmpty {
let denominator = max(current.totalChapterNum - 1, 1)
let progress = min(max(Double(current.durChapterIndex) / Double(denominator), 0), 1)
resolvedIndex = min(Int((progress * Double(max(chapters.count - 1, 0))).rounded()), chapters.count - 1)
migrated.durChapterPos = 0
} else {
resolvedIndex = 0
migrated.durChapterPos = 0
}
migrated.durChapterIndex = resolvedIndex
migrated.durChapterTitle = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : nil
migrated.durChapterTime = current.durChapterTime
return .init(book: migrated, chapters: chapters, exactChapterMatch: exactIndex != nil)
}
private func normalize(_ value: String) -> String {
String(value.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
}).lowercased()
}
}
public actor BookSourceSwitchService {
private let searcher: any BookSourceSearching
private let detailLoader: any BookDetailLoading
private let chapterLoader: any ChapterListLoading
private let contentLoader: any ChapterContentLoading
private let chapterRepository: any BookChapterRepository
private let mapper: BookSourceProgressMapper
public init(
searcher: any BookSourceSearching = BookSearchService(),
detailLoader: any BookDetailLoading = NetworkBookService(),
chapterLoader: any ChapterListLoading = NetworkBookService(),
contentLoader: any ChapterContentLoading = NetworkBookService(),
chapterRepository: any BookChapterRepository = FileBookChapterRepository.live(),
mapper: BookSourceProgressMapper = .init()
) {
self.searcher = searcher
self.detailLoader = detailLoader
self.chapterLoader = chapterLoader
self.contentLoader = contentLoader
self.chapterRepository = chapterRepository
self.mapper = mapper
}
public func candidates(for book: Book, sources: [BookSource]) async -> [BookSourceCandidate] {
var result: [BookSourceCandidate] = []
for await update in candidateUpdates(for: book, sources: sources) {
guard !Task.isCancelled else { return result }
result = update
}
return result
}
public func candidateUpdates(for book: Book, sources: [BookSource]) -> AsyncStream<[BookSourceCandidate]> {
let eligible = sources.enumerated()
.filter { $0.element.enabled && $0.element.bookSourceUrl != book.origin && $0.element.searchUrl?.isEmpty == false }
.sorted {
if $0.element.customOrder != $1.element.customOrder { return $0.element.customOrder < $1.element.customOrder }
return $0.offset < $1.offset
}
.map(\.element)
guard !eligible.isEmpty else { return AsyncStream { $0.finish() } }
let eligibleIDs = Set(eligible.map(\.id))
let searcher = self.searcher
return AsyncStream { continuation in
let task = Task {
var found: [String: SearchBook] = [:]
for await outcome in searcher.searchStream(keyword: book.name, sources: eligible) {
guard !Task.isCancelled else { break }
for candidate in outcome.books where eligibleIDs.contains(candidate.origin) {
found[BookSourceCandidate(book: candidate, score: 0).id] = candidate
}
continuation.yield(Self.ranked(Array(found.values), for: book))
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
private nonisolated static func ranked(_ books: [SearchBook], for book: Book) -> [BookSourceCandidate] {
let normalizedName = normalize(book.name)
let normalizedAuthor = normalizeAuthor(book.author)
return books.compactMap { candidate in
guard normalize(candidate.name) == normalizedName else { return nil }
let author = normalizeAuthor(candidate.author)
var score = 100
if !normalizedAuthor.isEmpty && author == normalizedAuthor { score += 80 }
if candidate.latestChapterTitle?.isEmpty == false { score += 10 }
score -= max(candidate.originOrder, 0)
return BookSourceCandidate(book: candidate, score: score)
}
.sorted {
if $0.score != $1.score { return $0.score > $1.score }
return $0.book.originName.localizedStandardCompare($1.book.originName) == .orderedAscending
}
}
public func prepareSwitch(
current: Book,
candidate: SearchBook,
source: BookSource,
now: Int64? = nil
) async -> BookSourceSwitchReport? {
guard !Task.isCancelled else { return nil }
let detail = await detailLoader.loadDetail(searchBook: candidate, source: source)
guard !Task.isCancelled else { return nil }
let toc = await chapterLoader.loadChapters(book: detail.book, source: source, maxPages: 20)
guard !Task.isCancelled, toc.chapters.contains(where: { !$0.isVolume }) else { return nil }
var result = mapper.migrate(
current: current,
replacement: detail.book,
chapters: toc.chapters,
now: now ?? Int64(Date().timeIntervalSince1970 * 1_000)
)
result.diagnostics = detail.diagnostics + toc.diagnostics
let readableIndex = result.chapters.indices.dropFirst(result.book.durChapterIndex)
.first(where: { !result.chapters[$0].isVolume })
?? result.chapters.indices.last(where: { !result.chapters[$0].isVolume })
guard let readableIndex else { return nil }
if readableIndex != result.book.durChapterIndex {
result.book.durChapterIndex = readableIndex
result.book.durChapterTitle = result.chapters[readableIndex].title
result.book.durChapterPos = 0
}
let chapter = result.chapters[readableIndex]
let nextURL = result.chapters.dropFirst(readableIndex + 1).first(where: { !$0.isVolume })?.url
let content = await contentLoader.loadContent(
book: result.book, chapter: chapter, source: source,
nextChapterURL: nextURL, maxPages: 20
)
guard !Task.isCancelled,
!content.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
result.verifiedContent = content
result.diagnostics += content.diagnostics
do {
try await chapterRepository.save(result.chapters, bookURL: result.book.bookUrl)
} catch {
result.diagnostics.append(.init(
severity: .error,
code: "source_switch.persistence",
message: "换源目录保存失败:\(error.localizedDescription)",
rule: result.book.bookUrl
))
}
return result
}
public func firstAvailableSource(
for book: Book,
sources: [BookSource],
now: Int64? = nil
) async -> AvailableBookSource? {
var tried = Set<String>()
for await update in candidateUpdates(for: book, sources: sources) {
for candidate in update where tried.insert(candidate.id).inserted {
guard !Task.isCancelled else { return nil }
guard let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { continue }
if let report = await prepareSwitch(
current: book,
candidate: candidate.book,
source: source,
now: now
) {
return .init(report: report, source: source)
}
}
}
return nil
}
private nonisolated static func normalize(_ value: String) -> String {
String(value.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
}).lowercased()
}
private nonisolated static func normalizeAuthor(_ value: String) -> String {
normalize(value.replacingOccurrences(of: "作者", with: "").replacingOccurrences(of: "著", with: ""))
}
}
@@ -0,0 +1,139 @@
import Foundation
public protocol ChapterListLoading: Sendable {
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport
}
extension NetworkBookService: ChapterListLoading {}
public struct BookUpdateReport: Sendable {
public var book: Book
public var chapters: [BookChapter]
public var addedChapterCount: Int
public var diagnostics: [RuleDiagnostic]
public var refreshed: Bool
public init(
book: Book,
chapters: [BookChapter],
addedChapterCount: Int,
diagnostics: [RuleDiagnostic] = [],
refreshed: Bool = true
) {
self.book = book
self.chapters = chapters
self.addedChapterCount = addedChapterCount
self.diagnostics = diagnostics
self.refreshed = refreshed
}
}
public struct BookUpdateMerger: Sendable {
public init() {}
public func merge(book: Book, oldChapters: [BookChapter], newChapters: [BookChapter], now: Int64) -> BookUpdateReport {
guard !newChapters.isEmpty else {
return .init(book: book, chapters: oldChapters, addedChapterCount: 0, refreshed: false)
}
var normalized = newChapters
let metadata = Dictionary(uniqueKeysWithValues: oldChapters.map {
("\($0.index)\u{0}\($0.title)", ($0.wordCount, $0.variable, $0.imageURL))
})
for index in normalized.indices {
normalized[index].index = index
normalized[index].bookUrl = book.bookUrl
if let old = metadata["\(index)\u{0}\(normalized[index].title)"] {
if let wordCount = old.0 { normalized[index].wordCount = wordCount }
if let variable = old.1 { normalized[index].variable = variable }
if let imageURL = old.2 { normalized[index].imageURL = imageURL }
}
}
var updated = book
let previousTotal = max(book.totalChapterNum, oldChapters.count)
let added = max(normalized.count - previousTotal, 0)
if added > 0 {
updated.lastCheckCount = added
updated.latestChapterTime = now
}
updated.lastCheckTime = now
updated.totalChapterNum = normalized.count
updated.latestChapterTitle = normalized.last(where: { !$0.isVolume })?.title ?? normalized.last?.title
let newReadIndex = resolveReadIndex(book: book, chapters: normalized)
updated.durChapterIndex = newReadIndex
if let oldTitle = book.durChapterTitle, !oldTitle.isEmpty,
normalized.indices.contains(newReadIndex),
normalize(normalized[newReadIndex].title) != normalize(oldTitle) {
updated.durChapterPos = 0
}
updated.durChapterTitle = normalized.indices.contains(newReadIndex) ? normalized[newReadIndex].title : updated.durChapterTitle
return .init(book: updated, chapters: normalized, addedChapterCount: added)
}
private func resolveReadIndex(book: Book, chapters: [BookChapter]) -> Int {
if let title = book.durChapterTitle, !title.isEmpty {
if let exact = chapters.firstIndex(where: { $0.title == title }) { return exact }
let normalizedTitle = normalize(title)
if let close = chapters.firstIndex(where: { normalize($0.title) == normalizedTitle }) { return close }
}
return min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
}
private func normalize(_ title: String) -> String {
String(title.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
}).lowercased()
}
}
public actor BookUpdateCoordinator {
private let repository: any BookChapterRepository
private let loader: any ChapterListLoading
private let merger: BookUpdateMerger
public init(
repository: any BookChapterRepository = FileBookChapterRepository.live(),
loader: any ChapterListLoading = NetworkBookService(),
merger: BookUpdateMerger = .init()
) {
self.repository = repository
self.loader = loader
self.merger = merger
}
public func storedChapters(bookURL: String) async -> [BookChapter] {
(try? await repository.load(bookURL: bookURL)) ?? []
}
public func refresh(book: Book, source: BookSource, now: Int64? = nil, maxPages: Int = 20) async -> BookUpdateReport {
let old = (try? await repository.load(bookURL: book.bookUrl)) ?? []
let loaded = await loader.loadChapters(book: book, source: source, maxPages: maxPages)
guard !loaded.chapters.isEmpty else {
return .init(
book: book,
chapters: old,
addedChapterCount: 0,
diagnostics: loaded.diagnostics,
refreshed: false
)
}
var result = merger.merge(
book: book,
oldChapters: old,
newChapters: loaded.chapters,
now: now ?? Int64(Date().timeIntervalSince1970 * 1_000)
)
result.diagnostics = loaded.diagnostics
do {
try await repository.save(result.chapters, bookURL: result.book.bookUrl)
} catch {
result.diagnostics.append(.init(
severity: .error,
code: "toc.persistence",
message: "目录保存失败:\(error.localizedDescription)",
rule: book.bookUrl
))
}
return result
}
}
@@ -0,0 +1,278 @@
import CryptoKit
import Foundation
public struct CachedChapterContent: Codable, Equatable, Sendable {
public var bookURL: String
public var chapterURL: String
public var title: String?
public var content: String
public var supplementalContent: String?
public var savedAt: Int64
public var sourceID: String?
public init(
bookURL: String,
chapterURL: String,
title: String? = nil,
content: String,
supplementalContent: String? = nil,
savedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
sourceID: String? = nil
) {
self.bookURL = bookURL
self.chapterURL = chapterURL
self.title = title
self.content = content
self.supplementalContent = supplementalContent
self.savedAt = savedAt
self.sourceID = sourceID
}
}
public enum ChapterContentCacheError: Error, LocalizedError, Equatable {
case entryTooLarge(Int)
case invalidEntry
public var errorDescription: String? {
switch self {
case .entryTooLarge(let maximum): "章节正文超过缓存上限 \(maximum / 1_024 / 1_024) MB"
case .invalidEntry: "章节缓存内容无效"
}
}
}
public actor ChapterContentCache {
public static let maximumEntryBytes = 8 * 1_024 * 1_024
public static let defaultMaximumTotalBytes = 256 * 1_024 * 1_024
private let directory: URL
private let maximumTotalBytes: Int?
public init(directory: URL, maximumTotalBytes: Int? = nil) {
self.directory = directory
self.maximumTotalBytes = maximumTotalBytes.map { max($0, Self.maximumEntryBytes) }
}
public static func live() -> ChapterContentCache {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/Chapters", isDirectory: true)
return ChapterContentCache(directory: base)
}
public func load(bookURL: String, chapterURL: String) throws -> CachedChapterContent? {
let url = fileURL(bookURL: bookURL, chapterURL: chapterURL)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
do {
let value = try JSONDecoder().decode(CachedChapterContent.self, from: Data(contentsOf: url, options: .mappedIfSafe))
guard value.bookURL == bookURL, value.chapterURL == chapterURL, !value.content.isEmpty else {
try? FileManager.default.removeItem(at: url)
throw ChapterContentCacheError.invalidEntry
}
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
return value
} catch let error as ChapterContentCacheError {
throw error
} catch {
try? FileManager.default.removeItem(at: url)
return nil
}
}
public func save(_ entry: CachedChapterContent) throws {
guard !entry.bookURL.isEmpty, !entry.chapterURL.isEmpty, !entry.content.isEmpty else {
throw ChapterContentCacheError.invalidEntry
}
let encoder = JSONEncoder()
let data = try encoder.encode(entry)
guard data.count <= Self.maximumEntryBytes else {
throw ChapterContentCacheError.entryTooLarge(Self.maximumEntryBytes)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: fileURL(bookURL: entry.bookURL, chapterURL: entry.chapterURL), options: .atomic)
try pruneIfNeeded()
}
public func remove(bookURL: String) throws {
guard FileManager.default.fileExists(atPath: directory.path) else { return }
let prefix = digest(bookURL) + "-"
for url in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
where url.lastPathComponent.hasPrefix(prefix) {
try FileManager.default.removeItem(at: url)
}
}
public func remove(bookURL: String, chapterURL: String) throws {
let url = fileURL(bookURL: bookURL, chapterURL: chapterURL)
guard FileManager.default.fileExists(atPath: url.path) else { return }
try FileManager.default.removeItem(at: url)
}
public func removeAll() throws {
guard FileManager.default.fileExists(atPath: directory.path) else { return }
try FileManager.default.removeItem(at: directory)
}
public func statistics() throws -> (entries: Int, bytes: Int) {
let files = try cacheFiles()
return (files.count, files.reduce(0) { $0 + $1.size })
}
public func pruneIfNeeded() throws {
let maximumTotalBytes = maximumTotalBytes ?? ApplicationSettingsStore.shared.settings.chapterCacheMB * 1024 * 1024
var files = try cacheFiles().sorted { $0.modified < $1.modified }
var total = files.reduce(0) { $0 + $1.size }
while total > maximumTotalBytes, let oldest = files.first {
try FileManager.default.removeItem(at: oldest.url)
total -= oldest.size
files.removeFirst()
}
}
private func cacheFiles() throws -> [(url: URL, size: Int, modified: Date)] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
return try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
).compactMap { url in
guard url.pathExtension == "json" else { return nil }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
return (url, values.fileSize ?? 0, values.contentModificationDate ?? .distantPast)
}
}
private func fileURL(bookURL: String, chapterURL: String) -> URL {
directory.appendingPathComponent("\(digest(bookURL))-\(digest(chapterURL)).json")
}
private func digest(_ value: String) -> String {
SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
}
}
public protocol ChapterContentLoading: Sendable {
func loadContent(
book: Book,
chapter: BookChapter,
source: BookSource,
nextChapterURL: String?,
maxPages: Int
) async -> ChapterContentReport
}
extension NetworkBookService: ChapterContentLoading {}
public struct ChapterCacheJob: Sendable, Equatable {
public var chapter: BookChapter
public var nextChapterURL: String?
public init(chapter: BookChapter, nextChapterURL: String?) {
self.chapter = chapter
self.nextChapterURL = nextChapterURL
}
}
public enum ChapterCachePlanner {
public static func plan(chapters: [BookChapter], startIndex: Int, limit: Int? = nil) -> [ChapterCacheJob] {
guard !chapters.isEmpty, limit.map({ $0 > 0 }) ?? true else { return [] }
let start = min(max(startIndex, 0), chapters.count - 1)
var jobs: [ChapterCacheJob] = []
for index in chapters.indices where index >= start && !chapters[index].isVolume {
let nextURL = chapters[(index + 1)...].first(where: { !$0.isVolume })?.url
jobs.append(.init(chapter: chapters[index], nextChapterURL: nextURL))
if let limit, jobs.count >= limit { break }
}
return jobs
}
}
public struct CoordinatedChapterContent: Sendable {
public var report: ChapterContentReport
public var loadedFromCache: Bool
public init(report: ChapterContentReport, loadedFromCache: Bool) {
self.report = report
self.loadedFromCache = loadedFromCache
}
}
public actor ChapterContentCoordinator {
private let loader: any ChapterContentLoading
private let cache: ChapterContentCache
public init(loader: any ChapterContentLoading = NetworkBookService(), cache: ChapterContentCache = .live()) {
self.loader = loader
self.cache = cache
}
public func load(
book: Book,
chapter: BookChapter,
source: BookSource,
nextChapterURL: String? = nil,
maxPages: Int = 20,
forceRefresh: Bool = false
) async -> CoordinatedChapterContent {
if forceRefresh {
try? await cache.remove(bookURL: book.bookUrl, chapterURL: chapter.url)
} else if let cached = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url),
cached.sourceID == nil || cached.sourceID == source.id {
return .init(report: .init(
title: cached.title,
content: cached.content,
supplementalContent: cached.supplementalContent
), loadedFromCache: true)
}
let report = await loader.loadContent(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextChapterURL,
maxPages: maxPages
)
await saveContent(report, book: book, chapter: chapter, source: source)
return .init(report: report, loadedFromCache: false)
}
public func saveContent(_ report: ChapterContentReport, book: Book, chapter: BookChapter, source: BookSource) async {
if !report.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
try? await cache.save(.init(
bookURL: book.bookUrl,
chapterURL: chapter.url,
title: report.title,
content: report.content,
supplementalContent: report.supplementalContent,
sourceID: source.id
))
}
}
public func saveEditedContent(book: Book, chapter: BookChapter, title: String?, content: String) async throws {
let value = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { throw ChapterContentCacheError.invalidEntry }
try await cache.save(.init(
bookURL: book.bookUrl,
chapterURL: chapter.url,
title: title,
content: content,
sourceID: book.origin
))
}
@discardableResult
public func prefetch(
book: Book,
chapter: BookChapter,
source: BookSource,
nextChapterURL: String? = nil
) async -> Bool {
if let cached = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url),
cached.sourceID == nil || cached.sourceID == source.id { return false }
let result = await load(book: book, chapter: chapter, source: source, nextChapterURL: nextChapterURL)
return !result.report.content.isEmpty
}
public func invalidate(bookURL: String) async {
try? await cache.remove(bookURL: bookURL)
}
}
@@ -0,0 +1,199 @@
import Foundation
import SwiftSoup
public struct MetadataTextSanitizer: Sendable {
public init() {}
public func clean(_ value: String?) -> String? {
guard var value else { return nil }
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
do {
value = replacingBlockTagsWithNewlines(value)
let document = try SwiftSoup.parseBodyFragment(value)
try document.select("script,style,noscript").remove()
value = try document.body()?.text(trimAndNormaliseWhitespace: false) ?? document.text()
} catch {
value = value.replacingOccurrences(
of: #"<[^>]+>"#,
with: "",
options: [.regularExpression, .caseInsensitive]
)
value = (try? Parser.unescapeEntities(value, false)) ?? value
}
value = value
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
.replacingOccurrences(of: "\u{00A0}", with: " ")
.replacingOccurrences(of: "\u{200B}", with: "")
let output = value.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
let result = output.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return result.isEmpty ? nil : result
}
private func replacingBlockTagsWithNewlines(_ value: String) -> String {
value.replacingOccurrences(
of: #"(?i)<\s*br\s*/?\s*>|<\s*/?\s*(?:p|div|li|ul|ol|section|article|blockquote|h[1-6])\b[^>]*>"#,
with: "\n",
options: .regularExpression
)
}
}
struct ChapterHTMLFormatter {
func format(_ html: String, baseURL: URL) -> String {
guard let expression = try? NSRegularExpression(pattern: #"(?i)<img\b[^>]*>"#) else {
return MetadataTextSanitizer().clean(html) ?? ""
}
var protected = html
var images: [String: String] = [:]
let token = UUID().uuidString
for (index, match) in expression.matches(in: html, range: NSRange(html.startIndex..., in: html)).enumerated().reversed() {
guard let range = Range(match.range, in: html),
let element = try? SwiftSoup.parseBodyFragment(String(html[range])).select("img").first() else { continue }
let src = ["data-src", "data-original", "data-srcset", "src"]
.compactMap { try? element.attr($0) }.first { !$0.isEmpty } ?? ""
let marker = "LEGADO_IMAGE_\(token)_\(index)"
let url = LegadoURLBuilder.resolve(src, against: baseURL)
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
images[marker] = src.isEmpty ? "" : "<img src=\"\(url)\">"
protected.replaceSubrange(range, with: "\n\(marker)\n")
}
var result = MetadataTextSanitizer().clean(protected) ?? ""
for (marker, image) in images { result = result.replacingOccurrences(of: marker, with: image) }
return result
}
}
public struct ContentProcessingReport: Sendable {
public var title: String
public var content: String
public var effectiveRuleIDs: [Int64]
public var removedDuplicateTitle: Bool
public var diagnostics: [RuleDiagnostic]
public init(
title: String,
content: String,
effectiveRuleIDs: [Int64] = [],
removedDuplicateTitle: Bool = false,
diagnostics: [RuleDiagnostic] = []
) {
self.title = title
self.content = content
self.effectiveRuleIDs = effectiveRuleIDs
self.removedDuplicateTitle = removedDuplicateTitle
self.diagnostics = diagnostics
}
}
public struct ContentSanitizer: Sendable {
private let regex: RegexEvaluator
public init(regex: RegexEvaluator = .init()) {
self.regex = regex
}
public func process(
book: Book,
chapter: BookChapter,
content: String,
rules: [ReplaceRule],
removeDuplicateTitle: Bool = true,
resegment: Bool = true
) -> ContentProcessingReport {
let ordered = rules.filter(\.isEnabled).sorted { $0.sortOrder == $1.sortOrder ? $0.id < $1.id : $0.sortOrder < $1.sortOrder }
var title = chapter.title
var body = content
var effective: [Int64] = []
var diagnostics: [RuleDiagnostic] = []
for rule in ordered where rule.scopeTitle && applies(rule, to: book) {
let result = apply(rule, to: title)
diagnostics += result.diagnostics
if result.value != title {
title = result.value
effective.append(rule.id)
}
}
var removed = false
if removeDuplicateTitle {
let lines = body.components(separatedBy: .newlines)
if let firstIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
let normalizedLine = normalize(lines[firstIndex])
let normalizedTitles = [chapter.title, title, book.name + chapter.title].map(normalize)
if normalizedTitles.contains(normalizedLine) {
var updated = lines
updated.remove(at: firstIndex)
body = updated.joined(separator: "\n")
removed = true
}
}
}
if resegment {
body = body.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: " "))) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
}
for rule in ordered where rule.scopeContent && applies(rule, to: book) {
let result = apply(rule, to: body)
diagnostics += result.diagnostics
if result.value != body {
body = result.value
effective.append(rule.id)
}
}
var seen = Set<Int64>()
effective = effective.filter { seen.insert($0).inserted }
return .init(
title: title,
content: body,
effectiveRuleIDs: effective,
removedDuplicateTitle: removed,
diagnostics: diagnostics
)
}
private func applies(_ rule: ReplaceRule, to book: Book) -> Bool {
if let excluded = rule.excludeScope, !excluded.isEmpty,
(!book.name.isEmpty && excluded.contains(book.name)) || (!book.origin.isEmpty && excluded.contains(book.origin)) { return false }
guard let scope = rule.scope, !scope.isEmpty else { return true }
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]) {
guard !rule.pattern.isEmpty else { return (input, []) }
do {
if rule.isRegex {
return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
}
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), [])
} catch let error as RuleEngineError {
return (input, [error.diagnostic])
} catch {
return (input, [.init(
severity: .error,
code: "replace.failed",
message: "净化规则“\(rule.name)”执行失败:\(error.localizedDescription)",
rule: rule.pattern
)])
}
}
private func normalize(_ value: String) -> String {
value.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
}.map(String.init).joined().lowercased()
}
}
@@ -0,0 +1,496 @@
import Foundation
public struct BookDetailReport: Sendable {
public var book: Book
public var diagnostics: [RuleDiagnostic]
public init(book: Book, diagnostics: [RuleDiagnostic] = []) {
self.book = book
self.diagnostics = diagnostics
}
}
public struct ChapterListReport: Sendable {
public var chapters: [BookChapter]
public var diagnostics: [RuleDiagnostic]
public var pagesVisited: Int
public init(chapters: [BookChapter], diagnostics: [RuleDiagnostic] = [], pagesVisited: Int = 0) {
self.chapters = chapters
self.diagnostics = diagnostics
self.pagesVisited = pagesVisited
}
}
public struct ChapterContentReport: Sendable {
public var title: String?
public var content: String
public var supplementalContent: String?
public var diagnostics: [RuleDiagnostic]
public var pagesVisited: Int
public init(
title: String? = nil,
content: String = "",
supplementalContent: String? = nil,
diagnostics: [RuleDiagnostic] = [],
pagesVisited: Int = 0
) {
self.title = title
self.content = content
self.supplementalContent = supplementalContent
self.diagnostics = diagnostics
self.pagesVisited = pagesVisited
}
}
public struct NetworkBookService: Sendable {
private let transport: any HTTPTransport
private let urlBuilder: LegadoURLBuilder
private let engine: RuleEngine
private let networkPolicy: SourceNetworkPolicy
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
private let snapshotRecorder: (any DifferentialSnapshotRecording)?
public init(
transport: any HTTPTransport = URLSessionHTTPTransport(),
urlBuilder: LegadoURLBuilder = .init(),
engine: RuleEngine = .init(),
networkPolicy: SourceNetworkPolicy = .shared,
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil
) {
self.transport = transport
self.urlBuilder = urlBuilder
self.engine = engine
self.networkPolicy = networkPolicy
self.webViewExecutor = webViewExecutor
self.snapshotRecorder = snapshotRecorder
}
public func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport {
var book = Book(
bookUrl: searchBook.bookURL,
name: searchBook.name,
author: searchBook.author,
origin: source.bookSourceUrl
)
book.originName = source.bookSourceName
book.type = searchBook.type
book.kind = searchBook.kind
book.coverUrl = searchBook.coverURL
book.intro = MetadataTextSanitizer().clean(searchBook.intro)
book.wordCount = searchBook.wordCount
book.latestChapterTitle = searchBook.latestChapterTitle
book.tocUrl = searchBook.tocURL
book.originOrder = searchBook.originOrder
book.variable = encodeVariables(searchBook.variables)
guard let rules = source.ruleBookInfo else {
return .init(book: book, diagnostics: [diagnostic(
code: "detail.configuration",
message: "书源缺少详情规则",
rule: searchBook.bookURL
)])
}
do {
let response = try await request(
searchBook.bookURL,
source: source,
stage: .detail,
context: .init(variables: searchBook.variables, source: source, book: book)
)
var document = PageDocument(response: response)
var diagnostics: [RuleDiagnostic] = []
var execution = RuleExecutionContext(
variables: searchBook.variables.merging(response.requestVariables) { _, requestValue in requestValue },
baseURL: response.finalURL.absoluteString,
source: source,
book: book
)
if let initialRule = rules.`init`, !initialRule.isEmpty {
let initial = await evaluate(initialRule, in: document, listMode: true, context: execution)
diagnostics += initial.diagnostics
execution.variables = initial.variables
if let first = initial.values.first {
document = .init(value: first, baseURL: document.baseURL, isFragment: true)
}
}
let name = await evaluate(rules.name, in: document, context: execution)
execution.variables = name.variables
let author = await evaluate(rules.author, in: document, context: execution)
execution.variables = author.variables
let kind = await evaluate(rules.kind, in: document, context: execution)
execution.variables = kind.variables
let intro = await evaluate(rules.intro, in: document, context: execution)
execution.variables = intro.variables
let lastChapter = await evaluate(rules.lastChapter, in: document, context: execution)
execution.variables = lastChapter.variables
let cover = await evaluate(rules.coverUrl, in: document, context: execution)
execution.variables = cover.variables
let toc = await evaluate(rules.tocUrl, in: document, context: execution)
execution.variables = toc.variables
let wordCount = await evaluate(rules.wordCount, in: document, context: execution)
execution.variables = wordCount.variables
let evaluations = [name, author, kind, intro, lastChapter, cover, toc, wordCount]
diagnostics += evaluations.flatMap(\.diagnostics)
book.name = nonempty(name.joinedString) ?? book.name
book.author = nonempty(author.joinedString) ?? book.author
book.kind = nonempty(kind.strings.filter { !$0.isEmpty }.joined(separator: ",")) ?? book.kind
book.intro = MetadataTextSanitizer().clean(intro.joinedString) ?? book.intro
book.latestChapterTitle = nonempty(lastChapter.joinedString) ?? book.latestChapterTitle
book.wordCount = nonempty(wordCount.joinedString) ?? book.wordCount
if let value = nonempty(cover.joinedString) {
book.coverUrl = resolve(value, against: response.finalURL)
}
let tocValue = document.isHTML ? toc.leadingString : toc.joinedString
book.tocUrl = nonempty(tocValue).map { resolve($0, against: response.finalURL) }
?? nonempty(book.tocUrl)
?? response.finalURL.absoluteString
book.variable = encodeVariables(execution.variables)
return .init(book: book, diagnostics: diagnostics)
} catch {
return .init(book: book, diagnostics: [diagnostic(error, stage: "detail", rule: searchBook.bookURL)])
}
}
public func loadChapters(
book: Book,
source: BookSource,
maxPages: Int = 20
) async -> ChapterListReport {
guard let rules = source.ruleToc,
var listRule = nonempty(rules.chapterList) else {
return .init(chapters: [], diagnostics: [diagnostic(
code: "toc.configuration",
message: "书源缺少目录列表规则",
rule: book.tocUrl
)])
}
let reverse = listRule.hasPrefix("-")
if listRule.hasPrefix("-") || listRule.hasPrefix("+") { listRule.removeFirst() }
let firstURL = nonempty(book.tocUrl) ?? book.bookUrl
var queue = [firstURL]
var visited = Set<String>()
var requested = Set<String>()
var chapters: [BookChapter] = []
var diagnostics: [RuleDiagnostic] = []
var sharedContext = RuleExecutionContext(
variables: decodeVariables(book.variable),
source: source,
book: book
)
while let pageURL = queue.first, visited.count < max(maxPages, 1) {
queue.removeFirst()
guard requested.insert(pageURL).inserted else { continue }
do {
let response = try await request(
pageURL,
source: source,
stage: .toc,
baseURL: URL(string: book.bookUrl),
context: .init(variables: sharedContext.variables, source: source, book: book)
)
let canonicalURL = response.finalURL.absoluteString
guard visited.insert(pageIdentity(pageURL, finalURL: canonicalURL)).inserted else { continue }
let document = PageDocument(response: response)
var pageContext = sharedContext
pageContext.variables.merge(response.requestVariables) { _, requestValue in requestValue }
pageContext.baseURL = document.baseURL
let list = await evaluate(listRule, in: document, listMode: true, context: pageContext)
diagnostics += list.diagnostics
pageContext.variables = list.variables
for item in list.listItems(rule: listRule) {
try Task.checkCancellation()
let fragment = PageDocument(value: item, baseURL: document.baseURL, isFragment: true)
var itemContext = pageContext
let name = await evaluate(rules.chapterName, in: fragment, context: itemContext)
itemContext.variables = name.variables
let url = await evaluate(rules.chapterUrl, in: fragment, context: itemContext)
itemContext.variables = url.variables
let volume = await evaluate(rules.isVolume, in: fragment, context: itemContext)
itemContext.variables = volume.variables
let vip = await evaluate(rules.isVip, in: fragment, context: itemContext)
itemContext.variables = vip.variables
let pay = await evaluate(rules.isPay, in: fragment, context: itemContext)
itemContext.variables = pay.variables
let info = await evaluate(rules.updateTime, in: fragment, context: itemContext)
itemContext.variables = info.variables
diagnostics += [name, url, volume, vip, pay, info].flatMap(\.diagnostics)
guard let title = nonempty(name.joinedString) else { continue }
let isVolume = truthy(volume.joinedString)
let rawURL = nonempty(fragment.isHTML ? url.leadingString : url.joinedString)
?? (isVolume ? "#volume-\(chapters.count)" : canonicalURL)
var chapter = BookChapter(
url: resolve(rawURL, against: response.finalURL),
title: title,
bookUrl: book.bookUrl,
index: chapters.count
)
chapter.baseUrl = canonicalURL
chapter.isVolume = isVolume
chapter.isVip = truthy(vip.joinedString)
chapter.isPay = truthy(pay.joinedString)
chapter.tag = nonempty(info.joinedString)
chapter.variable = encodeVariables(itemContext.variables)
chapters.append(chapter)
}
let next = await evaluate(rules.nextTocUrl, in: document, context: pageContext)
diagnostics += next.diagnostics
sharedContext.variables = next.variables
for value in next.strings where !value.isEmpty {
let absolute = resolve(value, against: response.finalURL)
if !requested.contains(absolute), !queue.contains(absolute) { queue.append(absolute) }
}
} catch is CancellationError {
diagnostics.append(diagnostic(code: "toc.cancelled", message: "目录加载已取消", rule: pageURL))
break
} catch {
diagnostics.append(diagnostic(error, stage: "toc", rule: pageURL))
}
}
if reverse { chapters.reverse() }
var unique = Set<String>()
chapters = chapters.filter { unique.insert($0.url).inserted }
for index in chapters.indices { chapters[index].index = index }
if chapters.isEmpty {
diagnostics.append(diagnostic(code: "toc.empty", message: "目录规则未解析到章节", rule: listRule))
}
return .init(chapters: chapters, diagnostics: diagnostics, pagesVisited: visited.count)
}
public func loadContent(
book: Book,
chapter: BookChapter,
source: BookSource,
nextChapterURL: String? = nil,
maxPages: Int = 20
) async -> ChapterContentReport {
guard let rules = source.ruleContent,
let contentRule = nonempty(rules.content) else {
return .init(diagnostics: [diagnostic(
code: "content.configuration",
message: "书源缺少正文规则",
rule: chapter.url
)])
}
var queue = [chapter.url]
var visited = Set<String>()
var requested = Set<String>()
var contents: [String] = []
var supplemental: String?
var title: String?
var diagnostics: [RuleDiagnostic] = []
var contentVariables = decodeVariables(book.variable)
contentVariables.merge(decodeVariables(chapter.variable)) { _, chapterValue in chapterValue }
let stopURL = nextChapterURL.map { value in
URL(string: chapter.baseUrl).map { resolve(value, against: $0) } ?? value
}
while let pageURL = queue.first, visited.count < max(maxPages, 1) {
queue.removeFirst()
guard pageURL != stopURL, requested.insert(pageURL).inserted else { continue }
do {
let response = try await request(
pageURL,
source: source,
stage: .content,
baseURL: URL(string: chapter.baseUrl),
context: .init(
variables: contentVariables,
source: source,
book: book,
chapter: chapter
)
)
let canonicalURL = response.finalURL.absoluteString
guard canonicalURL != stopURL,
visited.insert(pageIdentity(pageURL, finalURL: canonicalURL)).inserted else { continue }
let document = PageDocument(response: response)
var execution = RuleExecutionContext(
variables: contentVariables.merging(response.requestVariables) { _, requestValue in requestValue },
baseURL: canonicalURL,
source: source,
book: book,
chapter: chapter
)
let content = await evaluate(contentRule, in: document, context: execution)
diagnostics += content.diagnostics
execution.variables = content.variables
contentVariables = execution.variables
var pageContent = content.strings.filter { !$0.isEmpty }.joined(separator: "\n")
if source.bookSourceType == 0 {
pageContent = ChapterHTMLFormatter().format(pageContent, baseURL: response.finalURL)
}
if !pageContent.isEmpty { contents.append(pageContent) }
if title == nil {
let value = await evaluate(rules.title, in: document, context: execution)
diagnostics += value.diagnostics
execution.variables = value.variables
contentVariables = execution.variables
title = nonempty(value.joinedString)
}
if supplemental == nil {
let value = await evaluate(rules.subContent, in: document, context: execution)
diagnostics += value.diagnostics
execution.variables = value.variables
contentVariables = execution.variables
supplemental = nonempty(value.strings.filter { !$0.isEmpty }.joined(separator: "\n"))
}
let next = await evaluate(rules.nextContentUrl, in: document, context: execution)
diagnostics += next.diagnostics
contentVariables = next.variables
for value in next.strings where !value.isEmpty {
let absolute = resolve(value, against: response.finalURL)
if absolute != stopURL, !requested.contains(absolute), !queue.contains(absolute) {
queue.append(absolute)
}
}
} catch is CancellationError {
diagnostics.append(diagnostic(code: "content.cancelled", message: "正文加载已取消", rule: pageURL))
break
} catch {
diagnostics.append(diagnostic(error, stage: "content", rule: pageURL))
}
}
var content = contents.joined(separator: "\n")
if let replacement = nonempty(rules.replaceRegex) {
let replaced = await engine.evaluate(
replacement, inText: content,
context: .init(variables: contentVariables, baseURL: chapter.baseUrl, source: source, book: book, chapter: chapter)
)
diagnostics += replaced.diagnostics
if !replaced.diagnostics.contains(where: { $0.severity == .error }) {
content = replaced.strings.joined(separator: "\n")
}
}
if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !chapter.isVolume {
diagnostics.append(diagnostic(code: "content.empty", message: "正文规则未解析到内容", rule: contentRule))
}
return .init(
title: title,
content: content,
supplementalContent: supplemental,
diagnostics: diagnostics,
pagesVisited: visited.count
)
}
private func request(
_ value: String,
source: BookSource,
stage: DifferentialStage,
baseURL: URL? = nil,
context: URLTemplateContext? = nil
) async throws -> HTTPResponse {
let prepared = try urlBuilder.prepare(
value,
context: context ?? .init(source: source),
baseURL: baseURL ?? URL(string: source.bookSourceUrl),
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
)
let timeout = min(max(Double(source.respondTime) / 1_000, 5), 180)
return try await networkPolicy.execute(
prepared,
source: source,
transport: transport,
timeout: timeout,
webViewExecutor: webViewExecutor,
snapshotRecorder: snapshotRecorder,
snapshotStage: stage
)
}
private func evaluate(
_ rule: String?,
in document: PageDocument,
listMode: Bool = false,
context: RuleExecutionContext = .init()
) async -> RuleEvaluation {
guard let rule = nonempty(rule) else { return .init(variables: context.variables) }
var context = context
context.isHTMLFragment = document.isFragment
if document.isHTML {
return await engine.evaluate(
rule,
inText: document.value.stringValue,
baseURL: document.baseURL,
listMode: listMode,
context: context
)
}
return await engine.evaluate(rule, in: document.value, listMode: listMode, context: context)
}
private func resolve(_ value: String, against baseURL: URL) -> String {
LegadoURLBuilder.resolve(value, against: baseURL)
}
private func pageIdentity(_ request: String, finalURL: String) -> String {
guard let range = request.range(of: #",\s*(?=\{)"#, options: .regularExpression) else { return finalURL }
return finalURL + request[range.lowerBound...]
}
private func truthy(_ value: String?) -> Bool {
guard let value else { return false }
return ["true", "1", "yes", "是", "vip", "paid"].contains(value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
}
private func encodeVariables(_ variables: [String: String]) -> String? {
guard !variables.isEmpty,
let data = try? JSONEncoder().encode(variables) else { return nil }
return String(data: data, encoding: .utf8)
}
private func decodeVariables(_ source: String?) -> [String: String] {
guard let source, let data = source.data(using: .utf8),
let variables = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
return variables
}
private func nonempty(_ value: String?) -> String? {
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return value
}
private func diagnostic(_ error: Error, stage: String, rule: String) -> RuleDiagnostic {
if let error = error as? RuleEngineError { return error.diagnostic }
return diagnostic(code: "\(stage).request", message: error.localizedDescription, rule: rule)
}
private func diagnostic(code: String, message: String, rule: String) -> RuleDiagnostic {
.init(severity: .error, code: code, message: message, rule: rule)
}
private struct PageDocument: Sendable {
var value: JSONValue
var isHTML: Bool
var baseURL: String
var isFragment = false
init(response: HTTPResponse) {
if let json = response.decodedJSON {
value = json
isHTML = false
} else {
value = .string(response.decodedText)
isHTML = true
}
baseURL = response.finalURL.absoluteString
}
init(value: JSONValue, baseURL: String, isFragment: Bool = false) {
self.value = value
if case .string = value { self.isHTML = true }
else { self.isHTML = false }
self.baseURL = baseURL
self.isFragment = isFragment
}
}
}
@@ -0,0 +1,33 @@
import Foundation
public enum ReplaceRuleValidator {
public static func validate(_ rule: ReplaceRule) throws {
guard !rule.pattern.isEmpty else { throw ReplaceRuleImportError.emptyPattern(rule.name) }
guard rule.scopeTitle || rule.scopeContent else { throw ReplaceRuleImportError.invalidDocument("至少选择标题或正文一个作用域") }
if rule.isRegex {
do { _ = try NSRegularExpression(pattern: rule.pattern) }
catch { throw ReplaceRuleImportError.invalidDocument("正则语法错误:\(error.localizedDescription)") }
if rule.pattern.hasSuffix("|"), !rule.pattern.hasSuffix("\\|") {
throw ReplaceRuleImportError.invalidDocument("正则不能以空分支 | 结尾")
}
}
}
}
public struct ReplaceRuleTestResult: Sendable {
public let report: ContentProcessingReport
public let elapsedMilliseconds: Double
}
public struct ReplaceRuleTester: Sendable {
public init() {}
public func test(rule: ReplaceRule, title: String, content: String, bookName: String, sourceURL: String) throws -> ReplaceRuleTestResult {
try ReplaceRuleValidator.validate(rule)
let book = Book(bookUrl: "test", name: bookName, origin: sourceURL)
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 0)
let started = DispatchTime.now().uptimeNanoseconds
let report = ContentSanitizer().process(book: book, chapter: chapter, content: content,
rules: [rule], removeDuplicateTitle: false, resegment: false)
return .init(report: report, elapsedMilliseconds: Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000)
}
}
@@ -0,0 +1,200 @@
import Foundation
public enum SourceDebugStage: String, Codable, CaseIterable, Sendable {
case configuration
case search
case detail
case toc
case content
public var title: String {
switch self {
case .configuration: "配置"
case .search: "搜索"
case .detail: "详情"
case .toc: "目录"
case .content: "正文"
}
}
}
public enum SourceDebugStatus: String, Codable, Sendable {
case passed
case warning
case failed
case skipped
}
public struct SourceDebugStep: Codable, Hashable, Identifiable, Sendable {
public var stage: SourceDebugStage
public var status: SourceDebugStatus
public var summary: String
public var elapsedMilliseconds: Int
public var diagnostics: [RuleDiagnostic]
public var id: SourceDebugStage { stage }
public init(
stage: SourceDebugStage,
status: SourceDebugStatus,
summary: String,
elapsedMilliseconds: Int = 0,
diagnostics: [RuleDiagnostic] = []
) {
self.stage = stage
self.status = status
self.summary = summary
self.elapsedMilliseconds = elapsedMilliseconds
self.diagnostics = diagnostics
}
}
public struct BookSourceDebugReport: Codable, Sendable {
public var sourceID: String
public var keyword: String
public var steps: [SourceDebugStep]
public var sampleBookName: String?
public var sampleChapterTitle: String?
public var contentPreview: String?
public var passed: Bool {
!steps.contains { $0.status == .failed }
&& steps.contains { $0.stage == .content && $0.status != .skipped }
}
public init(sourceID: String, keyword: String, steps: [SourceDebugStep] = []) {
self.sourceID = sourceID
self.keyword = keyword
self.steps = steps
}
}
public struct BookSourceDebugger: Sendable {
private let searchService: BookSearchService
private let bookService: NetworkBookService
public init(transport: any HTTPTransport = URLSessionHTTPTransport()) {
searchService = BookSearchService(transport: transport)
bookService = NetworkBookService(transport: transport)
}
public func run(source: BookSource, keyword: String) async -> BookSourceDebugReport {
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
var report = BookSourceDebugReport(sourceID: source.id, keyword: keyword)
let missing = missingConfiguration(source)
report.steps.append(.init(
stage: .configuration,
status: missing.isEmpty ? .passed : .failed,
summary: missing.isEmpty ? "搜索、详情、目录、正文规则均已配置" : "缺少:\(missing.joined(separator: "、"))",
diagnostics: missing.map {
.init(severity: .error, code: "debug.configuration", message: "缺少\($0)配置", rule: source.bookSourceName)
}
))
guard !keyword.isEmpty else {
appendSkipped(after: .configuration, to: &report, reason: "请输入调试关键词")
return report
}
let searchStart = ProcessInfo.processInfo.systemUptime
let search = await searchService.search(keyword: keyword, page: 1, source: source)
let searchStep = step(
stage: .search,
hasOutput: !search.books.isEmpty,
summary: search.books.isEmpty ? "没有解析到搜索结果" : "解析到 \(search.books.count) 条结果",
diagnostics: search.diagnostics,
started: searchStart
)
report.steps.append(searchStep)
guard let searchBook = search.books.first else {
appendSkipped(after: .search, to: &report, reason: "搜索阶段没有样本书籍")
return report
}
report.sampleBookName = searchBook.name
let detailStart = ProcessInfo.processInfo.systemUptime
let detail = await bookService.loadDetail(searchBook: searchBook, source: source)
report.steps.append(step(
stage: .detail,
hasOutput: !detail.book.name.isEmpty,
summary: "样本《\(detail.book.name)》;目录地址已\(!detail.book.tocUrl.isEmpty ? "解析" : "回退")",
diagnostics: detail.diagnostics,
started: detailStart
))
let tocStart = ProcessInfo.processInfo.systemUptime
let toc = await bookService.loadChapters(book: detail.book, source: source, maxPages: 5)
report.steps.append(step(
stage: .toc,
hasOutput: !toc.chapters.isEmpty,
summary: toc.chapters.isEmpty ? "没有解析到章节" : "解析到 \(toc.chapters.count) 章,访问 \(toc.pagesVisited) 页",
diagnostics: toc.diagnostics,
started: tocStart
))
guard let chapter = toc.chapters.first(where: { !$0.isVolume }) else {
appendSkipped(after: .toc, to: &report, reason: "目录阶段没有可读取章节")
return report
}
report.sampleChapterTitle = chapter.title
let next = toc.chapters.drop(while: { $0.id != chapter.id }).dropFirst().first(where: { !$0.isVolume })?.url
let contentStart = ProcessInfo.processInfo.systemUptime
let content = await bookService.loadContent(
book: detail.book,
chapter: chapter,
source: source,
nextChapterURL: next,
maxPages: 5
)
let cleaned = content.content.trimmingCharacters(in: .whitespacesAndNewlines)
report.contentPreview = cleaned.isEmpty ? nil : String(cleaned.prefix(240))
report.steps.append(step(
stage: .content,
hasOutput: !cleaned.isEmpty,
summary: cleaned.isEmpty ? "没有解析到正文" : "正文 \(cleaned.count) 字,访问 \(content.pagesVisited) 页",
diagnostics: content.diagnostics,
started: contentStart
))
return report
}
private func missingConfiguration(_ source: BookSource) -> [String] {
var missing: [String] = []
if source.searchUrl?.isEmpty != false || source.ruleSearch == nil { missing.append("搜索") }
if source.ruleBookInfo == nil { missing.append("详情") }
if source.ruleToc?.chapterList?.isEmpty != false { missing.append("目录") }
if source.ruleContent?.content?.isEmpty != false { missing.append("正文") }
return missing
}
private func step(
stage: SourceDebugStage,
hasOutput: Bool,
summary: String,
diagnostics: [RuleDiagnostic],
started: TimeInterval
) -> SourceDebugStep {
let containsError = diagnostics.contains { $0.severity == .error }
let status: SourceDebugStatus = if !hasOutput {
.failed
} else if containsError || !diagnostics.isEmpty {
.warning
} else {
.passed
}
let milliseconds = Int((ProcessInfo.processInfo.systemUptime - started) * 1_000)
return .init(
stage: stage,
status: status,
summary: summary,
elapsedMilliseconds: max(milliseconds, 0),
diagnostics: diagnostics
)
}
private func appendSkipped(after stage: SourceDebugStage, to report: inout BookSourceDebugReport, reason: String) {
guard let index = SourceDebugStage.allCases.firstIndex(of: stage) else { return }
for remaining in SourceDebugStage.allCases.dropFirst(index + 1) {
report.steps.append(.init(stage: remaining, status: .skipped, summary: reason))
}
}
}
@@ -0,0 +1,103 @@
import Foundation
public struct DiagnosticLogEntry: Codable, Identifiable, Sendable {
public enum Level: String, Codable, CaseIterable, Sendable { case info, warning, error }
public var id: UUID
public var date: Date
public var level: Level
public var category: String
public var message: String
}
public final class DiagnosticLog: @unchecked Sendable {
public static let shared = DiagnosticLog()
private let lock = NSLock()
private let fileURL: URL
private let maximumEntries: Int
private let isEnabled: @Sendable () -> Bool
private var entries: [DiagnosticLogEntry]
public init(directory: URL? = nil, maximumEntries: Int = 500,
isEnabled: @escaping @Sendable () -> Bool = { ApplicationSettingsStore.shared.settings.recordLog }) {
fileURL = (directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/Logs", isDirectory: true)).appendingPathComponent("diagnostics.json")
self.maximumEntries = max(1, maximumEntries)
self.isEnabled = isEnabled
entries = (try? JSONDecoder().decode([DiagnosticLogEntry].self, from: Data(contentsOf: fileURL))) ?? []
entries = Array(entries.suffix(self.maximumEntries))
}
public func record(_ level: DiagnosticLogEntry.Level = .info, category: String, message: String) {
guard isEnabled() else { return }
lock.lock()
defer { lock.unlock() }
entries.append(.init(id: UUID(), date: Date(), level: level,
category: String(category.prefix(80)), message: Self.redact(String(message.prefix(2048)))))
entries = Array(entries.suffix(maximumEntries))
try? persist()
}
public func snapshot() -> [DiagnosticLogEntry] {
lock.lock()
defer { lock.unlock() }
return entries.reversed()
}
public func clear() throws {
lock.lock()
defer { lock.unlock() }
if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) }
entries.removeAll()
}
public func export() -> String {
let formatter = ISO8601DateFormatter()
return snapshot().reversed().map {
"\(formatter.string(from: $0.date)) [\($0.level.rawValue.uppercased())] [\($0.category)] \($0.message)"
}.joined(separator: "\n")
}
public func network(_ request: URLRequest, status: Int? = nil, error: Error? = nil, startedAt: Date) {
// URL query strings, credentials and fragments can contain access tokens.
let url = request.url.map(Self.safeURL) ?? "未知地址"
let duration = Int(Date().timeIntervalSince(startedAt) * 1000)
let result: String
if let error, case HTTPClientError.statusResponse(let response) = error {
result = "HTTP \(response.statusCode)"
} else {
result = error.map { "失败 (\(($0 as NSError).domain):\(($0 as NSError).code))" } ?? "HTTP \(status ?? 0)"
}
record(error != nil || (status ?? 0) >= 400 ? .error : .info, category: "网络",
message: "\(request.httpMethod ?? "GET") \(url) · \(result) · \(duration) ms")
}
public static func safeURL(_ url: URL) -> String {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "[URL]" }
parts.user = nil; parts.password = nil; parts.query = nil; parts.fragment = nil
return parts.string ?? "[URL]"
}
public static func redact(_ text: String) -> String {
var value = text
if let regex = try? NSRegularExpression(pattern: #"https?://[^\s\"<>]+"#) {
for match in regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).reversed() {
guard let range = Range(match.range, in: value), let url = URL(string: String(value[range])) else { continue }
value.replaceSubrange(range, with: safeURL(url))
}
}
for pattern in [
#"(?im)(authorization|cookie)["']?(\s*[=:]\s*)[^\r\n]+"#,
#"(?i)["']?(password|passwd|token|secret)["']?(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)"#
] {
if let regex = try? NSRegularExpression(pattern: pattern) {
value = regex.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: "$1$2[已隐藏]")
}
}
return value
}
private func persist() throws {
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(entries).write(to: fileURL, options: .atomic)
}
}
@@ -0,0 +1,234 @@
import Foundation
import CryptoKit
public enum DifferentialStage: String, Codable, Sendable {
case search, detail, toc, content, login, debug
}
public struct DifferentialRequestSnapshot: Codable, Sendable, Equatable {
public struct Request: Codable, Sendable, Equatable {
public var ruleURL: String
public var renderedURL: String
public var method: String
public var headers: [String: String]
public var bodyBase64: String?
public var charset: String?
public var cookies: [String: String]
public var timeoutMilliseconds: Int
public var retryCount: Int
public var proxy: String?
public var dnsIp: String?
public var serverID: String?
}
public struct Response: Codable, Sendable, Equatable {
public struct ErrorInfo: Codable, Sendable, Equatable {
public var code: String
public var legadoCode: Int
public var message: String
}
public var statusCode: Int
public var finalURL: String
public var headers: [String: String]
public var charset: String?
public var bodySHA256: String
public var bodyJsSHA256: String?
public var setCookies: [String: String]
public var error: ErrorInfo?
}
public init(caseID: String, stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, failedResponse: HTTPResponse?, error: Error, timeout: TimeInterval) {
self.caseID = caseID
self.stage = stage
self.sourceID = sourceID
let response = failedResponse
request = .init(
ruleURL: prepared.ruleURL, renderedURL: prepared.url.absoluteString, method: prepared.method.rawValue,
headers: Self.normalizedHeaders(prepared.headers), bodyBase64: prepared.body?.base64EncodedString(),
charset: prepared.charset,
cookies: Self.cookies(from: prepared.headers.first { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame }?.value),
timeoutMilliseconds: Int(timeout * 1_000), retryCount: prepared.retry,
proxy: prepared.proxy, dnsIp: prepared.dnsIP, serverID: prepared.serverID.map(String.init)
)
let classification = Self.classify(error)
self.response = .init(
statusCode: response?.statusCode ?? 0,
finalURL: response?.finalURL.absoluteString ?? prepared.url.absoluteString,
headers: Self.normalizedHeaders(response?.headers ?? [:]),
charset: response?.requestedCharset,
bodySHA256: Self.sha256(response?.data ?? Data()),
bodyJsSHA256: nil,
setCookies: Self.cookies(from: response?.headers.first { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame }?.value),
error: .init(code: classification.code, legadoCode: classification.legadoCode, message: error.localizedDescription)
)
}
public var schemaVersion = 1
public var caseID: String
public var platform = "ios"
public var stage: DifferentialStage
public var sourceID: String
public var request: Request
public var response: Response
public init(caseID: String, stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) {
self.caseID = caseID
self.stage = stage
self.sourceID = sourceID
let requestCookies = Self.cookies(from: prepared.headers.first { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame }?.value)
let setCookies = Self.cookies(from: response.headers.first { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame }?.value)
request = .init(
ruleURL: prepared.ruleURL,
renderedURL: prepared.url.absoluteString,
method: prepared.method.rawValue,
headers: Self.normalizedHeaders(prepared.headers),
bodyBase64: prepared.body?.base64EncodedString(),
charset: prepared.charset,
cookies: requestCookies,
timeoutMilliseconds: Int(timeout * 1_000),
retryCount: prepared.retry,
proxy: prepared.proxy,
dnsIp: prepared.dnsIP,
serverID: prepared.serverID.map(String.init)
)
self.response = .init(
statusCode: response.statusCode,
finalURL: response.finalURL.absoluteString,
headers: Self.normalizedHeaders(response.headers),
charset: response.requestedCharset,
bodySHA256: Self.sha256(rawResponse.data),
bodyJsSHA256: prepared.bodyJavaScript == nil ? nil : Self.sha256(response.data),
setCookies: setCookies,
error: nil
)
}
private static func normalizedHeaders(_ headers: [String: String]) -> [String: String] {
headers.reduce(into: [:]) { result, entry in
let (key, value) = entry
let normalized = key.lowercased()
result[normalized] = normalized == "cookie" || normalized == "set-cookie" || normalized == "authorization"
? "<redacted:\(sha256(Data(value.utf8)))>" : value
}
}
private static func cookies(from header: String?) -> [String: String] {
guard let header else { return [:] }
return header.split(separator: ";").reduce(into: [:]) { result, part in
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { return }
result[pair[0].trimmingCharacters(in: .whitespaces)] = "<redacted:\(sha256(Data(pair[1].utf8)))>"
}
}
private static func sha256(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private static func classify(_ error: Error) -> (code: String, legadoCode: Int) {
if case HTTPClientError.statusResponse(let response) = error { return ("http.\(response.statusCode)", -7) }
guard let urlError = error as? URLError else { return ("other", -7) }
switch urlError.code {
case .timedOut: return ("timeout", -2)
case .cannotFindHost, .dnsLookupFailed: return ("dns", -3)
case .cannotConnectToHost: return ("connection_refused", -4)
case .networkConnectionLost, .notConnectedToInternet: return ("socket", -5)
case .secureConnectionFailed, .serverCertificateHasBadDate, .serverCertificateUntrusted,
.serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, .clientCertificateRejected:
return ("tls", -6)
default: return ("other", -7)
}
}
}
public protocol DifferentialSnapshotRecording: Sendable {
func record(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) async
func recordFailure(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, response: HTTPResponse?, error: Error, timeout: TimeInterval) async
}
public actor MemoryDifferentialSnapshotRecorder: DifferentialSnapshotRecording {
public let caseID: String
public private(set) var snapshots: [DifferentialRequestSnapshot] = []
public init(caseID: String) { self.caseID = caseID }
public func record(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) async {
snapshots.append(.init(caseID: caseID, stage: stage, sourceID: sourceID, prepared: prepared, rawResponse: rawResponse, response: response, timeout: timeout))
}
public func recordFailure(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, response: HTTPResponse?, error: Error, timeout: TimeInterval) async {
snapshots.append(.init(caseID: caseID, stage: stage, sourceID: sourceID, prepared: prepared, failedResponse: response, error: error, timeout: timeout))
}
public func encodedSnapshots() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(snapshots)
}
}
public struct DifferentialBusinessSnapshot: Codable, Sendable {
public struct Content: Codable, Sendable {
public var title: String?
public var text: String
public var supplementalText: String?
public var pagesVisited: Int
}
public struct Result: Codable, Sendable {
public var books: [SearchBook]?
public var book: Book?
public var chapters: [BookChapter]?
public var content: Content?
public var variables: [String: String]
public var cookies: [String: String]
public var diagnostics: [RuleDiagnostic]
}
public var schemaVersion = 1
public var caseID: String
public var platform = "ios"
public var flow: DifferentialStage
public var result: Result
public static func search(caseID: String, report: BookSearchReport) -> Self {
.init(caseID: caseID, flow: .search, result: .init(
books: report.books, book: nil, chapters: nil, content: nil,
variables: [:], cookies: [:], diagnostics: report.outcomes.flatMap(\.diagnostics)
))
}
public static func detail(caseID: String, report: BookDetailReport) -> Self {
.init(caseID: caseID, flow: .detail, result: .init(
books: nil, book: report.book, chapters: nil, content: nil,
variables: decodeVariables(report.book.variable), cookies: [:], diagnostics: report.diagnostics
))
}
public static func toc(caseID: String, report: ChapterListReport) -> Self {
.init(caseID: caseID, flow: .toc, result: .init(
books: nil, book: nil, chapters: report.chapters, content: nil,
variables: [:], cookies: [:], diagnostics: report.diagnostics
))
}
public static func content(caseID: String, report: ChapterContentReport) -> Self {
.init(caseID: caseID, flow: .content, result: .init(
books: nil, book: nil, chapters: nil,
content: .init(title: report.title, text: report.content, supplementalText: report.supplementalContent, pagesVisited: report.pagesVisited),
variables: [:], cookies: [:], diagnostics: report.diagnostics
))
}
public func encoded() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(self)
}
private static func decodeVariables(_ value: String?) -> [String: String] {
guard let value, let data = value.data(using: .utf8) else { return [:] }
return (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
}
}
@@ -0,0 +1,87 @@
import Foundation
public enum RSSDebugStage: String, Codable, CaseIterable, Sendable {
case configuration, listRequest, listParse, contentRequest, contentParse
public var title: String {
switch self {
case .configuration: "配置"
case .listRequest: "列表请求"
case .listParse: "列表解析"
case .contentRequest: "正文请求"
case .contentParse: "正文解析"
}
}
}
public struct RSSDebugStep: Codable, Hashable, Identifiable, Sendable {
public var stage: RSSDebugStage
public var status: SourceDebugStatus
public var summary: String
public var diagnostics: [RuleDiagnostic]
public var id: RSSDebugStage { stage }
}
public struct RSSSourceDebugReport: Sendable {
public var sourceURL: String
public var input: String
public var steps: [RSSDebugStep] = []
public var listSource: String?
public var contentSource: String?
public var articles: [RssArticle] = []
public var contentPreview: String?
public var passed: Bool { !steps.contains { $0.status == .failed } && steps.contains { $0.stage == .listParse && $0.status != .skipped } }
}
public struct RSSSourceDebugger: Sendable {
private let service: RSSService
public init(service: RSSService = .init()) { self.service = service }
public func run(source: RssSource, input: String) async -> RSSSourceDebugReport {
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
var report = RSSSourceDebugReport(sourceURL: source.sourceUrl, input: input)
let listURL = resolvedListURL(source: source, input: input)
let missing = source.sourceUrl.isEmpty || listURL.isEmpty || source.sourceName.isEmpty
report.steps.append(.init(stage: .configuration, status: missing ? .failed : .passed, summary: missing ? "源名称、源URL或调试地址为空" : "使用地址:\(listURL)", diagnostics: []))
guard !missing else { appendSkipped(after: .configuration, to: &report, reason: "配置不完整"); return report }
do {
let fetched = try await service.articlesDetailed(sortName: input, sortURL: listURL, source: source, page: 1, key: input.isEmpty ? nil : input)
report.listSource = fetched.body
report.steps.append(.init(stage: .listRequest, status: (200..<400).contains(fetched.statusCode) ? .passed : .warning, summary: "HTTP \(fetched.statusCode);\(fetched.finalURL)", diagnostics: []))
report.articles = fetched.report.result.articles
report.steps.append(.init(stage: .listParse, status: report.articles.isEmpty ? .failed : (fetched.report.diagnostics.isEmpty ? .passed : .warning), summary: report.articles.isEmpty ? "没有解析到文章" : "解析到 \(report.articles.count) 篇文章", diagnostics: fetched.report.diagnostics))
guard let article = report.articles.first, let rule = source.ruleContent, !rule.isEmpty else {
report.steps.append(.init(stage: .contentRequest, status: .skipped, summary: "没有样本文章或未配置正文规则", diagnostics: []))
report.steps.append(.init(stage: .contentParse, status: .skipped, summary: "正文将直接使用描述或网页", diagnostics: []))
return report
}
let content = try await service.contentDetailed(article: article, rule: rule, source: source)
report.contentSource = content.body
report.steps.append(.init(stage: .contentRequest, status: (200..<400).contains(content.statusCode) ? .passed : .warning, summary: "HTTP \(content.statusCode);\(content.finalURL)", diagnostics: []))
let cleaned = content.content.trimmingCharacters(in: .whitespacesAndNewlines)
report.contentPreview = cleaned.isEmpty ? nil : String(cleaned.prefix(400))
report.steps.append(.init(stage: .contentParse, status: cleaned.isEmpty ? .failed : (content.diagnostics.isEmpty ? .passed : .warning), summary: cleaned.isEmpty ? "正文结果为空" : "正文结果 \(cleaned.count) 字", diagnostics: content.diagnostics))
} catch {
let reachedList = report.steps.contains { $0.stage == .listRequest }
let stage: RSSDebugStage = reachedList ? .contentRequest : .listRequest
report.steps.append(.init(stage: stage, status: .failed, summary: error.localizedDescription, diagnostics: []))
appendSkipped(after: stage, to: &report, reason: "前一阶段失败")
}
return report
}
private func resolvedListURL(source: RssSource, input: String) -> String {
if let separator = input.range(of: "::") { return String(input[separator.upperBound...]) }
if !input.isEmpty, let search = source.searchUrl, !search.isEmpty { return search }
return source.sourceUrl
}
private func appendSkipped(after stage: RSSDebugStage, to report: inout RSSSourceDebugReport, reason: String) {
guard let index = RSSDebugStage.allCases.firstIndex(of: stage) else { return }
let existing = Set(report.steps.map(\.stage))
for value in RSSDebugStage.allCases.dropFirst(index + 1) where !existing.contains(value) {
report.steps.append(.init(stage: value, status: .skipped, summary: reason, diagnostics: []))
}
}
}
+258
View File
@@ -0,0 +1,258 @@
import Foundation
public struct BookSource: Codable, Hashable, Identifiable, Sendable {
public var bookSourceUrl: String
public var bookSourceName: String
public var bookSourceGroup: String?
public var bookSourceType: Int
public var bookUrlPattern: String?
public var customOrder: Int
public var enabled: Bool
public var enabledExplore: Bool
public var jsLib: String?
public var enabledCookieJar: Bool?
public var concurrentRate: String?
public var header: String?
public var loginUrl: String?
public var loginUi: String?
public var loginCheckJs: String?
public var coverDecodeJs: String?
public var bookSourceComment: String?
public var variableComment: String?
public var lastUpdateTime: Int64
public var respondTime: Int64
public var weight: Int
public var exploreUrl: String?
public var exploreScreen: String?
public var ruleExplore: ExploreRule?
public var searchUrl: String?
public var ruleSearch: SearchRule?
public var ruleBookInfo: BookInfoRule?
public var ruleToc: TocRule?
public var ruleContent: ContentRule?
public var ruleReview: ReviewRule?
public var eventListener: Bool
public var customButton: Bool
public var additionalFields: [String: JSONValue] = [:]
public var id: String { bookSourceUrl }
public init(bookSourceUrl: String, bookSourceName: String) {
self.bookSourceUrl = bookSourceUrl
self.bookSourceName = bookSourceName
bookSourceGroup = nil
bookSourceType = 0
bookUrlPattern = nil
customOrder = 0
enabled = true
enabledExplore = true
jsLib = nil
enabledCookieJar = true
concurrentRate = nil
header = nil
loginUrl = nil
loginUi = nil
loginCheckJs = nil
coverDecodeJs = nil
bookSourceComment = nil
variableComment = nil
lastUpdateTime = 0
respondTime = 180_000
weight = 0
exploreUrl = nil
exploreScreen = nil
ruleExplore = nil
searchUrl = nil
ruleSearch = nil
ruleBookInfo = nil
ruleToc = nil
ruleContent = nil
ruleReview = nil
eventListener = false
customButton = false
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case bookSourceUrl, bookSourceName, bookSourceGroup, bookSourceType, bookUrlPattern, customOrder, enabled, enabledExplore, jsLib, enabledCookieJar, concurrentRate, header, loginUrl, loginUi, loginCheckJs, coverDecodeJs, bookSourceComment, variableComment, lastUpdateTime, respondTime, weight, exploreUrl, exploreScreen, ruleExplore, searchUrl, ruleSearch, ruleBookInfo, ruleToc, ruleContent, ruleReview, eventListener, customButton
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
bookSourceUrl = try values.decodeIfPresent(String.self, forKey: .bookSourceUrl) ?? ""
bookSourceName = try values.decodeIfPresent(String.self, forKey: .bookSourceName) ?? ""
bookSourceGroup = try values.decodeIfPresent(String.self, forKey: .bookSourceGroup)
bookSourceType = try values.decodeLossyInt(forKey: .bookSourceType) ?? 0
bookUrlPattern = try values.decodeIfPresent(String.self, forKey: .bookUrlPattern)
customOrder = try values.decodeLossyInt(forKey: .customOrder) ?? 0
enabled = try values.decodeLossyBool(forKey: .enabled) ?? true
enabledExplore = try values.decodeLossyBool(forKey: .enabledExplore) ?? true
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
enabledCookieJar = values.contains(.enabledCookieJar)
? try values.decodeLossyBool(forKey: .enabledCookieJar)
: true
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
header = try values.decodeIfPresent(String.self, forKey: .header)
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
loginUi = try values.decodeIfPresent(String.self, forKey: .loginUi)
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
coverDecodeJs = try values.decodeIfPresent(String.self, forKey: .coverDecodeJs)
bookSourceComment = try values.decodeIfPresent(String.self, forKey: .bookSourceComment)
variableComment = try values.decodeIfPresent(String.self, forKey: .variableComment)
lastUpdateTime = try values.decodeLossyInt64(forKey: .lastUpdateTime) ?? 0
respondTime = try values.decodeLossyInt64(forKey: .respondTime) ?? 180_000
weight = try values.decodeLossyInt(forKey: .weight) ?? 0
exploreUrl = try values.decodeIfPresent(String.self, forKey: .exploreUrl)
exploreScreen = try values.decodeIfPresent(String.self, forKey: .exploreScreen)
ruleExplore = try values.decodeFlexible(ExploreRule.self, forKey: .ruleExplore)
searchUrl = try values.decodeIfPresent(String.self, forKey: .searchUrl)
ruleSearch = try values.decodeFlexible(SearchRule.self, forKey: .ruleSearch)
ruleBookInfo = try values.decodeFlexible(BookInfoRule.self, forKey: .ruleBookInfo)
ruleToc = try values.decodeFlexible(TocRule.self, forKey: .ruleToc)
ruleContent = try values.decodeFlexible(ContentRule.self, forKey: .ruleContent)
ruleReview = try values.decodeFlexible(ReviewRule.self, forKey: .ruleReview)
eventListener = try values.decodeLossyBool(forKey: .eventListener) ?? false
customButton = try values.decodeLossyBool(forKey: .customButton) ?? false
if case .object(var raw) = try JSONValue(from: decoder) {
for key in CodingKeys.allCases {
if BookSourceEditorDocument.ruleKeys.contains(key.rawValue) {
var nested: [String: JSONValue] = [:]
var value = raw[key.rawValue]
if case .string(let text) = value { value = try? JSONValue.decode(data: Data(text.utf8)) }
if case .object(let object) = value { nested = object }
for field in BookSourceEditorSchema.sections.flatMap(\.fields) where field.path.hasPrefix(key.rawValue + ".") {
nested.removeValue(forKey: String(field.path.split(separator: ".")[1]))
}
raw[key.rawValue] = nested.isEmpty ? nil : .object(nested)
} else { raw.removeValue(forKey: key.rawValue) }
}
additionalFields = raw
}
}
public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: SourceCodingKey.self)
for (key, value) in additionalFields { try values.encode(value, forKey: SourceCodingKey(key)) }
try values.encode(bookSourceUrl, forKey: SourceCodingKey("bookSourceUrl"))
try values.encode(bookSourceName, forKey: SourceCodingKey("bookSourceName"))
try values.encodeIfPresent(bookSourceGroup, forKey: SourceCodingKey("bookSourceGroup"))
try values.encode(bookSourceType, forKey: SourceCodingKey("bookSourceType"))
try values.encodeIfPresent(bookUrlPattern, forKey: SourceCodingKey("bookUrlPattern"))
try values.encode(customOrder, forKey: SourceCodingKey("customOrder"))
try values.encode(enabled, forKey: SourceCodingKey("enabled"))
try values.encode(enabledExplore, forKey: SourceCodingKey("enabledExplore"))
try values.encodeIfPresent(jsLib, forKey: SourceCodingKey("jsLib"))
try values.encode(enabledCookieJar, forKey: SourceCodingKey("enabledCookieJar"))
try values.encodeIfPresent(concurrentRate, forKey: SourceCodingKey("concurrentRate"))
try values.encodeIfPresent(header, forKey: SourceCodingKey("header"))
try values.encodeIfPresent(loginUrl, forKey: SourceCodingKey("loginUrl"))
try values.encodeIfPresent(loginUi, forKey: SourceCodingKey("loginUi"))
try values.encodeIfPresent(loginCheckJs, forKey: SourceCodingKey("loginCheckJs"))
try values.encodeIfPresent(coverDecodeJs, forKey: SourceCodingKey("coverDecodeJs"))
try values.encodeIfPresent(bookSourceComment, forKey: SourceCodingKey("bookSourceComment"))
try values.encodeIfPresent(variableComment, forKey: SourceCodingKey("variableComment"))
try values.encode(lastUpdateTime, forKey: SourceCodingKey("lastUpdateTime"))
try values.encode(respondTime, forKey: SourceCodingKey("respondTime"))
try values.encode(weight, forKey: SourceCodingKey("weight"))
try values.encodeIfPresent(exploreUrl, forKey: SourceCodingKey("exploreUrl"))
try values.encodeIfPresent(exploreScreen, forKey: SourceCodingKey("exploreScreen"))
try encodeRule(ruleExplore, key: "ruleExplore", into: &values)
try values.encodeIfPresent(searchUrl, forKey: SourceCodingKey("searchUrl"))
try encodeRule(ruleSearch, key: "ruleSearch", into: &values)
try encodeRule(ruleBookInfo, key: "ruleBookInfo", into: &values)
try encodeRule(ruleToc, key: "ruleToc", into: &values)
try encodeRule(ruleContent, key: "ruleContent", into: &values)
try encodeRule(ruleReview, key: "ruleReview", into: &values)
try values.encode(eventListener, forKey: SourceCodingKey("eventListener"))
try values.encode(customButton, forKey: SourceCodingKey("customButton"))
}
private func encodeRule<T: Encodable>(_ rule: T?, key: String, into values: inout KeyedEncodingContainer<SourceCodingKey>) throws {
var object: [String: JSONValue] = [:]
if case .object(let extra) = additionalFields[key] { object = extra }
if let rule, case .object(let known) = try JSONValue.decode(data: JSONEncoder().encode(rule)) {
object.merge(known) { _, new in new }
}
if rule != nil || !object.isEmpty { try values.encode(object, forKey: SourceCodingKey(key)) }
}
}
public extension BookSource {
var groups: [String] {
Self.parseGroups(bookSourceGroup)
}
func hasGroup(_ group: String) -> Bool {
groups.contains(group.trimmingCharacters(in: .whitespacesAndNewlines))
}
mutating func addGroups(_ value: String) {
bookSourceGroup = Self.joinGroups(groups + Self.parseGroups(value))
}
mutating func removeGroups(_ value: String) {
let removed = Set(Self.parseGroups(value))
bookSourceGroup = Self.joinGroups(groups.filter { !removed.contains($0) })
}
mutating func replaceGroup(_ oldValue: String, with newValue: String?) {
let oldValue = oldValue.trimmingCharacters(in: .whitespacesAndNewlines)
var values = groups.filter { $0 != oldValue }
if let newValue { values += Self.parseGroups(newValue) }
bookSourceGroup = Self.joinGroups(values)
}
static func parseGroups(_ value: String?) -> [String] {
guard let value else { return [] }
var seen = Set<String>()
return value
.components(separatedBy: CharacterSet(charactersIn: ",;,;"))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0).inserted }
}
static func joinGroups(_ values: [String]) -> String? {
var seen = Set<String>()
let normalized = values
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0).inserted }
return normalized.isEmpty ? nil : normalized.joined(separator: ",")
}
}
private extension KeyedDecodingContainer {
func decodeFlexible<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(T.self, forKey: key) {
return value
}
let json = try decode(String.self, forKey: key)
guard !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let data = json.data(using: .utf8) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
func decodeLossyBool(forKey key: Key) throws -> Bool? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(Bool.self, forKey: key) { return value }
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
let value = try decode(String.self, forKey: key).lowercased()
if ["true", "1", "yes"].contains(value) { return true }
if ["false", "0", "no"].contains(value) { return false }
return nil
}
func decodeLossyInt(forKey key: Key) throws -> Int? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(Int.self, forKey: key) { return value }
return Int(try decode(String.self, forKey: key))
}
func decodeLossyInt64(forKey key: Key) throws -> Int64? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(Int64.self, forKey: key) { return value }
return Int64(try decode(String.self, forKey: key))
}
}
@@ -0,0 +1,199 @@
import Foundation
public struct BookSourceEditorField: Identifiable, Sendable {
public let path: String
public let title: String
public var id: String { path }
public var help: String {
switch path {
case "bookSourceUrl": return "填写书源站点地址,例如 https://example.com;它也是书源的唯一标识。"
case "bookSourceName": return "显示在书源列表中的名称,不能为空。"
case "bookSourceGroup": return "多个分组可用逗号或分号分隔。"
case "bookUrlPattern": return "匹配该书源的书籍详情 URL 的正则表达式,用于从网址添加书籍。"
case "concurrentRate": return "1000 表示请求间隔 1000 ms;1/1000 表示每 1000 ms 最多 1 次请求。"
case "header": return "填写请求头 JSON,或用 @js: / <js> 动态生成。User-Agent 为 null 可禁用默认 UA。"
case "jsLib": return "填写共享 JavaScript,或以 JSON 对象配置在线脚本库。"
case "searchUrl": return "用 {{key}} 表示关键词、{{page}} 表示页码;URL 后可用逗号附加请求选项 JSON。"
case "exploreUrl": return "填写 名称::地址,多项用换行或 && 分隔;也支持 JSON 分类数组和 JavaScript。"
case "loginUi": return "填写登录界面 JSON。"
case "ruleToc.formatJs": return "目录去重后处理标题,可使用 title、index(从 1 开始)和 gInt。"
case "ruleContent.nextContentUrl": return "提取当前章的下一分页地址,而非下一章。"
case "ruleContent.replaceRegex": return "在多页正文合并后执行的替换规则,使用 ## 分隔正则和替换内容。"
case "ruleContent.imageStyle": return "FULL 表示图片铺满;留空使用默认样式。"
default:
if isJavaScript { return "使用 JavaScript 编写,可通过更多菜单调试当前书源草稿。" }
if path.contains(".") { return "支持 CSS / JSoup、XPath、JSONPath、正则和 JavaScript;留空表示不配置此项。" }
return "留空表示不配置此项。"
}
}
public var isJavaScript: Bool {
path.lowercased().contains("js") || path == "jsLib" || path.hasSuffix("imageDecode") || path.hasSuffix("payAction")
}
}
public struct BookSourceEditorSection: Identifiable, Sendable {
public let id: String
public let title: String
public let fields: [BookSourceEditorField]
}
public enum BookSourceEditorSchema {
public static let sections: [BookSourceEditorSection] = [
.init(id: "basic", title: "基础", fields: [
.init(path: "bookSourceName", title: "书源名称"),
.init(path: "bookSourceUrl", title: "书源地址"),
.init(path: "bookSourceGroup", title: "分组"),
.init(path: "bookSourceComment", title: "备注"),
.init(path: "bookUrlPattern", title: "书籍 URL 正则"),
.init(path: "concurrentRate", title: "并发率"),
.init(path: "header", title: "请求头"),
.init(path: "jsLib", title: "JavaScript 库"),
.init(path: "variableComment", title: "变量说明"),
]),
.init(id: "login", title: "登录", fields: [
.init(path: "loginUrl", title: "登录地址 / 脚本"),
.init(path: "loginUi", title: "自定义登录界面"),
.init(path: "loginCheckJs", title: "登录检测脚本"),
.init(path: "coverDecodeJs", title: "封面解密脚本"),
]),
.init(id: "search", title: "搜索", fields: [
.init(path: "searchUrl", title: "搜索地址"),
.init(path: "ruleSearch.checkKeyWord", title: "测试关键词"),
.init(path: "ruleSearch.bookList", title: "书籍列表"),
.init(path: "ruleSearch.name", title: "书名"),
.init(path: "ruleSearch.author", title: "作者"),
.init(path: "ruleSearch.intro", title: "简介"),
.init(path: "ruleSearch.kind", title: "分类"),
.init(path: "ruleSearch.lastChapter", title: "最新章节"),
.init(path: "ruleSearch.updateTime", title: "更新时间"),
.init(path: "ruleSearch.bookUrl", title: "详情地址"),
.init(path: "ruleSearch.coverUrl", title: "封面地址"),
.init(path: "ruleSearch.wordCount", title: "字数"),
]),
.init(id: "explore", title: "发现", fields: [
.init(path: "exploreUrl", title: "发现分类地址"),
.init(path: "exploreScreen", title: "发现筛选配置"),
.init(path: "ruleExplore.bookList", title: "书籍列表"),
.init(path: "ruleExplore.name", title: "书名"),
.init(path: "ruleExplore.author", title: "作者"),
.init(path: "ruleExplore.intro", title: "简介"),
.init(path: "ruleExplore.kind", title: "分类"),
.init(path: "ruleExplore.lastChapter", title: "最新章节"),
.init(path: "ruleExplore.updateTime", title: "更新时间"),
.init(path: "ruleExplore.bookUrl", title: "详情地址"),
.init(path: "ruleExplore.coverUrl", title: "封面地址"),
.init(path: "ruleExplore.wordCount", title: "字数"),
]),
.init(id: "info", title: "详情", fields: [
.init(path: "ruleBookInfo.init", title: "预处理"),
.init(path: "ruleBookInfo.name", title: "书名"),
.init(path: "ruleBookInfo.author", title: "作者"),
.init(path: "ruleBookInfo.intro", title: "简介"),
.init(path: "ruleBookInfo.kind", title: "分类"),
.init(path: "ruleBookInfo.lastChapter", title: "最新章节"),
.init(path: "ruleBookInfo.updateTime", title: "更新时间"),
.init(path: "ruleBookInfo.coverUrl", title: "封面地址"),
.init(path: "ruleBookInfo.tocUrl", title: "目录地址"),
.init(path: "ruleBookInfo.wordCount", title: "字数"),
.init(path: "ruleBookInfo.canReName", title: "允许修改书名作者"),
.init(path: "ruleBookInfo.downloadUrls", title: "文件下载地址"),
]),
.init(id: "toc", title: "目录", fields: [
.init(path: "ruleToc.preUpdateJs", title: "更新前脚本"),
.init(path: "ruleToc.chapterList", title: "章节列表"),
.init(path: "ruleToc.chapterName", title: "章节名称"),
.init(path: "ruleToc.chapterUrl", title: "章节地址"),
.init(path: "ruleToc.formatJs", title: "标题处理脚本"),
.init(path: "ruleToc.isVolume", title: "卷名标识"),
.init(path: "ruleToc.isVip", title: "VIP 标识"),
.init(path: "ruleToc.isPay", title: "已购买标识"),
.init(path: "ruleToc.updateTime", title: "章节信息"),
.init(path: "ruleToc.nextTocUrl", title: "目录下一页"),
]),
.init(id: "content", title: "正文", fields: [
.init(path: "ruleContent.content", title: "正文"),
.init(path: "ruleContent.subContent", title: "副正文"),
.init(path: "ruleContent.title", title: "正文标题"),
.init(path: "ruleContent.nextContentUrl", title: "正文下一页"),
.init(path: "ruleContent.webJs", title: "WebView 注入脚本"),
.init(path: "ruleContent.sourceRegex", title: "资源正则"),
.init(path: "ruleContent.replaceRegex", title: "合并后替换"),
.init(path: "ruleContent.imageStyle", title: "图片样式"),
.init(path: "ruleContent.imageDecode", title: "图片解密脚本"),
.init(path: "ruleContent.payAction", title: "购买脚本"),
.init(path: "ruleContent.callBackJs", title: "事件回调脚本"),
]),
.init(id: "review", title: "段评", fields: [
.init(path: "ruleReview.reviewUrl", title: "段评地址"),
.init(path: "ruleReview.avatarRule", title: "头像"),
.init(path: "ruleReview.contentRule", title: "内容"),
.init(path: "ruleReview.postTimeRule", title: "发布时间"),
.init(path: "ruleReview.reviewQuoteUrl", title: "回复地址"),
.init(path: "ruleReview.voteUpUrl", title: "点赞地址"),
.init(path: "ruleReview.voteDownUrl", title: "点踩地址"),
.init(path: "ruleReview.postReviewUrl", title: "发布段评"),
.init(path: "ruleReview.postQuoteUrl", title: "发布回复"),
.init(path: "ruleReview.deleteUrl", title: "删除地址"),
]),
]
}
public struct BookSourceEditorDocument: Sendable, Equatable {
public private(set) var fields: [String: JSONValue]
public init(source: BookSource) throws {
self = try Self(json: String(decoding: JSONEncoder().encode(source), as: UTF8.self))
}
public init(json: String) throws {
guard case .object(let object) = try JSONValue.decode(data: Data(json.utf8)) else {
throw BookSourceImportError.invalidDocument("编辑器需要单个书源 JSON 对象")
}
fields = object
for key in Self.ruleKeys {
if case .string(let text) = fields[key], !text.isEmpty,
case .object(let object) = try JSONValue.decode(data: Data(text.utf8)) {
fields[key] = .object(object)
}
}
}
public func value(at path: String) -> JSONValue? {
let keys = path.split(separator: ".").map(String.init)
guard let key = keys.first else { return nil }
return keys.count == 1 ? fields[key] : fields[key]?[keys[1]]
}
public mutating func set(_ value: JSONValue?, at path: String) {
let keys = path.split(separator: ".").map(String.init)
guard let key = keys.first else { return }
if keys.count == 1 { fields[key] = value; return }
var object: [String: JSONValue] = [:]
if case .object(let existing) = fields[key] { object = existing }
object[keys[1]] = value
fields[key] = object.isEmpty ? nil : .object(object)
}
public func json() throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return String(decoding: try encoder.encode(fields), as: UTF8.self)
}
public func source() throws -> BookSource {
let source = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(fields))
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
guard errors.isEmpty else { throw BookSourceImportError.invalidSources(errors) }
return source
}
static let ruleKeys = ["ruleSearch", "ruleExplore", "ruleBookInfo", "ruleToc", "ruleContent", "ruleReview"]
}
struct SourceCodingKey: CodingKey {
let stringValue: String
let intValue: Int? = nil
init(_ value: String) { stringValue = value }
init?(stringValue: String) { self.init(stringValue) }
init?(intValue: Int) { return nil }
}
@@ -0,0 +1,143 @@
import Foundation
public struct BookListRule: Codable, Hashable, Sendable {
public var bookList: String?
public var name: String?
public var author: String?
public var intro: String?
public var kind: String?
public var lastChapter: String?
public var updateTime: String?
public var bookUrl: String?
public var coverUrl: String?
public var wordCount: String?
public init(
bookList: String? = nil,
name: String? = nil,
author: String? = nil,
intro: String? = nil,
kind: String? = nil,
lastChapter: String? = nil,
updateTime: String? = nil,
bookUrl: String? = nil,
coverUrl: String? = nil,
wordCount: String? = nil
) {
self.bookList = bookList
self.name = name
self.author = author
self.intro = intro
self.kind = kind
self.lastChapter = lastChapter
self.updateTime = updateTime
self.bookUrl = bookUrl
self.coverUrl = coverUrl
self.wordCount = wordCount
}
}
public struct SearchRule: Codable, Hashable, Sendable {
public var checkKeyWord: String?
public var bookList: String?
public var name: String?
public var author: String?
public var intro: String?
public var kind: String?
public var lastChapter: String?
public var updateTime: String?
public var bookUrl: String?
public var coverUrl: String?
public var wordCount: String?
public init(
checkKeyWord: String? = nil,
bookList: String? = nil,
name: String? = nil,
author: String? = nil,
intro: String? = nil,
kind: String? = nil,
lastChapter: String? = nil,
updateTime: String? = nil,
bookUrl: String? = nil,
coverUrl: String? = nil,
wordCount: String? = nil
) {
self.checkKeyWord = checkKeyWord
self.bookList = bookList
self.name = name
self.author = author
self.intro = intro
self.kind = kind
self.lastChapter = lastChapter
self.updateTime = updateTime
self.bookUrl = bookUrl
self.coverUrl = coverUrl
self.wordCount = wordCount
}
}
public typealias ExploreRule = BookListRule
public struct BookInfoRule: Codable, Hashable, Sendable {
public var `init`: String?
public var name: String?
public var author: String?
public var intro: String?
public var kind: String?
public var lastChapter: String?
public var updateTime: String?
public var coverUrl: String?
public var tocUrl: String?
public var wordCount: String?
public var canReName: String?
public var downloadUrls: String?
public init() {}
}
public struct TocRule: Codable, Hashable, Sendable {
public var preUpdateJs: String?
public var chapterList: String?
public var chapterName: String?
public var chapterUrl: String?
public var formatJs: String?
public var isVolume: String?
public var isVip: String?
public var isPay: String?
public var updateTime: String?
public var nextTocUrl: String?
public init() {}
}
public struct ContentRule: Codable, Hashable, Sendable {
public var content: String?
public var subContent: String?
public var title: String?
public var nextContentUrl: String?
public var webJs: String?
public var sourceRegex: String?
public var replaceRegex: String?
public var imageStyle: String?
public var imageDecode: String?
public var payAction: String?
public var callBackJs: String?
public init() {}
}
public struct ReviewRule: Codable, Hashable, Sendable {
public var reviewUrl: String?
public var avatarRule: String?
public var contentRule: String?
public var postTimeRule: String?
public var reviewQuoteUrl: String?
public var voteUpUrl: String?
public var voteDownUrl: String?
public var postReviewUrl: String?
public var postQuoteUrl: String?
public var deleteUrl: String?
public init() {}
}
@@ -0,0 +1,82 @@
import Foundation
public struct ExploreKind: Codable, Hashable, Identifiable, Sendable {
public var title: String
public var url: String?
public var type: String
public var action: String?
public var chars: [String?]?
public var defaultValue: String?
public var id: String { "\(title)\u{0}\(url ?? "")" }
public var navigableURL: String? {
guard type.caseInsensitiveCompare("url") == .orderedSame,
let value = url?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
enum CodingKeys: String, CodingKey {
case title, url, type, action, chars
case defaultValue = "default"
}
public init(
title: String,
url: String? = nil,
type: String = "url",
action: String? = nil,
chars: [String?]? = nil,
defaultValue: String? = nil
) {
self.title = title
self.url = url
self.type = type
self.action = action
self.chars = chars
self.defaultValue = defaultValue
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
title = values.lossyString(forKey: .title) ?? ""
url = values.lossyString(forKey: .url)
type = values.lossyString(forKey: .type) ?? "url"
action = values.lossyString(forKey: .action)
defaultValue = values.lossyString(forKey: .defaultValue)
if let decoded = try? values.decodeIfPresent([JSONValue].self, forKey: .chars) {
chars = decoded.map { value in
if case .null = value { return nil }
return value.stringValue
}
} else if let scalar = values.lossyString(forKey: .chars) {
chars = [scalar]
} else {
chars = nil
}
}
}
private extension KeyedDecodingContainer where Key == ExploreKind.CodingKeys {
func lossyString(forKey key: Key) -> String? {
guard contains(key), (try? decodeNil(forKey: key)) != true else { return nil }
if let value = try? decode(String.self, forKey: key) { return value }
if let value = try? decode(Bool.self, forKey: key) { return String(value) }
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
if let value = try? decode(Double.self, forKey: key) {
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
}
return nil
}
}
public struct ExploreKindsReport: Sendable {
public var kinds: [ExploreKind]
public var diagnostics: [RuleDiagnostic]
public init(kinds: [ExploreKind] = [], diagnostics: [RuleDiagnostic] = []) {
self.kinds = kinds
self.diagnostics = diagnostics
}
}
@@ -0,0 +1,474 @@
import Foundation
public struct Book: Codable, Hashable, Identifiable, Sendable {
public var bookUrl: String
public var tocUrl: String
public var origin: String
public var originName: String
public var name: String
public var author: String
public var kind: String?
public var customTag: String?
public var coverUrl: String?
public var customCoverUrl: String?
public var intro: String?
public var customIntro: String?
public var charset: String?
public var type: Int
public var group: Int64
public var latestChapterTitle: String?
public var latestChapterTime: Int64
public var lastCheckTime: Int64
public var lastCheckCount: Int
public var totalChapterNum: Int
public var durChapterTitle: String?
public var durChapterIndex: Int
public var durVolumeIndex: Int
public var chapterInVolumeIndex: Int
public var durChapterPos: Int
public var durChapterTime: Int64
public var wordCount: String?
public var canUpdate: Bool
public var order: Int
public var originOrder: Int
public var variable: String?
public var readConfig: BookReadConfig?
public var syncTime: Int64
public var id: String { bookUrl }
public init(bookUrl: String, name: String, author: String = "", origin: String = "local") {
self.bookUrl = bookUrl
tocUrl = ""
self.origin = origin
originName = ""
self.name = name
self.author = author
kind = nil
customTag = nil
coverUrl = nil
customCoverUrl = nil
intro = nil
customIntro = nil
charset = nil
type = 0
group = 0
latestChapterTitle = nil
latestChapterTime = 0
lastCheckTime = 0
lastCheckCount = 0
totalChapterNum = 0
durChapterTitle = nil
durChapterIndex = 0
durVolumeIndex = 0
chapterInVolumeIndex = 0
durChapterPos = 0
durChapterTime = 0
wordCount = nil
canUpdate = true
order = 0
originOrder = 0
variable = nil
readConfig = nil
syncTime = 0
}
private enum CodingKeys: String, CodingKey {
case bookUrl, tocUrl, origin, originName, name, author, kind, customTag, coverUrl, customCoverUrl
case intro, customIntro, charset, type, group, latestChapterTitle, latestChapterTime, lastCheckTime
case lastCheckCount, totalChapterNum, durChapterTitle, durChapterIndex, durVolumeIndex
case chapterInVolumeIndex, durChapterPos, durChapterTime, wordCount, canUpdate, order, originOrder
case variable, readConfig, syncTime
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
tocUrl = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? "local"
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
kind = try value.decodeIfPresent(String.self, forKey: .kind)
customTag = try value.decodeIfPresent(String.self, forKey: .customTag)
coverUrl = try value.decodeIfPresent(String.self, forKey: .coverUrl)
customCoverUrl = try value.decodeIfPresent(String.self, forKey: .customCoverUrl)
intro = try value.decodeIfPresent(String.self, forKey: .intro)
customIntro = try value.decodeIfPresent(String.self, forKey: .customIntro)
charset = try value.decodeIfPresent(String.self, forKey: .charset)
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
group = try value.decodeIfPresent(Int64.self, forKey: .group) ?? 0
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
latestChapterTime = try value.decodeIfPresent(Int64.self, forKey: .latestChapterTime) ?? 0
lastCheckTime = try value.decodeIfPresent(Int64.self, forKey: .lastCheckTime) ?? 0
lastCheckCount = try value.decodeIfPresent(Int.self, forKey: .lastCheckCount) ?? 0
totalChapterNum = try value.decodeIfPresent(Int.self, forKey: .totalChapterNum) ?? 0
durChapterTitle = try value.decodeIfPresent(String.self, forKey: .durChapterTitle)
durChapterIndex = try value.decodeIfPresent(Int.self, forKey: .durChapterIndex) ?? 0
durVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .durVolumeIndex) ?? 0
chapterInVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .chapterInVolumeIndex) ?? 0
durChapterPos = try value.decodeIfPresent(Int.self, forKey: .durChapterPos) ?? 0
durChapterTime = try value.decodeIfPresent(Int64.self, forKey: .durChapterTime) ?? 0
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
canUpdate = try value.decodeIfPresent(Bool.self, forKey: .canUpdate) ?? true
order = try value.decodeIfPresent(Int.self, forKey: .order) ?? 0
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
variable = try value.decodeIfPresent(String.self, forKey: .variable)
readConfig = try value.decodeIfPresent(BookReadConfig.self, forKey: .readConfig)
syncTime = try value.decodeIfPresent(Int64.self, forKey: .syncTime) ?? 0
}
}
public struct BookReadConfig: Codable, Hashable, Sendable {
public var reverseToc = false
public var pageAnim: Int?
public var reSegment = false
public var imageStyle: String?
public var useReplaceRule: Bool?
public var delTag: Int64 = 0
public var ttsEngine: String?
public var splitLongChapter = true
public var readSimulating = false
public var startDate: String?
public var startChapter: Int?
public var dailyChapters = 3
public var openCredits = 0
public var closeCredits = 0
public var playMode = 0
public var playSpeed: Float = 1
public init() {}
private enum CodingKeys: String, CodingKey {
case reverseToc, pageAnim, reSegment, imageStyle, useReplaceRule, delTag, ttsEngine
case splitLongChapter, readSimulating, startDate, startChapter, dailyChapters
case openCredits, closeCredits, playMode, playSpeed
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
reverseToc = try value.decodeIfPresent(Bool.self, forKey: .reverseToc) ?? false
pageAnim = try value.decodeIfPresent(Int.self, forKey: .pageAnim)
reSegment = try value.decodeIfPresent(Bool.self, forKey: .reSegment) ?? false
imageStyle = try value.decodeIfPresent(String.self, forKey: .imageStyle)
useReplaceRule = try value.decodeIfPresent(Bool.self, forKey: .useReplaceRule)
delTag = try value.decodeIfPresent(Int64.self, forKey: .delTag) ?? 0
ttsEngine = try value.decodeIfPresent(String.self, forKey: .ttsEngine)
splitLongChapter = try value.decodeIfPresent(Bool.self, forKey: .splitLongChapter) ?? true
readSimulating = try value.decodeIfPresent(Bool.self, forKey: .readSimulating) ?? false
startDate = try value.decodeIfPresent(String.self, forKey: .startDate)
startChapter = try value.decodeIfPresent(Int.self, forKey: .startChapter)
dailyChapters = try value.decodeIfPresent(Int.self, forKey: .dailyChapters) ?? 3
openCredits = try value.decodeIfPresent(Int.self, forKey: .openCredits) ?? 0
closeCredits = try value.decodeIfPresent(Int.self, forKey: .closeCredits) ?? 0
playMode = try value.decodeIfPresent(Int.self, forKey: .playMode) ?? 0
playSpeed = try value.decodeIfPresent(Float.self, forKey: .playSpeed) ?? 1
}
}
public struct BookGroup: Codable, Hashable, Identifiable, Sendable {
public static let idRoot: Int64 = -100
public static let idAll: Int64 = -1
public static let idLocal: Int64 = -2
public static let idAudio: Int64 = -3
public static let idNetNone: Int64 = -4
public static let idLocalNone: Int64 = -5
public static let idVideo: Int64 = -6
public static let idError: Int64 = -11
public var groupId: Int64
public var groupName: String
public var cover: String?
public var order: Int
public var enableRefresh: Bool
public var show: Bool
public var bookSort: Int
public var onlyUpdateRead: Bool
public var id: Int64 { groupId }
public init(
groupId: Int64,
groupName: String,
cover: String? = nil,
order: Int = 0,
enableRefresh: Bool = true,
show: Bool = true,
bookSort: Int = -1,
onlyUpdateRead: Bool = false
) {
self.groupId = groupId
self.groupName = groupName
self.cover = cover
self.order = order
self.enableRefresh = enableRefresh
self.show = show
self.bookSort = bookSort
self.onlyUpdateRead = onlyUpdateRead
}
}
public struct BookChapter: Codable, Hashable, Identifiable, Sendable {
public var url: String
public var title: String
public var isVolume: Bool
public var baseUrl: String
public var bookUrl: String
public var index: Int
public var isVip: Bool
public var isPay: Bool
public var resourceUrl: String?
public var tag: String?
public var wordCount: String?
public var start: Int64?
public var end: Int64?
public var startFragmentId: String?
public var endFragmentId: String?
public var variable: String?
public var imageURL: String?
public var id: String { "\(bookUrl)\u{0}\(url)" }
public init(url: String, title: String, bookUrl: String, index: Int) {
self.url = url
self.title = title
isVolume = false
baseUrl = ""
self.bookUrl = bookUrl
self.index = index
isVip = false
isPay = false
}
private enum CodingKeys: String, CodingKey {
case url, title, isVolume, baseUrl, bookUrl, index, isVip, isPay, resourceUrl, tag, wordCount
case start, end, startFragmentId, endFragmentId, variable, imgUrl, imageURL
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
url = try value.decodeIfPresent(String.self, forKey: .url) ?? ""
title = try value.decodeIfPresent(String.self, forKey: .title) ?? ""
isVolume = try value.decodeIfPresent(Bool.self, forKey: .isVolume) ?? false
baseUrl = try value.decodeIfPresent(String.self, forKey: .baseUrl) ?? ""
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
index = try value.decodeIfPresent(Int.self, forKey: .index) ?? 0
isVip = try value.decodeIfPresent(Bool.self, forKey: .isVip) ?? false
isPay = try value.decodeIfPresent(Bool.self, forKey: .isPay) ?? false
resourceUrl = try value.decodeIfPresent(String.self, forKey: .resourceUrl)
tag = try value.decodeIfPresent(String.self, forKey: .tag)
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
start = try value.decodeIfPresent(Int64.self, forKey: .start)
end = try value.decodeIfPresent(Int64.self, forKey: .end)
startFragmentId = try value.decodeIfPresent(String.self, forKey: .startFragmentId)
endFragmentId = try value.decodeIfPresent(String.self, forKey: .endFragmentId)
variable = try value.decodeIfPresent(String.self, forKey: .variable)
imageURL = try value.decodeIfPresent(String.self, forKey: .imgUrl)
?? value.decodeIfPresent(String.self, forKey: .imageURL)
}
public func encode(to encoder: Encoder) throws {
var value = encoder.container(keyedBy: CodingKeys.self)
try value.encode(url, forKey: .url)
try value.encode(title, forKey: .title)
try value.encode(isVolume, forKey: .isVolume)
try value.encode(baseUrl, forKey: .baseUrl)
try value.encode(bookUrl, forKey: .bookUrl)
try value.encode(index, forKey: .index)
try value.encode(isVip, forKey: .isVip)
try value.encode(isPay, forKey: .isPay)
try value.encodeIfPresent(resourceUrl, forKey: .resourceUrl)
try value.encodeIfPresent(tag, forKey: .tag)
try value.encodeIfPresent(wordCount, forKey: .wordCount)
try value.encodeIfPresent(start, forKey: .start)
try value.encodeIfPresent(end, forKey: .end)
try value.encodeIfPresent(startFragmentId, forKey: .startFragmentId)
try value.encodeIfPresent(endFragmentId, forKey: .endFragmentId)
try value.encodeIfPresent(variable, forKey: .variable)
try value.encodeIfPresent(imageURL, forKey: .imgUrl)
}
}
public struct Bookmark: Codable, Hashable, Identifiable, Sendable {
public var time: Int64
public var bookName: String
public var bookAuthor: String
public var chapterIndex: Int
public var chapterPosition: Int
public var chapterName: String
public var bookText: String
public var content: String
public var id: Int64 { time }
public init(time: Int64, bookName: String, bookAuthor: String = "") {
self.time = time
self.bookName = bookName
self.bookAuthor = bookAuthor
chapterIndex = 0
chapterPosition = 0
chapterName = ""
bookText = ""
content = ""
}
private enum CodingKeys: String, CodingKey {
case time, bookName, bookAuthor, chapterIndex, chapterPos, chapterPosition
case chapterName, bookText, content
}
public func resolvedChapterIndex(in chapters: [BookChapter]) -> Int? {
if !chapterName.isEmpty,
let index = chapters.firstIndex(where: { !$0.isVolume && $0.title == chapterName }) {
return index
}
return chapters.firstIndex(where: { !$0.isVolume && $0.index == chapterIndex })
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
time = try values.decodeIfPresent(Int64.self, forKey: .time) ?? Int64(Date().timeIntervalSince1970 * 1_000)
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
bookAuthor = try values.decodeIfPresent(String.self, forKey: .bookAuthor) ?? ""
chapterIndex = try values.decodeIfPresent(Int.self, forKey: .chapterIndex) ?? 0
chapterPosition = try values.decodeIfPresent(Int.self, forKey: .chapterPos)
?? values.decodeIfPresent(Int.self, forKey: .chapterPosition)
?? 0
chapterName = try values.decodeIfPresent(String.self, forKey: .chapterName) ?? ""
bookText = try values.decodeIfPresent(String.self, forKey: .bookText) ?? ""
content = try values.decodeIfPresent(String.self, forKey: .content) ?? ""
}
public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(time, forKey: .time)
try values.encode(bookName, forKey: .bookName)
try values.encode(bookAuthor, forKey: .bookAuthor)
try values.encode(chapterIndex, forKey: .chapterIndex)
try values.encode(chapterPosition, forKey: .chapterPos)
try values.encode(chapterName, forKey: .chapterName)
try values.encode(bookText, forKey: .bookText)
try values.encode(content, forKey: .content)
}
}
public struct ReadRecord: Codable, Hashable, Identifiable, Sendable {
public var deviceId: String
public var bookName: String
public var readTime: Int64
public var lastRead: Int64
public var dailyReadTimes: [String: Int64]
public var id: String { "\(deviceId)\u{0}\(bookName)" }
public init(
deviceId: String,
bookName: String,
readTime: Int64 = 0,
lastRead: Int64? = nil,
dailyReadTimes: [String: Int64] = [:]
) {
self.deviceId = deviceId
self.bookName = bookName
self.readTime = readTime
self.lastRead = lastRead ?? Int64(Date().timeIntervalSince1970 * 1_000)
self.dailyReadTimes = dailyReadTimes
}
private enum CodingKeys: String, CodingKey {
case deviceId, bookName, readTime, lastRead, dailyReadTimes
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
deviceId = try values.decodeIfPresent(String.self, forKey: .deviceId) ?? ""
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
readTime = try values.decodeIfPresent(Int64.self, forKey: .readTime) ?? 0
lastRead = try values.decodeIfPresent(Int64.self, forKey: .lastRead) ?? 0
dailyReadTimes = try values.decodeIfPresent([String: Int64].self, forKey: .dailyReadTimes) ?? [:]
}
}
public struct ReadRecordSummary: Hashable, Identifiable, Sendable {
public var bookName: String
public var readTime: Int64
public var lastRead: Int64
public var id: String { bookName }
public init(bookName: String, readTime: Int64, lastRead: Int64) {
self.bookName = bookName
self.readTime = readTime
self.lastRead = lastRead
}
}
public struct ReplaceRule: Codable, Hashable, Identifiable, Sendable {
public var id: Int64
public var name: String
public var group: String?
public var pattern: String
public var replacement: String
public var scope: String?
public var scopeTitle: Bool
public var scopeContent: Bool
public var excludeScope: String?
public var isEnabled: Bool
public var isRegex: Bool
public var timeoutMillisecond: Int64
public var sortOrder: Int
public init(id: Int64, name: String, pattern: String, replacement: String = "") {
self.id = id
self.name = name
self.pattern = pattern
self.replacement = replacement
scopeTitle = false
scopeContent = true
isEnabled = true
isRegex = true
timeoutMillisecond = 3_000
sortOrder = Int.min
}
private enum CodingKeys: String, CodingKey {
case id, name, group, pattern, replacement, scope, scopeTitle, scopeContent
case excludeScope, isEnabled, isRegex, timeoutMillisecond, sortOrder, order
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
group = try values.decodeIfPresent(String.self, forKey: .group)
pattern = try values.decodeIfPresent(String.self, forKey: .pattern) ?? ""
replacement = try values.decodeIfPresent(String.self, forKey: .replacement) ?? ""
scope = try values.decodeIfPresent(String.self, forKey: .scope)
scopeTitle = try values.decodeIfPresent(Bool.self, forKey: .scopeTitle) ?? false
scopeContent = try values.decodeIfPresent(Bool.self, forKey: .scopeContent) ?? true
excludeScope = try values.decodeIfPresent(String.self, forKey: .excludeScope)
isEnabled = try values.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
isRegex = try values.decodeIfPresent(Bool.self, forKey: .isRegex) ?? true
timeoutMillisecond = try values.decodeIfPresent(Int64.self, forKey: .timeoutMillisecond) ?? 3_000
sortOrder = try values.decodeIfPresent(Int.self, forKey: .sortOrder)
?? values.decodeIfPresent(Int.self, forKey: .order)
?? Int.min
}
public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try values.encode(name, forKey: .name)
try values.encodeIfPresent(group, forKey: .group)
try values.encode(pattern, forKey: .pattern)
try values.encode(replacement, forKey: .replacement)
try values.encodeIfPresent(scope, forKey: .scope)
try values.encode(scopeTitle, forKey: .scopeTitle)
try values.encode(scopeContent, forKey: .scopeContent)
try values.encodeIfPresent(excludeScope, forKey: .excludeScope)
try values.encode(isEnabled, forKey: .isEnabled)
try values.encode(isRegex, forKey: .isRegex)
try values.encode(timeoutMillisecond, forKey: .timeoutMillisecond)
try values.encode(sortOrder, forKey: .order)
}
}
+279
View File
@@ -0,0 +1,279 @@
import Foundation
public struct RssSource: Codable, Hashable, Identifiable, Sendable {
public var sourceUrl = ""
public var sourceName = ""
public var sourceIcon = ""
public var sourceGroup: String?
public var sourceComment: String?
public var enabled = true
public var variableComment: String?
public var jsLib: String?
public var enabledCookieJar: Bool? = true
public var concurrentRate: String?
public var header: String?
public var loginUrl: String?
public var loginUi: String?
public var loginCheckJs: String?
public var coverDecodeJs: String?
public var sortUrl: String?
public var singleUrl = false
public var articleStyle = 0
public var ruleArticles: String?
public var ruleNextPage: String?
public var ruleTitle: String?
public var rulePubDate: String?
public var ruleDescription: String?
public var ruleImage: String?
public var ruleLink: String?
public var ruleContent: String?
public var contentWhitelist: String?
public var contentBlacklist: String?
public var shouldOverrideUrlLoading: String?
public var style: String?
public var enableJs = true
public var loadWithBaseUrl = true
public var injectJs: String?
public var preloadJs: String?
public var startHtml: String?
public var startStyle: String?
public var startJs: String?
public var showWebLog = false
public var lastUpdateTime: Int64 = 0
public var customOrder = 0
public var type = 0
public var preload = false
public var cacheFirst = false
public var searchUrl: String?
public var id: String { sourceUrl }
public init(sourceUrl: String = "", sourceName: String = "") {
self.sourceUrl = sourceUrl
self.sourceName = sourceName
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: RSSCodingKey.self)
sourceUrl = values.string("sourceUrl") ?? ""
sourceName = values.string("sourceName") ?? ""
sourceIcon = values.string("sourceIcon") ?? ""
sourceGroup = values.string("sourceGroup")
sourceComment = values.string("sourceComment")
enabled = values.bool("enabled") ?? true
variableComment = values.string("variableComment")
jsLib = values.string("jsLib")
enabledCookieJar = values.contains("enabledCookieJar") ? values.bool("enabledCookieJar") : true
concurrentRate = values.string("concurrentRate")
header = values.string("header")
loginUrl = values.string("loginUrl")
loginUi = values.jsonString("loginUi")
loginCheckJs = values.string("loginCheckJs")
coverDecodeJs = values.string("coverDecodeJs")
sortUrl = values.string("sortUrl")
singleUrl = values.bool("singleUrl") ?? false
articleStyle = values.int("articleStyle") ?? 0
ruleArticles = values.string("ruleArticles")
ruleNextPage = values.string("ruleNextPage")
ruleTitle = values.string("ruleTitle")
rulePubDate = values.string("rulePubDate")
ruleDescription = values.string("ruleDescription")
ruleImage = values.string("ruleImage")
ruleLink = values.string("ruleLink")
ruleContent = values.string("ruleContent")
contentWhitelist = values.string("contentWhitelist")
contentBlacklist = values.string("contentBlacklist")
shouldOverrideUrlLoading = values.string("shouldOverrideUrlLoading")
style = values.string("style")
enableJs = values.bool("enableJs") ?? true
loadWithBaseUrl = values.bool("loadWithBaseUrl") ?? true
injectJs = values.string("injectJs")
preloadJs = values.string("preloadJs")
startHtml = values.string("startHtml")
startStyle = values.string("startStyle")
startJs = values.string("startJs")
showWebLog = values.bool("showWebLog") ?? false
lastUpdateTime = values.int64("lastUpdateTime") ?? 0
customOrder = values.int("customOrder") ?? 0
type = values.int("type") ?? 0
preload = values.bool("preload") ?? false
cacheFirst = values.bool("cacheFirst") ?? false
searchUrl = values.string("searchUrl")
}
public var groups: [String] { BookSource.parseGroups(sourceGroup) }
public var displayNameGroup: String {
groups.isEmpty ? sourceName : "\(sourceName) (\(sourceGroup ?? ""))"
}
public static func == (lhs: Self, rhs: Self) -> Bool { lhs.sourceUrl == rhs.sourceUrl }
public func hash(into hasher: inout Hasher) { hasher.combine(sourceUrl) }
public mutating func addGroups(_ value: String) {
var merged = Set(groups)
merged.formUnion(BookSource.parseGroups(value))
sourceGroup = merged.joined(separator: ",")
}
public mutating func removeGroups(_ value: String) {
let removed = Set(BookSource.parseGroups(value))
sourceGroup = groups.filter { !removed.contains($0) }.joined(separator: ",")
}
public func displayVariableComment(_ other: String) -> String {
guard let variableComment, !variableComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return other }
return "\(variableComment)\n\(other)"
}
}
public struct RssArticle: Codable, Hashable, Identifiable, Sendable {
public var origin = ""
public var sort = ""
public var title = ""
public var order: Int64 = 0
public var link = ""
public var pubDate: String?
public var description: String?
public var content: String?
public var image: String?
public var group = "默认分组"
public var read = false
public var variable: String?
public var type = 0
public var durPos = 0
public var id: String { "\(origin)\u{0}\(link)\u{0}\(sort)" }
public init(origin: String = "", sort: String = "", title: String = "", link: String = "") {
self.origin = origin; self.sort = sort; self.title = title; self.link = link
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: RSSCodingKey.self)
origin = values.string("origin") ?? ""; sort = values.string("sort") ?? ""
title = values.string("title") ?? ""; order = values.int64("order") ?? 0
link = values.string("link") ?? ""; pubDate = values.string("pubDate")
description = values.string("description"); content = values.string("content")
image = values.string("image"); group = values.string("group") ?? "默认分组"
read = values.bool("read") ?? false; variable = values.string("variable")
type = values.int("type") ?? 0; durPos = values.int("durPos") ?? 0
}
public func star(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssStar {
.init(article: self, starTime: time)
}
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
}
public var variableMap: [String: String] { Self.decodeVariables(variable) }
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.origin == rhs.origin && lhs.link == rhs.link && lhs.sort == rhs.sort
}
public func hash(into hasher: inout Hasher) { hasher.combine(link) }
fileprivate static func decodeVariables(_ value: String?) -> [String: String] {
guard let value, let data = value.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
return object.reduce(into: [:]) { result, pair in
if let string = pair.value as? String { result[pair.key] = string }
else if !(pair.value is NSNull) { result[pair.key] = String(describing: pair.value) }
}
}
}
public struct RssReadRecord: Codable, Hashable, Identifiable, Sendable {
public var record: String
public var title: String?
public var readTime: Int64?
public var read: Bool
public var origin: String
public var sort: String
public var image: String?
public var type: Int
public var durPos: Int
public var pubDate: String?
public var id: String { record }
public init(record: String, title: String? = nil, readTime: Int64? = nil, read: Bool = true, origin: String = "", sort: String = "", image: String? = nil, type: Int = 0, durPos: Int = 0, pubDate: String? = nil) {
self.record = record; self.title = title; self.readTime = readTime; self.read = read
self.origin = origin; self.sort = sort; self.image = image; self.type = type
self.durPos = durPos; self.pubDate = pubDate
}
public var article: RssArticle {
var value = RssArticle(origin: origin, sort: sort, title: title ?? "", link: record)
value.image = image; value.type = type; value.durPos = durPos; value.pubDate = pubDate
return value
}
}
public struct RssStar: Codable, Hashable, Identifiable, Sendable {
public var origin = ""
public var sort = ""
public var title = ""
public var starTime: Int64 = 0
public var link = ""
public var pubDate: String?
public var description: String?
public var content: String?
public var image: String?
public var group = "默认分组"
public var variable: String?
public var type = 0
public var durPos = 0
public var id: String { "\(origin)\u{0}\(link)" }
public init(article: RssArticle = .init(), starTime: Int64 = 0) {
origin = article.origin; sort = article.sort; title = article.title; self.starTime = starTime
link = article.link; pubDate = article.pubDate; description = article.description
content = article.content; image = article.image; group = article.group
variable = article.variable; type = article.type; durPos = article.durPos
}
public init(from decoder: Decoder) throws {
let article = try RssArticle(from: decoder)
self.init(article: article, starTime: try decoder.container(keyedBy: RSSCodingKey.self).int64("starTime") ?? 0)
}
public var article: RssArticle {
var value = RssArticle(origin: origin, sort: sort, title: title, link: link)
value.pubDate = pubDate; value.description = description; value.content = content
value.image = image; value.group = group; value.variable = variable; value.type = type; value.durPos = durPos
return value
}
public var variableMap: [String: String] { RssArticle.decodeVariables(variable) }
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
}
}
private struct RSSCodingKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}
private extension KeyedDecodingContainer where Key == RSSCodingKey {
func key(_ value: String) -> Key { Key(stringValue: value)! }
func contains(_ value: String) -> Bool { contains(key(value)) }
func string(_ name: String) -> String? {
let key = key(name)
if let value = try? decode(String.self, forKey: key) { return value }
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
return nil
}
func bool(_ name: String) -> Bool? {
let key = key(name)
if let value = try? decode(Bool.self, forKey: key) { return value }
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
if let value = string(name)?.lowercased() { return ["true", "1", "yes"].contains(value) ? true : (["false", "0", "no"].contains(value) ? false : nil) }
return nil
}
func int(_ name: String) -> Int? {
let key = key(name)
if let value = try? decode(Int.self, forKey: key) { return value }
return string(name).flatMap(Int.init)
}
func int64(_ name: String) -> Int64? {
let key = key(name)
if let value = try? decode(Int64.self, forKey: key) { return value }
return string(name).flatMap(Int64.init)
}
func jsonString(_ name: String) -> String? {
if let value = string(name) { return value }
let key = key(name)
guard let value = try? decode(JSONValue.self, forKey: key) else { return nil }
return value.stringValue
}
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
public struct SearchBook: Codable, Hashable, Identifiable, Sendable {
public var bookURL: String
public var origin: String
public var originName: String
public var type: Int
public var name: String
public var author: String
public var kind: String?
public var coverURL: String?
public var intro: String?
public var wordCount: String?
public var latestChapterTitle: String?
public var tocURL: String
public var time: Int64
public var originOrder: Int
public var chapterWordCountText: String?
public var chapterWordCount: Int
public var respondTime: Int
public var origins: Set<String>
public var variables: [String: String]
public var id: String { bookURL }
public var variable: String? {
get {
guard !variables.isEmpty, let data = try? JSONEncoder().encode(variables) else { return nil }
return String(decoding: data, as: UTF8.self)
}
set {
guard let newValue, let data = newValue.data(using: .utf8) else { variables = [:]; return }
variables = (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
}
}
public init(
bookURL: String,
origin: String,
originName: String,
type: Int,
name: String,
author: String = ""
) {
self.bookURL = bookURL
self.origin = origin
self.originName = originName
self.type = type
self.name = name
self.author = author
tocURL = ""
time = Int64(Date().timeIntervalSince1970 * 1_000)
originOrder = 0
chapterWordCountText = nil
chapterWordCount = -1
respondTime = -1
origins = [origin]
variables = [:]
}
private enum CodingKeys: String, CodingKey {
case bookUrl, origin, originName, type, name, author, kind, coverUrl, intro, wordCount
case latestChapterTitle, tocUrl, time, variable, originOrder, chapterWordCountText
case chapterWordCount, respondTime
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
bookURL = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? ""
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
kind = try value.decodeIfPresent(String.self, forKey: .kind)
coverURL = try value.decodeIfPresent(String.self, forKey: .coverUrl)
intro = try value.decodeIfPresent(String.self, forKey: .intro)
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
tocURL = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
time = try value.decodeIfPresent(Int64.self, forKey: .time) ?? 0
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
chapterWordCountText = try value.decodeIfPresent(String.self, forKey: .chapterWordCountText)
chapterWordCount = try value.decodeIfPresent(Int.self, forKey: .chapterWordCount) ?? -1
respondTime = try value.decodeIfPresent(Int.self, forKey: .respondTime) ?? -1
origins = [origin]
let rawVariable = try value.decodeIfPresent(String.self, forKey: .variable)
variables = rawVariable.flatMap { raw in
try? JSONDecoder().decode([String: String].self, from: Data(raw.utf8))
} ?? [:]
}
public func encode(to encoder: Encoder) throws {
var value = encoder.container(keyedBy: CodingKeys.self)
try value.encode(bookURL, forKey: .bookUrl)
try value.encode(origin, forKey: .origin)
try value.encode(originName, forKey: .originName)
try value.encode(type, forKey: .type)
try value.encode(name, forKey: .name)
try value.encode(author, forKey: .author)
try value.encodeIfPresent(kind, forKey: .kind)
try value.encodeIfPresent(coverURL, forKey: .coverUrl)
try value.encodeIfPresent(intro, forKey: .intro)
try value.encodeIfPresent(wordCount, forKey: .wordCount)
try value.encodeIfPresent(latestChapterTitle, forKey: .latestChapterTitle)
try value.encode(tocURL, forKey: .tocUrl)
try value.encode(time, forKey: .time)
try value.encodeIfPresent(variable, forKey: .variable)
try value.encode(originOrder, forKey: .originOrder)
try value.encodeIfPresent(chapterWordCountText, forKey: .chapterWordCountText)
try value.encode(chapterWordCount, forKey: .chapterWordCount)
try value.encode(respondTime, forKey: .respondTime)
}
}
@@ -0,0 +1,290 @@
import Foundation
public struct BookExploreService: Sendable {
private let transport: any HTTPTransport
private let urlBuilder: LegadoURLBuilder
private let engine: RuleEngine
private let networkPolicy: SourceNetworkPolicy
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
public init(
transport: any HTTPTransport = URLSessionHTTPTransport(),
urlBuilder: LegadoURLBuilder = .init(),
engine: RuleEngine = .init(),
networkPolicy: SourceNetworkPolicy = .shared,
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil
) {
self.transport = transport
self.urlBuilder = urlBuilder
self.engine = engine
self.networkPolicy = networkPolicy
self.webViewExecutor = webViewExecutor
}
public func kinds(for source: BookSource) async -> ExploreKindsReport {
guard let rule = source.exploreUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!rule.isEmpty else { return .init() }
var diagnostics: [RuleDiagnostic] = []
let rendered: String
if rule.lowercased().hasPrefix("@js:") || rule.lowercased().hasPrefix("<js>") {
let result = await engine.evaluate(
rule,
in: .null,
context: .init(source: source)
)
diagnostics += result.diagnostics
rendered = result.firstString ?? ""
} else {
rendered = rule
}
do {
return .init(kinds: try parseKinds(rendered), diagnostics: diagnostics)
} catch let error as RuleEngineError {
diagnostics.append(error.diagnostic)
return .init(diagnostics: diagnostics)
} catch {
diagnostics.append(.init(
severity: .error,
code: "explore.kinds",
message: error.localizedDescription,
rule: rule
))
return .init(diagnostics: diagnostics)
}
}
public func explore(
source: BookSource,
kind: ExploreKind,
page: Int = 1
) async -> SourceSearchOutcome {
guard let template = kind.navigableURL else {
return failure(source, code: "explore.url", message: "发现分类缺少 URL", rule: kind.title)
}
let rules = resolvedRules(source)
guard let rules, let listRule = rules.bookList, !listRule.isEmpty else {
return failure(source, code: "explore.configuration", message: "书源缺少发现列表规则", rule: template)
}
do {
let prepared = try urlBuilder.prepare(
template,
context: .init(page: page, source: source),
baseURL: URL(string: source.bookSourceUrl),
defaultHeaders: parseHeaders(source.header)
)
let timeout = min(max(Double(source.respondTime) / 1_000, 5), 180)
let response = try await networkPolicy.execute(
prepared,
source: source,
transport: transport,
timeout: timeout,
webViewExecutor: webViewExecutor
)
let body = response.decodedText
let root = response.decodedJSON
var context = RuleExecutionContext(
variables: response.requestVariables,
baseURL: response.finalURL.absoluteString,
source: source
)
let list = if let root {
await engine.evaluate(listRule.trimmingListPrefix, in: root, listMode: true, context: context)
} else {
await engine.evaluate(
listRule.trimmingListPrefix,
inText: body,
baseURL: response.finalURL.absoluteString,
listMode: true,
context: context
)
}
context.variables = list.variables
var diagnostics = list.diagnostics
var books: [SearchBook] = []
for item in list.listItems(rule: listRule.trimmingListPrefix) {
try Task.checkCancellation()
let fields = await evaluateFields(
rules,
item: item,
isHTML: root == nil,
baseURL: response.finalURL.absoluteString,
source: source,
variables: context.variables
)
diagnostics += fields.diagnostics
guard let name = fields.name, !name.isEmpty else { continue }
let bookURL = resolve(fields.bookURL ?? response.finalURL.absoluteString, against: response.finalURL)
var book = SearchBook(
bookURL: bookURL,
origin: source.bookSourceUrl,
originName: source.bookSourceName,
type: source.bookSourceType,
name: name,
author: fields.author ?? ""
)
book.kind = fields.kind
book.coverURL = fields.coverURL.map { resolve($0, against: response.finalURL) }
book.intro = MetadataTextSanitizer().clean(fields.intro)
book.wordCount = fields.wordCount
book.latestChapterTitle = fields.lastChapter
book.originOrder = source.customOrder
book.variables = fields.variables
books.append(book)
}
if listRule.hasPrefix("-") { books.reverse() }
var unique = Set<String>()
books = books.filter { unique.insert($0.bookURL).inserted }
return .init(sourceID: source.id, books: books, diagnostics: diagnostics)
} catch is CancellationError {
return failure(source, code: "explore.cancelled", message: "发现加载已取消", rule: template)
} catch let error as RuleEngineError {
return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic])
} catch {
return failure(source, code: "explore.request", message: error.localizedDescription, rule: template)
}
}
private func parseKinds(_ value: String) throws -> [ExploreKind] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if trimmed.hasPrefix("[") {
do {
return try JSONDecoder().decode([ExploreKind].self, from: Data(trimmed.utf8))
} catch let strictError {
do {
let object = try JSONSerialization.jsonObject(
with: Data(trimmed.utf8),
options: [.fragmentsAllowed, .json5Allowed]
)
let normalized = try JSONEncoder().encode(JSONValue.bridge(object))
return try JSONDecoder().decode([ExploreKind].self, from: normalized)
} catch {
throw RuleEngineError.malformedRule(
rule: value,
detail: "发现分类 JSON 无效:\(strictError.localizedDescription)"
)
}
}
}
return trimmed
.replacingOccurrences(of: "&&", with: "\n")
.split(whereSeparator: { $0.isNewline })
.compactMap { line in
let line = String(line)
let range = line.range(of: "::")
let title = String(range.map { line[..<$0.lowerBound] } ?? line[...])
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return nil }
let url = range.map { String(line[$0.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) }
return ExploreKind(title: title, url: url?.isEmpty == true ? nil : url)
}
}
private func resolvedRules(_ source: BookSource) -> BookListRule? {
if let rules = source.ruleExplore, rules.bookList?.isEmpty == false { return rules }
guard let search = source.ruleSearch else { return source.ruleExplore }
return .init(
bookList: search.bookList,
name: search.name,
author: search.author,
intro: search.intro,
kind: search.kind,
lastChapter: search.lastChapter,
updateTime: search.updateTime,
bookUrl: search.bookUrl,
coverUrl: search.coverUrl,
wordCount: search.wordCount
)
}
private func evaluateFields(
_ rules: BookListRule,
item: JSONValue,
isHTML: Bool,
baseURL: String,
source: BookSource,
variables: [String: String]
) async -> Fields {
var context = RuleExecutionContext(variables: variables, baseURL: baseURL, source: source, isHTMLFragment: true)
var result = Fields()
let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = name.variables
let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = author.variables
let kind = await evaluate(rules.kind, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = kind.variables
let bookURL = await evaluate(rules.bookUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = bookURL.variables
let cover = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = cover.variables
let intro = await evaluate(rules.intro, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = intro.variables
let wordCount = await evaluate(rules.wordCount, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = wordCount.variables
let lastChapter = await evaluate(rules.lastChapter, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = lastChapter.variables
result.name = name.joinedString
result.author = author.joinedString
result.kind = kind.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty
result.bookURL = isHTML ? bookURL.leadingString : bookURL.joinedString
result.coverURL = cover.joinedString
result.intro = MetadataTextSanitizer().clean(intro.joinedString)
result.wordCount = wordCount.joinedString
result.lastChapter = lastChapter.joinedString
result.variables = context.variables
result.diagnostics = [name, author, kind, bookURL, cover, intro, wordCount, lastChapter].flatMap(\.diagnostics)
return result
}
private func evaluate(
_ rule: String?,
item: JSONValue,
isHTML: Bool,
baseURL: String,
context: RuleExecutionContext
) async -> RuleEvaluation {
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
if case .string = item {
return await engine.evaluate(rule, inText: item.stringValue, baseURL: baseURL, context: context)
}
return await engine.evaluate(rule, in: item, context: context)
}
private func resolve(_ value: String, against base: URL) -> String {
LegadoURLBuilder.resolve(value, against: base)
}
private func parseHeaders(_ source: String?) -> [String: String] {
guard let source, let value = try? JSONValue.decode(data: Data(source.utf8)),
case .object(let object) = value else { return [:] }
return object.mapValues(\.stringValue)
}
private func failure(_ source: BookSource, code: String, message: String, rule: String) -> SourceSearchOutcome {
.init(sourceID: source.id, books: [], diagnostics: [
.init(severity: .error, code: code, message: message, rule: rule)
])
}
private struct Fields {
var name: String?
var author: String?
var kind: String?
var bookURL: String?
var coverURL: String?
var intro: String?
var wordCount: String?
var lastChapter: String?
var variables: [String: String] = [:]
var diagnostics: [RuleDiagnostic] = []
}
}
extension String {
var trimmingListPrefix: String {
guard hasPrefix("-") || hasPrefix("+") else { return self }
return String(dropFirst())
}
fileprivate var nilIfEmpty: String? { isEmpty ? nil : self }
}
@@ -0,0 +1,242 @@
import Compression
import CoreFoundation
import Foundation
public enum DuplicatePolicy: Sendable {
case replace
case skip
case fail
}
public enum BookSourceImportError: Error, Equatable, LocalizedError {
case emptyDocument
case invalidDocument(String)
case invalidSources([BookSourceIssue])
case duplicate(String)
public var errorDescription: String? {
switch self {
case .emptyDocument:
return "导入内容为空"
case .invalidDocument(let detail):
return "无法解析书源 JSON:\(detail)"
case .invalidSources(let issues):
return issues.map(\.message).joined(separator: ";")
case .duplicate(let url):
return "书源已存在:\(url)"
}
}
}
public struct BookSourceImportResult: Sendable {
public var sources: [BookSource]
public var inserted: Int
public var replaced: Int
public var skipped: Int
public var warnings: [BookSourceIssue]
public init(
sources: [BookSource],
inserted: Int,
replaced: Int,
skipped: Int,
warnings: [BookSourceIssue]
) {
self.sources = sources
self.inserted = inserted
self.replaced = replaced
self.skipped = skipped
self.warnings = warnings
}
}
public struct BookSourceImporter: Sendable {
public static let maximumDocumentBytes = 50 * 1_024 * 1_024
private let decoder: JSONDecoder
public init() {
decoder = JSONDecoder()
}
public struct RejectedSource: Sendable {
public var source: BookSource
public var issues: [BookSourceIssue]
}
public struct Inspection: Sendable {
public var sources: [BookSource]
public var rejected: [RejectedSource]
}
public func inspect(_ data: Data) throws -> Inspection {
let cleaned = try normalizedData(data)
let root: JSONValue
do { root = try JSONValue.decode(data: cleaned) }
catch { throw BookSourceImportError.invalidDocument(error.localizedDescription) }
let entries: [JSONValue]
switch root {
case .array(let array): entries = array
case .object: entries = [root]
default: throw BookSourceImportError.invalidDocument("需要书源对象或数组")
}
guard !entries.isEmpty else { throw BookSourceImportError.emptyDocument }
var sources: [BookSource] = []
var rejected: [RejectedSource] = []
for (index, entry) in entries.enumerated() {
do {
guard case .object = entry else { throw BookSourceImportError.invalidDocument("第 \(index + 1) 项不是对象") }
let source = try decoder.decode(BookSource.self, from: JSONEncoder().encode(entry))
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
if errors.isEmpty { sources.append(source) }
else { rejected.append(.init(source: source, issues: errors)) }
} catch {
let placeholder = BookSource(bookSourceUrl: entry["bookSourceUrl"]?.stringValue ?? "",
bookSourceName: entry["bookSourceName"]?.stringValue ?? "第 \(index + 1) 项")
rejected.append(.init(source: placeholder, issues: [.init(severity: .error, code: "source.decode",
field: "[\(index)]", message: "第 \(index + 1) 项:\(error.localizedDescription)")]))
}
}
return .init(sources: sources, rejected: rejected)
}
public func decode(_ data: Data) throws -> [BookSource] {
let result = try inspect(data)
guard result.rejected.isEmpty else { throw BookSourceImportError.invalidSources(result.rejected.flatMap(\.issues)) }
return result.sources
}
public func normalizedData(_ data: Data) throws -> Data {
guard data.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("书源文件超过 50 MB") }
let cleaned = decodeText(stripUTF8BOM(decompressIfGzip(data)))
guard cleaned.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("解压后书源超过 50 MB") }
guard !String(decoding: cleaned, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw BookSourceImportError.emptyDocument
}
return cleaned
}
public func prepare(
data: Data,
existing: [BookSource],
duplicatePolicy: DuplicatePolicy = .replace
) throws -> BookSourceImportResult {
let incoming = try decode(data)
var fatalIssues: [BookSourceIssue] = []
var warnings: [BookSourceIssue] = []
for source in incoming {
for issue in BookSourceValidator.validate(source) {
if issue.severity == .error {
fatalIssues.append(issue)
} else {
warnings.append(issue)
}
}
}
guard fatalIssues.isEmpty else {
throw BookSourceImportError.invalidSources(fatalIssues)
}
var merged = existing
var index = Dictionary(uniqueKeysWithValues: merged.enumerated().map { ($1.id, $0) })
var inserted = 0
var replaced = 0
var skipped = 0
for source in incoming {
if let existingIndex = index[source.id] {
switch duplicatePolicy {
case .replace:
merged[existingIndex] = source
replaced += 1
case .skip:
skipped += 1
case .fail:
throw BookSourceImportError.duplicate(source.id)
}
} else {
index[source.id] = merged.count
merged.append(source)
inserted += 1
}
}
return .init(
sources: merged,
inserted: inserted,
replaced: replaced,
skipped: skipped,
warnings: warnings
)
}
private func stripUTF8BOM(_ data: Data) -> Data {
let bom = Data([0xEF, 0xBB, 0xBF])
return data.starts(with: bom) ? Data(data.dropFirst(3)) : data
}
private func decodeText(_ data: Data) -> Data {
if let value = String(data: data, encoding: .utf8) { return Data(value.utf8) }
if data.starts(with: [0xFF, 0xFE]), let value = String(data: data, encoding: .utf16LittleEndian) {
return Data(value.utf8)
}
if data.starts(with: [0xFE, 0xFF]), let value = String(data: data, encoding: .utf16BigEndian) {
return Data(value.utf8)
}
if let value = String(data: data, encoding: Self.gb18030Encoding) {
return Data(value.utf8)
}
return data
}
private static var gb18030Encoding: String.Encoding {
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
}
private func decompressIfGzip(_ data: Data) -> Data {
guard data.count > 10, data[0] == 0x1F, data[1] == 0x8B, data[2] == 0x08 else { return data }
var offset = 10
let flags = Int(data[3])
if flags & 0x04 != 0 {
guard offset + 2 <= data.count - 8 else { return data }
let extraLength = Int(data[offset]) | (Int(data[offset + 1]) << 8)
offset += 2 + extraLength
}
if flags & 0x08 != 0 {
while offset < data.count, data[offset] != 0 { offset += 1 }
offset += 1
}
if flags & 0x10 != 0 {
while offset < data.count, data[offset] != 0 { offset += 1 }
offset += 1
}
if flags & 0x02 != 0 { offset += 2 }
guard offset < data.count - 8 else { return data }
let payload = Data(data[offset..<data.count - min(8, data.count - offset)])
return inflate(payload) ?? data
}
private func inflate(_ payload: Data) -> Data? {
var capacity = max(payload.count * 4, 64 * 1_024)
while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 {
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
let produced = payload.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_decode_buffer(
destination, capacity,
base, payload.count,
nil,
COMPRESSION_ZLIB
)
}
if produced > 0, produced < capacity {
let result = Data(bytes: destination, count: produced)
destination.deallocate()
return result
}
destination.deallocate()
guard produced == capacity, capacity <= Int.max / 2 else { return nil }
capacity = min(capacity * 2, Self.maximumDocumentBytes + 1)
if capacity == produced { return nil }
}
return nil
}
}
@@ -0,0 +1,72 @@
import Foundation
public struct BookSourceIssue: Codable, Hashable, Identifiable, Sendable {
public enum Severity: String, Codable, Sendable {
case warning
case error
}
public var severity: Severity
public var code: String
public var field: String
public var message: String
public var id: String { "\(code):\(field):\(message)" }
public init(severity: Severity, code: String, field: String, message: String) {
self.severity = severity
self.code = code
self.field = field
self.message = message
}
}
public enum BookSourceValidator {
public static func validate(_ source: BookSource) -> [BookSourceIssue] {
var issues: [BookSourceIssue] = []
if source.bookSourceUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
issues.append(.init(
severity: .error,
code: "source_url.empty",
field: "bookSourceUrl",
message: "书源地址不能为空"
))
}
if source.bookSourceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
issues.append(.init(
severity: .error,
code: "source_name.empty",
field: "bookSourceName",
message: "书源名称不能为空"
))
}
if !source.bookSourceUrl.isEmpty,
let components = URLComponents(string: source.bookSourceUrl),
components.scheme == nil {
issues.append(.init(
severity: .warning,
code: "source_url.no_scheme",
field: "bookSourceUrl",
message: "书源地址没有 URL scheme,运行时可能无法解析相对地址"
))
}
if source.searchUrl?.isEmpty == false, source.ruleSearch == nil {
issues.append(.init(
severity: .warning,
code: "search.rule_missing",
field: "ruleSearch",
message: "配置了搜索地址但没有搜索解析规则"
))
}
if source.exploreUrl?.isEmpty == false, source.ruleExplore == nil {
issues.append(.init(
severity: .warning,
code: "explore.rule_missing",
field: "ruleExplore",
message: "配置了发现地址但没有发现解析规则"
))
}
return issues
}
}
@@ -0,0 +1,13 @@
import Foundation
import Vision
public struct QRCodeDecoder: Sendable {
public init() {}
public func detect(in data: Data) throws -> [String] {
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr]
try VNImageRequestHandler(data: data).perform([request])
var seen = Set<String>()
return (request.results ?? []).compactMap(\.payloadStringValue).filter { seen.insert($0).inserted }
}
}
@@ -0,0 +1,88 @@
import Foundation
public struct RSSSourceImportPayload: Sendable {
public var sources: [RssSource]
public var subscriptionURLs: [String]
public init(sources: [RssSource] = [], subscriptionURLs: [String] = []) {
self.sources = sources
self.subscriptionURLs = subscriptionURLs
}
}
public enum RSSSourceImportError: Error, LocalizedError, Sendable {
case emptyDocument
case wrongFormat
case invalidSource
case duplicate(String)
public var errorDescription: String? {
switch self {
case .emptyDocument: "导入内容为空"
case .wrongFormat: "RSS源格式错误"
case .invalidSource: "不是订阅源"
case .duplicate(let url): "RSS源已存在:\(url)"
}
}
}
public struct RSSSourceImporter: Sendable {
public init() {}
public func decode(_ data: Data) throws -> RSSSourceImportPayload {
var data = data
if data.starts(with: [0xEF, 0xBB, 0xBF]) { data = Data(data.dropFirst(3)) }
guard !data.isEmpty else { throw RSSSourceImportError.emptyDocument }
let decoder = JSONDecoder()
if let manifest = try? decoder.decode(SubscriptionManifest.self, from: data), !manifest.sourceUrls.isEmpty {
return .init(subscriptionURLs: manifest.sourceUrls)
}
let sources: [RssSource]
if let values = try? decoder.decode([RssSource].self, from: data) { sources = values }
else if let value = try? decoder.decode(RssSource.self, from: data) { sources = [value] }
else { throw RSSSourceImportError.wrongFormat }
guard sources.isEmpty || sources.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
return .init(sources: sources)
}
public func merge(
_ incoming: [RssSource],
into existing: [RssSource],
duplicatePolicy: DuplicatePolicy = .replace,
addGroup: String? = nil
) throws -> [RssSource] {
var result = existing
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($0.element.sourceUrl, $0.offset) })
for original in incoming where !original.sourceUrl.isEmpty {
var source = original
if let addGroup, !addGroup.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source.addGroups(addGroup) }
if let index = indexes[source.sourceUrl] {
switch duplicatePolicy {
case .replace: result[index] = source
case .skip: continue
case .fail: throw RSSSourceImportError.duplicate(source.sourceUrl)
}
} else {
indexes[source.sourceUrl] = result.count
result.append(source)
}
}
return result
}
public func defaultSelection(incoming: [RssSource], existing: [RssSource]) -> [Bool] {
let current = Dictionary(uniqueKeysWithValues: existing.map { ($0.sourceUrl, $0.lastUpdateTime) })
return incoming.map { source in
guard let oldTime = current[source.sourceUrl] else { return true }
return oldTime < source.lastUpdateTime
}
}
public func encode(_ sources: [RssSource], prettyPrinted: Bool = true) throws -> Data {
let encoder = JSONEncoder()
if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] }
return try encoder.encode(sources)
}
private struct SubscriptionManifest: Decodable {
var sourceUrls: [String]
}
}
@@ -0,0 +1,42 @@
import Foundation
public enum RuleImportInput: Sendable, Equatable {
case json(String)
case remote(URL, withoutUserAgent: Bool)
public static func parse(_ input: String, kind: String = "bookSource") throws -> Self {
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("{"), value.hasSuffix("}") { return .json(value) }
if value.hasPrefix("["), value.hasSuffix("]"), (try? JSONValue.decode(data: Data(value.utf8))) != nil {
return .json(value)
}
if let start = value.range(of: "]("), value.hasSuffix(")") {
value = String(value[start.upperBound..<value.index(before: value.endIndex)])
}
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "*`<> \n\r\t"))
if let url = URL(string: value), url.scheme?.lowercased() == "legado" {
guard url.host?.lowercased() == "import", url.path.lowercased() == "/" + kind.lowercased(),
let src = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "src" })?.value,
!src.lowercased().hasPrefix("legado:") else {
throw BookSourceImportError.invalidDocument("二维码或深链类型不匹配,需要 \(kind)")
}
return try parse(src, kind: kind)
}
let marker = "#requestWithoutUA"
let withoutUA = value.hasSuffix(marker)
if withoutUA { value = String(value.dropLast(marker.count)) }
guard let url = URL(string: value), let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme), url.host?.isEmpty == false else {
throw BookSourceImportError.invalidDocument("请输入 JSON、HTTP(S) 地址或 Legado 导入二维码")
}
return .remote(url, withoutUserAgent: withoutUA)
}
public func request() -> URLRequest? {
guard case .remote(let url, let withoutUA) = self else { return nil }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45)
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
if withoutUA { request.setValue("null", forHTTPHeaderField: "User-Agent") }
return request
}
}
@@ -0,0 +1,767 @@
import CoreFoundation
import CryptoKit
import Foundation
public enum LocalBookError: Error, LocalizedError, Sendable {
case unsupportedFormat
case oversized
case unreadableEncoding
case missingFile
public var errorDescription: String? {
switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书"
case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失"
}
}
}
public struct LocalBookImportReport: Sendable {
public var book: Book
public var chapters: [BookChapter]
public init(book: Book, chapters: [BookChapter]) {
self.book = book
self.chapters = chapters
}
}
public enum TxtTocRuleSelector {
public static let separator = "🫅🈳🏻"
public static func select(
content: String,
rules: [TxtTocRule],
transformTitle: (TxtTocRule, String, Int, String?, Int) -> String = { _, value, _, _, _ in value }
) -> TxtTocRule? {
let source = content as NSString
var maximum = -1
var selected: TxtTocRule?
for rule in rules.filter(\.enable).sorted(by: { $0.serialNumber < $1.serialNumber }) {
guard !rule.rule.isEmpty else { continue }
guard let expression = try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines) else { continue }
var start = 0
var validCount = 0
var shortGapCount = 0
var lastTitle: String?
for match in expression.matches(in: content, range: NSRange(location: 0, length: source.length)) {
let length = match.range.location - start
if start == 0 || length > 1_000 {
let rawTitle = source.substring(with: match.range)
let title = transformTitle(rule, rawTitle, validCount, lastTitle, length)
if !title.isEmpty {
lastTitle = title
validCount += 1
}
start = NSMaxRange(match.range)
} else if length < 100 {
shortGapCount += 1
}
}
if validCount >= shortGapCount * 3, validCount > maximum + 2 {
maximum = validCount
selected = rule
if maximum > 70 { break }
}
}
return selected
}
}
public enum TxtTocRuleDefaults {
public static func load(bundle: Bundle = .main) -> [TxtTocRule] {
guard let url = bundle.url(forResource: "txtTocRule", withExtension: "json"),
let data = try? Data(contentsOf: url),
let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) else { return [] }
return values.sorted { $0.serialNumber < $1.serialNumber }
}
}
private final class TxtVolumeState {
var lastTitle = ""
}
private struct TxtHeadingMatch {
var title: String
var start: Int64
var end: Int64
var previousLength: Int
}
public struct LocalBookService: Sendable {
public var maximumFileSize: Int64
public var tocRules: [TxtTocRule]
private var loadsLiveTocRules = false
public init(maximumFileSize: Int64 = .max, tocRules: [TxtTocRule] = []) {
self.maximumFileSize = maximumFileSize
self.tocRules = tocRules.filter(\.enable).sorted { $0.serialNumber < $1.serialNumber }
}
public static func live() -> LocalBookService {
LocalBookService(tocRules: TxtTocRuleDefaults.load())
}
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])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
if Int64(values.fileSize ?? 0) > maximumFileSize { throw LocalBookError.oversized }
let data = try Data(contentsOf: sourceURL, options: .mappedIfSafe)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent("\(UUID().uuidString).txt")
try data.write(to: destination, options: .atomic)
do {
return try textFileReport(data: data, sourceURL: sourceURL, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func openTextFile(at url: URL) throws -> LocalBookImportReport {
guard url.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
return try textFileReport(data: Data(contentsOf: url, options: .mappedIfSafe), sourceURL: url, destination: url)
}
private func textFileReport(data: Data, sourceURL: URL, destination: URL) throws -> LocalBookImportReport {
let decoded = try decode(Data(data.prefix(512_000)))
let name = sourceURL.deletingPathExtension().lastPathComponent
var book = Book(bookUrl: destination.absoluteString, name: name, origin: "local")
book.originName = sourceURL.lastPathComponent
book.charset = decoded.charset
book.canUpdate = false
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(data, encoding: decoded.encoding)
let selectedRule = TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
rule, title, index, previous, length in
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
}
if let selectedRule {
book.tocUrl = selectedRule.rule + TxtTocRuleSelector.separator + selectedRule.replacement
}
let fullBOMLength = bomLength(in: data, encoding: decoded.encoding)
let hasTOC = !headingMatches(
data: data,
encoding: decoded.encoding,
bomLength: fullBOMLength,
pattern: selectedRule?.rule ?? fallbackTocPattern,
usesFirstCapture: selectedRule == nil
).isEmpty
var chapters = chapters(
data: data,
encoding: decoded.encoding,
bomLength: fullBOMLength,
book: book,
fallbackTitle: name,
selectedRule: selectedRule
)
let totalWordCount = applyWordCounts(&chapters, data: data, encoding: decoded.encoding)
book.wordCount = wordCountFormat(totalWordCount)
if hasTOC,
let first = chapters.first(where: { !$0.isVolume && $0.start == Int64(fullBOMLength) }),
first.start == Int64(fullBOMLength),
let endValue = first.end,
endValue > Int64(fullBOMLength),
let preface = String(
data: data.subdata(in: fullBOMLength..<min(Int(endValue), data.count)),
encoding: decoded.encoding
) {
book.intro = preface.utf16.count > 600 ? String(preface.prefix(600)) : preface
}
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
public func loadChapters(book: Book) throws -> ChapterListReport {
let decoded = try read(book)
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
let selectedRule = rule(storedIn: book.tocUrl) ?? TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
rule, title, index, previous, length in
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
}
var chapters = chapters(
data: decoded.data,
encoding: decoded.encoding,
bomLength: decoded.bomLength,
book: book,
fallbackTitle: book.name,
selectedRule: selectedRule
)
_ = applyWordCounts(&chapters, data: decoded.data, encoding: decoded.encoding)
return .init(chapters: chapters)
}
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
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
let start = max(Int(chapter.start ?? 0), 0)
let end = min(Int(chapter.end ?? Int64(fileSize)), fileSize)
guard start <= end else {
return .init(title: chapter.title, diagnostics: [
.init(severity: .error, code: "local.range", message: "本地章节范围无效", rule: chapter.url)
])
}
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(start))
let contentData = try handle.read(upToCount: end - start) ?? Data()
let encoding: String.Encoding
if let charset = book.charset, let stored = self.encoding(named: charset) {
encoding = stored
} else {
try handle.seek(toOffset: 0)
let probe = try handle.read(upToCount: 512_000) ?? Data()
encoding = try decode(probe).encoding
}
let content = (String(data: contentData, encoding: encoding) ?? "")
.replacingOccurrences(of: #"^[\n\s]+"#, with: "  ", options: .regularExpression)
return .init(title: chapter.title, content: content, pagesVisited: 1)
}
private func read(_ book: Book) throws -> (charset: String, encoding: String.Encoding, bomLength: Int, data: Data) {
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let data = try Data(contentsOf: url, options: .mappedIfSafe)
if let charset = book.charset, let encoding = encoding(named: charset) {
return (charset, encoding, bomLength(in: data, encoding: encoding), data)
}
let decoded = try decode(Data(data.prefix(512_000)))
return (decoded.charset, decoded.encoding, bomLength(in: data, encoding: decoded.encoding), data)
}
private func decode(_ data: Data, preferredCharset: String? = nil) throws -> (text: String, charset: String, encoding: String.Encoding, bomLength: Int) {
if let preferredCharset, let encoding = encoding(named: preferredCharset),
let value = String(data: data, encoding: encoding) {
return (stripBOM(value), preferredCharset, encoding, bomLength(in: data, encoding: encoding))
}
if data.starts(with: [0xFF, 0xFE]),
let value = String(data: data, encoding: .utf16LittleEndian) {
return (stripBOM(value), "UTF-16LE", .utf16LittleEndian, 2)
}
if data.starts(with: [0xFE, 0xFF]),
let value = String(data: data, encoding: .utf16BigEndian) {
return (stripBOM(value), "UTF-16BE", .utf16BigEndian, 2)
}
let candidates: [(String, String.Encoding)] = [
("UTF-8", .utf8),
("UTF-16", .utf16),
("GB18030", .gb18030),
("Big5", .big5)
]
for (name, encoding) in candidates {
if let value = String(data: data, encoding: encoding), isPlausible(value) {
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
}
// A bounded probe can end halfway through a UTF-8 scalar. Keep
// UTF-8 as the selected charset instead of misclassifying the
// truncated probe as UTF-16 and producing unreadable chapters.
if encoding == .utf8 {
let value = String(decoding: data, as: UTF8.self)
let replacementCount = value.reduce(into: 0) { count, character in
if character == "\u{FFFD}" { count += 1 }
}
if replacementCount <= 2, isPlausible(value) {
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
}
}
}
throw LocalBookError.unreadableEncoding
}
private func chapters(
data: Data,
encoding: String.Encoding,
bomLength: Int,
book: Book,
fallbackTitle: String,
selectedRule: TxtTocRule?
) -> [BookChapter] {
let pattern = selectedRule?.rule ?? fallbackTocPattern
let matches = headingMatches(
data: data,
encoding: encoding,
bomLength: bomLength,
pattern: pattern,
usesFirstCapture: selectedRule == nil
)
guard !matches.isEmpty else {
if data.count - bomLength > 10 * 1_024 {
return chaptersWithoutTOC(data, encoding: encoding, bomLength: bomLength, book: book)
}
var chapter = BookChapter(url: "local://chapter/0", title: fallbackTitle, bookUrl: book.bookUrl, index: 0)
chapter.start = Int64(bomLength)
chapter.end = Int64(data.count)
chapter.baseUrl = book.bookUrl
return finalized([chapter], book: book)
}
var previousTitle: String?
var chapters: [BookChapter] = []
let volumeState = TxtVolumeState()
if let first = matches.first,
first.start > Int64(bomLength),
let preface = String(
data: data.subdata(in: bomLength..<min(Int(first.start), data.count)),
encoding: encoding
),
!preface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
var insertedVolumes: [String] = []
let title = transformedTitle(
"前言",
script: selectedRule?.replacement,
index: chapters.count,
book: book,
volumeState: volumeState,
onVolume: { insertedVolumes.append($0) }
)
for volumeTitle in insertedVolumes {
var volume = BookChapter(url: "", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
volume.start = Int64(bomLength)
volume.end = Int64(bomLength)
volume.baseUrl = book.bookUrl
volume.isVolume = true
chapters.append(volume)
}
if !title.isEmpty {
var chapter = BookChapter(url: "", title: title, bookUrl: book.bookUrl, index: chapters.count)
chapter.start = Int64(bomLength)
chapter.end = first.start
chapter.baseUrl = book.bookUrl
chapters.append(chapter)
}
}
for (matchIndex, match) in matches.enumerated() {
splitLastLongChapterIfNeeded(
&chapters,
data: data,
encoding: encoding,
book: book,
volumeState: volumeState,
enabled: selectedRule != nil
)
var insertedVolumes: [String] = []
let title = transformedTitle(
match.title.isEmpty ? "第 \(matchIndex + 1) 章" : match.title,
script: selectedRule?.replacement,
index: chapters.count,
previousTitle: previousTitle,
previousLength: match.previousLength,
book: book,
volumeState: volumeState,
onVolume: { insertedVolumes.append($0) }
)
previousTitle = title
let start = match.end
let end = matchIndex + 1 < matches.count ? matches[matchIndex + 1].start : Int64(data.count)
for volumeTitle in insertedVolumes {
let offset = chapters.last?.end ?? 0
var volume = BookChapter(url: "local://chapter/\(chapters.count)", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
volume.start = offset
volume.end = offset
volume.baseUrl = book.bookUrl
volume.isVolume = true
chapters.append(volume)
}
var chapter = BookChapter(url: "local://chapter/\(chapters.count)", title: title, bookUrl: book.bookUrl, index: chapters.count)
chapter.start = start
chapter.end = max(end, start)
chapter.baseUrl = book.bookUrl
chapters.append(chapter)
}
splitLastLongChapterIfNeeded(
&chapters,
data: data,
encoding: encoding,
book: book,
volumeState: volumeState,
enabled: selectedRule != nil
)
return finalized(chapters, book: book)
}
private var fallbackTocPattern: String {
#"(?mi)^[\t ]*((?:第[0-9零〇一二三四五六七八九十百千万两]+[章节卷回部篇集][^\r\n]{0,50})|(?:chapter\s+\d+[^\r\n]{0,50}))[\t ]*$"#
}
private func splitLastLongChapterIfNeeded(
_ chapters: inout [BookChapter],
data: Data,
encoding: String.Encoding,
book: Book,
volumeState: TxtVolumeState,
enabled: Bool
) {
guard enabled, book.readConfig?.splitLongChapter ?? true,
var chapter = chapters.last,
!chapter.isVolume,
let startValue = chapter.start,
let endValue = chapter.end,
endValue - startValue > 102_400 else { return }
let start = max(Int(startValue), 0)
let end = min(max(Int(endValue), start), data.count)
chapters.removeLast()
chapter.end = chapter.start
chapter.isVolume = true
chapter.tag = nil
chapters.append(chapter)
volumeState.lastTitle = chapter.title
chapters.append(contentsOf: splitByteRange(
data,
start: start,
end: end,
encoding: encoding,
book: book,
title: { "\(chapter.title)(\($0 + 1))" }
))
}
private func splitByteRange(
_ data: Data,
start initialStart: Int,
end rangeEnd: Int,
encoding: String.Encoding,
book: Book,
title: (Int) -> String
) -> [BookChapter] {
splitHeadinglessRange(data, start: initialStart, end: rangeEnd, encoding: encoding, book: book) { _, _, index in title(index) }
}
private func headingMatches(
data: Data,
encoding: String.Encoding,
bomLength: Int,
pattern: String,
usesFirstCapture: Bool
) -> [TxtHeadingMatch] {
guard let expression = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { return [] }
if data.count <= 2 * 1_024 * 1_024,
bomLength <= data.count,
let text = String(data: data.subdata(in: bomLength..<data.count), encoding: encoding) {
let source = text as NSString
let offsets = byteOffsetMap(text, encoding: encoding, initialOffset: bomLength)
return expression.matches(in: text, range: NSRange(location: 0, length: source.length)).map { match in
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
let start = min(match.range.location, offsets.count - 1)
let end = min(NSMaxRange(match.range), offsets.count - 1)
return .init(title: title, start: offsets[start], end: offsets[end], previousLength: match.range.location)
}
}
let blockSize = 512_000
var position = bomLength
var output: [TxtHeadingMatch] = []
while position < data.count {
let limit = min(position + blockSize, data.count)
var end = limit
if limit < data.count,
let newline = newlineIndex(in: data, range: position..<limit, encoding: encoding, fromEnd: true),
newline > position {
end = newline
}
if end <= position { end = limit }
let blockData = data.subdata(in: position..<end)
guard let block = String(data: blockData, encoding: encoding) else {
position = end
continue
}
let source = block as NSString
let offsets = byteOffsetMap(block, encoding: encoding, initialOffset: position)
var previousEnd = 0
for match in expression.matches(in: block, range: NSRange(location: 0, length: source.length)) {
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
let startIndex = min(match.range.location, offsets.count - 1)
let endIndex = min(NSMaxRange(match.range), offsets.count - 1)
output.append(.init(
title: title,
start: offsets[startIndex],
end: offsets[endIndex],
previousLength: max(match.range.location - previousEnd, 0)
))
previousEnd = NSMaxRange(match.range)
}
position = end
}
return output
}
private func chaptersWithoutTOC(_ data: Data, encoding: String.Encoding, bomLength: Int, book: Book) -> [BookChapter] {
let chapters = splitHeadinglessRange(data, start: bomLength, end: data.count, encoding: encoding, book: book) {
block, chapter, _ in "第\(block)章(\(chapter))"
}
return finalized(chapters, book: book)
}
private func splitHeadinglessRange(
_ data: Data,
start rangeStart: Int,
end rangeEnd: Int,
encoding: String.Encoding,
book: Book,
title: (Int, Int, Int) -> String
) -> [BookChapter] {
let bufferSize = 512_000
let maximumLength = 10 * 1_024
var cursor = rangeStart
var block = 0
var output: [BookChapter] = []
var finalChapterNumber = 0
while cursor < rangeEnd {
block += 1
let blockEnd = min(cursor + bufferSize, rangeEnd)
var chapterInBlock = 0
while blockEnd - cursor > maximumLength {
chapterInBlock += 1
let searchStart = cursor + maximumLength
let newline = newlineIndex(
in: data,
range: searchStart..<blockEnd,
encoding: encoding,
fromEnd: false
)
let end = newline ?? blockEnd
var chapter = BookChapter(
url: "",
title: title(block, chapterInBlock, output.count),
bookUrl: book.bookUrl,
index: output.count
)
chapter.start = Int64(cursor)
chapter.end = Int64(end)
chapter.baseUrl = book.bookUrl
output.append(chapter)
cursor = end
}
finalChapterNumber = chapterInBlock + 1
if blockEnd == rangeEnd { break }
}
if cursor < rangeEnd {
if rangeEnd - cursor <= 100, !output.isEmpty {
output[output.count - 1].end = Int64(rangeEnd)
} else {
var chapter = BookChapter(
url: "",
title: title(block, finalChapterNumber, output.count),
bookUrl: book.bookUrl,
index: output.count
)
chapter.start = Int64(cursor)
chapter.end = Int64(rangeEnd)
chapter.baseUrl = book.bookUrl
output.append(chapter)
}
}
return output
}
@discardableResult
private func applyWordCounts(
_ chapters: inout [BookChapter],
data: Data,
encoding: String.Encoding
) -> Int {
var total = 0
for index in chapters.indices where !chapters[index].isVolume {
let start = max(Int(chapters[index].start ?? 0), 0)
let end = min(max(Int(chapters[index].end ?? Int64(start)), start), data.count)
guard start < end else {
chapters[index].wordCount = ""
continue
}
let text = String(data: data.subdata(in: start..<end), encoding: encoding) ?? ""
let count = text.utf16.count
chapters[index].wordCount = wordCountFormat(count)
total += count
}
return total
}
private func wordCountFormat(_ words: Int) -> String {
guard words > 0 else { return "" }
guard words > 10_000 else { return "\(words)字" }
let value = Double(words) / 10_000
let rounded = (value * 10).rounded() / 10
if rounded.rounded() == rounded { return "\(Int(rounded))万字" }
return String(format: "%.1f万字", rounded)
}
private func finalized(_ chapters: [BookChapter], book: Book) -> [BookChapter] {
chapters.enumerated().map { index, value in
var chapter = value
chapter.index = index
chapter.bookUrl = book.bookUrl
let seed = book.originName + String(index) + chapter.title
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
chapter.url = String(digest.dropFirst(8).prefix(16))
return chapter
}
}
private func rule(storedIn value: String) -> TxtTocRule? {
guard !value.isEmpty else { return nil }
let parts = value.components(separatedBy: TxtTocRuleSelector.separator)
return .init(name: "本书目录规则", rule: parts[0], replacement: parts.dropFirst().first ?? "")
}
private func effectiveTocRules() -> [TxtTocRule] {
tocRules
}
private func transformedTitle(
_ value: String,
script: String?,
index: Int,
previousTitle: String? = nil,
previousLength: Int = -1,
book: Book,
volumeState: TxtVolumeState = .init(),
onVolume: ((String) -> Void)? = nil
) -> String {
guard let script = script?.trimmingCharacters(in: .whitespacesAndNewlines), !script.isEmpty else { return value }
let previous: String
if let previousTitle,
let data = try? JSONSerialization.data(withJSONObject: [previousTitle]),
let encoded = String(data: data, encoding: .utf8) {
previous = String(encoded.dropFirst().dropLast())
} else {
previous = "null"
}
let volumeLiteral = (try? JSONSerialization.data(withJSONObject: [volumeState.lastTitle]))
.flatMap { String(data: $0, encoding: .utf8) }
.map { String($0.dropFirst().dropLast()) } ?? "\"\""
let wrapped = """
var index = \(index + 1); var prevTitle = \(previous); var prevLength = \(previousLength);
var lastVolumeTitle = \(volumeLiteral);
\(script)
"""
guard let evaluated = try? JavaScriptRuleEvaluator().evaluate(
script: wrapped,
result: .string(value),
executionContext: .init(page: index + 1, book: book)
) else { return value }
let title = evaluated.value.stringValue
let volumes = evaluated.variables["__legadoVolumes"]
.flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } ?? []
for volume in volumes {
volumeState.lastTitle = volume
onVolume?(volume)
}
return title
}
private func encoding(named name: String) -> String.Encoding? {
switch name.uppercased() {
case "UTF-8", "UTF8": .utf8
case "UTF-16", "UTF16": .utf16
case "UTF-16LE", "UTF16LE": .utf16LittleEndian
case "UTF-16BE", "UTF16BE": .utf16BigEndian
case "GB18030", "GBK", "GB2312": .gb18030
case "BIG5": .big5
default: nil
}
}
private func bomLength(in data: Data, encoding: String.Encoding) -> Int {
if data.starts(with: [0xEF, 0xBB, 0xBF]) { return 3 }
if isUTF16(encoding),
data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) { return 2 }
return 0
}
private func isUTF16(_ encoding: String.Encoding) -> Bool {
encoding == .utf16 || encoding == .utf16LittleEndian || encoding == .utf16BigEndian
}
private func newlineIndex(
in data: Data,
range: Range<Int>,
encoding: String.Encoding,
fromEnd: Bool
) -> Int? {
guard !range.isEmpty else { return nil }
let bytePair: (UInt8, UInt8)?
if encoding == .utf16LittleEndian || (encoding == .utf16 && data.starts(with: [0xFF, 0xFE])) {
bytePair = (0x0A, 0x00)
} else if encoding == .utf16BigEndian || (encoding == .utf16 && data.starts(with: [0xFE, 0xFF])) {
bytePair = (0x00, 0x0A)
} else {
bytePair = nil
}
guard let bytePair else {
return fromEnd ? data[range].lastIndex(of: 0x0A) : data[range].firstIndex(of: 0x0A)
}
var index = range.lowerBound + (range.lowerBound.isMultiple(of: 2) ? 0 : 1)
guard index + 1 < range.upperBound else { return nil }
if fromEnd {
index = range.upperBound - 2
if !index.isMultiple(of: 2) { index -= 1 }
while index >= range.lowerBound {
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
index -= 2
}
} else {
while index + 1 < range.upperBound {
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
index += 2
}
}
return nil
}
private func byteOffsetMap(_ content: String, encoding: String.Encoding, initialOffset: Int) -> [Int64] {
var offsets = [Int64](repeating: Int64(initialOffset), count: content.utf16.count + 1)
var utf16Index = 0
var byteOffset = initialOffset
for scalar in content.unicodeScalars {
let scalarText = String(scalar)
let unitCount = scalarText.utf16.count
let byteCount = isUTF16(encoding)
? unitCount * 2
: (scalarText.data(using: encoding, allowLossyConversion: true)?.count ?? 0)
if unitCount > 1 {
for step in 1..<unitCount { offsets[utf16Index + step] = Int64(byteOffset) }
}
utf16Index += unitCount
byteOffset += byteCount
offsets[utf16Index] = Int64(byteOffset)
}
return offsets
}
private func textProbe(_ data: Data, encoding: String.Encoding) -> String {
let prefix = Data(data.prefix(512_000))
for removed in 0...8 where prefix.count >= removed {
let candidate = prefix.dropLast(removed)
if let value = String(data: Data(candidate), encoding: encoding) { return stripBOM(value) }
}
return ""
}
private func stripBOM(_ value: String) -> String {
value.hasPrefix("\u{feff}") ? String(value.dropFirst()) : value
}
private func isPlausible(_ value: String) -> Bool {
guard !value.isEmpty else { return true }
let controls = value.unicodeScalars.filter { $0.value < 32 && !CharacterSet.whitespacesAndNewlines.contains($0) }.count
return controls * 100 < max(value.unicodeScalars.count, 1)
}
}
private extension String.Encoding {
static let gb18030 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)
))
static let big5 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.big5.rawValue)
))
}
@@ -0,0 +1,188 @@
import Foundation
public struct LocalFileEntry: Identifiable, Sendable, Equatable {
public let url: URL
public let bytes: Int64
public let modified: Date?
public var id: String { url.absoluteString }
}
public enum LocalFileError: Error, LocalizedError, Equatable {
case outsideLibrary, invalidName, alreadyExists, missingFile, emptyCache, missingDirectory
case saveFailed(String)
public var errorDescription: String? {
switch self {
case .outsideLibrary: "只能管理应用内的本地书文件。"
case .invalidName: "文件名不能为空,不能包含路径分隔符或控制字符。"
case .alreadyExists: "同名文件已存在,请换一个名称。"
case .missingFile: "文件已不存在,请重新导入。"
case .emptyCache: "这本书还没有可导出的缓存正文,请先缓存章节。"
case .missingDirectory: "还没有这本书的目录,请先打开详情加载目录。"
case .saveFailed(let message): "书架保存失败:\(message)"
}
}
}
public struct StagedLocalFile: Sendable {
public let original: URL
public let staged: URL
}
public actor LocalFileService {
public let directory: URL
public init(directory: URL) { self.directory = directory.standardizedFileURL }
public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
return try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey],
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 }
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 }
let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let stem = source.deletingPathExtension().lastPathComponent
var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2
while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt")
suffix += 1
}
try FileManager.default.copyItem(at: source, to: target)
return target
}
public func rename(_ source: URL, to name: String) throws -> URL {
try validate(source)
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.hasPrefix("."),
!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"
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source }
guard !FileManager.default.fileExists(atPath: target.path) else { throw LocalFileError.alreadyExists }
try FileManager.default.moveItem(at: source, to: target)
return target
}
public func stageRemoval(_ source: URL) throws -> StagedLocalFile? {
try validate(source, allowMissing: true)
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")
try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged)
}
public func restore(_ file: StagedLocalFile) throws {
try validate(file.original, allowMissing: true)
guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else {
throw LocalFileError.outsideLibrary
}
try FileManager.default.moveItem(at: file.staged, to: file.original)
}
public func finishRemoval(_ file: StagedLocalFile) throws {
guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else {
throw LocalFileError.outsideLibrary
}
try FileManager.default.removeItem(at: file.staged)
}
private func validate(_ url: URL, allowMissing: Bool = false) throws {
guard url.isFileURL, url.pathExtension.lowercased() == "txt",
url.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary
}
if !allowMissing, !FileManager.default.fileExists(atPath: url.path) { throw LocalFileError.missingFile }
}
}
public struct BookFileExport: Identifiable, Sendable {
public var id: String { url.absoluteString }
public let url: URL
public let cachedChapters: Int
public let totalChapters: Int
public var isPartial: Bool { cachedChapters < totalChapters }
}
public actor BookFileExporter {
private let directory: URL
private let cache: ChapterContentCache
public init(directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LegadoExports"), cache: ChapterContentCache = .live()) {
self.directory = directory
self.cache = cache
}
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
let target = try destination(filename: filename)
do {
try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0)
} catch {
try? FileManager.default.removeItem(at: target.deletingLastPathComponent())
throw error
}
}
public func cachedBook(_ book: Book, chapters: [BookChapter]) async throws -> BookFileExport {
guard chapters.contains(where: { !$0.isVolume }) else { throw LocalFileError.missingDirectory }
let target = try destination(filename: book.name + ".txt")
do {
_ = FileManager.default.createFile(atPath: target.path, contents: nil)
let output = try FileHandle(forWritingTo: target)
defer { try? output.close() }
try output.write(contentsOf: Data((book.name + (book.author.isEmpty ? "" : "\n作者:" + book.author) + "\n\n").utf8))
var cached = 0
var total = 0
for chapter in chapters.sorted(by: { $0.index < $1.index }) {
try Task.checkCancellation()
try output.write(contentsOf: Data((chapter.title + "\n\n").utf8))
guard !chapter.isVolume else { continue }
total += 1
if let entry = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url) {
cached += 1
try output.write(contentsOf: Data((entry.content + "\n" + (entry.supplementalContent ?? "") + "\n\n").utf8))
} else {
try output.write(contentsOf: Data("【本章尚未缓存】\n\n".utf8))
}
}
guard cached > 0 else { throw LocalFileError.emptyCache }
return .init(url: target, cachedChapters: cached, totalChapters: total)
} catch {
try? FileManager.default.removeItem(at: target.deletingLastPathComponent())
throw error
}
}
public func discard(_ export: BookFileExport) throws {
guard export.url.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL else {
throw LocalFileError.outsideLibrary
}
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
}
private func destination(filename: String) 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")
}
}
@@ -0,0 +1,118 @@
import Foundation
import Network
public struct ApplicationSettings: Codable, Equatable, Sendable {
public var userAgent = ""
public var proxy = ""
public var hosts = ""
public var timeoutSeconds = 0
public var concurrentSources = 32
public var chapterCacheMB = 256
public var prefetchChapters = 1
public var recordLog = false
public var ignoreCertificateErrors = true
public init() {}
public var effectiveUserAgent: String {
userAgent.isEmpty ? LegadoURLBuilder.defaultUserAgent : userAgent
}
public func validated() throws -> Self {
var value = self
value.userAgent = userAgent.trimmingCharacters(in: .whitespacesAndNewlines)
value.proxy = proxy.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.userAgent.contains(where: { $0.isNewline || $0 == "\0" }) else {
throw SettingsError.invalid("User-Agent 不能包含换行。")
}
if !value.proxy.isEmpty { _ = try Self.proxyDictionary(value.proxy) }
_ = try Self.parseHosts(hosts)
guard timeoutSeconds == 0 || (5...300).contains(timeoutSeconds),
(1...64).contains(concurrentSources), (16...4096).contains(chapterCacheMB),
(0...20).contains(prefetchChapters) else {
throw SettingsError.invalid("网络或缓存设置超出允许范围。")
}
return value
}
public func configure(_ configuration: URLSessionConfiguration, proxyOverride: String? = nil) throws {
configuration.httpMaximumConnectionsPerHost = concurrentSources
let address = proxyOverride ?? proxy
configuration.connectionProxyDictionary = address.isEmpty ? nil : try Self.proxyDictionary(address)
if timeoutSeconds > 0 {
configuration.timeoutIntervalForRequest = Double(timeoutSeconds)
configuration.timeoutIntervalForResource = Double(timeoutSeconds)
}
}
public func configure(_ request: URLRequest) -> URLRequest {
var request = request
if timeoutSeconds > 0 { request.timeoutInterval = Double(timeoutSeconds) }
if request.value(forHTTPHeaderField: "User-Agent") == nil,
URLProtocol.property(forKey: "LegadoSuppressUserAgent", in: request) as? Bool != true {
request.setValue(effectiveUserAgent, forHTTPHeaderField: "User-Agent")
}
return request
}
public static func proxyDictionary(_ address: String) throws -> [AnyHashable: Any] {
guard let url = URL(string: address), let host = url.host, !host.isEmpty,
let scheme = url.scheme?.lowercased(), ["http", "https", "socks", "socks4", "socks5"].contains(scheme),
url.user == nil, url.password == nil, url.query == nil, url.fragment == nil,
url.path.isEmpty || url.path == "/", (1...65535).contains(url.port ?? 8080) else {
throw SettingsError.invalid("代理格式应为 http://主机:端口、https://主机:端口或 socks5://主机:端口,不含账号和密码。")
}
let port = url.port ?? (scheme.hasPrefix("socks") ? 1080 : scheme == "https" ? 443 : 8080)
if scheme.hasPrefix("socks") {
return ["SOCKSEnable": 1, "SOCKSProxy": host, "SOCKSPort": port]
}
return ["HTTPEnable": 1, "HTTPProxy": host, "HTTPPort": port,
"HTTPSEnable": 1, "HTTPSProxy": host, "HTTPSPort": port]
}
public static func parseHosts(_ text: String) throws -> [String: String] {
var values: [String: String] = [:]
for (index, line) in text.components(separatedBy: .newlines).enumerated() {
let fields = line.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
.split(whereSeparator: { $0.isWhitespace }).map(String.init)
guard !fields.isEmpty else { continue }
guard fields.count >= 2, IPv4Address(fields[0]) != nil || IPv6Address(fields[0]) != nil else {
throw SettingsError.invalid("Hosts 第 \(index + 1) 行应为 IP地址 域名,可添加 # 注释。")
}
for domain in fields.dropFirst() {
let domain = domain.lowercased()
guard domain.utf8.count <= 253, domain.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ label in
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-"
&& label.utf8.allSatisfy { (97...122).contains($0) || (48...57).contains($0) || $0 == 45 }
}) else { throw SettingsError.invalid("Hosts 第 \(index + 1) 行的域名无效。") }
values[domain] = fields[0]
}
}
return values
}
}
public enum SettingsError: Error, LocalizedError {
case invalid(String)
public var errorDescription: String? { if case .invalid(let message) = self { message } else { nil } }
}
public final class ApplicationSettingsStore: @unchecked Sendable {
public static let shared = ApplicationSettingsStore()
public static let key = "legado.applicationSettings.v1"
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard) { self.defaults = defaults }
public var settings: ApplicationSettings {
guard let data = defaults.data(forKey: Self.key),
let value = try? JSONDecoder().decode(ApplicationSettings.self, from: data),
let validated = try? value.validated() else { return .init() }
return validated
}
public func save(_ settings: ApplicationSettings) throws {
let validated = try settings.validated()
defaults.set(try JSONEncoder().encode(validated), forKey: Self.key)
}
}
@@ -0,0 +1,34 @@
import Foundation
public struct BookSourceHeaderResolver: Sendable {
public init() {}
public func resolve(_ source: BookSource, includeLoginHeader: Bool = true) throws -> [String: String] {
var headers: [String: String] = [:]
if let raw = source.header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
let json: String
if raw.lowercased().hasPrefix("@js:") {
json = try JavaScriptRuleEvaluator().evaluate(
script: String(raw.dropFirst(4)),
result: .null,
executionContext: .init(source: source)
).value.stringValue
} else if raw.lowercased().hasPrefix("<js>"), raw.lowercased().hasSuffix("</js>") {
json = try JavaScriptRuleEvaluator().evaluate(
script: String(raw.dropFirst(4).dropLast(5)),
result: .null,
executionContext: .init(source: source)
).value.stringValue
} else {
json = raw
}
if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(json) {
headers = object.mapValues(\.stringValue)
}
}
if includeLoginHeader {
headers.merge(BookSourceRuntimeStore.shared.loginHeaders(sourceID: source.id)) { _, login in login }
}
return headers
}
}
@@ -0,0 +1,939 @@
import Foundation
import CoreFoundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public struct HTTPResponse: Sendable {
public var data: Data
public var statusCode: Int
public var headers: [String: String]
public var finalURL: URL
public var requestedCharset: String?
public var requestVariables: [String: String]
public init(data: Data, statusCode: Int, headers: [String: String], finalURL: URL, requestedCharset: String? = nil, requestVariables: [String: String] = [:]) {
self.data = data
self.statusCode = statusCode
self.headers = headers
self.finalURL = finalURL
self.requestedCharset = requestedCharset
self.requestVariables = requestVariables
}
public var decodedText: String {
let encoding = requestedCharset.flatMap(HTTPTextDecoder.encoding(named:))
?? contentTypeCharset.flatMap(HTTPTextDecoder.encoding(named:))
?? HTTPTextDecoder.bomEncoding(data)
?? HTTPTextDecoder.htmlEncoding(data)
?? .utf8
if let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: encoding) { return value }
if encoding != .utf8, let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: .utf8) { return value }
return String(decoding: data, as: UTF8.self)
}
public var decodedJSON: JSONValue? {
try? JSONValue.decode(data: Data(decodedText.utf8))
}
private var contentTypeCharset: String? {
guard let value = headers.first(where: { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame })?.value,
let expression = try? NSRegularExpression(pattern: #"charset\s*=\s*[\"']?([^;\"'\s]+)"#, options: .caseInsensitive),
let match = expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
let range = Range(match.range(at: 1), in: value) else { return nil }
return String(value[range])
}
}
public enum HTTPTextDecoder {
public static func htmlEncoding(_ data: Data) -> String.Encoding? {
let head = String(decoding: data.prefix(16_384), as: UTF8.self)
guard let meta = try? NSRegularExpression(pattern: #"<meta\b[^>]*>"#, options: .caseInsensitive),
let charset = try? NSRegularExpression(pattern: #"charset\s*=\s*["']?\s*([a-zA-Z0-9_-]+)"#, options: .caseInsensitive) else { return nil }
for tag in meta.matches(in: head, range: NSRange(head.startIndex..., in: head)) {
guard let match = charset.firstMatch(in: head, range: tag.range),
let range = Range(match.range(at: 1), in: head),
let encoding = encoding(named: String(head[range])) else { continue }
return encoding
}
return nil
}
public static func encoding(named rawValue: String) -> String.Encoding? {
let value = rawValue.lowercased().replacingOccurrences(of: "_", with: "-").trimmingCharacters(in: .whitespacesAndNewlines)
switch value {
case "utf-8", "utf8": return .utf8
case "utf-16", "utf16": return .utf16
case "utf-16le", "utf16le": return .utf16LittleEndian
case "utf-16be", "utf16be": return .utf16BigEndian
case "utf-32", "utf32": return .utf32
case "utf-32le", "utf32le": return .utf32LittleEndian
case "utf-32be", "utf32be": return .utf32BigEndian
case "gbk", "gb2312", "gb-2312", "gb18030", "gb-18030":
return foundationEncoding(.GB_18030_2000)
case "big5", "big-5": return foundationEncoding(.big5)
case "shift-jis", "shiftjis", "sjis": return .shiftJIS
case "iso-8859-1", "latin1": return .isoLatin1
case "windows-1252", "cp1252": return .windowsCP1252
default: return nil
}
}
private static func foundationEncoding(_ value: CFStringEncodings) -> String.Encoding {
.init(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(value.rawValue)))
}
public static func bomEncoding(_ data: Data) -> String.Encoding? {
let prefix = Array(data.prefix(4))
if prefix.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 }
if prefix.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian }
if prefix.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian }
if prefix.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian }
if prefix.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian }
return nil
}
public static func removingBOM(_ data: Data) -> Data {
let bytes = Array(data.prefix(4))
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return data.dropFirst(3) }
if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) || bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return data.dropFirst(4) }
if bytes.starts(with: [0xFF, 0xFE]) || bytes.starts(with: [0xFE, 0xFF]) { return data.dropFirst(2) }
return data
}
}
public protocol HTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> HTTPResponse
}
public final class LegadoUnsafeTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
public static let shared = LegadoUnsafeTrustDelegate()
private let settingsStore: ApplicationSettingsStore
private let interceptHostsRedirects: Bool
public init(settingsStore: ApplicationSettingsStore = .shared, interceptHostsRedirects: Bool = true) {
self.settingsStore = settingsStore
self.interceptHostsRedirects = interceptHostsRedirects
super.init()
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
Self.resolution(for: challenge, ignoreCertificateErrors: settingsStore.settings.ignoreCertificateErrors)
}
public func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void
) {
let settings = settingsStore.settings
let address = try? ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""]
completionHandler(interceptHostsRedirects && settings.proxy.isEmpty && address != nil ? nil : request)
}
public static func resolve(
_ challenge: URLAuthenticationChallenge,
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let result = resolution(for: challenge)
completionHandler(result.0, result.1)
}
static func resolution(
for challenge: URLAuthenticationChallenge,
ignoreCertificateErrors: Bool = ApplicationSettingsStore.shared.settings.ignoreCertificateErrors
) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
let disposition: URLSession.AuthChallengeDisposition
let credential: URLCredential?
if ignoreCertificateErrors,
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust {
disposition = .useCredential
credential = URLCredential(trust: trust)
} else {
disposition = .performDefaultHandling
credential = nil
}
return (disposition, credential)
}
}
public struct HTTPStreamResponse: Sendable {
public var statusCode: Int
public var headers: [String: String]
public var finalURL: URL
public var expectedContentLength: Int64?
public var chunks: AsyncThrowingStream<Data, Error>
public init(
statusCode: Int,
headers: [String: String],
finalURL: URL,
expectedContentLength: Int64? = nil,
chunks: AsyncThrowingStream<Data, Error>
) {
self.statusCode = statusCode
self.headers = headers
self.finalURL = finalURL
self.expectedContentLength = expectedContentLength
self.chunks = chunks
}
}
public protocol StreamingHTTPTransport: HTTPTransport {
func stream(for request: URLRequest) async throws -> HTTPStreamResponse
}
public protocol ProxyHTTPTransport: HTTPTransport {
func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse
}
public enum HTTPClientError: Error, LocalizedError, Sendable {
case nonHTTPResponse
case statusCode(Int)
case statusResponse(HTTPResponse)
public var errorDescription: String? {
switch self {
case .nonHTTPResponse: "服务器没有返回 HTTP 响应"
case .statusCode(let code): "服务器返回 HTTP \(code)"
case .statusResponse(let response): "服务器返回 HTTP \(response.statusCode)"
}
}
}
public final class URLSessionHTTPTransport: ProxyHTTPTransport, StreamingHTTPTransport, @unchecked Sendable {
public static let shared = URLSessionHTTPTransport()
private let configuration: URLSessionConfiguration
private let settingsStore: ApplicationSettingsStore
private let lock = NSLock()
private var cachedSettings: ApplicationSettings?
private var cachedSession: URLSession?
public init(configuration: URLSessionConfiguration = .ephemeral, settingsStore: ApplicationSettingsStore = .shared) {
self.configuration = configuration.copy() as! URLSessionConfiguration
self.settingsStore = settingsStore
self.configuration.httpCookieAcceptPolicy = .always
self.configuration.httpShouldSetCookies = true
self.configuration.httpCookieStorage = HTTPCookieStorage.shared
self.configuration.requestCachePolicy = .reloadRevalidatingCacheData
}
deinit { cachedSession?.finishTasksAndInvalidate() }
private func configuredSession(settings: ApplicationSettings) throws -> URLSession {
lock.lock()
defer { lock.unlock() }
if settings == cachedSettings, let cachedSession { return cachedSession }
let config = configuration.copy() as! URLSessionConfiguration
try settings.configure(config)
let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore), delegateQueue: nil)
cachedSession?.finishTasksAndInvalidate()
cachedSession = session
cachedSettings = settings
return session
}
public func data(for request: URLRequest) async throws -> HTTPResponse {
try await load(request, redirectsRemaining: 10)
}
private func load(_ original: URLRequest, redirectsRemaining: Int) async throws -> HTTPResponse {
try Task.checkCancellation()
let settings = settingsStore.settings
let request = settings.configure(original)
let started = Date()
do {
let response: HTTPResponse
if settings.proxy.isEmpty,
let address = try ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] {
response = try await HostsHTTPTransport.data(for: request, address: address, ignoreCertificateErrors: settings.ignoreCertificateErrors)
} else {
let session = try configuredSession(settings: settings)
response = try await rawData(for: request, session: session)
}
if [301, 302, 303, 307, 308].contains(response.statusCode),
let location = response.headers.first(where: { $0.key.lowercased() == "location" })?.value,
let target = URL(string: location, relativeTo: response.finalURL)?.absoluteURL {
guard redirectsRemaining > 0 else { throw URLError(.httpTooManyRedirects) }
var next = request
next.url = target
if response.statusCode == 303 || ([301, 302].contains(response.statusCode) && next.httpMethod == "POST") {
next.httpMethod = "GET"; next.httpBody = nil
next.setValue(nil, forHTTPHeaderField: "Content-Length")
next.setValue(nil, forHTTPHeaderField: "Content-Type")
}
if target.host?.lowercased() != request.url?.host?.lowercased()
|| target.scheme?.lowercased() != request.url?.scheme?.lowercased()
|| target.port != request.url?.port {
next.setValue(nil, forHTTPHeaderField: "Authorization")
next.setValue(nil, forHTTPHeaderField: "Cookie")
}
next.setValue(nil, forHTTPHeaderField: "Host")
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
return try await load(next, redirectsRemaining: redirectsRemaining - 1)
}
guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) }
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
return response
} catch {
DiagnosticLog.shared.network(request, error: error, startedAt: started)
throw error
}
}
public func download(for request: URLRequest) async throws -> (Data, URLResponse) {
let value = try await data(for: request)
guard let response = HTTPURLResponse(url: value.finalURL, statusCode: value.statusCode, httpVersion: nil, headerFields: value.headers) else {
throw HTTPClientError.nonHTTPResponse
}
return (value.data, response)
}
public func stream(for request: URLRequest) async throws -> HTTPStreamResponse {
let settings = settingsStore.settings
let request = settings.configure(request)
if !settings.hosts.isEmpty, settings.proxy.isEmpty {
let response = try await data(for: request)
return .init(statusCode: response.statusCode, headers: response.headers, finalURL: response.finalURL,
expectedContentLength: Int64(response.data.count), chunks: AsyncThrowingStream { continuation in
continuation.yield(response.data)
continuation.finish()
})
}
let session = try configuredSession(settings: settings)
let started = Date()
let bytes: URLSession.AsyncBytes
let response: URLResponse
do {
(bytes, response) = try await session.bytes(for: request)
} catch {
DiagnosticLog.shared.network(request, error: error, startedAt: started)
throw error
}
guard let http = response as? HTTPURLResponse, let finalURL = http.url else {
throw HTTPClientError.nonHTTPResponse
}
let headers = Self.headers(from: http)
DiagnosticLog.shared.network(request, status: http.statusCode, startedAt: started)
guard (200 ... 299).contains(http.statusCode) else {
var body = Data()
for try await byte in bytes { body.append(byte) }
throw HTTPClientError.statusResponse(.init(
data: body,
statusCode: http.statusCode,
headers: headers,
finalURL: finalURL
))
}
let chunks = AsyncThrowingStream<Data, Error> { continuation in
let task = Task {
do {
var chunk = Data()
chunk.reserveCapacity(32 * 1_024)
for try await byte in bytes {
try Task.checkCancellation()
chunk.append(byte)
if chunk.count >= 32 * 1_024 {
continuation.yield(chunk)
chunk.removeAll(keepingCapacity: true)
}
}
if !chunk.isEmpty { continuation.yield(chunk) }
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
return .init(
statusCode: http.statusCode,
headers: headers,
finalURL: finalURL,
expectedContentLength: http.expectedContentLength >= 0 ? http.expectedContentLength : nil,
chunks: chunks
)
}
public func data(for original: URLRequest, proxy: String) async throws -> HTTPResponse {
let settings = settingsStore.settings
let config = configuration.copy() as! URLSessionConfiguration
try settings.configure(config, proxyOverride: proxy)
let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore, interceptHostsRedirects: false), delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let request = settings.configure(original)
let started = Date()
do {
let response = try await rawData(for: request, session: session)
guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) }
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
return response
} catch {
DiagnosticLog.shared.network(request, error: error, startedAt: started)
throw error
}
}
private func rawData(for request: URLRequest, session: URLSession) async throws -> HTTPResponse {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, let url = http.url else { throw HTTPClientError.nonHTTPResponse }
return .init(data: data, statusCode: http.statusCode, headers: Self.headers(from: http), finalURL: url)
}
private static func headers(from response: HTTPURLResponse) -> [String: String] {
response.allHeaderFields.reduce(into: [:]) { result, entry in
result[String(describing: entry.key)] = String(describing: entry.value)
}
}
}
public final class LegadoCookieStore: @unchecked Sendable {
public static let shared = LegadoCookieStore()
private let storage: HTTPCookieStorage
private let lock = NSLock()
private let persistent = PersistentRuntimeCache.shared
public init(storage: HTTPCookieStorage = .shared) {
self.storage = storage
}
public func cookieHeader(for value: String) -> String {
guard let url = normalizedURL(value) else { return "" }
lock.lock()
let cookies = storage.cookies(for: url) ?? []
lock.unlock()
let session = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? ""
let saved = persistent.cookie(for: persistenceKey(url)) ?? ""
return mergeCookieHeaders(saved, session)
}
public func cookie(for value: String, key: String) -> String {
guard let url = normalizedURL(value) else { return "" }
lock.lock()
let result = storage.cookies(for: url)?.first(where: { $0.name == key })?.value
lock.unlock()
if let result { return result }
return cookieMap(persistent.cookie(for: persistenceKey(url)) ?? "")[key] ?? ""
}
public func setCookie(_ cookie: String, for value: String) {
guard let url = normalizedURL(value), !cookie.isEmpty else { return }
var cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookie], for: url)
if cookies.isEmpty,
let host = url.host,
let first = cookie.split(separator: ";").first {
let pair = first.split(separator: "=", maxSplits: 1).map(String.init)
if pair.count == 2, let fallback = HTTPCookie(properties: [
.name: pair[0].trimmingCharacters(in: .whitespaces),
.value: pair[1],
.domain: host,
.path: "/",
.secure: url.scheme == "https" ? "TRUE" : "FALSE"
]) {
cookies = [fallback]
}
}
lock.lock()
cookies.forEach(storage.setCookie)
lock.unlock()
let old = persistent.cookie(for: persistenceKey(url)) ?? ""
persistent.putCookie(
key: persistenceKey(url),
value: mergeCookieHeaders(old, persistentCookieHeader(cookie))
)
}
public func removeCookies(for value: String) {
guard let url = normalizedURL(value) else { return }
lock.lock()
storage.cookies(for: url)?.forEach(storage.deleteCookie)
lock.unlock()
persistent.deleteCookie(key: persistenceKey(url))
}
private func normalizedURL(_ value: String) -> URL? {
if let url = URL(string: value), url.scheme != nil { return url }
return URL(string: "https://\(value)")
}
private func persistenceKey(_ url: URL) -> String {
guard let host = url.host?.lowercased() else { return url.absoluteString }
return Self.persistenceDomain(for: host)
}
static func persistenceDomain(for host: String) -> String {
let host = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
guard !host.isEmpty, !isIPAddress(host) else { return host }
let labels = host.split(separator: ".").map(String.init)
guard labels.count > 2 else { return host }
let lastTwo = labels.suffix(2).joined(separator: ".")
let publicSecondLevels: Set<String> = [
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn",
"com.hk", "net.hk", "org.hk", "com.tw", "net.tw", "org.tw",
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk",
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"co.nz", "net.nz", "org.nz", "co.kr", "or.kr", "ne.kr",
"co.in", "firm.in", "net.in", "org.in", "gen.in", "ind.in",
"com.sg", "net.sg", "org.sg", "com.br", "net.br", "org.br"
]
return publicSecondLevels.contains(lastTwo)
? labels.suffix(3).joined(separator: ".")
: lastTwo
}
private static func isIPAddress(_ host: String) -> Bool {
if host.contains(":") { return host.allSatisfy { $0.isHexDigit || $0 == ":" || $0 == "." } }
let parts = host.split(separator: ".")
return parts.count == 4 && parts.allSatisfy { part in
guard let value = Int(part) else { return false }
return (0...255).contains(value)
}
}
private func cookieMap(_ header: String) -> [String: String] {
var result: [String: String] = [:]
for part in header.split(separator: ";") {
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { continue }
result[pair[0].trimmingCharacters(in: .whitespaces)] = pair[1].trimmingCharacters(in: .whitespaces)
}
return result
}
private func mergeCookieHeaders(_ headers: String...) -> String {
var values: [String: String] = [:]
var order: [String] = []
for header in headers {
for part in header.split(separator: ";") {
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { continue }
let name = pair[0].trimmingCharacters(in: .whitespaces)
if values[name] == nil { order.append(name) }
values[name] = pair[1].trimmingCharacters(in: .whitespaces)
}
}
return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ")
}
private func persistentCookieHeader(_ header: String) -> String {
let attributes = Set(["path", "domain", "expires", "max-age", "samesite"])
return header.split(separator: ";").compactMap { part -> String? in
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { return nil }
let name = pair[0].trimmingCharacters(in: .whitespaces)
guard !attributes.contains(name.lowercased()) else { return nil }
return "\(name)=\(pair[1].trimmingCharacters(in: .whitespaces))"
}.joined(separator: "; ")
}
}
public protocol SourceWebViewRequestExecuting: Sendable {
@MainActor
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse
}
public final class SourceWebViewExecutorRegistry: @unchecked Sendable {
public static let shared = SourceWebViewExecutorRegistry()
private let lock = NSLock()
private var storedExecutor: (any SourceWebViewRequestExecuting)?
public init() {}
public func register(_ executor: any SourceWebViewRequestExecuting) {
lock.lock()
storedExecutor = executor
lock.unlock()
}
public func executor() -> (any SourceWebViewRequestExecuting)? {
lock.lock()
let value = storedExecutor
lock.unlock()
return value
}
}
public struct SourceRateLimit: Sendable, Equatable {
public var accessLimit: Int
public var intervalMilliseconds: Int64
public init?(rawValue: String?) {
guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, value != "0" else { return nil }
if let slash = value.firstIndex(of: "/"),
let count = Int(value[..<slash]),
let interval = Int64(value[value.index(after: slash)...]),
count > 0, interval > 0 {
accessLimit = count
intervalMilliseconds = interval
} else if let interval = Int64(value), interval > 0 {
accessLimit = 1
intervalMilliseconds = interval
} else {
return nil
}
}
}
public struct SourceRateWindow: Sendable, Equatable {
public var startedAt: Int64
public var frequency: Int
public init(startedAt: Int64 = 0, frequency: Int = 0) {
self.startedAt = startedAt
self.frequency = frequency
}
public mutating func reserve(now: Int64, limit: SourceRateLimit) -> Int64 {
if frequency == 0 || now >= startedAt + limit.intervalMilliseconds {
startedAt = now
frequency = 1
return 0
}
if frequency < limit.accessLimit {
frequency += 1
return 0
}
return max(startedAt + limit.intervalMilliseconds - now, 0)
}
}
public actor SourceNetworkPolicy {
public static let shared = SourceNetworkPolicy()
private struct StoredCookie: Sendable {
var name: String
var value: String
var domain: String
var path: String
var expiresAt: Date?
var secure: Bool
}
private var windows: [String: SourceRateWindow] = [:]
private var cookies: [String: [StoredCookie]] = [:]
private let javaScript: JavaScriptRuleEvaluator
public init(javaScript: JavaScriptRuleEvaluator = .init()) { self.javaScript = javaScript }
public func execute(
_ prepared: PreparedRequest,
source: BookSource,
transport: any HTTPTransport,
timeout: TimeInterval,
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil,
snapshotStage: DifferentialStage = .debug
) async throws -> HTTPResponse {
try await acquire(source: source)
if prepared.url.scheme?.lowercased() == "data" {
do {
let response = try dataURIResponse(for: prepared)
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
await snapshotRecorder?.record(
stage: snapshotStage,
sourceID: source.id,
prepared: prepared,
rawResponse: response,
response: transformed,
timeout: timeout
)
return transformed
} catch {
await snapshotRecorder?.recordFailure(
stage: snapshotStage,
sourceID: source.id,
prepared: prepared,
response: nil,
error: error,
timeout: timeout
)
throw error
}
}
if prepared.useWebView || prepared.webJavaScript != nil {
guard let executor = webViewExecutor ?? SourceWebViewExecutorRegistry.shared.executor() else {
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "后台 WKWebView 请求")
}
return try await executeWebView(
prepared,
source: source,
executor: executor,
timeout: timeout,
snapshotRecorder: snapshotRecorder,
snapshotStage: snapshotStage
)
}
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
if source.enabledCookieJar == true {
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
let merged = mergeCookieHeaders(stored, explicit)
if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") }
}
let retryCount = max(prepared.retry, 0)
let attempts = retryCount == Int.max ? Int.max : retryCount + 1
var lastError: Error = HTTPClientError.nonHTTPResponse
for attempt in 0..<attempts {
try Task.checkCancellation()
do {
var response: HTTPResponse
if let proxy = prepared.proxy {
guard let proxyTransport = transport as? any ProxyHTTPTransport else {
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "当前传输层不支持代理")
}
response = try await proxyTransport.data(for: request, proxy: proxy)
} else {
response = try await transport.data(for: request)
}
response.requestedCharset = prepared.charset
guard (200...299).contains(response.statusCode) else {
throw HTTPClientError.statusResponse(response)
}
if source.enabledCookieJar == true { captureCookies(response: response, sourceID: source.id) }
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
await snapshotRecorder?.record(stage: snapshotStage, sourceID: source.id, prepared: prepared, rawResponse: response, response: transformed, timeout: timeout)
return transformed
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if case HTTPClientError.statusResponse(let response) = error,
source.enabledCookieJar == true {
captureCookies(response: response, sourceID: source.id)
}
guard case HTTPClientError.statusResponse = error, attempt + 1 < attempts else { break }
}
}
let failedResponse: HTTPResponse? = if case HTTPClientError.statusResponse(let response) = lastError { response } else { nil }
await snapshotRecorder?.recordFailure(stage: snapshotStage, sourceID: source.id, prepared: prepared, response: failedResponse, error: lastError, timeout: timeout)
throw lastError
}
public func executeStream(
_ prepared: PreparedRequest,
source: BookSource,
transport: any StreamingHTTPTransport,
timeout: TimeInterval
) async throws -> HTTPStreamResponse {
guard prepared.url.scheme?.lowercased() != "data",
!prepared.useWebView,
prepared.webJavaScript == nil,
prepared.bodyJavaScript == nil,
prepared.responseType == nil else {
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式响应转换")
}
guard prepared.proxy == nil else {
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式代理请求")
}
try await acquire(source: source)
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
if source.enabledCookieJar == true {
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
let merged = mergeCookieHeaders(stored, explicit)
if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") }
}
let response = try await transport.stream(for: request)
if source.enabledCookieJar == true {
captureCookies(
response: .init(
data: Data(),
statusCode: response.statusCode,
headers: response.headers,
finalURL: response.finalURL
),
sourceID: source.id
)
}
return response
}
private func executeWebView(
_ prepared: PreparedRequest,
source: BookSource,
executor: any SourceWebViewRequestExecuting,
timeout: TimeInterval,
snapshotRecorder: (any DifferentialSnapshotRecording)?,
snapshotStage: DifferentialStage
) async throws -> HTTPResponse {
let started = Date()
var webPrepared = prepared
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
if source.enabledCookieJar == true {
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
let merged = mergeCookieHeaders(stored, explicit)
if !merged.isEmpty {
request.setValue(merged, forHTTPHeaderField: "Cookie")
webPrepared.headers["Cookie"] = merged
}
}
let retryCount = max(prepared.retry, 0)
let attempts = retryCount == Int.max ? Int.max : retryCount + 1
var lastError: Error = HTTPClientError.nonHTTPResponse
for attempt in 0..<attempts {
do {
var response = try await executor.execute(webPrepared, source: source, timeout: timeout)
response.requestedCharset = prepared.charset
guard (200...299).contains(response.statusCode) else {
throw HTTPClientError.statusResponse(response)
}
if source.enabledCookieJar == true { captureCookies(response: response, sourceID: source.id) }
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
await snapshotRecorder?.record(stage: snapshotStage, sourceID: source.id, prepared: prepared, rawResponse: response, response: transformed, timeout: timeout)
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
return transformed
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if case HTTPClientError.statusResponse(let response) = error,
source.enabledCookieJar == true {
captureCookies(response: response, sourceID: source.id)
}
guard case HTTPClientError.statusResponse = error, attempt + 1 < attempts else { break }
}
}
let failedResponse: HTTPResponse? = if case HTTPClientError.statusResponse(let response) = lastError { response } else { nil }
await snapshotRecorder?.recordFailure(stage: snapshotStage, sourceID: source.id, prepared: prepared, response: failedResponse, error: lastError, timeout: timeout)
DiagnosticLog.shared.network(request, error: lastError, startedAt: started)
throw lastError
}
private nonisolated func transformBodyIfNeeded(
_ original: HTTPResponse,
prepared: PreparedRequest,
source: BookSource
) async throws -> HTTPResponse {
var response = original
response.requestVariables = prepared.variables
if prepared.responseType != nil {
response.data = Data(original.data.map { String(format: "%02x", $0) }.joined().utf8)
response.requestedCharset = "utf-8"
return response
}
guard let script = prepared.bodyJavaScript else { return response }
let transformed = try await javaScript.evaluateAsync(
script: script,
result: .string(response.decodedText),
executionContext: .init(
variables: response.requestVariables,
baseURL: response.finalURL.absoluteString,
key: prepared.key,
page: prepared.page,
source: source,
book: prepared.book,
chapter: prepared.chapter
)
)
response.data = Data(transformed.value.stringValue.utf8)
response.requestedCharset = "utf-8"
response.requestVariables = transformed.variables
return response
}
public func cookieHeader(sourceID: String, url: URL) -> String {
let now = Date()
let host = url.host?.lowercased() ?? ""
let path = url.path.isEmpty ? "/" : url.path
let values = (cookies[sourceID] ?? []).filter { cookie in
(cookie.expiresAt == nil || cookie.expiresAt! > now)
&& domainMatches(host: host, domain: cookie.domain)
&& path.hasPrefix(cookie.path)
&& (!cookie.secure || url.scheme?.lowercased() == "https")
}
return values.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
}
public func setCookieHeader(_ header: String, sourceID: String, url: URL) {
for part in header.split(separator: ";") {
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { continue }
upsert(.init(name: pair[0].trimmingCharacters(in: .whitespaces), value: pair[1], domain: url.host ?? "", path: "/", expiresAt: nil, secure: url.scheme == "https"), sourceID: sourceID)
}
}
public func removeCookies(sourceID: String) { cookies[sourceID] = nil }
private func acquire(source: BookSource) async throws {
guard let limit = SourceRateLimit(rawValue: source.concurrentRate) else { return }
while true {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
var window = windows[source.id] ?? .init()
let wait = window.reserve(now: now, limit: limit)
windows[source.id] = window
guard wait > 0 else { return }
try await Task.sleep(nanoseconds: UInt64(wait) * 1_000_000)
}
}
private func captureCookies(response: HTTPResponse, sourceID: String) {
guard let header = response.headers.first(where: { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame })?.value else { return }
LegadoCookieStore.shared.setCookie(header, for: response.finalURL.absoluteString)
for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: response.finalURL) {
upsert(.init(name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expiresAt: cookie.expiresDate, secure: cookie.isSecure), sourceID: sourceID)
}
}
private func dataURIResponse(for prepared: PreparedRequest) throws -> HTTPResponse {
let value = prepared.url.absoluteString
guard value.lowercased().hasPrefix("data:"),
let comma = value.firstIndex(of: ",") else {
throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 无效")
}
let metadata = String(value[value.index(value.startIndex, offsetBy: 5)..<comma])
let payload = String(value[value.index(after: comma)...])
let isBase64 = metadata.split(separator: ";").contains { $0.caseInsensitiveCompare("base64") == .orderedSame }
let data: Data?
if isBase64 {
data = Data(base64Encoded: payload.removingPercentEncoding ?? payload, options: .ignoreUnknownCharacters)
} else {
data = (payload.removingPercentEncoding ?? payload).data(using: .utf8)
}
guard let data else {
throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 内容无法解码")
}
let mediaType = metadata.split(separator: ";", maxSplits: 1).first.map(String.init)
let headers = mediaType.flatMap { $0.isEmpty ? nil : ["Content-Type": $0] } ?? [:]
return HTTPResponse(data: data, statusCode: 200, headers: headers, finalURL: prepared.url)
}
private func upsert(_ cookie: StoredCookie, sourceID: String) {
var values = cookies[sourceID] ?? []
values.removeAll { $0.name == cookie.name && $0.domain == cookie.domain && $0.path == cookie.path }
values.append(cookie)
cookies[sourceID] = values
}
private func domainMatches(host: String, domain: String) -> Bool {
let normalized = domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
return host == normalized || host.hasSuffix(".\(normalized)")
}
private func mergeCookieHeaders(_ headers: String...) -> String {
var values: [String: String] = [:]
var order: [String] = []
for header in headers {
for part in header.split(separator: ";") {
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { continue }
let name = pair[0].trimmingCharacters(in: .whitespaces)
if values[name] == nil { order.append(name) }
values[name] = pair[1].trimmingCharacters(in: .whitespaces)
}
}
return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ")
}
}
@@ -0,0 +1,198 @@
import Foundation
import Network
import Security
public enum HostsHTTPTransport {
public static func data(for request: URLRequest, address: String, ignoreCertificateErrors: Bool) async throws -> HTTPResponse {
let operation = try HostsRequestOperation(request: request, address: address, ignoreCertificateErrors: ignoreCertificateErrors)
return try await withTaskCancellationHandler {
try await operation.run()
} onCancel: { operation.cancel() }
}
}
private final class HostsRequestOperation: @unchecked Sendable {
private let queue = DispatchQueue(label: "legado.hosts.\(UUID().uuidString)")
private let connection: NWConnection
private let request: URLRequest
private var continuation: CheckedContinuation<HTTPResponse, Error>?
private var buffer = Data()
private var finished = false
private var cancelled = false
init(request: URLRequest, address: String, ignoreCertificateErrors: Bool) throws {
guard let url = request.url, let host = url.host,
["http", "https"].contains(url.scheme?.lowercased() ?? ""),
let port = NWEndpoint.Port(rawValue: UInt16(exactly: url.port ?? (url.scheme == "https" ? 443 : 80)) ?? 0),
port.rawValue > 0 else { throw URLError(.badURL) }
self.request = request
let parameters: NWParameters
if url.scheme?.lowercased() == "https" {
let tls = NWProtocolTLS.Options()
sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host)
sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1")
if ignoreCertificateErrors {
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { _, _, complete in complete(true) }, queue)
}
parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
} else { parameters = .tcp }
connection = NWConnection(host: NWEndpoint.Host(address), port: port, using: parameters)
}
func run() async throws -> HTTPResponse {
try await withCheckedThrowingContinuation { continuation in
queue.async {
self.continuation = continuation
if self.cancelled { self.finish(.failure(CancellationError())); return }
self.connection.stateUpdateHandler = { [weak self] state in
guard let self, !self.finished else { return }
switch state {
case .ready: self.send()
case .failed(let error): self.finish(.failure(error))
case .cancelled: self.finish(.failure(CancellationError()))
default: break
}
}
self.connection.start(queue: self.queue)
self.queue.asyncAfter(deadline: .now() + max(1, self.request.timeoutInterval)) { [weak self] in
self?.finish(.failure(URLError(.timedOut)))
}
}
}
}
func cancel() {
queue.async {
self.cancelled = true
if self.continuation != nil { self.finish(.failure(CancellationError())) }
}
}
private func send() {
guard let url = request.url, let host = url.host,
let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
finish(.failure(URLError(.badURL))); return
}
let path = (parts.percentEncodedPath.isEmpty ? "/" : parts.percentEncodedPath)
+ (parts.percentEncodedQuery.map { "?\($0)" } ?? "")
var headers = request.allHTTPHeaderFields ?? [:]
for name in headers.keys.filter({ ["host", "connection", "accept-encoding", "content-length", "transfer-encoding"].contains($0.lowercased()) }) {
headers.removeValue(forKey: name)
}
let hostField = host.contains(":") ? "[\(host)]" : host
headers["Host"] = hostField + (url.port.map { ":\($0)" } ?? "")
headers["Connection"] = "close"
headers["Accept-Encoding"] = "identity"
let body = request.httpBody ?? Data()
if !body.isEmpty || request.httpMethod == "POST" || request.httpMethod == "PUT" {
headers["Content-Length"] = String(body.count)
}
if request.httpShouldHandleCookies, headers.keys.contains(where: { $0.lowercased() == "cookie" }) == false {
headers.merge(HTTPCookie.requestHeaderFields(with: HTTPCookieStorage.shared.cookies(for: url) ?? [])) { explicit, _ in explicit }
}
let method = request.httpMethod ?? "GET"
guard !method.contains(where: { $0.isWhitespace }), !path.contains(where: { $0.isNewline }),
headers.allSatisfy({ !$0.key.contains(where: { $0.isNewline }) && !$0.value.contains(where: { $0.isNewline }) }) else {
finish(.failure(URLError(.badURL))); return
}
let head = "\(method) \(path) HTTP/1.1\r\n"
+ headers.map { "\($0.key): \($0.value)\r\n" }.joined() + "\r\n"
var data = Data(head.utf8)
data.append(body)
connection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error { self?.finish(.failure(error)) } else { self?.receive() }
})
}
private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, complete, error in
guard let self, !self.finished else { return }
if let error { self.finish(.failure(error)); return }
if let data { self.buffer.append(data) }
do {
if let response = try HostsHTTPResponseDecoder.decode(self.buffer, url: self.request.url!, method: self.request.httpMethod ?? "GET", complete: complete) {
if self.request.httpShouldHandleCookies {
let cookies = HTTPCookie.cookies(withResponseHeaderFields: response.headers, for: response.finalURL)
cookies.forEach { HTTPCookieStorage.shared.setCookie($0) }
}
self.finish(.success(response))
} else if complete { self.finish(.failure(URLError(.badServerResponse))) }
else { self.receive() }
} catch { self.finish(.failure(error)) }
}
}
private func finish(_ result: Result<HTTPResponse, Error>) {
guard !finished else { return }
finished = true
connection.stateUpdateHandler = nil
connection.cancel()
continuation?.resume(with: result)
continuation = nil
}
}
public enum HostsHTTPResponseDecoder {
public static func decode(_ data: Data, url: URL, method: String = "GET", complete: Bool = false) throws -> HTTPResponse? {
guard data.count <= 64 * 1024 * 1024 else { throw URLError(.dataLengthExceedsMaximum) }
guard let boundary = data.range(of: Data("\r\n\r\n".utf8)) else {
if data.count > 64 * 1024 { throw URLError(.badServerResponse) }
return nil
}
guard boundary.lowerBound <= 64 * 1024 else { throw URLError(.badServerResponse) }
let lines = String(decoding: data[..<boundary.lowerBound], as: UTF8.self).components(separatedBy: "\r\n")
guard let statusLine = lines.first, statusLine.hasPrefix("HTTP/1."),
let status = Int(statusLine.split(separator: " ").dropFirst().first ?? "") else { throw URLError(.badServerResponse) }
let remaining = Data(data[boundary.upperBound...])
if (100...199).contains(status) && status != 101 {
return try decode(remaining, url: url, method: method, complete: complete)
}
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { throw URLError(.badServerResponse) }
let name = String(line[..<colon]).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
}
let body: Data
if method == "HEAD" || status == 204 || status == 304 { body = Data() }
else if headers["transfer-encoding"]?.lowercased() == "chunked" {
guard let decoded = try decodeChunks(remaining) else { return nil }
body = decoded
} else if let rawLength = headers["content-length"] {
guard let length = Int(rawLength), length >= 0, length <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) }
guard remaining.count >= length else { return nil }
body = Data(remaining.prefix(length))
} else {
guard complete else { return nil }
body = remaining
}
guard headers["content-encoding"] == nil || headers["content-encoding"] == "identity" || body.isEmpty else {
throw SettingsError.invalid("Hosts 请求收到不支持的压缩响应,请移除此域名的 Hosts 覆盖后重试。")
}
return .init(data: body, statusCode: status, headers: headers, finalURL: url)
}
private static func decodeChunks(_ data: Data) throws -> Data? {
var offset = data.startIndex
var body = Data()
let newline = Data("\r\n".utf8)
while offset < data.endIndex {
guard let line = data.range(of: newline, in: offset..<data.endIndex) else { return nil }
let value = String(decoding: data[offset..<line.lowerBound], as: UTF8.self).components(separatedBy: ";")[0]
guard let count = Int(value.trimmingCharacters(in: .whitespaces), radix: 16), count >= 0,
count <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) }
offset = line.upperBound
if count == 0 {
if data[offset...].starts(with: newline) || data.range(of: Data("\r\n\r\n".utf8), in: offset..<data.endIndex) != nil { return body }
return nil
}
guard data.endIndex - offset >= count + 2 else { return nil }
body.append(data[offset..<(offset + count)])
offset += count
guard data[offset..<(offset + 2)] == newline else { throw URLError(.badServerResponse) }
offset += 2
}
return nil
}
}
@@ -0,0 +1,646 @@
import Foundation
public enum HTTPMethod: String, Codable, Sendable {
case get = "GET"
case post = "POST"
case head = "HEAD"
}
public struct URLTemplateContext: Sendable {
public var key: String?
public var page: Int?
public var variables: [String: String]
public var source: BookSource?
public var book: Book?
public var chapter: BookChapter?
public init(
key: String? = nil,
page: Int? = nil,
variables: [String: String] = [:],
source: BookSource? = nil,
book: Book? = nil,
chapter: BookChapter? = nil
) {
self.key = key
self.page = page
self.variables = variables
self.source = source
self.book = book
self.chapter = chapter
}
fileprivate func value(for expression: String) -> String? {
switch expression.trimmingCharacters(in: .whitespacesAndNewlines) {
case "key", "searchKey": key
case "page", "searchPage": page.map(String.init)
default: variables[expression.trimmingCharacters(in: .whitespacesAndNewlines)]
}
}
}
public struct PreparedRequest: Sendable {
public var suppressesUserAgent: Bool
public var ruleURL: String
public var url: URL
public var method: HTTPMethod
public var headers: [String: String]
public var body: Data?
public var charset: String?
public var retry: Int
public var useWebView: Bool
public var webJavaScript: String?
public var bodyJavaScript: String?
public var webViewDelayMilliseconds: Int
public var responseType: String?
public var proxy: String?
public var dnsIP: String?
public var serverID: Int64?
public var variables: [String: String]
public var key: String?
public var page: Int?
public var book: Book?
public var chapter: BookChapter?
public init(
ruleURL: String,
url: URL,
method: HTTPMethod,
headers: [String: String],
body: Data?,
charset: String?,
retry: Int,
useWebView: Bool,
webJavaScript: String?,
bodyJavaScript: String?,
webViewDelayMilliseconds: Int = 0,
responseType: String? = nil,
proxy: String? = nil,
dnsIP: String? = nil,
serverID: Int64? = nil,
variables: [String: String] = [:],
key: String? = nil,
page: Int? = nil,
book: Book? = nil,
chapter: BookChapter? = nil,
suppressesUserAgent: Bool = false
) {
self.suppressesUserAgent = suppressesUserAgent
self.ruleURL = ruleURL
self.url = url
self.method = method
self.headers = headers
self.body = body
self.charset = charset
self.retry = retry
self.useWebView = useWebView
self.webJavaScript = webJavaScript
self.bodyJavaScript = bodyJavaScript
self.webViewDelayMilliseconds = webViewDelayMilliseconds
self.responseType = responseType
self.proxy = proxy
self.dnsIP = dnsIP
self.serverID = serverID
self.variables = variables
self.key = key
self.page = page
self.book = book
self.chapter = chapter
}
public func urlRequest(timeout: TimeInterval = 30, handleCookies: Bool = true) -> URLRequest {
let override = ApplicationSettingsStore.shared.settings.timeoutSeconds
var request = URLRequest(url: url, timeoutInterval: override > 0 ? Double(override) : timeout)
request.httpMethod = method.rawValue
request.httpBody = method == .post ? body : nil
request.httpShouldHandleCookies = handleCookies
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
if suppressesUserAgent {
let mutable = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: "LegadoSuppressUserAgent", in: mutable)
return mutable as URLRequest
}
return request
}
}
public struct LegadoURLBuilder: Sendable {
public static let defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
public init() {}
public static func resolve(_ value: String, against baseURL: URL) -> String {
let value = value.trimmingCharacters(in: .whitespacesAndNewlines)
if value.lowercased().hasPrefix("javascript:") { return "" }
guard let pattern = try? NSRegularExpression(pattern: #",\s*(?=\{)"#),
let match = pattern.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
let range = Range(match.range, in: value) else {
return URL(string: value, relativeTo: baseURL)?.absoluteURL.absoluteString ?? value
}
let address = String(value[..<range.lowerBound])
let options = String(value[range.lowerBound...])
return (URL(string: address, relativeTo: baseURL)?.absoluteURL.absoluteString ?? address) + options
}
public static func decodeLenientJSON(_ source: String, quoteUnquotedValues: Bool = false) throws -> JSONValue {
try JSONValue.decode(data: Data(lenientJSON(source, quoteUnquotedValues: quoteUnquotedValues).utf8))
}
public func prepare(
_ template: String,
context: URLTemplateContext = .init(),
baseURL: URL? = nil,
defaultHeaders: [String: String] = [:]
) throws -> PreparedRequest {
let renderedResult = try renderResult(template, context: context, baseURL: baseURL)
let rendered = renderedResult.text
let parsed = try parseOptions(rendered, rule: template)
let options = parsed.options
var urlText = parsed.url
var variables = renderedResult.variables
if let script = options.javaScript {
let evaluated = try evaluateJavaScript(
script,
result: .string(urlText),
context: context,
variables: variables,
baseURL: baseURL
)
urlText = evaluated.value.stringValue
variables = evaluated.variables
}
let charsetEncoding = options.charset.flatMap(HTTPTextDecoder.encoding(named:))
let requestURLText = encodeQueryIfNeeded(urlText, encoding: charsetEncoding)
var urlAllowed = CharacterSet.urlFragmentAllowed
urlAllowed.insert(charactersIn: "%")
let encoded = requestURLText.addingPercentEncoding(withAllowedCharacters: urlAllowed) ?? requestURLText
guard let url = URL(string: encoded, relativeTo: baseURL)?.absoluteURL else {
throw RuleEngineError.malformedRule(rule: template, detail: "URL 无法解析:\(urlText)")
}
var headers = defaultHeaders
let proxyKey = headers.keys.first { $0.caseInsensitiveCompare("proxy") == .orderedSame }
let proxy = proxyKey.flatMap { headers.removeValue(forKey: $0) }
for (key, value) in options.headers {
for existing in Array(headers.keys) where existing.caseInsensitiveCompare(key) == .orderedSame {
headers.removeValue(forKey: existing)
}
headers[key] = value
}
let userAgentKey = headers.keys.first { $0.caseInsensitiveCompare("User-Agent") == .orderedSame }
let suppressesUserAgent = userAgentKey.flatMap { headers[$0] } == "null"
if suppressesUserAgent, let userAgentKey {
headers.removeValue(forKey: userAgentKey)
}
if !suppressesUserAgent,
headers.first(where: { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }) == nil {
headers["User-Agent"] = ApplicationSettingsStore.shared.settings.effectiveUserAgent
}
let bodyText = options.body?.stringValue
let trimmedBody = bodyText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let isStructuredBody = trimmedBody.hasPrefix("{") || trimmedBody.hasPrefix("[") || trimmedBody.hasPrefix("<")
let hasExplicitContentType = headers.keys.contains { $0.caseInsensitiveCompare("Content-Type") == .orderedSame }
let encodedBodyText = if let bodyText, !isStructuredBody, !hasExplicitContentType {
percentEncode(bodyText, encoding: charsetEncoding ?? .utf8, preserving: Self.formDelimiters, spaceAsPlus: true)
} else {
bodyText
}
let body = options.method == .post ? (encodedBodyText?.data(using: charsetEncoding ?? .utf8) ?? Data()) : nil
if options.method == .post, !hasExplicitContentType {
let charsetName = options.charset ?? "utf-8"
if trimmedBody.hasPrefix("<") {
headers["Content-Type"] = "application/xml; charset=\(charsetName)"
} else if isStructuredBody {
headers["Content-Type"] = "application/json; charset=\(charsetName)"
} else {
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=\(charsetName)"
}
}
return .init(
ruleURL: template,
url: url,
method: options.method,
headers: headers,
body: body,
charset: options.charset,
retry: options.retry,
useWebView: options.useWebView,
webJavaScript: options.webJavaScript,
bodyJavaScript: options.bodyJavaScript,
webViewDelayMilliseconds: options.webViewDelayMilliseconds,
responseType: options.responseType,
proxy: proxy,
dnsIP: options.dnsIP,
serverID: options.serverID,
variables: variables,
key: context.key,
page: context.page,
book: context.book,
chapter: context.chapter,
suppressesUserAgent: suppressesUserAgent
)
}
private static let queryDelimiters: CharacterSet = {
var value = CharacterSet.alphanumerics
value.insert(charactersIn: "-._~!$&'()*+,;=:@/?%")
return value
}()
private static let formDelimiters: CharacterSet = {
var value = CharacterSet.alphanumerics
value.insert(charactersIn: "-._~&=;%+")
return value
}()
private func encodeQueryIfNeeded(_ value: String, encoding: String.Encoding?) -> String {
guard let encoding, encoding != .utf8, let separator = value.firstIndex(of: "?") else { return value }
let queryStart = value.index(after: separator)
return String(value[...separator])
+ percentEncode(String(value[queryStart...]), encoding: encoding, preserving: Self.queryDelimiters)
}
private func percentEncode(
_ value: String,
encoding: String.Encoding,
preserving allowed: CharacterSet,
spaceAsPlus: Bool = false
) -> String {
var output = ""
for character in value {
let text = String(character)
if spaceAsPlus, text == " " {
output += "+"
continue
}
if text.unicodeScalars.allSatisfy({ $0.isASCII && allowed.contains($0) }) {
output += text
continue
}
let bytes = text.data(using: encoding) ?? Data(text.utf8)
output += bytes.map { String(format: "%%%02X", $0) }.joined()
}
return output
}
public func render(_ template: String, context: URLTemplateContext) throws -> String {
try renderResult(template, context: context, baseURL: nil).text
}
private func renderResult(
_ template: String,
context: URLTemplateContext,
baseURL: URL?
) throws -> (text: String, variables: [String: String]) {
var variables = context.variables
let segments = try evaluateJavaScriptSegments(
template,
context: context,
variables: variables,
baseURL: baseURL
)
var result = segments.text
variables = segments.variables
let inline = try NSRegularExpression(pattern: #"\{\{([\s\S]*?)\}\}"#)
let matches = inline.matches(in: result, range: NSRange(result.startIndex..., in: result)).reversed()
for match in matches {
guard let fullRange = Range(match.range(at: 0), in: result),
let expressionRange = Range(match.range(at: 1), in: result) else { continue }
let expression = String(result[expressionRange])
let value: String
if let literal = context.value(for: expression) {
value = literal
} else {
let evaluated = try evaluateJavaScript(
expression,
result: .string(result),
context: context,
variables: variables,
baseURL: baseURL
)
value = evaluated.value.stringValue
variables = evaluated.variables
}
result.replaceSubrange(fullRange, with: value)
}
if let key = context.key {
result = result.replacingOccurrences(of: "searchKey", with: key)
}
if let page = context.page {
result = result.replacingOccurrences(of: "searchPage", with: String(page))
}
if let page = context.page {
let pageExpression = try NSRegularExpression(pattern: #"<([^<>]*?)>"#)
let pageMatches = pageExpression.matches(
in: result,
range: NSRange(result.startIndex..., in: result)
).reversed()
for match in pageMatches {
guard let fullRange = Range(match.range(at: 0), in: result),
let listRange = Range(match.range(at: 1), in: result) else { continue }
let choices = result[listRange].split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !choices.isEmpty else { continue }
let selected = choices[min(max(page - 1, 0), choices.count - 1)]
result.replaceSubrange(fullRange, with: selected)
}
}
return (result, variables)
}
private func evaluateJavaScriptSegments(
_ template: String,
context: URLTemplateContext,
variables: [String: String],
baseURL: URL?
) throws -> (text: String, variables: [String: String]) {
let expression = try NSRegularExpression(
pattern: #"<js>([\s\S]*?)</js>|@js:([\s\S]*)"#,
options: .caseInsensitive
)
let matches = expression.matches(in: template, range: NSRange(template.startIndex..., in: template))
guard !matches.isEmpty else { return (template, variables) }
var output = template
var currentVariables = variables
var cursor = template.startIndex
for match in matches {
guard let matchRange = Range(match.range(at: 0), in: template) else { continue }
let prefix = String(template[cursor..<matchRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
if !prefix.isEmpty { output = prefix.replacingOccurrences(of: "@result", with: output) }
let capture = match.range(at: 2).location != NSNotFound ? match.range(at: 2) : match.range(at: 1)
guard let scriptRange = Range(capture, in: template) else { continue }
let evaluated = try evaluateJavaScript(
String(template[scriptRange]),
result: .string(output),
context: context,
variables: currentVariables,
baseURL: baseURL
)
output = evaluated.value.stringValue
currentVariables = evaluated.variables
cursor = matchRange.upperBound
}
let suffix = String(template[cursor...]).trimmingCharacters(in: .whitespacesAndNewlines)
if !suffix.isEmpty { output = suffix.replacingOccurrences(of: "@result", with: output) }
return (output, currentVariables)
}
private func evaluateJavaScript(
_ script: String,
result: JSONValue,
context: URLTemplateContext,
variables: [String: String],
baseURL: URL?
) throws -> JavaScriptRuleResult {
try JavaScriptRuleEvaluator().evaluate(
script: script,
result: result,
executionContext: .init(
variables: variables,
baseURL: baseURL?.absoluteString ?? context.source?.bookSourceUrl ?? "",
key: context.key,
page: context.page,
source: context.source,
book: context.book,
chapter: context.chapter
)
)
}
private func splitOptions(_ value: String) -> [(url: String, options: String)] {
guard let expression = try? NSRegularExpression(pattern: #"\s*,\s*(?=\{)"#) else {
return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")]
}
let matches = expression.matches(in: value, range: NSRange(value.startIndex..., in: value))
guard !matches.isEmpty else {
return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")]
}
return matches.reversed().compactMap { match in
guard let range = Range(match.range, in: value) else { return nil }
return (
String(value[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines),
String(value[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
)
}
}
private func parseOptions(_ source: String, rule: String) throws -> (url: String, options: Options) {
let candidates = splitOptions(source)
var firstError: Error?
for (index, candidate) in candidates.enumerated() {
guard !candidate.options.isEmpty else {
return (candidate.url, .init())
}
do {
let value = try decodeOptions(candidate.options)
guard case .object(let object) = value else {
throw RuleEngineError.malformedRule(rule: rule, detail: "URL 参数必须是 JSON 对象")
}
return (candidate.url, try makeOptions(object, rule: rule))
} catch {
if index == 0 { firstError = error }
continue
}
}
throw firstError ?? RuleEngineError.malformedRule(rule: rule, detail: "URL 参数 JSON 无效")
}
private func decodeOptions(_ source: String) throws -> JSONValue {
do {
return try JSONValue.decode(data: Data(source.utf8))
} catch {
do {
return try JSONValue.decode(data: Data(Self.lenientJSON(source).utf8))
} catch {
throw RuleEngineError.malformedRule(rule: source, detail: "URL 参数 JSON 无效:\(error.localizedDescription)")
}
}
}
private func makeOptions(_ object: [String: JSONValue], rule: String) throws -> Options {
var options = Options()
if let method = object["method"]?.stringValue.uppercased() {
options.method = HTTPMethod(rawValue: method) ?? .get
}
options.charset = object["charset"]?.stringValue.nilIfEmpty
options.retry = Int(object["retry"]?.stringValue ?? "") ?? 0
options.useWebView = object["webView"].map { value in
switch value {
case .bool(let enabled): enabled
case .number(let number): number != 0
case .string(let string): !string.isEmpty && string.lowercased() != "false"
default: false
}
} ?? false
options.webJavaScript = object["webJs"]?.stringValue.nilIfEmpty
options.bodyJavaScript = object["bodyJs"]?.stringValue.nilIfEmpty
options.javaScript = object["js"]?.stringValue.nilIfEmpty
options.webViewDelayMilliseconds = min(max(Int(object["webViewDelayTime"]?.stringValue ?? "") ?? 0, 0), 30_000)
options.responseType = object["type"]?.stringValue.nilIfEmpty
options.dnsIP = object["dnsIp"]?.stringValue.nilIfEmpty
options.serverID = Int64(object["serverID"]?.stringValue ?? "")
options.body = object["body"]
if let headerValue = object["headers"] {
options.headers = try parseHeaders(headerValue, rule: rule)
}
return options
}
private static func lenientJSON(_ source: String, quoteUnquotedValues: Bool = false) -> String {
let characters = Array(source)
var output = ""
var index = 0
var expectingKey = true
func peek(_ offset: Int = 0) -> Character? {
let position = index + offset
return position < characters.count ? characters[position] : nil
}
while index < characters.count {
let character = characters[index]
index += 1
switch character {
case "\"":
output.append("\"")
while index < characters.count {
let inner = characters[index]
index += 1
output.append(inner)
if inner == "\\", index < characters.count {
output.append(characters[index])
index += 1
continue
}
if inner == "\"" { break }
}
expectingKey = false
case "'":
var text = ""
while index < characters.count {
let inner = characters[index]
index += 1
if inner == "\\", index < characters.count {
let escaped = characters[index]
index += 1
text.append(escaped == "'" ? "'" : "\\\(escaped)")
continue
}
if inner == "'" { break }
text.append(inner)
}
output.append(escapedJSONString(text))
expectingKey = false
case "/":
if peek() == "/" {
index += 1
while let value = peek(), value != "\n" { index += 1 }
} else if peek() == "*" {
index += 1
while let value = peek() {
if value == "*", peek(1) == "/" {
index += 2
break
}
index += 1
}
} else {
output.append(character)
}
case "{", ",":
output.append(character)
expectingKey = true
case ":":
output.append(character)
expectingKey = false
if quoteUnquotedValues {
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
}
}
case "}", "]":
while output.last == "," { output.removeLast() }
output.append(character)
expectingKey = false
default:
output.append(character)
if expectingKey, character.isLetter || character == "_" || character == "$" {
var name = String(character)
var position = index
while position < characters.count {
let inner = characters[position]
guard inner.isLetter || inner.isNumber || inner == "_" || inner == "$" || inner == "-" else { break }
name.append(inner)
position += 1
}
var peekPosition = position
while peekPosition < characters.count, characters[peekPosition].isWhitespace {
peekPosition += 1
}
if peekPosition < characters.count, characters[peekPosition] == ":" {
output.removeLast()
output.append(escapedJSONString(name))
index = position
}
}
if !character.isWhitespace { expectingKey = false }
}
}
return output
}
private static func escapedJSONString(_ value: String) -> String {
var text = ""
for character in value {
switch character {
case "\"": text.append("\\\"")
case "\\": text.append("\\\\")
case "\n": text.append("\\n")
case "\r": text.append("\\r")
case "\t": text.append("\\t")
default: text.append(character)
}
}
return "\"\(text)\""
}
private func parseHeaders(_ value: JSONValue, rule: String) throws -> [String: String] {
let parsed: JSONValue
if case .string(let source) = value {
do {
parsed = try JSONValue.decode(data: Data(source.utf8))
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
}
} else {
parsed = value
}
guard case .object(let object) = parsed else {
throw RuleEngineError.malformedRule(rule: rule, detail: "headers 必须是对象")
}
return object.mapValues(\.stringValue)
}
private struct Options {
var method: HTTPMethod = .get
var charset: String?
var headers: [String: String] = [:]
var body: JSONValue?
var retry = 0
var useWebView = false
var webJavaScript: String?
var bodyJavaScript: String?
var javaScript: String?
var webViewDelayMilliseconds = 0
var responseType: String?
var dnsIP: String?
var serverID: Int64?
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
@@ -0,0 +1,66 @@
import CryptoKit
import Foundation
public protocol BookChapterRepository: Sendable {
func load(bookURL: String) async throws -> [BookChapter]
func save(_ chapters: [BookChapter], bookURL: String) async throws
func remove(bookURL: String) async throws
}
public actor FileBookChapterRepository: BookChapterRepository {
public static let maximumDocumentBytes = 24 * 1_024 * 1_024
private let directory: URL
public init(directory: URL) { self.directory = directory }
public static func live() -> FileBookChapterRepository {
let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/Chapters", isDirectory: true)
return FileBookChapterRepository(directory: directory)
}
public func load(bookURL: String) throws -> [BookChapter] {
let url = fileURL(bookURL)
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
let data = try Data(contentsOf: url, options: .mappedIfSafe)
guard data.count <= Self.maximumDocumentBytes else {
try? FileManager.default.removeItem(at: url)
return []
}
return try JSONDecoder().decode([BookChapter].self, from: data).sorted { $0.index < $1.index }
}
public func save(_ chapters: [BookChapter], bookURL: String) throws {
var normalized = chapters
for index in normalized.indices {
normalized[index].bookUrl = bookURL
normalized[index].index = index
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(normalized)
guard data.count <= Self.maximumDocumentBytes else {
throw CocoaError(.fileWriteOutOfSpace)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: fileURL(bookURL), options: .atomic)
}
public func remove(bookURL: String) throws {
let url = fileURL(bookURL)
if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
}
private func fileURL(_ bookURL: String) -> URL {
let name = SHA256.hash(data: Data(bookURL.utf8)).map { String(format: "%02x", $0) }.joined()
return directory.appendingPathComponent("\(name).json")
}
}
public actor InMemoryBookChapterRepository: BookChapterRepository {
private var values: [String: [BookChapter]]
public init(values: [String: [BookChapter]] = [:]) { self.values = values }
public func load(bookURL: String) -> [BookChapter] { values[bookURL] ?? [] }
public func save(_ chapters: [BookChapter], bookURL: String) { values[bookURL] = chapters }
public func remove(bookURL: String) { values[bookURL] = nil }
}
@@ -0,0 +1,71 @@
import Foundation
public protocol BookRepository: Sendable {
func load() async throws -> [Book]
func save(_ books: [Book]) async throws
}
public actor FileBookRepository: BookRepository {
private let fileURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(fileURL: URL) {
self.fileURL = fileURL
encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
decoder = JSONDecoder()
}
public func load() throws -> [Book] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
return try decoder.decode([Book].self, from: Data(contentsOf: fileURL))
}
public func save(_ books: [Book]) throws {
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try encoder.encode(books).write(to: fileURL, options: .atomic)
}
}
public actor InMemoryBookRepository: BookRepository {
private var books: [Book]
public init(books: [Book] = []) {
self.books = books
}
public func load() -> [Book] { books }
public func save(_ books: [Book]) {
self.books = books
}
}
public protocol BookGroupRepository: Sendable {
func load() async throws -> [BookGroup]
func save(_ groups: [BookGroup]) async throws
}
public actor FileBookGroupRepository: BookGroupRepository {
private let fileURL: URL
public init(fileURL: URL) { self.fileURL = fileURL }
public func load() throws -> [BookGroup] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
return try JSONDecoder().decode([BookGroup].self, from: Data(contentsOf: fileURL))
}
public func save(_ groups: [BookGroup]) throws {
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(groups).write(to: fileURL, options: .atomic)
}
}
public actor InMemoryBookGroupRepository: BookGroupRepository {
private var groups: [BookGroup]
public init(groups: [BookGroup] = []) { self.groups = groups }
public func load() -> [BookGroup] { groups }
public func save(_ groups: [BookGroup]) { self.groups = groups }
}
@@ -0,0 +1,46 @@
import Foundation
public protocol BookSourceRepository: Sendable {
func load() async throws -> [BookSource]
func save(_ sources: [BookSource]) async throws
}
public actor FileBookSourceRepository: BookSourceRepository {
private let fileURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(fileURL: URL) {
self.fileURL = fileURL
encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
decoder = JSONDecoder()
}
public func load() throws -> [BookSource] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
return try decoder.decode([BookSource].self, from: Data(contentsOf: fileURL))
}
public func save(_ sources: [BookSource]) throws {
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let data = try encoder.encode(sources)
try data.write(to: fileURL, options: .atomic)
}
}
public actor InMemoryBookSourceRepository: BookSourceRepository {
private var sources: [BookSource]
public init(sources: [BookSource] = []) {
self.sources = sources
}
public func load() -> [BookSource] { sources }
public func save(_ sources: [BookSource]) {
self.sources = sources
}
}
@@ -0,0 +1,566 @@
import Foundation
import SQLite3
public struct SearchKeyword: Codable, Hashable, Identifiable, Sendable {
public var word: String
public var usage: Int
public var lastUseTime: Int64
public var id: String { word }
public init(word: String, usage: Int = 1, lastUseTime: Int64? = nil) {
self.word = word
self.usage = usage
self.lastUseTime = lastUseTime ?? Int64(Date().timeIntervalSince1970 * 1_000)
}
}
public struct LegadoCache: Codable, Hashable, Identifiable, Sendable {
public var key: String
public var value: String?
public var deadline: Int64
public var id: String { key }
public init(key: String, value: String? = nil, deadline: Int64 = 0) {
self.key = key
self.value = value
self.deadline = deadline
}
}
public struct LegadoCookie: Codable, Hashable, Identifiable, Sendable {
public var url: String
public var cookie: String
public var id: String { url }
public init(url: String, cookie: String = "") {
self.url = url
self.cookie = cookie
}
}
public struct TxtTocRule: Codable, Hashable, Identifiable, Sendable {
public var id: Int64
public var name: String
public var rule: String
public var replacement: String
public var example: String?
public var serialNumber: Int
public var enable: Bool
public init(
id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
name: String = "",
rule: String = "",
replacement: String = "",
example: String? = nil,
serialNumber: Int = -1,
enable: Bool = true
) {
self.id = id; self.name = name; self.rule = rule; self.replacement = replacement
self.example = example; self.serialNumber = serialNumber; self.enable = enable
}
private enum CodingKeys: String, CodingKey {
case id, name, rule, replacement, example, serialNumber, enable
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
id = try value.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
rule = try value.decodeIfPresent(String.self, forKey: .rule) ?? ""
replacement = try value.decodeIfPresent(String.self, forKey: .replacement) ?? ""
example = try value.decodeIfPresent(String.self, forKey: .example)
serialNumber = try value.decodeIfPresent(Int.self, forKey: .serialNumber) ?? -1
enable = try value.decodeIfPresent(Bool.self, forKey: .enable) ?? true
}
}
public struct HttpTTS: Codable, Hashable, Identifiable, Sendable {
public var id: Int64
public var name: String
public var url: String
public var contentType: String?
public var concurrentRate: String?
public var loginUrl: String?
public var loginUi: String?
public var header: String?
public var jsLib: String?
public var enabledCookieJar: Bool?
public var loginCheckJs: String?
public var lastUpdateTime: Int64
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
self.id = id; self.name = name; self.url = url; contentType = nil; concurrentRate = "0"
loginUrl = nil; loginUi = nil; header = nil; jsLib = nil; enabledCookieJar = false
loginCheckJs = nil; lastUpdateTime = id
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let now = Int64(Date().timeIntervalSince1970 * 1_000)
id = (try? values.decode(Int64.self, forKey: .id))
?? Int64((try? values.decode(String.self, forKey: .id)) ?? "")
?? now
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
url = try values.decodeIfPresent(String.self, forKey: .url) ?? ""
contentType = try values.decodeIfPresent(String.self, forKey: .contentType)
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
if let text = try? values.decodeIfPresent(String.self, forKey: .loginUi) {
loginUi = text
} else if let json = try? values.decodeIfPresent(JSONValue.self, forKey: .loginUi) {
loginUi = (try? JSONEncoder().encode(json)).flatMap { String(data: $0, encoding: .utf8) }
} else {
loginUi = nil
}
header = try values.decodeIfPresent(String.self, forKey: .header)
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
enabledCookieJar = try values.decodeIfPresent(Bool.self, forKey: .enabledCookieJar) ?? false
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
lastUpdateTime = (try? values.decode(Int64.self, forKey: .lastUpdateTime))
?? Int64((try? values.decode(String.self, forKey: .lastUpdateTime)) ?? "")
?? now
}
}
public struct RuleSub: Codable, Hashable, Identifiable, Sendable {
public var id: Int64
public var name: String
public var url: String
public var type: Int
public var customOrder: Int
public var autoUpdate: Bool
public var update: Int64
public var updateInterval: Int
public var silentUpdate: Bool
public var js: String?
public var showRule: String?
public var sourceUrl: String?
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
self.id = id; self.name = name; self.url = url; type = 0; customOrder = 0
autoUpdate = false; update = id; updateInterval = 0; silentUpdate = false
js = nil; showRule = nil; sourceUrl = nil
}
}
public struct KeyboardAssist: Codable, Hashable, Identifiable, Sendable {
public var type: Int
public var key: String
public var value: String
public var serialNo: Int
public var id: String { "\(type)\u{0}\(key)" }
public init(type: Int = 0, key: String, value: String, serialNo: Int = 0) {
self.type = type; self.key = key; self.value = value; self.serialNo = serialNo
}
}
public struct Server: Codable, Hashable, Identifiable, Sendable {
public enum ServerType: String, Codable, Sendable { case webDAV = "WEBDAV" }
public struct WebDAVConfig: Codable, Hashable, Sendable {
public var url: String
public var username: String
public var password: String
public init(url: String, username: String, password: String) {
self.url = url; self.username = username; self.password = password
}
}
public var id: Int64
public var name: String
public var type: ServerType
public var config: String?
public var sortNumber: Int
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", type: ServerType = .webDAV) {
self.id = id; self.name = name; self.type = type; config = nil; sortNumber = 0
}
public var webDAVConfig: WebDAVConfig? {
guard type == .webDAV, let config else { return nil }
return try? JSONDecoder().decode(WebDAVConfig.self, from: Data(config.utf8))
}
}
public enum SearchKeywordOrder: Sendable { case usage, lastUseTime }
public protocol SearchKeywordRepository: Sendable {
func load(order: SearchKeywordOrder) async throws -> [SearchKeyword]
@discardableResult func record(_ word: String, at time: Int64) async throws -> SearchKeyword
func delete(_ word: String) async throws
func deleteAll() async throws
}
public actor SQLiteSearchKeywordRepository: SearchKeywordRepository {
private let database: LegadoDatabase
private let legacyWords: [String]
private var migratedLegacy = false
public init(database: LegadoDatabase, legacyWords: [String] = []) {
self.database = database
self.legacyWords = legacyWords
}
public func load(order: SearchKeywordOrder) async throws -> [SearchKeyword] {
try await migrateLegacyIfNeeded()
return try await database.loadSearchKeywords(order: order)
}
public func record(_ word: String, at time: Int64) async throws -> SearchKeyword {
try await migrateLegacyIfNeeded()
return try await database.recordSearchKeyword(word, at: time)
}
public func delete(_ word: String) async throws { try await database.deleteSearchKeyword(word) }
public func deleteAll() async throws { try await database.deleteAllSearchKeywords() }
private func migrateLegacyIfNeeded() async throws {
guard !migratedLegacy else { return }
migratedLegacy = true
try await database.importLegacySearchKeywords(legacyWords)
}
}
public actor InMemorySearchKeywordRepository: SearchKeywordRepository {
private var values: [String: SearchKeyword]
public init(values: [SearchKeyword] = []) {
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.word, $0) })
}
public func load(order: SearchKeywordOrder) -> [SearchKeyword] {
values.values.sorted {
switch order {
case .usage:
$0.usage == $1.usage ? $0.lastUseTime > $1.lastUseTime : $0.usage > $1.usage
case .lastUseTime:
$0.lastUseTime == $1.lastUseTime ? $0.usage > $1.usage : $0.lastUseTime > $1.lastUseTime
}
}
}
public func record(_ word: String, at time: Int64) -> SearchKeyword {
var value = values[word] ?? .init(word: word, usage: 0, lastUseTime: time)
value.usage += 1
value.lastUseTime = time
values[word] = value
return value
}
public func delete(_ word: String) { values[word] = nil }
public func deleteAll() { values.removeAll() }
}
public protocol LegadoCacheRepository: Sendable {
func value(for key: String, now: Int64) async throws -> String?
func put(_ cache: LegadoCache) async throws
func delete(_ key: String) async throws
func deleteSourceVariables(_ sourceKey: String) async throws
func clearExpired(now: Int64) async throws
}
public actor SQLiteLegadoCacheRepository: LegadoCacheRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func value(for key: String, now: Int64) async throws -> String? {
try await database.cacheValue(for: key, now: now)
}
public func put(_ cache: LegadoCache) async throws { try await database.putCache(cache) }
public func delete(_ key: String) async throws { try await database.deleteCache(key) }
public func deleteSourceVariables(_ sourceKey: String) async throws {
try await database.deleteSourceVariables(sourceKey)
}
public func clearExpired(now: Int64) async throws { try await database.clearExpiredCaches(now: now) }
}
public protocol LegadoCookieRepository: Sendable {
func get(_ url: String) async throws -> LegadoCookie?
func getOkHTTP() async throws -> [LegadoCookie]
func put(_ cookie: LegadoCookie) async throws
func delete(_ url: String) async throws
func deleteOkHTTP() async throws
}
public protocol SearchBookRepository: Sendable {
func cachedMatches(keyword: String) async throws -> [SearchBook]
func get(bookURL: String) async throws -> SearchBook?
func first(name: String, author: String) async throws -> SearchBook?
func changeSources(name: String, author: String, sourceGroup: String, key: String?) async throws -> [SearchBook]
func enabledWithCover(name: String, author: String) async throws -> [SearchBook]
func put(_ books: [SearchBook]) async throws
func clear(name: String, author: String) async throws
func clearExpired(before time: Int64) async throws
}
extension SearchBookRepository {
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
try await changeSources(name: keyword, author: "", sourceGroup: "", key: nil)
}
}
public protocol ConfigurationRepository: Sendable {
func loadTxtTocRules() async throws -> [TxtTocRule]
func saveTxtTocRules(_ values: [TxtTocRule]) async throws
func loadHttpTTS() async throws -> [HttpTTS]
func saveHttpTTS(_ values: [HttpTTS]) async throws
func loadRuleSubs() async throws -> [RuleSub]
func saveRuleSubs(_ values: [RuleSub]) async throws
func loadKeyboardAssists() async throws -> [KeyboardAssist]
func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws
func loadServers() async throws -> [Server]
func saveServers(_ values: [Server]) async throws
}
public actor SQLiteConfigurationRepository: ConfigurationRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func loadTxtTocRules() async throws -> [TxtTocRule] { try await database.loadTxtTocRules() }
public func saveTxtTocRules(_ values: [TxtTocRule]) async throws { try await database.replaceTxtTocRules(values) }
public func loadHttpTTS() async throws -> [HttpTTS] { try await database.loadHttpTTS() }
public func saveHttpTTS(_ values: [HttpTTS]) async throws { try await database.replaceHttpTTS(values) }
public func loadRuleSubs() async throws -> [RuleSub] { try await database.loadRuleSubs() }
public func saveRuleSubs(_ values: [RuleSub]) async throws { try await database.replaceRuleSubs(values) }
public func loadKeyboardAssists() async throws -> [KeyboardAssist] { try await database.loadKeyboardAssists() }
public func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws { try await database.replaceKeyboardAssists(values) }
public func loadServers() async throws -> [Server] { try await database.loadServers() }
public func saveServers(_ values: [Server]) async throws { try await database.replaceServers(values) }
}
public actor SQLiteSearchBookRepository: SearchBookRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
try await database.cachedSearchMatches(keyword: keyword)
}
public func get(bookURL: String) async throws -> SearchBook? {
let books = try await database.loadSearchBooks()
return books.first { $0.bookURL == bookURL }
}
public func first(name: String, author: String) async throws -> SearchBook? {
let books = try await database.loadSearchBooks()
let sources = try await database.loadBookSources()
let sourceIDs = Set(sources.map(\.bookSourceUrl))
return books
.filter { $0.name == name && $0.author == author }
.filter { sourceIDs.contains($0.origin) }
.sorted { $0.originOrder < $1.originOrder }
.first
}
public func changeSources(
name: String,
author: String,
sourceGroup: String,
key: String?
) async throws -> [SearchBook] {
let sources = try await database.loadBookSources()
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
let books = try await database.loadSearchBooks()
return books.compactMap { book in
guard book.name == name,
book.author.localizedCaseInsensitiveContains(author),
let source = sourceMap[book.origin], source.enabled,
sourceGroup.isEmpty || (source.bookSourceGroup ?? "").contains(sourceGroup) else { return nil }
if let key, !key.isEmpty,
!book.originName.localizedCaseInsensitiveContains(key),
!(book.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key) { return nil }
var value = book
value.originOrder = source.customOrder
return value
}.sorted { $0.originOrder < $1.originOrder }
}
public func enabledWithCover(name: String, author: String) async throws -> [SearchBook] {
let sources = try await database.loadBookSources()
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
let books = try await database.loadSearchBooks()
return books.compactMap { book in
guard book.name == name, book.author == author,
!(book.coverURL ?? "").isEmpty,
let source = sourceMap[book.origin], source.enabled else { return nil }
var value = book
value.originOrder = source.customOrder
return value
}.sorted { $0.originOrder < $1.originOrder }
}
public func put(_ books: [SearchBook]) async throws { try await database.putSearchBooks(books) }
public func clear(name: String, author: String) async throws {
try await database.deleteSearchBooks(name: name, author: author)
}
public func clearExpired(before time: Int64) async throws {
try await database.clearExpiredSearchBooks(before: time)
}
}
public actor SQLiteLegadoCookieRepository: LegadoCookieRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func get(_ url: String) async throws -> LegadoCookie? { try await database.cookie(for: url) }
public func getOkHTTP() async throws -> [LegadoCookie] { try await database.loadOkHTTPCookies() }
public func put(_ cookie: LegadoCookie) async throws { try await database.putCookie(cookie) }
public func delete(_ url: String) async throws { try await database.deleteCookie(url) }
public func deleteOkHTTP() async throws { try await database.deleteOkHTTPCookies() }
}
public enum SearchKeywordRepositoryFactory {
public static func live(defaults: UserDefaults = .standard) -> any SearchKeywordRepository {
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
return InMemorySearchKeywordRepository()
}
return SQLiteSearchKeywordRepository(
database: database,
legacyWords: defaults.stringArray(forKey: "bookSearchHistory") ?? []
)
}
}
public enum SearchBookRepositoryFactory {
public static func live() -> any SearchBookRepository {
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
return InMemorySearchBookRepository()
}
return SQLiteSearchBookRepository(database: database)
}
}
public final class PersistentRuntimeCache: @unchecked Sendable {
public static let shared = PersistentRuntimeCache()
private let lock = NSLock()
private var handle: OpaquePointer?
public init(url: URL = LegadoDatabase.defaultURL()) {
let migrator = try? LegadoDatabase(url: url)
_ = migrator
sqlite3_open_v2(
url.path,
&handle,
SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX,
nil
)
if let handle { sqlite3_busy_timeout(handle, 5_000) }
}
deinit { if let handle { sqlite3_close(handle) } }
public func get(_ key: String, now: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> String? {
lock.lock()
defer { lock.unlock() }
guard let handle else { return nil }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(
handle,
"SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)",
-1,
&statement,
nil
) == SQLITE_OK, let statement else { return nil }
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
sqlite3_bind_int64(statement, 2, now)
guard sqlite3_step(statement) == SQLITE_ROW,
let value = sqlite3_column_text(statement, 0) else { return nil }
return String(cString: value)
}
public func put(_ key: String, value: String, deadline: Int64 = 0) {
lock.lock()
defer { lock.unlock() }
guard let handle else { return }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(
handle,
"""
INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?)
ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline
""",
-1,
&statement,
nil
) == SQLITE_OK, let statement else { return }
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
sqlite3_bind_int64(statement, 3, deadline)
sqlite3_step(statement)
}
public func cookie(for key: String) -> String? {
lock.lock()
defer { lock.unlock() }
guard let handle else { return nil }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(handle, "SELECT cookie FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
let statement else { return nil }
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
guard sqlite3_step(statement) == SQLITE_ROW, let value = sqlite3_column_text(statement, 0) else { return nil }
return String(cString: value)
}
public func putCookie(key: String, value: String) {
lock.lock()
defer { lock.unlock() }
guard let handle else { return }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(
handle,
"INSERT INTO cookies(url, cookie) VALUES(?, ?) ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie",
-1,
&statement,
nil
) == SQLITE_OK, let statement else { return }
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
sqlite3_step(statement)
}
public func deleteCookie(key: String) {
lock.lock()
defer { lock.unlock() }
guard let handle else { return }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(handle, "DELETE FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
let statement else { return }
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
sqlite3_step(statement)
}
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
}
public actor InMemorySearchBookRepository: SearchBookRepository {
private var values: [String: SearchBook] = [:]
public init(values: [SearchBook] = []) {
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.bookURL, $0) })
}
public func get(bookURL: String) -> SearchBook? { values[bookURL] }
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
guard !keyword.isEmpty else { return [] }
return Array(values.values.filter {
$0.name.localizedCaseInsensitiveContains(keyword) || $0.author.localizedCaseInsensitiveContains(keyword)
}.sorted {
let lhsExact = $0.name == keyword || $0.author == keyword
let rhsExact = $1.name == keyword || $1.author == keyword
if lhsExact != rhsExact { return lhsExact }
return $0.time > $1.time
}.prefix(200))
}
public func first(name: String, author: String) -> SearchBook? {
values.values.filter { $0.name == name && $0.author == author }
.sorted { $0.originOrder < $1.originOrder }.first
}
public func changeSources(name: String, author: String, sourceGroup: String, key: String?) -> [SearchBook] {
values.values.filter {
$0.name == name && $0.author.localizedCaseInsensitiveContains(author)
&& (key?.isEmpty != false
|| $0.originName.localizedCaseInsensitiveContains(key ?? "")
|| ($0.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key ?? ""))
}.sorted { $0.originOrder < $1.originOrder }
}
public func enabledWithCover(name: String, author: String) -> [SearchBook] {
values.values.filter { $0.name == name && $0.author == author && !($0.coverURL ?? "").isEmpty }
.sorted { $0.originOrder < $1.originOrder }
}
public func put(_ books: [SearchBook]) { for book in books { values[book.bookURL] = book } }
public func clear(name: String, author: String) {
values = values.filter { $0.value.name != name || $0.value.author != author }
}
public func clearExpired(before time: Int64) { values = values.filter { $0.value.time >= time } }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import Foundation
public protocol RssSourceRepository: Sendable {
func all() async throws -> [RssSource]
func enabled() async throws -> [RssSource]
func disabled() async throws -> [RssSource]
func groups() async throws -> [String]
func inGroup(_ group: String) async throws -> [RssSource]
func search(_ key: String) async throws -> [RssSource]
func save(_ values: [RssSource]) async throws
func setEnabled(_ value: Bool, sourceURL: String) async throws
func remove(sourceURL: String) async throws
}
public actor SQLiteRssSourceRepository: RssSourceRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func all() async throws -> [RssSource] { try await database.loadRssSources() }
public func enabled() async throws -> [RssSource] { try await all().filter(\.enabled) }
public func disabled() async throws -> [RssSource] { try await all().filter { !$0.enabled } }
public func groups() async throws -> [String] {
var seen = Set<String>()
return try await all().flatMap(\.groups).filter { seen.insert($0).inserted }
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
public func inGroup(_ group: String) async throws -> [RssSource] {
try await all().filter { $0.groups.contains(group) }
}
public func search(_ key: String) async throws -> [RssSource] {
guard !key.isEmpty else { return try await all() }
return try await all().filter {
[$0.sourceName, $0.sourceUrl, $0.sourceGroup ?? "", $0.sourceComment ?? ""]
.contains { $0.localizedCaseInsensitiveContains(key) }
}
}
public func enabled(search key: String) async throws -> [RssSource] {
try await search(key).filter(\.enabled)
}
public func enabled(inGroup group: String) async throws -> [RssSource] {
try await inGroup(group).filter(\.enabled)
}
public func loginSources() async throws -> [RssSource] {
try await all().filter { !($0.loginUrl ?? "").isEmpty }
}
public func noGroup() async throws -> [RssSource] {
try await all().filter { $0.groups.isEmpty || $0.groups.contains("未分组") }
}
public func contains(sourceURL: String) async throws -> Bool {
try await all().contains { $0.sourceUrl == sourceURL }
}
public func source(for sourceURL: String) async throws -> RssSource? {
try await all().first { $0.sourceUrl == sourceURL }
}
public func save(_ values: [RssSource]) async throws { try await database.replaceRssSources(values) }
public func setEnabled(_ value: Bool, sourceURL: String) async throws {
var values = try await all()
guard let index = values.firstIndex(where: { $0.sourceUrl == sourceURL }) else { return }
values[index].enabled = value
try await save(values)
}
public func remove(sourceURL: String) async throws {
let values = try await all().filter { $0.sourceUrl != sourceURL }
try await save(values)
try await database.removeRssArticles(origin: sourceURL)
try await database.deleteSourceVariables(sourceURL)
}
}
public protocol RssArticleRepository: Sendable {
func articles(origin: String, sort: String) async throws -> [RssArticle]
func replace(_ values: [RssArticle], origin: String, sort: String) async throws
func append(_ values: [RssArticle]) async throws
func put(_ values: [RssArticle]) async throws
func clearOld(origin: String, sort: String, before order: Int64) async throws
func remove(origin: String) async throws
func article(origin: String, link: String, sort: String) async throws -> RssArticle?
func article(origin: String, link: String) async throws -> RssArticle?
func update(_ values: [RssArticle]) async throws
func updateOrigin(from oldOrigin: String, to origin: String) async throws
}
public actor SQLiteRssArticleRepository: RssArticleRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func articles(origin: String, sort: String) async throws -> [RssArticle] {
try await database.loadRssArticles(origin: origin, sort: sort)
}
public func replace(_ values: [RssArticle], origin: String, sort: String) async throws {
try await database.replaceRssArticles(values, origin: origin, sort: sort)
}
public func append(_ values: [RssArticle]) async throws { try await database.appendRssArticles(values) }
public func put(_ values: [RssArticle]) async throws { try await database.putRssArticles(values) }
public func clearOld(origin: String, sort: String, before order: Int64) async throws {
try await database.clearOldRssArticles(origin: origin, sort: sort, before: order)
}
public func remove(origin: String) async throws { try await database.removeRssArticles(origin: origin) }
public func article(origin: String, link: String, sort: String) async throws -> RssArticle? {
try await database.findRssArticle(origin: origin, link: link, sort: sort)
}
public func article(origin: String, link: String) async throws -> RssArticle? {
try await database.findRssArticle(origin: origin, link: link)
}
public func update(_ values: [RssArticle]) async throws { try await database.updateRssArticles(values) }
public func updateOrigin(from oldOrigin: String, to origin: String) async throws {
try await database.updateRssArticleOrigin(from: oldOrigin, to: origin)
}
}
public actor SQLiteRssReadRecordRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func all() async throws -> [RssReadRecord] { try await database.loadRssReadRecords() }
public func forOrigin(_ origin: String) async throws -> [RssReadRecord] {
try await all().filter { $0.origin == origin }
}
public func insertIgnoringExisting(_ value: RssReadRecord) async throws {
try await database.appendRssReadRecords([value])
}
public func record(_ record: String, origin: String) async throws -> RssReadRecord? {
try await database.findRssReadRecord(record: record, origin: origin)
}
public func update(_ values: [RssReadRecord]) async throws { try await database.updateRssReadRecords(values) }
public func clear() async throws { try await database.clearRssReadRecords() }
public func remove(origin: String) async throws { try await database.removeRssReadRecords(origin: origin) }
}
public actor SQLiteRssStarRepository {
private let database: LegadoDatabase
public init(database: LegadoDatabase) { self.database = database }
public func all() async throws -> [RssStar] { try await database.loadRssStars() }
public func groups() async throws -> [String] {
var seen = Set<String>()
return try await all().map(\.group).filter { seen.insert($0).inserted }.sorted()
}
public func inGroup(_ group: String) async throws -> [RssStar] {
try await all().filter { $0.group == group }
}
public func save(_ value: RssStar) async throws {
try await database.updateRssStars([value])
}
public func remove(origin: String, link: String) async throws {
try await database.removeRssStars(origin: origin, link: link)
}
public func star(origin: String, link: String) async throws -> RssStar? { try await database.findRssStar(origin: origin, link: link) }
public func remove(origin: String) async throws { try await database.removeRssStars(origin: origin) }
public func remove(group: String) async throws { try await database.removeRssStars(group: group) }
public func clear() async throws { try await database.clearRssStars() }
public func updateOrigin(from oldOrigin: String, to origin: String) async throws {
try await database.updateRssStarOrigin(from: oldOrigin, to: origin)
}
}
public enum RSSRepositoryFactory {
public static func database(url: URL = LegadoDatabase.defaultURL()) throws -> LegadoDatabase {
try LegadoDatabase(url: url)
}
}
@@ -0,0 +1,227 @@
import Foundation
public protocol ReadingActivityRepository: Sendable {
func loadBookmarks() async throws -> [Bookmark]
func saveBookmarks(_ bookmarks: [Bookmark]) async throws
func loadReadRecords() async throws -> [ReadRecord]
func saveReadRecords(_ records: [ReadRecord]) async throws
}
public actor FileReadingActivityRepository: ReadingActivityRepository {
private let bookmarksURL: URL
private let recordsURL: URL
public init(bookmarksURL: URL, recordsURL: URL) {
self.bookmarksURL = bookmarksURL
self.recordsURL = recordsURL
}
public func loadBookmarks() throws -> [Bookmark] { try load([Bookmark].self, from: bookmarksURL) }
public func saveBookmarks(_ bookmarks: [Bookmark]) throws { try save(bookmarks, to: bookmarksURL) }
public func loadReadRecords() throws -> [ReadRecord] { try load([ReadRecord].self, from: recordsURL) }
public func saveReadRecords(_ records: [ReadRecord]) throws { try save(records, to: recordsURL) }
private func load<T: Decodable>(_ type: T.Type, from url: URL) throws -> T where T: ExpressibleByArrayLiteral {
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
return try JSONDecoder().decode(type, from: Data(contentsOf: url))
}
private func save<T: Encodable>(_ value: T, to url: URL) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(value).write(to: url, options: .atomic)
}
}
public actor InMemoryReadingActivityRepository: ReadingActivityRepository {
private var bookmarks: [Bookmark]
private var records: [ReadRecord]
public init(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) {
self.bookmarks = bookmarks
self.records = records
}
public func loadBookmarks() -> [Bookmark] { bookmarks }
public func saveBookmarks(_ bookmarks: [Bookmark]) { self.bookmarks = bookmarks }
public func loadReadRecords() -> [ReadRecord] { records }
public func saveReadRecords(_ records: [ReadRecord]) { self.records = records }
}
public struct ReadingActivityService: Sendable {
public init() {}
public func sortedBookmarks(_ bookmarks: [Bookmark]) -> [Bookmark] {
bookmarks.sorted {
let name = $0.bookName.localizedStandardCompare($1.bookName)
if name != .orderedSame { return name == .orderedAscending }
let author = $0.bookAuthor.localizedStandardCompare($1.bookAuthor)
if author != .orderedSame { return author == .orderedAscending }
if $0.chapterIndex != $1.chapterIndex { return $0.chapterIndex < $1.chapterIndex }
return $0.chapterPosition < $1.chapterPosition
}
}
public func summaries(_ records: [ReadRecord]) -> [ReadRecordSummary] {
Dictionary(grouping: records, by: \.bookName)
.map { name, values in
ReadRecordSummary(
bookName: name,
readTime: values.reduce(0) { $0 + max($1.readTime, 0) },
lastRead: values.map(\.lastRead).max() ?? 0
)
}
.sorted { $0.lastRead > $1.lastRead }
}
public func displayedSummaries(
_ records: [ReadRecord],
searchKey: String,
sortMode: Int
) -> [ReadRecordSummary] {
let values = summaries(records).filter {
searchKey.isEmpty || $0.bookName.localizedCaseInsensitiveContains(searchKey)
}
return values.sorted {
switch sortMode {
case 1:
if $0.readTime != $1.readTime { return $0.readTime > $1.readTime }
case 2:
if $0.lastRead != $1.lastRead { return $0.lastRead > $1.lastRead }
default:
let order = $0.bookName.localizedStandardCompare($1.bookName)
if order != .orderedSame { return order == .orderedAscending }
}
return $0.bookName.localizedStandardCompare($1.bookName) == .orderedAscending
}
}
public func addingReadTime(
_ elapsedMilliseconds: Int64,
deviceID: String,
bookName: String,
now: Int64,
to records: [ReadRecord],
calendar: Calendar = .current
) -> [ReadRecord] {
guard elapsedMilliseconds > 0, !bookName.isEmpty else { return records }
var updated = records
let day = Self.dayKey(
for: Date(timeIntervalSince1970: Double(now) / 1_000),
calendar: calendar
)
if let index = updated.firstIndex(where: { $0.deviceId == deviceID && $0.bookName == bookName }) {
updated[index].readTime += elapsedMilliseconds
updated[index].lastRead = now
updated[index].dailyReadTimes[day, default: 0] += elapsedMilliseconds
} else {
updated.append(.init(
deviceId: deviceID,
bookName: bookName,
readTime: elapsedMilliseconds,
lastRead: now,
dailyReadTimes: [day: elapsedMilliseconds]
))
}
return updated
}
public func statistics(
_ records: [ReadRecord],
now: Date = Date(),
calendar: Calendar = .current
) -> ReadingStatistics {
let dailyTotals = records.reduce(into: [String: Int64]()) { result, record in
for (day, milliseconds) in record.dailyReadTimes where milliseconds > 0 {
result[day, default: 0] += milliseconds
}
}
let today = calendar.startOfDay(for: now)
let todayKey = Self.dayKey(for: today, calendar: calendar)
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
var cursor: Date?
if dailyTotals[todayKey, default: 0] > 0 {
cursor = today
} else if dailyTotals[Self.dayKey(for: yesterday, calendar: calendar), default: 0] > 0 {
cursor = yesterday
} else {
cursor = nil
}
var streak = 0
while let day = cursor,
dailyTotals[Self.dayKey(for: day, calendar: calendar), default: 0] > 0 {
streak += 1
cursor = calendar.date(byAdding: .day, value: -1, to: day)
}
let recentDays = (-6...0).compactMap { offset -> DailyReadingTotal? in
guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { return nil }
return DailyReadingTotal(
date: date,
readTime: dailyTotals[Self.dayKey(for: date, calendar: calendar), default: 0]
)
}
return ReadingStatistics(
todayReadTime: dailyTotals[todayKey, default: 0],
currentStreak: streak,
totalReadTime: records.reduce(0) { $0 + max($1.readTime, 0) },
bookCount: Set(records.lazy.map(\.bookName).filter { !$0.isEmpty }).count,
recentDays: recentDays
)
}
private static func dayKey(for date: Date, calendar: Calendar) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}
}
public struct DailyReadingTotal: Hashable, Sendable {
public let date: Date
public let readTime: Int64
public init(date: Date, readTime: Int64) {
self.date = date
self.readTime = readTime
}
}
public struct ReadingStatistics: Hashable, Sendable {
public let todayReadTime: Int64
public let currentStreak: Int
public let totalReadTime: Int64
public let bookCount: Int
public let recentDays: [DailyReadingTotal]
public init(
todayReadTime: Int64,
currentStreak: Int,
totalReadTime: Int64,
bookCount: Int,
recentDays: [DailyReadingTotal]
) {
self.todayReadTime = todayReadTime
self.currentStreak = currentStreak
self.totalReadTime = totalReadTime
self.bookCount = bookCount
self.recentDays = recentDays
}
}
public struct BookProgress: Codable, Hashable, Sendable {
public var name: String
public var author: String
public var durChapterIndex: Int
public var durChapterPos: Int
public var durChapterTime: Int64
public var durChapterTitle: String?
public init(book: Book) {
name = book.name
author = book.author
durChapterIndex = book.durChapterIndex
durChapterPos = book.durChapterPos
durChapterTime = book.durChapterTime
durChapterTitle = book.durChapterTitle
}
}
@@ -0,0 +1,91 @@
import Foundation
public enum ReplaceRuleImportError: Error, LocalizedError, Equatable {
case emptyDocument
case documentTooLarge(Int)
case invalidDocument(String)
case emptyPattern(String)
public var errorDescription: String? {
switch self {
case .emptyDocument: "净化规则内容为空"
case .documentTooLarge(let maximum): "净化规则文件超过 \(maximum / 1_024 / 1_024) MB 上限"
case .invalidDocument(let detail): "无法解析净化规则 JSON:\(detail)"
case .emptyPattern(let name): "净化规则“\(name)”没有匹配内容"
}
}
}
public struct ReplaceRuleImporter: Sendable {
public static let maximumDocumentBytes = 10 * 1_024 * 1_024
public init() {}
public func decode(_ data: Data) throws -> [ReplaceRule] {
guard data.count <= Self.maximumDocumentBytes else {
throw ReplaceRuleImportError.documentTooLarge(Self.maximumDocumentBytes)
}
let bom = Data([0xEF, 0xBB, 0xBF])
let cleaned = data.starts(with: bom) ? Data(data.dropFirst(3)) : data
guard !cleaned.isEmpty else { throw ReplaceRuleImportError.emptyDocument }
do {
let decoder = JSONDecoder()
let rules = if let array = try? decoder.decode([ReplaceRule].self, from: cleaned) {
array
} else {
[try decoder.decode(ReplaceRule.self, from: cleaned)]
}
for rule in rules { try ReplaceRuleValidator.validate(rule) }
return rules
} catch let error as ReplaceRuleImportError {
throw error
} catch {
throw ReplaceRuleImportError.invalidDocument(error.localizedDescription)
}
}
public func merge(_ incoming: [ReplaceRule], into existing: [ReplaceRule]) -> [ReplaceRule] {
var result = existing
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($1.id, $0) })
for rule in incoming {
if let index = indexes[rule.id] {
result[index] = rule
} else {
indexes[rule.id] = result.count
result.append(rule)
}
}
return result.sorted { $0.sortOrder < $1.sortOrder }
}
}
public protocol ReplaceRuleRepository: Sendable {
func load() async throws -> [ReplaceRule]
func save(_ rules: [ReplaceRule]) async throws
}
public actor FileReplaceRuleRepository: ReplaceRuleRepository {
private let fileURL: URL
public init(fileURL: URL) { self.fileURL = fileURL }
public func load() throws -> [ReplaceRule] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
return try JSONDecoder().decode([ReplaceRule].self, from: Data(contentsOf: fileURL))
}
public func save(_ rules: [ReplaceRule]) throws {
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(rules).write(to: fileURL, options: .atomic)
}
}
public actor InMemoryReplaceRuleRepository: ReplaceRuleRepository {
private var rules: [ReplaceRule]
public init(rules: [ReplaceRule] = []) { self.rules = rules }
public func load() -> [ReplaceRule] { rules }
public func save(_ rules: [ReplaceRule]) { self.rules = rules }
}
@@ -0,0 +1,105 @@
import Foundation
#if canImport(FoundationXML)
import FoundationXML
#endif
public struct RSSParseResult: Sendable, Equatable {
public var articles: [RssArticle]
public var nextURL: String?
public init(articles: [RssArticle], nextURL: String? = nil) {
self.articles = articles
self.nextURL = nextURL
}
}
public enum RSSDefaultParser {
public static func parse(sortName: String, xml: String, sourceURL: String) throws -> RSSParseResult {
let delegate = Delegate(sortName: sortName, sourceURL: sourceURL)
let parser = XMLParser(data: Data(xml.utf8))
parser.shouldProcessNamespaces = false
parser.delegate = delegate
guard parser.parse() else { throw parser.parserError ?? RSSParserError.invalidXML }
return .init(articles: delegate.articles)
}
private final class Delegate: NSObject, XMLParserDelegate {
let sortName: String
let sourceURL: String
var articles: [RssArticle] = []
var article = RssArticle()
var insideItem = false
var activeField: String?
var text = ""
init(sortName: String, sourceURL: String) {
self.sortName = sortName
self.sourceURL = sourceURL
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
let name = elementName.lowercased()
if name == "item" { insideItem = true; article = RssArticle(); return }
guard insideItem else { return }
if name == "media:thumbnail" {
article.image = attribute(attributeDict, named: "url")
} else if name == "enclosure", attribute(attributeDict, named: "type")?.localizedCaseInsensitiveContains("image/") == true {
article.image = attribute(attributeDict, named: "url")
}
if ["title", "link", "description", "content:encoded", "pubdate", "time"].contains(name) {
activeField = name
text = ""
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
if insideItem, activeField != nil { text += string }
}
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
if insideItem, activeField != nil { text += String(decoding: CDATABlock, as: UTF8.self) }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
let name = elementName.lowercased()
if name == "item" {
article.origin = sourceURL; article.sort = sortName; articles.append(article)
insideItem = false; activeField = nil
return
}
guard insideItem, name == activeField else { return }
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
switch name {
case "title": article.title = value
case "link": article.link = value
case "description": article.description = value; if article.image == nil { article.image = firstImageURL(in: text) }
case "content:encoded": article.content = value; if article.image == nil { article.image = firstImageURL(in: text) }
case "pubdate", "time": article.pubDate = value
default: break
}
activeField = nil; text = ""
}
private func attribute(_ values: [String: String], named name: String) -> String? {
values.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
}
private func firstImageURL(in input: String) -> String? {
guard let expression = try? NSRegularExpression(pattern: "(<img [^>]*>)", options: .caseInsensitive),
let image = expression.firstMatch(in: input, range: NSRange(input.startIndex..., in: input)),
let tagRange = Range(image.range(at: 1), in: input) else { return nil }
let tag = String(input[tagRange])
guard let expression = try? NSRegularExpression(pattern: #"src\s*=\s*\"([^\"]+)\""#, options: .caseInsensitive),
let source = expression.firstMatch(in: tag, range: NSRange(tag.startIndex..., in: tag)),
let sourceRange = Range(source.range(at: 1), in: tag) else { return nil }
return String(tag[sourceRange]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}
public enum RSSParserError: Error, LocalizedError, Sendable {
case invalidXML
case emptyBody(String)
public var errorDescription: String? {
switch self {
case .invalidXML: "RSS XML解析失败"
case .emptyBody(let source): "获取网页内容失败:\(source)"
}
}
}
@@ -0,0 +1,130 @@
import Foundation
public enum RSSReadingMode: Equatable, Sendable {
case html(content: String, baseURL: String?)
case startHTML(content: String, baseURL: String?)
case web(url: String)
case image(url: String)
case video(url: String)
}
public struct RSSReadingPolicy: Sendable {
public init() {}
public func mode(article: RssArticle, source: RssSource, resolvedContent: String? = nil) -> RSSReadingMode {
if article.type == 1 || article.type == 2 {
let raw = resolvedContent?.trimmed.nilIfEmpty ?? article.link
let articleURL = absoluteURL(article.link, base: article.origin)
let url = absoluteURL(raw, base: articleURL)
return article.type == 1 ? .image(url: url) : .video(url: url)
}
if let description = article.description?.trimmed.nilIfEmpty {
return .html(content: description, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil)
}
if source.ruleContent?.trimmed.nilIfEmpty != nil, let content = resolvedContent {
return .html(content: content, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil)
}
return .web(url: absoluteURL(article.link, base: article.origin))
}
private func absoluteURL(_ value: String, base: String) -> String {
URL(string: value, relativeTo: URL(string: base))?.absoluteURL.absoluteString ?? value
}
}
public struct RSSHTMLProcessor: Sendable {
public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}"
public init() {}
public func contentHTML(_ content: String, style: String?) -> String {
injectStyle(into: content, style: style?.trimmed.nilIfEmpty ?? Self.defaultStyle)
}
public func startHTML(_ content: String, source: RssSource) -> String {
var html: String
if let script = source.startJs?.trimmed.nilIfEmpty {
if let range = content.range(of: "</body>") {
html = String(content[..<range.lowerBound]) + "<script>\(script)</script>" + String(content[range.lowerBound...])
} else {
html = "<body>\(content)<script>\(script)</script></body>"
}
} else { html = content }
return contentHTML(html, style: source.startStyle?.trimmed.nilIfEmpty ?? source.style)
}
private func injectStyle(into content: String, style: String) -> String {
let tag = "<style>\(style)</style>"
if let range = content.range(of: "</style>") {
return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...])
}
if let range = content.range(of: "</head>") {
return String(content[..<range.lowerBound]) + tag + String(content[range.lowerBound...])
}
return tag + content
}
}
public struct RSSStartPageResolver: Sendable {
public init() {}
public func resolve(_ source: RssSource) throws -> RSSReadingMode? {
guard let raw = source.startHtml?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
let html: String
if raw.hasPrefix("@js:") {
let result = try JavaScriptRuleEvaluator().evaluate(script: String(raw.dropFirst(4)), result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter))
html = result.value.stringValue
} else if raw.hasPrefix("<js>"), let end = raw.range(of: "</js>", options: .backwards) {
let script = String(raw[raw.index(raw.startIndex, offsetBy: 4)..<end.lowerBound])
let result = try JavaScriptRuleEvaluator().evaluate(script: script, result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter))
html = result.value.stringValue
} else { html = raw }
return .startHTML(content: html, baseURL: source.loadWithBaseUrl ? source.sourceUrl : nil)
}
}
public struct RSSResourcePolicy: Sendable {
private let blacklist: [String]
private let whitelist: [String]
public init(blacklist: String?, whitelist: String?) {
self.blacklist = Self.rules(blacklist)
self.whitelist = Self.rules(whitelist)
}
public func allows(_ url: String) -> Bool {
if !blacklist.isEmpty { return !blacklist.contains { matches(url, rule: $0) } }
if !whitelist.isEmpty { return whitelist.contains { matches(url, rule: $0) } }
return true
}
public func contentBlockerJSON() -> String? {
var rules: [[String: Any]] = []
if !blacklist.isEmpty {
for value in blacklist {
rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "block"]])
}
} else if !whitelist.isEmpty {
rules.append(["trigger": ["url-filter": ".*"], "action": ["type": "block"]])
for value in whitelist {
rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "ignore-previous-rules"]])
}
}
guard !rules.isEmpty, let data = try? JSONSerialization.data(withJSONObject: rules) else { return nil }
return String(decoding: data, as: UTF8.self)
}
private func matches(_ value: String, rule: String) -> Bool {
if value.hasPrefix(rule) { return true }
guard let expression = try? NSRegularExpression(pattern: rule) else { return false }
let range = NSRange(value.startIndex..., in: value)
guard let match = expression.firstMatch(in: value, range: range) else { return false }
return match.range == range
}
private func filter(for rule: String) -> String {
let literalPrefix = "^" + NSRegularExpression.escapedPattern(for: rule)
return "(?:\(literalPrefix))|(?:^(?:\(rule))$)"
}
private static func rules(_ value: String?) -> [String] {
value?.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
}
}
private extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
var nilIfEmpty: String? { isEmpty ? nil : self }
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
public struct RSSRuleParseReport: Sendable {
public var result: RSSParseResult
public var diagnostics: [RuleDiagnostic]
public init(result: RSSParseResult, diagnostics: [RuleDiagnostic] = []) {
self.result = result
self.diagnostics = diagnostics
}
}
public struct RSSRuleParser: Sendable {
private let engine: RuleEngine
public init(engine: RuleEngine = .init()) { self.engine = engine }
public func parse(
sortName: String,
sortURL: String,
redirectURL: String,
body: String,
source: RssSource,
variables: [String: String] = [:]
) async throws -> RSSRuleParseReport {
guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RSSParserError.emptyBody(source.sourceUrl)
}
guard var listRule = source.ruleArticles, !listRule.isEmpty else {
return .init(result: try RSSDefaultParser.parse(sortName: sortName, xml: body, sourceURL: source.sourceUrl))
}
let reverse = listRule.hasPrefix("-")
if reverse { listRule.removeFirst() }
let adapter = source.bookSourceAdapter
let baseContext = RuleExecutionContext(variables: variables, baseURL: sortURL, source: adapter)
let list = await engine.evaluate(listRule, inText: body, baseURL: sortURL, listMode: true, context: baseContext)
var diagnostics = list.diagnostics
var nextURL: String?
if let nextRule = source.ruleNextPage, !nextRule.isEmpty {
if nextRule.uppercased() == "PAGE" {
nextURL = sortURL
} else {
let next = await engine.evaluate(nextRule, inText: body, baseURL: sortURL, context: .init(variables: list.variables, baseURL: sortURL, source: adapter))
diagnostics += next.diagnostics
if let value = next.joinedString, !value.isEmpty {
nextURL = URL(string: value, relativeTo: URL(string: sortURL))?.absoluteURL.absoluteString ?? value
}
}
}
let variable = encodeVariables(list.variables)
var articles: [RssArticle] = []
for item in flatten(list.values) {
var context = RuleExecutionContext(variables: list.variables, baseURL: sortURL, source: adapter)
let title = await evaluate(source.ruleTitle, item: item, context: context); context.variables = title.variables
let pubDate = await evaluate(source.rulePubDate, item: item, context: context); context.variables = pubDate.variables
let description = await evaluate(source.ruleDescription, item: item, context: context); context.variables = description.variables
let image = await evaluate(source.ruleImage, item: item, context: context); context.variables = image.variables
let link = await evaluate(source.ruleLink, item: item, context: context)
diagnostics += [title, pubDate, description, image, link].flatMap(\.diagnostics)
guard let titleValue = title.joinedString?.trimmingCharacters(in: .whitespacesAndNewlines), !titleValue.isEmpty else { continue }
let rawLink = link.leadingString ?? ""
let resolvedLink = URL(string: rawLink, relativeTo: URL(string: sortURL))?
.absoluteURL.absoluteString ?? rawLink
var article = RssArticle(origin: source.sourceUrl, sort: sortName, title: titleValue, link: resolvedLink)
article.pubDate = pubDate.joinedString
article.description = source.ruleDescription?.isEmpty == false ? description.joinedString : nil
if let imageValue = image.joinedString, !imageValue.isEmpty {
article.image = URL(string: imageValue, relativeTo: URL(string: source.sourceUrl))?.absoluteURL.absoluteString ?? imageValue
}
article.variable = variable
article.type = source.type
articles.append(article)
}
if reverse { articles.reverse() }
_ = redirectURL
return .init(result: .init(articles: articles, nextURL: nextURL), diagnostics: diagnostics)
}
private func evaluate(_ rule: String?, item: JSONValue, context: RuleExecutionContext) async -> RuleEvaluation {
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
if case .string(let text) = item {
return await engine.evaluate(rule, inText: text, baseURL: context.baseURL, context: context)
}
return await engine.evaluate(rule, in: item, context: context)
}
private func flatten(_ values: [JSONValue]) -> [JSONValue] {
values.flatMap { value in
if case .array(let items) = value { return items }
return [value]
}
}
private func encodeVariables(_ values: [String: String]) -> String? {
guard !values.isEmpty, let data = try? JSONEncoder().encode(values) else { return nil }
return String(decoding: data, as: UTF8.self)
}
}
extension RssSource {
var bookSourceAdapter: BookSource {
var value = BookSource(bookSourceUrl: sourceUrl, bookSourceName: sourceName)
value.bookSourceGroup = sourceGroup; value.bookSourceType = type; value.customOrder = customOrder
value.enabled = enabled; value.jsLib = jsLib; value.enabledCookieJar = enabledCookieJar
value.concurrentRate = concurrentRate; value.header = header; value.loginUrl = loginUrl
value.loginUi = loginUi; value.loginCheckJs = loginCheckJs; value.coverDecodeJs = coverDecodeJs
value.bookSourceComment = sourceComment; value.variableComment = variableComment
value.lastUpdateTime = lastUpdateTime; value.searchUrl = searchUrl
return value
}
}
+132
View File
@@ -0,0 +1,132 @@
import Foundation
public struct RSSFetchResult: Sendable {
public var requestURL: String
public var finalURL: String
public var statusCode: Int
public var body: String
public var report: RSSRuleParseReport
}
public struct RSSContentFetchResult: Sendable {
public var requestURL: String
public var finalURL: String
public var statusCode: Int
public var body: String
public var content: String
public var diagnostics: [RuleDiagnostic]
}
public struct RSSService: Sendable {
private let transport: any HTTPTransport
private let urlBuilder: LegadoURLBuilder
private let engine: RuleEngine
private let parser: RSSRuleParser
private let networkPolicy: SourceNetworkPolicy
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
public init(
transport: any HTTPTransport = URLSessionHTTPTransport(),
urlBuilder: LegadoURLBuilder = .init(),
engine: RuleEngine = .init(),
networkPolicy: SourceNetworkPolicy = .shared,
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil
) {
self.transport = transport
self.urlBuilder = urlBuilder
self.engine = engine
self.parser = RSSRuleParser(engine: engine)
self.networkPolicy = networkPolicy
self.webViewExecutor = webViewExecutor
}
public func articles(
sortName: String,
sortURL: String,
source: RssSource,
page: Int,
key: String? = nil
) async throws -> RSSRuleParseReport {
try await articlesDetailed(sortName: sortName, sortURL: sortURL, source: source, page: page, key: key).report
}
public func articlesDetailed(
sortName: String,
sortURL: String,
source: RssSource,
page: Int,
key: String? = nil
) async throws -> RSSFetchResult {
let adapter = source.bookSourceAdapter
let headers = try BookSourceHeaderResolver().resolve(adapter)
let prepared = try urlBuilder.prepare(
sortURL,
context: .init(key: key, page: page, source: adapter),
baseURL: URL(string: source.sourceUrl),
defaultHeaders: headers
)
let response = try await networkPolicy.execute(
prepared,
source: adapter,
transport: transport,
timeout: 180,
webViewExecutor: webViewExecutor,
snapshotStage: .debug
)
let report = try await parser.parse(
sortName: sortName,
sortURL: sortURL,
redirectURL: response.finalURL.absoluteString,
body: response.decodedText,
source: source,
variables: response.requestVariables
)
return .init(
requestURL: prepared.url.absoluteString,
finalURL: response.finalURL.absoluteString,
statusCode: response.statusCode,
body: response.decodedText,
report: report
)
}
public func content(article: RssArticle, rule: String, source: RssSource) async throws -> String {
try await contentDetailed(article: article, rule: rule, source: source).content
}
public func contentDetailed(article: RssArticle, rule: String, source: RssSource) async throws -> RSSContentFetchResult {
let adapter = source.bookSourceAdapter
let headers = try BookSourceHeaderResolver().resolve(adapter)
let prepared = try urlBuilder.prepare(
article.link,
context: .init(variables: article.variableMap, source: adapter),
baseURL: URL(string: article.origin),
defaultHeaders: headers
)
let response = try await networkPolicy.execute(
prepared,
source: adapter,
transport: transport,
timeout: 180,
webViewExecutor: webViewExecutor,
snapshotStage: .content
)
let result = await engine.evaluate(
rule,
inText: response.decodedText,
baseURL: URL(string: article.link, relativeTo: URL(string: article.origin))?.absoluteURL.absoluteString ?? article.link,
context: .init(variables: article.variableMap, baseURL: response.finalURL.absoluteString, source: adapter)
)
if let error = result.diagnostics.first(where: { $0.severity == .error }) {
throw RuleEngineError.malformedRule(rule: rule, detail: error.message)
}
return .init(
requestURL: prepared.url.absoluteString,
finalURL: response.finalURL.absoluteString,
statusCode: response.statusCode,
body: response.decodedText,
content: result.joinedString ?? "",
diagnostics: result.diagnostics
)
}
}
@@ -0,0 +1,33 @@
import Foundation
public struct RSSSort: Hashable, Identifiable, Sendable {
public var name: String
public var url: String
public var id: String { "\(name)\u{0}\(url)" }
public init(name: String, url: String) { self.name = name; self.url = url }
}
public struct RSSSortResolver: Sendable {
private let engine: RuleEngine
public init(engine: RuleEngine = .init()) { self.engine = engine }
public func resolve(_ source: RssSource) async -> [RSSSort] {
var value = source.sortUrl
if let rule = value, rule.hasPrefix("@js:") || rule.hasPrefix("<js>") {
let result = await engine.evaluate(rule, in: .null, context: .init(source: source.bookSourceAdapter))
value = result.joinedString
}
guard let value else { return [.init(name: "", url: source.sourceUrl)] }
let pattern = try? NSRegularExpression(pattern: "(?:&&|\\n)+")
let range = NSRange(value.startIndex..., in: value)
let normalized = pattern?.stringByReplacingMatches(in: value, range: range, withTemplate: "\u{1F}") ?? value
let sorts = normalized.split(separator: "\u{1F}", omittingEmptySubsequences: true).compactMap { item -> RSSSort? in
let text = String(item)
let parts = text.components(separatedBy: "::")
let name = parts.first ?? ""
let url = parts.dropFirst().joined(separator: "::")
return url.isEmpty ? nil : .init(name: name, url: url)
}
return sorts.isEmpty ? [.init(name: "", url: source.sourceUrl)] : sorts
}
}
@@ -0,0 +1,312 @@
import Foundation
import Kanna
import SwiftSoup
public struct HTMLRuleEvaluator: Sendable {
private enum IndexItem {
case index(Int)
case range(Int?, Int?, Int)
}
private struct IndexedSelector {
var selector: String
var excludes: Bool
var items: [IndexItem]
}
public init() {}
public func selectCSSFragments(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] {
do {
let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment)
let stages = splitChain(rule).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return try selectElements(stages: stages, from: root).map { try $0.outerHtml() }
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error))
}
}
public func extractCSS(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] {
do {
let parsed = try parseCSSExtraction(rule)
let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment)
let elements = try selectElements(stages: parsed.selectors, from: root)
if parsed.output == "html" {
for element in elements {
try element.select("script").remove()
try element.select("style").remove()
}
let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n")
return value.isEmpty ? [] : [value]
}
if parsed.output == "all" {
let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n")
return value.isEmpty ? [] : [value]
}
var seenAttributes: Set<String> = []
return try elements.compactMap { element in
let value: String
switch parsed.output {
case "text": value = try element.text()
case "ownText": value = element.ownText()
case "textNodes":
value = element.textNodes().map { $0.text().trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
default:
value = try element.attr(parsed.output)
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
seenAttributes.insert(value).inserted else { return nil }
}
return value.isEmpty ? nil : value
}
} catch let error as RuleEngineError {
throw error
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error))
}
}
public func selectXPathFragments(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] {
try xpathResult(rule, html: html, isFragment: isFragment).compactMap(\.toHTML)
}
public func extractXPath(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] {
let result = try xpathResult(rule, html: html, isFragment: isFragment)
switch result {
case .none:
return []
case .NodeSet:
return result.compactMap { node in
let value = node.text ?? node.content ?? ""
return value.isEmpty ? nil : value
}
case .Bool(let value):
return [String(value)]
case .Number(let value):
return [value.rounded() == value ? String(format: "%.0f", value) : String(value)]
case .String(let value):
return value.isEmpty ? [] : [value]
}
}
private func xpathResult(_ rule: String, html: String, isFragment: Bool) throws -> XPathObject {
let document = try parseKanna(html, rule: rule)
if isFragment, let element = document.at_xpath("/html/body/*[1]") {
return element.xpath(rule)
}
return document.xpath(rule)
}
private func parseKanna(_ html: String, rule: String) throws -> any HTMLDocument {
do {
return try Kanna.HTML(html: html, encoding: .utf8)
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "HTML 解析失败:\(error.localizedDescription)")
}
}
private func parseCSSExtraction(_ rule: String) throws -> (selectors: [String], output: String) {
let components = splitChain(rule)
guard components.count > 1 else {
let selector = rule.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selector.isEmpty else {
throw RuleEngineError.malformedRule(rule: rule, detail: "CSS selector 不能为空")
}
if selector.range(of: #"^[\w:-]+$"#, options: .regularExpression) != nil {
return ([], selector)
}
return ([selector], "text")
}
var selectors = components.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let output = components.last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
while selectors.first?.isEmpty == true { selectors.removeFirst() }
guard selectors.allSatisfy({ !$0.isEmpty }), !output.isEmpty else {
throw RuleEngineError.malformedRule(rule: rule, detail: "CSS 取值规则必须是 selector@text 或 selector@attribute")
}
return (selectors, output)
}
private func cssFailureDetail(_ error: Error) -> String {
if case SwiftSoup.Exception.Error(_, let message) = error {
return "CSS 规则执行失败:\(message)"
}
return "CSS 规则执行失败:\(error.localizedDescription)"
}
private func splitChain(_ rule: String) -> [String] {
var parts: [String] = []
var quote: Character?
var escaped = false
var squareDepth = 0
var roundDepth = 0
var start = rule.startIndex
for index in rule.indices {
let character = rule[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if let activeQuote = quote {
if character == activeQuote { quote = nil }
continue
}
if character == "\"" || character == "'" { quote = character; continue }
if character == "[" { squareDepth += 1 }
if character == "]" { squareDepth -= 1 }
if character == "(" { roundDepth += 1 }
if character == ")" { roundDepth -= 1 }
if character == "@", squareDepth == 0, roundDepth == 0 {
parts.append(String(rule[start..<index]))
start = rule.index(after: index)
}
}
parts.append(String(rule[start...]))
return parts
}
private func parseRoot(_ html: String, baseURL: String, isFragment: Bool) throws -> Element {
guard isFragment else { return try SwiftSoup.parse(html, baseURL) }
if html.range(of: #"^\s*(?:<!doctype\b|<html\b)"#, options: [.regularExpression, .caseInsensitive]) != nil {
return try SwiftSoup.parse(html, baseURL)
}
// Reconstruct the selected element, not a new document. In particular,
// parsing a standalone tr/td as a document discards its table structure.
let tag = html.range(of: #"^\s*<([A-Za-z][A-Za-z0-9:-]*)"#, options: .regularExpression)
.map { String(html[$0]).trimmingCharacters(in: .whitespacesAndNewlines).dropFirst().lowercased() } ?? ""
let parent: String
switch tag {
case "tr": parent = "tbody"
case "td", "th": parent = "tr"
case "tbody", "thead", "tfoot", "caption", "colgroup": parent = "table"
case "col": parent = "colgroup"
case "option", "optgroup": parent = "select"
default: parent = "body"
}
let container = try Element(Tag.valueOf(parent), baseURL)
let nodes = try Parser.parseFragment(html, container, Array(baseURL.utf8))
let elements = nodes.compactMap { $0 as? Element }
if elements.count == 1, let element = elements.first { return element }
return try SwiftSoup.parseBodyFragment(html, baseURL)
}
private func selectElements(stages: [String], from document: Element) throws -> [Element] {
var current: [Element] = [document]
for stage in stages {
var selected: [Element] = []
let parsed = parseIndexedSelector(stage)
for parent in current {
let candidates: [Element]
switch parsed.selector {
case "", "children": candidates = parent.children().array()
default: candidates = try parent.select(cssSelector(parsed.selector)).array()
}
selected += applyIndex(parsed, to: candidates)
}
current = selected
}
return current
}
private func cssSelector(_ legacy: String) -> String {
if legacy.hasPrefix("class.") { return "." + legacy.dropFirst(6) }
if legacy.hasPrefix("tag.") { return String(legacy.dropFirst(4)) }
if legacy.hasPrefix("id.") { return "#" + legacy.dropFirst(3) }
if legacy.hasPrefix("text.") {
let value = String(legacy.dropFirst(5)).replacingOccurrences(of: "\"", with: "\\\"")
return ":containsOwn(\(value))"
}
return legacy
}
private func parseIndexedSelector(_ source: String) -> IndexedSelector {
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasSuffix("]"), let open = value.lastIndex(of: "[") {
var content = String(value[value.index(after: open)..<value.index(before: value.endIndex)])
.trimmingCharacters(in: .whitespacesAndNewlines)
let allowed = CharacterSet(charactersIn: "!-0123456789:, ")
if !content.isEmpty, content.unicodeScalars.allSatisfy(allowed.contains) {
let excludes = content.hasPrefix("!")
if excludes { content.removeFirst() }
let items = content.split(separator: ",", omittingEmptySubsequences: false).compactMap(parseIndexItem)
if !items.isEmpty {
return .init(
selector: String(value[..<open]).trimmingCharacters(in: .whitespacesAndNewlines),
excludes: excludes,
items: items
)
}
}
}
for delimiter in ["!", "."] {
guard let marker = value.lastIndex(of: Character(delimiter)) else { continue }
let suffix = String(value[value.index(after: marker)...])
let values = suffix.split(separator: ":", omittingEmptySubsequences: false).compactMap {
Int($0.trimmingCharacters(in: .whitespaces))
}
if !suffix.isEmpty, values.count == suffix.split(separator: ":", omittingEmptySubsequences: false).count {
return .init(
selector: String(value[..<marker]),
excludes: delimiter == "!",
items: values.map(IndexItem.index)
)
}
}
return .init(selector: value, excludes: false, items: [])
}
private func parseIndexItem(_ source: Substring) -> IndexItem? {
let values = source.split(separator: ":", omittingEmptySubsequences: false)
if values.count == 1, let index = Int(values[0].trimmingCharacters(in: .whitespaces)) {
return .index(index)
}
guard (2...3).contains(values.count) else { return nil }
let start = values[0].isEmpty ? nil : Int(values[0].trimmingCharacters(in: .whitespaces))
let end = values[1].isEmpty ? nil : Int(values[1].trimmingCharacters(in: .whitespaces))
let step = values.count == 3 ? Int(values[2].trimmingCharacters(in: .whitespaces)) ?? 1 : 1
return step == 0 ? nil : .range(start, end, step)
}
private func applyIndex(_ selector: IndexedSelector, to elements: [Element]) -> [Element] {
guard !selector.items.isEmpty, !elements.isEmpty else { return elements }
var indexes: [Int] = []
func append(_ index: Int) {
let resolved = index < 0 ? elements.count + index : index
if elements.indices.contains(resolved), !indexes.contains(resolved) { indexes.append(resolved) }
}
for item in selector.items {
switch item {
case .index(let index): append(index)
case .range(let rawStart, let rawEnd, let rawStep):
var start = rawStart ?? 0
var end = rawEnd ?? (elements.count - 1)
if start < 0 { start += elements.count }
if end < 0 { end += elements.count }
if (start < 0 && end < 0) || (start >= elements.count && end >= elements.count) {
continue
}
start = min(max(start, 0), elements.count - 1)
end = min(max(end, 0), elements.count - 1)
let direction = end >= start ? 1 : -1
let normalizedStep: Int
if rawStep > 0 {
normalizedStep = rawStep
} else if -rawStep < elements.count {
normalizedStep = rawStep + elements.count
} else {
normalizedStep = 1
}
let step = max(normalizedStep, 1) * direction
var cursor = start
while direction > 0 ? cursor <= end : cursor >= end {
append(cursor)
cursor += step
}
}
}
if selector.excludes {
let excluded = Set(indexes)
return elements.enumerated().compactMap { excluded.contains($0.offset) ? nil : $0.element }
}
return indexes.map { elements[$0] }
}
}
@@ -0,0 +1,496 @@
import Foundation
public struct JSONPathEvaluator: Sendable {
private enum Selector: Equatable {
case key(String)
case index(Int)
case wildcard
}
private enum Segment: Equatable {
case child(Selector)
case recursive(Selector)
case union([Selector])
case slice(start: Int?, end: Int?, step: Int)
case filter(String)
}
public init() {}
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path)
var current = [root]
for segment in segments {
try Task.checkCancellation()
switch segment {
case .child(let selector):
current = current.flatMap { select(selector, from: $0) }
case .recursive(let selector):
current = current.flatMap { recursivelySelect(selector, from: $0) }
case .union(let selectors):
current = current.flatMap { node in selectors.flatMap { select($0, from: node) } }
case .slice(let start, let end, let step):
current = current.flatMap { slice($0, start: start, end: end, step: step) }
case .filter(let predicate):
var filtered: [JSONValue] = []
for node in current {
guard case .array(let values) = node else { continue }
for value in values where try matches(predicate, candidate: value, root: root) {
filtered.append(value)
}
}
current = filtered
}
}
return current
}
private func parse(_ path: String) throws -> [Segment] {
let rule = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard rule.first == "$" else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 必须以 $ 开始")
}
var segments: [Segment] = []
var index = rule.index(after: rule.startIndex)
while index < rule.endIndex {
let character = rule[index]
if character == "." {
let next = rule.index(after: index)
guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
}
if rule[next] == "." {
index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector))
index = parsed.next
} else {
index = next
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.child(parsed.selector))
index = parsed.next
}
} else if character == "[" {
let parsed = try parseBracket(rule, from: index, original: path)
segments.append(parsed.segment)
index = parsed.next
} else {
throw RuleEngineError.malformedRule(
rule: path,
detail: "JSONPath 在 \(rule.distance(from: rule.startIndex, to: index)) 处包含意外字符"
)
}
}
return segments
}
private func parseDotSelector(
_ rule: String,
from start: String.Index,
original: String
) throws -> (selector: Selector, next: String.Index) {
if rule[start] == "*" {
return (.wildcard, rule.index(after: start))
}
var end = start
while end < rule.endIndex, rule[end] != ".", rule[end] != "[" {
end = rule.index(after: end)
}
let key = String(rule[start..<end])
guard !key.isEmpty else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 属性名不能为空")
}
return (.key(key), end)
}
private func parseBracket(
_ rule: String,
from open: String.Index,
original: String
) throws -> (segment: Segment, next: String.Index) {
var index = rule.index(after: open)
var quote: Character?
var escaped = false
var depth = 1
while index < rule.endIndex {
let character = rule[index]
if escaped {
escaped = false
} else if character == "\\" {
escaped = true
} else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" {
quote = character
} else if character == "[" {
depth += 1
} else if character == "]" {
depth -= 1
guard depth == 0 else {
index = rule.index(after: index)
continue
}
let content = String(rule[rule.index(after: open)..<index])
.trimmingCharacters(in: .whitespacesAndNewlines)
let segment = try parseBracketContent(content, original: original)
return (segment, rule.index(after: index))
}
index = rule.index(after: index)
}
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号未闭合")
}
private func parseBracketContent(_ content: String, original: String) throws -> Segment {
guard !content.isEmpty else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号不能为空")
}
if content.hasPrefix("?(") && content.hasSuffix(")") {
let predicate = String(content.dropFirst(2).dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
guard !predicate.isEmpty else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath filter 不能为空")
}
return .filter(predicate)
}
if content == "*" { return .child(.wildcard) }
if content.contains(":") {
let values = content.split(separator: ":", omittingEmptySubsequences: false)
guard (2 ... 3).contains(values.count),
let step = values.count == 3 ? Int(values[2]) : 1,
step != 0 else {
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath slice 格式无效")
}
return .slice(
start: values[0].isEmpty ? nil : Int(values[0]),
end: values[1].isEmpty ? nil : Int(values[1]),
step: step
)
}
let parts = splitCommaSeparated(content)
let selectors = try parts.map { try parseSelector($0, original: original) }
return selectors.count == 1 ? .child(selectors[0]) : .union(selectors)
}
private func splitCommaSeparated(_ content: String) -> [String] {
var parts: [String] = []
var quote: Character?
var escaped = false
var start = content.startIndex
var index = start
while index < content.endIndex {
let character = content[index]
if escaped {
escaped = false
} else if character == "\\" {
escaped = true
} else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" {
quote = character
} else if character == "," {
parts.append(String(content[start..<index]).trimmingCharacters(in: .whitespaces))
start = content.index(after: index)
}
index = content.index(after: index)
}
parts.append(String(content[start...]).trimmingCharacters(in: .whitespaces))
return parts
}
private func parseSelector(_ source: String, original: String) throws -> Selector {
if source == "*" { return .wildcard }
if let index = Int(source) { return .index(index) }
if source.count >= 2,
let first = source.first,
first == "\"" || first == "'",
source.last == first {
let key = String(source.dropFirst().dropLast())
.replacingOccurrences(of: "\\\(first)", with: String(first))
.replacingOccurrences(of: "\\\\", with: "\\")
return .key(key)
}
throw RuleEngineError.malformedRule(rule: original, detail: "不支持的 JSONPath 选择器:\(source)")
}
private func select(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
switch (selector, value) {
case (.key(let key), .object(let object)):
return object[key].map { [$0] } ?? []
case (.index(let index), .array(let array)):
let resolved = index >= 0 ? index : array.count + index
return array.indices.contains(resolved) ? [array[resolved]] : []
case (.wildcard, .object(let object)):
return object.keys.sorted().compactMap { object[$0] }
case (.wildcard, .array(let array)):
return array
default:
return []
}
}
private func recursivelySelect(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
var result = select(selector, from: value)
switch value {
case .object(let object):
for key in object.keys.sorted() {
if let child = object[key] { result += recursivelySelect(selector, from: child) }
}
case .array(let array):
for child in array { result += recursivelySelect(selector, from: child) }
default:
break
}
return result
}
private func slice(_ value: JSONValue, start: Int?, end: Int?, step: Int) -> [JSONValue] {
guard case .array(let array) = value, !array.isEmpty else { return [] }
let count = array.count
let defaultStart = step > 0 ? 0 : count - 1
let defaultEnd = step > 0 ? count : -1
var cursor = normalize(start ?? defaultStart, count: count, allowEnd: true)
let boundary = end == nil && step < 0 ? -1 : normalize(end ?? defaultEnd, count: count, allowEnd: true)
var result: [JSONValue] = []
while step > 0 ? cursor < boundary : cursor > boundary {
if array.indices.contains(cursor) { result.append(array[cursor]) }
cursor += step
}
return result
}
private func normalize(_ index: Int, count: Int, allowEnd: Bool) -> Int {
let value = index < 0 ? count + index : index
return min(max(value, 0), allowEnd ? count : count - 1)
}
private func matches(_ source: String, candidate: JSONValue, root: JSONValue) throws -> Bool {
let predicate = stripOuterParentheses(source.trimmingCharacters(in: .whitespacesAndNewlines))
if let parts = splitLogical(predicate, operator: "||"), parts.count > 1 {
for part in parts where try matches(part, candidate: candidate, root: root) { return true }
return false
}
if let parts = splitLogical(predicate, operator: "&&"), parts.count > 1 {
for part in parts where !(try matches(part, candidate: candidate, root: root)) { return false }
return true
}
if predicate.hasPrefix("!") && !predicate.hasPrefix("!=") {
return try !matches(String(predicate.dropFirst()), candidate: candidate, root: root)
}
if let comparison = findComparison(predicate) {
let lhs = try resolveOperand(String(predicate[..<comparison.range.lowerBound]), candidate: candidate, root: root)
let rhs = try resolveOperand(String(predicate[comparison.range.upperBound...]), candidate: candidate, root: root)
guard !lhs.isEmpty, !rhs.isEmpty else { return false }
return try lhs.contains { left in
try rhs.contains { right in try compare(left, right, operation: comparison.operation) }
}
}
let values = try resolveOperand(predicate, candidate: candidate, root: root)
return values.contains(where: isTruthy)
}
private func splitLogical(_ source: String, operator operation: String) -> [String]? {
var parts: [String] = []
var quote: Character?
var regex = false
var escaped = false
var roundDepth = 0
var squareDepth = 0
var start = source.startIndex
var index = start
while index < source.endIndex {
let character = source[index]
if escaped {
escaped = false
} else if character == "\\" {
escaped = true
} else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" {
quote = character
} else if character == "/" {
regex.toggle()
} else if !regex {
if character == "(" { roundDepth += 1 }
if character == ")" { roundDepth -= 1 }
if character == "[" { squareDepth += 1 }
if character == "]" { squareDepth -= 1 }
if roundDepth == 0, squareDepth == 0, source[index...].hasPrefix(operation) {
parts.append(String(source[start..<index]).trimmingCharacters(in: .whitespaces))
index = source.index(index, offsetBy: operation.count)
start = index
continue
}
}
index = source.index(after: index)
}
guard !parts.isEmpty else { return nil }
parts.append(String(source[start...]).trimmingCharacters(in: .whitespaces))
return parts
}
private func stripOuterParentheses(_ source: String) -> String {
var value = source
while value.first == "(", value.last == ")", outerParenthesesEncloseAll(value) {
value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value
}
private func outerParenthesesEncloseAll(_ source: String) -> Bool {
var depth = 0
var quote: Character?
var escaped = false
for index in source.indices {
let character = source[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if let activeQuote = quote {
if character == activeQuote { quote = nil }
continue
}
if character == "\"" || character == "'" { quote = character; continue }
if character == "(" { depth += 1 }
if character == ")" {
depth -= 1
if depth == 0 && index != source.index(before: source.endIndex) { return false }
}
}
return depth == 0
}
private func findComparison(_ source: String) -> (range: Range<String.Index>, operation: String)? {
let operations = ["==", "!=", ">=", "<=", "=~", ">", "<"]
var quote: Character?
var escaped = false
var roundDepth = 0
var squareDepth = 0
var index = source.startIndex
while index < source.endIndex {
let character = source[index]
if escaped {
escaped = false
} else if character == "\\" {
escaped = true
} else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" {
quote = character
} else {
if character == "(" { roundDepth += 1 }
if character == ")" { roundDepth -= 1 }
if character == "[" { squareDepth += 1 }
if character == "]" { squareDepth -= 1 }
if roundDepth == 0, squareDepth == 0 {
for operation in operations where source[index...].hasPrefix(operation) {
return (index..<source.index(index, offsetBy: operation.count), operation)
}
}
}
index = source.index(after: index)
}
return nil
}
private func resolveOperand(_ source: String, candidate: JSONValue, root: JSONValue) throws -> [JSONValue] {
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "JSONPath filter 比较值不能为空")
}
if value.first == "@" || value.first == "$" {
return try resolvePath(value, base: value.first == "@" ? candidate : root)
}
if value == "true" { return [.bool(true)] }
if value == "false" { return [.bool(false)] }
if value == "null" { return [.null] }
if let number = Double(value) { return [.number(number)] }
if let first = value.first, (first == "\"" || first == "'"), value.last == first {
let inner = String(value.dropFirst().dropLast())
.replacingOccurrences(of: "\\\(first)", with: String(first))
.replacingOccurrences(of: "\\n", with: "\n")
.replacingOccurrences(of: "\\\\", with: "\\")
return [.string(inner)]
}
if value.first == "/", value.dropFirst().lastIndex(of: "/") != nil {
return [.string(value)]
}
throw RuleEngineError.malformedRule(rule: source, detail: "不支持的 JSONPath filter 操作数:\(value)")
}
private func resolvePath(_ path: String, base: JSONValue) throws -> [JSONValue] {
var current = [base]
var index = path.index(after: path.startIndex)
while index < path.endIndex {
if path[index] == "." {
index = path.index(after: index)
var end = index
while end < path.endIndex, path[end] != ".", path[end] != "[" { end = path.index(after: end) }
let key = String(path[index..<end])
guard !key.isEmpty else { throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径属性为空") }
current = current.flatMap { select(.key(key), from: $0) }
index = end
} else if path[index] == "[" {
guard let close = path[index...].firstIndex(of: "]") else {
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径方括号未闭合")
}
let token = String(path[path.index(after: index)..<close]).trimmingCharacters(in: .whitespaces)
let selector = try parseSelector(token, original: path)
current = current.flatMap { select(selector, from: $0) }
index = path.index(after: close)
} else {
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径包含意外字符")
}
}
return current
}
private func compare(_ lhs: JSONValue, _ rhs: JSONValue, operation: String) throws -> Bool {
if operation == "=~" {
let literal = rhs.stringValue
guard literal.first == "/", let closing = literal.dropFirst().lastIndex(of: "/") else {
throw RuleEngineError.malformedRule(rule: literal, detail: "filter 正则必须写成 /pattern/flags")
}
let pattern = String(literal[literal.index(after: literal.startIndex)..<closing])
guard pattern.count <= 1_024 else {
throw RuleEngineError.unsupportedRule(rule: literal, feature: "过长 JSONPath filter 正则")
}
let flags = literal[literal.index(after: closing)...]
let options: NSRegularExpression.Options = flags.contains("i") ? .caseInsensitive : []
let regex = try NSRegularExpression(pattern: pattern, options: options)
let range = NSRange(lhs.stringValue.startIndex..., in: lhs.stringValue)
return regex.firstMatch(in: lhs.stringValue, range: range) != nil
}
if operation == "==" || operation == "!=" {
let equal: Bool
switch (lhs, rhs) {
case (.number(let left), .number(let right)): equal = left == right
case (.bool(let left), .bool(let right)): equal = left == right
case (.null, .null): equal = true
default: equal = lhs.stringValue == rhs.stringValue
}
return operation == "==" ? equal : !equal
}
let ordering: ComparisonResult
if case .number(let left) = lhs, case .number(let right) = rhs {
ordering = left < right ? .orderedAscending : left > right ? .orderedDescending : .orderedSame
} else {
ordering = lhs.stringValue.compare(rhs.stringValue, options: .numeric)
}
return switch operation {
case ">": ordering == .orderedDescending
case ">=": ordering != .orderedAscending
case "<": ordering == .orderedAscending
case "<=": ordering != .orderedDescending
default: false
}
}
private func isTruthy(_ value: JSONValue) -> Bool {
switch value {
case .null: false
case .bool(let value): value
case .number(let value): value != 0
case .string(let value): !value.isEmpty
case .array(let value): !value.isEmpty
case .object(let value): !value.isEmpty
}
}
}
@@ -0,0 +1,102 @@
import Foundation
public enum JSONValue: Codable, Hashable, Sendable {
case object([String: JSONValue])
case array([JSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
self = .object(try container.decode([String: JSONValue].self))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .object(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
case .null: try container.encodeNil()
}
}
public static func decode(data: Data) throws -> JSONValue {
try JSONDecoder().decode(JSONValue.self, from: data)
}
public var stringValue: String {
switch self {
case .string(let value): return value
case .number(let value):
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
case .bool(let value): return String(value)
case .null: return ""
case .object, .array:
guard let data = try? JSONEncoder().encode(self) else { return "" }
return String(decoding: data, as: UTF8.self)
}
}
public subscript(key: String) -> JSONValue? {
guard case .object(let value) = self else { return nil }
return value[key]
}
public subscript(index: Int) -> JSONValue? {
guard case .array(let value) = self else { return nil }
let resolved = index >= 0 ? index : value.count + index
guard value.indices.contains(resolved) else { return nil }
return value[resolved]
}
}
public extension JSONValue {
var foundationObject: Any {
switch self {
case .object(let value): value.mapValues(\.foundationObject)
case .array(let value): value.map(\.foundationObject)
case .string(let value): value
case .number(let value): value
case .bool(let value): value
case .null: NSNull()
}
}
static func bridge(_ value: Any?) -> JSONValue {
guard let value else { return .null }
if value is NSNull { return .null }
if let value = value as? JSONValue { return value }
if let value = value as? String { return .string(value) }
if let value = value as? Bool { return .bool(value) }
if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue)
}
if let value = value as? [Any] { return .array(value.map(bridge)) }
if let value = value as? [String: Any] { return .object(value.mapValues(bridge)) }
if let value = value as? NSDictionary {
var object: [String: JSONValue] = [:]
for (key, item) in value { object[String(describing: key)] = bridge(item) }
return .object(object)
}
if let value = value as? NSArray { return .array(value.map(bridge)) }
return .string(String(describing: value))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
import Foundation
public struct RegexLimits: Sendable {
public var maximumInputLength: Int
public var maximumPatternLength: Int
public var maximumMatches: Int
public init(
maximumInputLength: Int = 2_000_000,
maximumPatternLength: Int = 8_192,
maximumMatches: Int = 10_000
) {
self.maximumInputLength = maximumInputLength
self.maximumPatternLength = maximumPatternLength
self.maximumMatches = maximumMatches
}
}
public struct RegexEvaluator: Sendable {
public var limits: RegexLimits
public init(limits: RegexLimits = .init()) {
self.limits = limits
}
public func captureGroups(pattern: String, input: String) throws -> [[String]] {
guard pattern.count <= limits.maximumPatternLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制")
}
guard input.utf16.count <= limits.maximumInputLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制")
}
guard !hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词")
}
let expression: NSRegularExpression
do {
expression = try NSRegularExpression(pattern: pattern)
} catch {
throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription)
}
let range = NSRange(input.startIndex..<input.endIndex, in: input)
var captures: [[String]] = []
let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000
var timedOut = false
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
if DispatchTime.now().uptimeNanoseconds >= deadline {
timedOut = true
stop.pointee = true
return
}
guard let match else { return }
if captures.count >= limits.maximumMatches {
stop.pointee = true
return
}
captures.append((0 ..< match.numberOfRanges).map { group in
let range = match.range(at: group)
guard range.location != NSNotFound, let swiftRange = Range(range, in: input) else { return "" }
return String(input[swiftRange])
})
}
if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") }
if captures.count >= limits.maximumMatches {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制")
}
return captures
}
public func replacing(
pattern: String,
in input: String,
with replacement: String,
firstOnly: Bool
) throws -> String {
guard pattern.count <= limits.maximumPatternLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制")
}
guard input.utf16.count <= limits.maximumInputLength else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制")
}
guard !hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词")
}
let expression: NSRegularExpression
do {
expression = try NSRegularExpression(pattern: pattern)
} catch {
if firstOnly { return replacement }
return input.replacingOccurrences(of: pattern, with: replacement)
}
let range = NSRange(input.startIndex..<input.endIndex, in: input)
if firstOnly {
let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000
var first: NSTextCheckingResult?
var timedOut = false
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
if DispatchTime.now().uptimeNanoseconds >= deadline {
timedOut = true
stop.pointee = true
} else if let match {
first = match
stop.pointee = true
}
}
if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") }
guard let first else { return "" }
return expression.replacementString(for: first, in: input, offset: 0, template: replacement)
}
return try replacingStrict(pattern: pattern, in: input, with: replacement)
}
public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> String {
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
}
let expression: NSRegularExpression
do { expression = try NSRegularExpression(pattern: pattern) }
catch { throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription) }
let milliseconds = timeoutMilliseconds <= 0 ? 3_000 : min(timeoutMilliseconds, 60_000)
let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(milliseconds) * 1_000_000
let range = NSRange(location: 0, length: input.utf16.count)
var matches: [NSTextCheckingResult] = []
var timedOut = false
var exceeded = false
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
if DispatchTime.now().uptimeNanoseconds >= deadline {
timedOut = true
stop.pointee = true
} else if let match {
if matches.count >= limits.maximumMatches { exceeded = true; stop.pointee = true }
else { matches.append(match) }
}
}
guard !timedOut else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") }
guard !exceeded else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制") }
let original = input as NSString
var output = ""
var offset = 0
var outputLength = 0
for match in matches {
guard DispatchTime.now().uptimeNanoseconds < deadline else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset))
let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement)
output += untouched + replaced
outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
}
output += original.substring(from: offset)
return output
}
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"#
guard let expression = try? NSRegularExpression(pattern: risky) else { return false }
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in
guard let innerRange = Range(match.range(at: 1), in: pattern),
let outerRange = Range(match.range(at: 2), in: pattern) else { return true }
let inner = String(pattern[innerRange])
let outer = String(pattern[outerRange])
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false }
if !pattern.contains("(?s"),
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false }
return true
}
}
}
@@ -0,0 +1,103 @@
import Foundation
public struct RuleDiagnostic: Codable, Hashable, Identifiable, Sendable {
public enum Severity: String, Codable, Sendable {
case warning
case error
}
public var severity: Severity
public var code: String
public var message: String
public var rule: String
public var id: String { "\(severity.rawValue):\(code):\(rule):\(message)" }
public init(severity: Severity, code: String, message: String, rule: String) {
self.severity = severity
self.code = code
self.message = message
self.rule = rule
}
}
public enum RuleEngineError: Error, Equatable, LocalizedError, Sendable {
case malformedRule(rule: String, detail: String)
case unsupportedRule(rule: String, feature: String)
case invalidInput(rule: String, expected: String)
case resourceLimit(rule: String, detail: String)
public var code: String {
switch self {
case .malformedRule: "rule.malformed"
case .unsupportedRule: "rule.unsupported"
case .invalidInput: "rule.invalid_input"
case .resourceLimit: "rule.resource_limit"
}
}
public var errorDescription: String? {
switch self {
case .malformedRule(_, let detail),
.invalidInput(_, let detail),
.resourceLimit(_, let detail):
detail
case .unsupportedRule(_, let feature):
"当前规则后端尚不支持:\(feature)"
}
}
public var rule: String {
switch self {
case .malformedRule(let rule, _),
.unsupportedRule(let rule, _),
.invalidInput(let rule, _),
.resourceLimit(let rule, _):
rule
}
}
public var diagnostic: RuleDiagnostic {
.init(severity: .error, code: code, message: localizedDescription, rule: rule)
}
}
public struct RuleEvaluation: Sendable {
public var values: [JSONValue]
public var diagnostics: [RuleDiagnostic]
public var variables: [String: String]
public init(
values: [JSONValue] = [],
diagnostics: [RuleDiagnostic] = [],
variables: [String: String] = [:]
) {
self.values = values
self.diagnostics = diagnostics
self.variables = variables
}
public var strings: [String] {
values.flatMap { value in
if case .array(let items) = value { return items.map(\.stringValue) }
return [value.stringValue]
}
}
func listItems(rule: String) -> [JSONValue] {
if rule.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(":") { return values }
return values.flatMap { value in
if case .array(let items) = value { return items }
return [value]
}
}
public var firstString: String? { strings.first(where: { !$0.isEmpty }) }
public var leadingString: String? {
guard let value = strings.first, !value.isEmpty else { return nil }
return value
}
public var joinedString: String? {
let value = strings.joined(separator: "\n")
return value.isEmpty ? nil : value
}
}
@@ -0,0 +1,533 @@
import Foundation
public struct RuleEngine: Sendable {
private let parser: RuleExpressionParser
private let jsonPath: JSONPathEvaluator
private let regex: RegexEvaluator
private let html: HTMLRuleEvaluator
private let javaScript: JavaScriptRuleEvaluator
public init(
parser: RuleExpressionParser = .init(),
jsonPath: JSONPathEvaluator = .init(),
regex: RegexEvaluator = .init(),
html: HTMLRuleEvaluator = .init(),
javaScript: JavaScriptRuleEvaluator = .init()
) {
self.parser = parser
self.jsonPath = jsonPath
self.regex = regex
self.html = html
self.javaScript = javaScript
}
public func evaluate(
_ rule: String,
in input: JSONValue,
listMode: Bool = false,
context: RuleExecutionContext = .init()
) async -> RuleEvaluation {
var context = context
if context.content == nil { context.content = input }
if let pipeline = splitJavaScriptPipeline(rule) {
return await evaluatePipeline(pipeline, input: input, listMode: listMode, context: context)
}
do {
let extracted = try extractPutRules(rule)
if !extracted.puts.isEmpty {
var nextContext = context
var diagnostics: [RuleDiagnostic] = []
for key in extracted.puts.keys.sorted() {
guard let valueRule = extracted.puts[key] else { continue }
let value = await evaluate(valueRule, in: input, context: nextContext)
diagnostics += value.diagnostics
nextContext.variables.merge(value.variables) { _, new in new }
nextContext.variables[key] = value.strings.joined(separator: "\n")
}
if extracted.rule.isEmpty {
return .init(values: [input], diagnostics: diagnostics, variables: nextContext.variables)
}
var result = await evaluate(extracted.rule, in: input, listMode: listMode, context: nextContext)
result.diagnostics = diagnostics + result.diagnostics
result.variables = nextContext.variables.merging(result.variables) { _, new in new }
return result
}
let rendered = try await renderDynamicRule(extracted.rule, result: input, context: context)
if rendered.captureLiteral {
return literalEvaluation(rendered.rule, context: rendered.context)
}
if rendered.context.variables != context.variables {
var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result
}
return await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
}
}
private func evaluatePrepared(
_ rule: String,
in input: JSONValue,
context: RuleExecutionContext
) async -> RuleEvaluation {
do {
let plan = try parser.parse(rule, inputLooksLikeJSON: true)
var results: [[JSONValue]] = []
var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches {
do {
let values = try await evaluate(branch, in: input, context: context)
results.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
} catch let error as RuleEngineError {
diagnostics.append(error.diagnostic)
results.append([])
} catch {
diagnostics.append(.init(
severity: .error,
code: "rule.unexpected",
message: error.localizedDescription,
rule: branch.expression
))
results.append([])
}
}
return .init(
values: combine(results, with: plan.combination),
diagnostics: diagnostics,
variables: context.variables
)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
return .init(diagnostics: [.init(
severity: .error,
code: "rule.unexpected",
message: error.localizedDescription,
rule: rule
)], variables: context.variables)
}
}
public func evaluateJSONPath(_ rule: String, in input: JSONValue) throws -> [JSONValue] {
try jsonPath.evaluate(path: rule, in: input)
}
public func evaluate(
_ rule: String,
inText input: String,
baseURL: String = "",
listMode: Bool = false,
context: RuleExecutionContext = .init()
) async -> RuleEvaluation {
var context = context
if context.baseURL.isEmpty { context.baseURL = baseURL }
if context.content == nil { context.content = .string(input) }
if let pipeline = splitJavaScriptPipeline(rule) {
return await evaluatePipeline(pipeline, input: .string(input), listMode: listMode, context: context)
}
do {
let extracted = try extractPutRules(rule)
if !extracted.puts.isEmpty {
var nextContext = context
var diagnostics: [RuleDiagnostic] = []
for key in extracted.puts.keys.sorted() {
guard let valueRule = extracted.puts[key] else { continue }
let value = await evaluate(valueRule, inText: input, baseURL: baseURL, listMode: false, context: nextContext)
diagnostics += value.diagnostics
nextContext.variables.merge(value.variables) { _, new in new }
nextContext.variables[key] = value.strings.joined(separator: "\n")
}
if extracted.rule.isEmpty {
return .init(values: [.string(input)], diagnostics: diagnostics, variables: nextContext.variables)
}
var result = await evaluate(extracted.rule, inText: input, baseURL: baseURL, listMode: listMode, context: nextContext)
result.diagnostics = diagnostics + result.diagnostics
result.variables = nextContext.variables.merging(result.variables) { _, new in new }
return result
}
let rendered = try await renderDynamicRule(extracted.rule, result: .string(input), context: context)
if rendered.captureLiteral {
return literalEvaluation(rendered.rule, context: rendered.context)
}
if rendered.context.variables != context.variables {
var result = await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context)
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result
}
return await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
}
}
private func evaluatePrepared(
_ rule: String,
inText input: String,
baseURL: String,
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
if rule.hasPrefix(":"), rule.count > 1 {
do {
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
guard !patterns.isEmpty else { return .init(variables: context.variables) }
var stage = input
var captures: [[String]] = []
for (index, pattern) in patterns.enumerated() {
captures = try regex.captureGroups(pattern: pattern, input: stage)
if index + 1 < patterns.count {
stage = captures.compactMap(\.first).joined()
}
}
return .init(
values: captures.map { .array($0.map(JSONValue.string)) },
variables: context.variables
)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
}
}
do {
let plan = try parser.parse(rule, inputLooksLikeJSON: (try? JSONValue.decode(data: Data(input.utf8))) != nil)
var results: [[JSONValue]] = []
var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches {
do {
let strings = try await evaluateTextBranch(
branch,
input: input,
baseURL: baseURL,
listMode: listMode,
context: context
)
results.append(strings.map(JSONValue.string))
if plan.combination == .fallback, !strings.isEmpty { break }
} catch let error as RuleEngineError {
diagnostics.append(error.diagnostic)
results.append([])
}
}
return .init(
values: combine(results, with: plan.combination),
diagnostics: diagnostics,
variables: context.variables
)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
return .init(diagnostics: [.init(
severity: .error,
code: "rule.unexpected",
message: error.localizedDescription,
rule: rule
)], variables: context.variables)
}
}
private func evaluate(
_ branch: RuleBranch,
in input: JSONValue,
context: RuleExecutionContext
) async throws -> [JSONValue] {
try Task.checkCancellation()
let rawValues: [JSONValue]
if branch.expression.isEmpty {
return try [JSONValue.string(regex.replacing(
pattern: branch.replacePattern ?? "", in: input.stringValue,
with: branch.replacement, firstOnly: branch.replaceFirst
))]
}
switch branch.mode {
case .jsonPath:
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
rawValues = try jsonPath.evaluate(path: path, in: input)
case .regex:
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
rawValues = matches.map { .array($0.map(JSONValue.string)) }
case .css:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "CSS selector")
case .xpath:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "XPath")
case .javaScript:
rawValues = [try await javaScript.evaluateAsync(
script: branch.expression,
result: input,
executionContext: context
).value]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues }
return try rawValues.map {
.string(try regex.replacing(
pattern: pattern,
in: $0.stringValue,
with: branch.replacement,
firstOnly: branch.replaceFirst
))
}
}
private func evaluateTextBranch(
_ branch: RuleBranch,
input: String,
baseURL: String,
listMode: Bool,
context: RuleExecutionContext
) async throws -> [String] {
try Task.checkCancellation()
let raw: [String]
if branch.expression.isEmpty {
return try [regex.replacing(
pattern: branch.replacePattern ?? "", in: input,
with: branch.replacement, firstOnly: branch.replaceFirst
)]
}
switch branch.mode {
case .css:
raw = try listMode
? html.selectCSSFragments(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment)
: html.extractCSS(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment)
case .xpath:
raw = try listMode
? html.selectXPathFragments(branch.expression, html: input, isFragment: context.isHTMLFragment)
: html.extractXPath(branch.expression, html: input, isFragment: context.isHTMLFragment)
case .jsonPath:
let root: JSONValue
do {
root = try JSONValue.decode(data: Data(input.utf8))
} catch {
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
case .regex:
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
}
case .javaScript:
raw = [try await javaScript.evaluateAsync(
script: branch.expression,
result: .string(input),
executionContext: context
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw }
return try raw.map {
try regex.replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
)
}
}
private func combine(_ results: [[JSONValue]], with combination: RuleCombination) -> [JSONValue] {
switch combination {
case .single, .concatenate:
return results.flatMap { $0 }
case .fallback:
return results.first(where: { !$0.isEmpty }) ?? []
case .interleave:
guard let firstCount = results.first?.count else { return [] }
return (0 ..< firstCount).flatMap { index in
results.compactMap { $0.indices.contains(index) ? $0[index] : nil }
}
}
}
private func evaluatePipeline(
_ pipeline: JavaScriptPipeline,
input: JSONValue,
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
var execution = context
let initial: RuleEvaluation
if pipeline.prefix.isEmpty {
initial = .init(values: [input], variables: context.variables)
} else {
initial = await evaluateValue(pipeline.prefix, input: input, listMode: listMode, context: context)
}
execution.variables = initial.variables
var diagnostics = initial.diagnostics
let scriptInput: JSONValue
if pipeline.prefix.isEmpty {
scriptInput = input
} else if listMode {
if initial.values.count == 1, case .array = initial.values[0] {
scriptInput = initial.values[0]
} else {
scriptInput = .array(initial.values)
}
} else {
scriptInput = .string(initial.strings.joined(separator: "\n"))
}
do {
let rendered = try await renderDynamicRule(pipeline.script, result: scriptInput, context: execution)
let result = try await javaScript.evaluateAsync(
script: rendered.rule, result: scriptInput, executionContext: rendered.context
)
execution.variables = result.variables
guard !pipeline.suffix.isEmpty else {
return .init(values: result.value == .null ? [] : [result.value], diagnostics: diagnostics, variables: execution.variables)
}
execution.isHTMLFragment = false
var next = await evaluateValue(pipeline.suffix, input: result.value, listMode: listMode, context: execution)
next.diagnostics = diagnostics + next.diagnostics
return next
} catch let error as RuleEngineError {
diagnostics.append(error.diagnostic)
} catch {
diagnostics.append(.init(severity: .error, code: "rule.javascript", message: error.localizedDescription, rule: pipeline.script))
}
return .init(diagnostics: diagnostics, variables: execution.variables)
}
private func evaluateValue(_ rule: String, input: JSONValue, listMode: Bool, context: RuleExecutionContext) async -> RuleEvaluation {
if case .string(let text) = input {
return await evaluate(rule, inText: text, baseURL: context.baseURL, listMode: listMode, context: context)
}
return await evaluate(rule, in: input, listMode: listMode, context: context)
}
private func splitJavaScriptPipeline(_ rule: String) -> JavaScriptPipeline? {
guard let pattern = try? NSRegularExpression(pattern: #"<js>([\s\S]*?)</js>|@js:([\s\S]*)"#, options: .caseInsensitive),
let match = pattern.firstMatch(in: rule, range: NSRange(rule.startIndex..., in: rule)),
let full = Range(match.range, in: rule),
let script = Range(match.range(at: match.range(at: 2).location == NSNotFound ? 1 : 2), in: rule) else { return nil }
return .init(
prefix: String(rule[..<full.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines),
script: String(rule[script]),
suffix: String(rule[full.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
)
}
private func extractPutRules(_ source: String) throws -> (rule: String, puts: [String: String]) {
let expression = try NSRegularExpression(pattern: #"@put:(\{[^}]+?\})"#, options: .caseInsensitive)
let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source))
guard !matches.isEmpty else { return (source, [:]) }
var puts: [String: String] = [:]
var rule = source
for match in matches.reversed() {
guard let fullRange = Range(match.range(at: 0), in: source),
let jsonRange = Range(match.range(at: 1), in: source) else { continue }
if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(String(source[jsonRange]), quoteUnquotedValues: true) {
puts.merge(object.mapValues(\.stringValue)) { _, new in new }
}
if let currentRange = Range(match.range(at: 0), in: rule) {
rule.removeSubrange(currentRange)
} else {
rule = rule.replacingOccurrences(of: String(source[fullRange]), with: "")
}
}
return (rule.trimmingCharacters(in: .whitespacesAndNewlines), puts)
}
private func renderDynamicRule(
_ source: String,
result: JSONValue,
context: RuleExecutionContext
) async throws -> (rule: String, context: RuleExecutionContext, captureLiteral: Bool) {
var source = source
var captureLiteral = false
if case .array(let captures) = result {
let captureExpression = try NSRegularExpression(pattern: #"\$(\d{1,2})"#)
let captureEnd = source.range(of: "##")?.lowerBound ?? source.endIndex
let matches = captureExpression.matches(in: source, range: NSRange(source.startIndex..<captureEnd, in: source))
if !matches.isEmpty {
captureLiteral = true
for match in matches.reversed() {
guard let fullRange = Range(match.range(at: 0), in: source),
let indexRange = Range(match.range(at: 1), in: source),
let index = Int(source[indexRange]) else { continue }
source.replaceSubrange(fullRange, with: captures.indices.contains(index) ? captures[index].stringValue : "")
}
}
}
let expression = try NSRegularExpression(
pattern: #"@get:\{([^}]+?)\}|\{\{([\s\S]*?)\}\}"#,
options: .caseInsensitive
)
let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source))
guard !matches.isEmpty else { return (source, context, captureLiteral) }
let replacementStart = source.range(of: "##")?.lowerBound ?? source.endIndex
let selectorTemplate = source.hasPrefix("$.") || source.hasPrefix("$[")
if !selectorTemplate, let first = matches.first,
first.range.location < NSRange(source.startIndex..<replacementStart, in: source).length {
captureLiteral = true
}
var nextContext = context
var output = ""
var cursor = source.startIndex
for match in matches {
guard let fullRange = Range(match.range(at: 0), in: source) else { continue }
output += source[cursor..<fullRange.lowerBound]
if match.range(at: 1).location != NSNotFound,
let keyRange = Range(match.range(at: 1), in: source) {
let key = String(source[keyRange])
if key == "bookName", let book = nextContext.book {
output += book.name
} else if key == "title", let chapter = nextContext.chapter {
output += chapter.title
} else {
output += nextContext.variables[key] ?? ""
}
} else if let scriptRange = Range(match.range(at: 2), in: source) {
let expression = String(source[scriptRange]).trimmingCharacters(in: .whitespacesAndNewlines)
if expression.hasPrefix("@") || expression.hasPrefix("$.") || expression.hasPrefix("$[") || expression.hasPrefix("//") {
captureLiteral = true
let nested = await evaluateValue(expression, input: context.content ?? result, listMode: false, context: nextContext)
if let error = nested.diagnostics.first(where: { $0.severity == .error }) {
throw RuleEngineError.malformedRule(rule: expression, detail: error.message)
}
output += nested.strings.joined(separator: "\n")
nextContext.variables = nested.variables
} else {
let evaluated = try await javaScript.evaluateAsync(
script: expression,
result: result,
executionContext: nextContext
)
output += evaluated.value.stringValue
nextContext.variables = evaluated.variables
}
}
cursor = fullRange.upperBound
}
output += source[cursor...]
return (output, nextContext, captureLiteral)
}
private func literalEvaluation(_ source: String, context: RuleExecutionContext) -> RuleEvaluation {
let components = source.components(separatedBy: "##")
var value = components.first ?? ""
if components.count > 1, !components[1].isEmpty {
value = (try? regex.replacing(
pattern: components[1],
in: value,
with: components.count > 2 ? components[2] : "",
firstOnly: components.count > 3
)) ?? value
}
return .init(values: [.string(value)], variables: context.variables)
}
private struct JavaScriptPipeline {
var prefix: String
var script: String
var suffix: String
}
}
@@ -0,0 +1,189 @@
import Foundation
public enum RuleMode: String, Codable, Sendable {
case css
case xpath
case jsonPath
case regex
case javaScript
case webJavaScript
}
public enum RuleCombination: String, Codable, Sendable {
case single
case concatenate = "&&"
case fallback = "||"
case interleave = "%%"
}
public struct RuleBranch: Codable, Hashable, Sendable {
public var mode: RuleMode
public var expression: String
public var replacePattern: String?
public var replacement: String
public var replaceFirst: Bool
public init(
mode: RuleMode,
expression: String,
replacePattern: String? = nil,
replacement: String = "",
replaceFirst: Bool = false
) {
self.mode = mode
self.expression = expression
self.replacePattern = replacePattern
self.replacement = replacement
self.replaceFirst = replaceFirst
}
}
public struct RulePlan: Codable, Hashable, Sendable {
public var source: String
public var combination: RuleCombination
public var branches: [RuleBranch]
}
public struct RuleExpressionParser: Sendable {
public init() {}
public func parse(_ source: String, inputLooksLikeJSON: Bool = false) throws -> RulePlan {
let normalized = source.trimmingCharacters(in: .newlines)
guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
}
let split = try splitAtTopLevel(normalized)
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) }
return .init(source: source, combination: split.combination, branches: branches)
}
private func parseBranch(_ source: String, inputLooksLikeJSON: Bool) throws -> RuleBranch {
var value = String(source.drop(while: { $0.isWhitespace }))
var mode: RuleMode
if value.lowercased().hasPrefix("@css:") {
mode = .css
value.removeFirst(5)
} else if value.hasPrefix("@@") {
mode = .css
value.removeFirst(2)
} else if value.lowercased().hasPrefix("@xpath:") {
mode = .xpath
value.removeFirst(7)
} else if value.lowercased().hasPrefix("@json:") {
mode = .jsonPath
value.removeFirst(6)
} else if value.lowercased().hasPrefix("@js:") {
mode = .javaScript
value.removeFirst(4)
} else if value.lowercased().hasPrefix("<js>") && value.lowercased().hasSuffix("</js>") {
mode = .javaScript
value = String(value.dropFirst(4).dropLast(5))
} else if value.lowercased().hasPrefix("<webjs>") && value.lowercased().hasSuffix("</webjs>") {
mode = .webJavaScript
value = String(value.dropFirst(7).dropLast(8))
} else if value.hasPrefix(":") {
mode = .regex
value.removeFirst()
} else if value.hasPrefix("$.") || value.hasPrefix("$[") || inputLooksLikeJSON {
mode = .jsonPath
} else if value.hasPrefix("/") || value.hasPrefix("./") {
mode = .xpath
} else {
mode = .css
}
let replacement = value.components(separatedBy: "##")
let expression = replacement[0].trimmingCharacters(in: .whitespacesAndNewlines)
guard !expression.isEmpty || replacement.count > 1 else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则主体不能为空")
}
return .init(
mode: mode,
expression: expression,
replacePattern: replacement.count > 1 ? replacement[1] : nil,
replacement: replacement.count > 2 ? replacement[2] : "",
replaceFirst: replacement.count > 3
)
}
private func splitAtTopLevel(_ source: String) throws -> (parts: [String], combination: RuleCombination) {
var quote: Character?
var escaped = false
var roundDepth = 0
var squareDepth = 0
var curlyDepth = 0
var found: RuleCombination?
var parts: [String] = []
var partStart = source.startIndex
var index = source.startIndex
while index < source.endIndex {
let character = source[index]
if escaped {
escaped = false
index = source.index(after: index)
continue
}
if character == "\\" {
escaped = true
index = source.index(after: index)
continue
}
if let activeQuote = quote {
if character == activeQuote { quote = nil }
index = source.index(after: index)
continue
}
if character == "\"" || character == "'" {
quote = character
index = source.index(after: index)
continue
}
switch character {
case "(": roundDepth += 1
case ")": roundDepth -= 1
case "[": squareDepth += 1
case "]": squareDepth -= 1
case "{": curlyDepth += 1
case "}": curlyDepth -= 1
default: break
}
if roundDepth < 0 || squareDepth < 0 || curlyDepth < 0 {
throw RuleEngineError.malformedRule(rule: source, detail: "规则括号未平衡")
}
if roundDepth == 0, squareDepth == 0, curlyDepth == 0 {
let remainder = source[index...]
let candidate: RuleCombination? = if remainder.hasPrefix("&&") {
.concatenate
} else if remainder.hasPrefix("||") {
.fallback
} else if remainder.hasPrefix("%%") {
.interleave
} else {
nil
}
if let candidate {
if found == nil { found = candidate }
if found == candidate {
parts.append(String(source[partStart..<index]))
index = source.index(index, offsetBy: 2)
partStart = index
continue
}
}
}
index = source.index(after: index)
}
guard quote == nil, roundDepth == 0, squareDepth == 0, curlyDepth == 0 else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则引号或括号未闭合")
}
if let found {
parts.append(String(source[partStart...]))
guard parts.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else {
throw RuleEngineError.malformedRule(rule: source, detail: "组合规则存在空分支")
}
return (parts, found)
}
return ([source], .single)
}
}
@@ -0,0 +1,452 @@
import Foundation
public struct SourceSearchOutcome: Sendable {
public var sourceID: String
public var books: [SearchBook]
public var diagnostics: [RuleDiagnostic]
public init(sourceID: String, books: [SearchBook], diagnostics: [RuleDiagnostic]) {
self.sourceID = sourceID
self.books = books
self.diagnostics = diagnostics
}
}
public struct BookSearchReport: Sendable {
public var books: [SearchBook]
public var outcomes: [SourceSearchOutcome]
public init(books: [SearchBook], outcomes: [SourceSearchOutcome]) {
self.books = books
self.outcomes = outcomes
}
}
public actor BookSearchHistory {
public static let shared = BookSearchHistory(defaults: .standard)
private struct Entry: Codable, Sendable {
var success: Date?
var failure: Date?
var failures = 0
}
private var entries: [String: Entry]
private let defaults: UserDefaults?
private static let key = "legado.searchSourceHistory.v1"
private var saveTask: Task<Void, Never>?
public init(defaults: UserDefaults? = nil) {
self.defaults = defaults
entries = defaults?.data(forKey: Self.key).flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:]
}
public func ordered(_ sources: [BookSource], preferred: Set<String>, now: Date = Date()) -> [BookSource] {
func rank(_ source: BookSource) -> Int {
if preferred.contains(source.id) { return 0 }
guard let entry = entries[source.id] else { return 2 }
if let failure = entry.failure, now.timeIntervalSince(failure) < 300, entry.failures > 0 { return 3 }
if let success = entry.success, now.timeIntervalSince(success) < 7 * 86_400 { return 1 }
return 2
}
return sources.enumerated().sorted {
let lhs = rank($0.element), rhs = rank($1.element)
if lhs != rhs { return lhs < rhs }
return $0.offset < $1.offset
}.map(\.element)
}
public func record(_ outcome: SourceSearchOutcome, now: Date = Date()) {
guard !outcome.diagnostics.contains(where: { $0.code == "search.cancelled" }) else { return }
var entry = entries[outcome.sourceID] ?? Entry()
if !outcome.books.isEmpty {
entry.success = now
entry.failures = 0
} else if outcome.diagnostics.contains(where: { $0.severity == .error }) {
entry.failure = now
entry.failures += 1
} else { return }
entries[outcome.sourceID] = entry
guard defaults != nil, saveTask == nil else { return }
saveTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled else { return }
await self?.flush()
}
}
public func flush() {
saveTask?.cancel()
saveTask = nil
guard let defaults else { return }
let retained = entries.sorted {
if ($0.value.success != nil) != ($1.value.success != nil) { return $0.value.success != nil }
return ($0.value.success ?? $0.value.failure ?? .distantPast) > ($1.value.success ?? $1.value.failure ?? .distantPast)
}.prefix(512)
entries = Dictionary(uniqueKeysWithValues: retained.map { ($0.key, $0.value) })
if let data = try? JSONEncoder().encode(entries) { defaults.set(data, forKey: Self.key) }
}
}
public struct BookSearchAccumulator: Sendable {
private var books: [SearchBook] = []
private var indices: [String: Int] = [:]
public init() {}
public mutating func add(_ incoming: [SearchBook], replacingExisting: Bool = false) {
for book in incoming {
let identity = BookSearchService.mergeIdentity(for: book)
if let index = indices[identity] {
let origins = books[index].origins.union(book.origins)
if replacingExisting { books[index] = book }
books[index].origins = origins
} else {
indices[identity] = books.count
books.append(book)
}
}
}
public func results(keyword: String) -> [SearchBook] {
var buckets = Array(repeating: [SearchBook](), count: 4)
for book in books {
let rank: Int
if book.name == keyword || book.author == keyword { rank = 0 }
else if book.kind?.contains(keyword) == true { rank = 1 }
else if book.name.contains(keyword) || book.author.contains(keyword) { rank = 2 }
else { rank = 3 }
buckets[rank].append(book)
}
return buckets.flatMap { $0 }
}
}
public struct BookSearchService: Sendable {
private let transport: any HTTPTransport
private let urlBuilder: LegadoURLBuilder
private let engine: RuleEngine
private let networkPolicy: SourceNetworkPolicy
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
private let snapshotRecorder: (any DifferentialSnapshotRecording)?
private let history: BookSearchHistory
public init(
transport: any HTTPTransport = URLSessionHTTPTransport(),
urlBuilder: LegadoURLBuilder = .init(),
engine: RuleEngine = .init(),
networkPolicy: SourceNetworkPolicy = .shared,
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil,
history: BookSearchHistory = .shared
) {
self.transport = transport
self.urlBuilder = urlBuilder
self.engine = engine
self.networkPolicy = networkPolicy
self.webViewExecutor = webViewExecutor
self.snapshotRecorder = snapshotRecorder
self.history = history
}
public func search(
keyword: String,
page: Int = 1,
sources: [BookSource]
) async -> BookSearchReport {
let enabled = sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }
let sourceOrder = Dictionary(enabled.enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { first, _ in first })
var outcomes: [SourceSearchOutcome] = []
for await outcome in searchOutcomes(keyword: keyword, page: page, sources: enabled) {
outcomes.append(outcome)
}
outcomes.sort { (sourceOrder[$0.sourceID] ?? .max) < (sourceOrder[$1.sourceID] ?? .max) }
return .init(books: merge(outcomes: outcomes, keyword: keyword), outcomes: outcomes)
}
public func searchOutcomes(
keyword: String,
page: Int = 1,
sources: [BookSource],
maximumConcurrentSources: Int = ApplicationSettingsStore.shared.settings.concurrentSources,
includeDisabled: Bool = false,
preferredSourceIDs: Set<String> = []
) -> AsyncStream<SourceSearchOutcome> {
var seen = Set<String>()
let enabled = sources.filter { (includeDisabled || $0.enabled) && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted }
let concurrency = min(max(maximumConcurrentSources, 1), enabled.count)
guard concurrency > 0 else {
return AsyncStream { $0.finish() }
}
return AsyncStream { continuation in
let task = Task {
let ordered = await history.ordered(enabled, preferred: preferredSourceIDs)
await withTaskGroup(of: SourceSearchOutcome.self) { group in
var complexRunning = 0
let complexLimit = min(concurrency, 4)
func isComplex(_ source: BookSource) -> Bool {
let template = (source.searchUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return template.hasPrefix("@js:") || template.hasPrefix("<js>") || template.contains("webview") || template.contains("webjs")
}
let complexIDs = Set(ordered.filter(isComplex).map(\.id))
let normal = ordered.enumerated().filter { !complexIDs.contains($0.element.id) }.map(\.offset)
let complex = ordered.enumerated().filter { complexIDs.contains($0.element.id) }.map(\.offset)
var normalIndex = 0
var complexIndex = 0
func enqueue() -> Bool {
guard !Task.isCancelled else { return false }
let nextNormal = normalIndex < normal.count ? normal[normalIndex] : Int.max
let nextComplex = complexIndex < complex.count && complexRunning < complexLimit ? complex[complexIndex] : Int.max
guard min(nextNormal, nextComplex) != Int.max else { return false }
let source: BookSource
if nextComplex < nextNormal {
source = ordered[nextComplex]
complexIndex += 1
complexRunning += 1
} else {
source = ordered[nextNormal]
normalIndex += 1
}
group.addTask {
await search(keyword: keyword, page: page, source: source,
timeoutLimit: enabled.count > 1 ? 8 : 30)
}
return true
}
for _ in 0..<concurrency { if !enqueue() { break } }
while let outcome = await group.next() {
if complexIDs.contains(outcome.sourceID) { complexRunning -= 1 }
guard !Task.isCancelled else {
group.cancelAll()
break
}
await history.record(outcome)
continuation.yield(outcome)
_ = enqueue()
}
}
await history.flush()
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
Task { await history.flush() }
}
}
}
public func search(keyword: String, page: Int, source: BookSource) async -> SourceSearchOutcome {
await search(keyword: keyword, page: page, source: source, timeoutLimit: 30)
}
private func search(keyword: String, page: Int, source: BookSource, timeoutLimit: TimeInterval) async -> SourceSearchOutcome {
guard let template = source.searchUrl, let rules = source.ruleSearch else {
return failure(source, code: "search.configuration", message: "书源缺少搜索地址或规则")
}
do {
var prepared = try await RuleExecutionWorker.run {
let headers = try BookSourceHeaderResolver().resolve(source)
return try urlBuilder.prepare(template, context: .init(key: keyword, page: page, source: source),
baseURL: URL(string: source.bookSourceUrl), defaultHeaders: headers)
}
if timeoutLimit < 30 { prepared.retry = min(prepared.retry, 1) }
let timeout = min(max(Double(source.respondTime) / 1_000, 5), timeoutLimit)
let response = try await networkPolicy.execute(
prepared,
source: source,
transport: transport,
timeout: timeout,
webViewExecutor: webViewExecutor,
snapshotRecorder: snapshotRecorder,
snapshotStage: .search
)
guard let listRule = rules.bookList, !listRule.isEmpty else {
return failure(source, code: "search.book_list_rule", message: "搜索列表规则为空")
}
let root = response.decodedJSON
let body = response.decodedText
let listEvaluation = if let root {
await engine.evaluate(
listRule.trimmingListPrefix,
in: root,
listMode: true,
context: .init(variables: response.requestVariables, baseURL: response.finalURL.absoluteString, key: keyword, page: page, source: source)
)
} else {
await engine.evaluate(
listRule.trimmingListPrefix,
inText: body,
baseURL: response.finalURL.absoluteString,
listMode: true,
context: .init(variables: response.requestVariables, key: keyword, page: page, source: source)
)
}
var diagnostics = listEvaluation.diagnostics
var books: [SearchBook] = []
for item in listEvaluation.listItems(rule: listRule.trimmingListPrefix) {
try Task.checkCancellation()
let fields = await evaluateFields(
rules,
item: item,
isHTML: root == nil,
baseURL: response.finalURL.absoluteString,
source: source,
variables: listEvaluation.variables,
keyword: keyword,
page: page
)
diagnostics += fields.diagnostics
guard let name = fields.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { continue }
let bookURLValue = fields.bookURL?.nilIfEmpty ?? response.finalURL.absoluteString
let bookURL = resolve(bookURLValue, against: response.finalURL)
var book = SearchBook(
bookURL: bookURL,
origin: source.bookSourceUrl,
originName: source.bookSourceName,
type: source.bookSourceType,
name: name,
author: fields.author ?? ""
)
book.kind = fields.kind
book.coverURL = fields.coverURL.map { resolve($0, against: response.finalURL) }
book.intro = MetadataTextSanitizer().clean(fields.intro)
book.wordCount = fields.wordCount
book.latestChapterTitle = fields.lastChapter
book.originOrder = source.customOrder
book.variables = fields.variables
books.append(book)
}
if listRule.hasPrefix("-") { books.reverse() }
var seenURLs = Set<String>()
let unique = books.filter { seenURLs.insert($0.bookURL).inserted }
return .init(sourceID: source.id, books: unique, diagnostics: diagnostics)
} catch is CancellationError {
return failure(source, code: "search.cancelled", message: "搜索已取消")
} catch let error as RuleEngineError {
return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic])
} catch {
return failure(source, code: "search.request", message: error.localizedDescription)
}
}
private func evaluateFields(
_ rules: SearchRule,
item: JSONValue,
isHTML: Bool,
baseURL: String,
source: BookSource,
variables: [String: String],
keyword: String,
page: Int
) async -> FieldEvaluation {
var result = FieldEvaluation()
let currentBook = Book(bookUrl: baseURL, name: "", origin: source.bookSourceUrl)
var context = RuleExecutionContext(variables: variables, baseURL: baseURL, key: keyword, page: page, source: source, book: currentBook, isHTMLFragment: true)
let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = name.variables
context.book?.name = name.joinedString ?? ""
let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = author.variables
context.book?.author = author.joinedString ?? ""
let kind = await evaluate(rules.kind, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = kind.variables
let bookURL = await evaluate(rules.bookUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = bookURL.variables
context.book?.bookUrl = bookURL.leadingString ?? baseURL
let coverURL = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = coverURL.variables
let intro = await evaluate(rules.intro, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = intro.variables
let wordCount = await evaluate(rules.wordCount, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = wordCount.variables
let lastChapter = await evaluate(rules.lastChapter, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
context.variables = lastChapter.variables
let values = (name, author, kind, bookURL, coverURL, intro, wordCount, lastChapter)
result.name = values.0.joinedString
result.author = values.1.joinedString
result.kind = values.2.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty
result.bookURL = isHTML ? values.3.leadingString : values.3.joinedString
result.coverURL = values.4.joinedString
result.intro = MetadataTextSanitizer().clean(values.5.joinedString)
result.wordCount = values.6.joinedString
result.lastChapter = values.7.joinedString
result.diagnostics = [values.0, values.1, values.2, values.3, values.4, values.5, values.6, values.7]
.flatMap(\.diagnostics)
result.variables = context.variables
return result
}
private func evaluate(
_ rule: String?,
item: JSONValue,
isHTML: Bool,
baseURL: String,
context: RuleExecutionContext = .init()
) async -> RuleEvaluation {
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
if case .string(let text) = item {
return await engine.evaluate(rule, inText: text, baseURL: baseURL, context: context)
}
return await engine.evaluate(rule, in: item, context: context)
}
private func resolve(_ value: String, against baseURL: URL) -> String {
LegadoURLBuilder.resolve(value, against: baseURL)
}
private func failure(_ source: BookSource, code: String, message: String) -> SourceSearchOutcome {
.init(
sourceID: source.id,
books: [],
diagnostics: [.init(severity: .error, code: code, message: message, rule: source.searchUrl ?? "")]
)
}
public func merge(outcomes: [SourceSearchOutcome], keyword: String) -> [SearchBook] {
merge(outcomes.flatMap(\.books), keyword: keyword)
}
private func merge(_ books: [SearchBook], keyword: String) -> [SearchBook] {
var accumulator = BookSearchAccumulator()
accumulator.add(books)
return accumulator.results(keyword: keyword)
}
public static func mergeIdentity(for book: SearchBook) -> String {
"\(normalizedIdentityText(book.name))\u{0}\(normalizedAuthor(book.author))"
}
private static func normalizedIdentityText(_ value: String) -> String {
String(value.unicodeScalars.filter {
!CharacterSet.whitespacesAndNewlines.contains($0)
&& !CharacterSet.punctuationCharacters.contains($0)
&& !CharacterSet.symbols.contains($0)
}).lowercased()
}
private static func normalizedAuthor(_ value: String) -> String {
var value = normalizedIdentityText(value)
for prefix in ["作者", "author", "著者"] where value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}
for suffix in ["著", "撰"] where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
return value
}
private struct FieldEvaluation {
var name: String?
var author: String?
var kind: String?
var bookURL: String?
var coverURL: String?
var intro: String?
var wordCount: String?
var lastChapter: String?
var diagnostics: [RuleDiagnostic] = []
var variables: [String: String] = [:]
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
+407
View File
@@ -0,0 +1,407 @@
import Foundation
import CryptoKit
public enum HttpTTSServiceError: Error, LocalizedError, Sendable {
case emptyText
case invalidContentType(expected: String, actual: String, body: String)
case textResponse(contentType: String, body: String)
public var errorDescription: String? {
switch self {
case .emptyText:
"朗读文本为空"
case .invalidContentType(let expected, let actual, let body):
"TTS 返回类型不匹配(期望 \(expected),实际 \(actual)):\(body)"
case .textResponse(_, let body):
body
}
}
}
public struct HttpTTSAudio: Sendable {
public var data: Data
public var contentType: String?
public var finalURL: URL
public init(data: Data, contentType: String?, finalURL: URL) {
self.data = data
self.contentType = contentType
self.finalURL = finalURL
}
}
public struct HttpTTSAudioStream: Sendable {
public var contentType: String?
public var finalURL: URL
public var expectedContentLength: Int64?
public var chunks: AsyncThrowingStream<Data, Error>
public var isIncremental: Bool
public init(
contentType: String?,
finalURL: URL,
expectedContentLength: Int64?,
chunks: AsyncThrowingStream<Data, Error>,
isIncremental: Bool
) {
self.contentType = contentType
self.finalURL = finalURL
self.expectedContentLength = expectedContentLength
self.chunks = chunks
self.isIncremental = isIncremental
}
}
public actor HttpTTSAudioCache {
public static let shared = HttpTTSAudioCache()
private let directory: URL
private let maximumBytes: Int64
public init(directory: URL? = nil, maximumBytes: Int64 = 128 * 1_024 * 1_024) {
self.directory = directory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/httpTTS_cache", isDirectory: true)
self.maximumBytes = maximumBytes
}
public func data(for key: String) -> Data? {
let url = directory.appendingPathComponent(key + ".audio")
guard let data = try? Data(contentsOf: url) else { return nil }
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
return data
}
public func store(_ data: Data, for key: String) {
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appendingPathComponent(key + ".audio"), options: .atomic)
trimIfNeeded()
} catch { }
}
public func clear() { try? FileManager.default.removeItem(at: directory) }
private func trimIfNeeded() {
let keys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]
guard let files = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: Array(keys),
options: [.skipsHiddenFiles]
) else { return }
var entries: [(URL, Int64, Date)] = files.compactMap { url in
guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { return nil }
return (url, Int64(values.fileSize ?? 0), values.contentModificationDate ?? .distantPast)
}
var total = entries.reduce(Int64(0)) { $0 + $1.1 }
guard total > maximumBytes else { return }
entries.sort { $0.2 < $1.2 }
for entry in entries where total > maximumBytes {
try? FileManager.default.removeItem(at: entry.0)
total -= entry.1
}
}
}
public actor HttpTTSService {
private let urlBuilder: LegadoURLBuilder
private let networkPolicy: SourceNetworkPolicy
private let transport: any HTTPTransport
private let cache: HttpTTSAudioCache
public init(
urlBuilder: LegadoURLBuilder = .init(),
networkPolicy: SourceNetworkPolicy = .shared,
transport: any HTTPTransport = URLSessionHTTPTransport(),
cache: HttpTTSAudioCache = .shared
) {
self.urlBuilder = urlBuilder
self.networkPolicy = networkPolicy
self.transport = transport
self.cache = cache
}
public func audio(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudio {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw HttpTTSServiceError.emptyText }
let source = engine.bookSourceAdapter
let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate)
if let data = await cache.data(for: cacheKey) {
return .init(data: data, contentType: engine.contentType, finalURL: cacheURL(engine: engine))
}
let context = URLTemplateContext(
key: text,
variables: [
"speakText": text,
"speakSpeed": String(speechRate)
],
source: source
)
var lastError: Error = HttpTTSServiceError.emptyText
for attempt in 0..<6 {
try Task.checkCancellation()
do {
let prepared = try urlBuilder.prepare(
engine.url,
context: context,
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
)
var response = try await networkPolicy.execute(
prepared,
source: source,
transport: transport,
timeout: 300
)
if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) {
let rebuilt = try urlBuilder.prepare(
engine.url,
context: context,
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
)
response = try await networkPolicy.execute(
rebuilt,
source: source,
transport: transport,
timeout: 300
)
}
let contentType = response.headers.first {
$0.key.caseInsensitiveCompare("Content-Type") == .orderedSame
}?.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines)
try validateContentType(engine: engine, response: response, contentType: contentType)
await cache.store(response.data, for: cacheKey)
return .init(data: response.data, contentType: contentType, finalURL: response.finalURL)
} catch is CancellationError {
throw CancellationError()
} catch HTTPClientError.statusResponse(let response) {
if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) {
let rebuilt = try urlBuilder.prepare(
engine.url,
context: context,
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
)
let recovered = try await networkPolicy.execute(
rebuilt,
source: source,
transport: transport,
timeout: 300
)
let contentType = Self.contentType(from: recovered.headers)
try validateContentType(engine: engine, response: recovered, contentType: contentType)
await cache.store(recovered.data, for: cacheKey)
return .init(data: recovered.data, contentType: contentType, finalURL: recovered.finalURL)
}
lastError = HTTPClientError.statusResponse(response)
if attempt == 5 { throw lastError }
} catch let error as RuleEngineError {
throw error
} catch {
lastError = error
if attempt == 5 { throw error }
}
}
throw lastError
}
public func audioStream(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudioStream {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw HttpTTSServiceError.emptyText }
let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate)
if let data = await cache.data(for: cacheKey) {
return Self.bufferedStream(
data: data,
contentType: engine.contentType,
finalURL: cacheURL(engine: engine)
)
}
let requiresBufferedValidation = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
guard !requiresBufferedValidation,
let streamingTransport = transport as? any StreamingHTTPTransport else {
let value = try await audio(engine: engine, text: text, speechRate: speechRate)
return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL)
}
let source = engine.bookSourceAdapter
let context = URLTemplateContext(
key: text,
variables: ["speakText": text, "speakSpeed": String(speechRate)],
source: source
)
let prepared = try urlBuilder.prepare(
engine.url,
context: context,
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
)
if prepared.bodyJavaScript != nil || prepared.responseType != nil || prepared.proxy != nil || prepared.useWebView {
let value = try await audio(engine: engine, text: text, speechRate: speechRate)
return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL)
}
let response = try await networkPolicy.executeStream(
prepared,
source: source,
transport: streamingTransport,
timeout: 300
)
let contentType = Self.contentType(from: response.headers)
try validateStreamContentType(engine: engine, contentType: contentType)
let upstream = response.chunks
let cache = self.cache
let cachedChunks = AsyncThrowingStream<Data, Error> { continuation in
let task = Task {
var complete = Data()
if let length = response.expectedContentLength, length > 0, length <= Int64(Int.max) {
complete.reserveCapacity(Int(length))
}
do {
for try await chunk in upstream {
try Task.checkCancellation()
complete.append(chunk)
continuation.yield(chunk)
}
await cache.store(complete, for: cacheKey)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
return .init(
contentType: contentType,
finalURL: response.finalURL,
expectedContentLength: response.expectedContentLength,
chunks: cachedChunks,
isIncremental: true
)
}
private static func cacheKey(engine: HttpTTS, text: String, speechRate: Int) -> String {
let seed = "\(engine.url)-|-\(speechRate)-|-\(text)"
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
return String(digest.dropFirst(8).prefix(16))
}
private static func bufferedStream(data: Data, contentType: String?, finalURL: URL) -> HttpTTSAudioStream {
.init(
contentType: contentType,
finalURL: finalURL,
expectedContentLength: Int64(data.count),
chunks: AsyncThrowingStream { continuation in
continuation.yield(data)
continuation.finish()
},
isIncremental: false
)
}
private static func contentType(from headers: [String: String]) -> String? {
headers.first { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame }?
.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func cacheURL(engine: HttpTTS) -> URL {
URL(string: engine.url.components(separatedBy: ",").first ?? "")
?? URL(fileURLWithPath: "/httpTTS-cache")
}
private func evaluateLoginCheckIfNeeded(engine: HttpTTS, source: BookSource, response: HTTPResponse) throws -> Bool {
guard let script = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines),
!script.isEmpty else { return false }
let headers = response.headers.mapValues(JSONValue.string)
let input = JSONValue.object([
"code": .number(Double(response.statusCode)),
"body": .string(response.decodedText),
"headers": .object(headers),
"url": .string(response.finalURL.absoluteString)
])
let wrapped = """
(function () {
var raw = result;
var rebuildRequest = false;
function header(name) {
var wanted = String(name).toLowerCase();
for (var key in raw.headers) if (key.toLowerCase() === wanted) return raw.headers[key];
return null;
}
result = {
code: function () { return raw.code; },
statusCode: function () { return raw.code; },
body: function () { return { string: function () { return raw.body; } }; },
header: header,
headers: function () { return { get: header }; },
url: function () { return { toString: function () { return raw.url; } }; }
};
java.getHeaderMap = function () {
return { putAll: function () {}, put: function () {} };
};
java.getResponse = function () { rebuildRequest = true; return result; };
java.initUrl = function () { rebuildRequest = true; return result; };
java.ruleUrl = raw.url;
(function () {
\(script)
})();
return { rebuildRequest: rebuildRequest };
})()
"""
let result = try JavaScriptRuleEvaluator().evaluate(
script: wrapped,
result: input,
executionContext: .init(
variables: ["speakSpeed": "", "speakText": ""],
baseURL: response.finalURL.absoluteString,
source: source
)
)
if case .object(let object) = result.value,
case .bool(let requested)? = object["rebuildRequest"] {
return requested
}
return false
}
private func validateContentType(engine: HttpTTS, response: HTTPResponse, contentType: String?) throws {
guard let contentType, !contentType.isEmpty else { return }
if contentType == "application/json" || contentType.hasPrefix("text/") {
throw HttpTTSServiceError.textResponse(contentType: contentType, body: response.decodedText)
}
guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines),
!expected.isEmpty else { return }
let expression = try NSRegularExpression(pattern: expected)
let range = NSRange(contentType.startIndex..., in: contentType)
guard expression.firstMatch(in: contentType, range: range) != nil else {
throw HttpTTSServiceError.invalidContentType(
expected: expected,
actual: contentType,
body: response.decodedText
)
}
}
private func validateStreamContentType(engine: HttpTTS, contentType: String?) throws {
guard let contentType, !contentType.isEmpty else { return }
if contentType == "application/json" || contentType.hasPrefix("text/") {
throw HttpTTSServiceError.textResponse(contentType: contentType, body: "TTS 返回了文本响应")
}
guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines),
!expected.isEmpty else { return }
let expression = try NSRegularExpression(pattern: expected)
let range = NSRange(contentType.startIndex..., in: contentType)
guard expression.firstMatch(in: contentType, range: range) != nil else {
throw HttpTTSServiceError.invalidContentType(expected: expected, actual: contentType, body: "")
}
}
}
public extension HttpTTS {
var sourceKey: String { "httpTts:\(id)" }
var bookSourceAdapter: BookSource {
var source = BookSource(bookSourceUrl: sourceKey, bookSourceName: name)
source.concurrentRate = concurrentRate
source.loginUrl = loginUrl
source.loginUi = loginUi
source.header = header
source.jsLib = jsLib
source.enabledCookieJar = enabledCookieJar
source.loginCheckJs = loginCheckJs
source.lastUpdateTime = lastUpdateTime
return source
}
}