mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationXML)
|
||||
import FoundationXML
|
||||
#endif
|
||||
|
||||
public struct RSSParseResult: Sendable, Equatable {
|
||||
public var articles: [RssArticle]
|
||||
public var nextURL: String?
|
||||
public init(articles: [RssArticle], nextURL: String? = nil) {
|
||||
self.articles = articles
|
||||
self.nextURL = nextURL
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSDefaultParser {
|
||||
public static func parse(sortName: String, xml: String, sourceURL: String) throws -> RSSParseResult {
|
||||
let delegate = Delegate(sortName: sortName, sourceURL: sourceURL)
|
||||
let parser = XMLParser(data: Data(xml.utf8))
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else { throw parser.parserError ?? RSSParserError.invalidXML }
|
||||
return .init(articles: delegate.articles)
|
||||
}
|
||||
|
||||
private final class Delegate: NSObject, XMLParserDelegate {
|
||||
let sortName: String
|
||||
let sourceURL: String
|
||||
var articles: [RssArticle] = []
|
||||
var article = RssArticle()
|
||||
var insideItem = false
|
||||
var activeField: String?
|
||||
var text = ""
|
||||
|
||||
init(sortName: String, sourceURL: String) {
|
||||
self.sortName = sortName
|
||||
self.sourceURL = sourceURL
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
let name = elementName.lowercased()
|
||||
if name == "item" { insideItem = true; article = RssArticle(); return }
|
||||
guard insideItem else { return }
|
||||
if name == "media:thumbnail" {
|
||||
article.image = attribute(attributeDict, named: "url")
|
||||
} else if name == "enclosure", attribute(attributeDict, named: "type")?.localizedCaseInsensitiveContains("image/") == true {
|
||||
article.image = attribute(attributeDict, named: "url")
|
||||
}
|
||||
if ["title", "link", "description", "content:encoded", "pubdate", "time"].contains(name) {
|
||||
activeField = name
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if insideItem, activeField != nil { text += string }
|
||||
}
|
||||
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
|
||||
if insideItem, activeField != nil { text += String(decoding: CDATABlock, as: UTF8.self) }
|
||||
}
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
|
||||
let name = elementName.lowercased()
|
||||
if name == "item" {
|
||||
article.origin = sourceURL; article.sort = sortName; articles.append(article)
|
||||
insideItem = false; activeField = nil
|
||||
return
|
||||
}
|
||||
guard insideItem, name == activeField else { return }
|
||||
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch name {
|
||||
case "title": article.title = value
|
||||
case "link": article.link = value
|
||||
case "description": article.description = value; if article.image == nil { article.image = firstImageURL(in: text) }
|
||||
case "content:encoded": article.content = value; if article.image == nil { article.image = firstImageURL(in: text) }
|
||||
case "pubdate", "time": article.pubDate = value
|
||||
default: break
|
||||
}
|
||||
activeField = nil; text = ""
|
||||
}
|
||||
|
||||
private func attribute(_ values: [String: String], named name: String) -> String? {
|
||||
values.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
|
||||
}
|
||||
private func firstImageURL(in input: String) -> String? {
|
||||
guard let expression = try? NSRegularExpression(pattern: "(<img [^>]*>)", options: .caseInsensitive),
|
||||
let image = expression.firstMatch(in: input, range: NSRange(input.startIndex..., in: input)),
|
||||
let tagRange = Range(image.range(at: 1), in: input) else { return nil }
|
||||
let tag = String(input[tagRange])
|
||||
guard let expression = try? NSRegularExpression(pattern: #"src\s*=\s*\"([^\"]+)\""#, options: .caseInsensitive),
|
||||
let source = expression.firstMatch(in: tag, range: NSRange(tag.startIndex..., in: tag)),
|
||||
let sourceRange = Range(source.range(at: 1), in: tag) else { return nil }
|
||||
return String(tag[sourceRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSParserError: Error, LocalizedError, Sendable {
|
||||
case invalidXML
|
||||
case emptyBody(String)
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidXML: "RSS XML解析失败"
|
||||
case .emptyBody(let source): "获取网页内容失败:\(source)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
public enum RSSReadingMode: Equatable, Sendable {
|
||||
case html(content: String, baseURL: String?)
|
||||
case startHTML(content: String, baseURL: String?)
|
||||
case web(url: String)
|
||||
case image(url: String)
|
||||
case video(url: String)
|
||||
}
|
||||
|
||||
public struct RSSReadingPolicy: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func mode(article: RssArticle, source: RssSource, resolvedContent: String? = nil) -> RSSReadingMode {
|
||||
if article.type == 1 || article.type == 2 {
|
||||
let raw = resolvedContent?.trimmed.nilIfEmpty ?? article.link
|
||||
let articleURL = absoluteURL(article.link, base: article.origin)
|
||||
let url = absoluteURL(raw, base: articleURL)
|
||||
return article.type == 1 ? .image(url: url) : .video(url: url)
|
||||
}
|
||||
if let description = article.description?.trimmed.nilIfEmpty {
|
||||
return .html(content: description, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil)
|
||||
}
|
||||
if source.ruleContent?.trimmed.nilIfEmpty != nil, let content = resolvedContent {
|
||||
return .html(content: content, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil)
|
||||
}
|
||||
return .web(url: absoluteURL(article.link, base: article.origin))
|
||||
}
|
||||
|
||||
private func absoluteURL(_ value: String, base: String) -> String {
|
||||
URL(string: value, relativeTo: URL(string: base))?.absoluteURL.absoluteString ?? value
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSHTMLProcessor: Sendable {
|
||||
public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}"
|
||||
public init() {}
|
||||
|
||||
public func contentHTML(_ content: String, style: String?) -> String {
|
||||
injectStyle(into: content, style: style?.trimmed.nilIfEmpty ?? Self.defaultStyle)
|
||||
}
|
||||
|
||||
public func startHTML(_ content: String, source: RssSource) -> String {
|
||||
var html: String
|
||||
if let script = source.startJs?.trimmed.nilIfEmpty {
|
||||
if let range = content.range(of: "</body>") {
|
||||
html = String(content[..<range.lowerBound]) + "<script>\(script)</script>" + String(content[range.lowerBound...])
|
||||
} else {
|
||||
html = "<body>\(content)<script>\(script)</script></body>"
|
||||
}
|
||||
} else { html = content }
|
||||
return contentHTML(html, style: source.startStyle?.trimmed.nilIfEmpty ?? source.style)
|
||||
}
|
||||
|
||||
private func injectStyle(into content: String, style: String) -> String {
|
||||
let tag = "<style>\(style)</style>"
|
||||
if let range = content.range(of: "</style>") {
|
||||
return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...])
|
||||
}
|
||||
if let range = content.range(of: "</head>") {
|
||||
return String(content[..<range.lowerBound]) + tag + String(content[range.lowerBound...])
|
||||
}
|
||||
return tag + content
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSStartPageResolver: Sendable {
|
||||
public init() {}
|
||||
public func resolve(_ source: RssSource) throws -> RSSReadingMode? {
|
||||
guard let raw = source.startHtml?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
||||
let html: String
|
||||
if raw.hasPrefix("@js:") {
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(script: String(raw.dropFirst(4)), result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter))
|
||||
html = result.value.stringValue
|
||||
} else if raw.hasPrefix("<js>"), let end = raw.range(of: "</js>", options: .backwards) {
|
||||
let script = String(raw[raw.index(raw.startIndex, offsetBy: 4)..<end.lowerBound])
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(script: script, result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter))
|
||||
html = result.value.stringValue
|
||||
} else { html = raw }
|
||||
return .startHTML(content: html, baseURL: source.loadWithBaseUrl ? source.sourceUrl : nil)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSResourcePolicy: Sendable {
|
||||
private let blacklist: [String]
|
||||
private let whitelist: [String]
|
||||
public init(blacklist: String?, whitelist: String?) {
|
||||
self.blacklist = Self.rules(blacklist)
|
||||
self.whitelist = Self.rules(whitelist)
|
||||
}
|
||||
public func allows(_ url: String) -> Bool {
|
||||
if !blacklist.isEmpty { return !blacklist.contains { matches(url, rule: $0) } }
|
||||
if !whitelist.isEmpty { return whitelist.contains { matches(url, rule: $0) } }
|
||||
return true
|
||||
}
|
||||
public func contentBlockerJSON() -> String? {
|
||||
var rules: [[String: Any]] = []
|
||||
if !blacklist.isEmpty {
|
||||
for value in blacklist {
|
||||
rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "block"]])
|
||||
}
|
||||
} else if !whitelist.isEmpty {
|
||||
rules.append(["trigger": ["url-filter": ".*"], "action": ["type": "block"]])
|
||||
for value in whitelist {
|
||||
rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "ignore-previous-rules"]])
|
||||
}
|
||||
}
|
||||
guard !rules.isEmpty, let data = try? JSONSerialization.data(withJSONObject: rules) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
private func matches(_ value: String, rule: String) -> Bool {
|
||||
if value.hasPrefix(rule) { return true }
|
||||
guard let expression = try? NSRegularExpression(pattern: rule) else { return false }
|
||||
let range = NSRange(value.startIndex..., in: value)
|
||||
guard let match = expression.firstMatch(in: value, range: range) else { return false }
|
||||
return match.range == range
|
||||
}
|
||||
private func filter(for rule: String) -> String {
|
||||
let literalPrefix = "^" + NSRegularExpression.escapedPattern(for: rule)
|
||||
return "(?:\(literalPrefix))|(?:^(?:\(rule))$)"
|
||||
}
|
||||
private static func rules(_ value: String?) -> [String] {
|
||||
value?.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSRuleParseReport: Sendable {
|
||||
public var result: RSSParseResult
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
public init(result: RSSParseResult, diagnostics: [RuleDiagnostic] = []) {
|
||||
self.result = result
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSRuleParser: Sendable {
|
||||
private let engine: RuleEngine
|
||||
public init(engine: RuleEngine = .init()) { self.engine = engine }
|
||||
|
||||
public func parse(
|
||||
sortName: String,
|
||||
sortURL: String,
|
||||
redirectURL: String,
|
||||
body: String,
|
||||
source: RssSource,
|
||||
variables: [String: String] = [:]
|
||||
) async throws -> RSSRuleParseReport {
|
||||
guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RSSParserError.emptyBody(source.sourceUrl)
|
||||
}
|
||||
guard var listRule = source.ruleArticles, !listRule.isEmpty else {
|
||||
return .init(result: try RSSDefaultParser.parse(sortName: sortName, xml: body, sourceURL: source.sourceUrl))
|
||||
}
|
||||
let reverse = listRule.hasPrefix("-")
|
||||
if reverse { listRule.removeFirst() }
|
||||
let adapter = source.bookSourceAdapter
|
||||
let baseContext = RuleExecutionContext(variables: variables, baseURL: sortURL, source: adapter)
|
||||
let list = await engine.evaluate(listRule, inText: body, baseURL: sortURL, listMode: true, context: baseContext)
|
||||
var diagnostics = list.diagnostics
|
||||
var nextURL: String?
|
||||
if let nextRule = source.ruleNextPage, !nextRule.isEmpty {
|
||||
if nextRule.uppercased() == "PAGE" {
|
||||
nextURL = sortURL
|
||||
} else {
|
||||
let next = await engine.evaluate(nextRule, inText: body, baseURL: sortURL, context: .init(variables: list.variables, baseURL: sortURL, source: adapter))
|
||||
diagnostics += next.diagnostics
|
||||
if let value = next.joinedString, !value.isEmpty {
|
||||
nextURL = URL(string: value, relativeTo: URL(string: sortURL))?.absoluteURL.absoluteString ?? value
|
||||
}
|
||||
}
|
||||
}
|
||||
let variable = encodeVariables(list.variables)
|
||||
var articles: [RssArticle] = []
|
||||
for item in flatten(list.values) {
|
||||
var context = RuleExecutionContext(variables: list.variables, baseURL: sortURL, source: adapter)
|
||||
let title = await evaluate(source.ruleTitle, item: item, context: context); context.variables = title.variables
|
||||
let pubDate = await evaluate(source.rulePubDate, item: item, context: context); context.variables = pubDate.variables
|
||||
let description = await evaluate(source.ruleDescription, item: item, context: context); context.variables = description.variables
|
||||
let image = await evaluate(source.ruleImage, item: item, context: context); context.variables = image.variables
|
||||
let link = await evaluate(source.ruleLink, item: item, context: context)
|
||||
diagnostics += [title, pubDate, description, image, link].flatMap(\.diagnostics)
|
||||
guard let titleValue = title.joinedString?.trimmingCharacters(in: .whitespacesAndNewlines), !titleValue.isEmpty else { continue }
|
||||
let rawLink = link.leadingString ?? ""
|
||||
let resolvedLink = URL(string: rawLink, relativeTo: URL(string: sortURL))?
|
||||
.absoluteURL.absoluteString ?? rawLink
|
||||
var article = RssArticle(origin: source.sourceUrl, sort: sortName, title: titleValue, link: resolvedLink)
|
||||
article.pubDate = pubDate.joinedString
|
||||
article.description = source.ruleDescription?.isEmpty == false ? description.joinedString : nil
|
||||
if let imageValue = image.joinedString, !imageValue.isEmpty {
|
||||
article.image = URL(string: imageValue, relativeTo: URL(string: source.sourceUrl))?.absoluteURL.absoluteString ?? imageValue
|
||||
}
|
||||
article.variable = variable
|
||||
article.type = source.type
|
||||
articles.append(article)
|
||||
}
|
||||
if reverse { articles.reverse() }
|
||||
_ = redirectURL
|
||||
return .init(result: .init(articles: articles, nextURL: nextURL), diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
private func evaluate(_ rule: String?, item: JSONValue, context: RuleExecutionContext) 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: context.baseURL, context: context)
|
||||
}
|
||||
return await engine.evaluate(rule, in: item, context: context)
|
||||
}
|
||||
private func flatten(_ values: [JSONValue]) -> [JSONValue] {
|
||||
values.flatMap { value in
|
||||
if case .array(let items) = value { return items }
|
||||
return [value]
|
||||
}
|
||||
}
|
||||
private func encodeVariables(_ values: [String: String]) -> String? {
|
||||
guard !values.isEmpty, let data = try? JSONEncoder().encode(values) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension RssSource {
|
||||
var bookSourceAdapter: BookSource {
|
||||
var value = BookSource(bookSourceUrl: sourceUrl, bookSourceName: sourceName)
|
||||
value.bookSourceGroup = sourceGroup; value.bookSourceType = type; value.customOrder = customOrder
|
||||
value.enabled = enabled; value.jsLib = jsLib; value.enabledCookieJar = enabledCookieJar
|
||||
value.concurrentRate = concurrentRate; value.header = header; value.loginUrl = loginUrl
|
||||
value.loginUi = loginUi; value.loginCheckJs = loginCheckJs; value.coverDecodeJs = coverDecodeJs
|
||||
value.bookSourceComment = sourceComment; value.variableComment = variableComment
|
||||
value.lastUpdateTime = lastUpdateTime; value.searchUrl = searchUrl
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSFetchResult: Sendable {
|
||||
public var requestURL: String
|
||||
public var finalURL: String
|
||||
public var statusCode: Int
|
||||
public var body: String
|
||||
public var report: RSSRuleParseReport
|
||||
}
|
||||
|
||||
public struct RSSContentFetchResult: Sendable {
|
||||
public var requestURL: String
|
||||
public var finalURL: String
|
||||
public var statusCode: Int
|
||||
public var body: String
|
||||
public var content: String
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
}
|
||||
|
||||
public struct RSSService: Sendable {
|
||||
private let transport: any HTTPTransport
|
||||
private let urlBuilder: LegadoURLBuilder
|
||||
private let engine: RuleEngine
|
||||
private let parser: RSSRuleParser
|
||||
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.parser = RSSRuleParser(engine: engine)
|
||||
self.networkPolicy = networkPolicy
|
||||
self.webViewExecutor = webViewExecutor
|
||||
}
|
||||
|
||||
public func articles(
|
||||
sortName: String,
|
||||
sortURL: String,
|
||||
source: RssSource,
|
||||
page: Int,
|
||||
key: String? = nil
|
||||
) async throws -> RSSRuleParseReport {
|
||||
try await articlesDetailed(sortName: sortName, sortURL: sortURL, source: source, page: page, key: key).report
|
||||
}
|
||||
|
||||
public func articlesDetailed(
|
||||
sortName: String,
|
||||
sortURL: String,
|
||||
source: RssSource,
|
||||
page: Int,
|
||||
key: String? = nil
|
||||
) async throws -> RSSFetchResult {
|
||||
let adapter = source.bookSourceAdapter
|
||||
let headers = try BookSourceHeaderResolver().resolve(adapter)
|
||||
let prepared = try urlBuilder.prepare(
|
||||
sortURL,
|
||||
context: .init(key: key, page: page, source: adapter),
|
||||
baseURL: URL(string: source.sourceUrl),
|
||||
defaultHeaders: headers
|
||||
)
|
||||
let response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: adapter,
|
||||
transport: transport,
|
||||
timeout: 180,
|
||||
webViewExecutor: webViewExecutor,
|
||||
snapshotStage: .debug
|
||||
)
|
||||
let report = try await parser.parse(
|
||||
sortName: sortName,
|
||||
sortURL: sortURL,
|
||||
redirectURL: response.finalURL.absoluteString,
|
||||
body: response.decodedText,
|
||||
source: source,
|
||||
variables: response.requestVariables
|
||||
)
|
||||
return .init(
|
||||
requestURL: prepared.url.absoluteString,
|
||||
finalURL: response.finalURL.absoluteString,
|
||||
statusCode: response.statusCode,
|
||||
body: response.decodedText,
|
||||
report: report
|
||||
)
|
||||
}
|
||||
|
||||
public func content(article: RssArticle, rule: String, source: RssSource) async throws -> String {
|
||||
try await contentDetailed(article: article, rule: rule, source: source).content
|
||||
}
|
||||
|
||||
public func contentDetailed(article: RssArticle, rule: String, source: RssSource) async throws -> RSSContentFetchResult {
|
||||
let adapter = source.bookSourceAdapter
|
||||
let headers = try BookSourceHeaderResolver().resolve(adapter)
|
||||
let prepared = try urlBuilder.prepare(
|
||||
article.link,
|
||||
context: .init(variables: article.variableMap, source: adapter),
|
||||
baseURL: URL(string: article.origin),
|
||||
defaultHeaders: headers
|
||||
)
|
||||
let response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: adapter,
|
||||
transport: transport,
|
||||
timeout: 180,
|
||||
webViewExecutor: webViewExecutor,
|
||||
snapshotStage: .content
|
||||
)
|
||||
let result = await engine.evaluate(
|
||||
rule,
|
||||
inText: response.decodedText,
|
||||
baseURL: URL(string: article.link, relativeTo: URL(string: article.origin))?.absoluteURL.absoluteString ?? article.link,
|
||||
context: .init(variables: article.variableMap, baseURL: response.finalURL.absoluteString, source: adapter)
|
||||
)
|
||||
if let error = result.diagnostics.first(where: { $0.severity == .error }) {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: error.message)
|
||||
}
|
||||
return .init(
|
||||
requestURL: prepared.url.absoluteString,
|
||||
finalURL: response.finalURL.absoluteString,
|
||||
statusCode: response.statusCode,
|
||||
body: response.decodedText,
|
||||
content: result.joinedString ?? "",
|
||||
diagnostics: result.diagnostics
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSSort: Hashable, Identifiable, Sendable {
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var id: String { "\(name)\u{0}\(url)" }
|
||||
public init(name: String, url: String) { self.name = name; self.url = url }
|
||||
}
|
||||
|
||||
public struct RSSSortResolver: Sendable {
|
||||
private let engine: RuleEngine
|
||||
public init(engine: RuleEngine = .init()) { self.engine = engine }
|
||||
|
||||
public func resolve(_ source: RssSource) async -> [RSSSort] {
|
||||
var value = source.sortUrl
|
||||
if let rule = value, rule.hasPrefix("@js:") || rule.hasPrefix("<js>") {
|
||||
let result = await engine.evaluate(rule, in: .null, context: .init(source: source.bookSourceAdapter))
|
||||
value = result.joinedString
|
||||
}
|
||||
guard let value else { return [.init(name: "", url: source.sourceUrl)] }
|
||||
let pattern = try? NSRegularExpression(pattern: "(?:&&|\\n)+")
|
||||
let range = NSRange(value.startIndex..., in: value)
|
||||
let normalized = pattern?.stringByReplacingMatches(in: value, range: range, withTemplate: "\u{1F}") ?? value
|
||||
let sorts = normalized.split(separator: "\u{1F}", omittingEmptySubsequences: true).compactMap { item -> RSSSort? in
|
||||
let text = String(item)
|
||||
let parts = text.components(separatedBy: "::")
|
||||
let name = parts.first ?? ""
|
||||
let url = parts.dropFirst().joined(separator: "::")
|
||||
return url.isEmpty ? nil : .init(name: name, url: url)
|
||||
}
|
||||
return sorts.isEmpty ? [.init(name: "", url: source.sourceUrl)] : sorts
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user