mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
291 lines
12 KiB
Swift
291 lines
12 KiB
Swift
import Foundation
|
|
|
|
public struct BookExploreService: Sendable {
|
|
private let transport: any HTTPTransport
|
|
private let urlBuilder: LegadoURLBuilder
|
|
private let engine: RuleEngine
|
|
private let networkPolicy: SourceNetworkPolicy
|
|
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
|
|
|
|
public init(
|
|
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
|
urlBuilder: LegadoURLBuilder = .init(),
|
|
engine: RuleEngine = .init(),
|
|
networkPolicy: SourceNetworkPolicy = .shared,
|
|
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil
|
|
) {
|
|
self.transport = transport
|
|
self.urlBuilder = urlBuilder
|
|
self.engine = engine
|
|
self.networkPolicy = networkPolicy
|
|
self.webViewExecutor = webViewExecutor
|
|
}
|
|
|
|
public func kinds(for source: BookSource) async -> ExploreKindsReport {
|
|
guard let rule = source.exploreUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!rule.isEmpty else { return .init() }
|
|
var diagnostics: [RuleDiagnostic] = []
|
|
let rendered: String
|
|
if rule.lowercased().hasPrefix("@js:") || rule.lowercased().hasPrefix("<js>") {
|
|
let result = await engine.evaluate(
|
|
rule,
|
|
in: .null,
|
|
context: .init(source: source)
|
|
)
|
|
diagnostics += result.diagnostics
|
|
rendered = result.firstString ?? ""
|
|
} else {
|
|
rendered = rule
|
|
}
|
|
do {
|
|
return .init(kinds: try parseKinds(rendered), diagnostics: diagnostics)
|
|
} catch let error as RuleEngineError {
|
|
diagnostics.append(error.diagnostic)
|
|
return .init(diagnostics: diagnostics)
|
|
} catch {
|
|
diagnostics.append(.init(
|
|
severity: .error,
|
|
code: "explore.kinds",
|
|
message: error.localizedDescription,
|
|
rule: rule
|
|
))
|
|
return .init(diagnostics: diagnostics)
|
|
}
|
|
}
|
|
|
|
public func explore(
|
|
source: BookSource,
|
|
kind: ExploreKind,
|
|
page: Int = 1
|
|
) async -> SourceSearchOutcome {
|
|
guard let template = kind.navigableURL else {
|
|
return failure(source, code: "explore.url", message: "发现分类缺少 URL", rule: kind.title)
|
|
}
|
|
let rules = resolvedRules(source)
|
|
guard let rules, let listRule = rules.bookList, !listRule.isEmpty else {
|
|
return failure(source, code: "explore.configuration", message: "书源缺少发现列表规则", rule: template)
|
|
}
|
|
do {
|
|
let prepared = try urlBuilder.prepare(
|
|
template,
|
|
context: .init(page: page, source: source),
|
|
baseURL: URL(string: source.bookSourceUrl),
|
|
defaultHeaders: parseHeaders(source.header)
|
|
)
|
|
let timeout = min(max(Double(source.respondTime) / 1_000, 5), 180)
|
|
let response = try await networkPolicy.execute(
|
|
prepared,
|
|
source: source,
|
|
transport: transport,
|
|
timeout: timeout,
|
|
webViewExecutor: webViewExecutor
|
|
)
|
|
let body = response.decodedText
|
|
let root = response.decodedJSON
|
|
var context = RuleExecutionContext(
|
|
variables: response.requestVariables,
|
|
baseURL: response.finalURL.absoluteString,
|
|
source: source
|
|
)
|
|
let list = if let root {
|
|
await engine.evaluate(listRule.trimmingListPrefix, in: root, listMode: true, context: context)
|
|
} else {
|
|
await engine.evaluate(
|
|
listRule.trimmingListPrefix,
|
|
inText: body,
|
|
baseURL: response.finalURL.absoluteString,
|
|
listMode: true,
|
|
context: context
|
|
)
|
|
}
|
|
context.variables = list.variables
|
|
var diagnostics = list.diagnostics
|
|
var books: [SearchBook] = []
|
|
for item in list.listItems(rule: listRule.trimmingListPrefix) {
|
|
try Task.checkCancellation()
|
|
let fields = await evaluateFields(
|
|
rules,
|
|
item: item,
|
|
isHTML: root == nil,
|
|
baseURL: response.finalURL.absoluteString,
|
|
source: source,
|
|
variables: context.variables
|
|
)
|
|
diagnostics += fields.diagnostics
|
|
guard let name = fields.name, !name.isEmpty else { continue }
|
|
let bookURL = resolve(fields.bookURL ?? response.finalURL.absoluteString, 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 unique = Set<String>()
|
|
books = books.filter { unique.insert($0.bookURL).inserted }
|
|
return .init(sourceID: source.id, books: books, diagnostics: diagnostics)
|
|
} catch is CancellationError {
|
|
return failure(source, code: "explore.cancelled", message: "发现加载已取消", rule: template)
|
|
} catch let error as RuleEngineError {
|
|
return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic])
|
|
} catch {
|
|
return failure(source, code: "explore.request", message: error.localizedDescription, rule: template)
|
|
}
|
|
}
|
|
|
|
private func parseKinds(_ value: String) throws -> [ExploreKind] {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return [] }
|
|
if trimmed.hasPrefix("[") {
|
|
do {
|
|
return try JSONDecoder().decode([ExploreKind].self, from: Data(trimmed.utf8))
|
|
} catch let strictError {
|
|
do {
|
|
let object = try JSONSerialization.jsonObject(
|
|
with: Data(trimmed.utf8),
|
|
options: [.fragmentsAllowed, .json5Allowed]
|
|
)
|
|
let normalized = try JSONEncoder().encode(JSONValue.bridge(object))
|
|
return try JSONDecoder().decode([ExploreKind].self, from: normalized)
|
|
} catch {
|
|
throw RuleEngineError.malformedRule(
|
|
rule: value,
|
|
detail: "发现分类 JSON 无效:\(strictError.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return trimmed
|
|
.replacingOccurrences(of: "&&", with: "\n")
|
|
.split(whereSeparator: { $0.isNewline })
|
|
.compactMap { line in
|
|
let line = String(line)
|
|
let range = line.range(of: "::")
|
|
let title = String(range.map { line[..<$0.lowerBound] } ?? line[...])
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !title.isEmpty else { return nil }
|
|
let url = range.map { String(line[$0.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
return ExploreKind(title: title, url: url?.isEmpty == true ? nil : url)
|
|
}
|
|
}
|
|
|
|
private func resolvedRules(_ source: BookSource) -> BookListRule? {
|
|
if let rules = source.ruleExplore, rules.bookList?.isEmpty == false { return rules }
|
|
guard let search = source.ruleSearch else { return source.ruleExplore }
|
|
return .init(
|
|
bookList: search.bookList,
|
|
name: search.name,
|
|
author: search.author,
|
|
intro: search.intro,
|
|
kind: search.kind,
|
|
lastChapter: search.lastChapter,
|
|
updateTime: search.updateTime,
|
|
bookUrl: search.bookUrl,
|
|
coverUrl: search.coverUrl,
|
|
wordCount: search.wordCount
|
|
)
|
|
}
|
|
|
|
private func evaluateFields(
|
|
_ rules: BookListRule,
|
|
item: JSONValue,
|
|
isHTML: Bool,
|
|
baseURL: String,
|
|
source: BookSource,
|
|
variables: [String: String]
|
|
) async -> Fields {
|
|
var context = RuleExecutionContext(variables: variables, baseURL: baseURL, source: source, isHTMLFragment: true)
|
|
var result = Fields()
|
|
let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
|
context.variables = name.variables
|
|
let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
|
context.variables = author.variables
|
|
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
|
|
let cover = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
|
context.variables = cover.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
|
|
result.name = name.joinedString
|
|
result.author = author.joinedString
|
|
result.kind = kind.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty
|
|
result.bookURL = isHTML ? bookURL.leadingString : bookURL.joinedString
|
|
result.coverURL = cover.joinedString
|
|
result.intro = MetadataTextSanitizer().clean(intro.joinedString)
|
|
result.wordCount = wordCount.joinedString
|
|
result.lastChapter = lastChapter.joinedString
|
|
result.variables = context.variables
|
|
result.diagnostics = [name, author, kind, bookURL, cover, intro, wordCount, lastChapter].flatMap(\.diagnostics)
|
|
return result
|
|
}
|
|
|
|
private func evaluate(
|
|
_ rule: String?,
|
|
item: JSONValue,
|
|
isHTML: Bool,
|
|
baseURL: String,
|
|
context: RuleExecutionContext
|
|
) async -> RuleEvaluation {
|
|
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
|
|
if case .string = item {
|
|
return await engine.evaluate(rule, inText: item.stringValue, baseURL: baseURL, context: context)
|
|
}
|
|
return await engine.evaluate(rule, in: item, context: context)
|
|
}
|
|
|
|
private func resolve(_ value: String, against base: URL) -> String {
|
|
LegadoURLBuilder.resolve(value, against: base)
|
|
}
|
|
|
|
private func parseHeaders(_ source: String?) -> [String: String] {
|
|
guard let source, let value = try? JSONValue.decode(data: Data(source.utf8)),
|
|
case .object(let object) = value else { return [:] }
|
|
return object.mapValues(\.stringValue)
|
|
}
|
|
|
|
private func failure(_ source: BookSource, code: String, message: String, rule: String) -> SourceSearchOutcome {
|
|
.init(sourceID: source.id, books: [], diagnostics: [
|
|
.init(severity: .error, code: code, message: message, rule: rule)
|
|
])
|
|
}
|
|
|
|
private struct Fields {
|
|
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 variables: [String: String] = [:]
|
|
var diagnostics: [RuleDiagnostic] = []
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
var trimmingListPrefix: String {
|
|
guard hasPrefix("-") || hasPrefix("+") else { return self }
|
|
return String(dropFirst())
|
|
}
|
|
|
|
fileprivate var nilIfEmpty: String? { isEmpty ? nil : self }
|
|
}
|