mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
201 lines
7.5 KiB
Swift
201 lines
7.5 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|