mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
import SwiftSoup
|
||||
|
||||
public struct MetadataTextSanitizer: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func clean(_ value: String?) -> String? {
|
||||
guard var value else { return nil }
|
||||
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return nil }
|
||||
|
||||
do {
|
||||
value = replacingBlockTagsWithNewlines(value)
|
||||
let document = try SwiftSoup.parseBodyFragment(value)
|
||||
try document.select("script,style,noscript").remove()
|
||||
value = try document.body()?.text(trimAndNormaliseWhitespace: false) ?? document.text()
|
||||
} catch {
|
||||
value = value.replacingOccurrences(
|
||||
of: #"<[^>]+>"#,
|
||||
with: "",
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
)
|
||||
value = (try? Parser.unescapeEntities(value, false)) ?? value
|
||||
}
|
||||
|
||||
value = value
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
||||
.replacingOccurrences(of: "\u{200B}", with: "")
|
||||
let output = value.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
let result = output.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return result.isEmpty ? nil : result
|
||||
}
|
||||
|
||||
private func replacingBlockTagsWithNewlines(_ value: String) -> String {
|
||||
value.replacingOccurrences(
|
||||
of: #"(?i)<\s*br\s*/?\s*>|<\s*/?\s*(?:p|div|li|ul|ol|section|article|blockquote|h[1-6])\b[^>]*>"#,
|
||||
with: "\n",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterHTMLFormatter {
|
||||
func format(_ html: String, baseURL: URL) -> String {
|
||||
guard let expression = try? NSRegularExpression(pattern: #"(?i)<img\b[^>]*>"#) else {
|
||||
return MetadataTextSanitizer().clean(html) ?? ""
|
||||
}
|
||||
var protected = html
|
||||
var images: [String: String] = [:]
|
||||
let token = UUID().uuidString
|
||||
for (index, match) in expression.matches(in: html, range: NSRange(html.startIndex..., in: html)).enumerated().reversed() {
|
||||
guard let range = Range(match.range, in: html),
|
||||
let element = try? SwiftSoup.parseBodyFragment(String(html[range])).select("img").first() else { continue }
|
||||
let src = ["data-src", "data-original", "data-srcset", "src"]
|
||||
.compactMap { try? element.attr($0) }.first { !$0.isEmpty } ?? ""
|
||||
let marker = "LEGADO_IMAGE_\(token)_\(index)"
|
||||
let url = LegadoURLBuilder.resolve(src, against: baseURL)
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
images[marker] = src.isEmpty ? "" : "<img src=\"\(url)\">"
|
||||
protected.replaceSubrange(range, with: "\n\(marker)\n")
|
||||
}
|
||||
var result = MetadataTextSanitizer().clean(protected) ?? ""
|
||||
for (marker, image) in images { result = result.replacingOccurrences(of: marker, with: image) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
public struct ContentProcessingReport: Sendable {
|
||||
public var title: String
|
||||
public var content: String
|
||||
public var effectiveRuleIDs: [Int64]
|
||||
public var removedDuplicateTitle: Bool
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
content: String,
|
||||
effectiveRuleIDs: [Int64] = [],
|
||||
removedDuplicateTitle: Bool = false,
|
||||
diagnostics: [RuleDiagnostic] = []
|
||||
) {
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.effectiveRuleIDs = effectiveRuleIDs
|
||||
self.removedDuplicateTitle = removedDuplicateTitle
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct ContentSanitizer: Sendable {
|
||||
private let regex: RegexEvaluator
|
||||
|
||||
public init(regex: RegexEvaluator = .init()) {
|
||||
self.regex = regex
|
||||
}
|
||||
|
||||
public func process(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
content: String,
|
||||
rules: [ReplaceRule],
|
||||
removeDuplicateTitle: Bool = true,
|
||||
resegment: Bool = true
|
||||
) -> ContentProcessingReport {
|
||||
let ordered = rules.filter(\.isEnabled).sorted { $0.sortOrder == $1.sortOrder ? $0.id < $1.id : $0.sortOrder < $1.sortOrder }
|
||||
var title = chapter.title
|
||||
var body = content
|
||||
var effective: [Int64] = []
|
||||
var diagnostics: [RuleDiagnostic] = []
|
||||
|
||||
for rule in ordered where rule.scopeTitle && applies(rule, to: book) {
|
||||
let result = apply(rule, to: title)
|
||||
diagnostics += result.diagnostics
|
||||
if result.value != title {
|
||||
title = result.value
|
||||
effective.append(rule.id)
|
||||
}
|
||||
}
|
||||
|
||||
var removed = false
|
||||
if removeDuplicateTitle {
|
||||
let lines = body.components(separatedBy: .newlines)
|
||||
if let firstIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
|
||||
let normalizedLine = normalize(lines[firstIndex])
|
||||
let normalizedTitles = [chapter.title, title, book.name + chapter.title].map(normalize)
|
||||
if normalizedTitles.contains(normalizedLine) {
|
||||
var updated = lines
|
||||
updated.remove(at: firstIndex)
|
||||
body = updated.joined(separator: "\n")
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resegment {
|
||||
body = body.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: " "))) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
for rule in ordered where rule.scopeContent && applies(rule, to: book) {
|
||||
let result = apply(rule, to: body)
|
||||
diagnostics += result.diagnostics
|
||||
if result.value != body {
|
||||
body = result.value
|
||||
effective.append(rule.id)
|
||||
}
|
||||
}
|
||||
|
||||
var seen = Set<Int64>()
|
||||
effective = effective.filter { seen.insert($0).inserted }
|
||||
return .init(
|
||||
title: title,
|
||||
content: body,
|
||||
effectiveRuleIDs: effective,
|
||||
removedDuplicateTitle: removed,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
}
|
||||
|
||||
private func applies(_ rule: ReplaceRule, to book: Book) -> Bool {
|
||||
if let excluded = rule.excludeScope, !excluded.isEmpty,
|
||||
(!book.name.isEmpty && excluded.contains(book.name)) || (!book.origin.isEmpty && excluded.contains(book.origin)) { return false }
|
||||
guard let scope = rule.scope, !scope.isEmpty else { return true }
|
||||
return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin))
|
||||
}
|
||||
|
||||
private func apply(_ rule: ReplaceRule, to input: String) -> (value: String, diagnostics: [RuleDiagnostic]) {
|
||||
guard !rule.pattern.isEmpty else { return (input, []) }
|
||||
do {
|
||||
if rule.isRegex {
|
||||
return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
|
||||
}
|
||||
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), [])
|
||||
} catch let error as RuleEngineError {
|
||||
return (input, [error.diagnostic])
|
||||
} catch {
|
||||
return (input, [.init(
|
||||
severity: .error,
|
||||
code: "replace.failed",
|
||||
message: "净化规则“\(rule.name)”执行失败:\(error.localizedDescription)",
|
||||
rule: rule.pattern
|
||||
)])
|
||||
}
|
||||
}
|
||||
|
||||
private func normalize(_ value: String) -> String {
|
||||
value.unicodeScalars.filter {
|
||||
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
|
||||
}.map(String.init).joined().lowercased()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user