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,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 }
}
+107
View File
@@ -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
}
}
+132
View File
@@ -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
}
}