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: "") { html = String(content[..\(script)" + String(content[range.lowerBound...]) } else { html = "\(content)" } } else { html = content } return contentHTML(html, style: source.startStyle?.trimmed.nilIfEmpty ?? source.style) } private func injectStyle(into content: String, style: String) -> String { let tag = "" if let range = content.range(of: "") { return String(content[..") { return String(content[.. 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(""), let end = raw.range(of: "", options: .backwards) { let script = String(raw[raw.index(raw.startIndex, offsetBy: 4).. 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 } }