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? 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, 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 = [] ) -> AsyncStream { var seen = Set() 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("") || 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.. 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() 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 } }