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,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user