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
+258
View File
@@ -0,0 +1,258 @@
import Foundation
public struct BookSource: Codable, Hashable, Identifiable, Sendable {
public var bookSourceUrl: String
public var bookSourceName: String
public var bookSourceGroup: String?
public var bookSourceType: Int
public var bookUrlPattern: String?
public var customOrder: Int
public var enabled: Bool
public var enabledExplore: Bool
public var jsLib: String?
public var enabledCookieJar: Bool?
public var concurrentRate: String?
public var header: String?
public var loginUrl: String?
public var loginUi: String?
public var loginCheckJs: String?
public var coverDecodeJs: String?
public var bookSourceComment: String?
public var variableComment: String?
public var lastUpdateTime: Int64
public var respondTime: Int64
public var weight: Int
public var exploreUrl: String?
public var exploreScreen: String?
public var ruleExplore: ExploreRule?
public var searchUrl: String?
public var ruleSearch: SearchRule?
public var ruleBookInfo: BookInfoRule?
public var ruleToc: TocRule?
public var ruleContent: ContentRule?
public var ruleReview: ReviewRule?
public var eventListener: Bool
public var customButton: Bool
public var additionalFields: [String: JSONValue] = [:]
public var id: String { bookSourceUrl }
public init(bookSourceUrl: String, bookSourceName: String) {
self.bookSourceUrl = bookSourceUrl
self.bookSourceName = bookSourceName
bookSourceGroup = nil
bookSourceType = 0
bookUrlPattern = nil
customOrder = 0
enabled = true
enabledExplore = true
jsLib = nil
enabledCookieJar = true
concurrentRate = nil
header = nil
loginUrl = nil
loginUi = nil
loginCheckJs = nil
coverDecodeJs = nil
bookSourceComment = nil
variableComment = nil
lastUpdateTime = 0
respondTime = 180_000
weight = 0
exploreUrl = nil
exploreScreen = nil
ruleExplore = nil
searchUrl = nil
ruleSearch = nil
ruleBookInfo = nil
ruleToc = nil
ruleContent = nil
ruleReview = nil
eventListener = false
customButton = false
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case bookSourceUrl, bookSourceName, bookSourceGroup, bookSourceType, bookUrlPattern, customOrder, enabled, enabledExplore, jsLib, enabledCookieJar, concurrentRate, header, loginUrl, loginUi, loginCheckJs, coverDecodeJs, bookSourceComment, variableComment, lastUpdateTime, respondTime, weight, exploreUrl, exploreScreen, ruleExplore, searchUrl, ruleSearch, ruleBookInfo, ruleToc, ruleContent, ruleReview, eventListener, customButton
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
bookSourceUrl = try values.decodeIfPresent(String.self, forKey: .bookSourceUrl) ?? ""
bookSourceName = try values.decodeIfPresent(String.self, forKey: .bookSourceName) ?? ""
bookSourceGroup = try values.decodeIfPresent(String.self, forKey: .bookSourceGroup)
bookSourceType = try values.decodeLossyInt(forKey: .bookSourceType) ?? 0
bookUrlPattern = try values.decodeIfPresent(String.self, forKey: .bookUrlPattern)
customOrder = try values.decodeLossyInt(forKey: .customOrder) ?? 0
enabled = try values.decodeLossyBool(forKey: .enabled) ?? true
enabledExplore = try values.decodeLossyBool(forKey: .enabledExplore) ?? true
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
enabledCookieJar = values.contains(.enabledCookieJar)
? try values.decodeLossyBool(forKey: .enabledCookieJar)
: true
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
header = try values.decodeIfPresent(String.self, forKey: .header)
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
loginUi = try values.decodeIfPresent(String.self, forKey: .loginUi)
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
coverDecodeJs = try values.decodeIfPresent(String.self, forKey: .coverDecodeJs)
bookSourceComment = try values.decodeIfPresent(String.self, forKey: .bookSourceComment)
variableComment = try values.decodeIfPresent(String.self, forKey: .variableComment)
lastUpdateTime = try values.decodeLossyInt64(forKey: .lastUpdateTime) ?? 0
respondTime = try values.decodeLossyInt64(forKey: .respondTime) ?? 180_000
weight = try values.decodeLossyInt(forKey: .weight) ?? 0
exploreUrl = try values.decodeIfPresent(String.self, forKey: .exploreUrl)
exploreScreen = try values.decodeIfPresent(String.self, forKey: .exploreScreen)
ruleExplore = try values.decodeFlexible(ExploreRule.self, forKey: .ruleExplore)
searchUrl = try values.decodeIfPresent(String.self, forKey: .searchUrl)
ruleSearch = try values.decodeFlexible(SearchRule.self, forKey: .ruleSearch)
ruleBookInfo = try values.decodeFlexible(BookInfoRule.self, forKey: .ruleBookInfo)
ruleToc = try values.decodeFlexible(TocRule.self, forKey: .ruleToc)
ruleContent = try values.decodeFlexible(ContentRule.self, forKey: .ruleContent)
ruleReview = try values.decodeFlexible(ReviewRule.self, forKey: .ruleReview)
eventListener = try values.decodeLossyBool(forKey: .eventListener) ?? false
customButton = try values.decodeLossyBool(forKey: .customButton) ?? false
if case .object(var raw) = try JSONValue(from: decoder) {
for key in CodingKeys.allCases {
if BookSourceEditorDocument.ruleKeys.contains(key.rawValue) {
var nested: [String: JSONValue] = [:]
var value = raw[key.rawValue]
if case .string(let text) = value { value = try? JSONValue.decode(data: Data(text.utf8)) }
if case .object(let object) = value { nested = object }
for field in BookSourceEditorSchema.sections.flatMap(\.fields) where field.path.hasPrefix(key.rawValue + ".") {
nested.removeValue(forKey: String(field.path.split(separator: ".")[1]))
}
raw[key.rawValue] = nested.isEmpty ? nil : .object(nested)
} else { raw.removeValue(forKey: key.rawValue) }
}
additionalFields = raw
}
}
public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: SourceCodingKey.self)
for (key, value) in additionalFields { try values.encode(value, forKey: SourceCodingKey(key)) }
try values.encode(bookSourceUrl, forKey: SourceCodingKey("bookSourceUrl"))
try values.encode(bookSourceName, forKey: SourceCodingKey("bookSourceName"))
try values.encodeIfPresent(bookSourceGroup, forKey: SourceCodingKey("bookSourceGroup"))
try values.encode(bookSourceType, forKey: SourceCodingKey("bookSourceType"))
try values.encodeIfPresent(bookUrlPattern, forKey: SourceCodingKey("bookUrlPattern"))
try values.encode(customOrder, forKey: SourceCodingKey("customOrder"))
try values.encode(enabled, forKey: SourceCodingKey("enabled"))
try values.encode(enabledExplore, forKey: SourceCodingKey("enabledExplore"))
try values.encodeIfPresent(jsLib, forKey: SourceCodingKey("jsLib"))
try values.encode(enabledCookieJar, forKey: SourceCodingKey("enabledCookieJar"))
try values.encodeIfPresent(concurrentRate, forKey: SourceCodingKey("concurrentRate"))
try values.encodeIfPresent(header, forKey: SourceCodingKey("header"))
try values.encodeIfPresent(loginUrl, forKey: SourceCodingKey("loginUrl"))
try values.encodeIfPresent(loginUi, forKey: SourceCodingKey("loginUi"))
try values.encodeIfPresent(loginCheckJs, forKey: SourceCodingKey("loginCheckJs"))
try values.encodeIfPresent(coverDecodeJs, forKey: SourceCodingKey("coverDecodeJs"))
try values.encodeIfPresent(bookSourceComment, forKey: SourceCodingKey("bookSourceComment"))
try values.encodeIfPresent(variableComment, forKey: SourceCodingKey("variableComment"))
try values.encode(lastUpdateTime, forKey: SourceCodingKey("lastUpdateTime"))
try values.encode(respondTime, forKey: SourceCodingKey("respondTime"))
try values.encode(weight, forKey: SourceCodingKey("weight"))
try values.encodeIfPresent(exploreUrl, forKey: SourceCodingKey("exploreUrl"))
try values.encodeIfPresent(exploreScreen, forKey: SourceCodingKey("exploreScreen"))
try encodeRule(ruleExplore, key: "ruleExplore", into: &values)
try values.encodeIfPresent(searchUrl, forKey: SourceCodingKey("searchUrl"))
try encodeRule(ruleSearch, key: "ruleSearch", into: &values)
try encodeRule(ruleBookInfo, key: "ruleBookInfo", into: &values)
try encodeRule(ruleToc, key: "ruleToc", into: &values)
try encodeRule(ruleContent, key: "ruleContent", into: &values)
try encodeRule(ruleReview, key: "ruleReview", into: &values)
try values.encode(eventListener, forKey: SourceCodingKey("eventListener"))
try values.encode(customButton, forKey: SourceCodingKey("customButton"))
}
private func encodeRule<T: Encodable>(_ rule: T?, key: String, into values: inout KeyedEncodingContainer<SourceCodingKey>) throws {
var object: [String: JSONValue] = [:]
if case .object(let extra) = additionalFields[key] { object = extra }
if let rule, case .object(let known) = try JSONValue.decode(data: JSONEncoder().encode(rule)) {
object.merge(known) { _, new in new }
}
if rule != nil || !object.isEmpty { try values.encode(object, forKey: SourceCodingKey(key)) }
}
}
public extension BookSource {
var groups: [String] {
Self.parseGroups(bookSourceGroup)
}
func hasGroup(_ group: String) -> Bool {
groups.contains(group.trimmingCharacters(in: .whitespacesAndNewlines))
}
mutating func addGroups(_ value: String) {
bookSourceGroup = Self.joinGroups(groups + Self.parseGroups(value))
}
mutating func removeGroups(_ value: String) {
let removed = Set(Self.parseGroups(value))
bookSourceGroup = Self.joinGroups(groups.filter { !removed.contains($0) })
}
mutating func replaceGroup(_ oldValue: String, with newValue: String?) {
let oldValue = oldValue.trimmingCharacters(in: .whitespacesAndNewlines)
var values = groups.filter { $0 != oldValue }
if let newValue { values += Self.parseGroups(newValue) }
bookSourceGroup = Self.joinGroups(values)
}
static func parseGroups(_ value: String?) -> [String] {
guard let value else { return [] }
var seen = Set<String>()
return value
.components(separatedBy: CharacterSet(charactersIn: ",;,;"))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0).inserted }
}
static func joinGroups(_ values: [String]) -> String? {
var seen = Set<String>()
let normalized = values
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0).inserted }
return normalized.isEmpty ? nil : normalized.joined(separator: ",")
}
}
private extension KeyedDecodingContainer {
func decodeFlexible<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(T.self, forKey: key) {
return value
}
let json = try decode(String.self, forKey: key)
guard !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let data = json.data(using: .utf8) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
func decodeLossyBool(forKey key: Key) throws -> Bool? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(Bool.self, forKey: key) { return value }
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
let value = try decode(String.self, forKey: key).lowercased()
if ["true", "1", "yes"].contains(value) { return true }
if ["false", "0", "no"].contains(value) { return false }
return nil
}
func decodeLossyInt(forKey key: Key) throws -> Int? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(Int.self, forKey: key) { return value }
return Int(try decode(String.self, forKey: key))
}
func decodeLossyInt64(forKey key: Key) throws -> Int64? {
guard contains(key), try !decodeNil(forKey: key) else { return nil }
if let value = try? decode(Int64.self, forKey: key) { return value }
return Int64(try decode(String.self, forKey: key))
}
}
@@ -0,0 +1,199 @@
import Foundation
public struct BookSourceEditorField: Identifiable, Sendable {
public let path: String
public let title: String
public var id: String { path }
public var help: String {
switch path {
case "bookSourceUrl": return "填写书源站点地址,例如 https://example.com;它也是书源的唯一标识。"
case "bookSourceName": return "显示在书源列表中的名称,不能为空。"
case "bookSourceGroup": return "多个分组可用逗号或分号分隔。"
case "bookUrlPattern": return "匹配该书源的书籍详情 URL 的正则表达式,用于从网址添加书籍。"
case "concurrentRate": return "1000 表示请求间隔 1000 ms;1/1000 表示每 1000 ms 最多 1 次请求。"
case "header": return "填写请求头 JSON,或用 @js: / <js> 动态生成。User-Agent 为 null 可禁用默认 UA。"
case "jsLib": return "填写共享 JavaScript,或以 JSON 对象配置在线脚本库。"
case "searchUrl": return "用 {{key}} 表示关键词、{{page}} 表示页码;URL 后可用逗号附加请求选项 JSON。"
case "exploreUrl": return "填写 名称::地址,多项用换行或 && 分隔;也支持 JSON 分类数组和 JavaScript。"
case "loginUi": return "填写登录界面 JSON。"
case "ruleToc.formatJs": return "目录去重后处理标题,可使用 title、index(从 1 开始)和 gInt。"
case "ruleContent.nextContentUrl": return "提取当前章的下一分页地址,而非下一章。"
case "ruleContent.replaceRegex": return "在多页正文合并后执行的替换规则,使用 ## 分隔正则和替换内容。"
case "ruleContent.imageStyle": return "FULL 表示图片铺满;留空使用默认样式。"
default:
if isJavaScript { return "使用 JavaScript 编写,可通过更多菜单调试当前书源草稿。" }
if path.contains(".") { return "支持 CSS / JSoup、XPath、JSONPath、正则和 JavaScript;留空表示不配置此项。" }
return "留空表示不配置此项。"
}
}
public var isJavaScript: Bool {
path.lowercased().contains("js") || path == "jsLib" || path.hasSuffix("imageDecode") || path.hasSuffix("payAction")
}
}
public struct BookSourceEditorSection: Identifiable, Sendable {
public let id: String
public let title: String
public let fields: [BookSourceEditorField]
}
public enum BookSourceEditorSchema {
public static let sections: [BookSourceEditorSection] = [
.init(id: "basic", title: "基础", fields: [
.init(path: "bookSourceName", title: "书源名称"),
.init(path: "bookSourceUrl", title: "书源地址"),
.init(path: "bookSourceGroup", title: "分组"),
.init(path: "bookSourceComment", title: "备注"),
.init(path: "bookUrlPattern", title: "书籍 URL 正则"),
.init(path: "concurrentRate", title: "并发率"),
.init(path: "header", title: "请求头"),
.init(path: "jsLib", title: "JavaScript 库"),
.init(path: "variableComment", title: "变量说明"),
]),
.init(id: "login", title: "登录", fields: [
.init(path: "loginUrl", title: "登录地址 / 脚本"),
.init(path: "loginUi", title: "自定义登录界面"),
.init(path: "loginCheckJs", title: "登录检测脚本"),
.init(path: "coverDecodeJs", title: "封面解密脚本"),
]),
.init(id: "search", title: "搜索", fields: [
.init(path: "searchUrl", title: "搜索地址"),
.init(path: "ruleSearch.checkKeyWord", title: "测试关键词"),
.init(path: "ruleSearch.bookList", title: "书籍列表"),
.init(path: "ruleSearch.name", title: "书名"),
.init(path: "ruleSearch.author", title: "作者"),
.init(path: "ruleSearch.intro", title: "简介"),
.init(path: "ruleSearch.kind", title: "分类"),
.init(path: "ruleSearch.lastChapter", title: "最新章节"),
.init(path: "ruleSearch.updateTime", title: "更新时间"),
.init(path: "ruleSearch.bookUrl", title: "详情地址"),
.init(path: "ruleSearch.coverUrl", title: "封面地址"),
.init(path: "ruleSearch.wordCount", title: "字数"),
]),
.init(id: "explore", title: "发现", fields: [
.init(path: "exploreUrl", title: "发现分类地址"),
.init(path: "exploreScreen", title: "发现筛选配置"),
.init(path: "ruleExplore.bookList", title: "书籍列表"),
.init(path: "ruleExplore.name", title: "书名"),
.init(path: "ruleExplore.author", title: "作者"),
.init(path: "ruleExplore.intro", title: "简介"),
.init(path: "ruleExplore.kind", title: "分类"),
.init(path: "ruleExplore.lastChapter", title: "最新章节"),
.init(path: "ruleExplore.updateTime", title: "更新时间"),
.init(path: "ruleExplore.bookUrl", title: "详情地址"),
.init(path: "ruleExplore.coverUrl", title: "封面地址"),
.init(path: "ruleExplore.wordCount", title: "字数"),
]),
.init(id: "info", title: "详情", fields: [
.init(path: "ruleBookInfo.init", title: "预处理"),
.init(path: "ruleBookInfo.name", title: "书名"),
.init(path: "ruleBookInfo.author", title: "作者"),
.init(path: "ruleBookInfo.intro", title: "简介"),
.init(path: "ruleBookInfo.kind", title: "分类"),
.init(path: "ruleBookInfo.lastChapter", title: "最新章节"),
.init(path: "ruleBookInfo.updateTime", title: "更新时间"),
.init(path: "ruleBookInfo.coverUrl", title: "封面地址"),
.init(path: "ruleBookInfo.tocUrl", title: "目录地址"),
.init(path: "ruleBookInfo.wordCount", title: "字数"),
.init(path: "ruleBookInfo.canReName", title: "允许修改书名作者"),
.init(path: "ruleBookInfo.downloadUrls", title: "文件下载地址"),
]),
.init(id: "toc", title: "目录", fields: [
.init(path: "ruleToc.preUpdateJs", title: "更新前脚本"),
.init(path: "ruleToc.chapterList", title: "章节列表"),
.init(path: "ruleToc.chapterName", title: "章节名称"),
.init(path: "ruleToc.chapterUrl", title: "章节地址"),
.init(path: "ruleToc.formatJs", title: "标题处理脚本"),
.init(path: "ruleToc.isVolume", title: "卷名标识"),
.init(path: "ruleToc.isVip", title: "VIP 标识"),
.init(path: "ruleToc.isPay", title: "已购买标识"),
.init(path: "ruleToc.updateTime", title: "章节信息"),
.init(path: "ruleToc.nextTocUrl", title: "目录下一页"),
]),
.init(id: "content", title: "正文", fields: [
.init(path: "ruleContent.content", title: "正文"),
.init(path: "ruleContent.subContent", title: "副正文"),
.init(path: "ruleContent.title", title: "正文标题"),
.init(path: "ruleContent.nextContentUrl", title: "正文下一页"),
.init(path: "ruleContent.webJs", title: "WebView 注入脚本"),
.init(path: "ruleContent.sourceRegex", title: "资源正则"),
.init(path: "ruleContent.replaceRegex", title: "合并后替换"),
.init(path: "ruleContent.imageStyle", title: "图片样式"),
.init(path: "ruleContent.imageDecode", title: "图片解密脚本"),
.init(path: "ruleContent.payAction", title: "购买脚本"),
.init(path: "ruleContent.callBackJs", title: "事件回调脚本"),
]),
.init(id: "review", title: "段评", fields: [
.init(path: "ruleReview.reviewUrl", title: "段评地址"),
.init(path: "ruleReview.avatarRule", title: "头像"),
.init(path: "ruleReview.contentRule", title: "内容"),
.init(path: "ruleReview.postTimeRule", title: "发布时间"),
.init(path: "ruleReview.reviewQuoteUrl", title: "回复地址"),
.init(path: "ruleReview.voteUpUrl", title: "点赞地址"),
.init(path: "ruleReview.voteDownUrl", title: "点踩地址"),
.init(path: "ruleReview.postReviewUrl", title: "发布段评"),
.init(path: "ruleReview.postQuoteUrl", title: "发布回复"),
.init(path: "ruleReview.deleteUrl", title: "删除地址"),
]),
]
}
public struct BookSourceEditorDocument: Sendable, Equatable {
public private(set) var fields: [String: JSONValue]
public init(source: BookSource) throws {
self = try Self(json: String(decoding: JSONEncoder().encode(source), as: UTF8.self))
}
public init(json: String) throws {
guard case .object(let object) = try JSONValue.decode(data: Data(json.utf8)) else {
throw BookSourceImportError.invalidDocument("编辑器需要单个书源 JSON 对象")
}
fields = object
for key in Self.ruleKeys {
if case .string(let text) = fields[key], !text.isEmpty,
case .object(let object) = try JSONValue.decode(data: Data(text.utf8)) {
fields[key] = .object(object)
}
}
}
public func value(at path: String) -> JSONValue? {
let keys = path.split(separator: ".").map(String.init)
guard let key = keys.first else { return nil }
return keys.count == 1 ? fields[key] : fields[key]?[keys[1]]
}
public mutating func set(_ value: JSONValue?, at path: String) {
let keys = path.split(separator: ".").map(String.init)
guard let key = keys.first else { return }
if keys.count == 1 { fields[key] = value; return }
var object: [String: JSONValue] = [:]
if case .object(let existing) = fields[key] { object = existing }
object[keys[1]] = value
fields[key] = object.isEmpty ? nil : .object(object)
}
public func json() throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return String(decoding: try encoder.encode(fields), as: UTF8.self)
}
public func source() throws -> BookSource {
let source = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(fields))
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
guard errors.isEmpty else { throw BookSourceImportError.invalidSources(errors) }
return source
}
static let ruleKeys = ["ruleSearch", "ruleExplore", "ruleBookInfo", "ruleToc", "ruleContent", "ruleReview"]
}
struct SourceCodingKey: CodingKey {
let stringValue: String
let intValue: Int? = nil
init(_ value: String) { stringValue = value }
init?(stringValue: String) { self.init(stringValue) }
init?(intValue: Int) { return nil }
}
@@ -0,0 +1,143 @@
import Foundation
public struct BookListRule: Codable, Hashable, Sendable {
public var bookList: String?
public var name: String?
public var author: String?
public var intro: String?
public var kind: String?
public var lastChapter: String?
public var updateTime: String?
public var bookUrl: String?
public var coverUrl: String?
public var wordCount: String?
public init(
bookList: String? = nil,
name: String? = nil,
author: String? = nil,
intro: String? = nil,
kind: String? = nil,
lastChapter: String? = nil,
updateTime: String? = nil,
bookUrl: String? = nil,
coverUrl: String? = nil,
wordCount: String? = nil
) {
self.bookList = bookList
self.name = name
self.author = author
self.intro = intro
self.kind = kind
self.lastChapter = lastChapter
self.updateTime = updateTime
self.bookUrl = bookUrl
self.coverUrl = coverUrl
self.wordCount = wordCount
}
}
public struct SearchRule: Codable, Hashable, Sendable {
public var checkKeyWord: String?
public var bookList: String?
public var name: String?
public var author: String?
public var intro: String?
public var kind: String?
public var lastChapter: String?
public var updateTime: String?
public var bookUrl: String?
public var coverUrl: String?
public var wordCount: String?
public init(
checkKeyWord: String? = nil,
bookList: String? = nil,
name: String? = nil,
author: String? = nil,
intro: String? = nil,
kind: String? = nil,
lastChapter: String? = nil,
updateTime: String? = nil,
bookUrl: String? = nil,
coverUrl: String? = nil,
wordCount: String? = nil
) {
self.checkKeyWord = checkKeyWord
self.bookList = bookList
self.name = name
self.author = author
self.intro = intro
self.kind = kind
self.lastChapter = lastChapter
self.updateTime = updateTime
self.bookUrl = bookUrl
self.coverUrl = coverUrl
self.wordCount = wordCount
}
}
public typealias ExploreRule = BookListRule
public struct BookInfoRule: Codable, Hashable, Sendable {
public var `init`: String?
public var name: String?
public var author: String?
public var intro: String?
public var kind: String?
public var lastChapter: String?
public var updateTime: String?
public var coverUrl: String?
public var tocUrl: String?
public var wordCount: String?
public var canReName: String?
public var downloadUrls: String?
public init() {}
}
public struct TocRule: Codable, Hashable, Sendable {
public var preUpdateJs: String?
public var chapterList: String?
public var chapterName: String?
public var chapterUrl: String?
public var formatJs: String?
public var isVolume: String?
public var isVip: String?
public var isPay: String?
public var updateTime: String?
public var nextTocUrl: String?
public init() {}
}
public struct ContentRule: Codable, Hashable, Sendable {
public var content: String?
public var subContent: String?
public var title: String?
public var nextContentUrl: String?
public var webJs: String?
public var sourceRegex: String?
public var replaceRegex: String?
public var imageStyle: String?
public var imageDecode: String?
public var payAction: String?
public var callBackJs: String?
public init() {}
}
public struct ReviewRule: Codable, Hashable, Sendable {
public var reviewUrl: String?
public var avatarRule: String?
public var contentRule: String?
public var postTimeRule: String?
public var reviewQuoteUrl: String?
public var voteUpUrl: String?
public var voteDownUrl: String?
public var postReviewUrl: String?
public var postQuoteUrl: String?
public var deleteUrl: String?
public init() {}
}
@@ -0,0 +1,82 @@
import Foundation
public struct ExploreKind: Codable, Hashable, Identifiable, Sendable {
public var title: String
public var url: String?
public var type: String
public var action: String?
public var chars: [String?]?
public var defaultValue: String?
public var id: String { "\(title)\u{0}\(url ?? "")" }
public var navigableURL: String? {
guard type.caseInsensitiveCompare("url") == .orderedSame,
let value = url?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
enum CodingKeys: String, CodingKey {
case title, url, type, action, chars
case defaultValue = "default"
}
public init(
title: String,
url: String? = nil,
type: String = "url",
action: String? = nil,
chars: [String?]? = nil,
defaultValue: String? = nil
) {
self.title = title
self.url = url
self.type = type
self.action = action
self.chars = chars
self.defaultValue = defaultValue
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
title = values.lossyString(forKey: .title) ?? ""
url = values.lossyString(forKey: .url)
type = values.lossyString(forKey: .type) ?? "url"
action = values.lossyString(forKey: .action)
defaultValue = values.lossyString(forKey: .defaultValue)
if let decoded = try? values.decodeIfPresent([JSONValue].self, forKey: .chars) {
chars = decoded.map { value in
if case .null = value { return nil }
return value.stringValue
}
} else if let scalar = values.lossyString(forKey: .chars) {
chars = [scalar]
} else {
chars = nil
}
}
}
private extension KeyedDecodingContainer where Key == ExploreKind.CodingKeys {
func lossyString(forKey key: Key) -> String? {
guard contains(key), (try? decodeNil(forKey: key)) != true else { return nil }
if let value = try? decode(String.self, forKey: key) { return value }
if let value = try? decode(Bool.self, forKey: key) { return String(value) }
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
if let value = try? decode(Double.self, forKey: key) {
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
}
return nil
}
}
public struct ExploreKindsReport: Sendable {
public var kinds: [ExploreKind]
public var diagnostics: [RuleDiagnostic]
public init(kinds: [ExploreKind] = [], diagnostics: [RuleDiagnostic] = []) {
self.kinds = kinds
self.diagnostics = diagnostics
}
}
@@ -0,0 +1,474 @@
import Foundation
public struct Book: Codable, Hashable, Identifiable, Sendable {
public var bookUrl: String
public var tocUrl: String
public var origin: String
public var originName: String
public var name: String
public var author: String
public var kind: String?
public var customTag: String?
public var coverUrl: String?
public var customCoverUrl: String?
public var intro: String?
public var customIntro: String?
public var charset: String?
public var type: Int
public var group: Int64
public var latestChapterTitle: String?
public var latestChapterTime: Int64
public var lastCheckTime: Int64
public var lastCheckCount: Int
public var totalChapterNum: Int
public var durChapterTitle: String?
public var durChapterIndex: Int
public var durVolumeIndex: Int
public var chapterInVolumeIndex: Int
public var durChapterPos: Int
public var durChapterTime: Int64
public var wordCount: String?
public var canUpdate: Bool
public var order: Int
public var originOrder: Int
public var variable: String?
public var readConfig: BookReadConfig?
public var syncTime: Int64
public var id: String { bookUrl }
public init(bookUrl: String, name: String, author: String = "", origin: String = "local") {
self.bookUrl = bookUrl
tocUrl = ""
self.origin = origin
originName = ""
self.name = name
self.author = author
kind = nil
customTag = nil
coverUrl = nil
customCoverUrl = nil
intro = nil
customIntro = nil
charset = nil
type = 0
group = 0
latestChapterTitle = nil
latestChapterTime = 0
lastCheckTime = 0
lastCheckCount = 0
totalChapterNum = 0
durChapterTitle = nil
durChapterIndex = 0
durVolumeIndex = 0
chapterInVolumeIndex = 0
durChapterPos = 0
durChapterTime = 0
wordCount = nil
canUpdate = true
order = 0
originOrder = 0
variable = nil
readConfig = nil
syncTime = 0
}
private enum CodingKeys: String, CodingKey {
case bookUrl, tocUrl, origin, originName, name, author, kind, customTag, coverUrl, customCoverUrl
case intro, customIntro, charset, type, group, latestChapterTitle, latestChapterTime, lastCheckTime
case lastCheckCount, totalChapterNum, durChapterTitle, durChapterIndex, durVolumeIndex
case chapterInVolumeIndex, durChapterPos, durChapterTime, wordCount, canUpdate, order, originOrder
case variable, readConfig, syncTime
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
tocUrl = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? "local"
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
kind = try value.decodeIfPresent(String.self, forKey: .kind)
customTag = try value.decodeIfPresent(String.self, forKey: .customTag)
coverUrl = try value.decodeIfPresent(String.self, forKey: .coverUrl)
customCoverUrl = try value.decodeIfPresent(String.self, forKey: .customCoverUrl)
intro = try value.decodeIfPresent(String.self, forKey: .intro)
customIntro = try value.decodeIfPresent(String.self, forKey: .customIntro)
charset = try value.decodeIfPresent(String.self, forKey: .charset)
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
group = try value.decodeIfPresent(Int64.self, forKey: .group) ?? 0
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
latestChapterTime = try value.decodeIfPresent(Int64.self, forKey: .latestChapterTime) ?? 0
lastCheckTime = try value.decodeIfPresent(Int64.self, forKey: .lastCheckTime) ?? 0
lastCheckCount = try value.decodeIfPresent(Int.self, forKey: .lastCheckCount) ?? 0
totalChapterNum = try value.decodeIfPresent(Int.self, forKey: .totalChapterNum) ?? 0
durChapterTitle = try value.decodeIfPresent(String.self, forKey: .durChapterTitle)
durChapterIndex = try value.decodeIfPresent(Int.self, forKey: .durChapterIndex) ?? 0
durVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .durVolumeIndex) ?? 0
chapterInVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .chapterInVolumeIndex) ?? 0
durChapterPos = try value.decodeIfPresent(Int.self, forKey: .durChapterPos) ?? 0
durChapterTime = try value.decodeIfPresent(Int64.self, forKey: .durChapterTime) ?? 0
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
canUpdate = try value.decodeIfPresent(Bool.self, forKey: .canUpdate) ?? true
order = try value.decodeIfPresent(Int.self, forKey: .order) ?? 0
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
variable = try value.decodeIfPresent(String.self, forKey: .variable)
readConfig = try value.decodeIfPresent(BookReadConfig.self, forKey: .readConfig)
syncTime = try value.decodeIfPresent(Int64.self, forKey: .syncTime) ?? 0
}
}
public struct BookReadConfig: Codable, Hashable, Sendable {
public var reverseToc = false
public var pageAnim: Int?
public var reSegment = false
public var imageStyle: String?
public var useReplaceRule: Bool?
public var delTag: Int64 = 0
public var ttsEngine: String?
public var splitLongChapter = true
public var readSimulating = false
public var startDate: String?
public var startChapter: Int?
public var dailyChapters = 3
public var openCredits = 0
public var closeCredits = 0
public var playMode = 0
public var playSpeed: Float = 1
public init() {}
private enum CodingKeys: String, CodingKey {
case reverseToc, pageAnim, reSegment, imageStyle, useReplaceRule, delTag, ttsEngine
case splitLongChapter, readSimulating, startDate, startChapter, dailyChapters
case openCredits, closeCredits, playMode, playSpeed
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
reverseToc = try value.decodeIfPresent(Bool.self, forKey: .reverseToc) ?? false
pageAnim = try value.decodeIfPresent(Int.self, forKey: .pageAnim)
reSegment = try value.decodeIfPresent(Bool.self, forKey: .reSegment) ?? false
imageStyle = try value.decodeIfPresent(String.self, forKey: .imageStyle)
useReplaceRule = try value.decodeIfPresent(Bool.self, forKey: .useReplaceRule)
delTag = try value.decodeIfPresent(Int64.self, forKey: .delTag) ?? 0
ttsEngine = try value.decodeIfPresent(String.self, forKey: .ttsEngine)
splitLongChapter = try value.decodeIfPresent(Bool.self, forKey: .splitLongChapter) ?? true
readSimulating = try value.decodeIfPresent(Bool.self, forKey: .readSimulating) ?? false
startDate = try value.decodeIfPresent(String.self, forKey: .startDate)
startChapter = try value.decodeIfPresent(Int.self, forKey: .startChapter)
dailyChapters = try value.decodeIfPresent(Int.self, forKey: .dailyChapters) ?? 3
openCredits = try value.decodeIfPresent(Int.self, forKey: .openCredits) ?? 0
closeCredits = try value.decodeIfPresent(Int.self, forKey: .closeCredits) ?? 0
playMode = try value.decodeIfPresent(Int.self, forKey: .playMode) ?? 0
playSpeed = try value.decodeIfPresent(Float.self, forKey: .playSpeed) ?? 1
}
}
public struct BookGroup: Codable, Hashable, Identifiable, Sendable {
public static let idRoot: Int64 = -100
public static let idAll: Int64 = -1
public static let idLocal: Int64 = -2
public static let idAudio: Int64 = -3
public static let idNetNone: Int64 = -4
public static let idLocalNone: Int64 = -5
public static let idVideo: Int64 = -6
public static let idError: Int64 = -11
public var groupId: Int64
public var groupName: String
public var cover: String?
public var order: Int
public var enableRefresh: Bool
public var show: Bool
public var bookSort: Int
public var onlyUpdateRead: Bool
public var id: Int64 { groupId }
public init(
groupId: Int64,
groupName: String,
cover: String? = nil,
order: Int = 0,
enableRefresh: Bool = true,
show: Bool = true,
bookSort: Int = -1,
onlyUpdateRead: Bool = false
) {
self.groupId = groupId
self.groupName = groupName
self.cover = cover
self.order = order
self.enableRefresh = enableRefresh
self.show = show
self.bookSort = bookSort
self.onlyUpdateRead = onlyUpdateRead
}
}
public struct BookChapter: Codable, Hashable, Identifiable, Sendable {
public var url: String
public var title: String
public var isVolume: Bool
public var baseUrl: String
public var bookUrl: String
public var index: Int
public var isVip: Bool
public var isPay: Bool
public var resourceUrl: String?
public var tag: String?
public var wordCount: String?
public var start: Int64?
public var end: Int64?
public var startFragmentId: String?
public var endFragmentId: String?
public var variable: String?
public var imageURL: String?
public var id: String { "\(bookUrl)\u{0}\(url)" }
public init(url: String, title: String, bookUrl: String, index: Int) {
self.url = url
self.title = title
isVolume = false
baseUrl = ""
self.bookUrl = bookUrl
self.index = index
isVip = false
isPay = false
}
private enum CodingKeys: String, CodingKey {
case url, title, isVolume, baseUrl, bookUrl, index, isVip, isPay, resourceUrl, tag, wordCount
case start, end, startFragmentId, endFragmentId, variable, imgUrl, imageURL
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
url = try value.decodeIfPresent(String.self, forKey: .url) ?? ""
title = try value.decodeIfPresent(String.self, forKey: .title) ?? ""
isVolume = try value.decodeIfPresent(Bool.self, forKey: .isVolume) ?? false
baseUrl = try value.decodeIfPresent(String.self, forKey: .baseUrl) ?? ""
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
index = try value.decodeIfPresent(Int.self, forKey: .index) ?? 0
isVip = try value.decodeIfPresent(Bool.self, forKey: .isVip) ?? false
isPay = try value.decodeIfPresent(Bool.self, forKey: .isPay) ?? false
resourceUrl = try value.decodeIfPresent(String.self, forKey: .resourceUrl)
tag = try value.decodeIfPresent(String.self, forKey: .tag)
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
start = try value.decodeIfPresent(Int64.self, forKey: .start)
end = try value.decodeIfPresent(Int64.self, forKey: .end)
startFragmentId = try value.decodeIfPresent(String.self, forKey: .startFragmentId)
endFragmentId = try value.decodeIfPresent(String.self, forKey: .endFragmentId)
variable = try value.decodeIfPresent(String.self, forKey: .variable)
imageURL = try value.decodeIfPresent(String.self, forKey: .imgUrl)
?? value.decodeIfPresent(String.self, forKey: .imageURL)
}
public func encode(to encoder: Encoder) throws {
var value = encoder.container(keyedBy: CodingKeys.self)
try value.encode(url, forKey: .url)
try value.encode(title, forKey: .title)
try value.encode(isVolume, forKey: .isVolume)
try value.encode(baseUrl, forKey: .baseUrl)
try value.encode(bookUrl, forKey: .bookUrl)
try value.encode(index, forKey: .index)
try value.encode(isVip, forKey: .isVip)
try value.encode(isPay, forKey: .isPay)
try value.encodeIfPresent(resourceUrl, forKey: .resourceUrl)
try value.encodeIfPresent(tag, forKey: .tag)
try value.encodeIfPresent(wordCount, forKey: .wordCount)
try value.encodeIfPresent(start, forKey: .start)
try value.encodeIfPresent(end, forKey: .end)
try value.encodeIfPresent(startFragmentId, forKey: .startFragmentId)
try value.encodeIfPresent(endFragmentId, forKey: .endFragmentId)
try value.encodeIfPresent(variable, forKey: .variable)
try value.encodeIfPresent(imageURL, forKey: .imgUrl)
}
}
public struct Bookmark: Codable, Hashable, Identifiable, Sendable {
public var time: Int64
public var bookName: String
public var bookAuthor: String
public var chapterIndex: Int
public var chapterPosition: Int
public var chapterName: String
public var bookText: String
public var content: String
public var id: Int64 { time }
public init(time: Int64, bookName: String, bookAuthor: String = "") {
self.time = time
self.bookName = bookName
self.bookAuthor = bookAuthor
chapterIndex = 0
chapterPosition = 0
chapterName = ""
bookText = ""
content = ""
}
private enum CodingKeys: String, CodingKey {
case time, bookName, bookAuthor, chapterIndex, chapterPos, chapterPosition
case chapterName, bookText, content
}
public func resolvedChapterIndex(in chapters: [BookChapter]) -> Int? {
if !chapterName.isEmpty,
let index = chapters.firstIndex(where: { !$0.isVolume && $0.title == chapterName }) {
return index
}
return chapters.firstIndex(where: { !$0.isVolume && $0.index == chapterIndex })
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
time = try values.decodeIfPresent(Int64.self, forKey: .time) ?? Int64(Date().timeIntervalSince1970 * 1_000)
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
bookAuthor = try values.decodeIfPresent(String.self, forKey: .bookAuthor) ?? ""
chapterIndex = try values.decodeIfPresent(Int.self, forKey: .chapterIndex) ?? 0
chapterPosition = try values.decodeIfPresent(Int.self, forKey: .chapterPos)
?? values.decodeIfPresent(Int.self, forKey: .chapterPosition)
?? 0
chapterName = try values.decodeIfPresent(String.self, forKey: .chapterName) ?? ""
bookText = try values.decodeIfPresent(String.self, forKey: .bookText) ?? ""
content = try values.decodeIfPresent(String.self, forKey: .content) ?? ""
}
public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(time, forKey: .time)
try values.encode(bookName, forKey: .bookName)
try values.encode(bookAuthor, forKey: .bookAuthor)
try values.encode(chapterIndex, forKey: .chapterIndex)
try values.encode(chapterPosition, forKey: .chapterPos)
try values.encode(chapterName, forKey: .chapterName)
try values.encode(bookText, forKey: .bookText)
try values.encode(content, forKey: .content)
}
}
public struct ReadRecord: Codable, Hashable, Identifiable, Sendable {
public var deviceId: String
public var bookName: String
public var readTime: Int64
public var lastRead: Int64
public var dailyReadTimes: [String: Int64]
public var id: String { "\(deviceId)\u{0}\(bookName)" }
public init(
deviceId: String,
bookName: String,
readTime: Int64 = 0,
lastRead: Int64? = nil,
dailyReadTimes: [String: Int64] = [:]
) {
self.deviceId = deviceId
self.bookName = bookName
self.readTime = readTime
self.lastRead = lastRead ?? Int64(Date().timeIntervalSince1970 * 1_000)
self.dailyReadTimes = dailyReadTimes
}
private enum CodingKeys: String, CodingKey {
case deviceId, bookName, readTime, lastRead, dailyReadTimes
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
deviceId = try values.decodeIfPresent(String.self, forKey: .deviceId) ?? ""
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
readTime = try values.decodeIfPresent(Int64.self, forKey: .readTime) ?? 0
lastRead = try values.decodeIfPresent(Int64.self, forKey: .lastRead) ?? 0
dailyReadTimes = try values.decodeIfPresent([String: Int64].self, forKey: .dailyReadTimes) ?? [:]
}
}
public struct ReadRecordSummary: Hashable, Identifiable, Sendable {
public var bookName: String
public var readTime: Int64
public var lastRead: Int64
public var id: String { bookName }
public init(bookName: String, readTime: Int64, lastRead: Int64) {
self.bookName = bookName
self.readTime = readTime
self.lastRead = lastRead
}
}
public struct ReplaceRule: Codable, Hashable, Identifiable, Sendable {
public var id: Int64
public var name: String
public var group: String?
public var pattern: String
public var replacement: String
public var scope: String?
public var scopeTitle: Bool
public var scopeContent: Bool
public var excludeScope: String?
public var isEnabled: Bool
public var isRegex: Bool
public var timeoutMillisecond: Int64
public var sortOrder: Int
public init(id: Int64, name: String, pattern: String, replacement: String = "") {
self.id = id
self.name = name
self.pattern = pattern
self.replacement = replacement
scopeTitle = false
scopeContent = true
isEnabled = true
isRegex = true
timeoutMillisecond = 3_000
sortOrder = Int.min
}
private enum CodingKeys: String, CodingKey {
case id, name, group, pattern, replacement, scope, scopeTitle, scopeContent
case excludeScope, isEnabled, isRegex, timeoutMillisecond, sortOrder, order
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
group = try values.decodeIfPresent(String.self, forKey: .group)
pattern = try values.decodeIfPresent(String.self, forKey: .pattern) ?? ""
replacement = try values.decodeIfPresent(String.self, forKey: .replacement) ?? ""
scope = try values.decodeIfPresent(String.self, forKey: .scope)
scopeTitle = try values.decodeIfPresent(Bool.self, forKey: .scopeTitle) ?? false
scopeContent = try values.decodeIfPresent(Bool.self, forKey: .scopeContent) ?? true
excludeScope = try values.decodeIfPresent(String.self, forKey: .excludeScope)
isEnabled = try values.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
isRegex = try values.decodeIfPresent(Bool.self, forKey: .isRegex) ?? true
timeoutMillisecond = try values.decodeIfPresent(Int64.self, forKey: .timeoutMillisecond) ?? 3_000
sortOrder = try values.decodeIfPresent(Int.self, forKey: .sortOrder)
?? values.decodeIfPresent(Int.self, forKey: .order)
?? Int.min
}
public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try values.encode(name, forKey: .name)
try values.encodeIfPresent(group, forKey: .group)
try values.encode(pattern, forKey: .pattern)
try values.encode(replacement, forKey: .replacement)
try values.encodeIfPresent(scope, forKey: .scope)
try values.encode(scopeTitle, forKey: .scopeTitle)
try values.encode(scopeContent, forKey: .scopeContent)
try values.encodeIfPresent(excludeScope, forKey: .excludeScope)
try values.encode(isEnabled, forKey: .isEnabled)
try values.encode(isRegex, forKey: .isRegex)
try values.encode(timeoutMillisecond, forKey: .timeoutMillisecond)
try values.encode(sortOrder, forKey: .order)
}
}
+279
View File
@@ -0,0 +1,279 @@
import Foundation
public struct RssSource: Codable, Hashable, Identifiable, Sendable {
public var sourceUrl = ""
public var sourceName = ""
public var sourceIcon = ""
public var sourceGroup: String?
public var sourceComment: String?
public var enabled = true
public var variableComment: String?
public var jsLib: String?
public var enabledCookieJar: Bool? = true
public var concurrentRate: String?
public var header: String?
public var loginUrl: String?
public var loginUi: String?
public var loginCheckJs: String?
public var coverDecodeJs: String?
public var sortUrl: String?
public var singleUrl = false
public var articleStyle = 0
public var ruleArticles: String?
public var ruleNextPage: String?
public var ruleTitle: String?
public var rulePubDate: String?
public var ruleDescription: String?
public var ruleImage: String?
public var ruleLink: String?
public var ruleContent: String?
public var contentWhitelist: String?
public var contentBlacklist: String?
public var shouldOverrideUrlLoading: String?
public var style: String?
public var enableJs = true
public var loadWithBaseUrl = true
public var injectJs: String?
public var preloadJs: String?
public var startHtml: String?
public var startStyle: String?
public var startJs: String?
public var showWebLog = false
public var lastUpdateTime: Int64 = 0
public var customOrder = 0
public var type = 0
public var preload = false
public var cacheFirst = false
public var searchUrl: String?
public var id: String { sourceUrl }
public init(sourceUrl: String = "", sourceName: String = "") {
self.sourceUrl = sourceUrl
self.sourceName = sourceName
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: RSSCodingKey.self)
sourceUrl = values.string("sourceUrl") ?? ""
sourceName = values.string("sourceName") ?? ""
sourceIcon = values.string("sourceIcon") ?? ""
sourceGroup = values.string("sourceGroup")
sourceComment = values.string("sourceComment")
enabled = values.bool("enabled") ?? true
variableComment = values.string("variableComment")
jsLib = values.string("jsLib")
enabledCookieJar = values.contains("enabledCookieJar") ? values.bool("enabledCookieJar") : true
concurrentRate = values.string("concurrentRate")
header = values.string("header")
loginUrl = values.string("loginUrl")
loginUi = values.jsonString("loginUi")
loginCheckJs = values.string("loginCheckJs")
coverDecodeJs = values.string("coverDecodeJs")
sortUrl = values.string("sortUrl")
singleUrl = values.bool("singleUrl") ?? false
articleStyle = values.int("articleStyle") ?? 0
ruleArticles = values.string("ruleArticles")
ruleNextPage = values.string("ruleNextPage")
ruleTitle = values.string("ruleTitle")
rulePubDate = values.string("rulePubDate")
ruleDescription = values.string("ruleDescription")
ruleImage = values.string("ruleImage")
ruleLink = values.string("ruleLink")
ruleContent = values.string("ruleContent")
contentWhitelist = values.string("contentWhitelist")
contentBlacklist = values.string("contentBlacklist")
shouldOverrideUrlLoading = values.string("shouldOverrideUrlLoading")
style = values.string("style")
enableJs = values.bool("enableJs") ?? true
loadWithBaseUrl = values.bool("loadWithBaseUrl") ?? true
injectJs = values.string("injectJs")
preloadJs = values.string("preloadJs")
startHtml = values.string("startHtml")
startStyle = values.string("startStyle")
startJs = values.string("startJs")
showWebLog = values.bool("showWebLog") ?? false
lastUpdateTime = values.int64("lastUpdateTime") ?? 0
customOrder = values.int("customOrder") ?? 0
type = values.int("type") ?? 0
preload = values.bool("preload") ?? false
cacheFirst = values.bool("cacheFirst") ?? false
searchUrl = values.string("searchUrl")
}
public var groups: [String] { BookSource.parseGroups(sourceGroup) }
public var displayNameGroup: String {
groups.isEmpty ? sourceName : "\(sourceName) (\(sourceGroup ?? ""))"
}
public static func == (lhs: Self, rhs: Self) -> Bool { lhs.sourceUrl == rhs.sourceUrl }
public func hash(into hasher: inout Hasher) { hasher.combine(sourceUrl) }
public mutating func addGroups(_ value: String) {
var merged = Set(groups)
merged.formUnion(BookSource.parseGroups(value))
sourceGroup = merged.joined(separator: ",")
}
public mutating func removeGroups(_ value: String) {
let removed = Set(BookSource.parseGroups(value))
sourceGroup = groups.filter { !removed.contains($0) }.joined(separator: ",")
}
public func displayVariableComment(_ other: String) -> String {
guard let variableComment, !variableComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return other }
return "\(variableComment)\n\(other)"
}
}
public struct RssArticle: Codable, Hashable, Identifiable, Sendable {
public var origin = ""
public var sort = ""
public var title = ""
public var order: Int64 = 0
public var link = ""
public var pubDate: String?
public var description: String?
public var content: String?
public var image: String?
public var group = "默认分组"
public var read = false
public var variable: String?
public var type = 0
public var durPos = 0
public var id: String { "\(origin)\u{0}\(link)\u{0}\(sort)" }
public init(origin: String = "", sort: String = "", title: String = "", link: String = "") {
self.origin = origin; self.sort = sort; self.title = title; self.link = link
}
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: RSSCodingKey.self)
origin = values.string("origin") ?? ""; sort = values.string("sort") ?? ""
title = values.string("title") ?? ""; order = values.int64("order") ?? 0
link = values.string("link") ?? ""; pubDate = values.string("pubDate")
description = values.string("description"); content = values.string("content")
image = values.string("image"); group = values.string("group") ?? "默认分组"
read = values.bool("read") ?? false; variable = values.string("variable")
type = values.int("type") ?? 0; durPos = values.int("durPos") ?? 0
}
public func star(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssStar {
.init(article: self, starTime: time)
}
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
}
public var variableMap: [String: String] { Self.decodeVariables(variable) }
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.origin == rhs.origin && lhs.link == rhs.link && lhs.sort == rhs.sort
}
public func hash(into hasher: inout Hasher) { hasher.combine(link) }
fileprivate static func decodeVariables(_ value: String?) -> [String: String] {
guard let value, let data = value.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
return object.reduce(into: [:]) { result, pair in
if let string = pair.value as? String { result[pair.key] = string }
else if !(pair.value is NSNull) { result[pair.key] = String(describing: pair.value) }
}
}
}
public struct RssReadRecord: Codable, Hashable, Identifiable, Sendable {
public var record: String
public var title: String?
public var readTime: Int64?
public var read: Bool
public var origin: String
public var sort: String
public var image: String?
public var type: Int
public var durPos: Int
public var pubDate: String?
public var id: String { record }
public init(record: String, title: String? = nil, readTime: Int64? = nil, read: Bool = true, origin: String = "", sort: String = "", image: String? = nil, type: Int = 0, durPos: Int = 0, pubDate: String? = nil) {
self.record = record; self.title = title; self.readTime = readTime; self.read = read
self.origin = origin; self.sort = sort; self.image = image; self.type = type
self.durPos = durPos; self.pubDate = pubDate
}
public var article: RssArticle {
var value = RssArticle(origin: origin, sort: sort, title: title ?? "", link: record)
value.image = image; value.type = type; value.durPos = durPos; value.pubDate = pubDate
return value
}
}
public struct RssStar: Codable, Hashable, Identifiable, Sendable {
public var origin = ""
public var sort = ""
public var title = ""
public var starTime: Int64 = 0
public var link = ""
public var pubDate: String?
public var description: String?
public var content: String?
public var image: String?
public var group = "默认分组"
public var variable: String?
public var type = 0
public var durPos = 0
public var id: String { "\(origin)\u{0}\(link)" }
public init(article: RssArticle = .init(), starTime: Int64 = 0) {
origin = article.origin; sort = article.sort; title = article.title; self.starTime = starTime
link = article.link; pubDate = article.pubDate; description = article.description
content = article.content; image = article.image; group = article.group
variable = article.variable; type = article.type; durPos = article.durPos
}
public init(from decoder: Decoder) throws {
let article = try RssArticle(from: decoder)
self.init(article: article, starTime: try decoder.container(keyedBy: RSSCodingKey.self).int64("starTime") ?? 0)
}
public var article: RssArticle {
var value = RssArticle(origin: origin, sort: sort, title: title, link: link)
value.pubDate = pubDate; value.description = description; value.content = content
value.image = image; value.group = group; value.variable = variable; value.type = type; value.durPos = durPos
return value
}
public var variableMap: [String: String] { RssArticle.decodeVariables(variable) }
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
}
}
private struct RSSCodingKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}
private extension KeyedDecodingContainer where Key == RSSCodingKey {
func key(_ value: String) -> Key { Key(stringValue: value)! }
func contains(_ value: String) -> Bool { contains(key(value)) }
func string(_ name: String) -> String? {
let key = key(name)
if let value = try? decode(String.self, forKey: key) { return value }
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
return nil
}
func bool(_ name: String) -> Bool? {
let key = key(name)
if let value = try? decode(Bool.self, forKey: key) { return value }
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
if let value = string(name)?.lowercased() { return ["true", "1", "yes"].contains(value) ? true : (["false", "0", "no"].contains(value) ? false : nil) }
return nil
}
func int(_ name: String) -> Int? {
let key = key(name)
if let value = try? decode(Int.self, forKey: key) { return value }
return string(name).flatMap(Int.init)
}
func int64(_ name: String) -> Int64? {
let key = key(name)
if let value = try? decode(Int64.self, forKey: key) { return value }
return string(name).flatMap(Int64.init)
}
func jsonString(_ name: String) -> String? {
if let value = string(name) { return value }
let key = key(name)
guard let value = try? decode(JSONValue.self, forKey: key) else { return nil }
return value.stringValue
}
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
public struct SearchBook: Codable, Hashable, Identifiable, Sendable {
public var bookURL: String
public var origin: String
public var originName: String
public var type: Int
public var name: String
public var author: String
public var kind: String?
public var coverURL: String?
public var intro: String?
public var wordCount: String?
public var latestChapterTitle: String?
public var tocURL: String
public var time: Int64
public var originOrder: Int
public var chapterWordCountText: String?
public var chapterWordCount: Int
public var respondTime: Int
public var origins: Set<String>
public var variables: [String: String]
public var id: String { bookURL }
public var variable: String? {
get {
guard !variables.isEmpty, let data = try? JSONEncoder().encode(variables) else { return nil }
return String(decoding: data, as: UTF8.self)
}
set {
guard let newValue, let data = newValue.data(using: .utf8) else { variables = [:]; return }
variables = (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
}
}
public init(
bookURL: String,
origin: String,
originName: String,
type: Int,
name: String,
author: String = ""
) {
self.bookURL = bookURL
self.origin = origin
self.originName = originName
self.type = type
self.name = name
self.author = author
tocURL = ""
time = Int64(Date().timeIntervalSince1970 * 1_000)
originOrder = 0
chapterWordCountText = nil
chapterWordCount = -1
respondTime = -1
origins = [origin]
variables = [:]
}
private enum CodingKeys: String, CodingKey {
case bookUrl, origin, originName, type, name, author, kind, coverUrl, intro, wordCount
case latestChapterTitle, tocUrl, time, variable, originOrder, chapterWordCountText
case chapterWordCount, respondTime
}
public init(from decoder: Decoder) throws {
let value = try decoder.container(keyedBy: CodingKeys.self)
bookURL = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? ""
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
kind = try value.decodeIfPresent(String.self, forKey: .kind)
coverURL = try value.decodeIfPresent(String.self, forKey: .coverUrl)
intro = try value.decodeIfPresent(String.self, forKey: .intro)
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
tocURL = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
time = try value.decodeIfPresent(Int64.self, forKey: .time) ?? 0
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
chapterWordCountText = try value.decodeIfPresent(String.self, forKey: .chapterWordCountText)
chapterWordCount = try value.decodeIfPresent(Int.self, forKey: .chapterWordCount) ?? -1
respondTime = try value.decodeIfPresent(Int.self, forKey: .respondTime) ?? -1
origins = [origin]
let rawVariable = try value.decodeIfPresent(String.self, forKey: .variable)
variables = rawVariable.flatMap { raw in
try? JSONDecoder().decode([String: String].self, from: Data(raw.utf8))
} ?? [:]
}
public func encode(to encoder: Encoder) throws {
var value = encoder.container(keyedBy: CodingKeys.self)
try value.encode(bookURL, forKey: .bookUrl)
try value.encode(origin, forKey: .origin)
try value.encode(originName, forKey: .originName)
try value.encode(type, forKey: .type)
try value.encode(name, forKey: .name)
try value.encode(author, forKey: .author)
try value.encodeIfPresent(kind, forKey: .kind)
try value.encodeIfPresent(coverURL, forKey: .coverUrl)
try value.encodeIfPresent(intro, forKey: .intro)
try value.encodeIfPresent(wordCount, forKey: .wordCount)
try value.encodeIfPresent(latestChapterTitle, forKey: .latestChapterTitle)
try value.encode(tocURL, forKey: .tocUrl)
try value.encode(time, forKey: .time)
try value.encodeIfPresent(variable, forKey: .variable)
try value.encode(originOrder, forKey: .originOrder)
try value.encodeIfPresent(chapterWordCountText, forKey: .chapterWordCountText)
try value.encode(chapterWordCount, forKey: .chapterWordCount)
try value.encode(respondTime, forKey: .respondTime)
}
}