mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user