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

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -0,0 +1,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))
}
}
}