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)
}
}