mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -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: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user