mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21: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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
public enum SourceDebugStage: String, Codable, CaseIterable, Sendable {
|
||||
case configuration
|
||||
case search
|
||||
case detail
|
||||
case toc
|
||||
case content
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .configuration: "配置"
|
||||
case .search: "搜索"
|
||||
case .detail: "详情"
|
||||
case .toc: "目录"
|
||||
case .content: "正文"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SourceDebugStatus: String, Codable, Sendable {
|
||||
case passed
|
||||
case warning
|
||||
case failed
|
||||
case skipped
|
||||
}
|
||||
|
||||
public struct SourceDebugStep: Codable, Hashable, Identifiable, Sendable {
|
||||
public var stage: SourceDebugStage
|
||||
public var status: SourceDebugStatus
|
||||
public var summary: String
|
||||
public var elapsedMilliseconds: Int
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
|
||||
public var id: SourceDebugStage { stage }
|
||||
|
||||
public init(
|
||||
stage: SourceDebugStage,
|
||||
status: SourceDebugStatus,
|
||||
summary: String,
|
||||
elapsedMilliseconds: Int = 0,
|
||||
diagnostics: [RuleDiagnostic] = []
|
||||
) {
|
||||
self.stage = stage
|
||||
self.status = status
|
||||
self.summary = summary
|
||||
self.elapsedMilliseconds = elapsedMilliseconds
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceDebugReport: Codable, Sendable {
|
||||
public var sourceID: String
|
||||
public var keyword: String
|
||||
public var steps: [SourceDebugStep]
|
||||
public var sampleBookName: String?
|
||||
public var sampleChapterTitle: String?
|
||||
public var contentPreview: String?
|
||||
|
||||
public var passed: Bool {
|
||||
!steps.contains { $0.status == .failed }
|
||||
&& steps.contains { $0.stage == .content && $0.status != .skipped }
|
||||
}
|
||||
|
||||
public init(sourceID: String, keyword: String, steps: [SourceDebugStep] = []) {
|
||||
self.sourceID = sourceID
|
||||
self.keyword = keyword
|
||||
self.steps = steps
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceDebugger: Sendable {
|
||||
private let searchService: BookSearchService
|
||||
private let bookService: NetworkBookService
|
||||
|
||||
public init(transport: any HTTPTransport = URLSessionHTTPTransport()) {
|
||||
searchService = BookSearchService(transport: transport)
|
||||
bookService = NetworkBookService(transport: transport)
|
||||
}
|
||||
|
||||
public func run(source: BookSource, keyword: String) async -> BookSourceDebugReport {
|
||||
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var report = BookSourceDebugReport(sourceID: source.id, keyword: keyword)
|
||||
let missing = missingConfiguration(source)
|
||||
report.steps.append(.init(
|
||||
stage: .configuration,
|
||||
status: missing.isEmpty ? .passed : .failed,
|
||||
summary: missing.isEmpty ? "搜索、详情、目录、正文规则均已配置" : "缺少:\(missing.joined(separator: "、"))",
|
||||
diagnostics: missing.map {
|
||||
.init(severity: .error, code: "debug.configuration", message: "缺少\($0)配置", rule: source.bookSourceName)
|
||||
}
|
||||
))
|
||||
guard !keyword.isEmpty else {
|
||||
appendSkipped(after: .configuration, to: &report, reason: "请输入调试关键词")
|
||||
return report
|
||||
}
|
||||
|
||||
let searchStart = ProcessInfo.processInfo.systemUptime
|
||||
let search = await searchService.search(keyword: keyword, page: 1, source: source)
|
||||
let searchStep = step(
|
||||
stage: .search,
|
||||
hasOutput: !search.books.isEmpty,
|
||||
summary: search.books.isEmpty ? "没有解析到搜索结果" : "解析到 \(search.books.count) 条结果",
|
||||
diagnostics: search.diagnostics,
|
||||
started: searchStart
|
||||
)
|
||||
report.steps.append(searchStep)
|
||||
guard let searchBook = search.books.first else {
|
||||
appendSkipped(after: .search, to: &report, reason: "搜索阶段没有样本书籍")
|
||||
return report
|
||||
}
|
||||
report.sampleBookName = searchBook.name
|
||||
|
||||
let detailStart = ProcessInfo.processInfo.systemUptime
|
||||
let detail = await bookService.loadDetail(searchBook: searchBook, source: source)
|
||||
report.steps.append(step(
|
||||
stage: .detail,
|
||||
hasOutput: !detail.book.name.isEmpty,
|
||||
summary: "样本《\(detail.book.name)》;目录地址已\(!detail.book.tocUrl.isEmpty ? "解析" : "回退")",
|
||||
diagnostics: detail.diagnostics,
|
||||
started: detailStart
|
||||
))
|
||||
|
||||
let tocStart = ProcessInfo.processInfo.systemUptime
|
||||
let toc = await bookService.loadChapters(book: detail.book, source: source, maxPages: 5)
|
||||
report.steps.append(step(
|
||||
stage: .toc,
|
||||
hasOutput: !toc.chapters.isEmpty,
|
||||
summary: toc.chapters.isEmpty ? "没有解析到章节" : "解析到 \(toc.chapters.count) 章,访问 \(toc.pagesVisited) 页",
|
||||
diagnostics: toc.diagnostics,
|
||||
started: tocStart
|
||||
))
|
||||
guard let chapter = toc.chapters.first(where: { !$0.isVolume }) else {
|
||||
appendSkipped(after: .toc, to: &report, reason: "目录阶段没有可读取章节")
|
||||
return report
|
||||
}
|
||||
report.sampleChapterTitle = chapter.title
|
||||
|
||||
let next = toc.chapters.drop(while: { $0.id != chapter.id }).dropFirst().first(where: { !$0.isVolume })?.url
|
||||
let contentStart = ProcessInfo.processInfo.systemUptime
|
||||
let content = await bookService.loadContent(
|
||||
book: detail.book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: next,
|
||||
maxPages: 5
|
||||
)
|
||||
let cleaned = content.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
report.contentPreview = cleaned.isEmpty ? nil : String(cleaned.prefix(240))
|
||||
report.steps.append(step(
|
||||
stage: .content,
|
||||
hasOutput: !cleaned.isEmpty,
|
||||
summary: cleaned.isEmpty ? "没有解析到正文" : "正文 \(cleaned.count) 字,访问 \(content.pagesVisited) 页",
|
||||
diagnostics: content.diagnostics,
|
||||
started: contentStart
|
||||
))
|
||||
return report
|
||||
}
|
||||
|
||||
private func missingConfiguration(_ source: BookSource) -> [String] {
|
||||
var missing: [String] = []
|
||||
if source.searchUrl?.isEmpty != false || source.ruleSearch == nil { missing.append("搜索") }
|
||||
if source.ruleBookInfo == nil { missing.append("详情") }
|
||||
if source.ruleToc?.chapterList?.isEmpty != false { missing.append("目录") }
|
||||
if source.ruleContent?.content?.isEmpty != false { missing.append("正文") }
|
||||
return missing
|
||||
}
|
||||
|
||||
private func step(
|
||||
stage: SourceDebugStage,
|
||||
hasOutput: Bool,
|
||||
summary: String,
|
||||
diagnostics: [RuleDiagnostic],
|
||||
started: TimeInterval
|
||||
) -> SourceDebugStep {
|
||||
let containsError = diagnostics.contains { $0.severity == .error }
|
||||
let status: SourceDebugStatus = if !hasOutput {
|
||||
.failed
|
||||
} else if containsError || !diagnostics.isEmpty {
|
||||
.warning
|
||||
} else {
|
||||
.passed
|
||||
}
|
||||
let milliseconds = Int((ProcessInfo.processInfo.systemUptime - started) * 1_000)
|
||||
return .init(
|
||||
stage: stage,
|
||||
status: status,
|
||||
summary: summary,
|
||||
elapsedMilliseconds: max(milliseconds, 0),
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
}
|
||||
|
||||
private func appendSkipped(after stage: SourceDebugStage, to report: inout BookSourceDebugReport, reason: String) {
|
||||
guard let index = SourceDebugStage.allCases.firstIndex(of: stage) else { return }
|
||||
for remaining in SourceDebugStage.allCases.dropFirst(index + 1) {
|
||||
report.steps.append(.init(stage: remaining, status: .skipped, summary: reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
public struct DiagnosticLogEntry: Codable, Identifiable, Sendable {
|
||||
public enum Level: String, Codable, CaseIterable, Sendable { case info, warning, error }
|
||||
public var id: UUID
|
||||
public var date: Date
|
||||
public var level: Level
|
||||
public var category: String
|
||||
public var message: String
|
||||
}
|
||||
|
||||
public final class DiagnosticLog: @unchecked Sendable {
|
||||
public static let shared = DiagnosticLog()
|
||||
private let lock = NSLock()
|
||||
private let fileURL: URL
|
||||
private let maximumEntries: Int
|
||||
private let isEnabled: @Sendable () -> Bool
|
||||
private var entries: [DiagnosticLogEntry]
|
||||
|
||||
public init(directory: URL? = nil, maximumEntries: Int = 500,
|
||||
isEnabled: @escaping @Sendable () -> Bool = { ApplicationSettingsStore.shared.settings.recordLog }) {
|
||||
fileURL = (directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/Logs", isDirectory: true)).appendingPathComponent("diagnostics.json")
|
||||
self.maximumEntries = max(1, maximumEntries)
|
||||
self.isEnabled = isEnabled
|
||||
entries = (try? JSONDecoder().decode([DiagnosticLogEntry].self, from: Data(contentsOf: fileURL))) ?? []
|
||||
entries = Array(entries.suffix(self.maximumEntries))
|
||||
}
|
||||
|
||||
public func record(_ level: DiagnosticLogEntry.Level = .info, category: String, message: String) {
|
||||
guard isEnabled() else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
entries.append(.init(id: UUID(), date: Date(), level: level,
|
||||
category: String(category.prefix(80)), message: Self.redact(String(message.prefix(2048)))))
|
||||
entries = Array(entries.suffix(maximumEntries))
|
||||
try? persist()
|
||||
}
|
||||
|
||||
public func snapshot() -> [DiagnosticLogEntry] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return entries.reversed()
|
||||
}
|
||||
|
||||
public func clear() throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) }
|
||||
entries.removeAll()
|
||||
}
|
||||
|
||||
public func export() -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return snapshot().reversed().map {
|
||||
"\(formatter.string(from: $0.date)) [\($0.level.rawValue.uppercased())] [\($0.category)] \($0.message)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
public func network(_ request: URLRequest, status: Int? = nil, error: Error? = nil, startedAt: Date) {
|
||||
// URL query strings, credentials and fragments can contain access tokens.
|
||||
let url = request.url.map(Self.safeURL) ?? "未知地址"
|
||||
let duration = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||
let result: String
|
||||
if let error, case HTTPClientError.statusResponse(let response) = error {
|
||||
result = "HTTP \(response.statusCode)"
|
||||
} else {
|
||||
result = error.map { "失败 (\(($0 as NSError).domain):\(($0 as NSError).code))" } ?? "HTTP \(status ?? 0)"
|
||||
}
|
||||
record(error != nil || (status ?? 0) >= 400 ? .error : .info, category: "网络",
|
||||
message: "\(request.httpMethod ?? "GET") \(url) · \(result) · \(duration) ms")
|
||||
}
|
||||
|
||||
public static func safeURL(_ url: URL) -> String {
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "[URL]" }
|
||||
parts.user = nil; parts.password = nil; parts.query = nil; parts.fragment = nil
|
||||
return parts.string ?? "[URL]"
|
||||
}
|
||||
|
||||
public static func redact(_ text: String) -> String {
|
||||
var value = text
|
||||
if let regex = try? NSRegularExpression(pattern: #"https?://[^\s\"<>]+"#) {
|
||||
for match in regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).reversed() {
|
||||
guard let range = Range(match.range, in: value), let url = URL(string: String(value[range])) else { continue }
|
||||
value.replaceSubrange(range, with: safeURL(url))
|
||||
}
|
||||
}
|
||||
for pattern in [
|
||||
#"(?im)(authorization|cookie)["']?(\s*[=:]\s*)[^\r\n]+"#,
|
||||
#"(?i)["']?(password|passwd|token|secret)["']?(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)"#
|
||||
] {
|
||||
if let regex = try? NSRegularExpression(pattern: pattern) {
|
||||
value = regex.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: "$1$2[已隐藏]")
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try JSONEncoder().encode(entries).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public enum DifferentialStage: String, Codable, Sendable {
|
||||
case search, detail, toc, content, login, debug
|
||||
}
|
||||
|
||||
public struct DifferentialRequestSnapshot: Codable, Sendable, Equatable {
|
||||
public struct Request: Codable, Sendable, Equatable {
|
||||
public var ruleURL: String
|
||||
public var renderedURL: String
|
||||
public var method: String
|
||||
public var headers: [String: String]
|
||||
public var bodyBase64: String?
|
||||
public var charset: String?
|
||||
public var cookies: [String: String]
|
||||
public var timeoutMilliseconds: Int
|
||||
public var retryCount: Int
|
||||
public var proxy: String?
|
||||
public var dnsIp: String?
|
||||
public var serverID: String?
|
||||
}
|
||||
|
||||
public struct Response: Codable, Sendable, Equatable {
|
||||
public struct ErrorInfo: Codable, Sendable, Equatable {
|
||||
public var code: String
|
||||
public var legadoCode: Int
|
||||
public var message: String
|
||||
}
|
||||
|
||||
public var statusCode: Int
|
||||
public var finalURL: String
|
||||
public var headers: [String: String]
|
||||
public var charset: String?
|
||||
public var bodySHA256: String
|
||||
public var bodyJsSHA256: String?
|
||||
public var setCookies: [String: String]
|
||||
public var error: ErrorInfo?
|
||||
}
|
||||
|
||||
public init(caseID: String, stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, failedResponse: HTTPResponse?, error: Error, timeout: TimeInterval) {
|
||||
self.caseID = caseID
|
||||
self.stage = stage
|
||||
self.sourceID = sourceID
|
||||
let response = failedResponse
|
||||
request = .init(
|
||||
ruleURL: prepared.ruleURL, renderedURL: prepared.url.absoluteString, method: prepared.method.rawValue,
|
||||
headers: Self.normalizedHeaders(prepared.headers), bodyBase64: prepared.body?.base64EncodedString(),
|
||||
charset: prepared.charset,
|
||||
cookies: Self.cookies(from: prepared.headers.first { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame }?.value),
|
||||
timeoutMilliseconds: Int(timeout * 1_000), retryCount: prepared.retry,
|
||||
proxy: prepared.proxy, dnsIp: prepared.dnsIP, serverID: prepared.serverID.map(String.init)
|
||||
)
|
||||
let classification = Self.classify(error)
|
||||
self.response = .init(
|
||||
statusCode: response?.statusCode ?? 0,
|
||||
finalURL: response?.finalURL.absoluteString ?? prepared.url.absoluteString,
|
||||
headers: Self.normalizedHeaders(response?.headers ?? [:]),
|
||||
charset: response?.requestedCharset,
|
||||
bodySHA256: Self.sha256(response?.data ?? Data()),
|
||||
bodyJsSHA256: nil,
|
||||
setCookies: Self.cookies(from: response?.headers.first { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame }?.value),
|
||||
error: .init(code: classification.code, legadoCode: classification.legadoCode, message: error.localizedDescription)
|
||||
)
|
||||
}
|
||||
|
||||
public var schemaVersion = 1
|
||||
public var caseID: String
|
||||
public var platform = "ios"
|
||||
public var stage: DifferentialStage
|
||||
public var sourceID: String
|
||||
public var request: Request
|
||||
public var response: Response
|
||||
|
||||
public init(caseID: String, stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) {
|
||||
self.caseID = caseID
|
||||
self.stage = stage
|
||||
self.sourceID = sourceID
|
||||
let requestCookies = Self.cookies(from: prepared.headers.first { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame }?.value)
|
||||
let setCookies = Self.cookies(from: response.headers.first { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame }?.value)
|
||||
request = .init(
|
||||
ruleURL: prepared.ruleURL,
|
||||
renderedURL: prepared.url.absoluteString,
|
||||
method: prepared.method.rawValue,
|
||||
headers: Self.normalizedHeaders(prepared.headers),
|
||||
bodyBase64: prepared.body?.base64EncodedString(),
|
||||
charset: prepared.charset,
|
||||
cookies: requestCookies,
|
||||
timeoutMilliseconds: Int(timeout * 1_000),
|
||||
retryCount: prepared.retry,
|
||||
proxy: prepared.proxy,
|
||||
dnsIp: prepared.dnsIP,
|
||||
serverID: prepared.serverID.map(String.init)
|
||||
)
|
||||
self.response = .init(
|
||||
statusCode: response.statusCode,
|
||||
finalURL: response.finalURL.absoluteString,
|
||||
headers: Self.normalizedHeaders(response.headers),
|
||||
charset: response.requestedCharset,
|
||||
bodySHA256: Self.sha256(rawResponse.data),
|
||||
bodyJsSHA256: prepared.bodyJavaScript == nil ? nil : Self.sha256(response.data),
|
||||
setCookies: setCookies,
|
||||
error: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func normalizedHeaders(_ headers: [String: String]) -> [String: String] {
|
||||
headers.reduce(into: [:]) { result, entry in
|
||||
let (key, value) = entry
|
||||
let normalized = key.lowercased()
|
||||
result[normalized] = normalized == "cookie" || normalized == "set-cookie" || normalized == "authorization"
|
||||
? "<redacted:\(sha256(Data(value.utf8)))>" : value
|
||||
}
|
||||
}
|
||||
|
||||
private static func cookies(from header: String?) -> [String: String] {
|
||||
guard let header else { return [:] }
|
||||
return header.split(separator: ";").reduce(into: [:]) { result, part in
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { return }
|
||||
result[pair[0].trimmingCharacters(in: .whitespaces)] = "<redacted:\(sha256(Data(pair[1].utf8)))>"
|
||||
}
|
||||
}
|
||||
|
||||
private static func sha256(_ data: Data) -> String {
|
||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func classify(_ error: Error) -> (code: String, legadoCode: Int) {
|
||||
if case HTTPClientError.statusResponse(let response) = error { return ("http.\(response.statusCode)", -7) }
|
||||
guard let urlError = error as? URLError else { return ("other", -7) }
|
||||
switch urlError.code {
|
||||
case .timedOut: return ("timeout", -2)
|
||||
case .cannotFindHost, .dnsLookupFailed: return ("dns", -3)
|
||||
case .cannotConnectToHost: return ("connection_refused", -4)
|
||||
case .networkConnectionLost, .notConnectedToInternet: return ("socket", -5)
|
||||
case .secureConnectionFailed, .serverCertificateHasBadDate, .serverCertificateUntrusted,
|
||||
.serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, .clientCertificateRejected:
|
||||
return ("tls", -6)
|
||||
default: return ("other", -7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol DifferentialSnapshotRecording: Sendable {
|
||||
func record(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) async
|
||||
func recordFailure(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, response: HTTPResponse?, error: Error, timeout: TimeInterval) async
|
||||
}
|
||||
|
||||
public actor MemoryDifferentialSnapshotRecorder: DifferentialSnapshotRecording {
|
||||
public let caseID: String
|
||||
public private(set) var snapshots: [DifferentialRequestSnapshot] = []
|
||||
|
||||
public init(caseID: String) { self.caseID = caseID }
|
||||
|
||||
public func record(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) async {
|
||||
snapshots.append(.init(caseID: caseID, stage: stage, sourceID: sourceID, prepared: prepared, rawResponse: rawResponse, response: response, timeout: timeout))
|
||||
}
|
||||
|
||||
public func recordFailure(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, response: HTTPResponse?, error: Error, timeout: TimeInterval) async {
|
||||
snapshots.append(.init(caseID: caseID, stage: stage, sourceID: sourceID, prepared: prepared, failedResponse: response, error: error, timeout: timeout))
|
||||
}
|
||||
|
||||
public func encodedSnapshots() throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return try encoder.encode(snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
public struct DifferentialBusinessSnapshot: Codable, Sendable {
|
||||
public struct Content: Codable, Sendable {
|
||||
public var title: String?
|
||||
public var text: String
|
||||
public var supplementalText: String?
|
||||
public var pagesVisited: Int
|
||||
}
|
||||
|
||||
public struct Result: Codable, Sendable {
|
||||
public var books: [SearchBook]?
|
||||
public var book: Book?
|
||||
public var chapters: [BookChapter]?
|
||||
public var content: Content?
|
||||
public var variables: [String: String]
|
||||
public var cookies: [String: String]
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
}
|
||||
|
||||
public var schemaVersion = 1
|
||||
public var caseID: String
|
||||
public var platform = "ios"
|
||||
public var flow: DifferentialStage
|
||||
public var result: Result
|
||||
|
||||
public static func search(caseID: String, report: BookSearchReport) -> Self {
|
||||
.init(caseID: caseID, flow: .search, result: .init(
|
||||
books: report.books, book: nil, chapters: nil, content: nil,
|
||||
variables: [:], cookies: [:], diagnostics: report.outcomes.flatMap(\.diagnostics)
|
||||
))
|
||||
}
|
||||
|
||||
public static func detail(caseID: String, report: BookDetailReport) -> Self {
|
||||
.init(caseID: caseID, flow: .detail, result: .init(
|
||||
books: nil, book: report.book, chapters: nil, content: nil,
|
||||
variables: decodeVariables(report.book.variable), cookies: [:], diagnostics: report.diagnostics
|
||||
))
|
||||
}
|
||||
|
||||
public static func toc(caseID: String, report: ChapterListReport) -> Self {
|
||||
.init(caseID: caseID, flow: .toc, result: .init(
|
||||
books: nil, book: nil, chapters: report.chapters, content: nil,
|
||||
variables: [:], cookies: [:], diagnostics: report.diagnostics
|
||||
))
|
||||
}
|
||||
|
||||
public static func content(caseID: String, report: ChapterContentReport) -> Self {
|
||||
.init(caseID: caseID, flow: .content, result: .init(
|
||||
books: nil, book: nil, chapters: nil,
|
||||
content: .init(title: report.title, text: report.content, supplementalText: report.supplementalContent, pagesVisited: report.pagesVisited),
|
||||
variables: [:], cookies: [:], diagnostics: report.diagnostics
|
||||
))
|
||||
}
|
||||
|
||||
public func encoded() throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return try encoder.encode(self)
|
||||
}
|
||||
|
||||
private static func decodeVariables(_ value: String?) -> [String: String] {
|
||||
guard let value, let data = value.data(using: .utf8) else { return [:] }
|
||||
return (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
public enum RSSDebugStage: String, Codable, CaseIterable, Sendable {
|
||||
case configuration, listRequest, listParse, contentRequest, contentParse
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .configuration: "配置"
|
||||
case .listRequest: "列表请求"
|
||||
case .listParse: "列表解析"
|
||||
case .contentRequest: "正文请求"
|
||||
case .contentParse: "正文解析"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSDebugStep: Codable, Hashable, Identifiable, Sendable {
|
||||
public var stage: RSSDebugStage
|
||||
public var status: SourceDebugStatus
|
||||
public var summary: String
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
public var id: RSSDebugStage { stage }
|
||||
}
|
||||
|
||||
public struct RSSSourceDebugReport: Sendable {
|
||||
public var sourceURL: String
|
||||
public var input: String
|
||||
public var steps: [RSSDebugStep] = []
|
||||
public var listSource: String?
|
||||
public var contentSource: String?
|
||||
public var articles: [RssArticle] = []
|
||||
public var contentPreview: String?
|
||||
public var passed: Bool { !steps.contains { $0.status == .failed } && steps.contains { $0.stage == .listParse && $0.status != .skipped } }
|
||||
}
|
||||
|
||||
public struct RSSSourceDebugger: Sendable {
|
||||
private let service: RSSService
|
||||
public init(service: RSSService = .init()) { self.service = service }
|
||||
|
||||
public func run(source: RssSource, input: String) async -> RSSSourceDebugReport {
|
||||
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var report = RSSSourceDebugReport(sourceURL: source.sourceUrl, input: input)
|
||||
let listURL = resolvedListURL(source: source, input: input)
|
||||
let missing = source.sourceUrl.isEmpty || listURL.isEmpty || source.sourceName.isEmpty
|
||||
report.steps.append(.init(stage: .configuration, status: missing ? .failed : .passed, summary: missing ? "源名称、源URL或调试地址为空" : "使用地址:\(listURL)", diagnostics: []))
|
||||
guard !missing else { appendSkipped(after: .configuration, to: &report, reason: "配置不完整"); return report }
|
||||
|
||||
do {
|
||||
let fetched = try await service.articlesDetailed(sortName: input, sortURL: listURL, source: source, page: 1, key: input.isEmpty ? nil : input)
|
||||
report.listSource = fetched.body
|
||||
report.steps.append(.init(stage: .listRequest, status: (200..<400).contains(fetched.statusCode) ? .passed : .warning, summary: "HTTP \(fetched.statusCode);\(fetched.finalURL)", diagnostics: []))
|
||||
report.articles = fetched.report.result.articles
|
||||
report.steps.append(.init(stage: .listParse, status: report.articles.isEmpty ? .failed : (fetched.report.diagnostics.isEmpty ? .passed : .warning), summary: report.articles.isEmpty ? "没有解析到文章" : "解析到 \(report.articles.count) 篇文章", diagnostics: fetched.report.diagnostics))
|
||||
guard let article = report.articles.first, let rule = source.ruleContent, !rule.isEmpty else {
|
||||
report.steps.append(.init(stage: .contentRequest, status: .skipped, summary: "没有样本文章或未配置正文规则", diagnostics: []))
|
||||
report.steps.append(.init(stage: .contentParse, status: .skipped, summary: "正文将直接使用描述或网页", diagnostics: []))
|
||||
return report
|
||||
}
|
||||
let content = try await service.contentDetailed(article: article, rule: rule, source: source)
|
||||
report.contentSource = content.body
|
||||
report.steps.append(.init(stage: .contentRequest, status: (200..<400).contains(content.statusCode) ? .passed : .warning, summary: "HTTP \(content.statusCode);\(content.finalURL)", diagnostics: []))
|
||||
let cleaned = content.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
report.contentPreview = cleaned.isEmpty ? nil : String(cleaned.prefix(400))
|
||||
report.steps.append(.init(stage: .contentParse, status: cleaned.isEmpty ? .failed : (content.diagnostics.isEmpty ? .passed : .warning), summary: cleaned.isEmpty ? "正文结果为空" : "正文结果 \(cleaned.count) 字", diagnostics: content.diagnostics))
|
||||
} catch {
|
||||
let reachedList = report.steps.contains { $0.stage == .listRequest }
|
||||
let stage: RSSDebugStage = reachedList ? .contentRequest : .listRequest
|
||||
report.steps.append(.init(stage: stage, status: .failed, summary: error.localizedDescription, diagnostics: []))
|
||||
appendSkipped(after: stage, to: &report, reason: "前一阶段失败")
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
private func resolvedListURL(source: RssSource, input: String) -> String {
|
||||
if let separator = input.range(of: "::") { return String(input[separator.upperBound...]) }
|
||||
if !input.isEmpty, let search = source.searchUrl, !search.isEmpty { return search }
|
||||
return source.sourceUrl
|
||||
}
|
||||
|
||||
private func appendSkipped(after stage: RSSDebugStage, to report: inout RSSSourceDebugReport, reason: String) {
|
||||
guard let index = RSSDebugStage.allCases.firstIndex(of: stage) else { return }
|
||||
let existing = Set(report.steps.map(\.stage))
|
||||
for value in RSSDebugStage.allCases.dropFirst(index + 1) where !existing.contains(value) {
|
||||
report.steps.append(.init(stage: value, status: .skipped, summary: reason, diagnostics: []))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSource: Codable, Hashable, Identifiable, Sendable {
|
||||
public var bookSourceUrl: String
|
||||
public var bookSourceName: String
|
||||
public var bookSourceGroup: String?
|
||||
public var bookSourceType: Int
|
||||
public var bookUrlPattern: String?
|
||||
public var customOrder: Int
|
||||
public var enabled: Bool
|
||||
public var enabledExplore: Bool
|
||||
public var jsLib: String?
|
||||
public var enabledCookieJar: Bool?
|
||||
public var concurrentRate: String?
|
||||
public var header: String?
|
||||
public var loginUrl: String?
|
||||
public var loginUi: String?
|
||||
public var loginCheckJs: String?
|
||||
public var coverDecodeJs: String?
|
||||
public var bookSourceComment: String?
|
||||
public var variableComment: String?
|
||||
public var lastUpdateTime: Int64
|
||||
public var respondTime: Int64
|
||||
public var weight: Int
|
||||
public var exploreUrl: String?
|
||||
public var exploreScreen: String?
|
||||
public var ruleExplore: ExploreRule?
|
||||
public var searchUrl: String?
|
||||
public var ruleSearch: SearchRule?
|
||||
public var ruleBookInfo: BookInfoRule?
|
||||
public var ruleToc: TocRule?
|
||||
public var ruleContent: ContentRule?
|
||||
public var ruleReview: ReviewRule?
|
||||
public var eventListener: Bool
|
||||
public var customButton: Bool
|
||||
|
||||
public var additionalFields: [String: JSONValue] = [:]
|
||||
|
||||
public var id: String { bookSourceUrl }
|
||||
|
||||
public init(bookSourceUrl: String, bookSourceName: String) {
|
||||
self.bookSourceUrl = bookSourceUrl
|
||||
self.bookSourceName = bookSourceName
|
||||
bookSourceGroup = nil
|
||||
bookSourceType = 0
|
||||
bookUrlPattern = nil
|
||||
customOrder = 0
|
||||
enabled = true
|
||||
enabledExplore = true
|
||||
jsLib = nil
|
||||
enabledCookieJar = true
|
||||
concurrentRate = nil
|
||||
header = nil
|
||||
loginUrl = nil
|
||||
loginUi = nil
|
||||
loginCheckJs = nil
|
||||
coverDecodeJs = nil
|
||||
bookSourceComment = nil
|
||||
variableComment = nil
|
||||
lastUpdateTime = 0
|
||||
respondTime = 180_000
|
||||
weight = 0
|
||||
exploreUrl = nil
|
||||
exploreScreen = nil
|
||||
ruleExplore = nil
|
||||
searchUrl = nil
|
||||
ruleSearch = nil
|
||||
ruleBookInfo = nil
|
||||
ruleToc = nil
|
||||
ruleContent = nil
|
||||
ruleReview = nil
|
||||
eventListener = false
|
||||
customButton = false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case bookSourceUrl, bookSourceName, bookSourceGroup, bookSourceType, bookUrlPattern, customOrder, enabled, enabledExplore, jsLib, enabledCookieJar, concurrentRate, header, loginUrl, loginUi, loginCheckJs, coverDecodeJs, bookSourceComment, variableComment, lastUpdateTime, respondTime, weight, exploreUrl, exploreScreen, ruleExplore, searchUrl, ruleSearch, ruleBookInfo, ruleToc, ruleContent, ruleReview, eventListener, customButton
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bookSourceUrl = try values.decodeIfPresent(String.self, forKey: .bookSourceUrl) ?? ""
|
||||
bookSourceName = try values.decodeIfPresent(String.self, forKey: .bookSourceName) ?? ""
|
||||
bookSourceGroup = try values.decodeIfPresent(String.self, forKey: .bookSourceGroup)
|
||||
bookSourceType = try values.decodeLossyInt(forKey: .bookSourceType) ?? 0
|
||||
bookUrlPattern = try values.decodeIfPresent(String.self, forKey: .bookUrlPattern)
|
||||
customOrder = try values.decodeLossyInt(forKey: .customOrder) ?? 0
|
||||
enabled = try values.decodeLossyBool(forKey: .enabled) ?? true
|
||||
enabledExplore = try values.decodeLossyBool(forKey: .enabledExplore) ?? true
|
||||
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
|
||||
enabledCookieJar = values.contains(.enabledCookieJar)
|
||||
? try values.decodeLossyBool(forKey: .enabledCookieJar)
|
||||
: true
|
||||
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
|
||||
header = try values.decodeIfPresent(String.self, forKey: .header)
|
||||
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
|
||||
loginUi = try values.decodeIfPresent(String.self, forKey: .loginUi)
|
||||
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
|
||||
coverDecodeJs = try values.decodeIfPresent(String.self, forKey: .coverDecodeJs)
|
||||
bookSourceComment = try values.decodeIfPresent(String.self, forKey: .bookSourceComment)
|
||||
variableComment = try values.decodeIfPresent(String.self, forKey: .variableComment)
|
||||
lastUpdateTime = try values.decodeLossyInt64(forKey: .lastUpdateTime) ?? 0
|
||||
respondTime = try values.decodeLossyInt64(forKey: .respondTime) ?? 180_000
|
||||
weight = try values.decodeLossyInt(forKey: .weight) ?? 0
|
||||
exploreUrl = try values.decodeIfPresent(String.self, forKey: .exploreUrl)
|
||||
exploreScreen = try values.decodeIfPresent(String.self, forKey: .exploreScreen)
|
||||
ruleExplore = try values.decodeFlexible(ExploreRule.self, forKey: .ruleExplore)
|
||||
searchUrl = try values.decodeIfPresent(String.self, forKey: .searchUrl)
|
||||
ruleSearch = try values.decodeFlexible(SearchRule.self, forKey: .ruleSearch)
|
||||
ruleBookInfo = try values.decodeFlexible(BookInfoRule.self, forKey: .ruleBookInfo)
|
||||
ruleToc = try values.decodeFlexible(TocRule.self, forKey: .ruleToc)
|
||||
ruleContent = try values.decodeFlexible(ContentRule.self, forKey: .ruleContent)
|
||||
ruleReview = try values.decodeFlexible(ReviewRule.self, forKey: .ruleReview)
|
||||
eventListener = try values.decodeLossyBool(forKey: .eventListener) ?? false
|
||||
customButton = try values.decodeLossyBool(forKey: .customButton) ?? false
|
||||
if case .object(var raw) = try JSONValue(from: decoder) {
|
||||
for key in CodingKeys.allCases {
|
||||
if BookSourceEditorDocument.ruleKeys.contains(key.rawValue) {
|
||||
var nested: [String: JSONValue] = [:]
|
||||
var value = raw[key.rawValue]
|
||||
if case .string(let text) = value { value = try? JSONValue.decode(data: Data(text.utf8)) }
|
||||
if case .object(let object) = value { nested = object }
|
||||
for field in BookSourceEditorSchema.sections.flatMap(\.fields) where field.path.hasPrefix(key.rawValue + ".") {
|
||||
nested.removeValue(forKey: String(field.path.split(separator: ".")[1]))
|
||||
}
|
||||
raw[key.rawValue] = nested.isEmpty ? nil : .object(nested)
|
||||
} else { raw.removeValue(forKey: key.rawValue) }
|
||||
}
|
||||
additionalFields = raw
|
||||
}
|
||||
}
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var values = encoder.container(keyedBy: SourceCodingKey.self)
|
||||
for (key, value) in additionalFields { try values.encode(value, forKey: SourceCodingKey(key)) }
|
||||
try values.encode(bookSourceUrl, forKey: SourceCodingKey("bookSourceUrl"))
|
||||
try values.encode(bookSourceName, forKey: SourceCodingKey("bookSourceName"))
|
||||
try values.encodeIfPresent(bookSourceGroup, forKey: SourceCodingKey("bookSourceGroup"))
|
||||
try values.encode(bookSourceType, forKey: SourceCodingKey("bookSourceType"))
|
||||
try values.encodeIfPresent(bookUrlPattern, forKey: SourceCodingKey("bookUrlPattern"))
|
||||
try values.encode(customOrder, forKey: SourceCodingKey("customOrder"))
|
||||
try values.encode(enabled, forKey: SourceCodingKey("enabled"))
|
||||
try values.encode(enabledExplore, forKey: SourceCodingKey("enabledExplore"))
|
||||
try values.encodeIfPresent(jsLib, forKey: SourceCodingKey("jsLib"))
|
||||
try values.encode(enabledCookieJar, forKey: SourceCodingKey("enabledCookieJar"))
|
||||
try values.encodeIfPresent(concurrentRate, forKey: SourceCodingKey("concurrentRate"))
|
||||
try values.encodeIfPresent(header, forKey: SourceCodingKey("header"))
|
||||
try values.encodeIfPresent(loginUrl, forKey: SourceCodingKey("loginUrl"))
|
||||
try values.encodeIfPresent(loginUi, forKey: SourceCodingKey("loginUi"))
|
||||
try values.encodeIfPresent(loginCheckJs, forKey: SourceCodingKey("loginCheckJs"))
|
||||
try values.encodeIfPresent(coverDecodeJs, forKey: SourceCodingKey("coverDecodeJs"))
|
||||
try values.encodeIfPresent(bookSourceComment, forKey: SourceCodingKey("bookSourceComment"))
|
||||
try values.encodeIfPresent(variableComment, forKey: SourceCodingKey("variableComment"))
|
||||
try values.encode(lastUpdateTime, forKey: SourceCodingKey("lastUpdateTime"))
|
||||
try values.encode(respondTime, forKey: SourceCodingKey("respondTime"))
|
||||
try values.encode(weight, forKey: SourceCodingKey("weight"))
|
||||
try values.encodeIfPresent(exploreUrl, forKey: SourceCodingKey("exploreUrl"))
|
||||
try values.encodeIfPresent(exploreScreen, forKey: SourceCodingKey("exploreScreen"))
|
||||
try encodeRule(ruleExplore, key: "ruleExplore", into: &values)
|
||||
try values.encodeIfPresent(searchUrl, forKey: SourceCodingKey("searchUrl"))
|
||||
try encodeRule(ruleSearch, key: "ruleSearch", into: &values)
|
||||
try encodeRule(ruleBookInfo, key: "ruleBookInfo", into: &values)
|
||||
try encodeRule(ruleToc, key: "ruleToc", into: &values)
|
||||
try encodeRule(ruleContent, key: "ruleContent", into: &values)
|
||||
try encodeRule(ruleReview, key: "ruleReview", into: &values)
|
||||
try values.encode(eventListener, forKey: SourceCodingKey("eventListener"))
|
||||
try values.encode(customButton, forKey: SourceCodingKey("customButton"))
|
||||
}
|
||||
|
||||
private func encodeRule<T: Encodable>(_ rule: T?, key: String, into values: inout KeyedEncodingContainer<SourceCodingKey>) throws {
|
||||
var object: [String: JSONValue] = [:]
|
||||
if case .object(let extra) = additionalFields[key] { object = extra }
|
||||
if let rule, case .object(let known) = try JSONValue.decode(data: JSONEncoder().encode(rule)) {
|
||||
object.merge(known) { _, new in new }
|
||||
}
|
||||
if rule != nil || !object.isEmpty { try values.encode(object, forKey: SourceCodingKey(key)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension BookSource {
|
||||
var groups: [String] {
|
||||
Self.parseGroups(bookSourceGroup)
|
||||
}
|
||||
|
||||
func hasGroup(_ group: String) -> Bool {
|
||||
groups.contains(group.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
mutating func addGroups(_ value: String) {
|
||||
bookSourceGroup = Self.joinGroups(groups + Self.parseGroups(value))
|
||||
}
|
||||
|
||||
mutating func removeGroups(_ value: String) {
|
||||
let removed = Set(Self.parseGroups(value))
|
||||
bookSourceGroup = Self.joinGroups(groups.filter { !removed.contains($0) })
|
||||
}
|
||||
|
||||
mutating func replaceGroup(_ oldValue: String, with newValue: String?) {
|
||||
let oldValue = oldValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var values = groups.filter { $0 != oldValue }
|
||||
if let newValue { values += Self.parseGroups(newValue) }
|
||||
bookSourceGroup = Self.joinGroups(values)
|
||||
}
|
||||
|
||||
static func parseGroups(_ value: String?) -> [String] {
|
||||
guard let value else { return [] }
|
||||
var seen = Set<String>()
|
||||
return value
|
||||
.components(separatedBy: CharacterSet(charactersIn: ",;,;"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
static func joinGroups(_ values: [String]) -> String? {
|
||||
var seen = Set<String>()
|
||||
let normalized = values
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||
return normalized.isEmpty ? nil : normalized.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeFlexible<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(T.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
let json = try decode(String.self, forKey: key)
|
||||
guard !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let data = json.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||
let value = try decode(String.self, forKey: key).lowercased()
|
||||
if ["true", "1", "yes"].contains(value) { return true }
|
||||
if ["false", "0", "no"].contains(value) { return false }
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||
return Int(try decode(String.self, forKey: key))
|
||||
}
|
||||
|
||||
func decodeLossyInt64(forKey key: Key) throws -> Int64? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(Int64.self, forKey: key) { return value }
|
||||
return Int64(try decode(String.self, forKey: key))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSourceEditorField: Identifiable, Sendable {
|
||||
public let path: String
|
||||
public let title: String
|
||||
public var id: String { path }
|
||||
public var help: String {
|
||||
switch path {
|
||||
case "bookSourceUrl": return "填写书源站点地址,例如 https://example.com;它也是书源的唯一标识。"
|
||||
case "bookSourceName": return "显示在书源列表中的名称,不能为空。"
|
||||
case "bookSourceGroup": return "多个分组可用逗号或分号分隔。"
|
||||
case "bookUrlPattern": return "匹配该书源的书籍详情 URL 的正则表达式,用于从网址添加书籍。"
|
||||
case "concurrentRate": return "1000 表示请求间隔 1000 ms;1/1000 表示每 1000 ms 最多 1 次请求。"
|
||||
case "header": return "填写请求头 JSON,或用 @js: / <js> 动态生成。User-Agent 为 null 可禁用默认 UA。"
|
||||
case "jsLib": return "填写共享 JavaScript,或以 JSON 对象配置在线脚本库。"
|
||||
case "searchUrl": return "用 {{key}} 表示关键词、{{page}} 表示页码;URL 后可用逗号附加请求选项 JSON。"
|
||||
case "exploreUrl": return "填写 名称::地址,多项用换行或 && 分隔;也支持 JSON 分类数组和 JavaScript。"
|
||||
case "loginUi": return "填写登录界面 JSON。"
|
||||
case "ruleToc.formatJs": return "目录去重后处理标题,可使用 title、index(从 1 开始)和 gInt。"
|
||||
case "ruleContent.nextContentUrl": return "提取当前章的下一分页地址,而非下一章。"
|
||||
case "ruleContent.replaceRegex": return "在多页正文合并后执行的替换规则,使用 ## 分隔正则和替换内容。"
|
||||
case "ruleContent.imageStyle": return "FULL 表示图片铺满;留空使用默认样式。"
|
||||
default:
|
||||
if isJavaScript { return "使用 JavaScript 编写,可通过更多菜单调试当前书源草稿。" }
|
||||
if path.contains(".") { return "支持 CSS / JSoup、XPath、JSONPath、正则和 JavaScript;留空表示不配置此项。" }
|
||||
return "留空表示不配置此项。"
|
||||
}
|
||||
}
|
||||
public var isJavaScript: Bool {
|
||||
path.lowercased().contains("js") || path == "jsLib" || path.hasSuffix("imageDecode") || path.hasSuffix("payAction")
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceEditorSection: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let fields: [BookSourceEditorField]
|
||||
}
|
||||
|
||||
public enum BookSourceEditorSchema {
|
||||
public static let sections: [BookSourceEditorSection] = [
|
||||
.init(id: "basic", title: "基础", fields: [
|
||||
.init(path: "bookSourceName", title: "书源名称"),
|
||||
.init(path: "bookSourceUrl", title: "书源地址"),
|
||||
.init(path: "bookSourceGroup", title: "分组"),
|
||||
.init(path: "bookSourceComment", title: "备注"),
|
||||
.init(path: "bookUrlPattern", title: "书籍 URL 正则"),
|
||||
.init(path: "concurrentRate", title: "并发率"),
|
||||
.init(path: "header", title: "请求头"),
|
||||
.init(path: "jsLib", title: "JavaScript 库"),
|
||||
.init(path: "variableComment", title: "变量说明"),
|
||||
]),
|
||||
.init(id: "login", title: "登录", fields: [
|
||||
.init(path: "loginUrl", title: "登录地址 / 脚本"),
|
||||
.init(path: "loginUi", title: "自定义登录界面"),
|
||||
.init(path: "loginCheckJs", title: "登录检测脚本"),
|
||||
.init(path: "coverDecodeJs", title: "封面解密脚本"),
|
||||
]),
|
||||
.init(id: "search", title: "搜索", fields: [
|
||||
.init(path: "searchUrl", title: "搜索地址"),
|
||||
.init(path: "ruleSearch.checkKeyWord", title: "测试关键词"),
|
||||
.init(path: "ruleSearch.bookList", title: "书籍列表"),
|
||||
.init(path: "ruleSearch.name", title: "书名"),
|
||||
.init(path: "ruleSearch.author", title: "作者"),
|
||||
.init(path: "ruleSearch.intro", title: "简介"),
|
||||
.init(path: "ruleSearch.kind", title: "分类"),
|
||||
.init(path: "ruleSearch.lastChapter", title: "最新章节"),
|
||||
.init(path: "ruleSearch.updateTime", title: "更新时间"),
|
||||
.init(path: "ruleSearch.bookUrl", title: "详情地址"),
|
||||
.init(path: "ruleSearch.coverUrl", title: "封面地址"),
|
||||
.init(path: "ruleSearch.wordCount", title: "字数"),
|
||||
]),
|
||||
.init(id: "explore", title: "发现", fields: [
|
||||
.init(path: "exploreUrl", title: "发现分类地址"),
|
||||
.init(path: "exploreScreen", title: "发现筛选配置"),
|
||||
.init(path: "ruleExplore.bookList", title: "书籍列表"),
|
||||
.init(path: "ruleExplore.name", title: "书名"),
|
||||
.init(path: "ruleExplore.author", title: "作者"),
|
||||
.init(path: "ruleExplore.intro", title: "简介"),
|
||||
.init(path: "ruleExplore.kind", title: "分类"),
|
||||
.init(path: "ruleExplore.lastChapter", title: "最新章节"),
|
||||
.init(path: "ruleExplore.updateTime", title: "更新时间"),
|
||||
.init(path: "ruleExplore.bookUrl", title: "详情地址"),
|
||||
.init(path: "ruleExplore.coverUrl", title: "封面地址"),
|
||||
.init(path: "ruleExplore.wordCount", title: "字数"),
|
||||
]),
|
||||
.init(id: "info", title: "详情", fields: [
|
||||
.init(path: "ruleBookInfo.init", title: "预处理"),
|
||||
.init(path: "ruleBookInfo.name", title: "书名"),
|
||||
.init(path: "ruleBookInfo.author", title: "作者"),
|
||||
.init(path: "ruleBookInfo.intro", title: "简介"),
|
||||
.init(path: "ruleBookInfo.kind", title: "分类"),
|
||||
.init(path: "ruleBookInfo.lastChapter", title: "最新章节"),
|
||||
.init(path: "ruleBookInfo.updateTime", title: "更新时间"),
|
||||
.init(path: "ruleBookInfo.coverUrl", title: "封面地址"),
|
||||
.init(path: "ruleBookInfo.tocUrl", title: "目录地址"),
|
||||
.init(path: "ruleBookInfo.wordCount", title: "字数"),
|
||||
.init(path: "ruleBookInfo.canReName", title: "允许修改书名作者"),
|
||||
.init(path: "ruleBookInfo.downloadUrls", title: "文件下载地址"),
|
||||
]),
|
||||
.init(id: "toc", title: "目录", fields: [
|
||||
.init(path: "ruleToc.preUpdateJs", title: "更新前脚本"),
|
||||
.init(path: "ruleToc.chapterList", title: "章节列表"),
|
||||
.init(path: "ruleToc.chapterName", title: "章节名称"),
|
||||
.init(path: "ruleToc.chapterUrl", title: "章节地址"),
|
||||
.init(path: "ruleToc.formatJs", title: "标题处理脚本"),
|
||||
.init(path: "ruleToc.isVolume", title: "卷名标识"),
|
||||
.init(path: "ruleToc.isVip", title: "VIP 标识"),
|
||||
.init(path: "ruleToc.isPay", title: "已购买标识"),
|
||||
.init(path: "ruleToc.updateTime", title: "章节信息"),
|
||||
.init(path: "ruleToc.nextTocUrl", title: "目录下一页"),
|
||||
]),
|
||||
.init(id: "content", title: "正文", fields: [
|
||||
.init(path: "ruleContent.content", title: "正文"),
|
||||
.init(path: "ruleContent.subContent", title: "副正文"),
|
||||
.init(path: "ruleContent.title", title: "正文标题"),
|
||||
.init(path: "ruleContent.nextContentUrl", title: "正文下一页"),
|
||||
.init(path: "ruleContent.webJs", title: "WebView 注入脚本"),
|
||||
.init(path: "ruleContent.sourceRegex", title: "资源正则"),
|
||||
.init(path: "ruleContent.replaceRegex", title: "合并后替换"),
|
||||
.init(path: "ruleContent.imageStyle", title: "图片样式"),
|
||||
.init(path: "ruleContent.imageDecode", title: "图片解密脚本"),
|
||||
.init(path: "ruleContent.payAction", title: "购买脚本"),
|
||||
.init(path: "ruleContent.callBackJs", title: "事件回调脚本"),
|
||||
]),
|
||||
.init(id: "review", title: "段评", fields: [
|
||||
.init(path: "ruleReview.reviewUrl", title: "段评地址"),
|
||||
.init(path: "ruleReview.avatarRule", title: "头像"),
|
||||
.init(path: "ruleReview.contentRule", title: "内容"),
|
||||
.init(path: "ruleReview.postTimeRule", title: "发布时间"),
|
||||
.init(path: "ruleReview.reviewQuoteUrl", title: "回复地址"),
|
||||
.init(path: "ruleReview.voteUpUrl", title: "点赞地址"),
|
||||
.init(path: "ruleReview.voteDownUrl", title: "点踩地址"),
|
||||
.init(path: "ruleReview.postReviewUrl", title: "发布段评"),
|
||||
.init(path: "ruleReview.postQuoteUrl", title: "发布回复"),
|
||||
.init(path: "ruleReview.deleteUrl", title: "删除地址"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
public struct BookSourceEditorDocument: Sendable, Equatable {
|
||||
public private(set) var fields: [String: JSONValue]
|
||||
|
||||
public init(source: BookSource) throws {
|
||||
self = try Self(json: String(decoding: JSONEncoder().encode(source), as: UTF8.self))
|
||||
}
|
||||
|
||||
public init(json: String) throws {
|
||||
guard case .object(let object) = try JSONValue.decode(data: Data(json.utf8)) else {
|
||||
throw BookSourceImportError.invalidDocument("编辑器需要单个书源 JSON 对象")
|
||||
}
|
||||
fields = object
|
||||
for key in Self.ruleKeys {
|
||||
if case .string(let text) = fields[key], !text.isEmpty,
|
||||
case .object(let object) = try JSONValue.decode(data: Data(text.utf8)) {
|
||||
fields[key] = .object(object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func value(at path: String) -> JSONValue? {
|
||||
let keys = path.split(separator: ".").map(String.init)
|
||||
guard let key = keys.first else { return nil }
|
||||
return keys.count == 1 ? fields[key] : fields[key]?[keys[1]]
|
||||
}
|
||||
|
||||
public mutating func set(_ value: JSONValue?, at path: String) {
|
||||
let keys = path.split(separator: ".").map(String.init)
|
||||
guard let key = keys.first else { return }
|
||||
if keys.count == 1 { fields[key] = value; return }
|
||||
var object: [String: JSONValue] = [:]
|
||||
if case .object(let existing) = fields[key] { object = existing }
|
||||
object[keys[1]] = value
|
||||
fields[key] = object.isEmpty ? nil : .object(object)
|
||||
}
|
||||
|
||||
public func json() throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return String(decoding: try encoder.encode(fields), as: UTF8.self)
|
||||
}
|
||||
|
||||
public func source() throws -> BookSource {
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(fields))
|
||||
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||
guard errors.isEmpty else { throw BookSourceImportError.invalidSources(errors) }
|
||||
return source
|
||||
}
|
||||
|
||||
static let ruleKeys = ["ruleSearch", "ruleExplore", "ruleBookInfo", "ruleToc", "ruleContent", "ruleReview"]
|
||||
}
|
||||
|
||||
struct SourceCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int? = nil
|
||||
init(_ value: String) { stringValue = value }
|
||||
init?(stringValue: String) { self.init(stringValue) }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookListRule: Codable, Hashable, Sendable {
|
||||
public var bookList: String?
|
||||
public var name: String?
|
||||
public var author: String?
|
||||
public var intro: String?
|
||||
public var kind: String?
|
||||
public var lastChapter: String?
|
||||
public var updateTime: String?
|
||||
public var bookUrl: String?
|
||||
public var coverUrl: String?
|
||||
public var wordCount: String?
|
||||
|
||||
public init(
|
||||
bookList: String? = nil,
|
||||
name: String? = nil,
|
||||
author: String? = nil,
|
||||
intro: String? = nil,
|
||||
kind: String? = nil,
|
||||
lastChapter: String? = nil,
|
||||
updateTime: String? = nil,
|
||||
bookUrl: String? = nil,
|
||||
coverUrl: String? = nil,
|
||||
wordCount: String? = nil
|
||||
) {
|
||||
self.bookList = bookList
|
||||
self.name = name
|
||||
self.author = author
|
||||
self.intro = intro
|
||||
self.kind = kind
|
||||
self.lastChapter = lastChapter
|
||||
self.updateTime = updateTime
|
||||
self.bookUrl = bookUrl
|
||||
self.coverUrl = coverUrl
|
||||
self.wordCount = wordCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchRule: Codable, Hashable, Sendable {
|
||||
public var checkKeyWord: String?
|
||||
public var bookList: String?
|
||||
public var name: String?
|
||||
public var author: String?
|
||||
public var intro: String?
|
||||
public var kind: String?
|
||||
public var lastChapter: String?
|
||||
public var updateTime: String?
|
||||
public var bookUrl: String?
|
||||
public var coverUrl: String?
|
||||
public var wordCount: String?
|
||||
|
||||
public init(
|
||||
checkKeyWord: String? = nil,
|
||||
bookList: String? = nil,
|
||||
name: String? = nil,
|
||||
author: String? = nil,
|
||||
intro: String? = nil,
|
||||
kind: String? = nil,
|
||||
lastChapter: String? = nil,
|
||||
updateTime: String? = nil,
|
||||
bookUrl: String? = nil,
|
||||
coverUrl: String? = nil,
|
||||
wordCount: String? = nil
|
||||
) {
|
||||
self.checkKeyWord = checkKeyWord
|
||||
self.bookList = bookList
|
||||
self.name = name
|
||||
self.author = author
|
||||
self.intro = intro
|
||||
self.kind = kind
|
||||
self.lastChapter = lastChapter
|
||||
self.updateTime = updateTime
|
||||
self.bookUrl = bookUrl
|
||||
self.coverUrl = coverUrl
|
||||
self.wordCount = wordCount
|
||||
}
|
||||
}
|
||||
|
||||
public typealias ExploreRule = BookListRule
|
||||
|
||||
public struct BookInfoRule: Codable, Hashable, Sendable {
|
||||
public var `init`: String?
|
||||
public var name: String?
|
||||
public var author: String?
|
||||
public var intro: String?
|
||||
public var kind: String?
|
||||
public var lastChapter: String?
|
||||
public var updateTime: String?
|
||||
public var coverUrl: String?
|
||||
public var tocUrl: String?
|
||||
public var wordCount: String?
|
||||
public var canReName: String?
|
||||
public var downloadUrls: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public struct TocRule: Codable, Hashable, Sendable {
|
||||
public var preUpdateJs: String?
|
||||
public var chapterList: String?
|
||||
public var chapterName: String?
|
||||
public var chapterUrl: String?
|
||||
public var formatJs: String?
|
||||
public var isVolume: String?
|
||||
public var isVip: String?
|
||||
public var isPay: String?
|
||||
public var updateTime: String?
|
||||
public var nextTocUrl: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public struct ContentRule: Codable, Hashable, Sendable {
|
||||
public var content: String?
|
||||
public var subContent: String?
|
||||
public var title: String?
|
||||
public var nextContentUrl: String?
|
||||
public var webJs: String?
|
||||
public var sourceRegex: String?
|
||||
public var replaceRegex: String?
|
||||
public var imageStyle: String?
|
||||
public var imageDecode: String?
|
||||
public var payAction: String?
|
||||
public var callBackJs: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public struct ReviewRule: Codable, Hashable, Sendable {
|
||||
public var reviewUrl: String?
|
||||
public var avatarRule: String?
|
||||
public var contentRule: String?
|
||||
public var postTimeRule: String?
|
||||
public var reviewQuoteUrl: String?
|
||||
public var voteUpUrl: String?
|
||||
public var voteDownUrl: String?
|
||||
public var postReviewUrl: String?
|
||||
public var postQuoteUrl: String?
|
||||
public var deleteUrl: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
public struct ExploreKind: Codable, Hashable, Identifiable, Sendable {
|
||||
public var title: String
|
||||
public var url: String?
|
||||
public var type: String
|
||||
public var action: String?
|
||||
public var chars: [String?]?
|
||||
public var defaultValue: String?
|
||||
|
||||
public var id: String { "\(title)\u{0}\(url ?? "")" }
|
||||
|
||||
public var navigableURL: String? {
|
||||
guard type.caseInsensitiveCompare("url") == .orderedSame,
|
||||
let value = url?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, url, type, action, chars
|
||||
case defaultValue = "default"
|
||||
}
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
url: String? = nil,
|
||||
type: String = "url",
|
||||
action: String? = nil,
|
||||
chars: [String?]? = nil,
|
||||
defaultValue: String? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.type = type
|
||||
self.action = action
|
||||
self.chars = chars
|
||||
self.defaultValue = defaultValue
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = values.lossyString(forKey: .title) ?? ""
|
||||
url = values.lossyString(forKey: .url)
|
||||
type = values.lossyString(forKey: .type) ?? "url"
|
||||
action = values.lossyString(forKey: .action)
|
||||
defaultValue = values.lossyString(forKey: .defaultValue)
|
||||
if let decoded = try? values.decodeIfPresent([JSONValue].self, forKey: .chars) {
|
||||
chars = decoded.map { value in
|
||||
if case .null = value { return nil }
|
||||
return value.stringValue
|
||||
}
|
||||
} else if let scalar = values.lossyString(forKey: .chars) {
|
||||
chars = [scalar]
|
||||
} else {
|
||||
chars = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer where Key == ExploreKind.CodingKeys {
|
||||
func lossyString(forKey key: Key) -> String? {
|
||||
guard contains(key), (try? decodeNil(forKey: key)) != true else { return nil }
|
||||
if let value = try? decode(String.self, forKey: key) { return value }
|
||||
if let value = try? decode(Bool.self, forKey: key) { return String(value) }
|
||||
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
|
||||
if let value = try? decode(Double.self, forKey: key) {
|
||||
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExploreKindsReport: Sendable {
|
||||
public var kinds: [ExploreKind]
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
|
||||
public init(kinds: [ExploreKind] = [], diagnostics: [RuleDiagnostic] = []) {
|
||||
self.kinds = kinds
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import Foundation
|
||||
|
||||
public struct Book: Codable, Hashable, Identifiable, Sendable {
|
||||
public var bookUrl: String
|
||||
public var tocUrl: String
|
||||
public var origin: String
|
||||
public var originName: String
|
||||
public var name: String
|
||||
public var author: String
|
||||
public var kind: String?
|
||||
public var customTag: String?
|
||||
public var coverUrl: String?
|
||||
public var customCoverUrl: String?
|
||||
public var intro: String?
|
||||
public var customIntro: String?
|
||||
public var charset: String?
|
||||
public var type: Int
|
||||
public var group: Int64
|
||||
public var latestChapterTitle: String?
|
||||
public var latestChapterTime: Int64
|
||||
public var lastCheckTime: Int64
|
||||
public var lastCheckCount: Int
|
||||
public var totalChapterNum: Int
|
||||
public var durChapterTitle: String?
|
||||
public var durChapterIndex: Int
|
||||
public var durVolumeIndex: Int
|
||||
public var chapterInVolumeIndex: Int
|
||||
public var durChapterPos: Int
|
||||
public var durChapterTime: Int64
|
||||
public var wordCount: String?
|
||||
public var canUpdate: Bool
|
||||
public var order: Int
|
||||
public var originOrder: Int
|
||||
public var variable: String?
|
||||
public var readConfig: BookReadConfig?
|
||||
public var syncTime: Int64
|
||||
|
||||
public var id: String { bookUrl }
|
||||
|
||||
public init(bookUrl: String, name: String, author: String = "", origin: String = "local") {
|
||||
self.bookUrl = bookUrl
|
||||
tocUrl = ""
|
||||
self.origin = origin
|
||||
originName = ""
|
||||
self.name = name
|
||||
self.author = author
|
||||
kind = nil
|
||||
customTag = nil
|
||||
coverUrl = nil
|
||||
customCoverUrl = nil
|
||||
intro = nil
|
||||
customIntro = nil
|
||||
charset = nil
|
||||
type = 0
|
||||
group = 0
|
||||
latestChapterTitle = nil
|
||||
latestChapterTime = 0
|
||||
lastCheckTime = 0
|
||||
lastCheckCount = 0
|
||||
totalChapterNum = 0
|
||||
durChapterTitle = nil
|
||||
durChapterIndex = 0
|
||||
durVolumeIndex = 0
|
||||
chapterInVolumeIndex = 0
|
||||
durChapterPos = 0
|
||||
durChapterTime = 0
|
||||
wordCount = nil
|
||||
canUpdate = true
|
||||
order = 0
|
||||
originOrder = 0
|
||||
variable = nil
|
||||
readConfig = nil
|
||||
syncTime = 0
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case bookUrl, tocUrl, origin, originName, name, author, kind, customTag, coverUrl, customCoverUrl
|
||||
case intro, customIntro, charset, type, group, latestChapterTitle, latestChapterTime, lastCheckTime
|
||||
case lastCheckCount, totalChapterNum, durChapterTitle, durChapterIndex, durVolumeIndex
|
||||
case chapterInVolumeIndex, durChapterPos, durChapterTime, wordCount, canUpdate, order, originOrder
|
||||
case variable, readConfig, syncTime
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||
tocUrl = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
|
||||
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? "local"
|
||||
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
|
||||
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
kind = try value.decodeIfPresent(String.self, forKey: .kind)
|
||||
customTag = try value.decodeIfPresent(String.self, forKey: .customTag)
|
||||
coverUrl = try value.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||
customCoverUrl = try value.decodeIfPresent(String.self, forKey: .customCoverUrl)
|
||||
intro = try value.decodeIfPresent(String.self, forKey: .intro)
|
||||
customIntro = try value.decodeIfPresent(String.self, forKey: .customIntro)
|
||||
charset = try value.decodeIfPresent(String.self, forKey: .charset)
|
||||
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||
group = try value.decodeIfPresent(Int64.self, forKey: .group) ?? 0
|
||||
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
|
||||
latestChapterTime = try value.decodeIfPresent(Int64.self, forKey: .latestChapterTime) ?? 0
|
||||
lastCheckTime = try value.decodeIfPresent(Int64.self, forKey: .lastCheckTime) ?? 0
|
||||
lastCheckCount = try value.decodeIfPresent(Int.self, forKey: .lastCheckCount) ?? 0
|
||||
totalChapterNum = try value.decodeIfPresent(Int.self, forKey: .totalChapterNum) ?? 0
|
||||
durChapterTitle = try value.decodeIfPresent(String.self, forKey: .durChapterTitle)
|
||||
durChapterIndex = try value.decodeIfPresent(Int.self, forKey: .durChapterIndex) ?? 0
|
||||
durVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .durVolumeIndex) ?? 0
|
||||
chapterInVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .chapterInVolumeIndex) ?? 0
|
||||
durChapterPos = try value.decodeIfPresent(Int.self, forKey: .durChapterPos) ?? 0
|
||||
durChapterTime = try value.decodeIfPresent(Int64.self, forKey: .durChapterTime) ?? 0
|
||||
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||
canUpdate = try value.decodeIfPresent(Bool.self, forKey: .canUpdate) ?? true
|
||||
order = try value.decodeIfPresent(Int.self, forKey: .order) ?? 0
|
||||
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
|
||||
variable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||
readConfig = try value.decodeIfPresent(BookReadConfig.self, forKey: .readConfig)
|
||||
syncTime = try value.decodeIfPresent(Int64.self, forKey: .syncTime) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookReadConfig: Codable, Hashable, Sendable {
|
||||
public var reverseToc = false
|
||||
public var pageAnim: Int?
|
||||
public var reSegment = false
|
||||
public var imageStyle: String?
|
||||
public var useReplaceRule: Bool?
|
||||
public var delTag: Int64 = 0
|
||||
public var ttsEngine: String?
|
||||
public var splitLongChapter = true
|
||||
public var readSimulating = false
|
||||
public var startDate: String?
|
||||
public var startChapter: Int?
|
||||
public var dailyChapters = 3
|
||||
public var openCredits = 0
|
||||
public var closeCredits = 0
|
||||
public var playMode = 0
|
||||
public var playSpeed: Float = 1
|
||||
|
||||
public init() {}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case reverseToc, pageAnim, reSegment, imageStyle, useReplaceRule, delTag, ttsEngine
|
||||
case splitLongChapter, readSimulating, startDate, startChapter, dailyChapters
|
||||
case openCredits, closeCredits, playMode, playSpeed
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
reverseToc = try value.decodeIfPresent(Bool.self, forKey: .reverseToc) ?? false
|
||||
pageAnim = try value.decodeIfPresent(Int.self, forKey: .pageAnim)
|
||||
reSegment = try value.decodeIfPresent(Bool.self, forKey: .reSegment) ?? false
|
||||
imageStyle = try value.decodeIfPresent(String.self, forKey: .imageStyle)
|
||||
useReplaceRule = try value.decodeIfPresent(Bool.self, forKey: .useReplaceRule)
|
||||
delTag = try value.decodeIfPresent(Int64.self, forKey: .delTag) ?? 0
|
||||
ttsEngine = try value.decodeIfPresent(String.self, forKey: .ttsEngine)
|
||||
splitLongChapter = try value.decodeIfPresent(Bool.self, forKey: .splitLongChapter) ?? true
|
||||
readSimulating = try value.decodeIfPresent(Bool.self, forKey: .readSimulating) ?? false
|
||||
startDate = try value.decodeIfPresent(String.self, forKey: .startDate)
|
||||
startChapter = try value.decodeIfPresent(Int.self, forKey: .startChapter)
|
||||
dailyChapters = try value.decodeIfPresent(Int.self, forKey: .dailyChapters) ?? 3
|
||||
openCredits = try value.decodeIfPresent(Int.self, forKey: .openCredits) ?? 0
|
||||
closeCredits = try value.decodeIfPresent(Int.self, forKey: .closeCredits) ?? 0
|
||||
playMode = try value.decodeIfPresent(Int.self, forKey: .playMode) ?? 0
|
||||
playSpeed = try value.decodeIfPresent(Float.self, forKey: .playSpeed) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookGroup: Codable, Hashable, Identifiable, Sendable {
|
||||
public static let idRoot: Int64 = -100
|
||||
public static let idAll: Int64 = -1
|
||||
public static let idLocal: Int64 = -2
|
||||
public static let idAudio: Int64 = -3
|
||||
public static let idNetNone: Int64 = -4
|
||||
public static let idLocalNone: Int64 = -5
|
||||
public static let idVideo: Int64 = -6
|
||||
public static let idError: Int64 = -11
|
||||
|
||||
public var groupId: Int64
|
||||
public var groupName: String
|
||||
public var cover: String?
|
||||
public var order: Int
|
||||
public var enableRefresh: Bool
|
||||
public var show: Bool
|
||||
public var bookSort: Int
|
||||
public var onlyUpdateRead: Bool
|
||||
|
||||
public var id: Int64 { groupId }
|
||||
|
||||
public init(
|
||||
groupId: Int64,
|
||||
groupName: String,
|
||||
cover: String? = nil,
|
||||
order: Int = 0,
|
||||
enableRefresh: Bool = true,
|
||||
show: Bool = true,
|
||||
bookSort: Int = -1,
|
||||
onlyUpdateRead: Bool = false
|
||||
) {
|
||||
self.groupId = groupId
|
||||
self.groupName = groupName
|
||||
self.cover = cover
|
||||
self.order = order
|
||||
self.enableRefresh = enableRefresh
|
||||
self.show = show
|
||||
self.bookSort = bookSort
|
||||
self.onlyUpdateRead = onlyUpdateRead
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookChapter: Codable, Hashable, Identifiable, Sendable {
|
||||
public var url: String
|
||||
public var title: String
|
||||
public var isVolume: Bool
|
||||
public var baseUrl: String
|
||||
public var bookUrl: String
|
||||
public var index: Int
|
||||
public var isVip: Bool
|
||||
public var isPay: Bool
|
||||
public var resourceUrl: String?
|
||||
public var tag: String?
|
||||
public var wordCount: String?
|
||||
public var start: Int64?
|
||||
public var end: Int64?
|
||||
public var startFragmentId: String?
|
||||
public var endFragmentId: String?
|
||||
public var variable: String?
|
||||
public var imageURL: String?
|
||||
|
||||
public var id: String { "\(bookUrl)\u{0}\(url)" }
|
||||
|
||||
public init(url: String, title: String, bookUrl: String, index: Int) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
isVolume = false
|
||||
baseUrl = ""
|
||||
self.bookUrl = bookUrl
|
||||
self.index = index
|
||||
isVip = false
|
||||
isPay = false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, title, isVolume, baseUrl, bookUrl, index, isVip, isPay, resourceUrl, tag, wordCount
|
||||
case start, end, startFragmentId, endFragmentId, variable, imgUrl, imageURL
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
url = try value.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
title = try value.decodeIfPresent(String.self, forKey: .title) ?? ""
|
||||
isVolume = try value.decodeIfPresent(Bool.self, forKey: .isVolume) ?? false
|
||||
baseUrl = try value.decodeIfPresent(String.self, forKey: .baseUrl) ?? ""
|
||||
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||
index = try value.decodeIfPresent(Int.self, forKey: .index) ?? 0
|
||||
isVip = try value.decodeIfPresent(Bool.self, forKey: .isVip) ?? false
|
||||
isPay = try value.decodeIfPresent(Bool.self, forKey: .isPay) ?? false
|
||||
resourceUrl = try value.decodeIfPresent(String.self, forKey: .resourceUrl)
|
||||
tag = try value.decodeIfPresent(String.self, forKey: .tag)
|
||||
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||
start = try value.decodeIfPresent(Int64.self, forKey: .start)
|
||||
end = try value.decodeIfPresent(Int64.self, forKey: .end)
|
||||
startFragmentId = try value.decodeIfPresent(String.self, forKey: .startFragmentId)
|
||||
endFragmentId = try value.decodeIfPresent(String.self, forKey: .endFragmentId)
|
||||
variable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||
imageURL = try value.decodeIfPresent(String.self, forKey: .imgUrl)
|
||||
?? value.decodeIfPresent(String.self, forKey: .imageURL)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var value = encoder.container(keyedBy: CodingKeys.self)
|
||||
try value.encode(url, forKey: .url)
|
||||
try value.encode(title, forKey: .title)
|
||||
try value.encode(isVolume, forKey: .isVolume)
|
||||
try value.encode(baseUrl, forKey: .baseUrl)
|
||||
try value.encode(bookUrl, forKey: .bookUrl)
|
||||
try value.encode(index, forKey: .index)
|
||||
try value.encode(isVip, forKey: .isVip)
|
||||
try value.encode(isPay, forKey: .isPay)
|
||||
try value.encodeIfPresent(resourceUrl, forKey: .resourceUrl)
|
||||
try value.encodeIfPresent(tag, forKey: .tag)
|
||||
try value.encodeIfPresent(wordCount, forKey: .wordCount)
|
||||
try value.encodeIfPresent(start, forKey: .start)
|
||||
try value.encodeIfPresent(end, forKey: .end)
|
||||
try value.encodeIfPresent(startFragmentId, forKey: .startFragmentId)
|
||||
try value.encodeIfPresent(endFragmentId, forKey: .endFragmentId)
|
||||
try value.encodeIfPresent(variable, forKey: .variable)
|
||||
try value.encodeIfPresent(imageURL, forKey: .imgUrl)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Bookmark: Codable, Hashable, Identifiable, Sendable {
|
||||
public var time: Int64
|
||||
public var bookName: String
|
||||
public var bookAuthor: String
|
||||
public var chapterIndex: Int
|
||||
public var chapterPosition: Int
|
||||
public var chapterName: String
|
||||
public var bookText: String
|
||||
public var content: String
|
||||
|
||||
public var id: Int64 { time }
|
||||
|
||||
public init(time: Int64, bookName: String, bookAuthor: String = "") {
|
||||
self.time = time
|
||||
self.bookName = bookName
|
||||
self.bookAuthor = bookAuthor
|
||||
chapterIndex = 0
|
||||
chapterPosition = 0
|
||||
chapterName = ""
|
||||
bookText = ""
|
||||
content = ""
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case time, bookName, bookAuthor, chapterIndex, chapterPos, chapterPosition
|
||||
case chapterName, bookText, content
|
||||
}
|
||||
|
||||
public func resolvedChapterIndex(in chapters: [BookChapter]) -> Int? {
|
||||
if !chapterName.isEmpty,
|
||||
let index = chapters.firstIndex(where: { !$0.isVolume && $0.title == chapterName }) {
|
||||
return index
|
||||
}
|
||||
return chapters.firstIndex(where: { !$0.isVolume && $0.index == chapterIndex })
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
time = try values.decodeIfPresent(Int64.self, forKey: .time) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
|
||||
bookAuthor = try values.decodeIfPresent(String.self, forKey: .bookAuthor) ?? ""
|
||||
chapterIndex = try values.decodeIfPresent(Int.self, forKey: .chapterIndex) ?? 0
|
||||
chapterPosition = try values.decodeIfPresent(Int.self, forKey: .chapterPos)
|
||||
?? values.decodeIfPresent(Int.self, forKey: .chapterPosition)
|
||||
?? 0
|
||||
chapterName = try values.decodeIfPresent(String.self, forKey: .chapterName) ?? ""
|
||||
bookText = try values.decodeIfPresent(String.self, forKey: .bookText) ?? ""
|
||||
content = try values.decodeIfPresent(String.self, forKey: .content) ?? ""
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||
try values.encode(time, forKey: .time)
|
||||
try values.encode(bookName, forKey: .bookName)
|
||||
try values.encode(bookAuthor, forKey: .bookAuthor)
|
||||
try values.encode(chapterIndex, forKey: .chapterIndex)
|
||||
try values.encode(chapterPosition, forKey: .chapterPos)
|
||||
try values.encode(chapterName, forKey: .chapterName)
|
||||
try values.encode(bookText, forKey: .bookText)
|
||||
try values.encode(content, forKey: .content)
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadRecord: Codable, Hashable, Identifiable, Sendable {
|
||||
public var deviceId: String
|
||||
public var bookName: String
|
||||
public var readTime: Int64
|
||||
public var lastRead: Int64
|
||||
public var dailyReadTimes: [String: Int64]
|
||||
|
||||
public var id: String { "\(deviceId)\u{0}\(bookName)" }
|
||||
|
||||
public init(
|
||||
deviceId: String,
|
||||
bookName: String,
|
||||
readTime: Int64 = 0,
|
||||
lastRead: Int64? = nil,
|
||||
dailyReadTimes: [String: Int64] = [:]
|
||||
) {
|
||||
self.deviceId = deviceId
|
||||
self.bookName = bookName
|
||||
self.readTime = readTime
|
||||
self.lastRead = lastRead ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
self.dailyReadTimes = dailyReadTimes
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case deviceId, bookName, readTime, lastRead, dailyReadTimes
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
deviceId = try values.decodeIfPresent(String.self, forKey: .deviceId) ?? ""
|
||||
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
|
||||
readTime = try values.decodeIfPresent(Int64.self, forKey: .readTime) ?? 0
|
||||
lastRead = try values.decodeIfPresent(Int64.self, forKey: .lastRead) ?? 0
|
||||
dailyReadTimes = try values.decodeIfPresent([String: Int64].self, forKey: .dailyReadTimes) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadRecordSummary: Hashable, Identifiable, Sendable {
|
||||
public var bookName: String
|
||||
public var readTime: Int64
|
||||
public var lastRead: Int64
|
||||
|
||||
public var id: String { bookName }
|
||||
|
||||
public init(bookName: String, readTime: Int64, lastRead: Int64) {
|
||||
self.bookName = bookName
|
||||
self.readTime = readTime
|
||||
self.lastRead = lastRead
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaceRule: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var group: String?
|
||||
public var pattern: String
|
||||
public var replacement: String
|
||||
public var scope: String?
|
||||
public var scopeTitle: Bool
|
||||
public var scopeContent: Bool
|
||||
public var excludeScope: String?
|
||||
public var isEnabled: Bool
|
||||
public var isRegex: Bool
|
||||
public var timeoutMillisecond: Int64
|
||||
public var sortOrder: Int
|
||||
|
||||
public init(id: Int64, name: String, pattern: String, replacement: String = "") {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.pattern = pattern
|
||||
self.replacement = replacement
|
||||
scopeTitle = false
|
||||
scopeContent = true
|
||||
isEnabled = true
|
||||
isRegex = true
|
||||
timeoutMillisecond = 3_000
|
||||
sortOrder = Int.min
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, group, pattern, replacement, scope, scopeTitle, scopeContent
|
||||
case excludeScope, isEnabled, isRegex, timeoutMillisecond, sortOrder, order
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
group = try values.decodeIfPresent(String.self, forKey: .group)
|
||||
pattern = try values.decodeIfPresent(String.self, forKey: .pattern) ?? ""
|
||||
replacement = try values.decodeIfPresent(String.self, forKey: .replacement) ?? ""
|
||||
scope = try values.decodeIfPresent(String.self, forKey: .scope)
|
||||
scopeTitle = try values.decodeIfPresent(Bool.self, forKey: .scopeTitle) ?? false
|
||||
scopeContent = try values.decodeIfPresent(Bool.self, forKey: .scopeContent) ?? true
|
||||
excludeScope = try values.decodeIfPresent(String.self, forKey: .excludeScope)
|
||||
isEnabled = try values.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
|
||||
isRegex = try values.decodeIfPresent(Bool.self, forKey: .isRegex) ?? true
|
||||
timeoutMillisecond = try values.decodeIfPresent(Int64.self, forKey: .timeoutMillisecond) ?? 3_000
|
||||
sortOrder = try values.decodeIfPresent(Int.self, forKey: .sortOrder)
|
||||
?? values.decodeIfPresent(Int.self, forKey: .order)
|
||||
?? Int.min
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||
try values.encode(id, forKey: .id)
|
||||
try values.encode(name, forKey: .name)
|
||||
try values.encodeIfPresent(group, forKey: .group)
|
||||
try values.encode(pattern, forKey: .pattern)
|
||||
try values.encode(replacement, forKey: .replacement)
|
||||
try values.encodeIfPresent(scope, forKey: .scope)
|
||||
try values.encode(scopeTitle, forKey: .scopeTitle)
|
||||
try values.encode(scopeContent, forKey: .scopeContent)
|
||||
try values.encodeIfPresent(excludeScope, forKey: .excludeScope)
|
||||
try values.encode(isEnabled, forKey: .isEnabled)
|
||||
try values.encode(isRegex, forKey: .isRegex)
|
||||
try values.encode(timeoutMillisecond, forKey: .timeoutMillisecond)
|
||||
try values.encode(sortOrder, forKey: .order)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
|
||||
public struct RssSource: Codable, Hashable, Identifiable, Sendable {
|
||||
public var sourceUrl = ""
|
||||
public var sourceName = ""
|
||||
public var sourceIcon = ""
|
||||
public var sourceGroup: String?
|
||||
public var sourceComment: String?
|
||||
public var enabled = true
|
||||
public var variableComment: String?
|
||||
public var jsLib: String?
|
||||
public var enabledCookieJar: Bool? = true
|
||||
public var concurrentRate: String?
|
||||
public var header: String?
|
||||
public var loginUrl: String?
|
||||
public var loginUi: String?
|
||||
public var loginCheckJs: String?
|
||||
public var coverDecodeJs: String?
|
||||
public var sortUrl: String?
|
||||
public var singleUrl = false
|
||||
public var articleStyle = 0
|
||||
public var ruleArticles: String?
|
||||
public var ruleNextPage: String?
|
||||
public var ruleTitle: String?
|
||||
public var rulePubDate: String?
|
||||
public var ruleDescription: String?
|
||||
public var ruleImage: String?
|
||||
public var ruleLink: String?
|
||||
public var ruleContent: String?
|
||||
public var contentWhitelist: String?
|
||||
public var contentBlacklist: String?
|
||||
public var shouldOverrideUrlLoading: String?
|
||||
public var style: String?
|
||||
public var enableJs = true
|
||||
public var loadWithBaseUrl = true
|
||||
public var injectJs: String?
|
||||
public var preloadJs: String?
|
||||
public var startHtml: String?
|
||||
public var startStyle: String?
|
||||
public var startJs: String?
|
||||
public var showWebLog = false
|
||||
public var lastUpdateTime: Int64 = 0
|
||||
public var customOrder = 0
|
||||
public var type = 0
|
||||
public var preload = false
|
||||
public var cacheFirst = false
|
||||
public var searchUrl: String?
|
||||
public var id: String { sourceUrl }
|
||||
|
||||
public init(sourceUrl: String = "", sourceName: String = "") {
|
||||
self.sourceUrl = sourceUrl
|
||||
self.sourceName = sourceName
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: RSSCodingKey.self)
|
||||
sourceUrl = values.string("sourceUrl") ?? ""
|
||||
sourceName = values.string("sourceName") ?? ""
|
||||
sourceIcon = values.string("sourceIcon") ?? ""
|
||||
sourceGroup = values.string("sourceGroup")
|
||||
sourceComment = values.string("sourceComment")
|
||||
enabled = values.bool("enabled") ?? true
|
||||
variableComment = values.string("variableComment")
|
||||
jsLib = values.string("jsLib")
|
||||
enabledCookieJar = values.contains("enabledCookieJar") ? values.bool("enabledCookieJar") : true
|
||||
concurrentRate = values.string("concurrentRate")
|
||||
header = values.string("header")
|
||||
loginUrl = values.string("loginUrl")
|
||||
loginUi = values.jsonString("loginUi")
|
||||
loginCheckJs = values.string("loginCheckJs")
|
||||
coverDecodeJs = values.string("coverDecodeJs")
|
||||
sortUrl = values.string("sortUrl")
|
||||
singleUrl = values.bool("singleUrl") ?? false
|
||||
articleStyle = values.int("articleStyle") ?? 0
|
||||
ruleArticles = values.string("ruleArticles")
|
||||
ruleNextPage = values.string("ruleNextPage")
|
||||
ruleTitle = values.string("ruleTitle")
|
||||
rulePubDate = values.string("rulePubDate")
|
||||
ruleDescription = values.string("ruleDescription")
|
||||
ruleImage = values.string("ruleImage")
|
||||
ruleLink = values.string("ruleLink")
|
||||
ruleContent = values.string("ruleContent")
|
||||
contentWhitelist = values.string("contentWhitelist")
|
||||
contentBlacklist = values.string("contentBlacklist")
|
||||
shouldOverrideUrlLoading = values.string("shouldOverrideUrlLoading")
|
||||
style = values.string("style")
|
||||
enableJs = values.bool("enableJs") ?? true
|
||||
loadWithBaseUrl = values.bool("loadWithBaseUrl") ?? true
|
||||
injectJs = values.string("injectJs")
|
||||
preloadJs = values.string("preloadJs")
|
||||
startHtml = values.string("startHtml")
|
||||
startStyle = values.string("startStyle")
|
||||
startJs = values.string("startJs")
|
||||
showWebLog = values.bool("showWebLog") ?? false
|
||||
lastUpdateTime = values.int64("lastUpdateTime") ?? 0
|
||||
customOrder = values.int("customOrder") ?? 0
|
||||
type = values.int("type") ?? 0
|
||||
preload = values.bool("preload") ?? false
|
||||
cacheFirst = values.bool("cacheFirst") ?? false
|
||||
searchUrl = values.string("searchUrl")
|
||||
}
|
||||
|
||||
public var groups: [String] { BookSource.parseGroups(sourceGroup) }
|
||||
public var displayNameGroup: String {
|
||||
groups.isEmpty ? sourceName : "\(sourceName) (\(sourceGroup ?? ""))"
|
||||
}
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool { lhs.sourceUrl == rhs.sourceUrl }
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(sourceUrl) }
|
||||
|
||||
public mutating func addGroups(_ value: String) {
|
||||
var merged = Set(groups)
|
||||
merged.formUnion(BookSource.parseGroups(value))
|
||||
sourceGroup = merged.joined(separator: ",")
|
||||
}
|
||||
public mutating func removeGroups(_ value: String) {
|
||||
let removed = Set(BookSource.parseGroups(value))
|
||||
sourceGroup = groups.filter { !removed.contains($0) }.joined(separator: ",")
|
||||
}
|
||||
public func displayVariableComment(_ other: String) -> String {
|
||||
guard let variableComment, !variableComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return other }
|
||||
return "\(variableComment)\n\(other)"
|
||||
}
|
||||
}
|
||||
|
||||
public struct RssArticle: Codable, Hashable, Identifiable, Sendable {
|
||||
public var origin = ""
|
||||
public var sort = ""
|
||||
public var title = ""
|
||||
public var order: Int64 = 0
|
||||
public var link = ""
|
||||
public var pubDate: String?
|
||||
public var description: String?
|
||||
public var content: String?
|
||||
public var image: String?
|
||||
public var group = "默认分组"
|
||||
public var read = false
|
||||
public var variable: String?
|
||||
public var type = 0
|
||||
public var durPos = 0
|
||||
public var id: String { "\(origin)\u{0}\(link)\u{0}\(sort)" }
|
||||
|
||||
public init(origin: String = "", sort: String = "", title: String = "", link: String = "") {
|
||||
self.origin = origin; self.sort = sort; self.title = title; self.link = link
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: RSSCodingKey.self)
|
||||
origin = values.string("origin") ?? ""; sort = values.string("sort") ?? ""
|
||||
title = values.string("title") ?? ""; order = values.int64("order") ?? 0
|
||||
link = values.string("link") ?? ""; pubDate = values.string("pubDate")
|
||||
description = values.string("description"); content = values.string("content")
|
||||
image = values.string("image"); group = values.string("group") ?? "默认分组"
|
||||
read = values.bool("read") ?? false; variable = values.string("variable")
|
||||
type = values.int("type") ?? 0; durPos = values.int("durPos") ?? 0
|
||||
}
|
||||
|
||||
public func star(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssStar {
|
||||
.init(article: self, starTime: time)
|
||||
}
|
||||
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
|
||||
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
|
||||
}
|
||||
public var variableMap: [String: String] { Self.decodeVariables(variable) }
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.origin == rhs.origin && lhs.link == rhs.link && lhs.sort == rhs.sort
|
||||
}
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(link) }
|
||||
|
||||
fileprivate static func decodeVariables(_ value: String?) -> [String: String] {
|
||||
guard let value, let data = value.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return object.reduce(into: [:]) { result, pair in
|
||||
if let string = pair.value as? String { result[pair.key] = string }
|
||||
else if !(pair.value is NSNull) { result[pair.key] = String(describing: pair.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RssReadRecord: Codable, Hashable, Identifiable, Sendable {
|
||||
public var record: String
|
||||
public var title: String?
|
||||
public var readTime: Int64?
|
||||
public var read: Bool
|
||||
public var origin: String
|
||||
public var sort: String
|
||||
public var image: String?
|
||||
public var type: Int
|
||||
public var durPos: Int
|
||||
public var pubDate: String?
|
||||
public var id: String { record }
|
||||
public init(record: String, title: String? = nil, readTime: Int64? = nil, read: Bool = true, origin: String = "", sort: String = "", image: String? = nil, type: Int = 0, durPos: Int = 0, pubDate: String? = nil) {
|
||||
self.record = record; self.title = title; self.readTime = readTime; self.read = read
|
||||
self.origin = origin; self.sort = sort; self.image = image; self.type = type
|
||||
self.durPos = durPos; self.pubDate = pubDate
|
||||
}
|
||||
public var article: RssArticle {
|
||||
var value = RssArticle(origin: origin, sort: sort, title: title ?? "", link: record)
|
||||
value.image = image; value.type = type; value.durPos = durPos; value.pubDate = pubDate
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
public struct RssStar: Codable, Hashable, Identifiable, Sendable {
|
||||
public var origin = ""
|
||||
public var sort = ""
|
||||
public var title = ""
|
||||
public var starTime: Int64 = 0
|
||||
public var link = ""
|
||||
public var pubDate: String?
|
||||
public var description: String?
|
||||
public var content: String?
|
||||
public var image: String?
|
||||
public var group = "默认分组"
|
||||
public var variable: String?
|
||||
public var type = 0
|
||||
public var durPos = 0
|
||||
public var id: String { "\(origin)\u{0}\(link)" }
|
||||
public init(article: RssArticle = .init(), starTime: Int64 = 0) {
|
||||
origin = article.origin; sort = article.sort; title = article.title; self.starTime = starTime
|
||||
link = article.link; pubDate = article.pubDate; description = article.description
|
||||
content = article.content; image = article.image; group = article.group
|
||||
variable = article.variable; type = article.type; durPos = article.durPos
|
||||
}
|
||||
public init(from decoder: Decoder) throws {
|
||||
let article = try RssArticle(from: decoder)
|
||||
self.init(article: article, starTime: try decoder.container(keyedBy: RSSCodingKey.self).int64("starTime") ?? 0)
|
||||
}
|
||||
public var article: RssArticle {
|
||||
var value = RssArticle(origin: origin, sort: sort, title: title, link: link)
|
||||
value.pubDate = pubDate; value.description = description; value.content = content
|
||||
value.image = image; value.group = group; value.variable = variable; value.type = type; value.durPos = durPos
|
||||
return value
|
||||
}
|
||||
public var variableMap: [String: String] { RssArticle.decodeVariables(variable) }
|
||||
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
|
||||
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
init?(stringValue: String) { self.stringValue = stringValue }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer where Key == RSSCodingKey {
|
||||
func key(_ value: String) -> Key { Key(stringValue: value)! }
|
||||
func contains(_ value: String) -> Bool { contains(key(value)) }
|
||||
func string(_ name: String) -> String? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(String.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
|
||||
return nil
|
||||
}
|
||||
func bool(_ name: String) -> Bool? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||
if let value = string(name)?.lowercased() { return ["true", "1", "yes"].contains(value) ? true : (["false", "0", "no"].contains(value) ? false : nil) }
|
||||
return nil
|
||||
}
|
||||
func int(_ name: String) -> Int? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||
return string(name).flatMap(Int.init)
|
||||
}
|
||||
func int64(_ name: String) -> Int64? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(Int64.self, forKey: key) { return value }
|
||||
return string(name).flatMap(Int64.init)
|
||||
}
|
||||
func jsonString(_ name: String) -> String? {
|
||||
if let value = string(name) { return value }
|
||||
let key = key(name)
|
||||
guard let value = try? decode(JSONValue.self, forKey: key) else { return nil }
|
||||
return value.stringValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
|
||||
public struct SearchBook: Codable, Hashable, Identifiable, Sendable {
|
||||
public var bookURL: String
|
||||
public var origin: String
|
||||
public var originName: String
|
||||
public var type: Int
|
||||
public var name: String
|
||||
public var author: String
|
||||
public var kind: String?
|
||||
public var coverURL: String?
|
||||
public var intro: String?
|
||||
public var wordCount: String?
|
||||
public var latestChapterTitle: String?
|
||||
public var tocURL: String
|
||||
public var time: Int64
|
||||
public var originOrder: Int
|
||||
public var chapterWordCountText: String?
|
||||
public var chapterWordCount: Int
|
||||
public var respondTime: Int
|
||||
public var origins: Set<String>
|
||||
public var variables: [String: String]
|
||||
|
||||
public var id: String { bookURL }
|
||||
public var variable: String? {
|
||||
get {
|
||||
guard !variables.isEmpty, let data = try? JSONEncoder().encode(variables) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
set {
|
||||
guard let newValue, let data = newValue.data(using: .utf8) else { variables = [:]; return }
|
||||
variables = (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
bookURL: String,
|
||||
origin: String,
|
||||
originName: String,
|
||||
type: Int,
|
||||
name: String,
|
||||
author: String = ""
|
||||
) {
|
||||
self.bookURL = bookURL
|
||||
self.origin = origin
|
||||
self.originName = originName
|
||||
self.type = type
|
||||
self.name = name
|
||||
self.author = author
|
||||
tocURL = ""
|
||||
time = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
originOrder = 0
|
||||
chapterWordCountText = nil
|
||||
chapterWordCount = -1
|
||||
respondTime = -1
|
||||
origins = [origin]
|
||||
variables = [:]
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case bookUrl, origin, originName, type, name, author, kind, coverUrl, intro, wordCount
|
||||
case latestChapterTitle, tocUrl, time, variable, originOrder, chapterWordCountText
|
||||
case chapterWordCount, respondTime
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bookURL = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? ""
|
||||
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
|
||||
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
kind = try value.decodeIfPresent(String.self, forKey: .kind)
|
||||
coverURL = try value.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||
intro = try value.decodeIfPresent(String.self, forKey: .intro)
|
||||
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
|
||||
tocURL = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
|
||||
time = try value.decodeIfPresent(Int64.self, forKey: .time) ?? 0
|
||||
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
|
||||
chapterWordCountText = try value.decodeIfPresent(String.self, forKey: .chapterWordCountText)
|
||||
chapterWordCount = try value.decodeIfPresent(Int.self, forKey: .chapterWordCount) ?? -1
|
||||
respondTime = try value.decodeIfPresent(Int.self, forKey: .respondTime) ?? -1
|
||||
origins = [origin]
|
||||
let rawVariable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||
variables = rawVariable.flatMap { raw in
|
||||
try? JSONDecoder().decode([String: String].self, from: Data(raw.utf8))
|
||||
} ?? [:]
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var value = encoder.container(keyedBy: CodingKeys.self)
|
||||
try value.encode(bookURL, forKey: .bookUrl)
|
||||
try value.encode(origin, forKey: .origin)
|
||||
try value.encode(originName, forKey: .originName)
|
||||
try value.encode(type, forKey: .type)
|
||||
try value.encode(name, forKey: .name)
|
||||
try value.encode(author, forKey: .author)
|
||||
try value.encodeIfPresent(kind, forKey: .kind)
|
||||
try value.encodeIfPresent(coverURL, forKey: .coverUrl)
|
||||
try value.encodeIfPresent(intro, forKey: .intro)
|
||||
try value.encodeIfPresent(wordCount, forKey: .wordCount)
|
||||
try value.encodeIfPresent(latestChapterTitle, forKey: .latestChapterTitle)
|
||||
try value.encode(tocURL, forKey: .tocUrl)
|
||||
try value.encode(time, forKey: .time)
|
||||
try value.encodeIfPresent(variable, forKey: .variable)
|
||||
try value.encode(originOrder, forKey: .originOrder)
|
||||
try value.encodeIfPresent(chapterWordCountText, forKey: .chapterWordCountText)
|
||||
try value.encode(chapterWordCount, forKey: .chapterWordCount)
|
||||
try value.encode(respondTime, forKey: .respondTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookExploreService: Sendable {
|
||||
private let transport: any HTTPTransport
|
||||
private let urlBuilder: LegadoURLBuilder
|
||||
private let engine: RuleEngine
|
||||
private let networkPolicy: SourceNetworkPolicy
|
||||
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
|
||||
|
||||
public init(
|
||||
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||
urlBuilder: LegadoURLBuilder = .init(),
|
||||
engine: RuleEngine = .init(),
|
||||
networkPolicy: SourceNetworkPolicy = .shared,
|
||||
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil
|
||||
) {
|
||||
self.transport = transport
|
||||
self.urlBuilder = urlBuilder
|
||||
self.engine = engine
|
||||
self.networkPolicy = networkPolicy
|
||||
self.webViewExecutor = webViewExecutor
|
||||
}
|
||||
|
||||
public func kinds(for source: BookSource) async -> ExploreKindsReport {
|
||||
guard let rule = source.exploreUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rule.isEmpty else { return .init() }
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
let rendered: String
|
||||
if rule.lowercased().hasPrefix("@js:") || rule.lowercased().hasPrefix("<js>") {
|
||||
let result = await engine.evaluate(
|
||||
rule,
|
||||
in: .null,
|
||||
context: .init(source: source)
|
||||
)
|
||||
diagnostics += result.diagnostics
|
||||
rendered = result.firstString ?? ""
|
||||
} else {
|
||||
rendered = rule
|
||||
}
|
||||
do {
|
||||
return .init(kinds: try parseKinds(rendered), diagnostics: diagnostics)
|
||||
} catch let error as RuleEngineError {
|
||||
diagnostics.append(error.diagnostic)
|
||||
return .init(diagnostics: diagnostics)
|
||||
} catch {
|
||||
diagnostics.append(.init(
|
||||
severity: .error,
|
||||
code: "explore.kinds",
|
||||
message: error.localizedDescription,
|
||||
rule: rule
|
||||
))
|
||||
return .init(diagnostics: diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
public func explore(
|
||||
source: BookSource,
|
||||
kind: ExploreKind,
|
||||
page: Int = 1
|
||||
) async -> SourceSearchOutcome {
|
||||
guard let template = kind.navigableURL else {
|
||||
return failure(source, code: "explore.url", message: "发现分类缺少 URL", rule: kind.title)
|
||||
}
|
||||
let rules = resolvedRules(source)
|
||||
guard let rules, let listRule = rules.bookList, !listRule.isEmpty else {
|
||||
return failure(source, code: "explore.configuration", message: "书源缺少发现列表规则", rule: template)
|
||||
}
|
||||
do {
|
||||
let prepared = try urlBuilder.prepare(
|
||||
template,
|
||||
context: .init(page: page, source: source),
|
||||
baseURL: URL(string: source.bookSourceUrl),
|
||||
defaultHeaders: parseHeaders(source.header)
|
||||
)
|
||||
let timeout = min(max(Double(source.respondTime) / 1_000, 5), 180)
|
||||
let response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: timeout,
|
||||
webViewExecutor: webViewExecutor
|
||||
)
|
||||
let body = response.decodedText
|
||||
let root = response.decodedJSON
|
||||
var context = RuleExecutionContext(
|
||||
variables: response.requestVariables,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
source: source
|
||||
)
|
||||
let list = if let root {
|
||||
await engine.evaluate(listRule.trimmingListPrefix, in: root, listMode: true, context: context)
|
||||
} else {
|
||||
await engine.evaluate(
|
||||
listRule.trimmingListPrefix,
|
||||
inText: body,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
listMode: true,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
context.variables = list.variables
|
||||
var diagnostics = list.diagnostics
|
||||
var books: [SearchBook] = []
|
||||
for item in list.listItems(rule: listRule.trimmingListPrefix) {
|
||||
try Task.checkCancellation()
|
||||
let fields = await evaluateFields(
|
||||
rules,
|
||||
item: item,
|
||||
isHTML: root == nil,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
source: source,
|
||||
variables: context.variables
|
||||
)
|
||||
diagnostics += fields.diagnostics
|
||||
guard let name = fields.name, !name.isEmpty else { continue }
|
||||
let bookURL = resolve(fields.bookURL ?? response.finalURL.absoluteString, against: response.finalURL)
|
||||
var book = SearchBook(
|
||||
bookURL: bookURL,
|
||||
origin: source.bookSourceUrl,
|
||||
originName: source.bookSourceName,
|
||||
type: source.bookSourceType,
|
||||
name: name,
|
||||
author: fields.author ?? ""
|
||||
)
|
||||
book.kind = fields.kind
|
||||
book.coverURL = fields.coverURL.map { resolve($0, against: response.finalURL) }
|
||||
book.intro = MetadataTextSanitizer().clean(fields.intro)
|
||||
book.wordCount = fields.wordCount
|
||||
book.latestChapterTitle = fields.lastChapter
|
||||
book.originOrder = source.customOrder
|
||||
book.variables = fields.variables
|
||||
books.append(book)
|
||||
}
|
||||
if listRule.hasPrefix("-") { books.reverse() }
|
||||
var unique = Set<String>()
|
||||
books = books.filter { unique.insert($0.bookURL).inserted }
|
||||
return .init(sourceID: source.id, books: books, diagnostics: diagnostics)
|
||||
} catch is CancellationError {
|
||||
return failure(source, code: "explore.cancelled", message: "发现加载已取消", rule: template)
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic])
|
||||
} catch {
|
||||
return failure(source, code: "explore.request", message: error.localizedDescription, rule: template)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseKinds(_ value: String) throws -> [ExploreKind] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
if trimmed.hasPrefix("[") {
|
||||
do {
|
||||
return try JSONDecoder().decode([ExploreKind].self, from: Data(trimmed.utf8))
|
||||
} catch let strictError {
|
||||
do {
|
||||
let object = try JSONSerialization.jsonObject(
|
||||
with: Data(trimmed.utf8),
|
||||
options: [.fragmentsAllowed, .json5Allowed]
|
||||
)
|
||||
let normalized = try JSONEncoder().encode(JSONValue.bridge(object))
|
||||
return try JSONDecoder().decode([ExploreKind].self, from: normalized)
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(
|
||||
rule: value,
|
||||
detail: "发现分类 JSON 无效:\(strictError.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
.replacingOccurrences(of: "&&", with: "\n")
|
||||
.split(whereSeparator: { $0.isNewline })
|
||||
.compactMap { line in
|
||||
let line = String(line)
|
||||
let range = line.range(of: "::")
|
||||
let title = String(range.map { line[..<$0.lowerBound] } ?? line[...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else { return nil }
|
||||
let url = range.map { String(line[$0.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
return ExploreKind(title: title, url: url?.isEmpty == true ? nil : url)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedRules(_ source: BookSource) -> BookListRule? {
|
||||
if let rules = source.ruleExplore, rules.bookList?.isEmpty == false { return rules }
|
||||
guard let search = source.ruleSearch else { return source.ruleExplore }
|
||||
return .init(
|
||||
bookList: search.bookList,
|
||||
name: search.name,
|
||||
author: search.author,
|
||||
intro: search.intro,
|
||||
kind: search.kind,
|
||||
lastChapter: search.lastChapter,
|
||||
updateTime: search.updateTime,
|
||||
bookUrl: search.bookUrl,
|
||||
coverUrl: search.coverUrl,
|
||||
wordCount: search.wordCount
|
||||
)
|
||||
}
|
||||
|
||||
private func evaluateFields(
|
||||
_ rules: BookListRule,
|
||||
item: JSONValue,
|
||||
isHTML: Bool,
|
||||
baseURL: String,
|
||||
source: BookSource,
|
||||
variables: [String: String]
|
||||
) async -> Fields {
|
||||
var context = RuleExecutionContext(variables: variables, baseURL: baseURL, source: source, isHTMLFragment: true)
|
||||
var result = Fields()
|
||||
let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = name.variables
|
||||
let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = author.variables
|
||||
let kind = await evaluate(rules.kind, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = kind.variables
|
||||
let bookURL = await evaluate(rules.bookUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = bookURL.variables
|
||||
let cover = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = cover.variables
|
||||
let intro = await evaluate(rules.intro, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = intro.variables
|
||||
let wordCount = await evaluate(rules.wordCount, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = wordCount.variables
|
||||
let lastChapter = await evaluate(rules.lastChapter, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = lastChapter.variables
|
||||
result.name = name.joinedString
|
||||
result.author = author.joinedString
|
||||
result.kind = kind.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty
|
||||
result.bookURL = isHTML ? bookURL.leadingString : bookURL.joinedString
|
||||
result.coverURL = cover.joinedString
|
||||
result.intro = MetadataTextSanitizer().clean(intro.joinedString)
|
||||
result.wordCount = wordCount.joinedString
|
||||
result.lastChapter = lastChapter.joinedString
|
||||
result.variables = context.variables
|
||||
result.diagnostics = [name, author, kind, bookURL, cover, intro, wordCount, lastChapter].flatMap(\.diagnostics)
|
||||
return result
|
||||
}
|
||||
|
||||
private func evaluate(
|
||||
_ rule: String?,
|
||||
item: JSONValue,
|
||||
isHTML: Bool,
|
||||
baseURL: String,
|
||||
context: RuleExecutionContext
|
||||
) async -> RuleEvaluation {
|
||||
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
|
||||
if case .string = item {
|
||||
return await engine.evaluate(rule, inText: item.stringValue, baseURL: baseURL, context: context)
|
||||
}
|
||||
return await engine.evaluate(rule, in: item, context: context)
|
||||
}
|
||||
|
||||
private func resolve(_ value: String, against base: URL) -> String {
|
||||
LegadoURLBuilder.resolve(value, against: base)
|
||||
}
|
||||
|
||||
private func parseHeaders(_ source: String?) -> [String: String] {
|
||||
guard let source, let value = try? JSONValue.decode(data: Data(source.utf8)),
|
||||
case .object(let object) = value else { return [:] }
|
||||
return object.mapValues(\.stringValue)
|
||||
}
|
||||
|
||||
private func failure(_ source: BookSource, code: String, message: String, rule: String) -> SourceSearchOutcome {
|
||||
.init(sourceID: source.id, books: [], diagnostics: [
|
||||
.init(severity: .error, code: code, message: message, rule: rule)
|
||||
])
|
||||
}
|
||||
|
||||
private struct Fields {
|
||||
var name: String?
|
||||
var author: String?
|
||||
var kind: String?
|
||||
var bookURL: String?
|
||||
var coverURL: String?
|
||||
var intro: String?
|
||||
var wordCount: String?
|
||||
var lastChapter: String?
|
||||
var variables: [String: String] = [:]
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var trimmingListPrefix: String {
|
||||
guard hasPrefix("-") || hasPrefix("+") else { return self }
|
||||
return String(dropFirst())
|
||||
}
|
||||
|
||||
fileprivate var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import Compression
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
public enum DuplicatePolicy: Sendable {
|
||||
case replace
|
||||
case skip
|
||||
case fail
|
||||
}
|
||||
|
||||
public enum BookSourceImportError: Error, Equatable, LocalizedError {
|
||||
case emptyDocument
|
||||
case invalidDocument(String)
|
||||
case invalidSources([BookSourceIssue])
|
||||
case duplicate(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyDocument:
|
||||
return "导入内容为空"
|
||||
case .invalidDocument(let detail):
|
||||
return "无法解析书源 JSON:\(detail)"
|
||||
case .invalidSources(let issues):
|
||||
return issues.map(\.message).joined(separator: ";")
|
||||
case .duplicate(let url):
|
||||
return "书源已存在:\(url)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceImportResult: Sendable {
|
||||
public var sources: [BookSource]
|
||||
public var inserted: Int
|
||||
public var replaced: Int
|
||||
public var skipped: Int
|
||||
public var warnings: [BookSourceIssue]
|
||||
|
||||
public init(
|
||||
sources: [BookSource],
|
||||
inserted: Int,
|
||||
replaced: Int,
|
||||
skipped: Int,
|
||||
warnings: [BookSourceIssue]
|
||||
) {
|
||||
self.sources = sources
|
||||
self.inserted = inserted
|
||||
self.replaced = replaced
|
||||
self.skipped = skipped
|
||||
self.warnings = warnings
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceImporter: Sendable {
|
||||
public static let maximumDocumentBytes = 50 * 1_024 * 1_024
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init() {
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public struct RejectedSource: Sendable {
|
||||
public var source: BookSource
|
||||
public var issues: [BookSourceIssue]
|
||||
}
|
||||
|
||||
public struct Inspection: Sendable {
|
||||
public var sources: [BookSource]
|
||||
public var rejected: [RejectedSource]
|
||||
}
|
||||
|
||||
public func inspect(_ data: Data) throws -> Inspection {
|
||||
let cleaned = try normalizedData(data)
|
||||
let root: JSONValue
|
||||
do { root = try JSONValue.decode(data: cleaned) }
|
||||
catch { throw BookSourceImportError.invalidDocument(error.localizedDescription) }
|
||||
let entries: [JSONValue]
|
||||
switch root {
|
||||
case .array(let array): entries = array
|
||||
case .object: entries = [root]
|
||||
default: throw BookSourceImportError.invalidDocument("需要书源对象或数组")
|
||||
}
|
||||
guard !entries.isEmpty else { throw BookSourceImportError.emptyDocument }
|
||||
var sources: [BookSource] = []
|
||||
var rejected: [RejectedSource] = []
|
||||
for (index, entry) in entries.enumerated() {
|
||||
do {
|
||||
guard case .object = entry else { throw BookSourceImportError.invalidDocument("第 \(index + 1) 项不是对象") }
|
||||
let source = try decoder.decode(BookSource.self, from: JSONEncoder().encode(entry))
|
||||
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||
if errors.isEmpty { sources.append(source) }
|
||||
else { rejected.append(.init(source: source, issues: errors)) }
|
||||
} catch {
|
||||
let placeholder = BookSource(bookSourceUrl: entry["bookSourceUrl"]?.stringValue ?? "",
|
||||
bookSourceName: entry["bookSourceName"]?.stringValue ?? "第 \(index + 1) 项")
|
||||
rejected.append(.init(source: placeholder, issues: [.init(severity: .error, code: "source.decode",
|
||||
field: "[\(index)]", message: "第 \(index + 1) 项:\(error.localizedDescription)")]))
|
||||
}
|
||||
}
|
||||
return .init(sources: sources, rejected: rejected)
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> [BookSource] {
|
||||
let result = try inspect(data)
|
||||
guard result.rejected.isEmpty else { throw BookSourceImportError.invalidSources(result.rejected.flatMap(\.issues)) }
|
||||
return result.sources
|
||||
}
|
||||
|
||||
public func normalizedData(_ data: Data) throws -> Data {
|
||||
guard data.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("书源文件超过 50 MB") }
|
||||
let cleaned = decodeText(stripUTF8BOM(decompressIfGzip(data)))
|
||||
guard cleaned.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("解压后书源超过 50 MB") }
|
||||
guard !String(decoding: cleaned, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw BookSourceImportError.emptyDocument
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
public func prepare(
|
||||
data: Data,
|
||||
existing: [BookSource],
|
||||
duplicatePolicy: DuplicatePolicy = .replace
|
||||
) throws -> BookSourceImportResult {
|
||||
let incoming = try decode(data)
|
||||
var fatalIssues: [BookSourceIssue] = []
|
||||
var warnings: [BookSourceIssue] = []
|
||||
for source in incoming {
|
||||
for issue in BookSourceValidator.validate(source) {
|
||||
if issue.severity == .error {
|
||||
fatalIssues.append(issue)
|
||||
} else {
|
||||
warnings.append(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard fatalIssues.isEmpty else {
|
||||
throw BookSourceImportError.invalidSources(fatalIssues)
|
||||
}
|
||||
|
||||
var merged = existing
|
||||
var index = Dictionary(uniqueKeysWithValues: merged.enumerated().map { ($1.id, $0) })
|
||||
var inserted = 0
|
||||
var replaced = 0
|
||||
var skipped = 0
|
||||
|
||||
for source in incoming {
|
||||
if let existingIndex = index[source.id] {
|
||||
switch duplicatePolicy {
|
||||
case .replace:
|
||||
merged[existingIndex] = source
|
||||
replaced += 1
|
||||
case .skip:
|
||||
skipped += 1
|
||||
case .fail:
|
||||
throw BookSourceImportError.duplicate(source.id)
|
||||
}
|
||||
} else {
|
||||
index[source.id] = merged.count
|
||||
merged.append(source)
|
||||
inserted += 1
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
sources: merged,
|
||||
inserted: inserted,
|
||||
replaced: replaced,
|
||||
skipped: skipped,
|
||||
warnings: warnings
|
||||
)
|
||||
}
|
||||
|
||||
private func stripUTF8BOM(_ data: Data) -> Data {
|
||||
let bom = Data([0xEF, 0xBB, 0xBF])
|
||||
return data.starts(with: bom) ? Data(data.dropFirst(3)) : data
|
||||
}
|
||||
|
||||
private func decodeText(_ data: Data) -> Data {
|
||||
if let value = String(data: data, encoding: .utf8) { return Data(value.utf8) }
|
||||
if data.starts(with: [0xFF, 0xFE]), let value = String(data: data, encoding: .utf16LittleEndian) {
|
||||
return Data(value.utf8)
|
||||
}
|
||||
if data.starts(with: [0xFE, 0xFF]), let value = String(data: data, encoding: .utf16BigEndian) {
|
||||
return Data(value.utf8)
|
||||
}
|
||||
if let value = String(data: data, encoding: Self.gb18030Encoding) {
|
||||
return Data(value.utf8)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static var gb18030Encoding: String.Encoding {
|
||||
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
|
||||
}
|
||||
|
||||
private func decompressIfGzip(_ data: Data) -> Data {
|
||||
guard data.count > 10, data[0] == 0x1F, data[1] == 0x8B, data[2] == 0x08 else { return data }
|
||||
var offset = 10
|
||||
let flags = Int(data[3])
|
||||
if flags & 0x04 != 0 {
|
||||
guard offset + 2 <= data.count - 8 else { return data }
|
||||
let extraLength = Int(data[offset]) | (Int(data[offset + 1]) << 8)
|
||||
offset += 2 + extraLength
|
||||
}
|
||||
if flags & 0x08 != 0 {
|
||||
while offset < data.count, data[offset] != 0 { offset += 1 }
|
||||
offset += 1
|
||||
}
|
||||
if flags & 0x10 != 0 {
|
||||
while offset < data.count, data[offset] != 0 { offset += 1 }
|
||||
offset += 1
|
||||
}
|
||||
if flags & 0x02 != 0 { offset += 2 }
|
||||
guard offset < data.count - 8 else { return data }
|
||||
let payload = Data(data[offset..<data.count - min(8, data.count - offset)])
|
||||
return inflate(payload) ?? data
|
||||
}
|
||||
|
||||
private func inflate(_ payload: Data) -> Data? {
|
||||
var capacity = max(payload.count * 4, 64 * 1_024)
|
||||
while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 {
|
||||
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
|
||||
let produced = payload.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
|
||||
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
||||
return compression_decode_buffer(
|
||||
destination, capacity,
|
||||
base, payload.count,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
}
|
||||
if produced > 0, produced < capacity {
|
||||
let result = Data(bytes: destination, count: produced)
|
||||
destination.deallocate()
|
||||
return result
|
||||
}
|
||||
destination.deallocate()
|
||||
guard produced == capacity, capacity <= Int.max / 2 else { return nil }
|
||||
capacity = min(capacity * 2, Self.maximumDocumentBytes + 1)
|
||||
if capacity == produced { return nil }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSourceIssue: Codable, Hashable, Identifiable, Sendable {
|
||||
public enum Severity: String, Codable, Sendable {
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
public var severity: Severity
|
||||
public var code: String
|
||||
public var field: String
|
||||
public var message: String
|
||||
|
||||
public var id: String { "\(code):\(field):\(message)" }
|
||||
|
||||
public init(severity: Severity, code: String, field: String, message: String) {
|
||||
self.severity = severity
|
||||
self.code = code
|
||||
self.field = field
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
public enum BookSourceValidator {
|
||||
public static func validate(_ source: BookSource) -> [BookSourceIssue] {
|
||||
var issues: [BookSourceIssue] = []
|
||||
if source.bookSourceUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
issues.append(.init(
|
||||
severity: .error,
|
||||
code: "source_url.empty",
|
||||
field: "bookSourceUrl",
|
||||
message: "书源地址不能为空"
|
||||
))
|
||||
}
|
||||
if source.bookSourceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
issues.append(.init(
|
||||
severity: .error,
|
||||
code: "source_name.empty",
|
||||
field: "bookSourceName",
|
||||
message: "书源名称不能为空"
|
||||
))
|
||||
}
|
||||
if !source.bookSourceUrl.isEmpty,
|
||||
let components = URLComponents(string: source.bookSourceUrl),
|
||||
components.scheme == nil {
|
||||
issues.append(.init(
|
||||
severity: .warning,
|
||||
code: "source_url.no_scheme",
|
||||
field: "bookSourceUrl",
|
||||
message: "书源地址没有 URL scheme,运行时可能无法解析相对地址"
|
||||
))
|
||||
}
|
||||
if source.searchUrl?.isEmpty == false, source.ruleSearch == nil {
|
||||
issues.append(.init(
|
||||
severity: .warning,
|
||||
code: "search.rule_missing",
|
||||
field: "ruleSearch",
|
||||
message: "配置了搜索地址但没有搜索解析规则"
|
||||
))
|
||||
}
|
||||
if source.exploreUrl?.isEmpty == false, source.ruleExplore == nil {
|
||||
issues.append(.init(
|
||||
severity: .warning,
|
||||
code: "explore.rule_missing",
|
||||
field: "ruleExplore",
|
||||
message: "配置了发现地址但没有发现解析规则"
|
||||
))
|
||||
}
|
||||
return issues
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import Vision
|
||||
|
||||
public struct QRCodeDecoder: Sendable {
|
||||
public init() {}
|
||||
public func detect(in data: Data) throws -> [String] {
|
||||
let request = VNDetectBarcodesRequest()
|
||||
request.symbologies = [.qr]
|
||||
try VNImageRequestHandler(data: data).perform([request])
|
||||
var seen = Set<String>()
|
||||
return (request.results ?? []).compactMap(\.payloadStringValue).filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSSourceImportPayload: Sendable {
|
||||
public var sources: [RssSource]
|
||||
public var subscriptionURLs: [String]
|
||||
public init(sources: [RssSource] = [], subscriptionURLs: [String] = []) {
|
||||
self.sources = sources
|
||||
self.subscriptionURLs = subscriptionURLs
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSSourceImportError: Error, LocalizedError, Sendable {
|
||||
case emptyDocument
|
||||
case wrongFormat
|
||||
case invalidSource
|
||||
case duplicate(String)
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyDocument: "导入内容为空"
|
||||
case .wrongFormat: "RSS源格式错误"
|
||||
case .invalidSource: "不是订阅源"
|
||||
case .duplicate(let url): "RSS源已存在:\(url)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSSourceImporter: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func decode(_ data: Data) throws -> RSSSourceImportPayload {
|
||||
var data = data
|
||||
if data.starts(with: [0xEF, 0xBB, 0xBF]) { data = Data(data.dropFirst(3)) }
|
||||
guard !data.isEmpty else { throw RSSSourceImportError.emptyDocument }
|
||||
let decoder = JSONDecoder()
|
||||
if let manifest = try? decoder.decode(SubscriptionManifest.self, from: data), !manifest.sourceUrls.isEmpty {
|
||||
return .init(subscriptionURLs: manifest.sourceUrls)
|
||||
}
|
||||
let sources: [RssSource]
|
||||
if let values = try? decoder.decode([RssSource].self, from: data) { sources = values }
|
||||
else if let value = try? decoder.decode(RssSource.self, from: data) { sources = [value] }
|
||||
else { throw RSSSourceImportError.wrongFormat }
|
||||
guard sources.isEmpty || sources.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
|
||||
return .init(sources: sources)
|
||||
}
|
||||
|
||||
public func merge(
|
||||
_ incoming: [RssSource],
|
||||
into existing: [RssSource],
|
||||
duplicatePolicy: DuplicatePolicy = .replace,
|
||||
addGroup: String? = nil
|
||||
) throws -> [RssSource] {
|
||||
var result = existing
|
||||
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($0.element.sourceUrl, $0.offset) })
|
||||
for original in incoming where !original.sourceUrl.isEmpty {
|
||||
var source = original
|
||||
if let addGroup, !addGroup.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source.addGroups(addGroup) }
|
||||
if let index = indexes[source.sourceUrl] {
|
||||
switch duplicatePolicy {
|
||||
case .replace: result[index] = source
|
||||
case .skip: continue
|
||||
case .fail: throw RSSSourceImportError.duplicate(source.sourceUrl)
|
||||
}
|
||||
} else {
|
||||
indexes[source.sourceUrl] = result.count
|
||||
result.append(source)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func defaultSelection(incoming: [RssSource], existing: [RssSource]) -> [Bool] {
|
||||
let current = Dictionary(uniqueKeysWithValues: existing.map { ($0.sourceUrl, $0.lastUpdateTime) })
|
||||
return incoming.map { source in
|
||||
guard let oldTime = current[source.sourceUrl] else { return true }
|
||||
return oldTime < source.lastUpdateTime
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(_ sources: [RssSource], prettyPrinted: Bool = true) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] }
|
||||
return try encoder.encode(sources)
|
||||
}
|
||||
|
||||
private struct SubscriptionManifest: Decodable {
|
||||
var sourceUrls: [String]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
public enum RuleImportInput: Sendable, Equatable {
|
||||
case json(String)
|
||||
case remote(URL, withoutUserAgent: Bool)
|
||||
|
||||
public static func parse(_ input: String, kind: String = "bookSource") throws -> Self {
|
||||
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("{"), value.hasSuffix("}") { return .json(value) }
|
||||
if value.hasPrefix("["), value.hasSuffix("]"), (try? JSONValue.decode(data: Data(value.utf8))) != nil {
|
||||
return .json(value)
|
||||
}
|
||||
if let start = value.range(of: "]("), value.hasSuffix(")") {
|
||||
value = String(value[start.upperBound..<value.index(before: value.endIndex)])
|
||||
}
|
||||
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "*`<> \n\r\t"))
|
||||
if let url = URL(string: value), url.scheme?.lowercased() == "legado" {
|
||||
guard url.host?.lowercased() == "import", url.path.lowercased() == "/" + kind.lowercased(),
|
||||
let src = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "src" })?.value,
|
||||
!src.lowercased().hasPrefix("legado:") else {
|
||||
throw BookSourceImportError.invalidDocument("二维码或深链类型不匹配,需要 \(kind)")
|
||||
}
|
||||
return try parse(src, kind: kind)
|
||||
}
|
||||
let marker = "#requestWithoutUA"
|
||||
let withoutUA = value.hasSuffix(marker)
|
||||
if withoutUA { value = String(value.dropLast(marker.count)) }
|
||||
guard let url = URL(string: value), let scheme = url.scheme?.lowercased(),
|
||||
["http", "https"].contains(scheme), url.host?.isEmpty == false else {
|
||||
throw BookSourceImportError.invalidDocument("请输入 JSON、HTTP(S) 地址或 Legado 导入二维码")
|
||||
}
|
||||
return .remote(url, withoutUserAgent: withoutUA)
|
||||
}
|
||||
|
||||
public func request() -> URLRequest? {
|
||||
guard case .remote(let url, let withoutUA) = self else { return nil }
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45)
|
||||
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
|
||||
if withoutUA { request.setValue("null", forHTTPHeaderField: "User-Agent") }
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
import CoreFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public enum LocalBookError: Error, LocalizedError, Sendable {
|
||||
case unsupportedFormat
|
||||
case oversized
|
||||
case unreadableEncoding
|
||||
case missingFile
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedFormat: "当前仅支持 TXT 本地书"
|
||||
case .oversized: "本地书超过当前配置允许的大小"
|
||||
case .unreadableEncoding: "无法识别 TXT 编码"
|
||||
case .missingFile: "本地书文件已丢失"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct LocalBookImportReport: Sendable {
|
||||
public var book: Book
|
||||
public var chapters: [BookChapter]
|
||||
|
||||
public init(book: Book, chapters: [BookChapter]) {
|
||||
self.book = book
|
||||
self.chapters = chapters
|
||||
}
|
||||
}
|
||||
|
||||
public enum TxtTocRuleSelector {
|
||||
public static let separator = "🫅🈳🏻"
|
||||
|
||||
public static func select(
|
||||
content: String,
|
||||
rules: [TxtTocRule],
|
||||
transformTitle: (TxtTocRule, String, Int, String?, Int) -> String = { _, value, _, _, _ in value }
|
||||
) -> TxtTocRule? {
|
||||
let source = content as NSString
|
||||
var maximum = -1
|
||||
var selected: TxtTocRule?
|
||||
for rule in rules.filter(\.enable).sorted(by: { $0.serialNumber < $1.serialNumber }) {
|
||||
guard !rule.rule.isEmpty else { continue }
|
||||
guard let expression = try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines) else { continue }
|
||||
var start = 0
|
||||
var validCount = 0
|
||||
var shortGapCount = 0
|
||||
var lastTitle: String?
|
||||
for match in expression.matches(in: content, range: NSRange(location: 0, length: source.length)) {
|
||||
let length = match.range.location - start
|
||||
if start == 0 || length > 1_000 {
|
||||
let rawTitle = source.substring(with: match.range)
|
||||
let title = transformTitle(rule, rawTitle, validCount, lastTitle, length)
|
||||
if !title.isEmpty {
|
||||
lastTitle = title
|
||||
validCount += 1
|
||||
}
|
||||
start = NSMaxRange(match.range)
|
||||
} else if length < 100 {
|
||||
shortGapCount += 1
|
||||
}
|
||||
}
|
||||
if validCount >= shortGapCount * 3, validCount > maximum + 2 {
|
||||
maximum = validCount
|
||||
selected = rule
|
||||
if maximum > 70 { break }
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
public enum TxtTocRuleDefaults {
|
||||
public static func load(bundle: Bundle = .main) -> [TxtTocRule] {
|
||||
guard let url = bundle.url(forResource: "txtTocRule", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) else { return [] }
|
||||
return values.sorted { $0.serialNumber < $1.serialNumber }
|
||||
}
|
||||
}
|
||||
|
||||
private final class TxtVolumeState {
|
||||
var lastTitle = ""
|
||||
}
|
||||
|
||||
private struct TxtHeadingMatch {
|
||||
var title: String
|
||||
var start: Int64
|
||||
var end: Int64
|
||||
var previousLength: Int
|
||||
}
|
||||
|
||||
public struct LocalBookService: Sendable {
|
||||
public var maximumFileSize: Int64
|
||||
public var tocRules: [TxtTocRule]
|
||||
private var loadsLiveTocRules = false
|
||||
|
||||
public init(maximumFileSize: Int64 = .max, tocRules: [TxtTocRule] = []) {
|
||||
self.maximumFileSize = maximumFileSize
|
||||
self.tocRules = tocRules.filter(\.enable).sorted { $0.serialNumber < $1.serialNumber }
|
||||
}
|
||||
|
||||
public static func live() -> LocalBookService {
|
||||
LocalBookService(tocRules: TxtTocRuleDefaults.load())
|
||||
}
|
||||
|
||||
public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
|
||||
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
|
||||
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
|
||||
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
|
||||
if Int64(values.fileSize ?? 0) > maximumFileSize { throw LocalBookError.oversized }
|
||||
let data = try Data(contentsOf: sourceURL, options: .mappedIfSafe)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destination = directory.appendingPathComponent("\(UUID().uuidString).txt")
|
||||
try data.write(to: destination, options: .atomic)
|
||||
do {
|
||||
return try textFileReport(data: data, sourceURL: sourceURL, destination: destination)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func openTextFile(at url: URL) throws -> LocalBookImportReport {
|
||||
guard url.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
|
||||
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
|
||||
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
|
||||
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
|
||||
return try textFileReport(data: Data(contentsOf: url, options: .mappedIfSafe), sourceURL: url, destination: url)
|
||||
}
|
||||
|
||||
private func textFileReport(data: Data, sourceURL: URL, destination: URL) throws -> LocalBookImportReport {
|
||||
let decoded = try decode(Data(data.prefix(512_000)))
|
||||
|
||||
let name = sourceURL.deletingPathExtension().lastPathComponent
|
||||
var book = Book(bookUrl: destination.absoluteString, name: name, origin: "local")
|
||||
book.originName = sourceURL.lastPathComponent
|
||||
book.charset = decoded.charset
|
||||
book.canUpdate = false
|
||||
let selectionVolumeState = TxtVolumeState()
|
||||
let probeText = textProbe(data, encoding: decoded.encoding)
|
||||
let selectedRule = TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
|
||||
rule, title, index, previous, length in
|
||||
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
|
||||
}
|
||||
if let selectedRule {
|
||||
book.tocUrl = selectedRule.rule + TxtTocRuleSelector.separator + selectedRule.replacement
|
||||
}
|
||||
let fullBOMLength = bomLength(in: data, encoding: decoded.encoding)
|
||||
let hasTOC = !headingMatches(
|
||||
data: data,
|
||||
encoding: decoded.encoding,
|
||||
bomLength: fullBOMLength,
|
||||
pattern: selectedRule?.rule ?? fallbackTocPattern,
|
||||
usesFirstCapture: selectedRule == nil
|
||||
).isEmpty
|
||||
var chapters = chapters(
|
||||
data: data,
|
||||
encoding: decoded.encoding,
|
||||
bomLength: fullBOMLength,
|
||||
book: book,
|
||||
fallbackTitle: name,
|
||||
selectedRule: selectedRule
|
||||
)
|
||||
let totalWordCount = applyWordCounts(&chapters, data: data, encoding: decoded.encoding)
|
||||
book.wordCount = wordCountFormat(totalWordCount)
|
||||
if hasTOC,
|
||||
let first = chapters.first(where: { !$0.isVolume && $0.start == Int64(fullBOMLength) }),
|
||||
first.start == Int64(fullBOMLength),
|
||||
let endValue = first.end,
|
||||
endValue > Int64(fullBOMLength),
|
||||
let preface = String(
|
||||
data: data.subdata(in: fullBOMLength..<min(Int(endValue), data.count)),
|
||||
encoding: decoded.encoding
|
||||
) {
|
||||
book.intro = preface.utf16.count > 600 ? String(preface.prefix(600)) : preface
|
||||
}
|
||||
book.totalChapterNum = chapters.count
|
||||
book.latestChapterTitle = chapters.last?.title
|
||||
return .init(book: book, chapters: chapters)
|
||||
}
|
||||
|
||||
public func loadChapters(book: Book) throws -> ChapterListReport {
|
||||
let decoded = try read(book)
|
||||
let selectionVolumeState = TxtVolumeState()
|
||||
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
|
||||
let selectedRule = rule(storedIn: book.tocUrl) ?? TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
|
||||
rule, title, index, previous, length in
|
||||
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
|
||||
}
|
||||
var chapters = chapters(
|
||||
data: decoded.data,
|
||||
encoding: decoded.encoding,
|
||||
bomLength: decoded.bomLength,
|
||||
book: book,
|
||||
fallbackTitle: book.name,
|
||||
selectedRule: selectedRule
|
||||
)
|
||||
_ = applyWordCounts(&chapters, data: decoded.data, encoding: decoded.encoding)
|
||||
return .init(chapters: chapters)
|
||||
}
|
||||
|
||||
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
|
||||
guard let url = URL(string: book.bookUrl), url.isFileURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
|
||||
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
let start = max(Int(chapter.start ?? 0), 0)
|
||||
let end = min(Int(chapter.end ?? Int64(fileSize)), fileSize)
|
||||
guard start <= end else {
|
||||
return .init(title: chapter.title, diagnostics: [
|
||||
.init(severity: .error, code: "local.range", message: "本地章节范围无效", rule: chapter.url)
|
||||
])
|
||||
}
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
try handle.seek(toOffset: UInt64(start))
|
||||
let contentData = try handle.read(upToCount: end - start) ?? Data()
|
||||
let encoding: String.Encoding
|
||||
if let charset = book.charset, let stored = self.encoding(named: charset) {
|
||||
encoding = stored
|
||||
} else {
|
||||
try handle.seek(toOffset: 0)
|
||||
let probe = try handle.read(upToCount: 512_000) ?? Data()
|
||||
encoding = try decode(probe).encoding
|
||||
}
|
||||
let content = (String(data: contentData, encoding: encoding) ?? "")
|
||||
.replacingOccurrences(of: #"^[\n\s]+"#, with: " ", options: .regularExpression)
|
||||
return .init(title: chapter.title, content: content, pagesVisited: 1)
|
||||
}
|
||||
|
||||
private func read(_ book: Book) throws -> (charset: String, encoding: String.Encoding, bomLength: Int, data: Data) {
|
||||
guard let url = URL(string: book.bookUrl), url.isFileURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
|
||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
if let charset = book.charset, let encoding = encoding(named: charset) {
|
||||
return (charset, encoding, bomLength(in: data, encoding: encoding), data)
|
||||
}
|
||||
let decoded = try decode(Data(data.prefix(512_000)))
|
||||
return (decoded.charset, decoded.encoding, bomLength(in: data, encoding: decoded.encoding), data)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data, preferredCharset: String? = nil) throws -> (text: String, charset: String, encoding: String.Encoding, bomLength: Int) {
|
||||
if let preferredCharset, let encoding = encoding(named: preferredCharset),
|
||||
let value = String(data: data, encoding: encoding) {
|
||||
return (stripBOM(value), preferredCharset, encoding, bomLength(in: data, encoding: encoding))
|
||||
}
|
||||
if data.starts(with: [0xFF, 0xFE]),
|
||||
let value = String(data: data, encoding: .utf16LittleEndian) {
|
||||
return (stripBOM(value), "UTF-16LE", .utf16LittleEndian, 2)
|
||||
}
|
||||
if data.starts(with: [0xFE, 0xFF]),
|
||||
let value = String(data: data, encoding: .utf16BigEndian) {
|
||||
return (stripBOM(value), "UTF-16BE", .utf16BigEndian, 2)
|
||||
}
|
||||
let candidates: [(String, String.Encoding)] = [
|
||||
("UTF-8", .utf8),
|
||||
("UTF-16", .utf16),
|
||||
("GB18030", .gb18030),
|
||||
("Big5", .big5)
|
||||
]
|
||||
for (name, encoding) in candidates {
|
||||
if let value = String(data: data, encoding: encoding), isPlausible(value) {
|
||||
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
|
||||
}
|
||||
// A bounded probe can end halfway through a UTF-8 scalar. Keep
|
||||
// UTF-8 as the selected charset instead of misclassifying the
|
||||
// truncated probe as UTF-16 and producing unreadable chapters.
|
||||
if encoding == .utf8 {
|
||||
let value = String(decoding: data, as: UTF8.self)
|
||||
let replacementCount = value.reduce(into: 0) { count, character in
|
||||
if character == "\u{FFFD}" { count += 1 }
|
||||
}
|
||||
if replacementCount <= 2, isPlausible(value) {
|
||||
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw LocalBookError.unreadableEncoding
|
||||
}
|
||||
|
||||
private func chapters(
|
||||
data: Data,
|
||||
encoding: String.Encoding,
|
||||
bomLength: Int,
|
||||
book: Book,
|
||||
fallbackTitle: String,
|
||||
selectedRule: TxtTocRule?
|
||||
) -> [BookChapter] {
|
||||
let pattern = selectedRule?.rule ?? fallbackTocPattern
|
||||
let matches = headingMatches(
|
||||
data: data,
|
||||
encoding: encoding,
|
||||
bomLength: bomLength,
|
||||
pattern: pattern,
|
||||
usesFirstCapture: selectedRule == nil
|
||||
)
|
||||
guard !matches.isEmpty else {
|
||||
if data.count - bomLength > 10 * 1_024 {
|
||||
return chaptersWithoutTOC(data, encoding: encoding, bomLength: bomLength, book: book)
|
||||
}
|
||||
var chapter = BookChapter(url: "local://chapter/0", title: fallbackTitle, bookUrl: book.bookUrl, index: 0)
|
||||
chapter.start = Int64(bomLength)
|
||||
chapter.end = Int64(data.count)
|
||||
chapter.baseUrl = book.bookUrl
|
||||
return finalized([chapter], book: book)
|
||||
}
|
||||
var previousTitle: String?
|
||||
var chapters: [BookChapter] = []
|
||||
let volumeState = TxtVolumeState()
|
||||
if let first = matches.first,
|
||||
first.start > Int64(bomLength),
|
||||
let preface = String(
|
||||
data: data.subdata(in: bomLength..<min(Int(first.start), data.count)),
|
||||
encoding: encoding
|
||||
),
|
||||
!preface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
var insertedVolumes: [String] = []
|
||||
let title = transformedTitle(
|
||||
"前言",
|
||||
script: selectedRule?.replacement,
|
||||
index: chapters.count,
|
||||
book: book,
|
||||
volumeState: volumeState,
|
||||
onVolume: { insertedVolumes.append($0) }
|
||||
)
|
||||
for volumeTitle in insertedVolumes {
|
||||
var volume = BookChapter(url: "", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
|
||||
volume.start = Int64(bomLength)
|
||||
volume.end = Int64(bomLength)
|
||||
volume.baseUrl = book.bookUrl
|
||||
volume.isVolume = true
|
||||
chapters.append(volume)
|
||||
}
|
||||
if !title.isEmpty {
|
||||
var chapter = BookChapter(url: "", title: title, bookUrl: book.bookUrl, index: chapters.count)
|
||||
chapter.start = Int64(bomLength)
|
||||
chapter.end = first.start
|
||||
chapter.baseUrl = book.bookUrl
|
||||
chapters.append(chapter)
|
||||
}
|
||||
}
|
||||
for (matchIndex, match) in matches.enumerated() {
|
||||
splitLastLongChapterIfNeeded(
|
||||
&chapters,
|
||||
data: data,
|
||||
encoding: encoding,
|
||||
book: book,
|
||||
volumeState: volumeState,
|
||||
enabled: selectedRule != nil
|
||||
)
|
||||
var insertedVolumes: [String] = []
|
||||
let title = transformedTitle(
|
||||
match.title.isEmpty ? "第 \(matchIndex + 1) 章" : match.title,
|
||||
script: selectedRule?.replacement,
|
||||
index: chapters.count,
|
||||
previousTitle: previousTitle,
|
||||
previousLength: match.previousLength,
|
||||
book: book,
|
||||
volumeState: volumeState,
|
||||
onVolume: { insertedVolumes.append($0) }
|
||||
)
|
||||
previousTitle = title
|
||||
let start = match.end
|
||||
let end = matchIndex + 1 < matches.count ? matches[matchIndex + 1].start : Int64(data.count)
|
||||
for volumeTitle in insertedVolumes {
|
||||
let offset = chapters.last?.end ?? 0
|
||||
var volume = BookChapter(url: "local://chapter/\(chapters.count)", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
|
||||
volume.start = offset
|
||||
volume.end = offset
|
||||
volume.baseUrl = book.bookUrl
|
||||
volume.isVolume = true
|
||||
chapters.append(volume)
|
||||
}
|
||||
var chapter = BookChapter(url: "local://chapter/\(chapters.count)", title: title, bookUrl: book.bookUrl, index: chapters.count)
|
||||
chapter.start = start
|
||||
chapter.end = max(end, start)
|
||||
chapter.baseUrl = book.bookUrl
|
||||
chapters.append(chapter)
|
||||
}
|
||||
splitLastLongChapterIfNeeded(
|
||||
&chapters,
|
||||
data: data,
|
||||
encoding: encoding,
|
||||
book: book,
|
||||
volumeState: volumeState,
|
||||
enabled: selectedRule != nil
|
||||
)
|
||||
return finalized(chapters, book: book)
|
||||
}
|
||||
|
||||
private var fallbackTocPattern: String {
|
||||
#"(?mi)^[\t ]*((?:第[0-9零〇一二三四五六七八九十百千万两]+[章节卷回部篇集][^\r\n]{0,50})|(?:chapter\s+\d+[^\r\n]{0,50}))[\t ]*$"#
|
||||
}
|
||||
|
||||
private func splitLastLongChapterIfNeeded(
|
||||
_ chapters: inout [BookChapter],
|
||||
data: Data,
|
||||
encoding: String.Encoding,
|
||||
book: Book,
|
||||
volumeState: TxtVolumeState,
|
||||
enabled: Bool
|
||||
) {
|
||||
guard enabled, book.readConfig?.splitLongChapter ?? true,
|
||||
var chapter = chapters.last,
|
||||
!chapter.isVolume,
|
||||
let startValue = chapter.start,
|
||||
let endValue = chapter.end,
|
||||
endValue - startValue > 102_400 else { return }
|
||||
let start = max(Int(startValue), 0)
|
||||
let end = min(max(Int(endValue), start), data.count)
|
||||
chapters.removeLast()
|
||||
chapter.end = chapter.start
|
||||
chapter.isVolume = true
|
||||
chapter.tag = nil
|
||||
chapters.append(chapter)
|
||||
volumeState.lastTitle = chapter.title
|
||||
chapters.append(contentsOf: splitByteRange(
|
||||
data,
|
||||
start: start,
|
||||
end: end,
|
||||
encoding: encoding,
|
||||
book: book,
|
||||
title: { "\(chapter.title)(\($0 + 1))" }
|
||||
))
|
||||
}
|
||||
|
||||
private func splitByteRange(
|
||||
_ data: Data,
|
||||
start initialStart: Int,
|
||||
end rangeEnd: Int,
|
||||
encoding: String.Encoding,
|
||||
book: Book,
|
||||
title: (Int) -> String
|
||||
) -> [BookChapter] {
|
||||
splitHeadinglessRange(data, start: initialStart, end: rangeEnd, encoding: encoding, book: book) { _, _, index in title(index) }
|
||||
}
|
||||
|
||||
private func headingMatches(
|
||||
data: Data,
|
||||
encoding: String.Encoding,
|
||||
bomLength: Int,
|
||||
pattern: String,
|
||||
usesFirstCapture: Bool
|
||||
) -> [TxtHeadingMatch] {
|
||||
guard let expression = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { return [] }
|
||||
if data.count <= 2 * 1_024 * 1_024,
|
||||
bomLength <= data.count,
|
||||
let text = String(data: data.subdata(in: bomLength..<data.count), encoding: encoding) {
|
||||
let source = text as NSString
|
||||
let offsets = byteOffsetMap(text, encoding: encoding, initialOffset: bomLength)
|
||||
return expression.matches(in: text, range: NSRange(location: 0, length: source.length)).map { match in
|
||||
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
|
||||
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
|
||||
let start = min(match.range.location, offsets.count - 1)
|
||||
let end = min(NSMaxRange(match.range), offsets.count - 1)
|
||||
return .init(title: title, start: offsets[start], end: offsets[end], previousLength: match.range.location)
|
||||
}
|
||||
}
|
||||
let blockSize = 512_000
|
||||
var position = bomLength
|
||||
var output: [TxtHeadingMatch] = []
|
||||
while position < data.count {
|
||||
let limit = min(position + blockSize, data.count)
|
||||
var end = limit
|
||||
if limit < data.count,
|
||||
let newline = newlineIndex(in: data, range: position..<limit, encoding: encoding, fromEnd: true),
|
||||
newline > position {
|
||||
end = newline
|
||||
}
|
||||
if end <= position { end = limit }
|
||||
let blockData = data.subdata(in: position..<end)
|
||||
guard let block = String(data: blockData, encoding: encoding) else {
|
||||
position = end
|
||||
continue
|
||||
}
|
||||
let source = block as NSString
|
||||
let offsets = byteOffsetMap(block, encoding: encoding, initialOffset: position)
|
||||
var previousEnd = 0
|
||||
for match in expression.matches(in: block, range: NSRange(location: 0, length: source.length)) {
|
||||
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
|
||||
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
|
||||
let startIndex = min(match.range.location, offsets.count - 1)
|
||||
let endIndex = min(NSMaxRange(match.range), offsets.count - 1)
|
||||
output.append(.init(
|
||||
title: title,
|
||||
start: offsets[startIndex],
|
||||
end: offsets[endIndex],
|
||||
previousLength: max(match.range.location - previousEnd, 0)
|
||||
))
|
||||
previousEnd = NSMaxRange(match.range)
|
||||
}
|
||||
position = end
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func chaptersWithoutTOC(_ data: Data, encoding: String.Encoding, bomLength: Int, book: Book) -> [BookChapter] {
|
||||
let chapters = splitHeadinglessRange(data, start: bomLength, end: data.count, encoding: encoding, book: book) {
|
||||
block, chapter, _ in "第\(block)章(\(chapter))"
|
||||
}
|
||||
return finalized(chapters, book: book)
|
||||
}
|
||||
|
||||
private func splitHeadinglessRange(
|
||||
_ data: Data,
|
||||
start rangeStart: Int,
|
||||
end rangeEnd: Int,
|
||||
encoding: String.Encoding,
|
||||
book: Book,
|
||||
title: (Int, Int, Int) -> String
|
||||
) -> [BookChapter] {
|
||||
let bufferSize = 512_000
|
||||
let maximumLength = 10 * 1_024
|
||||
var cursor = rangeStart
|
||||
var block = 0
|
||||
var output: [BookChapter] = []
|
||||
var finalChapterNumber = 0
|
||||
while cursor < rangeEnd {
|
||||
block += 1
|
||||
let blockEnd = min(cursor + bufferSize, rangeEnd)
|
||||
var chapterInBlock = 0
|
||||
while blockEnd - cursor > maximumLength {
|
||||
chapterInBlock += 1
|
||||
let searchStart = cursor + maximumLength
|
||||
let newline = newlineIndex(
|
||||
in: data,
|
||||
range: searchStart..<blockEnd,
|
||||
encoding: encoding,
|
||||
fromEnd: false
|
||||
)
|
||||
let end = newline ?? blockEnd
|
||||
var chapter = BookChapter(
|
||||
url: "",
|
||||
title: title(block, chapterInBlock, output.count),
|
||||
bookUrl: book.bookUrl,
|
||||
index: output.count
|
||||
)
|
||||
chapter.start = Int64(cursor)
|
||||
chapter.end = Int64(end)
|
||||
chapter.baseUrl = book.bookUrl
|
||||
output.append(chapter)
|
||||
cursor = end
|
||||
}
|
||||
finalChapterNumber = chapterInBlock + 1
|
||||
if blockEnd == rangeEnd { break }
|
||||
}
|
||||
if cursor < rangeEnd {
|
||||
if rangeEnd - cursor <= 100, !output.isEmpty {
|
||||
output[output.count - 1].end = Int64(rangeEnd)
|
||||
} else {
|
||||
var chapter = BookChapter(
|
||||
url: "",
|
||||
title: title(block, finalChapterNumber, output.count),
|
||||
bookUrl: book.bookUrl,
|
||||
index: output.count
|
||||
)
|
||||
chapter.start = Int64(cursor)
|
||||
chapter.end = Int64(rangeEnd)
|
||||
chapter.baseUrl = book.bookUrl
|
||||
output.append(chapter)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func applyWordCounts(
|
||||
_ chapters: inout [BookChapter],
|
||||
data: Data,
|
||||
encoding: String.Encoding
|
||||
) -> Int {
|
||||
var total = 0
|
||||
for index in chapters.indices where !chapters[index].isVolume {
|
||||
let start = max(Int(chapters[index].start ?? 0), 0)
|
||||
let end = min(max(Int(chapters[index].end ?? Int64(start)), start), data.count)
|
||||
guard start < end else {
|
||||
chapters[index].wordCount = ""
|
||||
continue
|
||||
}
|
||||
let text = String(data: data.subdata(in: start..<end), encoding: encoding) ?? ""
|
||||
let count = text.utf16.count
|
||||
chapters[index].wordCount = wordCountFormat(count)
|
||||
total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private func wordCountFormat(_ words: Int) -> String {
|
||||
guard words > 0 else { return "" }
|
||||
guard words > 10_000 else { return "\(words)字" }
|
||||
let value = Double(words) / 10_000
|
||||
let rounded = (value * 10).rounded() / 10
|
||||
if rounded.rounded() == rounded { return "\(Int(rounded))万字" }
|
||||
return String(format: "%.1f万字", rounded)
|
||||
}
|
||||
|
||||
private func finalized(_ chapters: [BookChapter], book: Book) -> [BookChapter] {
|
||||
chapters.enumerated().map { index, value in
|
||||
var chapter = value
|
||||
chapter.index = index
|
||||
chapter.bookUrl = book.bookUrl
|
||||
let seed = book.originName + String(index) + chapter.title
|
||||
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
chapter.url = String(digest.dropFirst(8).prefix(16))
|
||||
return chapter
|
||||
}
|
||||
}
|
||||
|
||||
private func rule(storedIn value: String) -> TxtTocRule? {
|
||||
guard !value.isEmpty else { return nil }
|
||||
let parts = value.components(separatedBy: TxtTocRuleSelector.separator)
|
||||
return .init(name: "本书目录规则", rule: parts[0], replacement: parts.dropFirst().first ?? "")
|
||||
}
|
||||
|
||||
private func effectiveTocRules() -> [TxtTocRule] {
|
||||
tocRules
|
||||
}
|
||||
|
||||
private func transformedTitle(
|
||||
_ value: String,
|
||||
script: String?,
|
||||
index: Int,
|
||||
previousTitle: String? = nil,
|
||||
previousLength: Int = -1,
|
||||
book: Book,
|
||||
volumeState: TxtVolumeState = .init(),
|
||||
onVolume: ((String) -> Void)? = nil
|
||||
) -> String {
|
||||
guard let script = script?.trimmingCharacters(in: .whitespacesAndNewlines), !script.isEmpty else { return value }
|
||||
let previous: String
|
||||
if let previousTitle,
|
||||
let data = try? JSONSerialization.data(withJSONObject: [previousTitle]),
|
||||
let encoded = String(data: data, encoding: .utf8) {
|
||||
previous = String(encoded.dropFirst().dropLast())
|
||||
} else {
|
||||
previous = "null"
|
||||
}
|
||||
let volumeLiteral = (try? JSONSerialization.data(withJSONObject: [volumeState.lastTitle]))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
.map { String($0.dropFirst().dropLast()) } ?? "\"\""
|
||||
let wrapped = """
|
||||
var index = \(index + 1); var prevTitle = \(previous); var prevLength = \(previousLength);
|
||||
var lastVolumeTitle = \(volumeLiteral);
|
||||
\(script)
|
||||
"""
|
||||
guard let evaluated = try? JavaScriptRuleEvaluator().evaluate(
|
||||
script: wrapped,
|
||||
result: .string(value),
|
||||
executionContext: .init(page: index + 1, book: book)
|
||||
) else { return value }
|
||||
let title = evaluated.value.stringValue
|
||||
let volumes = evaluated.variables["__legadoVolumes"]
|
||||
.flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } ?? []
|
||||
for volume in volumes {
|
||||
volumeState.lastTitle = volume
|
||||
onVolume?(volume)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
private func encoding(named name: String) -> String.Encoding? {
|
||||
switch name.uppercased() {
|
||||
case "UTF-8", "UTF8": .utf8
|
||||
case "UTF-16", "UTF16": .utf16
|
||||
case "UTF-16LE", "UTF16LE": .utf16LittleEndian
|
||||
case "UTF-16BE", "UTF16BE": .utf16BigEndian
|
||||
case "GB18030", "GBK", "GB2312": .gb18030
|
||||
case "BIG5": .big5
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
private func bomLength(in data: Data, encoding: String.Encoding) -> Int {
|
||||
if data.starts(with: [0xEF, 0xBB, 0xBF]) { return 3 }
|
||||
if isUTF16(encoding),
|
||||
data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) { return 2 }
|
||||
return 0
|
||||
}
|
||||
|
||||
private func isUTF16(_ encoding: String.Encoding) -> Bool {
|
||||
encoding == .utf16 || encoding == .utf16LittleEndian || encoding == .utf16BigEndian
|
||||
}
|
||||
|
||||
private func newlineIndex(
|
||||
in data: Data,
|
||||
range: Range<Int>,
|
||||
encoding: String.Encoding,
|
||||
fromEnd: Bool
|
||||
) -> Int? {
|
||||
guard !range.isEmpty else { return nil }
|
||||
let bytePair: (UInt8, UInt8)?
|
||||
if encoding == .utf16LittleEndian || (encoding == .utf16 && data.starts(with: [0xFF, 0xFE])) {
|
||||
bytePair = (0x0A, 0x00)
|
||||
} else if encoding == .utf16BigEndian || (encoding == .utf16 && data.starts(with: [0xFE, 0xFF])) {
|
||||
bytePair = (0x00, 0x0A)
|
||||
} else {
|
||||
bytePair = nil
|
||||
}
|
||||
guard let bytePair else {
|
||||
return fromEnd ? data[range].lastIndex(of: 0x0A) : data[range].firstIndex(of: 0x0A)
|
||||
}
|
||||
var index = range.lowerBound + (range.lowerBound.isMultiple(of: 2) ? 0 : 1)
|
||||
guard index + 1 < range.upperBound else { return nil }
|
||||
if fromEnd {
|
||||
index = range.upperBound - 2
|
||||
if !index.isMultiple(of: 2) { index -= 1 }
|
||||
while index >= range.lowerBound {
|
||||
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
|
||||
index -= 2
|
||||
}
|
||||
} else {
|
||||
while index + 1 < range.upperBound {
|
||||
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
|
||||
index += 2
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func byteOffsetMap(_ content: String, encoding: String.Encoding, initialOffset: Int) -> [Int64] {
|
||||
var offsets = [Int64](repeating: Int64(initialOffset), count: content.utf16.count + 1)
|
||||
var utf16Index = 0
|
||||
var byteOffset = initialOffset
|
||||
for scalar in content.unicodeScalars {
|
||||
let scalarText = String(scalar)
|
||||
let unitCount = scalarText.utf16.count
|
||||
let byteCount = isUTF16(encoding)
|
||||
? unitCount * 2
|
||||
: (scalarText.data(using: encoding, allowLossyConversion: true)?.count ?? 0)
|
||||
if unitCount > 1 {
|
||||
for step in 1..<unitCount { offsets[utf16Index + step] = Int64(byteOffset) }
|
||||
}
|
||||
utf16Index += unitCount
|
||||
byteOffset += byteCount
|
||||
offsets[utf16Index] = Int64(byteOffset)
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
private func textProbe(_ data: Data, encoding: String.Encoding) -> String {
|
||||
let prefix = Data(data.prefix(512_000))
|
||||
for removed in 0...8 where prefix.count >= removed {
|
||||
let candidate = prefix.dropLast(removed)
|
||||
if let value = String(data: Data(candidate), encoding: encoding) { return stripBOM(value) }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func stripBOM(_ value: String) -> String {
|
||||
value.hasPrefix("\u{feff}") ? String(value.dropFirst()) : value
|
||||
}
|
||||
|
||||
private func isPlausible(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else { return true }
|
||||
let controls = value.unicodeScalars.filter { $0.value < 32 && !CharacterSet.whitespacesAndNewlines.contains($0) }.count
|
||||
return controls * 100 < max(value.unicodeScalars.count, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String.Encoding {
|
||||
static let gb18030 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
|
||||
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)
|
||||
))
|
||||
static let big5 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
|
||||
CFStringEncoding(CFStringEncodings.big5.rawValue)
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
|
||||
public struct LocalFileEntry: Identifiable, Sendable, Equatable {
|
||||
public let url: URL
|
||||
public let bytes: Int64
|
||||
public let modified: Date?
|
||||
public var id: String { url.absoluteString }
|
||||
}
|
||||
|
||||
public enum LocalFileError: Error, LocalizedError, Equatable {
|
||||
case outsideLibrary, invalidName, alreadyExists, missingFile, emptyCache, missingDirectory
|
||||
case saveFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .outsideLibrary: "只能管理应用内的本地书文件。"
|
||||
case .invalidName: "文件名不能为空,不能包含路径分隔符或控制字符。"
|
||||
case .alreadyExists: "同名文件已存在,请换一个名称。"
|
||||
case .missingFile: "文件已不存在,请重新导入。"
|
||||
case .emptyCache: "这本书还没有可导出的缓存正文,请先缓存章节。"
|
||||
case .missingDirectory: "还没有这本书的目录,请先打开详情加载目录。"
|
||||
case .saveFailed(let message): "书架保存失败:\(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct StagedLocalFile: Sendable {
|
||||
public let original: URL
|
||||
public let staged: URL
|
||||
}
|
||||
|
||||
public actor LocalFileService {
|
||||
public let directory: URL
|
||||
|
||||
public init(directory: URL) { self.directory = directory.standardizedFileURL }
|
||||
|
||||
public func list() throws -> [LocalFileEntry] {
|
||||
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
).compactMap { url in
|
||||
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey])
|
||||
guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil }
|
||||
return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate)
|
||||
}.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending }
|
||||
}
|
||||
|
||||
public func importFile(from source: URL) throws -> URL {
|
||||
guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
|
||||
let values = try source.resourceValues(forKeys: [.isRegularFileKey])
|
||||
guard values.isRegularFile == true else { throw LocalFileError.missingFile }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let stem = source.deletingPathExtension().lastPathComponent
|
||||
var target = directory.appendingPathComponent(source.lastPathComponent)
|
||||
var suffix = 2
|
||||
while FileManager.default.fileExists(atPath: target.path) {
|
||||
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt")
|
||||
suffix += 1
|
||||
}
|
||||
try FileManager.default.copyItem(at: source, to: target)
|
||||
return target
|
||||
}
|
||||
|
||||
public func rename(_ source: URL, to name: String) throws -> URL {
|
||||
try validate(source)
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.hasPrefix("."),
|
||||
!trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else {
|
||||
throw LocalFileError.invalidName
|
||||
}
|
||||
let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt"
|
||||
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
|
||||
let target = directory.appendingPathComponent(filename)
|
||||
if source.standardizedFileURL == target.standardizedFileURL { return source }
|
||||
guard !FileManager.default.fileExists(atPath: target.path) else { throw LocalFileError.alreadyExists }
|
||||
try FileManager.default.moveItem(at: source, to: target)
|
||||
return target
|
||||
}
|
||||
|
||||
public func stageRemoval(_ source: URL) throws -> StagedLocalFile? {
|
||||
try validate(source, allowMissing: true)
|
||||
guard FileManager.default.fileExists(atPath: source.path) else { return nil }
|
||||
let trash = directory.appendingPathComponent(".Trash", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
|
||||
let staged = trash.appendingPathComponent(UUID().uuidString + ".txt")
|
||||
try FileManager.default.moveItem(at: source, to: staged)
|
||||
return .init(original: source, staged: staged)
|
||||
}
|
||||
|
||||
public func restore(_ file: StagedLocalFile) throws {
|
||||
try validate(file.original, allowMissing: true)
|
||||
guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else {
|
||||
throw LocalFileError.outsideLibrary
|
||||
}
|
||||
try FileManager.default.moveItem(at: file.staged, to: file.original)
|
||||
}
|
||||
|
||||
public func finishRemoval(_ file: StagedLocalFile) throws {
|
||||
guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else {
|
||||
throw LocalFileError.outsideLibrary
|
||||
}
|
||||
try FileManager.default.removeItem(at: file.staged)
|
||||
}
|
||||
|
||||
private func validate(_ url: URL, allowMissing: Bool = false) throws {
|
||||
guard url.isFileURL, url.pathExtension.lowercased() == "txt",
|
||||
url.standardizedFileURL.deletingLastPathComponent() == directory,
|
||||
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
|
||||
throw LocalFileError.outsideLibrary
|
||||
}
|
||||
if !allowMissing, !FileManager.default.fileExists(atPath: url.path) { throw LocalFileError.missingFile }
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookFileExport: Identifiable, Sendable {
|
||||
public var id: String { url.absoluteString }
|
||||
public let url: URL
|
||||
public let cachedChapters: Int
|
||||
public let totalChapters: Int
|
||||
public var isPartial: Bool { cachedChapters < totalChapters }
|
||||
}
|
||||
|
||||
public actor BookFileExporter {
|
||||
private let directory: URL
|
||||
private let cache: ChapterContentCache
|
||||
|
||||
public init(directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LegadoExports"), cache: ChapterContentCache = .live()) {
|
||||
self.directory = directory
|
||||
self.cache = cache
|
||||
}
|
||||
|
||||
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
|
||||
let target = try destination(filename: filename)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: source, to: target)
|
||||
return .init(url: target, cachedChapters: 0, totalChapters: 0)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: target.deletingLastPathComponent())
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func cachedBook(_ book: Book, chapters: [BookChapter]) async throws -> BookFileExport {
|
||||
guard chapters.contains(where: { !$0.isVolume }) else { throw LocalFileError.missingDirectory }
|
||||
let target = try destination(filename: book.name + ".txt")
|
||||
do {
|
||||
_ = FileManager.default.createFile(atPath: target.path, contents: nil)
|
||||
let output = try FileHandle(forWritingTo: target)
|
||||
defer { try? output.close() }
|
||||
try output.write(contentsOf: Data((book.name + (book.author.isEmpty ? "" : "\n作者:" + book.author) + "\n\n").utf8))
|
||||
var cached = 0
|
||||
var total = 0
|
||||
for chapter in chapters.sorted(by: { $0.index < $1.index }) {
|
||||
try Task.checkCancellation()
|
||||
try output.write(contentsOf: Data((chapter.title + "\n\n").utf8))
|
||||
guard !chapter.isVolume else { continue }
|
||||
total += 1
|
||||
if let entry = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url) {
|
||||
cached += 1
|
||||
try output.write(contentsOf: Data((entry.content + "\n" + (entry.supplementalContent ?? "") + "\n\n").utf8))
|
||||
} else {
|
||||
try output.write(contentsOf: Data("【本章尚未缓存】\n\n".utf8))
|
||||
}
|
||||
}
|
||||
guard cached > 0 else { throw LocalFileError.emptyCache }
|
||||
return .init(url: target, cachedChapters: cached, totalChapters: total)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: target.deletingLastPathComponent())
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func discard(_ export: BookFileExport) throws {
|
||||
guard export.url.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL else {
|
||||
throw LocalFileError.outsideLibrary
|
||||
}
|
||||
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
private func destination(filename: String) throws -> URL {
|
||||
let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_")
|
||||
let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
|
||||
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct ApplicationSettings: Codable, Equatable, Sendable {
|
||||
public var userAgent = ""
|
||||
public var proxy = ""
|
||||
public var hosts = ""
|
||||
public var timeoutSeconds = 0
|
||||
public var concurrentSources = 32
|
||||
public var chapterCacheMB = 256
|
||||
public var prefetchChapters = 1
|
||||
public var recordLog = false
|
||||
public var ignoreCertificateErrors = true
|
||||
|
||||
public init() {}
|
||||
|
||||
public var effectiveUserAgent: String {
|
||||
userAgent.isEmpty ? LegadoURLBuilder.defaultUserAgent : userAgent
|
||||
}
|
||||
|
||||
public func validated() throws -> Self {
|
||||
var value = self
|
||||
value.userAgent = userAgent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value.proxy = proxy.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.userAgent.contains(where: { $0.isNewline || $0 == "\0" }) else {
|
||||
throw SettingsError.invalid("User-Agent 不能包含换行。")
|
||||
}
|
||||
if !value.proxy.isEmpty { _ = try Self.proxyDictionary(value.proxy) }
|
||||
_ = try Self.parseHosts(hosts)
|
||||
guard timeoutSeconds == 0 || (5...300).contains(timeoutSeconds),
|
||||
(1...64).contains(concurrentSources), (16...4096).contains(chapterCacheMB),
|
||||
(0...20).contains(prefetchChapters) else {
|
||||
throw SettingsError.invalid("网络或缓存设置超出允许范围。")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
public func configure(_ configuration: URLSessionConfiguration, proxyOverride: String? = nil) throws {
|
||||
configuration.httpMaximumConnectionsPerHost = concurrentSources
|
||||
let address = proxyOverride ?? proxy
|
||||
configuration.connectionProxyDictionary = address.isEmpty ? nil : try Self.proxyDictionary(address)
|
||||
if timeoutSeconds > 0 {
|
||||
configuration.timeoutIntervalForRequest = Double(timeoutSeconds)
|
||||
configuration.timeoutIntervalForResource = Double(timeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
public func configure(_ request: URLRequest) -> URLRequest {
|
||||
var request = request
|
||||
if timeoutSeconds > 0 { request.timeoutInterval = Double(timeoutSeconds) }
|
||||
if request.value(forHTTPHeaderField: "User-Agent") == nil,
|
||||
URLProtocol.property(forKey: "LegadoSuppressUserAgent", in: request) as? Bool != true {
|
||||
request.setValue(effectiveUserAgent, forHTTPHeaderField: "User-Agent")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
public static func proxyDictionary(_ address: String) throws -> [AnyHashable: Any] {
|
||||
guard let url = URL(string: address), let host = url.host, !host.isEmpty,
|
||||
let scheme = url.scheme?.lowercased(), ["http", "https", "socks", "socks4", "socks5"].contains(scheme),
|
||||
url.user == nil, url.password == nil, url.query == nil, url.fragment == nil,
|
||||
url.path.isEmpty || url.path == "/", (1...65535).contains(url.port ?? 8080) else {
|
||||
throw SettingsError.invalid("代理格式应为 http://主机:端口、https://主机:端口或 socks5://主机:端口,不含账号和密码。")
|
||||
}
|
||||
let port = url.port ?? (scheme.hasPrefix("socks") ? 1080 : scheme == "https" ? 443 : 8080)
|
||||
if scheme.hasPrefix("socks") {
|
||||
return ["SOCKSEnable": 1, "SOCKSProxy": host, "SOCKSPort": port]
|
||||
}
|
||||
return ["HTTPEnable": 1, "HTTPProxy": host, "HTTPPort": port,
|
||||
"HTTPSEnable": 1, "HTTPSProxy": host, "HTTPSPort": port]
|
||||
}
|
||||
|
||||
public static func parseHosts(_ text: String) throws -> [String: String] {
|
||||
var values: [String: String] = [:]
|
||||
for (index, line) in text.components(separatedBy: .newlines).enumerated() {
|
||||
let fields = line.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
||||
.split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
||||
guard !fields.isEmpty else { continue }
|
||||
guard fields.count >= 2, IPv4Address(fields[0]) != nil || IPv6Address(fields[0]) != nil else {
|
||||
throw SettingsError.invalid("Hosts 第 \(index + 1) 行应为 IP地址 域名,可添加 # 注释。")
|
||||
}
|
||||
for domain in fields.dropFirst() {
|
||||
let domain = domain.lowercased()
|
||||
guard domain.utf8.count <= 253, domain.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ label in
|
||||
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-"
|
||||
&& label.utf8.allSatisfy { (97...122).contains($0) || (48...57).contains($0) || $0 == 45 }
|
||||
}) else { throw SettingsError.invalid("Hosts 第 \(index + 1) 行的域名无效。") }
|
||||
values[domain] = fields[0]
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
public enum SettingsError: Error, LocalizedError {
|
||||
case invalid(String)
|
||||
public var errorDescription: String? { if case .invalid(let message) = self { message } else { nil } }
|
||||
}
|
||||
|
||||
public final class ApplicationSettingsStore: @unchecked Sendable {
|
||||
public static let shared = ApplicationSettingsStore()
|
||||
public static let key = "legado.applicationSettings.v1"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = .standard) { self.defaults = defaults }
|
||||
|
||||
public var settings: ApplicationSettings {
|
||||
guard let data = defaults.data(forKey: Self.key),
|
||||
let value = try? JSONDecoder().decode(ApplicationSettings.self, from: data),
|
||||
let validated = try? value.validated() else { return .init() }
|
||||
return validated
|
||||
}
|
||||
|
||||
public func save(_ settings: ApplicationSettings) throws {
|
||||
let validated = try settings.validated()
|
||||
defaults.set(try JSONEncoder().encode(validated), forKey: Self.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSourceHeaderResolver: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func resolve(_ source: BookSource, includeLoginHeader: Bool = true) throws -> [String: String] {
|
||||
var headers: [String: String] = [:]
|
||||
if let raw = source.header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
|
||||
let json: String
|
||||
if raw.lowercased().hasPrefix("@js:") {
|
||||
json = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: String(raw.dropFirst(4)),
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
).value.stringValue
|
||||
} else if raw.lowercased().hasPrefix("<js>"), raw.lowercased().hasSuffix("</js>") {
|
||||
json = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: String(raw.dropFirst(4).dropLast(5)),
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
).value.stringValue
|
||||
} else {
|
||||
json = raw
|
||||
}
|
||||
if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(json) {
|
||||
headers = object.mapValues(\.stringValue)
|
||||
}
|
||||
}
|
||||
if includeLoginHeader {
|
||||
headers.merge(BookSourceRuntimeStore.shared.loginHeaders(sourceID: source.id)) { _, login in login }
|
||||
}
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public struct HTTPResponse: Sendable {
|
||||
public var data: Data
|
||||
public var statusCode: Int
|
||||
public var headers: [String: String]
|
||||
public var finalURL: URL
|
||||
public var requestedCharset: String?
|
||||
public var requestVariables: [String: String]
|
||||
|
||||
public init(data: Data, statusCode: Int, headers: [String: String], finalURL: URL, requestedCharset: String? = nil, requestVariables: [String: String] = [:]) {
|
||||
self.data = data
|
||||
self.statusCode = statusCode
|
||||
self.headers = headers
|
||||
self.finalURL = finalURL
|
||||
self.requestedCharset = requestedCharset
|
||||
self.requestVariables = requestVariables
|
||||
}
|
||||
|
||||
public var decodedText: String {
|
||||
let encoding = requestedCharset.flatMap(HTTPTextDecoder.encoding(named:))
|
||||
?? contentTypeCharset.flatMap(HTTPTextDecoder.encoding(named:))
|
||||
?? HTTPTextDecoder.bomEncoding(data)
|
||||
?? HTTPTextDecoder.htmlEncoding(data)
|
||||
?? .utf8
|
||||
if let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: encoding) { return value }
|
||||
if encoding != .utf8, let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: .utf8) { return value }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
public var decodedJSON: JSONValue? {
|
||||
try? JSONValue.decode(data: Data(decodedText.utf8))
|
||||
}
|
||||
|
||||
private var contentTypeCharset: String? {
|
||||
guard let value = headers.first(where: { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame })?.value,
|
||||
let expression = try? NSRegularExpression(pattern: #"charset\s*=\s*[\"']?([^;\"'\s]+)"#, options: .caseInsensitive),
|
||||
let match = expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
|
||||
let range = Range(match.range(at: 1), in: value) else { return nil }
|
||||
return String(value[range])
|
||||
}
|
||||
}
|
||||
|
||||
public enum HTTPTextDecoder {
|
||||
public static func htmlEncoding(_ data: Data) -> String.Encoding? {
|
||||
let head = String(decoding: data.prefix(16_384), as: UTF8.self)
|
||||
guard let meta = try? NSRegularExpression(pattern: #"<meta\b[^>]*>"#, options: .caseInsensitive),
|
||||
let charset = try? NSRegularExpression(pattern: #"charset\s*=\s*["']?\s*([a-zA-Z0-9_-]+)"#, options: .caseInsensitive) else { return nil }
|
||||
for tag in meta.matches(in: head, range: NSRange(head.startIndex..., in: head)) {
|
||||
guard let match = charset.firstMatch(in: head, range: tag.range),
|
||||
let range = Range(match.range(at: 1), in: head),
|
||||
let encoding = encoding(named: String(head[range])) else { continue }
|
||||
return encoding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func encoding(named rawValue: String) -> String.Encoding? {
|
||||
let value = rawValue.lowercased().replacingOccurrences(of: "_", with: "-").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch value {
|
||||
case "utf-8", "utf8": return .utf8
|
||||
case "utf-16", "utf16": return .utf16
|
||||
case "utf-16le", "utf16le": return .utf16LittleEndian
|
||||
case "utf-16be", "utf16be": return .utf16BigEndian
|
||||
case "utf-32", "utf32": return .utf32
|
||||
case "utf-32le", "utf32le": return .utf32LittleEndian
|
||||
case "utf-32be", "utf32be": return .utf32BigEndian
|
||||
case "gbk", "gb2312", "gb-2312", "gb18030", "gb-18030":
|
||||
return foundationEncoding(.GB_18030_2000)
|
||||
case "big5", "big-5": return foundationEncoding(.big5)
|
||||
case "shift-jis", "shiftjis", "sjis": return .shiftJIS
|
||||
case "iso-8859-1", "latin1": return .isoLatin1
|
||||
case "windows-1252", "cp1252": return .windowsCP1252
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func foundationEncoding(_ value: CFStringEncodings) -> String.Encoding {
|
||||
.init(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(value.rawValue)))
|
||||
}
|
||||
|
||||
public static func bomEncoding(_ data: Data) -> String.Encoding? {
|
||||
let prefix = Array(data.prefix(4))
|
||||
if prefix.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 }
|
||||
if prefix.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian }
|
||||
if prefix.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian }
|
||||
if prefix.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian }
|
||||
if prefix.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func removingBOM(_ data: Data) -> Data {
|
||||
let bytes = Array(data.prefix(4))
|
||||
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return data.dropFirst(3) }
|
||||
if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) || bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return data.dropFirst(4) }
|
||||
if bytes.starts(with: [0xFF, 0xFE]) || bytes.starts(with: [0xFE, 0xFF]) { return data.dropFirst(2) }
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
public protocol HTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse
|
||||
}
|
||||
|
||||
public final class LegadoUnsafeTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
public static let shared = LegadoUnsafeTrustDelegate()
|
||||
private let settingsStore: ApplicationSettingsStore
|
||||
private let interceptHostsRedirects: Bool
|
||||
|
||||
public init(settingsStore: ApplicationSettingsStore = .shared, interceptHostsRedirects: Bool = true) {
|
||||
self.settingsStore = settingsStore
|
||||
self.interceptHostsRedirects = interceptHostsRedirects
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge
|
||||
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
||||
Self.resolution(for: challenge, ignoreCertificateErrors: settingsStore.settings.ignoreCertificateErrors)
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping @Sendable (URLRequest?) -> Void
|
||||
) {
|
||||
let settings = settingsStore.settings
|
||||
let address = try? ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""]
|
||||
completionHandler(interceptHostsRedirects && settings.proxy.isEmpty && address != nil ? nil : request)
|
||||
}
|
||||
|
||||
public static func resolve(
|
||||
_ challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let result = resolution(for: challenge)
|
||||
completionHandler(result.0, result.1)
|
||||
}
|
||||
|
||||
static func resolution(
|
||||
for challenge: URLAuthenticationChallenge,
|
||||
ignoreCertificateErrors: Bool = ApplicationSettingsStore.shared.settings.ignoreCertificateErrors
|
||||
) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
||||
let disposition: URLSession.AuthChallengeDisposition
|
||||
let credential: URLCredential?
|
||||
if ignoreCertificateErrors,
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust {
|
||||
disposition = .useCredential
|
||||
credential = URLCredential(trust: trust)
|
||||
} else {
|
||||
disposition = .performDefaultHandling
|
||||
credential = nil
|
||||
}
|
||||
return (disposition, credential)
|
||||
}
|
||||
}
|
||||
|
||||
public struct HTTPStreamResponse: Sendable {
|
||||
public var statusCode: Int
|
||||
public var headers: [String: String]
|
||||
public var finalURL: URL
|
||||
public var expectedContentLength: Int64?
|
||||
public var chunks: AsyncThrowingStream<Data, Error>
|
||||
|
||||
public init(
|
||||
statusCode: Int,
|
||||
headers: [String: String],
|
||||
finalURL: URL,
|
||||
expectedContentLength: Int64? = nil,
|
||||
chunks: AsyncThrowingStream<Data, Error>
|
||||
) {
|
||||
self.statusCode = statusCode
|
||||
self.headers = headers
|
||||
self.finalURL = finalURL
|
||||
self.expectedContentLength = expectedContentLength
|
||||
self.chunks = chunks
|
||||
}
|
||||
}
|
||||
|
||||
public protocol StreamingHTTPTransport: HTTPTransport {
|
||||
func stream(for request: URLRequest) async throws -> HTTPStreamResponse
|
||||
}
|
||||
|
||||
public protocol ProxyHTTPTransport: HTTPTransport {
|
||||
func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse
|
||||
}
|
||||
|
||||
public enum HTTPClientError: Error, LocalizedError, Sendable {
|
||||
case nonHTTPResponse
|
||||
case statusCode(Int)
|
||||
case statusResponse(HTTPResponse)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .nonHTTPResponse: "服务器没有返回 HTTP 响应"
|
||||
case .statusCode(let code): "服务器返回 HTTP \(code)"
|
||||
case .statusResponse(let response): "服务器返回 HTTP \(response.statusCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class URLSessionHTTPTransport: ProxyHTTPTransport, StreamingHTTPTransport, @unchecked Sendable {
|
||||
public static let shared = URLSessionHTTPTransport()
|
||||
private let configuration: URLSessionConfiguration
|
||||
private let settingsStore: ApplicationSettingsStore
|
||||
private let lock = NSLock()
|
||||
private var cachedSettings: ApplicationSettings?
|
||||
private var cachedSession: URLSession?
|
||||
|
||||
public init(configuration: URLSessionConfiguration = .ephemeral, settingsStore: ApplicationSettingsStore = .shared) {
|
||||
self.configuration = configuration.copy() as! URLSessionConfiguration
|
||||
self.settingsStore = settingsStore
|
||||
self.configuration.httpCookieAcceptPolicy = .always
|
||||
self.configuration.httpShouldSetCookies = true
|
||||
self.configuration.httpCookieStorage = HTTPCookieStorage.shared
|
||||
self.configuration.requestCachePolicy = .reloadRevalidatingCacheData
|
||||
}
|
||||
|
||||
deinit { cachedSession?.finishTasksAndInvalidate() }
|
||||
|
||||
private func configuredSession(settings: ApplicationSettings) throws -> URLSession {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if settings == cachedSettings, let cachedSession { return cachedSession }
|
||||
let config = configuration.copy() as! URLSessionConfiguration
|
||||
try settings.configure(config)
|
||||
let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore), delegateQueue: nil)
|
||||
cachedSession?.finishTasksAndInvalidate()
|
||||
cachedSession = session
|
||||
cachedSettings = settings
|
||||
return session
|
||||
}
|
||||
|
||||
public func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
try await load(request, redirectsRemaining: 10)
|
||||
}
|
||||
|
||||
private func load(_ original: URLRequest, redirectsRemaining: Int) async throws -> HTTPResponse {
|
||||
try Task.checkCancellation()
|
||||
let settings = settingsStore.settings
|
||||
let request = settings.configure(original)
|
||||
let started = Date()
|
||||
do {
|
||||
let response: HTTPResponse
|
||||
if settings.proxy.isEmpty,
|
||||
let address = try ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] {
|
||||
response = try await HostsHTTPTransport.data(for: request, address: address, ignoreCertificateErrors: settings.ignoreCertificateErrors)
|
||||
} else {
|
||||
let session = try configuredSession(settings: settings)
|
||||
response = try await rawData(for: request, session: session)
|
||||
}
|
||||
if [301, 302, 303, 307, 308].contains(response.statusCode),
|
||||
let location = response.headers.first(where: { $0.key.lowercased() == "location" })?.value,
|
||||
let target = URL(string: location, relativeTo: response.finalURL)?.absoluteURL {
|
||||
guard redirectsRemaining > 0 else { throw URLError(.httpTooManyRedirects) }
|
||||
var next = request
|
||||
next.url = target
|
||||
if response.statusCode == 303 || ([301, 302].contains(response.statusCode) && next.httpMethod == "POST") {
|
||||
next.httpMethod = "GET"; next.httpBody = nil
|
||||
next.setValue(nil, forHTTPHeaderField: "Content-Length")
|
||||
next.setValue(nil, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
if target.host?.lowercased() != request.url?.host?.lowercased()
|
||||
|| target.scheme?.lowercased() != request.url?.scheme?.lowercased()
|
||||
|| target.port != request.url?.port {
|
||||
next.setValue(nil, forHTTPHeaderField: "Authorization")
|
||||
next.setValue(nil, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
next.setValue(nil, forHTTPHeaderField: "Host")
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return try await load(next, redirectsRemaining: redirectsRemaining - 1)
|
||||
}
|
||||
guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) }
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return response
|
||||
} catch {
|
||||
DiagnosticLog.shared.network(request, error: error, startedAt: started)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func download(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
let value = try await data(for: request)
|
||||
guard let response = HTTPURLResponse(url: value.finalURL, statusCode: value.statusCode, httpVersion: nil, headerFields: value.headers) else {
|
||||
throw HTTPClientError.nonHTTPResponse
|
||||
}
|
||||
return (value.data, response)
|
||||
}
|
||||
|
||||
public func stream(for request: URLRequest) async throws -> HTTPStreamResponse {
|
||||
let settings = settingsStore.settings
|
||||
let request = settings.configure(request)
|
||||
if !settings.hosts.isEmpty, settings.proxy.isEmpty {
|
||||
let response = try await data(for: request)
|
||||
return .init(statusCode: response.statusCode, headers: response.headers, finalURL: response.finalURL,
|
||||
expectedContentLength: Int64(response.data.count), chunks: AsyncThrowingStream { continuation in
|
||||
continuation.yield(response.data)
|
||||
continuation.finish()
|
||||
})
|
||||
}
|
||||
let session = try configuredSession(settings: settings)
|
||||
let started = Date()
|
||||
let bytes: URLSession.AsyncBytes
|
||||
let response: URLResponse
|
||||
do {
|
||||
(bytes, response) = try await session.bytes(for: request)
|
||||
} catch {
|
||||
DiagnosticLog.shared.network(request, error: error, startedAt: started)
|
||||
throw error
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse, let finalURL = http.url else {
|
||||
throw HTTPClientError.nonHTTPResponse
|
||||
}
|
||||
let headers = Self.headers(from: http)
|
||||
DiagnosticLog.shared.network(request, status: http.statusCode, startedAt: started)
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
var body = Data()
|
||||
for try await byte in bytes { body.append(byte) }
|
||||
throw HTTPClientError.statusResponse(.init(
|
||||
data: body,
|
||||
statusCode: http.statusCode,
|
||||
headers: headers,
|
||||
finalURL: finalURL
|
||||
))
|
||||
}
|
||||
let chunks = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
var chunk = Data()
|
||||
chunk.reserveCapacity(32 * 1_024)
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
chunk.append(byte)
|
||||
if chunk.count >= 32 * 1_024 {
|
||||
continuation.yield(chunk)
|
||||
chunk.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
if !chunk.isEmpty { continuation.yield(chunk) }
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
return .init(
|
||||
statusCode: http.statusCode,
|
||||
headers: headers,
|
||||
finalURL: finalURL,
|
||||
expectedContentLength: http.expectedContentLength >= 0 ? http.expectedContentLength : nil,
|
||||
chunks: chunks
|
||||
)
|
||||
}
|
||||
|
||||
public func data(for original: URLRequest, proxy: String) async throws -> HTTPResponse {
|
||||
let settings = settingsStore.settings
|
||||
let config = configuration.copy() as! URLSessionConfiguration
|
||||
try settings.configure(config, proxyOverride: proxy)
|
||||
let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore, interceptHostsRedirects: false), delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
let request = settings.configure(original)
|
||||
let started = Date()
|
||||
do {
|
||||
let response = try await rawData(for: request, session: session)
|
||||
guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) }
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return response
|
||||
} catch {
|
||||
DiagnosticLog.shared.network(request, error: error, startedAt: started)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func rawData(for request: URLRequest, session: URLSession) async throws -> HTTPResponse {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, let url = http.url else { throw HTTPClientError.nonHTTPResponse }
|
||||
return .init(data: data, statusCode: http.statusCode, headers: Self.headers(from: http), finalURL: url)
|
||||
}
|
||||
|
||||
private static func headers(from response: HTTPURLResponse) -> [String: String] {
|
||||
response.allHeaderFields.reduce(into: [:]) { result, entry in
|
||||
result[String(describing: entry.key)] = String(describing: entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class LegadoCookieStore: @unchecked Sendable {
|
||||
public static let shared = LegadoCookieStore()
|
||||
|
||||
private let storage: HTTPCookieStorage
|
||||
private let lock = NSLock()
|
||||
private let persistent = PersistentRuntimeCache.shared
|
||||
|
||||
public init(storage: HTTPCookieStorage = .shared) {
|
||||
self.storage = storage
|
||||
}
|
||||
|
||||
public func cookieHeader(for value: String) -> String {
|
||||
guard let url = normalizedURL(value) else { return "" }
|
||||
lock.lock()
|
||||
let cookies = storage.cookies(for: url) ?? []
|
||||
lock.unlock()
|
||||
let session = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? ""
|
||||
let saved = persistent.cookie(for: persistenceKey(url)) ?? ""
|
||||
return mergeCookieHeaders(saved, session)
|
||||
}
|
||||
|
||||
public func cookie(for value: String, key: String) -> String {
|
||||
guard let url = normalizedURL(value) else { return "" }
|
||||
lock.lock()
|
||||
let result = storage.cookies(for: url)?.first(where: { $0.name == key })?.value
|
||||
lock.unlock()
|
||||
if let result { return result }
|
||||
return cookieMap(persistent.cookie(for: persistenceKey(url)) ?? "")[key] ?? ""
|
||||
}
|
||||
|
||||
public func setCookie(_ cookie: String, for value: String) {
|
||||
guard let url = normalizedURL(value), !cookie.isEmpty else { return }
|
||||
var cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookie], for: url)
|
||||
if cookies.isEmpty,
|
||||
let host = url.host,
|
||||
let first = cookie.split(separator: ";").first {
|
||||
let pair = first.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
if pair.count == 2, let fallback = HTTPCookie(properties: [
|
||||
.name: pair[0].trimmingCharacters(in: .whitespaces),
|
||||
.value: pair[1],
|
||||
.domain: host,
|
||||
.path: "/",
|
||||
.secure: url.scheme == "https" ? "TRUE" : "FALSE"
|
||||
]) {
|
||||
cookies = [fallback]
|
||||
}
|
||||
}
|
||||
lock.lock()
|
||||
cookies.forEach(storage.setCookie)
|
||||
lock.unlock()
|
||||
let old = persistent.cookie(for: persistenceKey(url)) ?? ""
|
||||
persistent.putCookie(
|
||||
key: persistenceKey(url),
|
||||
value: mergeCookieHeaders(old, persistentCookieHeader(cookie))
|
||||
)
|
||||
}
|
||||
|
||||
public func removeCookies(for value: String) {
|
||||
guard let url = normalizedURL(value) else { return }
|
||||
lock.lock()
|
||||
storage.cookies(for: url)?.forEach(storage.deleteCookie)
|
||||
lock.unlock()
|
||||
persistent.deleteCookie(key: persistenceKey(url))
|
||||
}
|
||||
|
||||
private func normalizedURL(_ value: String) -> URL? {
|
||||
if let url = URL(string: value), url.scheme != nil { return url }
|
||||
return URL(string: "https://\(value)")
|
||||
}
|
||||
|
||||
private func persistenceKey(_ url: URL) -> String {
|
||||
guard let host = url.host?.lowercased() else { return url.absoluteString }
|
||||
return Self.persistenceDomain(for: host)
|
||||
}
|
||||
|
||||
static func persistenceDomain(for host: String) -> String {
|
||||
let host = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
guard !host.isEmpty, !isIPAddress(host) else { return host }
|
||||
let labels = host.split(separator: ".").map(String.init)
|
||||
guard labels.count > 2 else { return host }
|
||||
let lastTwo = labels.suffix(2).joined(separator: ".")
|
||||
let publicSecondLevels: Set<String> = [
|
||||
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn",
|
||||
"com.hk", "net.hk", "org.hk", "com.tw", "net.tw", "org.tw",
|
||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk",
|
||||
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
|
||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
||||
"co.nz", "net.nz", "org.nz", "co.kr", "or.kr", "ne.kr",
|
||||
"co.in", "firm.in", "net.in", "org.in", "gen.in", "ind.in",
|
||||
"com.sg", "net.sg", "org.sg", "com.br", "net.br", "org.br"
|
||||
]
|
||||
return publicSecondLevels.contains(lastTwo)
|
||||
? labels.suffix(3).joined(separator: ".")
|
||||
: lastTwo
|
||||
}
|
||||
|
||||
private static func isIPAddress(_ host: String) -> Bool {
|
||||
if host.contains(":") { return host.allSatisfy { $0.isHexDigit || $0 == ":" || $0 == "." } }
|
||||
let parts = host.split(separator: ".")
|
||||
return parts.count == 4 && parts.allSatisfy { part in
|
||||
guard let value = Int(part) else { return false }
|
||||
return (0...255).contains(value)
|
||||
}
|
||||
}
|
||||
|
||||
private func cookieMap(_ header: String) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
result[pair[0].trimmingCharacters(in: .whitespaces)] = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func mergeCookieHeaders(_ headers: String...) -> String {
|
||||
var values: [String: String] = [:]
|
||||
var order: [String] = []
|
||||
for header in headers {
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
let name = pair[0].trimmingCharacters(in: .whitespaces)
|
||||
if values[name] == nil { order.append(name) }
|
||||
values[name] = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ")
|
||||
}
|
||||
|
||||
private func persistentCookieHeader(_ header: String) -> String {
|
||||
let attributes = Set(["path", "domain", "expires", "max-age", "samesite"])
|
||||
return header.split(separator: ";").compactMap { part -> String? in
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { return nil }
|
||||
let name = pair[0].trimmingCharacters(in: .whitespaces)
|
||||
guard !attributes.contains(name.lowercased()) else { return nil }
|
||||
return "\(name)=\(pair[1].trimmingCharacters(in: .whitespaces))"
|
||||
}.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SourceWebViewRequestExecuting: Sendable {
|
||||
@MainActor
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse
|
||||
}
|
||||
|
||||
public final class SourceWebViewExecutorRegistry: @unchecked Sendable {
|
||||
public static let shared = SourceWebViewExecutorRegistry()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var storedExecutor: (any SourceWebViewRequestExecuting)?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func register(_ executor: any SourceWebViewRequestExecuting) {
|
||||
lock.lock()
|
||||
storedExecutor = executor
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
public func executor() -> (any SourceWebViewRequestExecuting)? {
|
||||
lock.lock()
|
||||
let value = storedExecutor
|
||||
lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
public struct SourceRateLimit: Sendable, Equatable {
|
||||
public var accessLimit: Int
|
||||
public var intervalMilliseconds: Int64
|
||||
|
||||
public init?(rawValue: String?) {
|
||||
guard let rawValue else { return nil }
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty, value != "0" else { return nil }
|
||||
if let slash = value.firstIndex(of: "/"),
|
||||
let count = Int(value[..<slash]),
|
||||
let interval = Int64(value[value.index(after: slash)...]),
|
||||
count > 0, interval > 0 {
|
||||
accessLimit = count
|
||||
intervalMilliseconds = interval
|
||||
} else if let interval = Int64(value), interval > 0 {
|
||||
accessLimit = 1
|
||||
intervalMilliseconds = interval
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SourceRateWindow: Sendable, Equatable {
|
||||
public var startedAt: Int64
|
||||
public var frequency: Int
|
||||
|
||||
public init(startedAt: Int64 = 0, frequency: Int = 0) {
|
||||
self.startedAt = startedAt
|
||||
self.frequency = frequency
|
||||
}
|
||||
|
||||
public mutating func reserve(now: Int64, limit: SourceRateLimit) -> Int64 {
|
||||
if frequency == 0 || now >= startedAt + limit.intervalMilliseconds {
|
||||
startedAt = now
|
||||
frequency = 1
|
||||
return 0
|
||||
}
|
||||
if frequency < limit.accessLimit {
|
||||
frequency += 1
|
||||
return 0
|
||||
}
|
||||
return max(startedAt + limit.intervalMilliseconds - now, 0)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SourceNetworkPolicy {
|
||||
public static let shared = SourceNetworkPolicy()
|
||||
|
||||
private struct StoredCookie: Sendable {
|
||||
var name: String
|
||||
var value: String
|
||||
var domain: String
|
||||
var path: String
|
||||
var expiresAt: Date?
|
||||
var secure: Bool
|
||||
}
|
||||
|
||||
private var windows: [String: SourceRateWindow] = [:]
|
||||
private var cookies: [String: [StoredCookie]] = [:]
|
||||
private let javaScript: JavaScriptRuleEvaluator
|
||||
|
||||
public init(javaScript: JavaScriptRuleEvaluator = .init()) { self.javaScript = javaScript }
|
||||
|
||||
public func execute(
|
||||
_ prepared: PreparedRequest,
|
||||
source: BookSource,
|
||||
transport: any HTTPTransport,
|
||||
timeout: TimeInterval,
|
||||
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
|
||||
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil,
|
||||
snapshotStage: DifferentialStage = .debug
|
||||
) async throws -> HTTPResponse {
|
||||
try await acquire(source: source)
|
||||
if prepared.url.scheme?.lowercased() == "data" {
|
||||
do {
|
||||
let response = try dataURIResponse(for: prepared)
|
||||
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
|
||||
await snapshotRecorder?.record(
|
||||
stage: snapshotStage,
|
||||
sourceID: source.id,
|
||||
prepared: prepared,
|
||||
rawResponse: response,
|
||||
response: transformed,
|
||||
timeout: timeout
|
||||
)
|
||||
return transformed
|
||||
} catch {
|
||||
await snapshotRecorder?.recordFailure(
|
||||
stage: snapshotStage,
|
||||
sourceID: source.id,
|
||||
prepared: prepared,
|
||||
response: nil,
|
||||
error: error,
|
||||
timeout: timeout
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if prepared.useWebView || prepared.webJavaScript != nil {
|
||||
guard let executor = webViewExecutor ?? SourceWebViewExecutorRegistry.shared.executor() else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "后台 WKWebView 请求")
|
||||
}
|
||||
return try await executeWebView(
|
||||
prepared,
|
||||
source: source,
|
||||
executor: executor,
|
||||
timeout: timeout,
|
||||
snapshotRecorder: snapshotRecorder,
|
||||
snapshotStage: snapshotStage
|
||||
)
|
||||
}
|
||||
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
|
||||
if source.enabledCookieJar == true {
|
||||
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
|
||||
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
let merged = mergeCookieHeaders(stored, explicit)
|
||||
if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") }
|
||||
}
|
||||
|
||||
let retryCount = max(prepared.retry, 0)
|
||||
let attempts = retryCount == Int.max ? Int.max : retryCount + 1
|
||||
var lastError: Error = HTTPClientError.nonHTTPResponse
|
||||
for attempt in 0..<attempts {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
var response: HTTPResponse
|
||||
if let proxy = prepared.proxy {
|
||||
guard let proxyTransport = transport as? any ProxyHTTPTransport else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "当前传输层不支持代理")
|
||||
}
|
||||
response = try await proxyTransport.data(for: request, proxy: proxy)
|
||||
} else {
|
||||
response = try await transport.data(for: request)
|
||||
}
|
||||
response.requestedCharset = prepared.charset
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw HTTPClientError.statusResponse(response)
|
||||
}
|
||||
if source.enabledCookieJar == true { captureCookies(response: response, sourceID: source.id) }
|
||||
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
|
||||
await snapshotRecorder?.record(stage: snapshotStage, sourceID: source.id, prepared: prepared, rawResponse: response, response: transformed, timeout: timeout)
|
||||
return transformed
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
lastError = error
|
||||
if case HTTPClientError.statusResponse(let response) = error,
|
||||
source.enabledCookieJar == true {
|
||||
captureCookies(response: response, sourceID: source.id)
|
||||
}
|
||||
guard case HTTPClientError.statusResponse = error, attempt + 1 < attempts else { break }
|
||||
}
|
||||
}
|
||||
let failedResponse: HTTPResponse? = if case HTTPClientError.statusResponse(let response) = lastError { response } else { nil }
|
||||
await snapshotRecorder?.recordFailure(stage: snapshotStage, sourceID: source.id, prepared: prepared, response: failedResponse, error: lastError, timeout: timeout)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
public func executeStream(
|
||||
_ prepared: PreparedRequest,
|
||||
source: BookSource,
|
||||
transport: any StreamingHTTPTransport,
|
||||
timeout: TimeInterval
|
||||
) async throws -> HTTPStreamResponse {
|
||||
guard prepared.url.scheme?.lowercased() != "data",
|
||||
!prepared.useWebView,
|
||||
prepared.webJavaScript == nil,
|
||||
prepared.bodyJavaScript == nil,
|
||||
prepared.responseType == nil else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式响应转换")
|
||||
}
|
||||
guard prepared.proxy == nil else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式代理请求")
|
||||
}
|
||||
try await acquire(source: source)
|
||||
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
|
||||
if source.enabledCookieJar == true {
|
||||
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
|
||||
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
let merged = mergeCookieHeaders(stored, explicit)
|
||||
if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") }
|
||||
}
|
||||
let response = try await transport.stream(for: request)
|
||||
if source.enabledCookieJar == true {
|
||||
captureCookies(
|
||||
response: .init(
|
||||
data: Data(),
|
||||
statusCode: response.statusCode,
|
||||
headers: response.headers,
|
||||
finalURL: response.finalURL
|
||||
),
|
||||
sourceID: source.id
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private func executeWebView(
|
||||
_ prepared: PreparedRequest,
|
||||
source: BookSource,
|
||||
executor: any SourceWebViewRequestExecuting,
|
||||
timeout: TimeInterval,
|
||||
snapshotRecorder: (any DifferentialSnapshotRecording)?,
|
||||
snapshotStage: DifferentialStage
|
||||
) async throws -> HTTPResponse {
|
||||
let started = Date()
|
||||
var webPrepared = prepared
|
||||
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
|
||||
if source.enabledCookieJar == true {
|
||||
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
|
||||
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
let merged = mergeCookieHeaders(stored, explicit)
|
||||
if !merged.isEmpty {
|
||||
request.setValue(merged, forHTTPHeaderField: "Cookie")
|
||||
webPrepared.headers["Cookie"] = merged
|
||||
}
|
||||
}
|
||||
let retryCount = max(prepared.retry, 0)
|
||||
let attempts = retryCount == Int.max ? Int.max : retryCount + 1
|
||||
var lastError: Error = HTTPClientError.nonHTTPResponse
|
||||
for attempt in 0..<attempts {
|
||||
do {
|
||||
var response = try await executor.execute(webPrepared, source: source, timeout: timeout)
|
||||
response.requestedCharset = prepared.charset
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw HTTPClientError.statusResponse(response)
|
||||
}
|
||||
if source.enabledCookieJar == true { captureCookies(response: response, sourceID: source.id) }
|
||||
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
|
||||
await snapshotRecorder?.record(stage: snapshotStage, sourceID: source.id, prepared: prepared, rawResponse: response, response: transformed, timeout: timeout)
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return transformed
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
lastError = error
|
||||
if case HTTPClientError.statusResponse(let response) = error,
|
||||
source.enabledCookieJar == true {
|
||||
captureCookies(response: response, sourceID: source.id)
|
||||
}
|
||||
guard case HTTPClientError.statusResponse = error, attempt + 1 < attempts else { break }
|
||||
}
|
||||
}
|
||||
let failedResponse: HTTPResponse? = if case HTTPClientError.statusResponse(let response) = lastError { response } else { nil }
|
||||
await snapshotRecorder?.recordFailure(stage: snapshotStage, sourceID: source.id, prepared: prepared, response: failedResponse, error: lastError, timeout: timeout)
|
||||
DiagnosticLog.shared.network(request, error: lastError, startedAt: started)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private nonisolated func transformBodyIfNeeded(
|
||||
_ original: HTTPResponse,
|
||||
prepared: PreparedRequest,
|
||||
source: BookSource
|
||||
) async throws -> HTTPResponse {
|
||||
var response = original
|
||||
response.requestVariables = prepared.variables
|
||||
if prepared.responseType != nil {
|
||||
response.data = Data(original.data.map { String(format: "%02x", $0) }.joined().utf8)
|
||||
response.requestedCharset = "utf-8"
|
||||
return response
|
||||
}
|
||||
guard let script = prepared.bodyJavaScript else { return response }
|
||||
let transformed = try await javaScript.evaluateAsync(
|
||||
script: script,
|
||||
result: .string(response.decodedText),
|
||||
executionContext: .init(
|
||||
variables: response.requestVariables,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
key: prepared.key,
|
||||
page: prepared.page,
|
||||
source: source,
|
||||
book: prepared.book,
|
||||
chapter: prepared.chapter
|
||||
)
|
||||
)
|
||||
response.data = Data(transformed.value.stringValue.utf8)
|
||||
response.requestedCharset = "utf-8"
|
||||
response.requestVariables = transformed.variables
|
||||
return response
|
||||
}
|
||||
|
||||
public func cookieHeader(sourceID: String, url: URL) -> String {
|
||||
let now = Date()
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let values = (cookies[sourceID] ?? []).filter { cookie in
|
||||
(cookie.expiresAt == nil || cookie.expiresAt! > now)
|
||||
&& domainMatches(host: host, domain: cookie.domain)
|
||||
&& path.hasPrefix(cookie.path)
|
||||
&& (!cookie.secure || url.scheme?.lowercased() == "https")
|
||||
}
|
||||
return values.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||
}
|
||||
|
||||
public func setCookieHeader(_ header: String, sourceID: String, url: URL) {
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
upsert(.init(name: pair[0].trimmingCharacters(in: .whitespaces), value: pair[1], domain: url.host ?? "", path: "/", expiresAt: nil, secure: url.scheme == "https"), sourceID: sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
public func removeCookies(sourceID: String) { cookies[sourceID] = nil }
|
||||
|
||||
private func acquire(source: BookSource) async throws {
|
||||
guard let limit = SourceRateLimit(rawValue: source.concurrentRate) else { return }
|
||||
while true {
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
var window = windows[source.id] ?? .init()
|
||||
let wait = window.reserve(now: now, limit: limit)
|
||||
windows[source.id] = window
|
||||
guard wait > 0 else { return }
|
||||
try await Task.sleep(nanoseconds: UInt64(wait) * 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func captureCookies(response: HTTPResponse, sourceID: String) {
|
||||
guard let header = response.headers.first(where: { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame })?.value else { return }
|
||||
LegadoCookieStore.shared.setCookie(header, for: response.finalURL.absoluteString)
|
||||
for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: response.finalURL) {
|
||||
upsert(.init(name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expiresAt: cookie.expiresDate, secure: cookie.isSecure), sourceID: sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
private func dataURIResponse(for prepared: PreparedRequest) throws -> HTTPResponse {
|
||||
let value = prepared.url.absoluteString
|
||||
guard value.lowercased().hasPrefix("data:"),
|
||||
let comma = value.firstIndex(of: ",") else {
|
||||
throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 无效")
|
||||
}
|
||||
let metadata = String(value[value.index(value.startIndex, offsetBy: 5)..<comma])
|
||||
let payload = String(value[value.index(after: comma)...])
|
||||
let isBase64 = metadata.split(separator: ";").contains { $0.caseInsensitiveCompare("base64") == .orderedSame }
|
||||
let data: Data?
|
||||
if isBase64 {
|
||||
data = Data(base64Encoded: payload.removingPercentEncoding ?? payload, options: .ignoreUnknownCharacters)
|
||||
} else {
|
||||
data = (payload.removingPercentEncoding ?? payload).data(using: .utf8)
|
||||
}
|
||||
guard let data else {
|
||||
throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 内容无法解码")
|
||||
}
|
||||
let mediaType = metadata.split(separator: ";", maxSplits: 1).first.map(String.init)
|
||||
let headers = mediaType.flatMap { $0.isEmpty ? nil : ["Content-Type": $0] } ?? [:]
|
||||
return HTTPResponse(data: data, statusCode: 200, headers: headers, finalURL: prepared.url)
|
||||
}
|
||||
|
||||
private func upsert(_ cookie: StoredCookie, sourceID: String) {
|
||||
var values = cookies[sourceID] ?? []
|
||||
values.removeAll { $0.name == cookie.name && $0.domain == cookie.domain && $0.path == cookie.path }
|
||||
values.append(cookie)
|
||||
cookies[sourceID] = values
|
||||
}
|
||||
|
||||
private func domainMatches(host: String, domain: String) -> Bool {
|
||||
let normalized = domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
return host == normalized || host.hasSuffix(".\(normalized)")
|
||||
}
|
||||
|
||||
private func mergeCookieHeaders(_ headers: String...) -> String {
|
||||
var values: [String: String] = [:]
|
||||
var order: [String] = []
|
||||
for header in headers {
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
let name = pair[0].trimmingCharacters(in: .whitespaces)
|
||||
if values[name] == nil { order.append(name) }
|
||||
values[name] = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
public enum HostsHTTPTransport {
|
||||
public static func data(for request: URLRequest, address: String, ignoreCertificateErrors: Bool) async throws -> HTTPResponse {
|
||||
let operation = try HostsRequestOperation(request: request, address: address, ignoreCertificateErrors: ignoreCertificateErrors)
|
||||
return try await withTaskCancellationHandler {
|
||||
try await operation.run()
|
||||
} onCancel: { operation.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private final class HostsRequestOperation: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "legado.hosts.\(UUID().uuidString)")
|
||||
private let connection: NWConnection
|
||||
private let request: URLRequest
|
||||
private var continuation: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var buffer = Data()
|
||||
private var finished = false
|
||||
private var cancelled = false
|
||||
|
||||
init(request: URLRequest, address: String, ignoreCertificateErrors: Bool) throws {
|
||||
guard let url = request.url, let host = url.host,
|
||||
["http", "https"].contains(url.scheme?.lowercased() ?? ""),
|
||||
let port = NWEndpoint.Port(rawValue: UInt16(exactly: url.port ?? (url.scheme == "https" ? 443 : 80)) ?? 0),
|
||||
port.rawValue > 0 else { throw URLError(.badURL) }
|
||||
self.request = request
|
||||
let parameters: NWParameters
|
||||
if url.scheme?.lowercased() == "https" {
|
||||
let tls = NWProtocolTLS.Options()
|
||||
sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host)
|
||||
sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1")
|
||||
if ignoreCertificateErrors {
|
||||
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { _, _, complete in complete(true) }, queue)
|
||||
}
|
||||
parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
} else { parameters = .tcp }
|
||||
connection = NWConnection(host: NWEndpoint.Host(address), port: port, using: parameters)
|
||||
}
|
||||
|
||||
func run() async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
self.continuation = continuation
|
||||
if self.cancelled { self.finish(.failure(CancellationError())); return }
|
||||
self.connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self, !self.finished else { return }
|
||||
switch state {
|
||||
case .ready: self.send()
|
||||
case .failed(let error): self.finish(.failure(error))
|
||||
case .cancelled: self.finish(.failure(CancellationError()))
|
||||
default: break
|
||||
}
|
||||
}
|
||||
self.connection.start(queue: self.queue)
|
||||
self.queue.asyncAfter(deadline: .now() + max(1, self.request.timeoutInterval)) { [weak self] in
|
||||
self?.finish(.failure(URLError(.timedOut)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
queue.async {
|
||||
self.cancelled = true
|
||||
if self.continuation != nil { self.finish(.failure(CancellationError())) }
|
||||
}
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let url = request.url, let host = url.host,
|
||||
let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
finish(.failure(URLError(.badURL))); return
|
||||
}
|
||||
let path = (parts.percentEncodedPath.isEmpty ? "/" : parts.percentEncodedPath)
|
||||
+ (parts.percentEncodedQuery.map { "?\($0)" } ?? "")
|
||||
var headers = request.allHTTPHeaderFields ?? [:]
|
||||
for name in headers.keys.filter({ ["host", "connection", "accept-encoding", "content-length", "transfer-encoding"].contains($0.lowercased()) }) {
|
||||
headers.removeValue(forKey: name)
|
||||
}
|
||||
let hostField = host.contains(":") ? "[\(host)]" : host
|
||||
headers["Host"] = hostField + (url.port.map { ":\($0)" } ?? "")
|
||||
headers["Connection"] = "close"
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
let body = request.httpBody ?? Data()
|
||||
if !body.isEmpty || request.httpMethod == "POST" || request.httpMethod == "PUT" {
|
||||
headers["Content-Length"] = String(body.count)
|
||||
}
|
||||
if request.httpShouldHandleCookies, headers.keys.contains(where: { $0.lowercased() == "cookie" }) == false {
|
||||
headers.merge(HTTPCookie.requestHeaderFields(with: HTTPCookieStorage.shared.cookies(for: url) ?? [])) { explicit, _ in explicit }
|
||||
}
|
||||
let method = request.httpMethod ?? "GET"
|
||||
guard !method.contains(where: { $0.isWhitespace }), !path.contains(where: { $0.isNewline }),
|
||||
headers.allSatisfy({ !$0.key.contains(where: { $0.isNewline }) && !$0.value.contains(where: { $0.isNewline }) }) else {
|
||||
finish(.failure(URLError(.badURL))); return
|
||||
}
|
||||
let head = "\(method) \(path) HTTP/1.1\r\n"
|
||||
+ headers.map { "\($0.key): \($0.value)\r\n" }.joined() + "\r\n"
|
||||
var data = Data(head.utf8)
|
||||
data.append(body)
|
||||
connection.send(content: data, completion: .contentProcessed { [weak self] error in
|
||||
if let error { self?.finish(.failure(error)) } else { self?.receive() }
|
||||
})
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, complete, error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error { self.finish(.failure(error)); return }
|
||||
if let data { self.buffer.append(data) }
|
||||
do {
|
||||
if let response = try HostsHTTPResponseDecoder.decode(self.buffer, url: self.request.url!, method: self.request.httpMethod ?? "GET", complete: complete) {
|
||||
if self.request.httpShouldHandleCookies {
|
||||
let cookies = HTTPCookie.cookies(withResponseHeaderFields: response.headers, for: response.finalURL)
|
||||
cookies.forEach { HTTPCookieStorage.shared.setCookie($0) }
|
||||
}
|
||||
self.finish(.success(response))
|
||||
} else if complete { self.finish(.failure(URLError(.badServerResponse))) }
|
||||
else { self.receive() }
|
||||
} catch { self.finish(.failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
connection.stateUpdateHandler = nil
|
||||
connection.cancel()
|
||||
continuation?.resume(with: result)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum HostsHTTPResponseDecoder {
|
||||
public static func decode(_ data: Data, url: URL, method: String = "GET", complete: Bool = false) throws -> HTTPResponse? {
|
||||
guard data.count <= 64 * 1024 * 1024 else { throw URLError(.dataLengthExceedsMaximum) }
|
||||
guard let boundary = data.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
if data.count > 64 * 1024 { throw URLError(.badServerResponse) }
|
||||
return nil
|
||||
}
|
||||
guard boundary.lowerBound <= 64 * 1024 else { throw URLError(.badServerResponse) }
|
||||
let lines = String(decoding: data[..<boundary.lowerBound], as: UTF8.self).components(separatedBy: "\r\n")
|
||||
guard let statusLine = lines.first, statusLine.hasPrefix("HTTP/1."),
|
||||
let status = Int(statusLine.split(separator: " ").dropFirst().first ?? "") else { throw URLError(.badServerResponse) }
|
||||
let remaining = Data(data[boundary.upperBound...])
|
||||
if (100...199).contains(status) && status != 101 {
|
||||
return try decode(remaining, url: url, method: method, complete: complete)
|
||||
}
|
||||
var headers: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colon = line.firstIndex(of: ":") else { throw URLError(.badServerResponse) }
|
||||
let name = String(line[..<colon]).lowercased()
|
||||
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
||||
}
|
||||
let body: Data
|
||||
if method == "HEAD" || status == 204 || status == 304 { body = Data() }
|
||||
else if headers["transfer-encoding"]?.lowercased() == "chunked" {
|
||||
guard let decoded = try decodeChunks(remaining) else { return nil }
|
||||
body = decoded
|
||||
} else if let rawLength = headers["content-length"] {
|
||||
guard let length = Int(rawLength), length >= 0, length <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) }
|
||||
guard remaining.count >= length else { return nil }
|
||||
body = Data(remaining.prefix(length))
|
||||
} else {
|
||||
guard complete else { return nil }
|
||||
body = remaining
|
||||
}
|
||||
guard headers["content-encoding"] == nil || headers["content-encoding"] == "identity" || body.isEmpty else {
|
||||
throw SettingsError.invalid("Hosts 请求收到不支持的压缩响应,请移除此域名的 Hosts 覆盖后重试。")
|
||||
}
|
||||
return .init(data: body, statusCode: status, headers: headers, finalURL: url)
|
||||
}
|
||||
|
||||
private static func decodeChunks(_ data: Data) throws -> Data? {
|
||||
var offset = data.startIndex
|
||||
var body = Data()
|
||||
let newline = Data("\r\n".utf8)
|
||||
while offset < data.endIndex {
|
||||
guard let line = data.range(of: newline, in: offset..<data.endIndex) else { return nil }
|
||||
let value = String(decoding: data[offset..<line.lowerBound], as: UTF8.self).components(separatedBy: ";")[0]
|
||||
guard let count = Int(value.trimmingCharacters(in: .whitespaces), radix: 16), count >= 0,
|
||||
count <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) }
|
||||
offset = line.upperBound
|
||||
if count == 0 {
|
||||
if data[offset...].starts(with: newline) || data.range(of: Data("\r\n\r\n".utf8), in: offset..<data.endIndex) != nil { return body }
|
||||
return nil
|
||||
}
|
||||
guard data.endIndex - offset >= count + 2 else { return nil }
|
||||
body.append(data[offset..<(offset + count)])
|
||||
offset += count
|
||||
guard data[offset..<(offset + 2)] == newline else { throw URLError(.badServerResponse) }
|
||||
offset += 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import Foundation
|
||||
|
||||
public enum HTTPMethod: String, Codable, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case head = "HEAD"
|
||||
}
|
||||
|
||||
public struct URLTemplateContext: Sendable {
|
||||
public var key: String?
|
||||
public var page: Int?
|
||||
public var variables: [String: String]
|
||||
public var source: BookSource?
|
||||
public var book: Book?
|
||||
public var chapter: BookChapter?
|
||||
|
||||
public init(
|
||||
key: String? = nil,
|
||||
page: Int? = nil,
|
||||
variables: [String: String] = [:],
|
||||
source: BookSource? = nil,
|
||||
book: Book? = nil,
|
||||
chapter: BookChapter? = nil
|
||||
) {
|
||||
self.key = key
|
||||
self.page = page
|
||||
self.variables = variables
|
||||
self.source = source
|
||||
self.book = book
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
fileprivate func value(for expression: String) -> String? {
|
||||
switch expression.trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||
case "key", "searchKey": key
|
||||
case "page", "searchPage": page.map(String.init)
|
||||
default: variables[expression.trimmingCharacters(in: .whitespacesAndNewlines)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct PreparedRequest: Sendable {
|
||||
public var suppressesUserAgent: Bool
|
||||
public var ruleURL: String
|
||||
public var url: URL
|
||||
public var method: HTTPMethod
|
||||
public var headers: [String: String]
|
||||
public var body: Data?
|
||||
public var charset: String?
|
||||
public var retry: Int
|
||||
public var useWebView: Bool
|
||||
public var webJavaScript: String?
|
||||
public var bodyJavaScript: String?
|
||||
public var webViewDelayMilliseconds: Int
|
||||
public var responseType: String?
|
||||
public var proxy: String?
|
||||
public var dnsIP: String?
|
||||
public var serverID: Int64?
|
||||
public var variables: [String: String]
|
||||
public var key: String?
|
||||
public var page: Int?
|
||||
public var book: Book?
|
||||
public var chapter: BookChapter?
|
||||
|
||||
public init(
|
||||
ruleURL: String,
|
||||
url: URL,
|
||||
method: HTTPMethod,
|
||||
headers: [String: String],
|
||||
body: Data?,
|
||||
charset: String?,
|
||||
retry: Int,
|
||||
useWebView: Bool,
|
||||
webJavaScript: String?,
|
||||
bodyJavaScript: String?,
|
||||
webViewDelayMilliseconds: Int = 0,
|
||||
responseType: String? = nil,
|
||||
proxy: String? = nil,
|
||||
dnsIP: String? = nil,
|
||||
serverID: Int64? = nil,
|
||||
variables: [String: String] = [:],
|
||||
key: String? = nil,
|
||||
page: Int? = nil,
|
||||
book: Book? = nil,
|
||||
chapter: BookChapter? = nil,
|
||||
suppressesUserAgent: Bool = false
|
||||
) {
|
||||
self.suppressesUserAgent = suppressesUserAgent
|
||||
self.ruleURL = ruleURL
|
||||
self.url = url
|
||||
self.method = method
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
self.charset = charset
|
||||
self.retry = retry
|
||||
self.useWebView = useWebView
|
||||
self.webJavaScript = webJavaScript
|
||||
self.bodyJavaScript = bodyJavaScript
|
||||
self.webViewDelayMilliseconds = webViewDelayMilliseconds
|
||||
self.responseType = responseType
|
||||
self.proxy = proxy
|
||||
self.dnsIP = dnsIP
|
||||
self.serverID = serverID
|
||||
self.variables = variables
|
||||
self.key = key
|
||||
self.page = page
|
||||
self.book = book
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
public func urlRequest(timeout: TimeInterval = 30, handleCookies: Bool = true) -> URLRequest {
|
||||
let override = ApplicationSettingsStore.shared.settings.timeoutSeconds
|
||||
var request = URLRequest(url: url, timeoutInterval: override > 0 ? Double(override) : timeout)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = method == .post ? body : nil
|
||||
request.httpShouldHandleCookies = handleCookies
|
||||
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
|
||||
if suppressesUserAgent {
|
||||
let mutable = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
|
||||
URLProtocol.setProperty(true, forKey: "LegadoSuppressUserAgent", in: mutable)
|
||||
return mutable as URLRequest
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
public struct LegadoURLBuilder: Sendable {
|
||||
public static let defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
|
||||
public init() {}
|
||||
|
||||
public static func resolve(_ value: String, against baseURL: URL) -> String {
|
||||
let value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.lowercased().hasPrefix("javascript:") { return "" }
|
||||
guard let pattern = try? NSRegularExpression(pattern: #",\s*(?=\{)"#),
|
||||
let match = pattern.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
|
||||
let range = Range(match.range, in: value) else {
|
||||
return URL(string: value, relativeTo: baseURL)?.absoluteURL.absoluteString ?? value
|
||||
}
|
||||
let address = String(value[..<range.lowerBound])
|
||||
let options = String(value[range.lowerBound...])
|
||||
return (URL(string: address, relativeTo: baseURL)?.absoluteURL.absoluteString ?? address) + options
|
||||
}
|
||||
|
||||
public static func decodeLenientJSON(_ source: String, quoteUnquotedValues: Bool = false) throws -> JSONValue {
|
||||
try JSONValue.decode(data: Data(lenientJSON(source, quoteUnquotedValues: quoteUnquotedValues).utf8))
|
||||
}
|
||||
|
||||
public func prepare(
|
||||
_ template: String,
|
||||
context: URLTemplateContext = .init(),
|
||||
baseURL: URL? = nil,
|
||||
defaultHeaders: [String: String] = [:]
|
||||
) throws -> PreparedRequest {
|
||||
let renderedResult = try renderResult(template, context: context, baseURL: baseURL)
|
||||
let rendered = renderedResult.text
|
||||
let parsed = try parseOptions(rendered, rule: template)
|
||||
let options = parsed.options
|
||||
var urlText = parsed.url
|
||||
var variables = renderedResult.variables
|
||||
if let script = options.javaScript {
|
||||
let evaluated = try evaluateJavaScript(
|
||||
script,
|
||||
result: .string(urlText),
|
||||
context: context,
|
||||
variables: variables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
urlText = evaluated.value.stringValue
|
||||
variables = evaluated.variables
|
||||
}
|
||||
let charsetEncoding = options.charset.flatMap(HTTPTextDecoder.encoding(named:))
|
||||
let requestURLText = encodeQueryIfNeeded(urlText, encoding: charsetEncoding)
|
||||
var urlAllowed = CharacterSet.urlFragmentAllowed
|
||||
urlAllowed.insert(charactersIn: "%")
|
||||
let encoded = requestURLText.addingPercentEncoding(withAllowedCharacters: urlAllowed) ?? requestURLText
|
||||
guard let url = URL(string: encoded, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw RuleEngineError.malformedRule(rule: template, detail: "URL 无法解析:\(urlText)")
|
||||
}
|
||||
var headers = defaultHeaders
|
||||
let proxyKey = headers.keys.first { $0.caseInsensitiveCompare("proxy") == .orderedSame }
|
||||
let proxy = proxyKey.flatMap { headers.removeValue(forKey: $0) }
|
||||
for (key, value) in options.headers {
|
||||
for existing in Array(headers.keys) where existing.caseInsensitiveCompare(key) == .orderedSame {
|
||||
headers.removeValue(forKey: existing)
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
let userAgentKey = headers.keys.first { $0.caseInsensitiveCompare("User-Agent") == .orderedSame }
|
||||
let suppressesUserAgent = userAgentKey.flatMap { headers[$0] } == "null"
|
||||
if suppressesUserAgent, let userAgentKey {
|
||||
headers.removeValue(forKey: userAgentKey)
|
||||
}
|
||||
if !suppressesUserAgent,
|
||||
headers.first(where: { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }) == nil {
|
||||
headers["User-Agent"] = ApplicationSettingsStore.shared.settings.effectiveUserAgent
|
||||
}
|
||||
let bodyText = options.body?.stringValue
|
||||
let trimmedBody = bodyText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let isStructuredBody = trimmedBody.hasPrefix("{") || trimmedBody.hasPrefix("[") || trimmedBody.hasPrefix("<")
|
||||
let hasExplicitContentType = headers.keys.contains { $0.caseInsensitiveCompare("Content-Type") == .orderedSame }
|
||||
let encodedBodyText = if let bodyText, !isStructuredBody, !hasExplicitContentType {
|
||||
percentEncode(bodyText, encoding: charsetEncoding ?? .utf8, preserving: Self.formDelimiters, spaceAsPlus: true)
|
||||
} else {
|
||||
bodyText
|
||||
}
|
||||
let body = options.method == .post ? (encodedBodyText?.data(using: charsetEncoding ?? .utf8) ?? Data()) : nil
|
||||
if options.method == .post, !hasExplicitContentType {
|
||||
let charsetName = options.charset ?? "utf-8"
|
||||
if trimmedBody.hasPrefix("<") {
|
||||
headers["Content-Type"] = "application/xml; charset=\(charsetName)"
|
||||
} else if isStructuredBody {
|
||||
headers["Content-Type"] = "application/json; charset=\(charsetName)"
|
||||
} else {
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=\(charsetName)"
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
ruleURL: template,
|
||||
url: url,
|
||||
method: options.method,
|
||||
headers: headers,
|
||||
body: body,
|
||||
charset: options.charset,
|
||||
retry: options.retry,
|
||||
useWebView: options.useWebView,
|
||||
webJavaScript: options.webJavaScript,
|
||||
bodyJavaScript: options.bodyJavaScript,
|
||||
webViewDelayMilliseconds: options.webViewDelayMilliseconds,
|
||||
responseType: options.responseType,
|
||||
proxy: proxy,
|
||||
dnsIP: options.dnsIP,
|
||||
serverID: options.serverID,
|
||||
variables: variables,
|
||||
key: context.key,
|
||||
page: context.page,
|
||||
book: context.book,
|
||||
chapter: context.chapter,
|
||||
suppressesUserAgent: suppressesUserAgent
|
||||
)
|
||||
}
|
||||
|
||||
private static let queryDelimiters: CharacterSet = {
|
||||
var value = CharacterSet.alphanumerics
|
||||
value.insert(charactersIn: "-._~!$&'()*+,;=:@/?%")
|
||||
return value
|
||||
}()
|
||||
private static let formDelimiters: CharacterSet = {
|
||||
var value = CharacterSet.alphanumerics
|
||||
value.insert(charactersIn: "-._~&=;%+")
|
||||
return value
|
||||
}()
|
||||
|
||||
private func encodeQueryIfNeeded(_ value: String, encoding: String.Encoding?) -> String {
|
||||
guard let encoding, encoding != .utf8, let separator = value.firstIndex(of: "?") else { return value }
|
||||
let queryStart = value.index(after: separator)
|
||||
return String(value[...separator])
|
||||
+ percentEncode(String(value[queryStart...]), encoding: encoding, preserving: Self.queryDelimiters)
|
||||
}
|
||||
|
||||
private func percentEncode(
|
||||
_ value: String,
|
||||
encoding: String.Encoding,
|
||||
preserving allowed: CharacterSet,
|
||||
spaceAsPlus: Bool = false
|
||||
) -> String {
|
||||
var output = ""
|
||||
for character in value {
|
||||
let text = String(character)
|
||||
if spaceAsPlus, text == " " {
|
||||
output += "+"
|
||||
continue
|
||||
}
|
||||
if text.unicodeScalars.allSatisfy({ $0.isASCII && allowed.contains($0) }) {
|
||||
output += text
|
||||
continue
|
||||
}
|
||||
let bytes = text.data(using: encoding) ?? Data(text.utf8)
|
||||
output += bytes.map { String(format: "%%%02X", $0) }.joined()
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
public func render(_ template: String, context: URLTemplateContext) throws -> String {
|
||||
try renderResult(template, context: context, baseURL: nil).text
|
||||
}
|
||||
|
||||
private func renderResult(
|
||||
_ template: String,
|
||||
context: URLTemplateContext,
|
||||
baseURL: URL?
|
||||
) throws -> (text: String, variables: [String: String]) {
|
||||
var variables = context.variables
|
||||
let segments = try evaluateJavaScriptSegments(
|
||||
template,
|
||||
context: context,
|
||||
variables: variables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
var result = segments.text
|
||||
variables = segments.variables
|
||||
let inline = try NSRegularExpression(pattern: #"\{\{([\s\S]*?)\}\}"#)
|
||||
let matches = inline.matches(in: result, range: NSRange(result.startIndex..., in: result)).reversed()
|
||||
for match in matches {
|
||||
guard let fullRange = Range(match.range(at: 0), in: result),
|
||||
let expressionRange = Range(match.range(at: 1), in: result) else { continue }
|
||||
let expression = String(result[expressionRange])
|
||||
let value: String
|
||||
if let literal = context.value(for: expression) {
|
||||
value = literal
|
||||
} else {
|
||||
let evaluated = try evaluateJavaScript(
|
||||
expression,
|
||||
result: .string(result),
|
||||
context: context,
|
||||
variables: variables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
value = evaluated.value.stringValue
|
||||
variables = evaluated.variables
|
||||
}
|
||||
result.replaceSubrange(fullRange, with: value)
|
||||
}
|
||||
if let key = context.key {
|
||||
result = result.replacingOccurrences(of: "searchKey", with: key)
|
||||
}
|
||||
if let page = context.page {
|
||||
result = result.replacingOccurrences(of: "searchPage", with: String(page))
|
||||
}
|
||||
if let page = context.page {
|
||||
let pageExpression = try NSRegularExpression(pattern: #"<([^<>]*?)>"#)
|
||||
let pageMatches = pageExpression.matches(
|
||||
in: result,
|
||||
range: NSRange(result.startIndex..., in: result)
|
||||
).reversed()
|
||||
for match in pageMatches {
|
||||
guard let fullRange = Range(match.range(at: 0), in: result),
|
||||
let listRange = Range(match.range(at: 1), in: result) else { continue }
|
||||
let choices = result[listRange].split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !choices.isEmpty else { continue }
|
||||
let selected = choices[min(max(page - 1, 0), choices.count - 1)]
|
||||
result.replaceSubrange(fullRange, with: selected)
|
||||
}
|
||||
}
|
||||
return (result, variables)
|
||||
}
|
||||
|
||||
private func evaluateJavaScriptSegments(
|
||||
_ template: String,
|
||||
context: URLTemplateContext,
|
||||
variables: [String: String],
|
||||
baseURL: URL?
|
||||
) throws -> (text: String, variables: [String: String]) {
|
||||
let expression = try NSRegularExpression(
|
||||
pattern: #"<js>([\s\S]*?)</js>|@js:([\s\S]*)"#,
|
||||
options: .caseInsensitive
|
||||
)
|
||||
let matches = expression.matches(in: template, range: NSRange(template.startIndex..., in: template))
|
||||
guard !matches.isEmpty else { return (template, variables) }
|
||||
var output = template
|
||||
var currentVariables = variables
|
||||
var cursor = template.startIndex
|
||||
for match in matches {
|
||||
guard let matchRange = Range(match.range(at: 0), in: template) else { continue }
|
||||
let prefix = String(template[cursor..<matchRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !prefix.isEmpty { output = prefix.replacingOccurrences(of: "@result", with: output) }
|
||||
let capture = match.range(at: 2).location != NSNotFound ? match.range(at: 2) : match.range(at: 1)
|
||||
guard let scriptRange = Range(capture, in: template) else { continue }
|
||||
let evaluated = try evaluateJavaScript(
|
||||
String(template[scriptRange]),
|
||||
result: .string(output),
|
||||
context: context,
|
||||
variables: currentVariables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
output = evaluated.value.stringValue
|
||||
currentVariables = evaluated.variables
|
||||
cursor = matchRange.upperBound
|
||||
}
|
||||
let suffix = String(template[cursor...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !suffix.isEmpty { output = suffix.replacingOccurrences(of: "@result", with: output) }
|
||||
return (output, currentVariables)
|
||||
}
|
||||
|
||||
private func evaluateJavaScript(
|
||||
_ script: String,
|
||||
result: JSONValue,
|
||||
context: URLTemplateContext,
|
||||
variables: [String: String],
|
||||
baseURL: URL?
|
||||
) throws -> JavaScriptRuleResult {
|
||||
try JavaScriptRuleEvaluator().evaluate(
|
||||
script: script,
|
||||
result: result,
|
||||
executionContext: .init(
|
||||
variables: variables,
|
||||
baseURL: baseURL?.absoluteString ?? context.source?.bookSourceUrl ?? "",
|
||||
key: context.key,
|
||||
page: context.page,
|
||||
source: context.source,
|
||||
book: context.book,
|
||||
chapter: context.chapter
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func splitOptions(_ value: String) -> [(url: String, options: String)] {
|
||||
guard let expression = try? NSRegularExpression(pattern: #"\s*,\s*(?=\{)"#) else {
|
||||
return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")]
|
||||
}
|
||||
let matches = expression.matches(in: value, range: NSRange(value.startIndex..., in: value))
|
||||
guard !matches.isEmpty else {
|
||||
return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")]
|
||||
}
|
||||
return matches.reversed().compactMap { match in
|
||||
guard let range = Range(match.range, in: value) else { return nil }
|
||||
return (
|
||||
String(value[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
String(value[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseOptions(_ source: String, rule: String) throws -> (url: String, options: Options) {
|
||||
let candidates = splitOptions(source)
|
||||
var firstError: Error?
|
||||
for (index, candidate) in candidates.enumerated() {
|
||||
guard !candidate.options.isEmpty else {
|
||||
return (candidate.url, .init())
|
||||
}
|
||||
do {
|
||||
let value = try decodeOptions(candidate.options)
|
||||
guard case .object(let object) = value else {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "URL 参数必须是 JSON 对象")
|
||||
}
|
||||
return (candidate.url, try makeOptions(object, rule: rule))
|
||||
} catch {
|
||||
if index == 0 { firstError = error }
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw firstError ?? RuleEngineError.malformedRule(rule: rule, detail: "URL 参数 JSON 无效")
|
||||
}
|
||||
|
||||
private func decodeOptions(_ source: String) throws -> JSONValue {
|
||||
do {
|
||||
return try JSONValue.decode(data: Data(source.utf8))
|
||||
} catch {
|
||||
do {
|
||||
return try JSONValue.decode(data: Data(Self.lenientJSON(source).utf8))
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "URL 参数 JSON 无效:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeOptions(_ object: [String: JSONValue], rule: String) throws -> Options {
|
||||
var options = Options()
|
||||
if let method = object["method"]?.stringValue.uppercased() {
|
||||
options.method = HTTPMethod(rawValue: method) ?? .get
|
||||
}
|
||||
options.charset = object["charset"]?.stringValue.nilIfEmpty
|
||||
options.retry = Int(object["retry"]?.stringValue ?? "") ?? 0
|
||||
options.useWebView = object["webView"].map { value in
|
||||
switch value {
|
||||
case .bool(let enabled): enabled
|
||||
case .number(let number): number != 0
|
||||
case .string(let string): !string.isEmpty && string.lowercased() != "false"
|
||||
default: false
|
||||
}
|
||||
} ?? false
|
||||
options.webJavaScript = object["webJs"]?.stringValue.nilIfEmpty
|
||||
options.bodyJavaScript = object["bodyJs"]?.stringValue.nilIfEmpty
|
||||
options.javaScript = object["js"]?.stringValue.nilIfEmpty
|
||||
options.webViewDelayMilliseconds = min(max(Int(object["webViewDelayTime"]?.stringValue ?? "") ?? 0, 0), 30_000)
|
||||
options.responseType = object["type"]?.stringValue.nilIfEmpty
|
||||
options.dnsIP = object["dnsIp"]?.stringValue.nilIfEmpty
|
||||
options.serverID = Int64(object["serverID"]?.stringValue ?? "")
|
||||
options.body = object["body"]
|
||||
if let headerValue = object["headers"] {
|
||||
options.headers = try parseHeaders(headerValue, rule: rule)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
private static func lenientJSON(_ source: String, quoteUnquotedValues: Bool = false) -> String {
|
||||
let characters = Array(source)
|
||||
var output = ""
|
||||
var index = 0
|
||||
var expectingKey = true
|
||||
|
||||
func peek(_ offset: Int = 0) -> Character? {
|
||||
let position = index + offset
|
||||
return position < characters.count ? characters[position] : nil
|
||||
}
|
||||
|
||||
while index < characters.count {
|
||||
let character = characters[index]
|
||||
index += 1
|
||||
switch character {
|
||||
case "\"":
|
||||
output.append("\"")
|
||||
while index < characters.count {
|
||||
let inner = characters[index]
|
||||
index += 1
|
||||
output.append(inner)
|
||||
if inner == "\\", index < characters.count {
|
||||
output.append(characters[index])
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if inner == "\"" { break }
|
||||
}
|
||||
expectingKey = false
|
||||
case "'":
|
||||
var text = ""
|
||||
while index < characters.count {
|
||||
let inner = characters[index]
|
||||
index += 1
|
||||
if inner == "\\", index < characters.count {
|
||||
let escaped = characters[index]
|
||||
index += 1
|
||||
text.append(escaped == "'" ? "'" : "\\\(escaped)")
|
||||
continue
|
||||
}
|
||||
if inner == "'" { break }
|
||||
text.append(inner)
|
||||
}
|
||||
output.append(escapedJSONString(text))
|
||||
expectingKey = false
|
||||
case "/":
|
||||
if peek() == "/" {
|
||||
index += 1
|
||||
while let value = peek(), value != "\n" { index += 1 }
|
||||
} else if peek() == "*" {
|
||||
index += 1
|
||||
while let value = peek() {
|
||||
if value == "*", peek(1) == "/" {
|
||||
index += 2
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
} else {
|
||||
output.append(character)
|
||||
}
|
||||
case "{", ",":
|
||||
output.append(character)
|
||||
expectingKey = true
|
||||
case ":":
|
||||
output.append(character)
|
||||
expectingKey = false
|
||||
if quoteUnquotedValues {
|
||||
var start = index
|
||||
while start < characters.count, characters[start].isWhitespace { start += 1 }
|
||||
if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) {
|
||||
var end = start
|
||||
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
|
||||
output.append(escapedJSONString(String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)))
|
||||
index = end
|
||||
}
|
||||
}
|
||||
case "}", "]":
|
||||
while output.last == "," { output.removeLast() }
|
||||
output.append(character)
|
||||
expectingKey = false
|
||||
default:
|
||||
output.append(character)
|
||||
if expectingKey, character.isLetter || character == "_" || character == "$" {
|
||||
var name = String(character)
|
||||
var position = index
|
||||
while position < characters.count {
|
||||
let inner = characters[position]
|
||||
guard inner.isLetter || inner.isNumber || inner == "_" || inner == "$" || inner == "-" else { break }
|
||||
name.append(inner)
|
||||
position += 1
|
||||
}
|
||||
var peekPosition = position
|
||||
while peekPosition < characters.count, characters[peekPosition].isWhitespace {
|
||||
peekPosition += 1
|
||||
}
|
||||
if peekPosition < characters.count, characters[peekPosition] == ":" {
|
||||
output.removeLast()
|
||||
output.append(escapedJSONString(name))
|
||||
index = position
|
||||
}
|
||||
}
|
||||
if !character.isWhitespace { expectingKey = false }
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func escapedJSONString(_ value: String) -> String {
|
||||
var text = ""
|
||||
for character in value {
|
||||
switch character {
|
||||
case "\"": text.append("\\\"")
|
||||
case "\\": text.append("\\\\")
|
||||
case "\n": text.append("\\n")
|
||||
case "\r": text.append("\\r")
|
||||
case "\t": text.append("\\t")
|
||||
default: text.append(character)
|
||||
}
|
||||
}
|
||||
return "\"\(text)\""
|
||||
}
|
||||
|
||||
private func parseHeaders(_ value: JSONValue, rule: String) throws -> [String: String] {
|
||||
let parsed: JSONValue
|
||||
if case .string(let source) = value {
|
||||
do {
|
||||
parsed = try JSONValue.decode(data: Data(source.utf8))
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
|
||||
}
|
||||
} else {
|
||||
parsed = value
|
||||
}
|
||||
guard case .object(let object) = parsed else {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "headers 必须是对象")
|
||||
}
|
||||
return object.mapValues(\.stringValue)
|
||||
}
|
||||
|
||||
private struct Options {
|
||||
var method: HTTPMethod = .get
|
||||
var charset: String?
|
||||
var headers: [String: String] = [:]
|
||||
var body: JSONValue?
|
||||
var retry = 0
|
||||
var useWebView = false
|
||||
var webJavaScript: String?
|
||||
var bodyJavaScript: String?
|
||||
var javaScript: String?
|
||||
var webViewDelayMilliseconds = 0
|
||||
var responseType: String?
|
||||
var dnsIP: String?
|
||||
var serverID: Int64?
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public protocol BookChapterRepository: Sendable {
|
||||
func load(bookURL: String) async throws -> [BookChapter]
|
||||
func save(_ chapters: [BookChapter], bookURL: String) async throws
|
||||
func remove(bookURL: String) async throws
|
||||
}
|
||||
|
||||
public actor FileBookChapterRepository: BookChapterRepository {
|
||||
public static let maximumDocumentBytes = 24 * 1_024 * 1_024
|
||||
private let directory: URL
|
||||
|
||||
public init(directory: URL) { self.directory = directory }
|
||||
|
||||
public static func live() -> FileBookChapterRepository {
|
||||
let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/Chapters", isDirectory: true)
|
||||
return FileBookChapterRepository(directory: directory)
|
||||
}
|
||||
|
||||
public func load(bookURL: String) throws -> [BookChapter] {
|
||||
let url = fileURL(bookURL)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
guard data.count <= Self.maximumDocumentBytes else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return []
|
||||
}
|
||||
return try JSONDecoder().decode([BookChapter].self, from: data).sorted { $0.index < $1.index }
|
||||
}
|
||||
|
||||
public func save(_ chapters: [BookChapter], bookURL: String) throws {
|
||||
var normalized = chapters
|
||||
for index in normalized.indices {
|
||||
normalized[index].bookUrl = bookURL
|
||||
normalized[index].index = index
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(normalized)
|
||||
guard data.count <= Self.maximumDocumentBytes else {
|
||||
throw CocoaError(.fileWriteOutOfSpace)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL(bookURL), options: .atomic)
|
||||
}
|
||||
|
||||
public func remove(bookURL: String) throws {
|
||||
let url = fileURL(bookURL)
|
||||
if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
private func fileURL(_ bookURL: String) -> URL {
|
||||
let name = SHA256.hash(data: Data(bookURL.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return directory.appendingPathComponent("\(name).json")
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookChapterRepository: BookChapterRepository {
|
||||
private var values: [String: [BookChapter]]
|
||||
public init(values: [String: [BookChapter]] = [:]) { self.values = values }
|
||||
public func load(bookURL: String) -> [BookChapter] { values[bookURL] ?? [] }
|
||||
public func save(_ chapters: [BookChapter], bookURL: String) { values[bookURL] = chapters }
|
||||
public func remove(bookURL: String) { values[bookURL] = nil }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
public protocol BookRepository: Sendable {
|
||||
func load() async throws -> [Book]
|
||||
func save(_ books: [Book]) async throws
|
||||
}
|
||||
|
||||
public actor FileBookRepository: BookRepository {
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public func load() throws -> [Book] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try decoder.decode([Book].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
|
||||
public func save(_ books: [Book]) throws {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try encoder.encode(books).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookRepository: BookRepository {
|
||||
private var books: [Book]
|
||||
|
||||
public init(books: [Book] = []) {
|
||||
self.books = books
|
||||
}
|
||||
|
||||
public func load() -> [Book] { books }
|
||||
|
||||
public func save(_ books: [Book]) {
|
||||
self.books = books
|
||||
}
|
||||
}
|
||||
|
||||
public protocol BookGroupRepository: Sendable {
|
||||
func load() async throws -> [BookGroup]
|
||||
func save(_ groups: [BookGroup]) async throws
|
||||
}
|
||||
|
||||
public actor FileBookGroupRepository: BookGroupRepository {
|
||||
private let fileURL: URL
|
||||
public init(fileURL: URL) { self.fileURL = fileURL }
|
||||
public func load() throws -> [BookGroup] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try JSONDecoder().decode([BookGroup].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
public func save(_ groups: [BookGroup]) throws {
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(groups).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookGroupRepository: BookGroupRepository {
|
||||
private var groups: [BookGroup]
|
||||
public init(groups: [BookGroup] = []) { self.groups = groups }
|
||||
public func load() -> [BookGroup] { groups }
|
||||
public func save(_ groups: [BookGroup]) { self.groups = groups }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public protocol BookSourceRepository: Sendable {
|
||||
func load() async throws -> [BookSource]
|
||||
func save(_ sources: [BookSource]) async throws
|
||||
}
|
||||
|
||||
public actor FileBookSourceRepository: BookSourceRepository {
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public func load() throws -> [BookSource] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try decoder.decode([BookSource].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
|
||||
public func save(_ sources: [BookSource]) throws {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let data = try encoder.encode(sources)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryBookSourceRepository: BookSourceRepository {
|
||||
private var sources: [BookSource]
|
||||
|
||||
public init(sources: [BookSource] = []) {
|
||||
self.sources = sources
|
||||
}
|
||||
|
||||
public func load() -> [BookSource] { sources }
|
||||
|
||||
public func save(_ sources: [BookSource]) {
|
||||
self.sources = sources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
public struct SearchKeyword: Codable, Hashable, Identifiable, Sendable {
|
||||
public var word: String
|
||||
public var usage: Int
|
||||
public var lastUseTime: Int64
|
||||
public var id: String { word }
|
||||
|
||||
public init(word: String, usage: Int = 1, lastUseTime: Int64? = nil) {
|
||||
self.word = word
|
||||
self.usage = usage
|
||||
self.lastUseTime = lastUseTime ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
public struct LegadoCache: Codable, Hashable, Identifiable, Sendable {
|
||||
public var key: String
|
||||
public var value: String?
|
||||
public var deadline: Int64
|
||||
public var id: String { key }
|
||||
|
||||
public init(key: String, value: String? = nil, deadline: Int64 = 0) {
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.deadline = deadline
|
||||
}
|
||||
}
|
||||
|
||||
public struct LegadoCookie: Codable, Hashable, Identifiable, Sendable {
|
||||
public var url: String
|
||||
public var cookie: String
|
||||
public var id: String { url }
|
||||
|
||||
public init(url: String, cookie: String = "") {
|
||||
self.url = url
|
||||
self.cookie = cookie
|
||||
}
|
||||
}
|
||||
|
||||
public struct TxtTocRule: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var rule: String
|
||||
public var replacement: String
|
||||
public var example: String?
|
||||
public var serialNumber: Int
|
||||
public var enable: Bool
|
||||
public init(
|
||||
id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
|
||||
name: String = "",
|
||||
rule: String = "",
|
||||
replacement: String = "",
|
||||
example: String? = nil,
|
||||
serialNumber: Int = -1,
|
||||
enable: Bool = true
|
||||
) {
|
||||
self.id = id; self.name = name; self.rule = rule; self.replacement = replacement
|
||||
self.example = example; self.serialNumber = serialNumber; self.enable = enable
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, rule, replacement, example, serialNumber, enable
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try value.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
rule = try value.decodeIfPresent(String.self, forKey: .rule) ?? ""
|
||||
replacement = try value.decodeIfPresent(String.self, forKey: .replacement) ?? ""
|
||||
example = try value.decodeIfPresent(String.self, forKey: .example)
|
||||
serialNumber = try value.decodeIfPresent(Int.self, forKey: .serialNumber) ?? -1
|
||||
enable = try value.decodeIfPresent(Bool.self, forKey: .enable) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
public struct HttpTTS: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var contentType: String?
|
||||
public var concurrentRate: String?
|
||||
public var loginUrl: String?
|
||||
public var loginUi: String?
|
||||
public var header: String?
|
||||
public var jsLib: String?
|
||||
public var enabledCookieJar: Bool?
|
||||
public var loginCheckJs: String?
|
||||
public var lastUpdateTime: Int64
|
||||
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
|
||||
self.id = id; self.name = name; self.url = url; contentType = nil; concurrentRate = "0"
|
||||
loginUrl = nil; loginUi = nil; header = nil; jsLib = nil; enabledCookieJar = false
|
||||
loginCheckJs = nil; lastUpdateTime = id
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
id = (try? values.decode(Int64.self, forKey: .id))
|
||||
?? Int64((try? values.decode(String.self, forKey: .id)) ?? "")
|
||||
?? now
|
||||
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
url = try values.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
contentType = try values.decodeIfPresent(String.self, forKey: .contentType)
|
||||
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
|
||||
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
|
||||
if let text = try? values.decodeIfPresent(String.self, forKey: .loginUi) {
|
||||
loginUi = text
|
||||
} else if let json = try? values.decodeIfPresent(JSONValue.self, forKey: .loginUi) {
|
||||
loginUi = (try? JSONEncoder().encode(json)).flatMap { String(data: $0, encoding: .utf8) }
|
||||
} else {
|
||||
loginUi = nil
|
||||
}
|
||||
header = try values.decodeIfPresent(String.self, forKey: .header)
|
||||
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
|
||||
enabledCookieJar = try values.decodeIfPresent(Bool.self, forKey: .enabledCookieJar) ?? false
|
||||
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
|
||||
lastUpdateTime = (try? values.decode(Int64.self, forKey: .lastUpdateTime))
|
||||
?? Int64((try? values.decode(String.self, forKey: .lastUpdateTime)) ?? "")
|
||||
?? now
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuleSub: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var type: Int
|
||||
public var customOrder: Int
|
||||
public var autoUpdate: Bool
|
||||
public var update: Int64
|
||||
public var updateInterval: Int
|
||||
public var silentUpdate: Bool
|
||||
public var js: String?
|
||||
public var showRule: String?
|
||||
public var sourceUrl: String?
|
||||
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
|
||||
self.id = id; self.name = name; self.url = url; type = 0; customOrder = 0
|
||||
autoUpdate = false; update = id; updateInterval = 0; silentUpdate = false
|
||||
js = nil; showRule = nil; sourceUrl = nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardAssist: Codable, Hashable, Identifiable, Sendable {
|
||||
public var type: Int
|
||||
public var key: String
|
||||
public var value: String
|
||||
public var serialNo: Int
|
||||
public var id: String { "\(type)\u{0}\(key)" }
|
||||
public init(type: Int = 0, key: String, value: String, serialNo: Int = 0) {
|
||||
self.type = type; self.key = key; self.value = value; self.serialNo = serialNo
|
||||
}
|
||||
}
|
||||
|
||||
public struct Server: Codable, Hashable, Identifiable, Sendable {
|
||||
public enum ServerType: String, Codable, Sendable { case webDAV = "WEBDAV" }
|
||||
public struct WebDAVConfig: Codable, Hashable, Sendable {
|
||||
public var url: String
|
||||
public var username: String
|
||||
public var password: String
|
||||
public init(url: String, username: String, password: String) {
|
||||
self.url = url; self.username = username; self.password = password
|
||||
}
|
||||
}
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var type: ServerType
|
||||
public var config: String?
|
||||
public var sortNumber: Int
|
||||
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", type: ServerType = .webDAV) {
|
||||
self.id = id; self.name = name; self.type = type; config = nil; sortNumber = 0
|
||||
}
|
||||
public var webDAVConfig: WebDAVConfig? {
|
||||
guard type == .webDAV, let config else { return nil }
|
||||
return try? JSONDecoder().decode(WebDAVConfig.self, from: Data(config.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
public enum SearchKeywordOrder: Sendable { case usage, lastUseTime }
|
||||
|
||||
public protocol SearchKeywordRepository: Sendable {
|
||||
func load(order: SearchKeywordOrder) async throws -> [SearchKeyword]
|
||||
@discardableResult func record(_ word: String, at time: Int64) async throws -> SearchKeyword
|
||||
func delete(_ word: String) async throws
|
||||
func deleteAll() async throws
|
||||
}
|
||||
|
||||
public actor SQLiteSearchKeywordRepository: SearchKeywordRepository {
|
||||
private let database: LegadoDatabase
|
||||
private let legacyWords: [String]
|
||||
private var migratedLegacy = false
|
||||
|
||||
public init(database: LegadoDatabase, legacyWords: [String] = []) {
|
||||
self.database = database
|
||||
self.legacyWords = legacyWords
|
||||
}
|
||||
|
||||
public func load(order: SearchKeywordOrder) async throws -> [SearchKeyword] {
|
||||
try await migrateLegacyIfNeeded()
|
||||
return try await database.loadSearchKeywords(order: order)
|
||||
}
|
||||
|
||||
public func record(_ word: String, at time: Int64) async throws -> SearchKeyword {
|
||||
try await migrateLegacyIfNeeded()
|
||||
return try await database.recordSearchKeyword(word, at: time)
|
||||
}
|
||||
|
||||
public func delete(_ word: String) async throws { try await database.deleteSearchKeyword(word) }
|
||||
public func deleteAll() async throws { try await database.deleteAllSearchKeywords() }
|
||||
|
||||
private func migrateLegacyIfNeeded() async throws {
|
||||
guard !migratedLegacy else { return }
|
||||
migratedLegacy = true
|
||||
try await database.importLegacySearchKeywords(legacyWords)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemorySearchKeywordRepository: SearchKeywordRepository {
|
||||
private var values: [String: SearchKeyword]
|
||||
public init(values: [SearchKeyword] = []) {
|
||||
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.word, $0) })
|
||||
}
|
||||
public func load(order: SearchKeywordOrder) -> [SearchKeyword] {
|
||||
values.values.sorted {
|
||||
switch order {
|
||||
case .usage:
|
||||
$0.usage == $1.usage ? $0.lastUseTime > $1.lastUseTime : $0.usage > $1.usage
|
||||
case .lastUseTime:
|
||||
$0.lastUseTime == $1.lastUseTime ? $0.usage > $1.usage : $0.lastUseTime > $1.lastUseTime
|
||||
}
|
||||
}
|
||||
}
|
||||
public func record(_ word: String, at time: Int64) -> SearchKeyword {
|
||||
var value = values[word] ?? .init(word: word, usage: 0, lastUseTime: time)
|
||||
value.usage += 1
|
||||
value.lastUseTime = time
|
||||
values[word] = value
|
||||
return value
|
||||
}
|
||||
public func delete(_ word: String) { values[word] = nil }
|
||||
public func deleteAll() { values.removeAll() }
|
||||
}
|
||||
|
||||
public protocol LegadoCacheRepository: Sendable {
|
||||
func value(for key: String, now: Int64) async throws -> String?
|
||||
func put(_ cache: LegadoCache) async throws
|
||||
func delete(_ key: String) async throws
|
||||
func deleteSourceVariables(_ sourceKey: String) async throws
|
||||
func clearExpired(now: Int64) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteLegadoCacheRepository: LegadoCacheRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func value(for key: String, now: Int64) async throws -> String? {
|
||||
try await database.cacheValue(for: key, now: now)
|
||||
}
|
||||
public func put(_ cache: LegadoCache) async throws { try await database.putCache(cache) }
|
||||
public func delete(_ key: String) async throws { try await database.deleteCache(key) }
|
||||
public func deleteSourceVariables(_ sourceKey: String) async throws {
|
||||
try await database.deleteSourceVariables(sourceKey)
|
||||
}
|
||||
public func clearExpired(now: Int64) async throws { try await database.clearExpiredCaches(now: now) }
|
||||
}
|
||||
|
||||
public protocol LegadoCookieRepository: Sendable {
|
||||
func get(_ url: String) async throws -> LegadoCookie?
|
||||
func getOkHTTP() async throws -> [LegadoCookie]
|
||||
func put(_ cookie: LegadoCookie) async throws
|
||||
func delete(_ url: String) async throws
|
||||
func deleteOkHTTP() async throws
|
||||
}
|
||||
|
||||
public protocol SearchBookRepository: Sendable {
|
||||
func cachedMatches(keyword: String) async throws -> [SearchBook]
|
||||
func get(bookURL: String) async throws -> SearchBook?
|
||||
func first(name: String, author: String) async throws -> SearchBook?
|
||||
func changeSources(name: String, author: String, sourceGroup: String, key: String?) async throws -> [SearchBook]
|
||||
func enabledWithCover(name: String, author: String) async throws -> [SearchBook]
|
||||
func put(_ books: [SearchBook]) async throws
|
||||
func clear(name: String, author: String) async throws
|
||||
func clearExpired(before time: Int64) async throws
|
||||
}
|
||||
|
||||
extension SearchBookRepository {
|
||||
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
||||
try await changeSources(name: keyword, author: "", sourceGroup: "", key: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ConfigurationRepository: Sendable {
|
||||
func loadTxtTocRules() async throws -> [TxtTocRule]
|
||||
func saveTxtTocRules(_ values: [TxtTocRule]) async throws
|
||||
func loadHttpTTS() async throws -> [HttpTTS]
|
||||
func saveHttpTTS(_ values: [HttpTTS]) async throws
|
||||
func loadRuleSubs() async throws -> [RuleSub]
|
||||
func saveRuleSubs(_ values: [RuleSub]) async throws
|
||||
func loadKeyboardAssists() async throws -> [KeyboardAssist]
|
||||
func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws
|
||||
func loadServers() async throws -> [Server]
|
||||
func saveServers(_ values: [Server]) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteConfigurationRepository: ConfigurationRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func loadTxtTocRules() async throws -> [TxtTocRule] { try await database.loadTxtTocRules() }
|
||||
public func saveTxtTocRules(_ values: [TxtTocRule]) async throws { try await database.replaceTxtTocRules(values) }
|
||||
public func loadHttpTTS() async throws -> [HttpTTS] { try await database.loadHttpTTS() }
|
||||
public func saveHttpTTS(_ values: [HttpTTS]) async throws { try await database.replaceHttpTTS(values) }
|
||||
public func loadRuleSubs() async throws -> [RuleSub] { try await database.loadRuleSubs() }
|
||||
public func saveRuleSubs(_ values: [RuleSub]) async throws { try await database.replaceRuleSubs(values) }
|
||||
public func loadKeyboardAssists() async throws -> [KeyboardAssist] { try await database.loadKeyboardAssists() }
|
||||
public func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws { try await database.replaceKeyboardAssists(values) }
|
||||
public func loadServers() async throws -> [Server] { try await database.loadServers() }
|
||||
public func saveServers(_ values: [Server]) async throws { try await database.replaceServers(values) }
|
||||
}
|
||||
|
||||
public actor SQLiteSearchBookRepository: SearchBookRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
|
||||
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
||||
try await database.cachedSearchMatches(keyword: keyword)
|
||||
}
|
||||
|
||||
public func get(bookURL: String) async throws -> SearchBook? {
|
||||
let books = try await database.loadSearchBooks()
|
||||
return books.first { $0.bookURL == bookURL }
|
||||
}
|
||||
|
||||
public func first(name: String, author: String) async throws -> SearchBook? {
|
||||
let books = try await database.loadSearchBooks()
|
||||
let sources = try await database.loadBookSources()
|
||||
let sourceIDs = Set(sources.map(\.bookSourceUrl))
|
||||
return books
|
||||
.filter { $0.name == name && $0.author == author }
|
||||
.filter { sourceIDs.contains($0.origin) }
|
||||
.sorted { $0.originOrder < $1.originOrder }
|
||||
.first
|
||||
}
|
||||
|
||||
public func changeSources(
|
||||
name: String,
|
||||
author: String,
|
||||
sourceGroup: String,
|
||||
key: String?
|
||||
) async throws -> [SearchBook] {
|
||||
let sources = try await database.loadBookSources()
|
||||
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
||||
let books = try await database.loadSearchBooks()
|
||||
return books.compactMap { book in
|
||||
guard book.name == name,
|
||||
book.author.localizedCaseInsensitiveContains(author),
|
||||
let source = sourceMap[book.origin], source.enabled,
|
||||
sourceGroup.isEmpty || (source.bookSourceGroup ?? "").contains(sourceGroup) else { return nil }
|
||||
if let key, !key.isEmpty,
|
||||
!book.originName.localizedCaseInsensitiveContains(key),
|
||||
!(book.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key) { return nil }
|
||||
var value = book
|
||||
value.originOrder = source.customOrder
|
||||
return value
|
||||
}.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
|
||||
public func enabledWithCover(name: String, author: String) async throws -> [SearchBook] {
|
||||
let sources = try await database.loadBookSources()
|
||||
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
||||
let books = try await database.loadSearchBooks()
|
||||
return books.compactMap { book in
|
||||
guard book.name == name, book.author == author,
|
||||
!(book.coverURL ?? "").isEmpty,
|
||||
let source = sourceMap[book.origin], source.enabled else { return nil }
|
||||
var value = book
|
||||
value.originOrder = source.customOrder
|
||||
return value
|
||||
}.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
|
||||
public func put(_ books: [SearchBook]) async throws { try await database.putSearchBooks(books) }
|
||||
public func clear(name: String, author: String) async throws {
|
||||
try await database.deleteSearchBooks(name: name, author: author)
|
||||
}
|
||||
public func clearExpired(before time: Int64) async throws {
|
||||
try await database.clearExpiredSearchBooks(before: time)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SQLiteLegadoCookieRepository: LegadoCookieRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func get(_ url: String) async throws -> LegadoCookie? { try await database.cookie(for: url) }
|
||||
public func getOkHTTP() async throws -> [LegadoCookie] { try await database.loadOkHTTPCookies() }
|
||||
public func put(_ cookie: LegadoCookie) async throws { try await database.putCookie(cookie) }
|
||||
public func delete(_ url: String) async throws { try await database.deleteCookie(url) }
|
||||
public func deleteOkHTTP() async throws { try await database.deleteOkHTTPCookies() }
|
||||
}
|
||||
|
||||
public enum SearchKeywordRepositoryFactory {
|
||||
public static func live(defaults: UserDefaults = .standard) -> any SearchKeywordRepository {
|
||||
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
|
||||
return InMemorySearchKeywordRepository()
|
||||
}
|
||||
return SQLiteSearchKeywordRepository(
|
||||
database: database,
|
||||
legacyWords: defaults.stringArray(forKey: "bookSearchHistory") ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum SearchBookRepositoryFactory {
|
||||
public static func live() -> any SearchBookRepository {
|
||||
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
|
||||
return InMemorySearchBookRepository()
|
||||
}
|
||||
return SQLiteSearchBookRepository(database: database)
|
||||
}
|
||||
}
|
||||
|
||||
public final class PersistentRuntimeCache: @unchecked Sendable {
|
||||
public static let shared = PersistentRuntimeCache()
|
||||
private let lock = NSLock()
|
||||
private var handle: OpaquePointer?
|
||||
|
||||
public init(url: URL = LegadoDatabase.defaultURL()) {
|
||||
let migrator = try? LegadoDatabase(url: url)
|
||||
_ = migrator
|
||||
sqlite3_open_v2(
|
||||
url.path,
|
||||
&handle,
|
||||
SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX,
|
||||
nil
|
||||
)
|
||||
if let handle { sqlite3_busy_timeout(handle, 5_000) }
|
||||
}
|
||||
|
||||
deinit { if let handle { sqlite3_close(handle) } }
|
||||
|
||||
public func get(_ key: String, now: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return nil }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
handle,
|
||||
"SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK, let statement else { return nil }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_bind_int64(statement, 2, now)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW,
|
||||
let value = sqlite3_column_text(statement, 0) else { return nil }
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
public func put(_ key: String, value: String, deadline: Int64 = 0) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
handle,
|
||||
"""
|
||||
INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?)
|
||||
ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline
|
||||
""",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
|
||||
sqlite3_bind_int64(statement, 3, deadline)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
public func cookie(for key: String) -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return nil }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(handle, "SELECT cookie FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
|
||||
let statement else { return nil }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW, let value = sqlite3_column_text(statement, 0) else { return nil }
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
public func putCookie(key: String, value: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
handle,
|
||||
"INSERT INTO cookies(url, cookie) VALUES(?, ?) ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
public func deleteCookie(key: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let handle else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(handle, "DELETE FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
|
||||
let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
}
|
||||
|
||||
public actor InMemorySearchBookRepository: SearchBookRepository {
|
||||
private var values: [String: SearchBook] = [:]
|
||||
public init(values: [SearchBook] = []) {
|
||||
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.bookURL, $0) })
|
||||
}
|
||||
public func get(bookURL: String) -> SearchBook? { values[bookURL] }
|
||||
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
||||
guard !keyword.isEmpty else { return [] }
|
||||
return Array(values.values.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(keyword) || $0.author.localizedCaseInsensitiveContains(keyword)
|
||||
}.sorted {
|
||||
let lhsExact = $0.name == keyword || $0.author == keyword
|
||||
let rhsExact = $1.name == keyword || $1.author == keyword
|
||||
if lhsExact != rhsExact { return lhsExact }
|
||||
return $0.time > $1.time
|
||||
}.prefix(200))
|
||||
}
|
||||
public func first(name: String, author: String) -> SearchBook? {
|
||||
values.values.filter { $0.name == name && $0.author == author }
|
||||
.sorted { $0.originOrder < $1.originOrder }.first
|
||||
}
|
||||
public func changeSources(name: String, author: String, sourceGroup: String, key: String?) -> [SearchBook] {
|
||||
values.values.filter {
|
||||
$0.name == name && $0.author.localizedCaseInsensitiveContains(author)
|
||||
&& (key?.isEmpty != false
|
||||
|| $0.originName.localizedCaseInsensitiveContains(key ?? "")
|
||||
|| ($0.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key ?? ""))
|
||||
}.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
public func enabledWithCover(name: String, author: String) -> [SearchBook] {
|
||||
values.values.filter { $0.name == name && $0.author == author && !($0.coverURL ?? "").isEmpty }
|
||||
.sorted { $0.originOrder < $1.originOrder }
|
||||
}
|
||||
public func put(_ books: [SearchBook]) { for book in books { values[book.bookURL] = book } }
|
||||
public func clear(name: String, author: String) {
|
||||
values = values.filter { $0.value.name != name || $0.value.author != author }
|
||||
}
|
||||
public func clearExpired(before time: Int64) { values = values.filter { $0.value.time >= time } }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
public protocol RssSourceRepository: Sendable {
|
||||
func all() async throws -> [RssSource]
|
||||
func enabled() async throws -> [RssSource]
|
||||
func disabled() async throws -> [RssSource]
|
||||
func groups() async throws -> [String]
|
||||
func inGroup(_ group: String) async throws -> [RssSource]
|
||||
func search(_ key: String) async throws -> [RssSource]
|
||||
func save(_ values: [RssSource]) async throws
|
||||
func setEnabled(_ value: Bool, sourceURL: String) async throws
|
||||
func remove(sourceURL: String) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteRssSourceRepository: RssSourceRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
|
||||
public func all() async throws -> [RssSource] { try await database.loadRssSources() }
|
||||
public func enabled() async throws -> [RssSource] { try await all().filter(\.enabled) }
|
||||
public func disabled() async throws -> [RssSource] { try await all().filter { !$0.enabled } }
|
||||
public func groups() async throws -> [String] {
|
||||
var seen = Set<String>()
|
||||
return try await all().flatMap(\.groups).filter { seen.insert($0).inserted }
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
public func inGroup(_ group: String) async throws -> [RssSource] {
|
||||
try await all().filter { $0.groups.contains(group) }
|
||||
}
|
||||
public func search(_ key: String) async throws -> [RssSource] {
|
||||
guard !key.isEmpty else { return try await all() }
|
||||
return try await all().filter {
|
||||
[$0.sourceName, $0.sourceUrl, $0.sourceGroup ?? "", $0.sourceComment ?? ""]
|
||||
.contains { $0.localizedCaseInsensitiveContains(key) }
|
||||
}
|
||||
}
|
||||
public func enabled(search key: String) async throws -> [RssSource] {
|
||||
try await search(key).filter(\.enabled)
|
||||
}
|
||||
public func enabled(inGroup group: String) async throws -> [RssSource] {
|
||||
try await inGroup(group).filter(\.enabled)
|
||||
}
|
||||
public func loginSources() async throws -> [RssSource] {
|
||||
try await all().filter { !($0.loginUrl ?? "").isEmpty }
|
||||
}
|
||||
public func noGroup() async throws -> [RssSource] {
|
||||
try await all().filter { $0.groups.isEmpty || $0.groups.contains("未分组") }
|
||||
}
|
||||
public func contains(sourceURL: String) async throws -> Bool {
|
||||
try await all().contains { $0.sourceUrl == sourceURL }
|
||||
}
|
||||
public func source(for sourceURL: String) async throws -> RssSource? {
|
||||
try await all().first { $0.sourceUrl == sourceURL }
|
||||
}
|
||||
public func save(_ values: [RssSource]) async throws { try await database.replaceRssSources(values) }
|
||||
public func setEnabled(_ value: Bool, sourceURL: String) async throws {
|
||||
var values = try await all()
|
||||
guard let index = values.firstIndex(where: { $0.sourceUrl == sourceURL }) else { return }
|
||||
values[index].enabled = value
|
||||
try await save(values)
|
||||
}
|
||||
public func remove(sourceURL: String) async throws {
|
||||
let values = try await all().filter { $0.sourceUrl != sourceURL }
|
||||
try await save(values)
|
||||
try await database.removeRssArticles(origin: sourceURL)
|
||||
try await database.deleteSourceVariables(sourceURL)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RssArticleRepository: Sendable {
|
||||
func articles(origin: String, sort: String) async throws -> [RssArticle]
|
||||
func replace(_ values: [RssArticle], origin: String, sort: String) async throws
|
||||
func append(_ values: [RssArticle]) async throws
|
||||
func put(_ values: [RssArticle]) async throws
|
||||
func clearOld(origin: String, sort: String, before order: Int64) async throws
|
||||
func remove(origin: String) async throws
|
||||
func article(origin: String, link: String, sort: String) async throws -> RssArticle?
|
||||
func article(origin: String, link: String) async throws -> RssArticle?
|
||||
func update(_ values: [RssArticle]) async throws
|
||||
func updateOrigin(from oldOrigin: String, to origin: String) async throws
|
||||
}
|
||||
|
||||
public actor SQLiteRssArticleRepository: RssArticleRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func articles(origin: String, sort: String) async throws -> [RssArticle] {
|
||||
try await database.loadRssArticles(origin: origin, sort: sort)
|
||||
}
|
||||
public func replace(_ values: [RssArticle], origin: String, sort: String) async throws {
|
||||
try await database.replaceRssArticles(values, origin: origin, sort: sort)
|
||||
}
|
||||
public func append(_ values: [RssArticle]) async throws { try await database.appendRssArticles(values) }
|
||||
public func put(_ values: [RssArticle]) async throws { try await database.putRssArticles(values) }
|
||||
public func clearOld(origin: String, sort: String, before order: Int64) async throws {
|
||||
try await database.clearOldRssArticles(origin: origin, sort: sort, before: order)
|
||||
}
|
||||
public func remove(origin: String) async throws { try await database.removeRssArticles(origin: origin) }
|
||||
public func article(origin: String, link: String, sort: String) async throws -> RssArticle? {
|
||||
try await database.findRssArticle(origin: origin, link: link, sort: sort)
|
||||
}
|
||||
public func article(origin: String, link: String) async throws -> RssArticle? {
|
||||
try await database.findRssArticle(origin: origin, link: link)
|
||||
}
|
||||
public func update(_ values: [RssArticle]) async throws { try await database.updateRssArticles(values) }
|
||||
public func updateOrigin(from oldOrigin: String, to origin: String) async throws {
|
||||
try await database.updateRssArticleOrigin(from: oldOrigin, to: origin)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SQLiteRssReadRecordRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func all() async throws -> [RssReadRecord] { try await database.loadRssReadRecords() }
|
||||
public func forOrigin(_ origin: String) async throws -> [RssReadRecord] {
|
||||
try await all().filter { $0.origin == origin }
|
||||
}
|
||||
public func insertIgnoringExisting(_ value: RssReadRecord) async throws {
|
||||
try await database.appendRssReadRecords([value])
|
||||
}
|
||||
public func record(_ record: String, origin: String) async throws -> RssReadRecord? {
|
||||
try await database.findRssReadRecord(record: record, origin: origin)
|
||||
}
|
||||
public func update(_ values: [RssReadRecord]) async throws { try await database.updateRssReadRecords(values) }
|
||||
public func clear() async throws { try await database.clearRssReadRecords() }
|
||||
public func remove(origin: String) async throws { try await database.removeRssReadRecords(origin: origin) }
|
||||
}
|
||||
|
||||
public actor SQLiteRssStarRepository {
|
||||
private let database: LegadoDatabase
|
||||
public init(database: LegadoDatabase) { self.database = database }
|
||||
public func all() async throws -> [RssStar] { try await database.loadRssStars() }
|
||||
public func groups() async throws -> [String] {
|
||||
var seen = Set<String>()
|
||||
return try await all().map(\.group).filter { seen.insert($0).inserted }.sorted()
|
||||
}
|
||||
public func inGroup(_ group: String) async throws -> [RssStar] {
|
||||
try await all().filter { $0.group == group }
|
||||
}
|
||||
public func save(_ value: RssStar) async throws {
|
||||
try await database.updateRssStars([value])
|
||||
}
|
||||
public func remove(origin: String, link: String) async throws {
|
||||
try await database.removeRssStars(origin: origin, link: link)
|
||||
}
|
||||
public func star(origin: String, link: String) async throws -> RssStar? { try await database.findRssStar(origin: origin, link: link) }
|
||||
public func remove(origin: String) async throws { try await database.removeRssStars(origin: origin) }
|
||||
public func remove(group: String) async throws { try await database.removeRssStars(group: group) }
|
||||
public func clear() async throws { try await database.clearRssStars() }
|
||||
public func updateOrigin(from oldOrigin: String, to origin: String) async throws {
|
||||
try await database.updateRssStarOrigin(from: oldOrigin, to: origin)
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSRepositoryFactory {
|
||||
public static func database(url: URL = LegadoDatabase.defaultURL()) throws -> LegadoDatabase {
|
||||
try LegadoDatabase(url: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
|
||||
public protocol ReadingActivityRepository: Sendable {
|
||||
func loadBookmarks() async throws -> [Bookmark]
|
||||
func saveBookmarks(_ bookmarks: [Bookmark]) async throws
|
||||
func loadReadRecords() async throws -> [ReadRecord]
|
||||
func saveReadRecords(_ records: [ReadRecord]) async throws
|
||||
}
|
||||
|
||||
public actor FileReadingActivityRepository: ReadingActivityRepository {
|
||||
private let bookmarksURL: URL
|
||||
private let recordsURL: URL
|
||||
|
||||
public init(bookmarksURL: URL, recordsURL: URL) {
|
||||
self.bookmarksURL = bookmarksURL
|
||||
self.recordsURL = recordsURL
|
||||
}
|
||||
|
||||
public func loadBookmarks() throws -> [Bookmark] { try load([Bookmark].self, from: bookmarksURL) }
|
||||
public func saveBookmarks(_ bookmarks: [Bookmark]) throws { try save(bookmarks, to: bookmarksURL) }
|
||||
public func loadReadRecords() throws -> [ReadRecord] { try load([ReadRecord].self, from: recordsURL) }
|
||||
public func saveReadRecords(_ records: [ReadRecord]) throws { try save(records, to: recordsURL) }
|
||||
|
||||
private func load<T: Decodable>(_ type: T.Type, from url: URL) throws -> T where T: ExpressibleByArrayLiteral {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
return try JSONDecoder().decode(type, from: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
private func save<T: Encodable>(_ value: T, to url: URL) throws {
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(value).write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryReadingActivityRepository: ReadingActivityRepository {
|
||||
private var bookmarks: [Bookmark]
|
||||
private var records: [ReadRecord]
|
||||
|
||||
public init(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) {
|
||||
self.bookmarks = bookmarks
|
||||
self.records = records
|
||||
}
|
||||
|
||||
public func loadBookmarks() -> [Bookmark] { bookmarks }
|
||||
public func saveBookmarks(_ bookmarks: [Bookmark]) { self.bookmarks = bookmarks }
|
||||
public func loadReadRecords() -> [ReadRecord] { records }
|
||||
public func saveReadRecords(_ records: [ReadRecord]) { self.records = records }
|
||||
}
|
||||
|
||||
public struct ReadingActivityService: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func sortedBookmarks(_ bookmarks: [Bookmark]) -> [Bookmark] {
|
||||
bookmarks.sorted {
|
||||
let name = $0.bookName.localizedStandardCompare($1.bookName)
|
||||
if name != .orderedSame { return name == .orderedAscending }
|
||||
let author = $0.bookAuthor.localizedStandardCompare($1.bookAuthor)
|
||||
if author != .orderedSame { return author == .orderedAscending }
|
||||
if $0.chapterIndex != $1.chapterIndex { return $0.chapterIndex < $1.chapterIndex }
|
||||
return $0.chapterPosition < $1.chapterPosition
|
||||
}
|
||||
}
|
||||
|
||||
public func summaries(_ records: [ReadRecord]) -> [ReadRecordSummary] {
|
||||
Dictionary(grouping: records, by: \.bookName)
|
||||
.map { name, values in
|
||||
ReadRecordSummary(
|
||||
bookName: name,
|
||||
readTime: values.reduce(0) { $0 + max($1.readTime, 0) },
|
||||
lastRead: values.map(\.lastRead).max() ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.lastRead > $1.lastRead }
|
||||
}
|
||||
|
||||
public func displayedSummaries(
|
||||
_ records: [ReadRecord],
|
||||
searchKey: String,
|
||||
sortMode: Int
|
||||
) -> [ReadRecordSummary] {
|
||||
let values = summaries(records).filter {
|
||||
searchKey.isEmpty || $0.bookName.localizedCaseInsensitiveContains(searchKey)
|
||||
}
|
||||
return values.sorted {
|
||||
switch sortMode {
|
||||
case 1:
|
||||
if $0.readTime != $1.readTime { return $0.readTime > $1.readTime }
|
||||
case 2:
|
||||
if $0.lastRead != $1.lastRead { return $0.lastRead > $1.lastRead }
|
||||
default:
|
||||
let order = $0.bookName.localizedStandardCompare($1.bookName)
|
||||
if order != .orderedSame { return order == .orderedAscending }
|
||||
}
|
||||
return $0.bookName.localizedStandardCompare($1.bookName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
public func addingReadTime(
|
||||
_ elapsedMilliseconds: Int64,
|
||||
deviceID: String,
|
||||
bookName: String,
|
||||
now: Int64,
|
||||
to records: [ReadRecord],
|
||||
calendar: Calendar = .current
|
||||
) -> [ReadRecord] {
|
||||
guard elapsedMilliseconds > 0, !bookName.isEmpty else { return records }
|
||||
var updated = records
|
||||
let day = Self.dayKey(
|
||||
for: Date(timeIntervalSince1970: Double(now) / 1_000),
|
||||
calendar: calendar
|
||||
)
|
||||
if let index = updated.firstIndex(where: { $0.deviceId == deviceID && $0.bookName == bookName }) {
|
||||
updated[index].readTime += elapsedMilliseconds
|
||||
updated[index].lastRead = now
|
||||
updated[index].dailyReadTimes[day, default: 0] += elapsedMilliseconds
|
||||
} else {
|
||||
updated.append(.init(
|
||||
deviceId: deviceID,
|
||||
bookName: bookName,
|
||||
readTime: elapsedMilliseconds,
|
||||
lastRead: now,
|
||||
dailyReadTimes: [day: elapsedMilliseconds]
|
||||
))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
public func statistics(
|
||||
_ records: [ReadRecord],
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> ReadingStatistics {
|
||||
let dailyTotals = records.reduce(into: [String: Int64]()) { result, record in
|
||||
for (day, milliseconds) in record.dailyReadTimes where milliseconds > 0 {
|
||||
result[day, default: 0] += milliseconds
|
||||
}
|
||||
}
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let todayKey = Self.dayKey(for: today, calendar: calendar)
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||
var cursor: Date?
|
||||
if dailyTotals[todayKey, default: 0] > 0 {
|
||||
cursor = today
|
||||
} else if dailyTotals[Self.dayKey(for: yesterday, calendar: calendar), default: 0] > 0 {
|
||||
cursor = yesterday
|
||||
} else {
|
||||
cursor = nil
|
||||
}
|
||||
var streak = 0
|
||||
while let day = cursor,
|
||||
dailyTotals[Self.dayKey(for: day, calendar: calendar), default: 0] > 0 {
|
||||
streak += 1
|
||||
cursor = calendar.date(byAdding: .day, value: -1, to: day)
|
||||
}
|
||||
let recentDays = (-6...0).compactMap { offset -> DailyReadingTotal? in
|
||||
guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { return nil }
|
||||
return DailyReadingTotal(
|
||||
date: date,
|
||||
readTime: dailyTotals[Self.dayKey(for: date, calendar: calendar), default: 0]
|
||||
)
|
||||
}
|
||||
return ReadingStatistics(
|
||||
todayReadTime: dailyTotals[todayKey, default: 0],
|
||||
currentStreak: streak,
|
||||
totalReadTime: records.reduce(0) { $0 + max($1.readTime, 0) },
|
||||
bookCount: Set(records.lazy.map(\.bookName).filter { !$0.isEmpty }).count,
|
||||
recentDays: recentDays
|
||||
)
|
||||
}
|
||||
|
||||
private static func dayKey(for date: Date, calendar: Calendar) -> String {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
public struct DailyReadingTotal: Hashable, Sendable {
|
||||
public let date: Date
|
||||
public let readTime: Int64
|
||||
|
||||
public init(date: Date, readTime: Int64) {
|
||||
self.date = date
|
||||
self.readTime = readTime
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadingStatistics: Hashable, Sendable {
|
||||
public let todayReadTime: Int64
|
||||
public let currentStreak: Int
|
||||
public let totalReadTime: Int64
|
||||
public let bookCount: Int
|
||||
public let recentDays: [DailyReadingTotal]
|
||||
|
||||
public init(
|
||||
todayReadTime: Int64,
|
||||
currentStreak: Int,
|
||||
totalReadTime: Int64,
|
||||
bookCount: Int,
|
||||
recentDays: [DailyReadingTotal]
|
||||
) {
|
||||
self.todayReadTime = todayReadTime
|
||||
self.currentStreak = currentStreak
|
||||
self.totalReadTime = totalReadTime
|
||||
self.bookCount = bookCount
|
||||
self.recentDays = recentDays
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookProgress: Codable, Hashable, Sendable {
|
||||
public var name: String
|
||||
public var author: String
|
||||
public var durChapterIndex: Int
|
||||
public var durChapterPos: Int
|
||||
public var durChapterTime: Int64
|
||||
public var durChapterTitle: String?
|
||||
|
||||
public init(book: Book) {
|
||||
name = book.name
|
||||
author = book.author
|
||||
durChapterIndex = book.durChapterIndex
|
||||
durChapterPos = book.durChapterPos
|
||||
durChapterTime = book.durChapterTime
|
||||
durChapterTitle = book.durChapterTitle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReplaceRuleImportError: Error, LocalizedError, Equatable {
|
||||
case emptyDocument
|
||||
case documentTooLarge(Int)
|
||||
case invalidDocument(String)
|
||||
case emptyPattern(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyDocument: "净化规则内容为空"
|
||||
case .documentTooLarge(let maximum): "净化规则文件超过 \(maximum / 1_024 / 1_024) MB 上限"
|
||||
case .invalidDocument(let detail): "无法解析净化规则 JSON:\(detail)"
|
||||
case .emptyPattern(let name): "净化规则“\(name)”没有匹配内容"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaceRuleImporter: Sendable {
|
||||
public static let maximumDocumentBytes = 10 * 1_024 * 1_024
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decode(_ data: Data) throws -> [ReplaceRule] {
|
||||
guard data.count <= Self.maximumDocumentBytes else {
|
||||
throw ReplaceRuleImportError.documentTooLarge(Self.maximumDocumentBytes)
|
||||
}
|
||||
let bom = Data([0xEF, 0xBB, 0xBF])
|
||||
let cleaned = data.starts(with: bom) ? Data(data.dropFirst(3)) : data
|
||||
guard !cleaned.isEmpty else { throw ReplaceRuleImportError.emptyDocument }
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let rules = if let array = try? decoder.decode([ReplaceRule].self, from: cleaned) {
|
||||
array
|
||||
} else {
|
||||
[try decoder.decode(ReplaceRule.self, from: cleaned)]
|
||||
}
|
||||
for rule in rules { try ReplaceRuleValidator.validate(rule) }
|
||||
return rules
|
||||
} catch let error as ReplaceRuleImportError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ReplaceRuleImportError.invalidDocument(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public func merge(_ incoming: [ReplaceRule], into existing: [ReplaceRule]) -> [ReplaceRule] {
|
||||
var result = existing
|
||||
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($1.id, $0) })
|
||||
for rule in incoming {
|
||||
if let index = indexes[rule.id] {
|
||||
result[index] = rule
|
||||
} else {
|
||||
indexes[rule.id] = result.count
|
||||
result.append(rule)
|
||||
}
|
||||
}
|
||||
return result.sorted { $0.sortOrder < $1.sortOrder }
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ReplaceRuleRepository: Sendable {
|
||||
func load() async throws -> [ReplaceRule]
|
||||
func save(_ rules: [ReplaceRule]) async throws
|
||||
}
|
||||
|
||||
public actor FileReplaceRuleRepository: ReplaceRuleRepository {
|
||||
private let fileURL: URL
|
||||
|
||||
public init(fileURL: URL) { self.fileURL = fileURL }
|
||||
|
||||
public func load() throws -> [ReplaceRule] {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
return try JSONDecoder().decode([ReplaceRule].self, from: Data(contentsOf: fileURL))
|
||||
}
|
||||
|
||||
public func save(_ rules: [ReplaceRule]) throws {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(rules).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryReplaceRuleRepository: ReplaceRuleRepository {
|
||||
private var rules: [ReplaceRule]
|
||||
public init(rules: [ReplaceRule] = []) { self.rules = rules }
|
||||
public func load() -> [ReplaceRule] { rules }
|
||||
public func save(_ rules: [ReplaceRule]) { self.rules = rules }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationXML)
|
||||
import FoundationXML
|
||||
#endif
|
||||
|
||||
public struct RSSParseResult: Sendable, Equatable {
|
||||
public var articles: [RssArticle]
|
||||
public var nextURL: String?
|
||||
public init(articles: [RssArticle], nextURL: String? = nil) {
|
||||
self.articles = articles
|
||||
self.nextURL = nextURL
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSDefaultParser {
|
||||
public static func parse(sortName: String, xml: String, sourceURL: String) throws -> RSSParseResult {
|
||||
let delegate = Delegate(sortName: sortName, sourceURL: sourceURL)
|
||||
let parser = XMLParser(data: Data(xml.utf8))
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else { throw parser.parserError ?? RSSParserError.invalidXML }
|
||||
return .init(articles: delegate.articles)
|
||||
}
|
||||
|
||||
private final class Delegate: NSObject, XMLParserDelegate {
|
||||
let sortName: String
|
||||
let sourceURL: String
|
||||
var articles: [RssArticle] = []
|
||||
var article = RssArticle()
|
||||
var insideItem = false
|
||||
var activeField: String?
|
||||
var text = ""
|
||||
|
||||
init(sortName: String, sourceURL: String) {
|
||||
self.sortName = sortName
|
||||
self.sourceURL = sourceURL
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
let name = elementName.lowercased()
|
||||
if name == "item" { insideItem = true; article = RssArticle(); return }
|
||||
guard insideItem else { return }
|
||||
if name == "media:thumbnail" {
|
||||
article.image = attribute(attributeDict, named: "url")
|
||||
} else if name == "enclosure", attribute(attributeDict, named: "type")?.localizedCaseInsensitiveContains("image/") == true {
|
||||
article.image = attribute(attributeDict, named: "url")
|
||||
}
|
||||
if ["title", "link", "description", "content:encoded", "pubdate", "time"].contains(name) {
|
||||
activeField = name
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if insideItem, activeField != nil { text += string }
|
||||
}
|
||||
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
|
||||
if insideItem, activeField != nil { text += String(decoding: CDATABlock, as: UTF8.self) }
|
||||
}
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
|
||||
let name = elementName.lowercased()
|
||||
if name == "item" {
|
||||
article.origin = sourceURL; article.sort = sortName; articles.append(article)
|
||||
insideItem = false; activeField = nil
|
||||
return
|
||||
}
|
||||
guard insideItem, name == activeField else { return }
|
||||
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch name {
|
||||
case "title": article.title = value
|
||||
case "link": article.link = value
|
||||
case "description": article.description = value; if article.image == nil { article.image = firstImageURL(in: text) }
|
||||
case "content:encoded": article.content = value; if article.image == nil { article.image = firstImageURL(in: text) }
|
||||
case "pubdate", "time": article.pubDate = value
|
||||
default: break
|
||||
}
|
||||
activeField = nil; text = ""
|
||||
}
|
||||
|
||||
private func attribute(_ values: [String: String], named name: String) -> String? {
|
||||
values.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
|
||||
}
|
||||
private func firstImageURL(in input: String) -> String? {
|
||||
guard let expression = try? NSRegularExpression(pattern: "(<img [^>]*>)", options: .caseInsensitive),
|
||||
let image = expression.firstMatch(in: input, range: NSRange(input.startIndex..., in: input)),
|
||||
let tagRange = Range(image.range(at: 1), in: input) else { return nil }
|
||||
let tag = String(input[tagRange])
|
||||
guard let expression = try? NSRegularExpression(pattern: #"src\s*=\s*\"([^\"]+)\""#, options: .caseInsensitive),
|
||||
let source = expression.firstMatch(in: tag, range: NSRange(tag.startIndex..., in: tag)),
|
||||
let sourceRange = Range(source.range(at: 1), in: tag) else { return nil }
|
||||
return String(tag[sourceRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSParserError: Error, LocalizedError, Sendable {
|
||||
case invalidXML
|
||||
case emptyBody(String)
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidXML: "RSS XML解析失败"
|
||||
case .emptyBody(let source): "获取网页内容失败:\(source)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
public enum RSSReadingMode: Equatable, Sendable {
|
||||
case html(content: String, baseURL: String?)
|
||||
case startHTML(content: String, baseURL: String?)
|
||||
case web(url: String)
|
||||
case image(url: String)
|
||||
case video(url: String)
|
||||
}
|
||||
|
||||
public struct RSSReadingPolicy: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func mode(article: RssArticle, source: RssSource, resolvedContent: String? = nil) -> RSSReadingMode {
|
||||
if article.type == 1 || article.type == 2 {
|
||||
let raw = resolvedContent?.trimmed.nilIfEmpty ?? article.link
|
||||
let articleURL = absoluteURL(article.link, base: article.origin)
|
||||
let url = absoluteURL(raw, base: articleURL)
|
||||
return article.type == 1 ? .image(url: url) : .video(url: url)
|
||||
}
|
||||
if let description = article.description?.trimmed.nilIfEmpty {
|
||||
return .html(content: description, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil)
|
||||
}
|
||||
if source.ruleContent?.trimmed.nilIfEmpty != nil, let content = resolvedContent {
|
||||
return .html(content: content, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil)
|
||||
}
|
||||
return .web(url: absoluteURL(article.link, base: article.origin))
|
||||
}
|
||||
|
||||
private func absoluteURL(_ value: String, base: String) -> String {
|
||||
URL(string: value, relativeTo: URL(string: base))?.absoluteURL.absoluteString ?? value
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSHTMLProcessor: Sendable {
|
||||
public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}"
|
||||
public init() {}
|
||||
|
||||
public func contentHTML(_ content: String, style: String?) -> String {
|
||||
injectStyle(into: content, style: style?.trimmed.nilIfEmpty ?? Self.defaultStyle)
|
||||
}
|
||||
|
||||
public func startHTML(_ content: String, source: RssSource) -> String {
|
||||
var html: String
|
||||
if let script = source.startJs?.trimmed.nilIfEmpty {
|
||||
if let range = content.range(of: "</body>") {
|
||||
html = String(content[..<range.lowerBound]) + "<script>\(script)</script>" + String(content[range.lowerBound...])
|
||||
} else {
|
||||
html = "<body>\(content)<script>\(script)</script></body>"
|
||||
}
|
||||
} else { html = content }
|
||||
return contentHTML(html, style: source.startStyle?.trimmed.nilIfEmpty ?? source.style)
|
||||
}
|
||||
|
||||
private func injectStyle(into content: String, style: String) -> String {
|
||||
let tag = "<style>\(style)</style>"
|
||||
if let range = content.range(of: "</style>") {
|
||||
return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...])
|
||||
}
|
||||
if let range = content.range(of: "</head>") {
|
||||
return String(content[..<range.lowerBound]) + tag + String(content[range.lowerBound...])
|
||||
}
|
||||
return tag + content
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSStartPageResolver: Sendable {
|
||||
public init() {}
|
||||
public func resolve(_ source: RssSource) throws -> RSSReadingMode? {
|
||||
guard let raw = source.startHtml?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
||||
let html: String
|
||||
if raw.hasPrefix("@js:") {
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(script: String(raw.dropFirst(4)), result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter))
|
||||
html = result.value.stringValue
|
||||
} else if raw.hasPrefix("<js>"), let end = raw.range(of: "</js>", options: .backwards) {
|
||||
let script = String(raw[raw.index(raw.startIndex, offsetBy: 4)..<end.lowerBound])
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(script: script, result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter))
|
||||
html = result.value.stringValue
|
||||
} else { html = raw }
|
||||
return .startHTML(content: html, baseURL: source.loadWithBaseUrl ? source.sourceUrl : nil)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSResourcePolicy: Sendable {
|
||||
private let blacklist: [String]
|
||||
private let whitelist: [String]
|
||||
public init(blacklist: String?, whitelist: String?) {
|
||||
self.blacklist = Self.rules(blacklist)
|
||||
self.whitelist = Self.rules(whitelist)
|
||||
}
|
||||
public func allows(_ url: String) -> Bool {
|
||||
if !blacklist.isEmpty { return !blacklist.contains { matches(url, rule: $0) } }
|
||||
if !whitelist.isEmpty { return whitelist.contains { matches(url, rule: $0) } }
|
||||
return true
|
||||
}
|
||||
public func contentBlockerJSON() -> String? {
|
||||
var rules: [[String: Any]] = []
|
||||
if !blacklist.isEmpty {
|
||||
for value in blacklist {
|
||||
rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "block"]])
|
||||
}
|
||||
} else if !whitelist.isEmpty {
|
||||
rules.append(["trigger": ["url-filter": ".*"], "action": ["type": "block"]])
|
||||
for value in whitelist {
|
||||
rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "ignore-previous-rules"]])
|
||||
}
|
||||
}
|
||||
guard !rules.isEmpty, let data = try? JSONSerialization.data(withJSONObject: rules) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
private func matches(_ value: String, rule: String) -> Bool {
|
||||
if value.hasPrefix(rule) { return true }
|
||||
guard let expression = try? NSRegularExpression(pattern: rule) else { return false }
|
||||
let range = NSRange(value.startIndex..., in: value)
|
||||
guard let match = expression.firstMatch(in: value, range: range) else { return false }
|
||||
return match.range == range
|
||||
}
|
||||
private func filter(for rule: String) -> String {
|
||||
let literalPrefix = "^" + NSRegularExpression.escapedPattern(for: rule)
|
||||
return "(?:\(literalPrefix))|(?:^(?:\(rule))$)"
|
||||
}
|
||||
private static func rules(_ value: String?) -> [String] {
|
||||
value?.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSRuleParseReport: Sendable {
|
||||
public var result: RSSParseResult
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
public init(result: RSSParseResult, diagnostics: [RuleDiagnostic] = []) {
|
||||
self.result = result
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSRuleParser: Sendable {
|
||||
private let engine: RuleEngine
|
||||
public init(engine: RuleEngine = .init()) { self.engine = engine }
|
||||
|
||||
public func parse(
|
||||
sortName: String,
|
||||
sortURL: String,
|
||||
redirectURL: String,
|
||||
body: String,
|
||||
source: RssSource,
|
||||
variables: [String: String] = [:]
|
||||
) async throws -> RSSRuleParseReport {
|
||||
guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RSSParserError.emptyBody(source.sourceUrl)
|
||||
}
|
||||
guard var listRule = source.ruleArticles, !listRule.isEmpty else {
|
||||
return .init(result: try RSSDefaultParser.parse(sortName: sortName, xml: body, sourceURL: source.sourceUrl))
|
||||
}
|
||||
let reverse = listRule.hasPrefix("-")
|
||||
if reverse { listRule.removeFirst() }
|
||||
let adapter = source.bookSourceAdapter
|
||||
let baseContext = RuleExecutionContext(variables: variables, baseURL: sortURL, source: adapter)
|
||||
let list = await engine.evaluate(listRule, inText: body, baseURL: sortURL, listMode: true, context: baseContext)
|
||||
var diagnostics = list.diagnostics
|
||||
var nextURL: String?
|
||||
if let nextRule = source.ruleNextPage, !nextRule.isEmpty {
|
||||
if nextRule.uppercased() == "PAGE" {
|
||||
nextURL = sortURL
|
||||
} else {
|
||||
let next = await engine.evaluate(nextRule, inText: body, baseURL: sortURL, context: .init(variables: list.variables, baseURL: sortURL, source: adapter))
|
||||
diagnostics += next.diagnostics
|
||||
if let value = next.joinedString, !value.isEmpty {
|
||||
nextURL = URL(string: value, relativeTo: URL(string: sortURL))?.absoluteURL.absoluteString ?? value
|
||||
}
|
||||
}
|
||||
}
|
||||
let variable = encodeVariables(list.variables)
|
||||
var articles: [RssArticle] = []
|
||||
for item in flatten(list.values) {
|
||||
var context = RuleExecutionContext(variables: list.variables, baseURL: sortURL, source: adapter)
|
||||
let title = await evaluate(source.ruleTitle, item: item, context: context); context.variables = title.variables
|
||||
let pubDate = await evaluate(source.rulePubDate, item: item, context: context); context.variables = pubDate.variables
|
||||
let description = await evaluate(source.ruleDescription, item: item, context: context); context.variables = description.variables
|
||||
let image = await evaluate(source.ruleImage, item: item, context: context); context.variables = image.variables
|
||||
let link = await evaluate(source.ruleLink, item: item, context: context)
|
||||
diagnostics += [title, pubDate, description, image, link].flatMap(\.diagnostics)
|
||||
guard let titleValue = title.joinedString?.trimmingCharacters(in: .whitespacesAndNewlines), !titleValue.isEmpty else { continue }
|
||||
let rawLink = link.leadingString ?? ""
|
||||
let resolvedLink = URL(string: rawLink, relativeTo: URL(string: sortURL))?
|
||||
.absoluteURL.absoluteString ?? rawLink
|
||||
var article = RssArticle(origin: source.sourceUrl, sort: sortName, title: titleValue, link: resolvedLink)
|
||||
article.pubDate = pubDate.joinedString
|
||||
article.description = source.ruleDescription?.isEmpty == false ? description.joinedString : nil
|
||||
if let imageValue = image.joinedString, !imageValue.isEmpty {
|
||||
article.image = URL(string: imageValue, relativeTo: URL(string: source.sourceUrl))?.absoluteURL.absoluteString ?? imageValue
|
||||
}
|
||||
article.variable = variable
|
||||
article.type = source.type
|
||||
articles.append(article)
|
||||
}
|
||||
if reverse { articles.reverse() }
|
||||
_ = redirectURL
|
||||
return .init(result: .init(articles: articles, nextURL: nextURL), diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
private func evaluate(_ rule: String?, item: JSONValue, context: RuleExecutionContext) async -> RuleEvaluation {
|
||||
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
|
||||
if case .string(let text) = item {
|
||||
return await engine.evaluate(rule, inText: text, baseURL: context.baseURL, context: context)
|
||||
}
|
||||
return await engine.evaluate(rule, in: item, context: context)
|
||||
}
|
||||
private func flatten(_ values: [JSONValue]) -> [JSONValue] {
|
||||
values.flatMap { value in
|
||||
if case .array(let items) = value { return items }
|
||||
return [value]
|
||||
}
|
||||
}
|
||||
private func encodeVariables(_ values: [String: String]) -> String? {
|
||||
guard !values.isEmpty, let data = try? JSONEncoder().encode(values) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension RssSource {
|
||||
var bookSourceAdapter: BookSource {
|
||||
var value = BookSource(bookSourceUrl: sourceUrl, bookSourceName: sourceName)
|
||||
value.bookSourceGroup = sourceGroup; value.bookSourceType = type; value.customOrder = customOrder
|
||||
value.enabled = enabled; value.jsLib = jsLib; value.enabledCookieJar = enabledCookieJar
|
||||
value.concurrentRate = concurrentRate; value.header = header; value.loginUrl = loginUrl
|
||||
value.loginUi = loginUi; value.loginCheckJs = loginCheckJs; value.coverDecodeJs = coverDecodeJs
|
||||
value.bookSourceComment = sourceComment; value.variableComment = variableComment
|
||||
value.lastUpdateTime = lastUpdateTime; value.searchUrl = searchUrl
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSFetchResult: Sendable {
|
||||
public var requestURL: String
|
||||
public var finalURL: String
|
||||
public var statusCode: Int
|
||||
public var body: String
|
||||
public var report: RSSRuleParseReport
|
||||
}
|
||||
|
||||
public struct RSSContentFetchResult: Sendable {
|
||||
public var requestURL: String
|
||||
public var finalURL: String
|
||||
public var statusCode: Int
|
||||
public var body: String
|
||||
public var content: String
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
}
|
||||
|
||||
public struct RSSService: Sendable {
|
||||
private let transport: any HTTPTransport
|
||||
private let urlBuilder: LegadoURLBuilder
|
||||
private let engine: RuleEngine
|
||||
private let parser: RSSRuleParser
|
||||
private let networkPolicy: SourceNetworkPolicy
|
||||
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
|
||||
|
||||
public init(
|
||||
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||
urlBuilder: LegadoURLBuilder = .init(),
|
||||
engine: RuleEngine = .init(),
|
||||
networkPolicy: SourceNetworkPolicy = .shared,
|
||||
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil
|
||||
) {
|
||||
self.transport = transport
|
||||
self.urlBuilder = urlBuilder
|
||||
self.engine = engine
|
||||
self.parser = RSSRuleParser(engine: engine)
|
||||
self.networkPolicy = networkPolicy
|
||||
self.webViewExecutor = webViewExecutor
|
||||
}
|
||||
|
||||
public func articles(
|
||||
sortName: String,
|
||||
sortURL: String,
|
||||
source: RssSource,
|
||||
page: Int,
|
||||
key: String? = nil
|
||||
) async throws -> RSSRuleParseReport {
|
||||
try await articlesDetailed(sortName: sortName, sortURL: sortURL, source: source, page: page, key: key).report
|
||||
}
|
||||
|
||||
public func articlesDetailed(
|
||||
sortName: String,
|
||||
sortURL: String,
|
||||
source: RssSource,
|
||||
page: Int,
|
||||
key: String? = nil
|
||||
) async throws -> RSSFetchResult {
|
||||
let adapter = source.bookSourceAdapter
|
||||
let headers = try BookSourceHeaderResolver().resolve(adapter)
|
||||
let prepared = try urlBuilder.prepare(
|
||||
sortURL,
|
||||
context: .init(key: key, page: page, source: adapter),
|
||||
baseURL: URL(string: source.sourceUrl),
|
||||
defaultHeaders: headers
|
||||
)
|
||||
let response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: adapter,
|
||||
transport: transport,
|
||||
timeout: 180,
|
||||
webViewExecutor: webViewExecutor,
|
||||
snapshotStage: .debug
|
||||
)
|
||||
let report = try await parser.parse(
|
||||
sortName: sortName,
|
||||
sortURL: sortURL,
|
||||
redirectURL: response.finalURL.absoluteString,
|
||||
body: response.decodedText,
|
||||
source: source,
|
||||
variables: response.requestVariables
|
||||
)
|
||||
return .init(
|
||||
requestURL: prepared.url.absoluteString,
|
||||
finalURL: response.finalURL.absoluteString,
|
||||
statusCode: response.statusCode,
|
||||
body: response.decodedText,
|
||||
report: report
|
||||
)
|
||||
}
|
||||
|
||||
public func content(article: RssArticle, rule: String, source: RssSource) async throws -> String {
|
||||
try await contentDetailed(article: article, rule: rule, source: source).content
|
||||
}
|
||||
|
||||
public func contentDetailed(article: RssArticle, rule: String, source: RssSource) async throws -> RSSContentFetchResult {
|
||||
let adapter = source.bookSourceAdapter
|
||||
let headers = try BookSourceHeaderResolver().resolve(adapter)
|
||||
let prepared = try urlBuilder.prepare(
|
||||
article.link,
|
||||
context: .init(variables: article.variableMap, source: adapter),
|
||||
baseURL: URL(string: article.origin),
|
||||
defaultHeaders: headers
|
||||
)
|
||||
let response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: adapter,
|
||||
transport: transport,
|
||||
timeout: 180,
|
||||
webViewExecutor: webViewExecutor,
|
||||
snapshotStage: .content
|
||||
)
|
||||
let result = await engine.evaluate(
|
||||
rule,
|
||||
inText: response.decodedText,
|
||||
baseURL: URL(string: article.link, relativeTo: URL(string: article.origin))?.absoluteURL.absoluteString ?? article.link,
|
||||
context: .init(variables: article.variableMap, baseURL: response.finalURL.absoluteString, source: adapter)
|
||||
)
|
||||
if let error = result.diagnostics.first(where: { $0.severity == .error }) {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: error.message)
|
||||
}
|
||||
return .init(
|
||||
requestURL: prepared.url.absoluteString,
|
||||
finalURL: response.finalURL.absoluteString,
|
||||
statusCode: response.statusCode,
|
||||
body: response.decodedText,
|
||||
content: result.joinedString ?? "",
|
||||
diagnostics: result.diagnostics
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSSort: Hashable, Identifiable, Sendable {
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var id: String { "\(name)\u{0}\(url)" }
|
||||
public init(name: String, url: String) { self.name = name; self.url = url }
|
||||
}
|
||||
|
||||
public struct RSSSortResolver: Sendable {
|
||||
private let engine: RuleEngine
|
||||
public init(engine: RuleEngine = .init()) { self.engine = engine }
|
||||
|
||||
public func resolve(_ source: RssSource) async -> [RSSSort] {
|
||||
var value = source.sortUrl
|
||||
if let rule = value, rule.hasPrefix("@js:") || rule.hasPrefix("<js>") {
|
||||
let result = await engine.evaluate(rule, in: .null, context: .init(source: source.bookSourceAdapter))
|
||||
value = result.joinedString
|
||||
}
|
||||
guard let value else { return [.init(name: "", url: source.sourceUrl)] }
|
||||
let pattern = try? NSRegularExpression(pattern: "(?:&&|\\n)+")
|
||||
let range = NSRange(value.startIndex..., in: value)
|
||||
let normalized = pattern?.stringByReplacingMatches(in: value, range: range, withTemplate: "\u{1F}") ?? value
|
||||
let sorts = normalized.split(separator: "\u{1F}", omittingEmptySubsequences: true).compactMap { item -> RSSSort? in
|
||||
let text = String(item)
|
||||
let parts = text.components(separatedBy: "::")
|
||||
let name = parts.first ?? ""
|
||||
let url = parts.dropFirst().joined(separator: "::")
|
||||
return url.isEmpty ? nil : .init(name: name, url: url)
|
||||
}
|
||||
return sorts.isEmpty ? [.init(name: "", url: source.sourceUrl)] : sorts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import Foundation
|
||||
import Kanna
|
||||
import SwiftSoup
|
||||
|
||||
public struct HTMLRuleEvaluator: Sendable {
|
||||
private enum IndexItem {
|
||||
case index(Int)
|
||||
case range(Int?, Int?, Int)
|
||||
}
|
||||
|
||||
private struct IndexedSelector {
|
||||
var selector: String
|
||||
var excludes: Bool
|
||||
var items: [IndexItem]
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public func selectCSSFragments(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] {
|
||||
do {
|
||||
let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment)
|
||||
let stages = splitChain(rule).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
return try selectElements(stages: stages, from: root).map { try $0.outerHtml() }
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
public func extractCSS(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] {
|
||||
do {
|
||||
let parsed = try parseCSSExtraction(rule)
|
||||
let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment)
|
||||
let elements = try selectElements(stages: parsed.selectors, from: root)
|
||||
if parsed.output == "html" {
|
||||
for element in elements {
|
||||
try element.select("script").remove()
|
||||
try element.select("style").remove()
|
||||
}
|
||||
let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n")
|
||||
return value.isEmpty ? [] : [value]
|
||||
}
|
||||
if parsed.output == "all" {
|
||||
let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n")
|
||||
return value.isEmpty ? [] : [value]
|
||||
}
|
||||
var seenAttributes: Set<String> = []
|
||||
return try elements.compactMap { element in
|
||||
let value: String
|
||||
switch parsed.output {
|
||||
case "text": value = try element.text()
|
||||
case "ownText": value = element.ownText()
|
||||
case "textNodes":
|
||||
value = element.textNodes().map { $0.text().trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
default:
|
||||
value = try element.attr(parsed.output)
|
||||
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
seenAttributes.insert(value).inserted else { return nil }
|
||||
}
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
} catch let error as RuleEngineError {
|
||||
throw error
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
public func selectXPathFragments(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] {
|
||||
try xpathResult(rule, html: html, isFragment: isFragment).compactMap(\.toHTML)
|
||||
}
|
||||
|
||||
public func extractXPath(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] {
|
||||
let result = try xpathResult(rule, html: html, isFragment: isFragment)
|
||||
switch result {
|
||||
case .none:
|
||||
return []
|
||||
case .NodeSet:
|
||||
return result.compactMap { node in
|
||||
let value = node.text ?? node.content ?? ""
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
case .Bool(let value):
|
||||
return [String(value)]
|
||||
case .Number(let value):
|
||||
return [value.rounded() == value ? String(format: "%.0f", value) : String(value)]
|
||||
case .String(let value):
|
||||
return value.isEmpty ? [] : [value]
|
||||
}
|
||||
}
|
||||
|
||||
private func xpathResult(_ rule: String, html: String, isFragment: Bool) throws -> XPathObject {
|
||||
let document = try parseKanna(html, rule: rule)
|
||||
if isFragment, let element = document.at_xpath("/html/body/*[1]") {
|
||||
return element.xpath(rule)
|
||||
}
|
||||
return document.xpath(rule)
|
||||
}
|
||||
|
||||
private func parseKanna(_ html: String, rule: String) throws -> any HTMLDocument {
|
||||
do {
|
||||
return try Kanna.HTML(html: html, encoding: .utf8)
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "HTML 解析失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func parseCSSExtraction(_ rule: String) throws -> (selectors: [String], output: String) {
|
||||
let components = splitChain(rule)
|
||||
guard components.count > 1 else {
|
||||
let selector = rule.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selector.isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "CSS selector 不能为空")
|
||||
}
|
||||
if selector.range(of: #"^[\w:-]+$"#, options: .regularExpression) != nil {
|
||||
return ([], selector)
|
||||
}
|
||||
return ([selector], "text")
|
||||
}
|
||||
var selectors = components.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
let output = components.last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
while selectors.first?.isEmpty == true { selectors.removeFirst() }
|
||||
guard selectors.allSatisfy({ !$0.isEmpty }), !output.isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "CSS 取值规则必须是 selector@text 或 selector@attribute")
|
||||
}
|
||||
return (selectors, output)
|
||||
}
|
||||
|
||||
private func cssFailureDetail(_ error: Error) -> String {
|
||||
if case SwiftSoup.Exception.Error(_, let message) = error {
|
||||
return "CSS 规则执行失败:\(message)"
|
||||
}
|
||||
return "CSS 规则执行失败:\(error.localizedDescription)"
|
||||
}
|
||||
|
||||
private func splitChain(_ rule: String) -> [String] {
|
||||
var parts: [String] = []
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
var squareDepth = 0
|
||||
var roundDepth = 0
|
||||
var start = rule.startIndex
|
||||
for index in rule.indices {
|
||||
let character = rule[index]
|
||||
if escaped { escaped = false; continue }
|
||||
if character == "\\" { escaped = true; continue }
|
||||
if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
continue
|
||||
}
|
||||
if character == "\"" || character == "'" { quote = character; continue }
|
||||
if character == "[" { squareDepth += 1 }
|
||||
if character == "]" { squareDepth -= 1 }
|
||||
if character == "(" { roundDepth += 1 }
|
||||
if character == ")" { roundDepth -= 1 }
|
||||
if character == "@", squareDepth == 0, roundDepth == 0 {
|
||||
parts.append(String(rule[start..<index]))
|
||||
start = rule.index(after: index)
|
||||
}
|
||||
}
|
||||
parts.append(String(rule[start...]))
|
||||
return parts
|
||||
}
|
||||
|
||||
private func parseRoot(_ html: String, baseURL: String, isFragment: Bool) throws -> Element {
|
||||
guard isFragment else { return try SwiftSoup.parse(html, baseURL) }
|
||||
if html.range(of: #"^\s*(?:<!doctype\b|<html\b)"#, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
return try SwiftSoup.parse(html, baseURL)
|
||||
}
|
||||
// Reconstruct the selected element, not a new document. In particular,
|
||||
// parsing a standalone tr/td as a document discards its table structure.
|
||||
let tag = html.range(of: #"^\s*<([A-Za-z][A-Za-z0-9:-]*)"#, options: .regularExpression)
|
||||
.map { String(html[$0]).trimmingCharacters(in: .whitespacesAndNewlines).dropFirst().lowercased() } ?? ""
|
||||
let parent: String
|
||||
switch tag {
|
||||
case "tr": parent = "tbody"
|
||||
case "td", "th": parent = "tr"
|
||||
case "tbody", "thead", "tfoot", "caption", "colgroup": parent = "table"
|
||||
case "col": parent = "colgroup"
|
||||
case "option", "optgroup": parent = "select"
|
||||
default: parent = "body"
|
||||
}
|
||||
let container = try Element(Tag.valueOf(parent), baseURL)
|
||||
let nodes = try Parser.parseFragment(html, container, Array(baseURL.utf8))
|
||||
let elements = nodes.compactMap { $0 as? Element }
|
||||
if elements.count == 1, let element = elements.first { return element }
|
||||
return try SwiftSoup.parseBodyFragment(html, baseURL)
|
||||
}
|
||||
|
||||
private func selectElements(stages: [String], from document: Element) throws -> [Element] {
|
||||
var current: [Element] = [document]
|
||||
for stage in stages {
|
||||
var selected: [Element] = []
|
||||
let parsed = parseIndexedSelector(stage)
|
||||
for parent in current {
|
||||
let candidates: [Element]
|
||||
switch parsed.selector {
|
||||
case "", "children": candidates = parent.children().array()
|
||||
default: candidates = try parent.select(cssSelector(parsed.selector)).array()
|
||||
}
|
||||
selected += applyIndex(parsed, to: candidates)
|
||||
}
|
||||
current = selected
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private func cssSelector(_ legacy: String) -> String {
|
||||
if legacy.hasPrefix("class.") { return "." + legacy.dropFirst(6) }
|
||||
if legacy.hasPrefix("tag.") { return String(legacy.dropFirst(4)) }
|
||||
if legacy.hasPrefix("id.") { return "#" + legacy.dropFirst(3) }
|
||||
if legacy.hasPrefix("text.") {
|
||||
let value = String(legacy.dropFirst(5)).replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return ":containsOwn(\(value))"
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
private func parseIndexedSelector(_ source: String) -> IndexedSelector {
|
||||
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasSuffix("]"), let open = value.lastIndex(of: "[") {
|
||||
var content = String(value[value.index(after: open)..<value.index(before: value.endIndex)])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let allowed = CharacterSet(charactersIn: "!-0123456789:, ")
|
||||
if !content.isEmpty, content.unicodeScalars.allSatisfy(allowed.contains) {
|
||||
let excludes = content.hasPrefix("!")
|
||||
if excludes { content.removeFirst() }
|
||||
let items = content.split(separator: ",", omittingEmptySubsequences: false).compactMap(parseIndexItem)
|
||||
if !items.isEmpty {
|
||||
return .init(
|
||||
selector: String(value[..<open]).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
excludes: excludes,
|
||||
items: items
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for delimiter in ["!", "."] {
|
||||
guard let marker = value.lastIndex(of: Character(delimiter)) else { continue }
|
||||
let suffix = String(value[value.index(after: marker)...])
|
||||
let values = suffix.split(separator: ":", omittingEmptySubsequences: false).compactMap {
|
||||
Int($0.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
if !suffix.isEmpty, values.count == suffix.split(separator: ":", omittingEmptySubsequences: false).count {
|
||||
return .init(
|
||||
selector: String(value[..<marker]),
|
||||
excludes: delimiter == "!",
|
||||
items: values.map(IndexItem.index)
|
||||
)
|
||||
}
|
||||
}
|
||||
return .init(selector: value, excludes: false, items: [])
|
||||
}
|
||||
|
||||
private func parseIndexItem(_ source: Substring) -> IndexItem? {
|
||||
let values = source.split(separator: ":", omittingEmptySubsequences: false)
|
||||
if values.count == 1, let index = Int(values[0].trimmingCharacters(in: .whitespaces)) {
|
||||
return .index(index)
|
||||
}
|
||||
guard (2...3).contains(values.count) else { return nil }
|
||||
let start = values[0].isEmpty ? nil : Int(values[0].trimmingCharacters(in: .whitespaces))
|
||||
let end = values[1].isEmpty ? nil : Int(values[1].trimmingCharacters(in: .whitespaces))
|
||||
let step = values.count == 3 ? Int(values[2].trimmingCharacters(in: .whitespaces)) ?? 1 : 1
|
||||
return step == 0 ? nil : .range(start, end, step)
|
||||
}
|
||||
|
||||
private func applyIndex(_ selector: IndexedSelector, to elements: [Element]) -> [Element] {
|
||||
guard !selector.items.isEmpty, !elements.isEmpty else { return elements }
|
||||
var indexes: [Int] = []
|
||||
func append(_ index: Int) {
|
||||
let resolved = index < 0 ? elements.count + index : index
|
||||
if elements.indices.contains(resolved), !indexes.contains(resolved) { indexes.append(resolved) }
|
||||
}
|
||||
for item in selector.items {
|
||||
switch item {
|
||||
case .index(let index): append(index)
|
||||
case .range(let rawStart, let rawEnd, let rawStep):
|
||||
var start = rawStart ?? 0
|
||||
var end = rawEnd ?? (elements.count - 1)
|
||||
if start < 0 { start += elements.count }
|
||||
if end < 0 { end += elements.count }
|
||||
if (start < 0 && end < 0) || (start >= elements.count && end >= elements.count) {
|
||||
continue
|
||||
}
|
||||
start = min(max(start, 0), elements.count - 1)
|
||||
end = min(max(end, 0), elements.count - 1)
|
||||
let direction = end >= start ? 1 : -1
|
||||
let normalizedStep: Int
|
||||
if rawStep > 0 {
|
||||
normalizedStep = rawStep
|
||||
} else if -rawStep < elements.count {
|
||||
normalizedStep = rawStep + elements.count
|
||||
} else {
|
||||
normalizedStep = 1
|
||||
}
|
||||
let step = max(normalizedStep, 1) * direction
|
||||
var cursor = start
|
||||
while direction > 0 ? cursor <= end : cursor >= end {
|
||||
append(cursor)
|
||||
cursor += step
|
||||
}
|
||||
}
|
||||
}
|
||||
if selector.excludes {
|
||||
let excluded = Set(indexes)
|
||||
return elements.enumerated().compactMap { excluded.contains($0.offset) ? nil : $0.element }
|
||||
}
|
||||
return indexes.map { elements[$0] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import Foundation
|
||||
|
||||
public struct JSONPathEvaluator: Sendable {
|
||||
private enum Selector: Equatable {
|
||||
case key(String)
|
||||
case index(Int)
|
||||
case wildcard
|
||||
}
|
||||
|
||||
private enum Segment: Equatable {
|
||||
case child(Selector)
|
||||
case recursive(Selector)
|
||||
case union([Selector])
|
||||
case slice(start: Int?, end: Int?, step: Int)
|
||||
case filter(String)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
|
||||
let segments = try parse(path)
|
||||
var current = [root]
|
||||
for segment in segments {
|
||||
try Task.checkCancellation()
|
||||
switch segment {
|
||||
case .child(let selector):
|
||||
current = current.flatMap { select(selector, from: $0) }
|
||||
case .recursive(let selector):
|
||||
current = current.flatMap { recursivelySelect(selector, from: $0) }
|
||||
case .union(let selectors):
|
||||
current = current.flatMap { node in selectors.flatMap { select($0, from: node) } }
|
||||
case .slice(let start, let end, let step):
|
||||
current = current.flatMap { slice($0, start: start, end: end, step: step) }
|
||||
case .filter(let predicate):
|
||||
var filtered: [JSONValue] = []
|
||||
for node in current {
|
||||
guard case .array(let values) = node else { continue }
|
||||
for value in values where try matches(predicate, candidate: value, root: root) {
|
||||
filtered.append(value)
|
||||
}
|
||||
}
|
||||
current = filtered
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private func parse(_ path: String) throws -> [Segment] {
|
||||
let rule = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard rule.first == "$" else {
|
||||
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 必须以 $ 开始")
|
||||
}
|
||||
var segments: [Segment] = []
|
||||
var index = rule.index(after: rule.startIndex)
|
||||
while index < rule.endIndex {
|
||||
let character = rule[index]
|
||||
if character == "." {
|
||||
let next = rule.index(after: index)
|
||||
guard next < rule.endIndex else {
|
||||
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
|
||||
}
|
||||
if rule[next] == "." {
|
||||
index = rule.index(after: next)
|
||||
let parsed = try parseDotSelector(rule, from: index, original: path)
|
||||
segments.append(.recursive(parsed.selector))
|
||||
index = parsed.next
|
||||
} else {
|
||||
index = next
|
||||
let parsed = try parseDotSelector(rule, from: index, original: path)
|
||||
segments.append(.child(parsed.selector))
|
||||
index = parsed.next
|
||||
}
|
||||
} else if character == "[" {
|
||||
let parsed = try parseBracket(rule, from: index, original: path)
|
||||
segments.append(parsed.segment)
|
||||
index = parsed.next
|
||||
} else {
|
||||
throw RuleEngineError.malformedRule(
|
||||
rule: path,
|
||||
detail: "JSONPath 在 \(rule.distance(from: rule.startIndex, to: index)) 处包含意外字符"
|
||||
)
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
private func parseDotSelector(
|
||||
_ rule: String,
|
||||
from start: String.Index,
|
||||
original: String
|
||||
) throws -> (selector: Selector, next: String.Index) {
|
||||
if rule[start] == "*" {
|
||||
return (.wildcard, rule.index(after: start))
|
||||
}
|
||||
var end = start
|
||||
while end < rule.endIndex, rule[end] != ".", rule[end] != "[" {
|
||||
end = rule.index(after: end)
|
||||
}
|
||||
let key = String(rule[start..<end])
|
||||
guard !key.isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 属性名不能为空")
|
||||
}
|
||||
return (.key(key), end)
|
||||
}
|
||||
|
||||
private func parseBracket(
|
||||
_ rule: String,
|
||||
from open: String.Index,
|
||||
original: String
|
||||
) throws -> (segment: Segment, next: String.Index) {
|
||||
var index = rule.index(after: open)
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
var depth = 1
|
||||
while index < rule.endIndex {
|
||||
let character = rule[index]
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if character == "\\" {
|
||||
escaped = true
|
||||
} else if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
} else if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
} else if character == "[" {
|
||||
depth += 1
|
||||
} else if character == "]" {
|
||||
depth -= 1
|
||||
guard depth == 0 else {
|
||||
index = rule.index(after: index)
|
||||
continue
|
||||
}
|
||||
let content = String(rule[rule.index(after: open)..<index])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let segment = try parseBracketContent(content, original: original)
|
||||
return (segment, rule.index(after: index))
|
||||
}
|
||||
index = rule.index(after: index)
|
||||
}
|
||||
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号未闭合")
|
||||
}
|
||||
|
||||
private func parseBracketContent(_ content: String, original: String) throws -> Segment {
|
||||
guard !content.isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号不能为空")
|
||||
}
|
||||
if content.hasPrefix("?(") && content.hasSuffix(")") {
|
||||
let predicate = String(content.dropFirst(2).dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !predicate.isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath filter 不能为空")
|
||||
}
|
||||
return .filter(predicate)
|
||||
}
|
||||
if content == "*" { return .child(.wildcard) }
|
||||
if content.contains(":") {
|
||||
let values = content.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard (2 ... 3).contains(values.count),
|
||||
let step = values.count == 3 ? Int(values[2]) : 1,
|
||||
step != 0 else {
|
||||
throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath slice 格式无效")
|
||||
}
|
||||
return .slice(
|
||||
start: values[0].isEmpty ? nil : Int(values[0]),
|
||||
end: values[1].isEmpty ? nil : Int(values[1]),
|
||||
step: step
|
||||
)
|
||||
}
|
||||
let parts = splitCommaSeparated(content)
|
||||
let selectors = try parts.map { try parseSelector($0, original: original) }
|
||||
return selectors.count == 1 ? .child(selectors[0]) : .union(selectors)
|
||||
}
|
||||
|
||||
private func splitCommaSeparated(_ content: String) -> [String] {
|
||||
var parts: [String] = []
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
var start = content.startIndex
|
||||
var index = start
|
||||
while index < content.endIndex {
|
||||
let character = content[index]
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if character == "\\" {
|
||||
escaped = true
|
||||
} else if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
} else if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
} else if character == "," {
|
||||
parts.append(String(content[start..<index]).trimmingCharacters(in: .whitespaces))
|
||||
start = content.index(after: index)
|
||||
}
|
||||
index = content.index(after: index)
|
||||
}
|
||||
parts.append(String(content[start...]).trimmingCharacters(in: .whitespaces))
|
||||
return parts
|
||||
}
|
||||
|
||||
private func parseSelector(_ source: String, original: String) throws -> Selector {
|
||||
if source == "*" { return .wildcard }
|
||||
if let index = Int(source) { return .index(index) }
|
||||
if source.count >= 2,
|
||||
let first = source.first,
|
||||
first == "\"" || first == "'",
|
||||
source.last == first {
|
||||
let key = String(source.dropFirst().dropLast())
|
||||
.replacingOccurrences(of: "\\\(first)", with: String(first))
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
return .key(key)
|
||||
}
|
||||
throw RuleEngineError.malformedRule(rule: original, detail: "不支持的 JSONPath 选择器:\(source)")
|
||||
}
|
||||
|
||||
private func select(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
|
||||
switch (selector, value) {
|
||||
case (.key(let key), .object(let object)):
|
||||
return object[key].map { [$0] } ?? []
|
||||
case (.index(let index), .array(let array)):
|
||||
let resolved = index >= 0 ? index : array.count + index
|
||||
return array.indices.contains(resolved) ? [array[resolved]] : []
|
||||
case (.wildcard, .object(let object)):
|
||||
return object.keys.sorted().compactMap { object[$0] }
|
||||
case (.wildcard, .array(let array)):
|
||||
return array
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func recursivelySelect(_ selector: Selector, from value: JSONValue) -> [JSONValue] {
|
||||
var result = select(selector, from: value)
|
||||
switch value {
|
||||
case .object(let object):
|
||||
for key in object.keys.sorted() {
|
||||
if let child = object[key] { result += recursivelySelect(selector, from: child) }
|
||||
}
|
||||
case .array(let array):
|
||||
for child in array { result += recursivelySelect(selector, from: child) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func slice(_ value: JSONValue, start: Int?, end: Int?, step: Int) -> [JSONValue] {
|
||||
guard case .array(let array) = value, !array.isEmpty else { return [] }
|
||||
let count = array.count
|
||||
let defaultStart = step > 0 ? 0 : count - 1
|
||||
let defaultEnd = step > 0 ? count : -1
|
||||
var cursor = normalize(start ?? defaultStart, count: count, allowEnd: true)
|
||||
let boundary = end == nil && step < 0 ? -1 : normalize(end ?? defaultEnd, count: count, allowEnd: true)
|
||||
var result: [JSONValue] = []
|
||||
while step > 0 ? cursor < boundary : cursor > boundary {
|
||||
if array.indices.contains(cursor) { result.append(array[cursor]) }
|
||||
cursor += step
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func normalize(_ index: Int, count: Int, allowEnd: Bool) -> Int {
|
||||
let value = index < 0 ? count + index : index
|
||||
return min(max(value, 0), allowEnd ? count : count - 1)
|
||||
}
|
||||
|
||||
private func matches(_ source: String, candidate: JSONValue, root: JSONValue) throws -> Bool {
|
||||
let predicate = stripOuterParentheses(source.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
if let parts = splitLogical(predicate, operator: "||"), parts.count > 1 {
|
||||
for part in parts where try matches(part, candidate: candidate, root: root) { return true }
|
||||
return false
|
||||
}
|
||||
if let parts = splitLogical(predicate, operator: "&&"), parts.count > 1 {
|
||||
for part in parts where !(try matches(part, candidate: candidate, root: root)) { return false }
|
||||
return true
|
||||
}
|
||||
if predicate.hasPrefix("!") && !predicate.hasPrefix("!=") {
|
||||
return try !matches(String(predicate.dropFirst()), candidate: candidate, root: root)
|
||||
}
|
||||
if let comparison = findComparison(predicate) {
|
||||
let lhs = try resolveOperand(String(predicate[..<comparison.range.lowerBound]), candidate: candidate, root: root)
|
||||
let rhs = try resolveOperand(String(predicate[comparison.range.upperBound...]), candidate: candidate, root: root)
|
||||
guard !lhs.isEmpty, !rhs.isEmpty else { return false }
|
||||
return try lhs.contains { left in
|
||||
try rhs.contains { right in try compare(left, right, operation: comparison.operation) }
|
||||
}
|
||||
}
|
||||
let values = try resolveOperand(predicate, candidate: candidate, root: root)
|
||||
return values.contains(where: isTruthy)
|
||||
}
|
||||
|
||||
private func splitLogical(_ source: String, operator operation: String) -> [String]? {
|
||||
var parts: [String] = []
|
||||
var quote: Character?
|
||||
var regex = false
|
||||
var escaped = false
|
||||
var roundDepth = 0
|
||||
var squareDepth = 0
|
||||
var start = source.startIndex
|
||||
var index = start
|
||||
while index < source.endIndex {
|
||||
let character = source[index]
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if character == "\\" {
|
||||
escaped = true
|
||||
} else if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
} else if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
} else if character == "/" {
|
||||
regex.toggle()
|
||||
} else if !regex {
|
||||
if character == "(" { roundDepth += 1 }
|
||||
if character == ")" { roundDepth -= 1 }
|
||||
if character == "[" { squareDepth += 1 }
|
||||
if character == "]" { squareDepth -= 1 }
|
||||
if roundDepth == 0, squareDepth == 0, source[index...].hasPrefix(operation) {
|
||||
parts.append(String(source[start..<index]).trimmingCharacters(in: .whitespaces))
|
||||
index = source.index(index, offsetBy: operation.count)
|
||||
start = index
|
||||
continue
|
||||
}
|
||||
}
|
||||
index = source.index(after: index)
|
||||
}
|
||||
guard !parts.isEmpty else { return nil }
|
||||
parts.append(String(source[start...]).trimmingCharacters(in: .whitespaces))
|
||||
return parts
|
||||
}
|
||||
|
||||
private func stripOuterParentheses(_ source: String) -> String {
|
||||
var value = source
|
||||
while value.first == "(", value.last == ")", outerParenthesesEncloseAll(value) {
|
||||
value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func outerParenthesesEncloseAll(_ source: String) -> Bool {
|
||||
var depth = 0
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
for index in source.indices {
|
||||
let character = source[index]
|
||||
if escaped { escaped = false; continue }
|
||||
if character == "\\" { escaped = true; continue }
|
||||
if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
continue
|
||||
}
|
||||
if character == "\"" || character == "'" { quote = character; continue }
|
||||
if character == "(" { depth += 1 }
|
||||
if character == ")" {
|
||||
depth -= 1
|
||||
if depth == 0 && index != source.index(before: source.endIndex) { return false }
|
||||
}
|
||||
}
|
||||
return depth == 0
|
||||
}
|
||||
|
||||
private func findComparison(_ source: String) -> (range: Range<String.Index>, operation: String)? {
|
||||
let operations = ["==", "!=", ">=", "<=", "=~", ">", "<"]
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
var roundDepth = 0
|
||||
var squareDepth = 0
|
||||
var index = source.startIndex
|
||||
while index < source.endIndex {
|
||||
let character = source[index]
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if character == "\\" {
|
||||
escaped = true
|
||||
} else if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
} else if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
} else {
|
||||
if character == "(" { roundDepth += 1 }
|
||||
if character == ")" { roundDepth -= 1 }
|
||||
if character == "[" { squareDepth += 1 }
|
||||
if character == "]" { squareDepth -= 1 }
|
||||
if roundDepth == 0, squareDepth == 0 {
|
||||
for operation in operations where source[index...].hasPrefix(operation) {
|
||||
return (index..<source.index(index, offsetBy: operation.count), operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
index = source.index(after: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveOperand(_ source: String, candidate: JSONValue, root: JSONValue) throws -> [JSONValue] {
|
||||
let value = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "JSONPath filter 比较值不能为空")
|
||||
}
|
||||
if value.first == "@" || value.first == "$" {
|
||||
return try resolvePath(value, base: value.first == "@" ? candidate : root)
|
||||
}
|
||||
if value == "true" { return [.bool(true)] }
|
||||
if value == "false" { return [.bool(false)] }
|
||||
if value == "null" { return [.null] }
|
||||
if let number = Double(value) { return [.number(number)] }
|
||||
if let first = value.first, (first == "\"" || first == "'"), value.last == first {
|
||||
let inner = String(value.dropFirst().dropLast())
|
||||
.replacingOccurrences(of: "\\\(first)", with: String(first))
|
||||
.replacingOccurrences(of: "\\n", with: "\n")
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
return [.string(inner)]
|
||||
}
|
||||
if value.first == "/", value.dropFirst().lastIndex(of: "/") != nil {
|
||||
return [.string(value)]
|
||||
}
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "不支持的 JSONPath filter 操作数:\(value)")
|
||||
}
|
||||
|
||||
private func resolvePath(_ path: String, base: JSONValue) throws -> [JSONValue] {
|
||||
var current = [base]
|
||||
var index = path.index(after: path.startIndex)
|
||||
while index < path.endIndex {
|
||||
if path[index] == "." {
|
||||
index = path.index(after: index)
|
||||
var end = index
|
||||
while end < path.endIndex, path[end] != ".", path[end] != "[" { end = path.index(after: end) }
|
||||
let key = String(path[index..<end])
|
||||
guard !key.isEmpty else { throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径属性为空") }
|
||||
current = current.flatMap { select(.key(key), from: $0) }
|
||||
index = end
|
||||
} else if path[index] == "[" {
|
||||
guard let close = path[index...].firstIndex(of: "]") else {
|
||||
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径方括号未闭合")
|
||||
}
|
||||
let token = String(path[path.index(after: index)..<close]).trimmingCharacters(in: .whitespaces)
|
||||
let selector = try parseSelector(token, original: path)
|
||||
current = current.flatMap { select(selector, from: $0) }
|
||||
index = path.index(after: close)
|
||||
} else {
|
||||
throw RuleEngineError.malformedRule(rule: path, detail: "filter 路径包含意外字符")
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private func compare(_ lhs: JSONValue, _ rhs: JSONValue, operation: String) throws -> Bool {
|
||||
if operation == "=~" {
|
||||
let literal = rhs.stringValue
|
||||
guard literal.first == "/", let closing = literal.dropFirst().lastIndex(of: "/") else {
|
||||
throw RuleEngineError.malformedRule(rule: literal, detail: "filter 正则必须写成 /pattern/flags")
|
||||
}
|
||||
let pattern = String(literal[literal.index(after: literal.startIndex)..<closing])
|
||||
guard pattern.count <= 1_024 else {
|
||||
throw RuleEngineError.unsupportedRule(rule: literal, feature: "过长 JSONPath filter 正则")
|
||||
}
|
||||
let flags = literal[literal.index(after: closing)...]
|
||||
let options: NSRegularExpression.Options = flags.contains("i") ? .caseInsensitive : []
|
||||
let regex = try NSRegularExpression(pattern: pattern, options: options)
|
||||
let range = NSRange(lhs.stringValue.startIndex..., in: lhs.stringValue)
|
||||
return regex.firstMatch(in: lhs.stringValue, range: range) != nil
|
||||
}
|
||||
if operation == "==" || operation == "!=" {
|
||||
let equal: Bool
|
||||
switch (lhs, rhs) {
|
||||
case (.number(let left), .number(let right)): equal = left == right
|
||||
case (.bool(let left), .bool(let right)): equal = left == right
|
||||
case (.null, .null): equal = true
|
||||
default: equal = lhs.stringValue == rhs.stringValue
|
||||
}
|
||||
return operation == "==" ? equal : !equal
|
||||
}
|
||||
let ordering: ComparisonResult
|
||||
if case .number(let left) = lhs, case .number(let right) = rhs {
|
||||
ordering = left < right ? .orderedAscending : left > right ? .orderedDescending : .orderedSame
|
||||
} else {
|
||||
ordering = lhs.stringValue.compare(rhs.stringValue, options: .numeric)
|
||||
}
|
||||
return switch operation {
|
||||
case ">": ordering == .orderedDescending
|
||||
case ">=": ordering != .orderedAscending
|
||||
case "<": ordering == .orderedAscending
|
||||
case "<=": ordering != .orderedDescending
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
private func isTruthy(_ value: JSONValue) -> Bool {
|
||||
switch value {
|
||||
case .null: false
|
||||
case .bool(let value): value
|
||||
case .number(let value): value != 0
|
||||
case .string(let value): !value.isEmpty
|
||||
case .array(let value): !value.isEmpty
|
||||
case .object(let value): !value.isEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
public enum JSONValue: Codable, Hashable, Sendable {
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case null
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([JSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
self = .object(try container.decode([String: JSONValue].self))
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .object(let value): try container.encode(value)
|
||||
case .array(let value): try container.encode(value)
|
||||
case .string(let value): try container.encode(value)
|
||||
case .number(let value): try container.encode(value)
|
||||
case .bool(let value): try container.encode(value)
|
||||
case .null: try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
public static func decode(data: Data) throws -> JSONValue {
|
||||
try JSONDecoder().decode(JSONValue.self, from: data)
|
||||
}
|
||||
|
||||
public var stringValue: String {
|
||||
switch self {
|
||||
case .string(let value): return value
|
||||
case .number(let value):
|
||||
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
|
||||
case .bool(let value): return String(value)
|
||||
case .null: return ""
|
||||
case .object, .array:
|
||||
guard let data = try? JSONEncoder().encode(self) else { return "" }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
public subscript(key: String) -> JSONValue? {
|
||||
guard case .object(let value) = self else { return nil }
|
||||
return value[key]
|
||||
}
|
||||
|
||||
public subscript(index: Int) -> JSONValue? {
|
||||
guard case .array(let value) = self else { return nil }
|
||||
let resolved = index >= 0 ? index : value.count + index
|
||||
guard value.indices.contains(resolved) else { return nil }
|
||||
return value[resolved]
|
||||
}
|
||||
}
|
||||
|
||||
public extension JSONValue {
|
||||
var foundationObject: Any {
|
||||
switch self {
|
||||
case .object(let value): value.mapValues(\.foundationObject)
|
||||
case .array(let value): value.map(\.foundationObject)
|
||||
case .string(let value): value
|
||||
case .number(let value): value
|
||||
case .bool(let value): value
|
||||
case .null: NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
static func bridge(_ value: Any?) -> JSONValue {
|
||||
guard let value else { return .null }
|
||||
if value is NSNull { return .null }
|
||||
if let value = value as? JSONValue { return value }
|
||||
if let value = value as? String { return .string(value) }
|
||||
if let value = value as? Bool { return .bool(value) }
|
||||
if let value = value as? NSNumber {
|
||||
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
|
||||
return .number(value.doubleValue)
|
||||
}
|
||||
if let value = value as? [Any] { return .array(value.map(bridge)) }
|
||||
if let value = value as? [String: Any] { return .object(value.mapValues(bridge)) }
|
||||
if let value = value as? NSDictionary {
|
||||
var object: [String: JSONValue] = [:]
|
||||
for (key, item) in value { object[String(describing: key)] = bridge(item) }
|
||||
return .object(object)
|
||||
}
|
||||
if let value = value as? NSArray { return .array(value.map(bridge)) }
|
||||
return .string(String(describing: value))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
|
||||
public struct RegexLimits: Sendable {
|
||||
public var maximumInputLength: Int
|
||||
public var maximumPatternLength: Int
|
||||
public var maximumMatches: Int
|
||||
|
||||
public init(
|
||||
maximumInputLength: Int = 2_000_000,
|
||||
maximumPatternLength: Int = 8_192,
|
||||
maximumMatches: Int = 10_000
|
||||
) {
|
||||
self.maximumInputLength = maximumInputLength
|
||||
self.maximumPatternLength = maximumPatternLength
|
||||
self.maximumMatches = maximumMatches
|
||||
}
|
||||
}
|
||||
|
||||
public struct RegexEvaluator: Sendable {
|
||||
public var limits: RegexLimits
|
||||
|
||||
public init(limits: RegexLimits = .init()) {
|
||||
self.limits = limits
|
||||
}
|
||||
|
||||
public func captureGroups(pattern: String, input: String) throws -> [[String]] {
|
||||
guard pattern.count <= limits.maximumPatternLength else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制")
|
||||
}
|
||||
guard input.utf16.count <= limits.maximumInputLength else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制")
|
||||
}
|
||||
guard !hasObviousNestedQuantifier(pattern) else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词")
|
||||
}
|
||||
let expression: NSRegularExpression
|
||||
do {
|
||||
expression = try NSRegularExpression(pattern: pattern)
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription)
|
||||
}
|
||||
let range = NSRange(input.startIndex..<input.endIndex, in: input)
|
||||
var captures: [[String]] = []
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000
|
||||
var timedOut = false
|
||||
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
|
||||
if DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
timedOut = true
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
guard let match else { return }
|
||||
if captures.count >= limits.maximumMatches {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
captures.append((0 ..< match.numberOfRanges).map { group in
|
||||
let range = match.range(at: group)
|
||||
guard range.location != NSNotFound, let swiftRange = Range(range, in: input) else { return "" }
|
||||
return String(input[swiftRange])
|
||||
})
|
||||
}
|
||||
if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") }
|
||||
if captures.count >= limits.maximumMatches {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制")
|
||||
}
|
||||
return captures
|
||||
}
|
||||
|
||||
public func replacing(
|
||||
pattern: String,
|
||||
in input: String,
|
||||
with replacement: String,
|
||||
firstOnly: Bool
|
||||
) throws -> String {
|
||||
guard pattern.count <= limits.maximumPatternLength else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制")
|
||||
}
|
||||
guard input.utf16.count <= limits.maximumInputLength else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制")
|
||||
}
|
||||
guard !hasObviousNestedQuantifier(pattern) else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词")
|
||||
}
|
||||
let expression: NSRegularExpression
|
||||
do {
|
||||
expression = try NSRegularExpression(pattern: pattern)
|
||||
} catch {
|
||||
if firstOnly { return replacement }
|
||||
return input.replacingOccurrences(of: pattern, with: replacement)
|
||||
}
|
||||
let range = NSRange(input.startIndex..<input.endIndex, in: input)
|
||||
if firstOnly {
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds + 3_000_000_000
|
||||
var first: NSTextCheckingResult?
|
||||
var timedOut = false
|
||||
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
|
||||
if DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
timedOut = true
|
||||
stop.pointee = true
|
||||
} else if let match {
|
||||
first = match
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") }
|
||||
guard let first else { return "" }
|
||||
return expression.replacementString(for: first, in: input, offset: 0, template: replacement)
|
||||
}
|
||||
return try replacingStrict(pattern: pattern, in: input, with: replacement)
|
||||
}
|
||||
|
||||
public func replacingStrict(pattern: String, in input: String, with replacement: String,
|
||||
timeoutMilliseconds: Int64 = 3_000) throws -> String {
|
||||
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
|
||||
!hasObviousNestedQuantifier(pattern) else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
|
||||
}
|
||||
let expression: NSRegularExpression
|
||||
do { expression = try NSRegularExpression(pattern: pattern) }
|
||||
catch { throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription) }
|
||||
let milliseconds = timeoutMilliseconds <= 0 ? 3_000 : min(timeoutMilliseconds, 60_000)
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(milliseconds) * 1_000_000
|
||||
let range = NSRange(location: 0, length: input.utf16.count)
|
||||
var matches: [NSTextCheckingResult] = []
|
||||
var timedOut = false
|
||||
var exceeded = false
|
||||
expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in
|
||||
if DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
timedOut = true
|
||||
stop.pointee = true
|
||||
} else if let match {
|
||||
if matches.count >= limits.maximumMatches { exceeded = true; stop.pointee = true }
|
||||
else { matches.append(match) }
|
||||
}
|
||||
}
|
||||
guard !timedOut else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") }
|
||||
guard !exceeded else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制") }
|
||||
let original = input as NSString
|
||||
var output = ""
|
||||
var offset = 0
|
||||
var outputLength = 0
|
||||
for match in matches {
|
||||
guard DispatchTime.now().uptimeNanoseconds < deadline else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
|
||||
}
|
||||
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset))
|
||||
let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement)
|
||||
output += untouched + replaced
|
||||
outputLength += untouched.utf16.count + replaced.utf16.count
|
||||
offset = NSMaxRange(match.range)
|
||||
guard outputLength <= limits.maximumInputLength * 2 else {
|
||||
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
|
||||
}
|
||||
}
|
||||
output += original.substring(from: offset)
|
||||
return output
|
||||
}
|
||||
|
||||
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
|
||||
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"#
|
||||
guard let expression = try? NSRegularExpression(pattern: risky) else { return false }
|
||||
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in
|
||||
guard let innerRange = Range(match.range(at: 1), in: pattern),
|
||||
let outerRange = Range(match.range(at: 2), in: pattern) else { return true }
|
||||
let inner = String(pattern[innerRange])
|
||||
let outer = String(pattern[outerRange])
|
||||
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false }
|
||||
if !pattern.contains("(?s"),
|
||||
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
public struct RuleDiagnostic: Codable, Hashable, Identifiable, Sendable {
|
||||
public enum Severity: String, Codable, Sendable {
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
public var severity: Severity
|
||||
public var code: String
|
||||
public var message: String
|
||||
public var rule: String
|
||||
|
||||
public var id: String { "\(severity.rawValue):\(code):\(rule):\(message)" }
|
||||
|
||||
public init(severity: Severity, code: String, message: String, rule: String) {
|
||||
self.severity = severity
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.rule = rule
|
||||
}
|
||||
}
|
||||
|
||||
public enum RuleEngineError: Error, Equatable, LocalizedError, Sendable {
|
||||
case malformedRule(rule: String, detail: String)
|
||||
case unsupportedRule(rule: String, feature: String)
|
||||
case invalidInput(rule: String, expected: String)
|
||||
case resourceLimit(rule: String, detail: String)
|
||||
|
||||
public var code: String {
|
||||
switch self {
|
||||
case .malformedRule: "rule.malformed"
|
||||
case .unsupportedRule: "rule.unsupported"
|
||||
case .invalidInput: "rule.invalid_input"
|
||||
case .resourceLimit: "rule.resource_limit"
|
||||
}
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .malformedRule(_, let detail),
|
||||
.invalidInput(_, let detail),
|
||||
.resourceLimit(_, let detail):
|
||||
detail
|
||||
case .unsupportedRule(_, let feature):
|
||||
"当前规则后端尚不支持:\(feature)"
|
||||
}
|
||||
}
|
||||
|
||||
public var rule: String {
|
||||
switch self {
|
||||
case .malformedRule(let rule, _),
|
||||
.unsupportedRule(let rule, _),
|
||||
.invalidInput(let rule, _),
|
||||
.resourceLimit(let rule, _):
|
||||
rule
|
||||
}
|
||||
}
|
||||
|
||||
public var diagnostic: RuleDiagnostic {
|
||||
.init(severity: .error, code: code, message: localizedDescription, rule: rule)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuleEvaluation: Sendable {
|
||||
public var values: [JSONValue]
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
public var variables: [String: String]
|
||||
|
||||
public init(
|
||||
values: [JSONValue] = [],
|
||||
diagnostics: [RuleDiagnostic] = [],
|
||||
variables: [String: String] = [:]
|
||||
) {
|
||||
self.values = values
|
||||
self.diagnostics = diagnostics
|
||||
self.variables = variables
|
||||
}
|
||||
|
||||
public var strings: [String] {
|
||||
values.flatMap { value in
|
||||
if case .array(let items) = value { return items.map(\.stringValue) }
|
||||
return [value.stringValue]
|
||||
}
|
||||
}
|
||||
|
||||
func listItems(rule: String) -> [JSONValue] {
|
||||
if rule.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(":") { return values }
|
||||
return values.flatMap { value in
|
||||
if case .array(let items) = value { return items }
|
||||
return [value]
|
||||
}
|
||||
}
|
||||
public var firstString: String? { strings.first(where: { !$0.isEmpty }) }
|
||||
public var leadingString: String? {
|
||||
guard let value = strings.first, !value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
public var joinedString: String? {
|
||||
let value = strings.joined(separator: "\n")
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import Foundation
|
||||
|
||||
public struct RuleEngine: Sendable {
|
||||
private let parser: RuleExpressionParser
|
||||
private let jsonPath: JSONPathEvaluator
|
||||
private let regex: RegexEvaluator
|
||||
private let html: HTMLRuleEvaluator
|
||||
private let javaScript: JavaScriptRuleEvaluator
|
||||
|
||||
public init(
|
||||
parser: RuleExpressionParser = .init(),
|
||||
jsonPath: JSONPathEvaluator = .init(),
|
||||
regex: RegexEvaluator = .init(),
|
||||
html: HTMLRuleEvaluator = .init(),
|
||||
javaScript: JavaScriptRuleEvaluator = .init()
|
||||
) {
|
||||
self.parser = parser
|
||||
self.jsonPath = jsonPath
|
||||
self.regex = regex
|
||||
self.html = html
|
||||
self.javaScript = javaScript
|
||||
}
|
||||
|
||||
public func evaluate(
|
||||
_ rule: String,
|
||||
in input: JSONValue,
|
||||
listMode: Bool = false,
|
||||
context: RuleExecutionContext = .init()
|
||||
) async -> RuleEvaluation {
|
||||
var context = context
|
||||
if context.content == nil { context.content = input }
|
||||
if let pipeline = splitJavaScriptPipeline(rule) {
|
||||
return await evaluatePipeline(pipeline, input: input, listMode: listMode, context: context)
|
||||
}
|
||||
do {
|
||||
let extracted = try extractPutRules(rule)
|
||||
if !extracted.puts.isEmpty {
|
||||
var nextContext = context
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
for key in extracted.puts.keys.sorted() {
|
||||
guard let valueRule = extracted.puts[key] else { continue }
|
||||
let value = await evaluate(valueRule, in: input, context: nextContext)
|
||||
diagnostics += value.diagnostics
|
||||
nextContext.variables.merge(value.variables) { _, new in new }
|
||||
nextContext.variables[key] = value.strings.joined(separator: "\n")
|
||||
}
|
||||
if extracted.rule.isEmpty {
|
||||
return .init(values: [input], diagnostics: diagnostics, variables: nextContext.variables)
|
||||
}
|
||||
var result = await evaluate(extracted.rule, in: input, listMode: listMode, context: nextContext)
|
||||
result.diagnostics = diagnostics + result.diagnostics
|
||||
result.variables = nextContext.variables.merging(result.variables) { _, new in new }
|
||||
return result
|
||||
}
|
||||
let rendered = try await renderDynamicRule(extracted.rule, result: input, context: context)
|
||||
if rendered.captureLiteral {
|
||||
return literalEvaluation(rendered.rule, context: rendered.context)
|
||||
}
|
||||
if rendered.context.variables != context.variables {
|
||||
var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
|
||||
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
|
||||
return result
|
||||
}
|
||||
return await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
||||
} catch {
|
||||
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluatePrepared(
|
||||
_ rule: String,
|
||||
in input: JSONValue,
|
||||
context: RuleExecutionContext
|
||||
) async -> RuleEvaluation {
|
||||
do {
|
||||
let plan = try parser.parse(rule, inputLooksLikeJSON: true)
|
||||
var results: [[JSONValue]] = []
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
for branch in plan.branches {
|
||||
do {
|
||||
let values = try await evaluate(branch, in: input, context: context)
|
||||
results.append(values)
|
||||
if plan.combination == .fallback, !values.isEmpty { break }
|
||||
} catch let error as RuleEngineError {
|
||||
diagnostics.append(error.diagnostic)
|
||||
results.append([])
|
||||
} catch {
|
||||
diagnostics.append(.init(
|
||||
severity: .error,
|
||||
code: "rule.unexpected",
|
||||
message: error.localizedDescription,
|
||||
rule: branch.expression
|
||||
))
|
||||
results.append([])
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
values: combine(results, with: plan.combination),
|
||||
diagnostics: diagnostics,
|
||||
variables: context.variables
|
||||
)
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
||||
} catch {
|
||||
return .init(diagnostics: [.init(
|
||||
severity: .error,
|
||||
code: "rule.unexpected",
|
||||
message: error.localizedDescription,
|
||||
rule: rule
|
||||
)], variables: context.variables)
|
||||
}
|
||||
}
|
||||
|
||||
public func evaluateJSONPath(_ rule: String, in input: JSONValue) throws -> [JSONValue] {
|
||||
try jsonPath.evaluate(path: rule, in: input)
|
||||
}
|
||||
|
||||
public func evaluate(
|
||||
_ rule: String,
|
||||
inText input: String,
|
||||
baseURL: String = "",
|
||||
listMode: Bool = false,
|
||||
context: RuleExecutionContext = .init()
|
||||
) async -> RuleEvaluation {
|
||||
var context = context
|
||||
if context.baseURL.isEmpty { context.baseURL = baseURL }
|
||||
if context.content == nil { context.content = .string(input) }
|
||||
if let pipeline = splitJavaScriptPipeline(rule) {
|
||||
return await evaluatePipeline(pipeline, input: .string(input), listMode: listMode, context: context)
|
||||
}
|
||||
do {
|
||||
let extracted = try extractPutRules(rule)
|
||||
if !extracted.puts.isEmpty {
|
||||
var nextContext = context
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
for key in extracted.puts.keys.sorted() {
|
||||
guard let valueRule = extracted.puts[key] else { continue }
|
||||
let value = await evaluate(valueRule, inText: input, baseURL: baseURL, listMode: false, context: nextContext)
|
||||
diagnostics += value.diagnostics
|
||||
nextContext.variables.merge(value.variables) { _, new in new }
|
||||
nextContext.variables[key] = value.strings.joined(separator: "\n")
|
||||
}
|
||||
if extracted.rule.isEmpty {
|
||||
return .init(values: [.string(input)], diagnostics: diagnostics, variables: nextContext.variables)
|
||||
}
|
||||
var result = await evaluate(extracted.rule, inText: input, baseURL: baseURL, listMode: listMode, context: nextContext)
|
||||
result.diagnostics = diagnostics + result.diagnostics
|
||||
result.variables = nextContext.variables.merging(result.variables) { _, new in new }
|
||||
return result
|
||||
}
|
||||
let rendered = try await renderDynamicRule(extracted.rule, result: .string(input), context: context)
|
||||
if rendered.captureLiteral {
|
||||
return literalEvaluation(rendered.rule, context: rendered.context)
|
||||
}
|
||||
if rendered.context.variables != context.variables {
|
||||
var result = await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context)
|
||||
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
|
||||
return result
|
||||
}
|
||||
return await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context)
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
||||
} catch {
|
||||
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluatePrepared(
|
||||
_ rule: String,
|
||||
inText input: String,
|
||||
baseURL: String,
|
||||
listMode: Bool,
|
||||
context: RuleExecutionContext
|
||||
) async -> RuleEvaluation {
|
||||
if rule.hasPrefix(":"), rule.count > 1 {
|
||||
do {
|
||||
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
|
||||
guard !patterns.isEmpty else { return .init(variables: context.variables) }
|
||||
var stage = input
|
||||
var captures: [[String]] = []
|
||||
for (index, pattern) in patterns.enumerated() {
|
||||
captures = try regex.captureGroups(pattern: pattern, input: stage)
|
||||
if index + 1 < patterns.count {
|
||||
stage = captures.compactMap(\.first).joined()
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
values: captures.map { .array($0.map(JSONValue.string)) },
|
||||
variables: context.variables
|
||||
)
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
||||
} catch {
|
||||
return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let plan = try parser.parse(rule, inputLooksLikeJSON: (try? JSONValue.decode(data: Data(input.utf8))) != nil)
|
||||
var results: [[JSONValue]] = []
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
for branch in plan.branches {
|
||||
do {
|
||||
let strings = try await evaluateTextBranch(
|
||||
branch,
|
||||
input: input,
|
||||
baseURL: baseURL,
|
||||
listMode: listMode,
|
||||
context: context
|
||||
)
|
||||
results.append(strings.map(JSONValue.string))
|
||||
if plan.combination == .fallback, !strings.isEmpty { break }
|
||||
} catch let error as RuleEngineError {
|
||||
diagnostics.append(error.diagnostic)
|
||||
results.append([])
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
values: combine(results, with: plan.combination),
|
||||
diagnostics: diagnostics,
|
||||
variables: context.variables
|
||||
)
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(diagnostics: [error.diagnostic], variables: context.variables)
|
||||
} catch {
|
||||
return .init(diagnostics: [.init(
|
||||
severity: .error,
|
||||
code: "rule.unexpected",
|
||||
message: error.localizedDescription,
|
||||
rule: rule
|
||||
)], variables: context.variables)
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluate(
|
||||
_ branch: RuleBranch,
|
||||
in input: JSONValue,
|
||||
context: RuleExecutionContext
|
||||
) async throws -> [JSONValue] {
|
||||
try Task.checkCancellation()
|
||||
let rawValues: [JSONValue]
|
||||
if branch.expression.isEmpty {
|
||||
return try [JSONValue.string(regex.replacing(
|
||||
pattern: branch.replacePattern ?? "", in: input.stringValue,
|
||||
with: branch.replacement, firstOnly: branch.replaceFirst
|
||||
))]
|
||||
}
|
||||
switch branch.mode {
|
||||
case .jsonPath:
|
||||
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
|
||||
rawValues = try jsonPath.evaluate(path: path, in: input)
|
||||
case .regex:
|
||||
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
|
||||
rawValues = matches.map { .array($0.map(JSONValue.string)) }
|
||||
case .css:
|
||||
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "CSS selector")
|
||||
case .xpath:
|
||||
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "XPath")
|
||||
case .javaScript:
|
||||
rawValues = [try await javaScript.evaluateAsync(
|
||||
script: branch.expression,
|
||||
result: input,
|
||||
executionContext: context
|
||||
).value]
|
||||
case .webJavaScript:
|
||||
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
|
||||
}
|
||||
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues }
|
||||
return try rawValues.map {
|
||||
.string(try regex.replacing(
|
||||
pattern: pattern,
|
||||
in: $0.stringValue,
|
||||
with: branch.replacement,
|
||||
firstOnly: branch.replaceFirst
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluateTextBranch(
|
||||
_ branch: RuleBranch,
|
||||
input: String,
|
||||
baseURL: String,
|
||||
listMode: Bool,
|
||||
context: RuleExecutionContext
|
||||
) async throws -> [String] {
|
||||
try Task.checkCancellation()
|
||||
let raw: [String]
|
||||
if branch.expression.isEmpty {
|
||||
return try [regex.replacing(
|
||||
pattern: branch.replacePattern ?? "", in: input,
|
||||
with: branch.replacement, firstOnly: branch.replaceFirst
|
||||
)]
|
||||
}
|
||||
switch branch.mode {
|
||||
case .css:
|
||||
raw = try listMode
|
||||
? html.selectCSSFragments(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment)
|
||||
: html.extractCSS(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment)
|
||||
case .xpath:
|
||||
raw = try listMode
|
||||
? html.selectXPathFragments(branch.expression, html: input, isFragment: context.isHTMLFragment)
|
||||
: html.extractXPath(branch.expression, html: input, isFragment: context.isHTMLFragment)
|
||||
case .jsonPath:
|
||||
let root: JSONValue
|
||||
do {
|
||||
root = try JSONValue.decode(data: Data(input.utf8))
|
||||
} catch {
|
||||
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
|
||||
}
|
||||
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
|
||||
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
|
||||
case .regex:
|
||||
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
|
||||
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
|
||||
}
|
||||
case .javaScript:
|
||||
raw = [try await javaScript.evaluateAsync(
|
||||
script: branch.expression,
|
||||
result: .string(input),
|
||||
executionContext: context
|
||||
).value.stringValue]
|
||||
case .webJavaScript:
|
||||
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
|
||||
}
|
||||
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw }
|
||||
return try raw.map {
|
||||
try regex.replacing(
|
||||
pattern: pattern,
|
||||
in: $0,
|
||||
with: branch.replacement,
|
||||
firstOnly: branch.replaceFirst
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func combine(_ results: [[JSONValue]], with combination: RuleCombination) -> [JSONValue] {
|
||||
switch combination {
|
||||
case .single, .concatenate:
|
||||
return results.flatMap { $0 }
|
||||
case .fallback:
|
||||
return results.first(where: { !$0.isEmpty }) ?? []
|
||||
case .interleave:
|
||||
guard let firstCount = results.first?.count else { return [] }
|
||||
return (0 ..< firstCount).flatMap { index in
|
||||
results.compactMap { $0.indices.contains(index) ? $0[index] : nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluatePipeline(
|
||||
_ pipeline: JavaScriptPipeline,
|
||||
input: JSONValue,
|
||||
listMode: Bool,
|
||||
context: RuleExecutionContext
|
||||
) async -> RuleEvaluation {
|
||||
var execution = context
|
||||
let initial: RuleEvaluation
|
||||
if pipeline.prefix.isEmpty {
|
||||
initial = .init(values: [input], variables: context.variables)
|
||||
} else {
|
||||
initial = await evaluateValue(pipeline.prefix, input: input, listMode: listMode, context: context)
|
||||
}
|
||||
execution.variables = initial.variables
|
||||
var diagnostics = initial.diagnostics
|
||||
let scriptInput: JSONValue
|
||||
if pipeline.prefix.isEmpty {
|
||||
scriptInput = input
|
||||
} else if listMode {
|
||||
if initial.values.count == 1, case .array = initial.values[0] {
|
||||
scriptInput = initial.values[0]
|
||||
} else {
|
||||
scriptInput = .array(initial.values)
|
||||
}
|
||||
} else {
|
||||
scriptInput = .string(initial.strings.joined(separator: "\n"))
|
||||
}
|
||||
do {
|
||||
let rendered = try await renderDynamicRule(pipeline.script, result: scriptInput, context: execution)
|
||||
let result = try await javaScript.evaluateAsync(
|
||||
script: rendered.rule, result: scriptInput, executionContext: rendered.context
|
||||
)
|
||||
execution.variables = result.variables
|
||||
guard !pipeline.suffix.isEmpty else {
|
||||
return .init(values: result.value == .null ? [] : [result.value], diagnostics: diagnostics, variables: execution.variables)
|
||||
}
|
||||
execution.isHTMLFragment = false
|
||||
var next = await evaluateValue(pipeline.suffix, input: result.value, listMode: listMode, context: execution)
|
||||
next.diagnostics = diagnostics + next.diagnostics
|
||||
return next
|
||||
} catch let error as RuleEngineError {
|
||||
diagnostics.append(error.diagnostic)
|
||||
} catch {
|
||||
diagnostics.append(.init(severity: .error, code: "rule.javascript", message: error.localizedDescription, rule: pipeline.script))
|
||||
}
|
||||
return .init(diagnostics: diagnostics, variables: execution.variables)
|
||||
}
|
||||
|
||||
private func evaluateValue(_ rule: String, input: JSONValue, listMode: Bool, context: RuleExecutionContext) async -> RuleEvaluation {
|
||||
if case .string(let text) = input {
|
||||
return await evaluate(rule, inText: text, baseURL: context.baseURL, listMode: listMode, context: context)
|
||||
}
|
||||
return await evaluate(rule, in: input, listMode: listMode, context: context)
|
||||
}
|
||||
|
||||
private func splitJavaScriptPipeline(_ rule: String) -> JavaScriptPipeline? {
|
||||
guard let pattern = try? NSRegularExpression(pattern: #"<js>([\s\S]*?)</js>|@js:([\s\S]*)"#, options: .caseInsensitive),
|
||||
let match = pattern.firstMatch(in: rule, range: NSRange(rule.startIndex..., in: rule)),
|
||||
let full = Range(match.range, in: rule),
|
||||
let script = Range(match.range(at: match.range(at: 2).location == NSNotFound ? 1 : 2), in: rule) else { return nil }
|
||||
return .init(
|
||||
prefix: String(rule[..<full.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
script: String(rule[script]),
|
||||
suffix: String(rule[full.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractPutRules(_ source: String) throws -> (rule: String, puts: [String: String]) {
|
||||
let expression = try NSRegularExpression(pattern: #"@put:(\{[^}]+?\})"#, options: .caseInsensitive)
|
||||
let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source))
|
||||
guard !matches.isEmpty else { return (source, [:]) }
|
||||
var puts: [String: String] = [:]
|
||||
var rule = source
|
||||
for match in matches.reversed() {
|
||||
guard let fullRange = Range(match.range(at: 0), in: source),
|
||||
let jsonRange = Range(match.range(at: 1), in: source) else { continue }
|
||||
if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(String(source[jsonRange]), quoteUnquotedValues: true) {
|
||||
puts.merge(object.mapValues(\.stringValue)) { _, new in new }
|
||||
}
|
||||
if let currentRange = Range(match.range(at: 0), in: rule) {
|
||||
rule.removeSubrange(currentRange)
|
||||
} else {
|
||||
rule = rule.replacingOccurrences(of: String(source[fullRange]), with: "")
|
||||
}
|
||||
}
|
||||
return (rule.trimmingCharacters(in: .whitespacesAndNewlines), puts)
|
||||
}
|
||||
|
||||
private func renderDynamicRule(
|
||||
_ source: String,
|
||||
result: JSONValue,
|
||||
context: RuleExecutionContext
|
||||
) async throws -> (rule: String, context: RuleExecutionContext, captureLiteral: Bool) {
|
||||
var source = source
|
||||
var captureLiteral = false
|
||||
if case .array(let captures) = result {
|
||||
let captureExpression = try NSRegularExpression(pattern: #"\$(\d{1,2})"#)
|
||||
let captureEnd = source.range(of: "##")?.lowerBound ?? source.endIndex
|
||||
let matches = captureExpression.matches(in: source, range: NSRange(source.startIndex..<captureEnd, in: source))
|
||||
if !matches.isEmpty {
|
||||
captureLiteral = true
|
||||
for match in matches.reversed() {
|
||||
guard let fullRange = Range(match.range(at: 0), in: source),
|
||||
let indexRange = Range(match.range(at: 1), in: source),
|
||||
let index = Int(source[indexRange]) else { continue }
|
||||
source.replaceSubrange(fullRange, with: captures.indices.contains(index) ? captures[index].stringValue : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
let expression = try NSRegularExpression(
|
||||
pattern: #"@get:\{([^}]+?)\}|\{\{([\s\S]*?)\}\}"#,
|
||||
options: .caseInsensitive
|
||||
)
|
||||
let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source))
|
||||
guard !matches.isEmpty else { return (source, context, captureLiteral) }
|
||||
let replacementStart = source.range(of: "##")?.lowerBound ?? source.endIndex
|
||||
let selectorTemplate = source.hasPrefix("$.") || source.hasPrefix("$[")
|
||||
if !selectorTemplate, let first = matches.first,
|
||||
first.range.location < NSRange(source.startIndex..<replacementStart, in: source).length {
|
||||
captureLiteral = true
|
||||
}
|
||||
var nextContext = context
|
||||
var output = ""
|
||||
var cursor = source.startIndex
|
||||
for match in matches {
|
||||
guard let fullRange = Range(match.range(at: 0), in: source) else { continue }
|
||||
output += source[cursor..<fullRange.lowerBound]
|
||||
if match.range(at: 1).location != NSNotFound,
|
||||
let keyRange = Range(match.range(at: 1), in: source) {
|
||||
let key = String(source[keyRange])
|
||||
if key == "bookName", let book = nextContext.book {
|
||||
output += book.name
|
||||
} else if key == "title", let chapter = nextContext.chapter {
|
||||
output += chapter.title
|
||||
} else {
|
||||
output += nextContext.variables[key] ?? ""
|
||||
}
|
||||
} else if let scriptRange = Range(match.range(at: 2), in: source) {
|
||||
let expression = String(source[scriptRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if expression.hasPrefix("@") || expression.hasPrefix("$.") || expression.hasPrefix("$[") || expression.hasPrefix("//") {
|
||||
captureLiteral = true
|
||||
let nested = await evaluateValue(expression, input: context.content ?? result, listMode: false, context: nextContext)
|
||||
if let error = nested.diagnostics.first(where: { $0.severity == .error }) {
|
||||
throw RuleEngineError.malformedRule(rule: expression, detail: error.message)
|
||||
}
|
||||
output += nested.strings.joined(separator: "\n")
|
||||
nextContext.variables = nested.variables
|
||||
} else {
|
||||
let evaluated = try await javaScript.evaluateAsync(
|
||||
script: expression,
|
||||
result: result,
|
||||
executionContext: nextContext
|
||||
)
|
||||
output += evaluated.value.stringValue
|
||||
nextContext.variables = evaluated.variables
|
||||
}
|
||||
}
|
||||
cursor = fullRange.upperBound
|
||||
}
|
||||
output += source[cursor...]
|
||||
return (output, nextContext, captureLiteral)
|
||||
}
|
||||
|
||||
private func literalEvaluation(_ source: String, context: RuleExecutionContext) -> RuleEvaluation {
|
||||
let components = source.components(separatedBy: "##")
|
||||
var value = components.first ?? ""
|
||||
if components.count > 1, !components[1].isEmpty {
|
||||
value = (try? regex.replacing(
|
||||
pattern: components[1],
|
||||
in: value,
|
||||
with: components.count > 2 ? components[2] : "",
|
||||
firstOnly: components.count > 3
|
||||
)) ?? value
|
||||
}
|
||||
return .init(values: [.string(value)], variables: context.variables)
|
||||
}
|
||||
|
||||
private struct JavaScriptPipeline {
|
||||
var prefix: String
|
||||
var script: String
|
||||
var suffix: String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
|
||||
public enum RuleMode: String, Codable, Sendable {
|
||||
case css
|
||||
case xpath
|
||||
case jsonPath
|
||||
case regex
|
||||
case javaScript
|
||||
case webJavaScript
|
||||
}
|
||||
|
||||
public enum RuleCombination: String, Codable, Sendable {
|
||||
case single
|
||||
case concatenate = "&&"
|
||||
case fallback = "||"
|
||||
case interleave = "%%"
|
||||
}
|
||||
|
||||
public struct RuleBranch: Codable, Hashable, Sendable {
|
||||
public var mode: RuleMode
|
||||
public var expression: String
|
||||
public var replacePattern: String?
|
||||
public var replacement: String
|
||||
public var replaceFirst: Bool
|
||||
|
||||
public init(
|
||||
mode: RuleMode,
|
||||
expression: String,
|
||||
replacePattern: String? = nil,
|
||||
replacement: String = "",
|
||||
replaceFirst: Bool = false
|
||||
) {
|
||||
self.mode = mode
|
||||
self.expression = expression
|
||||
self.replacePattern = replacePattern
|
||||
self.replacement = replacement
|
||||
self.replaceFirst = replaceFirst
|
||||
}
|
||||
}
|
||||
|
||||
public struct RulePlan: Codable, Hashable, Sendable {
|
||||
public var source: String
|
||||
public var combination: RuleCombination
|
||||
public var branches: [RuleBranch]
|
||||
}
|
||||
|
||||
public struct RuleExpressionParser: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func parse(_ source: String, inputLooksLikeJSON: Bool = false) throws -> RulePlan {
|
||||
let normalized = source.trimmingCharacters(in: .newlines)
|
||||
guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
|
||||
}
|
||||
let split = try splitAtTopLevel(normalized)
|
||||
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) }
|
||||
return .init(source: source, combination: split.combination, branches: branches)
|
||||
}
|
||||
|
||||
private func parseBranch(_ source: String, inputLooksLikeJSON: Bool) throws -> RuleBranch {
|
||||
var value = String(source.drop(while: { $0.isWhitespace }))
|
||||
var mode: RuleMode
|
||||
if value.lowercased().hasPrefix("@css:") {
|
||||
mode = .css
|
||||
value.removeFirst(5)
|
||||
} else if value.hasPrefix("@@") {
|
||||
mode = .css
|
||||
value.removeFirst(2)
|
||||
} else if value.lowercased().hasPrefix("@xpath:") {
|
||||
mode = .xpath
|
||||
value.removeFirst(7)
|
||||
} else if value.lowercased().hasPrefix("@json:") {
|
||||
mode = .jsonPath
|
||||
value.removeFirst(6)
|
||||
} else if value.lowercased().hasPrefix("@js:") {
|
||||
mode = .javaScript
|
||||
value.removeFirst(4)
|
||||
} else if value.lowercased().hasPrefix("<js>") && value.lowercased().hasSuffix("</js>") {
|
||||
mode = .javaScript
|
||||
value = String(value.dropFirst(4).dropLast(5))
|
||||
} else if value.lowercased().hasPrefix("<webjs>") && value.lowercased().hasSuffix("</webjs>") {
|
||||
mode = .webJavaScript
|
||||
value = String(value.dropFirst(7).dropLast(8))
|
||||
} else if value.hasPrefix(":") {
|
||||
mode = .regex
|
||||
value.removeFirst()
|
||||
} else if value.hasPrefix("$.") || value.hasPrefix("$[") || inputLooksLikeJSON {
|
||||
mode = .jsonPath
|
||||
} else if value.hasPrefix("/") || value.hasPrefix("./") {
|
||||
mode = .xpath
|
||||
} else {
|
||||
mode = .css
|
||||
}
|
||||
|
||||
let replacement = value.components(separatedBy: "##")
|
||||
let expression = replacement[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !expression.isEmpty || replacement.count > 1 else {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "规则主体不能为空")
|
||||
}
|
||||
return .init(
|
||||
mode: mode,
|
||||
expression: expression,
|
||||
replacePattern: replacement.count > 1 ? replacement[1] : nil,
|
||||
replacement: replacement.count > 2 ? replacement[2] : "",
|
||||
replaceFirst: replacement.count > 3
|
||||
)
|
||||
}
|
||||
|
||||
private func splitAtTopLevel(_ source: String) throws -> (parts: [String], combination: RuleCombination) {
|
||||
var quote: Character?
|
||||
var escaped = false
|
||||
var roundDepth = 0
|
||||
var squareDepth = 0
|
||||
var curlyDepth = 0
|
||||
var found: RuleCombination?
|
||||
var parts: [String] = []
|
||||
var partStart = source.startIndex
|
||||
var index = source.startIndex
|
||||
|
||||
while index < source.endIndex {
|
||||
let character = source[index]
|
||||
if escaped {
|
||||
escaped = false
|
||||
index = source.index(after: index)
|
||||
continue
|
||||
}
|
||||
if character == "\\" {
|
||||
escaped = true
|
||||
index = source.index(after: index)
|
||||
continue
|
||||
}
|
||||
if let activeQuote = quote {
|
||||
if character == activeQuote { quote = nil }
|
||||
index = source.index(after: index)
|
||||
continue
|
||||
}
|
||||
if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
index = source.index(after: index)
|
||||
continue
|
||||
}
|
||||
switch character {
|
||||
case "(": roundDepth += 1
|
||||
case ")": roundDepth -= 1
|
||||
case "[": squareDepth += 1
|
||||
case "]": squareDepth -= 1
|
||||
case "{": curlyDepth += 1
|
||||
case "}": curlyDepth -= 1
|
||||
default: break
|
||||
}
|
||||
if roundDepth < 0 || squareDepth < 0 || curlyDepth < 0 {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "规则括号未平衡")
|
||||
}
|
||||
if roundDepth == 0, squareDepth == 0, curlyDepth == 0 {
|
||||
let remainder = source[index...]
|
||||
let candidate: RuleCombination? = if remainder.hasPrefix("&&") {
|
||||
.concatenate
|
||||
} else if remainder.hasPrefix("||") {
|
||||
.fallback
|
||||
} else if remainder.hasPrefix("%%") {
|
||||
.interleave
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
if let candidate {
|
||||
if found == nil { found = candidate }
|
||||
if found == candidate {
|
||||
parts.append(String(source[partStart..<index]))
|
||||
index = source.index(index, offsetBy: 2)
|
||||
partStart = index
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
index = source.index(after: index)
|
||||
}
|
||||
guard quote == nil, roundDepth == 0, squareDepth == 0, curlyDepth == 0 else {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "规则引号或括号未闭合")
|
||||
}
|
||||
if let found {
|
||||
parts.append(String(source[partStart...]))
|
||||
guard parts.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "组合规则存在空分支")
|
||||
}
|
||||
return (parts, found)
|
||||
}
|
||||
return ([source], .single)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import Foundation
|
||||
|
||||
public struct SourceSearchOutcome: Sendable {
|
||||
public var sourceID: String
|
||||
public var books: [SearchBook]
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
|
||||
public init(sourceID: String, books: [SearchBook], diagnostics: [RuleDiagnostic]) {
|
||||
self.sourceID = sourceID
|
||||
self.books = books
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSearchReport: Sendable {
|
||||
public var books: [SearchBook]
|
||||
public var outcomes: [SourceSearchOutcome]
|
||||
|
||||
public init(books: [SearchBook], outcomes: [SourceSearchOutcome]) {
|
||||
self.books = books
|
||||
self.outcomes = outcomes
|
||||
}
|
||||
}
|
||||
|
||||
public actor BookSearchHistory {
|
||||
public static let shared = BookSearchHistory(defaults: .standard)
|
||||
private struct Entry: Codable, Sendable {
|
||||
var success: Date?
|
||||
var failure: Date?
|
||||
var failures = 0
|
||||
}
|
||||
private var entries: [String: Entry]
|
||||
private let defaults: UserDefaults?
|
||||
private static let key = "legado.searchSourceHistory.v1"
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
self.defaults = defaults
|
||||
entries = defaults?.data(forKey: Self.key).flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:]
|
||||
}
|
||||
|
||||
public func ordered(_ sources: [BookSource], preferred: Set<String>, now: Date = Date()) -> [BookSource] {
|
||||
func rank(_ source: BookSource) -> Int {
|
||||
if preferred.contains(source.id) { return 0 }
|
||||
guard let entry = entries[source.id] else { return 2 }
|
||||
if let failure = entry.failure, now.timeIntervalSince(failure) < 300, entry.failures > 0 { return 3 }
|
||||
if let success = entry.success, now.timeIntervalSince(success) < 7 * 86_400 { return 1 }
|
||||
return 2
|
||||
}
|
||||
return sources.enumerated().sorted {
|
||||
let lhs = rank($0.element), rhs = rank($1.element)
|
||||
if lhs != rhs { return lhs < rhs }
|
||||
return $0.offset < $1.offset
|
||||
}.map(\.element)
|
||||
}
|
||||
|
||||
public func record(_ outcome: SourceSearchOutcome, now: Date = Date()) {
|
||||
guard !outcome.diagnostics.contains(where: { $0.code == "search.cancelled" }) else { return }
|
||||
var entry = entries[outcome.sourceID] ?? Entry()
|
||||
if !outcome.books.isEmpty {
|
||||
entry.success = now
|
||||
entry.failures = 0
|
||||
} else if outcome.diagnostics.contains(where: { $0.severity == .error }) {
|
||||
entry.failure = now
|
||||
entry.failures += 1
|
||||
} else { return }
|
||||
entries[outcome.sourceID] = entry
|
||||
guard defaults != nil, saveTask == nil else { return }
|
||||
saveTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.flush()
|
||||
}
|
||||
}
|
||||
|
||||
public func flush() {
|
||||
saveTask?.cancel()
|
||||
saveTask = nil
|
||||
guard let defaults else { return }
|
||||
let retained = entries.sorted {
|
||||
if ($0.value.success != nil) != ($1.value.success != nil) { return $0.value.success != nil }
|
||||
return ($0.value.success ?? $0.value.failure ?? .distantPast) > ($1.value.success ?? $1.value.failure ?? .distantPast)
|
||||
}.prefix(512)
|
||||
entries = Dictionary(uniqueKeysWithValues: retained.map { ($0.key, $0.value) })
|
||||
if let data = try? JSONEncoder().encode(entries) { defaults.set(data, forKey: Self.key) }
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSearchAccumulator: Sendable {
|
||||
private var books: [SearchBook] = []
|
||||
private var indices: [String: Int] = [:]
|
||||
public init() {}
|
||||
|
||||
public mutating func add(_ incoming: [SearchBook], replacingExisting: Bool = false) {
|
||||
for book in incoming {
|
||||
let identity = BookSearchService.mergeIdentity(for: book)
|
||||
if let index = indices[identity] {
|
||||
let origins = books[index].origins.union(book.origins)
|
||||
if replacingExisting { books[index] = book }
|
||||
books[index].origins = origins
|
||||
} else {
|
||||
indices[identity] = books.count
|
||||
books.append(book)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func results(keyword: String) -> [SearchBook] {
|
||||
var buckets = Array(repeating: [SearchBook](), count: 4)
|
||||
for book in books {
|
||||
let rank: Int
|
||||
if book.name == keyword || book.author == keyword { rank = 0 }
|
||||
else if book.kind?.contains(keyword) == true { rank = 1 }
|
||||
else if book.name.contains(keyword) || book.author.contains(keyword) { rank = 2 }
|
||||
else { rank = 3 }
|
||||
buckets[rank].append(book)
|
||||
}
|
||||
return buckets.flatMap { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSearchService: Sendable {
|
||||
private let transport: any HTTPTransport
|
||||
private let urlBuilder: LegadoURLBuilder
|
||||
private let engine: RuleEngine
|
||||
private let networkPolicy: SourceNetworkPolicy
|
||||
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
|
||||
private let snapshotRecorder: (any DifferentialSnapshotRecording)?
|
||||
private let history: BookSearchHistory
|
||||
|
||||
public init(
|
||||
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||
urlBuilder: LegadoURLBuilder = .init(),
|
||||
engine: RuleEngine = .init(),
|
||||
networkPolicy: SourceNetworkPolicy = .shared,
|
||||
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
|
||||
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil,
|
||||
history: BookSearchHistory = .shared
|
||||
) {
|
||||
self.transport = transport
|
||||
self.urlBuilder = urlBuilder
|
||||
self.engine = engine
|
||||
self.networkPolicy = networkPolicy
|
||||
self.webViewExecutor = webViewExecutor
|
||||
self.snapshotRecorder = snapshotRecorder
|
||||
self.history = history
|
||||
}
|
||||
|
||||
public func search(
|
||||
keyword: String,
|
||||
page: Int = 1,
|
||||
sources: [BookSource]
|
||||
) async -> BookSearchReport {
|
||||
let enabled = sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }
|
||||
let sourceOrder = Dictionary(enabled.enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { first, _ in first })
|
||||
var outcomes: [SourceSearchOutcome] = []
|
||||
for await outcome in searchOutcomes(keyword: keyword, page: page, sources: enabled) {
|
||||
outcomes.append(outcome)
|
||||
}
|
||||
outcomes.sort { (sourceOrder[$0.sourceID] ?? .max) < (sourceOrder[$1.sourceID] ?? .max) }
|
||||
return .init(books: merge(outcomes: outcomes, keyword: keyword), outcomes: outcomes)
|
||||
}
|
||||
|
||||
public func searchOutcomes(
|
||||
keyword: String,
|
||||
page: Int = 1,
|
||||
sources: [BookSource],
|
||||
maximumConcurrentSources: Int = ApplicationSettingsStore.shared.settings.concurrentSources,
|
||||
includeDisabled: Bool = false,
|
||||
preferredSourceIDs: Set<String> = []
|
||||
) -> AsyncStream<SourceSearchOutcome> {
|
||||
var seen = Set<String>()
|
||||
let enabled = sources.filter { (includeDisabled || $0.enabled) && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted }
|
||||
let concurrency = min(max(maximumConcurrentSources, 1), enabled.count)
|
||||
guard concurrency > 0 else {
|
||||
return AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
return AsyncStream { continuation in
|
||||
let task = Task {
|
||||
let ordered = await history.ordered(enabled, preferred: preferredSourceIDs)
|
||||
await withTaskGroup(of: SourceSearchOutcome.self) { group in
|
||||
var complexRunning = 0
|
||||
let complexLimit = min(concurrency, 4)
|
||||
func isComplex(_ source: BookSource) -> Bool {
|
||||
let template = (source.searchUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return template.hasPrefix("@js:") || template.hasPrefix("<js>") || template.contains("webview") || template.contains("webjs")
|
||||
}
|
||||
let complexIDs = Set(ordered.filter(isComplex).map(\.id))
|
||||
let normal = ordered.enumerated().filter { !complexIDs.contains($0.element.id) }.map(\.offset)
|
||||
let complex = ordered.enumerated().filter { complexIDs.contains($0.element.id) }.map(\.offset)
|
||||
var normalIndex = 0
|
||||
var complexIndex = 0
|
||||
func enqueue() -> Bool {
|
||||
guard !Task.isCancelled else { return false }
|
||||
let nextNormal = normalIndex < normal.count ? normal[normalIndex] : Int.max
|
||||
let nextComplex = complexIndex < complex.count && complexRunning < complexLimit ? complex[complexIndex] : Int.max
|
||||
guard min(nextNormal, nextComplex) != Int.max else { return false }
|
||||
let source: BookSource
|
||||
if nextComplex < nextNormal {
|
||||
source = ordered[nextComplex]
|
||||
complexIndex += 1
|
||||
complexRunning += 1
|
||||
} else {
|
||||
source = ordered[nextNormal]
|
||||
normalIndex += 1
|
||||
}
|
||||
group.addTask {
|
||||
await search(keyword: keyword, page: page, source: source,
|
||||
timeoutLimit: enabled.count > 1 ? 8 : 30)
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _ in 0..<concurrency { if !enqueue() { break } }
|
||||
while let outcome = await group.next() {
|
||||
if complexIDs.contains(outcome.sourceID) { complexRunning -= 1 }
|
||||
guard !Task.isCancelled else {
|
||||
group.cancelAll()
|
||||
break
|
||||
}
|
||||
await history.record(outcome)
|
||||
continuation.yield(outcome)
|
||||
_ = enqueue()
|
||||
}
|
||||
}
|
||||
await history.flush()
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
task.cancel()
|
||||
Task { await history.flush() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func search(keyword: String, page: Int, source: BookSource) async -> SourceSearchOutcome {
|
||||
await search(keyword: keyword, page: page, source: source, timeoutLimit: 30)
|
||||
}
|
||||
|
||||
private func search(keyword: String, page: Int, source: BookSource, timeoutLimit: TimeInterval) async -> SourceSearchOutcome {
|
||||
guard let template = source.searchUrl, let rules = source.ruleSearch else {
|
||||
return failure(source, code: "search.configuration", message: "书源缺少搜索地址或规则")
|
||||
}
|
||||
do {
|
||||
var prepared = try await RuleExecutionWorker.run {
|
||||
let headers = try BookSourceHeaderResolver().resolve(source)
|
||||
return try urlBuilder.prepare(template, context: .init(key: keyword, page: page, source: source),
|
||||
baseURL: URL(string: source.bookSourceUrl), defaultHeaders: headers)
|
||||
}
|
||||
if timeoutLimit < 30 { prepared.retry = min(prepared.retry, 1) }
|
||||
let timeout = min(max(Double(source.respondTime) / 1_000, 5), timeoutLimit)
|
||||
let response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: timeout,
|
||||
webViewExecutor: webViewExecutor,
|
||||
snapshotRecorder: snapshotRecorder,
|
||||
snapshotStage: .search
|
||||
)
|
||||
guard let listRule = rules.bookList, !listRule.isEmpty else {
|
||||
return failure(source, code: "search.book_list_rule", message: "搜索列表规则为空")
|
||||
}
|
||||
let root = response.decodedJSON
|
||||
let body = response.decodedText
|
||||
let listEvaluation = if let root {
|
||||
await engine.evaluate(
|
||||
listRule.trimmingListPrefix,
|
||||
in: root,
|
||||
listMode: true,
|
||||
context: .init(variables: response.requestVariables, baseURL: response.finalURL.absoluteString, key: keyword, page: page, source: source)
|
||||
)
|
||||
} else {
|
||||
await engine.evaluate(
|
||||
listRule.trimmingListPrefix,
|
||||
inText: body,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
listMode: true,
|
||||
context: .init(variables: response.requestVariables, key: keyword, page: page, source: source)
|
||||
)
|
||||
}
|
||||
var diagnostics = listEvaluation.diagnostics
|
||||
var books: [SearchBook] = []
|
||||
for item in listEvaluation.listItems(rule: listRule.trimmingListPrefix) {
|
||||
try Task.checkCancellation()
|
||||
let fields = await evaluateFields(
|
||||
rules,
|
||||
item: item,
|
||||
isHTML: root == nil,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
source: source,
|
||||
variables: listEvaluation.variables,
|
||||
keyword: keyword,
|
||||
page: page
|
||||
)
|
||||
diagnostics += fields.diagnostics
|
||||
guard let name = fields.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { continue }
|
||||
let bookURLValue = fields.bookURL?.nilIfEmpty ?? response.finalURL.absoluteString
|
||||
let bookURL = resolve(bookURLValue, against: response.finalURL)
|
||||
var book = SearchBook(
|
||||
bookURL: bookURL,
|
||||
origin: source.bookSourceUrl,
|
||||
originName: source.bookSourceName,
|
||||
type: source.bookSourceType,
|
||||
name: name,
|
||||
author: fields.author ?? ""
|
||||
)
|
||||
book.kind = fields.kind
|
||||
book.coverURL = fields.coverURL.map { resolve($0, against: response.finalURL) }
|
||||
book.intro = MetadataTextSanitizer().clean(fields.intro)
|
||||
book.wordCount = fields.wordCount
|
||||
book.latestChapterTitle = fields.lastChapter
|
||||
book.originOrder = source.customOrder
|
||||
book.variables = fields.variables
|
||||
books.append(book)
|
||||
}
|
||||
if listRule.hasPrefix("-") { books.reverse() }
|
||||
var seenURLs = Set<String>()
|
||||
let unique = books.filter { seenURLs.insert($0.bookURL).inserted }
|
||||
return .init(sourceID: source.id, books: unique, diagnostics: diagnostics)
|
||||
} catch is CancellationError {
|
||||
return failure(source, code: "search.cancelled", message: "搜索已取消")
|
||||
} catch let error as RuleEngineError {
|
||||
return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic])
|
||||
} catch {
|
||||
return failure(source, code: "search.request", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluateFields(
|
||||
_ rules: SearchRule,
|
||||
item: JSONValue,
|
||||
isHTML: Bool,
|
||||
baseURL: String,
|
||||
source: BookSource,
|
||||
variables: [String: String],
|
||||
keyword: String,
|
||||
page: Int
|
||||
) async -> FieldEvaluation {
|
||||
var result = FieldEvaluation()
|
||||
let currentBook = Book(bookUrl: baseURL, name: "", origin: source.bookSourceUrl)
|
||||
var context = RuleExecutionContext(variables: variables, baseURL: baseURL, key: keyword, page: page, source: source, book: currentBook, isHTMLFragment: true)
|
||||
let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = name.variables
|
||||
context.book?.name = name.joinedString ?? ""
|
||||
let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = author.variables
|
||||
context.book?.author = author.joinedString ?? ""
|
||||
let kind = await evaluate(rules.kind, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = kind.variables
|
||||
let bookURL = await evaluate(rules.bookUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = bookURL.variables
|
||||
context.book?.bookUrl = bookURL.leadingString ?? baseURL
|
||||
let coverURL = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = coverURL.variables
|
||||
let intro = await evaluate(rules.intro, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = intro.variables
|
||||
let wordCount = await evaluate(rules.wordCount, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = wordCount.variables
|
||||
let lastChapter = await evaluate(rules.lastChapter, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||
context.variables = lastChapter.variables
|
||||
let values = (name, author, kind, bookURL, coverURL, intro, wordCount, lastChapter)
|
||||
result.name = values.0.joinedString
|
||||
result.author = values.1.joinedString
|
||||
result.kind = values.2.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty
|
||||
result.bookURL = isHTML ? values.3.leadingString : values.3.joinedString
|
||||
result.coverURL = values.4.joinedString
|
||||
result.intro = MetadataTextSanitizer().clean(values.5.joinedString)
|
||||
result.wordCount = values.6.joinedString
|
||||
result.lastChapter = values.7.joinedString
|
||||
result.diagnostics = [values.0, values.1, values.2, values.3, values.4, values.5, values.6, values.7]
|
||||
.flatMap(\.diagnostics)
|
||||
result.variables = context.variables
|
||||
return result
|
||||
}
|
||||
|
||||
private func evaluate(
|
||||
_ rule: String?,
|
||||
item: JSONValue,
|
||||
isHTML: Bool,
|
||||
baseURL: String,
|
||||
context: RuleExecutionContext = .init()
|
||||
) async -> RuleEvaluation {
|
||||
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
|
||||
if case .string(let text) = item {
|
||||
return await engine.evaluate(rule, inText: text, baseURL: baseURL, context: context)
|
||||
}
|
||||
return await engine.evaluate(rule, in: item, context: context)
|
||||
}
|
||||
|
||||
private func resolve(_ value: String, against baseURL: URL) -> String {
|
||||
LegadoURLBuilder.resolve(value, against: baseURL)
|
||||
}
|
||||
|
||||
private func failure(_ source: BookSource, code: String, message: String) -> SourceSearchOutcome {
|
||||
.init(
|
||||
sourceID: source.id,
|
||||
books: [],
|
||||
diagnostics: [.init(severity: .error, code: code, message: message, rule: source.searchUrl ?? "")]
|
||||
)
|
||||
}
|
||||
|
||||
public func merge(outcomes: [SourceSearchOutcome], keyword: String) -> [SearchBook] {
|
||||
merge(outcomes.flatMap(\.books), keyword: keyword)
|
||||
}
|
||||
|
||||
private func merge(_ books: [SearchBook], keyword: String) -> [SearchBook] {
|
||||
var accumulator = BookSearchAccumulator()
|
||||
accumulator.add(books)
|
||||
return accumulator.results(keyword: keyword)
|
||||
}
|
||||
|
||||
public static func mergeIdentity(for book: SearchBook) -> String {
|
||||
"\(normalizedIdentityText(book.name))\u{0}\(normalizedAuthor(book.author))"
|
||||
}
|
||||
|
||||
private static func normalizedIdentityText(_ value: String) -> String {
|
||||
String(value.unicodeScalars.filter {
|
||||
!CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
&& !CharacterSet.punctuationCharacters.contains($0)
|
||||
&& !CharacterSet.symbols.contains($0)
|
||||
}).lowercased()
|
||||
}
|
||||
|
||||
private static func normalizedAuthor(_ value: String) -> String {
|
||||
var value = normalizedIdentityText(value)
|
||||
for prefix in ["作者", "author", "著者"] where value.hasPrefix(prefix) {
|
||||
value.removeFirst(prefix.count)
|
||||
}
|
||||
for suffix in ["著", "撰"] where value.hasSuffix(suffix) {
|
||||
value.removeLast(suffix.count)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private struct FieldEvaluation {
|
||||
var name: String?
|
||||
var author: String?
|
||||
var kind: String?
|
||||
var bookURL: String?
|
||||
var coverURL: String?
|
||||
var intro: String?
|
||||
var wordCount: String?
|
||||
var lastChapter: String?
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
var variables: [String: String] = [:]
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public enum HttpTTSServiceError: Error, LocalizedError, Sendable {
|
||||
case emptyText
|
||||
case invalidContentType(expected: String, actual: String, body: String)
|
||||
case textResponse(contentType: String, body: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyText:
|
||||
"朗读文本为空"
|
||||
case .invalidContentType(let expected, let actual, let body):
|
||||
"TTS 返回类型不匹配(期望 \(expected),实际 \(actual)):\(body)"
|
||||
case .textResponse(_, let body):
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct HttpTTSAudio: Sendable {
|
||||
public var data: Data
|
||||
public var contentType: String?
|
||||
public var finalURL: URL
|
||||
|
||||
public init(data: Data, contentType: String?, finalURL: URL) {
|
||||
self.data = data
|
||||
self.contentType = contentType
|
||||
self.finalURL = finalURL
|
||||
}
|
||||
}
|
||||
|
||||
public struct HttpTTSAudioStream: Sendable {
|
||||
public var contentType: String?
|
||||
public var finalURL: URL
|
||||
public var expectedContentLength: Int64?
|
||||
public var chunks: AsyncThrowingStream<Data, Error>
|
||||
public var isIncremental: Bool
|
||||
|
||||
public init(
|
||||
contentType: String?,
|
||||
finalURL: URL,
|
||||
expectedContentLength: Int64?,
|
||||
chunks: AsyncThrowingStream<Data, Error>,
|
||||
isIncremental: Bool
|
||||
) {
|
||||
self.contentType = contentType
|
||||
self.finalURL = finalURL
|
||||
self.expectedContentLength = expectedContentLength
|
||||
self.chunks = chunks
|
||||
self.isIncremental = isIncremental
|
||||
}
|
||||
}
|
||||
|
||||
public actor HttpTTSAudioCache {
|
||||
public static let shared = HttpTTSAudioCache()
|
||||
private let directory: URL
|
||||
private let maximumBytes: Int64
|
||||
|
||||
public init(directory: URL? = nil, maximumBytes: Int64 = 128 * 1_024 * 1_024) {
|
||||
self.directory = directory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/httpTTS_cache", isDirectory: true)
|
||||
self.maximumBytes = maximumBytes
|
||||
}
|
||||
|
||||
public func data(for key: String) -> Data? {
|
||||
let url = directory.appendingPathComponent(key + ".audio")
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
|
||||
return data
|
||||
}
|
||||
|
||||
public func store(_ data: Data, for key: String) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: directory.appendingPathComponent(key + ".audio"), options: .atomic)
|
||||
trimIfNeeded()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
public func clear() { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
private func trimIfNeeded() {
|
||||
let keys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: Array(keys),
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return }
|
||||
var entries: [(URL, Int64, Date)] = files.compactMap { url in
|
||||
guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { return nil }
|
||||
return (url, Int64(values.fileSize ?? 0), values.contentModificationDate ?? .distantPast)
|
||||
}
|
||||
var total = entries.reduce(Int64(0)) { $0 + $1.1 }
|
||||
guard total > maximumBytes else { return }
|
||||
entries.sort { $0.2 < $1.2 }
|
||||
for entry in entries where total > maximumBytes {
|
||||
try? FileManager.default.removeItem(at: entry.0)
|
||||
total -= entry.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public actor HttpTTSService {
|
||||
private let urlBuilder: LegadoURLBuilder
|
||||
private let networkPolicy: SourceNetworkPolicy
|
||||
private let transport: any HTTPTransport
|
||||
private let cache: HttpTTSAudioCache
|
||||
|
||||
public init(
|
||||
urlBuilder: LegadoURLBuilder = .init(),
|
||||
networkPolicy: SourceNetworkPolicy = .shared,
|
||||
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||
cache: HttpTTSAudioCache = .shared
|
||||
) {
|
||||
self.urlBuilder = urlBuilder
|
||||
self.networkPolicy = networkPolicy
|
||||
self.transport = transport
|
||||
self.cache = cache
|
||||
}
|
||||
|
||||
public func audio(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudio {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw HttpTTSServiceError.emptyText }
|
||||
let source = engine.bookSourceAdapter
|
||||
let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate)
|
||||
if let data = await cache.data(for: cacheKey) {
|
||||
return .init(data: data, contentType: engine.contentType, finalURL: cacheURL(engine: engine))
|
||||
}
|
||||
let context = URLTemplateContext(
|
||||
key: text,
|
||||
variables: [
|
||||
"speakText": text,
|
||||
"speakSpeed": String(speechRate)
|
||||
],
|
||||
source: source
|
||||
)
|
||||
var lastError: Error = HttpTTSServiceError.emptyText
|
||||
for attempt in 0..<6 {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
let prepared = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
var response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: 300
|
||||
)
|
||||
if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) {
|
||||
let rebuilt = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
response = try await networkPolicy.execute(
|
||||
rebuilt,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: 300
|
||||
)
|
||||
}
|
||||
let contentType = response.headers.first {
|
||||
$0.key.caseInsensitiveCompare("Content-Type") == .orderedSame
|
||||
}?.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try validateContentType(engine: engine, response: response, contentType: contentType)
|
||||
await cache.store(response.data, for: cacheKey)
|
||||
return .init(data: response.data, contentType: contentType, finalURL: response.finalURL)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch HTTPClientError.statusResponse(let response) {
|
||||
if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) {
|
||||
let rebuilt = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
let recovered = try await networkPolicy.execute(
|
||||
rebuilt,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: 300
|
||||
)
|
||||
let contentType = Self.contentType(from: recovered.headers)
|
||||
try validateContentType(engine: engine, response: recovered, contentType: contentType)
|
||||
await cache.store(recovered.data, for: cacheKey)
|
||||
return .init(data: recovered.data, contentType: contentType, finalURL: recovered.finalURL)
|
||||
}
|
||||
lastError = HTTPClientError.statusResponse(response)
|
||||
if attempt == 5 { throw lastError }
|
||||
} catch let error as RuleEngineError {
|
||||
throw error
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt == 5 { throw error }
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
public func audioStream(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudioStream {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw HttpTTSServiceError.emptyText }
|
||||
let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate)
|
||||
if let data = await cache.data(for: cacheKey) {
|
||||
return Self.bufferedStream(
|
||||
data: data,
|
||||
contentType: engine.contentType,
|
||||
finalURL: cacheURL(engine: engine)
|
||||
)
|
||||
}
|
||||
let requiresBufferedValidation = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
guard !requiresBufferedValidation,
|
||||
let streamingTransport = transport as? any StreamingHTTPTransport else {
|
||||
let value = try await audio(engine: engine, text: text, speechRate: speechRate)
|
||||
return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL)
|
||||
}
|
||||
let source = engine.bookSourceAdapter
|
||||
let context = URLTemplateContext(
|
||||
key: text,
|
||||
variables: ["speakText": text, "speakSpeed": String(speechRate)],
|
||||
source: source
|
||||
)
|
||||
let prepared = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
if prepared.bodyJavaScript != nil || prepared.responseType != nil || prepared.proxy != nil || prepared.useWebView {
|
||||
let value = try await audio(engine: engine, text: text, speechRate: speechRate)
|
||||
return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL)
|
||||
}
|
||||
let response = try await networkPolicy.executeStream(
|
||||
prepared,
|
||||
source: source,
|
||||
transport: streamingTransport,
|
||||
timeout: 300
|
||||
)
|
||||
let contentType = Self.contentType(from: response.headers)
|
||||
try validateStreamContentType(engine: engine, contentType: contentType)
|
||||
let upstream = response.chunks
|
||||
let cache = self.cache
|
||||
let cachedChunks = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let task = Task {
|
||||
var complete = Data()
|
||||
if let length = response.expectedContentLength, length > 0, length <= Int64(Int.max) {
|
||||
complete.reserveCapacity(Int(length))
|
||||
}
|
||||
do {
|
||||
for try await chunk in upstream {
|
||||
try Task.checkCancellation()
|
||||
complete.append(chunk)
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
await cache.store(complete, for: cacheKey)
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
return .init(
|
||||
contentType: contentType,
|
||||
finalURL: response.finalURL,
|
||||
expectedContentLength: response.expectedContentLength,
|
||||
chunks: cachedChunks,
|
||||
isIncremental: true
|
||||
)
|
||||
}
|
||||
|
||||
private static func cacheKey(engine: HttpTTS, text: String, speechRate: Int) -> String {
|
||||
let seed = "\(engine.url)-|-\(speechRate)-|-\(text)"
|
||||
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return String(digest.dropFirst(8).prefix(16))
|
||||
}
|
||||
|
||||
private static func bufferedStream(data: Data, contentType: String?, finalURL: URL) -> HttpTTSAudioStream {
|
||||
.init(
|
||||
contentType: contentType,
|
||||
finalURL: finalURL,
|
||||
expectedContentLength: Int64(data.count),
|
||||
chunks: AsyncThrowingStream { continuation in
|
||||
continuation.yield(data)
|
||||
continuation.finish()
|
||||
},
|
||||
isIncremental: false
|
||||
)
|
||||
}
|
||||
|
||||
private static func contentType(from headers: [String: String]) -> String? {
|
||||
headers.first { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame }?
|
||||
.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func cacheURL(engine: HttpTTS) -> URL {
|
||||
URL(string: engine.url.components(separatedBy: ",").first ?? "")
|
||||
?? URL(fileURLWithPath: "/httpTTS-cache")
|
||||
}
|
||||
|
||||
private func evaluateLoginCheckIfNeeded(engine: HttpTTS, source: BookSource, response: HTTPResponse) throws -> Bool {
|
||||
guard let script = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!script.isEmpty else { return false }
|
||||
let headers = response.headers.mapValues(JSONValue.string)
|
||||
let input = JSONValue.object([
|
||||
"code": .number(Double(response.statusCode)),
|
||||
"body": .string(response.decodedText),
|
||||
"headers": .object(headers),
|
||||
"url": .string(response.finalURL.absoluteString)
|
||||
])
|
||||
let wrapped = """
|
||||
(function () {
|
||||
var raw = result;
|
||||
var rebuildRequest = false;
|
||||
function header(name) {
|
||||
var wanted = String(name).toLowerCase();
|
||||
for (var key in raw.headers) if (key.toLowerCase() === wanted) return raw.headers[key];
|
||||
return null;
|
||||
}
|
||||
result = {
|
||||
code: function () { return raw.code; },
|
||||
statusCode: function () { return raw.code; },
|
||||
body: function () { return { string: function () { return raw.body; } }; },
|
||||
header: header,
|
||||
headers: function () { return { get: header }; },
|
||||
url: function () { return { toString: function () { return raw.url; } }; }
|
||||
};
|
||||
java.getHeaderMap = function () {
|
||||
return { putAll: function () {}, put: function () {} };
|
||||
};
|
||||
java.getResponse = function () { rebuildRequest = true; return result; };
|
||||
java.initUrl = function () { rebuildRequest = true; return result; };
|
||||
java.ruleUrl = raw.url;
|
||||
(function () {
|
||||
\(script)
|
||||
})();
|
||||
return { rebuildRequest: rebuildRequest };
|
||||
})()
|
||||
"""
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: wrapped,
|
||||
result: input,
|
||||
executionContext: .init(
|
||||
variables: ["speakSpeed": "", "speakText": ""],
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
source: source
|
||||
)
|
||||
)
|
||||
if case .object(let object) = result.value,
|
||||
case .bool(let requested)? = object["rebuildRequest"] {
|
||||
return requested
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func validateContentType(engine: HttpTTS, response: HTTPResponse, contentType: String?) throws {
|
||||
guard let contentType, !contentType.isEmpty else { return }
|
||||
if contentType == "application/json" || contentType.hasPrefix("text/") {
|
||||
throw HttpTTSServiceError.textResponse(contentType: contentType, body: response.decodedText)
|
||||
}
|
||||
guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expected.isEmpty else { return }
|
||||
let expression = try NSRegularExpression(pattern: expected)
|
||||
let range = NSRange(contentType.startIndex..., in: contentType)
|
||||
guard expression.firstMatch(in: contentType, range: range) != nil else {
|
||||
throw HttpTTSServiceError.invalidContentType(
|
||||
expected: expected,
|
||||
actual: contentType,
|
||||
body: response.decodedText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateStreamContentType(engine: HttpTTS, contentType: String?) throws {
|
||||
guard let contentType, !contentType.isEmpty else { return }
|
||||
if contentType == "application/json" || contentType.hasPrefix("text/") {
|
||||
throw HttpTTSServiceError.textResponse(contentType: contentType, body: "TTS 返回了文本响应")
|
||||
}
|
||||
guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expected.isEmpty else { return }
|
||||
let expression = try NSRegularExpression(pattern: expected)
|
||||
let range = NSRange(contentType.startIndex..., in: contentType)
|
||||
guard expression.firstMatch(in: contentType, range: range) != nil else {
|
||||
throw HttpTTSServiceError.invalidContentType(expected: expected, actual: contentType, body: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension HttpTTS {
|
||||
var sourceKey: String { "httpTts:\(id)" }
|
||||
|
||||
var bookSourceAdapter: BookSource {
|
||||
var source = BookSource(bookSourceUrl: sourceKey, bookSourceName: name)
|
||||
source.concurrentRate = concurrentRate
|
||||
source.loginUrl = loginUrl
|
||||
source.loginUi = loginUi
|
||||
source.header = header
|
||||
source.jsLib = jsLib
|
||||
source.enabledCookieJar = enabledCookieJar
|
||||
source.loginCheckJs = loginCheckJs
|
||||
source.lastUpdateTime = lastUpdateTime
|
||||
return source
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user