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,242 @@
import Compression
import CoreFoundation
import Foundation
public enum DuplicatePolicy: Sendable {
case replace
case skip
case fail
}
public enum BookSourceImportError: Error, Equatable, LocalizedError {
case emptyDocument
case invalidDocument(String)
case invalidSources([BookSourceIssue])
case duplicate(String)
public var errorDescription: String? {
switch self {
case .emptyDocument:
return "导入内容为空"
case .invalidDocument(let detail):
return "无法解析书源 JSON:\(detail)"
case .invalidSources(let issues):
return issues.map(\.message).joined(separator: ";")
case .duplicate(let url):
return "书源已存在:\(url)"
}
}
}
public struct BookSourceImportResult: Sendable {
public var sources: [BookSource]
public var inserted: Int
public var replaced: Int
public var skipped: Int
public var warnings: [BookSourceIssue]
public init(
sources: [BookSource],
inserted: Int,
replaced: Int,
skipped: Int,
warnings: [BookSourceIssue]
) {
self.sources = sources
self.inserted = inserted
self.replaced = replaced
self.skipped = skipped
self.warnings = warnings
}
}
public struct BookSourceImporter: Sendable {
public static let maximumDocumentBytes = 50 * 1_024 * 1_024
private let decoder: JSONDecoder
public init() {
decoder = JSONDecoder()
}
public struct RejectedSource: Sendable {
public var source: BookSource
public var issues: [BookSourceIssue]
}
public struct Inspection: Sendable {
public var sources: [BookSource]
public var rejected: [RejectedSource]
}
public func inspect(_ data: Data) throws -> Inspection {
let cleaned = try normalizedData(data)
let root: JSONValue
do { root = try JSONValue.decode(data: cleaned) }
catch { throw BookSourceImportError.invalidDocument(error.localizedDescription) }
let entries: [JSONValue]
switch root {
case .array(let array): entries = array
case .object: entries = [root]
default: throw BookSourceImportError.invalidDocument("需要书源对象或数组")
}
guard !entries.isEmpty else { throw BookSourceImportError.emptyDocument }
var sources: [BookSource] = []
var rejected: [RejectedSource] = []
for (index, entry) in entries.enumerated() {
do {
guard case .object = entry else { throw BookSourceImportError.invalidDocument("第 \(index + 1) 项不是对象") }
let source = try decoder.decode(BookSource.self, from: JSONEncoder().encode(entry))
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
if errors.isEmpty { sources.append(source) }
else { rejected.append(.init(source: source, issues: errors)) }
} catch {
let placeholder = BookSource(bookSourceUrl: entry["bookSourceUrl"]?.stringValue ?? "",
bookSourceName: entry["bookSourceName"]?.stringValue ?? "第 \(index + 1) 项")
rejected.append(.init(source: placeholder, issues: [.init(severity: .error, code: "source.decode",
field: "[\(index)]", message: "第 \(index + 1) 项:\(error.localizedDescription)")]))
}
}
return .init(sources: sources, rejected: rejected)
}
public func decode(_ data: Data) throws -> [BookSource] {
let result = try inspect(data)
guard result.rejected.isEmpty else { throw BookSourceImportError.invalidSources(result.rejected.flatMap(\.issues)) }
return result.sources
}
public func normalizedData(_ data: Data) throws -> Data {
guard data.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("书源文件超过 50 MB") }
let cleaned = decodeText(stripUTF8BOM(decompressIfGzip(data)))
guard cleaned.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("解压后书源超过 50 MB") }
guard !String(decoding: cleaned, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw BookSourceImportError.emptyDocument
}
return cleaned
}
public func prepare(
data: Data,
existing: [BookSource],
duplicatePolicy: DuplicatePolicy = .replace
) throws -> BookSourceImportResult {
let incoming = try decode(data)
var fatalIssues: [BookSourceIssue] = []
var warnings: [BookSourceIssue] = []
for source in incoming {
for issue in BookSourceValidator.validate(source) {
if issue.severity == .error {
fatalIssues.append(issue)
} else {
warnings.append(issue)
}
}
}
guard fatalIssues.isEmpty else {
throw BookSourceImportError.invalidSources(fatalIssues)
}
var merged = existing
var index = Dictionary(uniqueKeysWithValues: merged.enumerated().map { ($1.id, $0) })
var inserted = 0
var replaced = 0
var skipped = 0
for source in incoming {
if let existingIndex = index[source.id] {
switch duplicatePolicy {
case .replace:
merged[existingIndex] = source
replaced += 1
case .skip:
skipped += 1
case .fail:
throw BookSourceImportError.duplicate(source.id)
}
} else {
index[source.id] = merged.count
merged.append(source)
inserted += 1
}
}
return .init(
sources: merged,
inserted: inserted,
replaced: replaced,
skipped: skipped,
warnings: warnings
)
}
private func stripUTF8BOM(_ data: Data) -> Data {
let bom = Data([0xEF, 0xBB, 0xBF])
return data.starts(with: bom) ? Data(data.dropFirst(3)) : data
}
private func decodeText(_ data: Data) -> Data {
if let value = String(data: data, encoding: .utf8) { return Data(value.utf8) }
if data.starts(with: [0xFF, 0xFE]), let value = String(data: data, encoding: .utf16LittleEndian) {
return Data(value.utf8)
}
if data.starts(with: [0xFE, 0xFF]), let value = String(data: data, encoding: .utf16BigEndian) {
return Data(value.utf8)
}
if let value = String(data: data, encoding: Self.gb18030Encoding) {
return Data(value.utf8)
}
return data
}
private static var gb18030Encoding: String.Encoding {
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
}
private func decompressIfGzip(_ data: Data) -> Data {
guard data.count > 10, data[0] == 0x1F, data[1] == 0x8B, data[2] == 0x08 else { return data }
var offset = 10
let flags = Int(data[3])
if flags & 0x04 != 0 {
guard offset + 2 <= data.count - 8 else { return data }
let extraLength = Int(data[offset]) | (Int(data[offset + 1]) << 8)
offset += 2 + extraLength
}
if flags & 0x08 != 0 {
while offset < data.count, data[offset] != 0 { offset += 1 }
offset += 1
}
if flags & 0x10 != 0 {
while offset < data.count, data[offset] != 0 { offset += 1 }
offset += 1
}
if flags & 0x02 != 0 { offset += 2 }
guard offset < data.count - 8 else { return data }
let payload = Data(data[offset..<data.count - min(8, data.count - offset)])
return inflate(payload) ?? data
}
private func inflate(_ payload: Data) -> Data? {
var capacity = max(payload.count * 4, 64 * 1_024)
while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 {
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
let produced = payload.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_decode_buffer(
destination, capacity,
base, payload.count,
nil,
COMPRESSION_ZLIB
)
}
if produced > 0, produced < capacity {
let result = Data(bytes: destination, count: produced)
destination.deallocate()
return result
}
destination.deallocate()
guard produced == capacity, capacity <= Int.max / 2 else { return nil }
capacity = min(capacity * 2, Self.maximumDocumentBytes + 1)
if capacity == produced { return nil }
}
return nil
}
}
@@ -0,0 +1,72 @@
import Foundation
public struct BookSourceIssue: Codable, Hashable, Identifiable, Sendable {
public enum Severity: String, Codable, Sendable {
case warning
case error
}
public var severity: Severity
public var code: String
public var field: String
public var message: String
public var id: String { "\(code):\(field):\(message)" }
public init(severity: Severity, code: String, field: String, message: String) {
self.severity = severity
self.code = code
self.field = field
self.message = message
}
}
public enum BookSourceValidator {
public static func validate(_ source: BookSource) -> [BookSourceIssue] {
var issues: [BookSourceIssue] = []
if source.bookSourceUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
issues.append(.init(
severity: .error,
code: "source_url.empty",
field: "bookSourceUrl",
message: "书源地址不能为空"
))
}
if source.bookSourceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
issues.append(.init(
severity: .error,
code: "source_name.empty",
field: "bookSourceName",
message: "书源名称不能为空"
))
}
if !source.bookSourceUrl.isEmpty,
let components = URLComponents(string: source.bookSourceUrl),
components.scheme == nil {
issues.append(.init(
severity: .warning,
code: "source_url.no_scheme",
field: "bookSourceUrl",
message: "书源地址没有 URL scheme,运行时可能无法解析相对地址"
))
}
if source.searchUrl?.isEmpty == false, source.ruleSearch == nil {
issues.append(.init(
severity: .warning,
code: "search.rule_missing",
field: "ruleSearch",
message: "配置了搜索地址但没有搜索解析规则"
))
}
if source.exploreUrl?.isEmpty == false, source.ruleExplore == nil {
issues.append(.init(
severity: .warning,
code: "explore.rule_missing",
field: "ruleExplore",
message: "配置了发现地址但没有发现解析规则"
))
}
return issues
}
}
@@ -0,0 +1,13 @@
import Foundation
import Vision
public struct QRCodeDecoder: Sendable {
public init() {}
public func detect(in data: Data) throws -> [String] {
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr]
try VNImageRequestHandler(data: data).perform([request])
var seen = Set<String>()
return (request.results ?? []).compactMap(\.payloadStringValue).filter { seen.insert($0).inserted }
}
}
@@ -0,0 +1,88 @@
import Foundation
public struct RSSSourceImportPayload: Sendable {
public var sources: [RssSource]
public var subscriptionURLs: [String]
public init(sources: [RssSource] = [], subscriptionURLs: [String] = []) {
self.sources = sources
self.subscriptionURLs = subscriptionURLs
}
}
public enum RSSSourceImportError: Error, LocalizedError, Sendable {
case emptyDocument
case wrongFormat
case invalidSource
case duplicate(String)
public var errorDescription: String? {
switch self {
case .emptyDocument: "导入内容为空"
case .wrongFormat: "RSS源格式错误"
case .invalidSource: "不是订阅源"
case .duplicate(let url): "RSS源已存在:\(url)"
}
}
}
public struct RSSSourceImporter: Sendable {
public init() {}
public func decode(_ data: Data) throws -> RSSSourceImportPayload {
var data = data
if data.starts(with: [0xEF, 0xBB, 0xBF]) { data = Data(data.dropFirst(3)) }
guard !data.isEmpty else { throw RSSSourceImportError.emptyDocument }
let decoder = JSONDecoder()
if let manifest = try? decoder.decode(SubscriptionManifest.self, from: data), !manifest.sourceUrls.isEmpty {
return .init(subscriptionURLs: manifest.sourceUrls)
}
let sources: [RssSource]
if let values = try? decoder.decode([RssSource].self, from: data) { sources = values }
else if let value = try? decoder.decode(RssSource.self, from: data) { sources = [value] }
else { throw RSSSourceImportError.wrongFormat }
guard sources.isEmpty || sources.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
return .init(sources: sources)
}
public func merge(
_ incoming: [RssSource],
into existing: [RssSource],
duplicatePolicy: DuplicatePolicy = .replace,
addGroup: String? = nil
) throws -> [RssSource] {
var result = existing
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($0.element.sourceUrl, $0.offset) })
for original in incoming where !original.sourceUrl.isEmpty {
var source = original
if let addGroup, !addGroup.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source.addGroups(addGroup) }
if let index = indexes[source.sourceUrl] {
switch duplicatePolicy {
case .replace: result[index] = source
case .skip: continue
case .fail: throw RSSSourceImportError.duplicate(source.sourceUrl)
}
} else {
indexes[source.sourceUrl] = result.count
result.append(source)
}
}
return result
}
public func defaultSelection(incoming: [RssSource], existing: [RssSource]) -> [Bool] {
let current = Dictionary(uniqueKeysWithValues: existing.map { ($0.sourceUrl, $0.lastUpdateTime) })
return incoming.map { source in
guard let oldTime = current[source.sourceUrl] else { return true }
return oldTime < source.lastUpdateTime
}
}
public func encode(_ sources: [RssSource], prettyPrinted: Bool = true) throws -> Data {
let encoder = JSONEncoder()
if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] }
return try encoder.encode(sources)
}
private struct SubscriptionManifest: Decodable {
var sourceUrls: [String]
}
}
@@ -0,0 +1,42 @@
import Foundation
public enum RuleImportInput: Sendable, Equatable {
case json(String)
case remote(URL, withoutUserAgent: Bool)
public static func parse(_ input: String, kind: String = "bookSource") throws -> Self {
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("{"), value.hasSuffix("}") { return .json(value) }
if value.hasPrefix("["), value.hasSuffix("]"), (try? JSONValue.decode(data: Data(value.utf8))) != nil {
return .json(value)
}
if let start = value.range(of: "]("), value.hasSuffix(")") {
value = String(value[start.upperBound..<value.index(before: value.endIndex)])
}
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "*`<> \n\r\t"))
if let url = URL(string: value), url.scheme?.lowercased() == "legado" {
guard url.host?.lowercased() == "import", url.path.lowercased() == "/" + kind.lowercased(),
let src = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "src" })?.value,
!src.lowercased().hasPrefix("legado:") else {
throw BookSourceImportError.invalidDocument("二维码或深链类型不匹配,需要 \(kind)")
}
return try parse(src, kind: kind)
}
let marker = "#requestWithoutUA"
let withoutUA = value.hasSuffix(marker)
if withoutUA { value = String(value.dropLast(marker.count)) }
guard let url = URL(string: value), let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme), url.host?.isEmpty == false else {
throw BookSourceImportError.invalidDocument("请输入 JSON、HTTP(S) 地址或 Legado 导入二维码")
}
return .remote(url, withoutUserAgent: withoutUA)
}
public func request() -> URLRequest? {
guard case .remote(let url, let withoutUA) = self else { return nil }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45)
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
if withoutUA { request.setValue("null", forHTTPHeaderField: "User-Agent") }
return request
}
}