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))
}
}
}
@@ -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: []))
}
}
}