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,118 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public struct ApplicationSettings: Codable, Equatable, Sendable {
|
||||
public var userAgent = ""
|
||||
public var proxy = ""
|
||||
public var hosts = ""
|
||||
public var timeoutSeconds = 0
|
||||
public var concurrentSources = 32
|
||||
public var chapterCacheMB = 256
|
||||
public var prefetchChapters = 1
|
||||
public var recordLog = false
|
||||
public var ignoreCertificateErrors = true
|
||||
|
||||
public init() {}
|
||||
|
||||
public var effectiveUserAgent: String {
|
||||
userAgent.isEmpty ? LegadoURLBuilder.defaultUserAgent : userAgent
|
||||
}
|
||||
|
||||
public func validated() throws -> Self {
|
||||
var value = self
|
||||
value.userAgent = userAgent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value.proxy = proxy.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.userAgent.contains(where: { $0.isNewline || $0 == "\0" }) else {
|
||||
throw SettingsError.invalid("User-Agent 不能包含换行。")
|
||||
}
|
||||
if !value.proxy.isEmpty { _ = try Self.proxyDictionary(value.proxy) }
|
||||
_ = try Self.parseHosts(hosts)
|
||||
guard timeoutSeconds == 0 || (5...300).contains(timeoutSeconds),
|
||||
(1...64).contains(concurrentSources), (16...4096).contains(chapterCacheMB),
|
||||
(0...20).contains(prefetchChapters) else {
|
||||
throw SettingsError.invalid("网络或缓存设置超出允许范围。")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
public func configure(_ configuration: URLSessionConfiguration, proxyOverride: String? = nil) throws {
|
||||
configuration.httpMaximumConnectionsPerHost = concurrentSources
|
||||
let address = proxyOverride ?? proxy
|
||||
configuration.connectionProxyDictionary = address.isEmpty ? nil : try Self.proxyDictionary(address)
|
||||
if timeoutSeconds > 0 {
|
||||
configuration.timeoutIntervalForRequest = Double(timeoutSeconds)
|
||||
configuration.timeoutIntervalForResource = Double(timeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
public func configure(_ request: URLRequest) -> URLRequest {
|
||||
var request = request
|
||||
if timeoutSeconds > 0 { request.timeoutInterval = Double(timeoutSeconds) }
|
||||
if request.value(forHTTPHeaderField: "User-Agent") == nil,
|
||||
URLProtocol.property(forKey: "LegadoSuppressUserAgent", in: request) as? Bool != true {
|
||||
request.setValue(effectiveUserAgent, forHTTPHeaderField: "User-Agent")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
public static func proxyDictionary(_ address: String) throws -> [AnyHashable: Any] {
|
||||
guard let url = URL(string: address), let host = url.host, !host.isEmpty,
|
||||
let scheme = url.scheme?.lowercased(), ["http", "https", "socks", "socks4", "socks5"].contains(scheme),
|
||||
url.user == nil, url.password == nil, url.query == nil, url.fragment == nil,
|
||||
url.path.isEmpty || url.path == "/", (1...65535).contains(url.port ?? 8080) else {
|
||||
throw SettingsError.invalid("代理格式应为 http://主机:端口、https://主机:端口或 socks5://主机:端口,不含账号和密码。")
|
||||
}
|
||||
let port = url.port ?? (scheme.hasPrefix("socks") ? 1080 : scheme == "https" ? 443 : 8080)
|
||||
if scheme.hasPrefix("socks") {
|
||||
return ["SOCKSEnable": 1, "SOCKSProxy": host, "SOCKSPort": port]
|
||||
}
|
||||
return ["HTTPEnable": 1, "HTTPProxy": host, "HTTPPort": port,
|
||||
"HTTPSEnable": 1, "HTTPSProxy": host, "HTTPSPort": port]
|
||||
}
|
||||
|
||||
public static func parseHosts(_ text: String) throws -> [String: String] {
|
||||
var values: [String: String] = [:]
|
||||
for (index, line) in text.components(separatedBy: .newlines).enumerated() {
|
||||
let fields = line.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
||||
.split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
||||
guard !fields.isEmpty else { continue }
|
||||
guard fields.count >= 2, IPv4Address(fields[0]) != nil || IPv6Address(fields[0]) != nil else {
|
||||
throw SettingsError.invalid("Hosts 第 \(index + 1) 行应为 IP地址 域名,可添加 # 注释。")
|
||||
}
|
||||
for domain in fields.dropFirst() {
|
||||
let domain = domain.lowercased()
|
||||
guard domain.utf8.count <= 253, domain.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ label in
|
||||
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-"
|
||||
&& label.utf8.allSatisfy { (97...122).contains($0) || (48...57).contains($0) || $0 == 45 }
|
||||
}) else { throw SettingsError.invalid("Hosts 第 \(index + 1) 行的域名无效。") }
|
||||
values[domain] = fields[0]
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
public enum SettingsError: Error, LocalizedError {
|
||||
case invalid(String)
|
||||
public var errorDescription: String? { if case .invalid(let message) = self { message } else { nil } }
|
||||
}
|
||||
|
||||
public final class ApplicationSettingsStore: @unchecked Sendable {
|
||||
public static let shared = ApplicationSettingsStore()
|
||||
public static let key = "legado.applicationSettings.v1"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = .standard) { self.defaults = defaults }
|
||||
|
||||
public var settings: ApplicationSettings {
|
||||
guard let data = defaults.data(forKey: Self.key),
|
||||
let value = try? JSONDecoder().decode(ApplicationSettings.self, from: data),
|
||||
let validated = try? value.validated() else { return .init() }
|
||||
return validated
|
||||
}
|
||||
|
||||
public func save(_ settings: ApplicationSettings) throws {
|
||||
let validated = try settings.validated()
|
||||
defaults.set(try JSONEncoder().encode(validated), forKey: Self.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSourceHeaderResolver: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func resolve(_ source: BookSource, includeLoginHeader: Bool = true) throws -> [String: String] {
|
||||
var headers: [String: String] = [:]
|
||||
if let raw = source.header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
|
||||
let json: String
|
||||
if raw.lowercased().hasPrefix("@js:") {
|
||||
json = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: String(raw.dropFirst(4)),
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
).value.stringValue
|
||||
} else if raw.lowercased().hasPrefix("<js>"), raw.lowercased().hasSuffix("</js>") {
|
||||
json = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: String(raw.dropFirst(4).dropLast(5)),
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
).value.stringValue
|
||||
} else {
|
||||
json = raw
|
||||
}
|
||||
if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(json) {
|
||||
headers = object.mapValues(\.stringValue)
|
||||
}
|
||||
}
|
||||
if includeLoginHeader {
|
||||
headers.merge(BookSourceRuntimeStore.shared.loginHeaders(sourceID: source.id)) { _, login in login }
|
||||
}
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public struct HTTPResponse: Sendable {
|
||||
public var data: Data
|
||||
public var statusCode: Int
|
||||
public var headers: [String: String]
|
||||
public var finalURL: URL
|
||||
public var requestedCharset: String?
|
||||
public var requestVariables: [String: String]
|
||||
|
||||
public init(data: Data, statusCode: Int, headers: [String: String], finalURL: URL, requestedCharset: String? = nil, requestVariables: [String: String] = [:]) {
|
||||
self.data = data
|
||||
self.statusCode = statusCode
|
||||
self.headers = headers
|
||||
self.finalURL = finalURL
|
||||
self.requestedCharset = requestedCharset
|
||||
self.requestVariables = requestVariables
|
||||
}
|
||||
|
||||
public var decodedText: String {
|
||||
let encoding = requestedCharset.flatMap(HTTPTextDecoder.encoding(named:))
|
||||
?? contentTypeCharset.flatMap(HTTPTextDecoder.encoding(named:))
|
||||
?? HTTPTextDecoder.bomEncoding(data)
|
||||
?? HTTPTextDecoder.htmlEncoding(data)
|
||||
?? .utf8
|
||||
if let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: encoding) { return value }
|
||||
if encoding != .utf8, let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: .utf8) { return value }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
public var decodedJSON: JSONValue? {
|
||||
try? JSONValue.decode(data: Data(decodedText.utf8))
|
||||
}
|
||||
|
||||
private var contentTypeCharset: String? {
|
||||
guard let value = headers.first(where: { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame })?.value,
|
||||
let expression = try? NSRegularExpression(pattern: #"charset\s*=\s*[\"']?([^;\"'\s]+)"#, options: .caseInsensitive),
|
||||
let match = expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
|
||||
let range = Range(match.range(at: 1), in: value) else { return nil }
|
||||
return String(value[range])
|
||||
}
|
||||
}
|
||||
|
||||
public enum HTTPTextDecoder {
|
||||
public static func htmlEncoding(_ data: Data) -> String.Encoding? {
|
||||
let head = String(decoding: data.prefix(16_384), as: UTF8.self)
|
||||
guard let meta = try? NSRegularExpression(pattern: #"<meta\b[^>]*>"#, options: .caseInsensitive),
|
||||
let charset = try? NSRegularExpression(pattern: #"charset\s*=\s*["']?\s*([a-zA-Z0-9_-]+)"#, options: .caseInsensitive) else { return nil }
|
||||
for tag in meta.matches(in: head, range: NSRange(head.startIndex..., in: head)) {
|
||||
guard let match = charset.firstMatch(in: head, range: tag.range),
|
||||
let range = Range(match.range(at: 1), in: head),
|
||||
let encoding = encoding(named: String(head[range])) else { continue }
|
||||
return encoding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func encoding(named rawValue: String) -> String.Encoding? {
|
||||
let value = rawValue.lowercased().replacingOccurrences(of: "_", with: "-").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch value {
|
||||
case "utf-8", "utf8": return .utf8
|
||||
case "utf-16", "utf16": return .utf16
|
||||
case "utf-16le", "utf16le": return .utf16LittleEndian
|
||||
case "utf-16be", "utf16be": return .utf16BigEndian
|
||||
case "utf-32", "utf32": return .utf32
|
||||
case "utf-32le", "utf32le": return .utf32LittleEndian
|
||||
case "utf-32be", "utf32be": return .utf32BigEndian
|
||||
case "gbk", "gb2312", "gb-2312", "gb18030", "gb-18030":
|
||||
return foundationEncoding(.GB_18030_2000)
|
||||
case "big5", "big-5": return foundationEncoding(.big5)
|
||||
case "shift-jis", "shiftjis", "sjis": return .shiftJIS
|
||||
case "iso-8859-1", "latin1": return .isoLatin1
|
||||
case "windows-1252", "cp1252": return .windowsCP1252
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func foundationEncoding(_ value: CFStringEncodings) -> String.Encoding {
|
||||
.init(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(value.rawValue)))
|
||||
}
|
||||
|
||||
public static func bomEncoding(_ data: Data) -> String.Encoding? {
|
||||
let prefix = Array(data.prefix(4))
|
||||
if prefix.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 }
|
||||
if prefix.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian }
|
||||
if prefix.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian }
|
||||
if prefix.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian }
|
||||
if prefix.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func removingBOM(_ data: Data) -> Data {
|
||||
let bytes = Array(data.prefix(4))
|
||||
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return data.dropFirst(3) }
|
||||
if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) || bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return data.dropFirst(4) }
|
||||
if bytes.starts(with: [0xFF, 0xFE]) || bytes.starts(with: [0xFE, 0xFF]) { return data.dropFirst(2) }
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
public protocol HTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse
|
||||
}
|
||||
|
||||
public final class LegadoUnsafeTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
public static let shared = LegadoUnsafeTrustDelegate()
|
||||
private let settingsStore: ApplicationSettingsStore
|
||||
private let interceptHostsRedirects: Bool
|
||||
|
||||
public init(settingsStore: ApplicationSettingsStore = .shared, interceptHostsRedirects: Bool = true) {
|
||||
self.settingsStore = settingsStore
|
||||
self.interceptHostsRedirects = interceptHostsRedirects
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge
|
||||
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
||||
Self.resolution(for: challenge, ignoreCertificateErrors: settingsStore.settings.ignoreCertificateErrors)
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping @Sendable (URLRequest?) -> Void
|
||||
) {
|
||||
let settings = settingsStore.settings
|
||||
let address = try? ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""]
|
||||
completionHandler(interceptHostsRedirects && settings.proxy.isEmpty && address != nil ? nil : request)
|
||||
}
|
||||
|
||||
public static func resolve(
|
||||
_ challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let result = resolution(for: challenge)
|
||||
completionHandler(result.0, result.1)
|
||||
}
|
||||
|
||||
static func resolution(
|
||||
for challenge: URLAuthenticationChallenge,
|
||||
ignoreCertificateErrors: Bool = ApplicationSettingsStore.shared.settings.ignoreCertificateErrors
|
||||
) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
||||
let disposition: URLSession.AuthChallengeDisposition
|
||||
let credential: URLCredential?
|
||||
if ignoreCertificateErrors,
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust {
|
||||
disposition = .useCredential
|
||||
credential = URLCredential(trust: trust)
|
||||
} else {
|
||||
disposition = .performDefaultHandling
|
||||
credential = nil
|
||||
}
|
||||
return (disposition, credential)
|
||||
}
|
||||
}
|
||||
|
||||
public struct HTTPStreamResponse: Sendable {
|
||||
public var statusCode: Int
|
||||
public var headers: [String: String]
|
||||
public var finalURL: URL
|
||||
public var expectedContentLength: Int64?
|
||||
public var chunks: AsyncThrowingStream<Data, Error>
|
||||
|
||||
public init(
|
||||
statusCode: Int,
|
||||
headers: [String: String],
|
||||
finalURL: URL,
|
||||
expectedContentLength: Int64? = nil,
|
||||
chunks: AsyncThrowingStream<Data, Error>
|
||||
) {
|
||||
self.statusCode = statusCode
|
||||
self.headers = headers
|
||||
self.finalURL = finalURL
|
||||
self.expectedContentLength = expectedContentLength
|
||||
self.chunks = chunks
|
||||
}
|
||||
}
|
||||
|
||||
public protocol StreamingHTTPTransport: HTTPTransport {
|
||||
func stream(for request: URLRequest) async throws -> HTTPStreamResponse
|
||||
}
|
||||
|
||||
public protocol ProxyHTTPTransport: HTTPTransport {
|
||||
func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse
|
||||
}
|
||||
|
||||
public enum HTTPClientError: Error, LocalizedError, Sendable {
|
||||
case nonHTTPResponse
|
||||
case statusCode(Int)
|
||||
case statusResponse(HTTPResponse)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .nonHTTPResponse: "服务器没有返回 HTTP 响应"
|
||||
case .statusCode(let code): "服务器返回 HTTP \(code)"
|
||||
case .statusResponse(let response): "服务器返回 HTTP \(response.statusCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class URLSessionHTTPTransport: ProxyHTTPTransport, StreamingHTTPTransport, @unchecked Sendable {
|
||||
public static let shared = URLSessionHTTPTransport()
|
||||
private let configuration: URLSessionConfiguration
|
||||
private let settingsStore: ApplicationSettingsStore
|
||||
private let lock = NSLock()
|
||||
private var cachedSettings: ApplicationSettings?
|
||||
private var cachedSession: URLSession?
|
||||
|
||||
public init(configuration: URLSessionConfiguration = .ephemeral, settingsStore: ApplicationSettingsStore = .shared) {
|
||||
self.configuration = configuration.copy() as! URLSessionConfiguration
|
||||
self.settingsStore = settingsStore
|
||||
self.configuration.httpCookieAcceptPolicy = .always
|
||||
self.configuration.httpShouldSetCookies = true
|
||||
self.configuration.httpCookieStorage = HTTPCookieStorage.shared
|
||||
self.configuration.requestCachePolicy = .reloadRevalidatingCacheData
|
||||
}
|
||||
|
||||
deinit { cachedSession?.finishTasksAndInvalidate() }
|
||||
|
||||
private func configuredSession(settings: ApplicationSettings) throws -> URLSession {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if settings == cachedSettings, let cachedSession { return cachedSession }
|
||||
let config = configuration.copy() as! URLSessionConfiguration
|
||||
try settings.configure(config)
|
||||
let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore), delegateQueue: nil)
|
||||
cachedSession?.finishTasksAndInvalidate()
|
||||
cachedSession = session
|
||||
cachedSettings = settings
|
||||
return session
|
||||
}
|
||||
|
||||
public func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
try await load(request, redirectsRemaining: 10)
|
||||
}
|
||||
|
||||
private func load(_ original: URLRequest, redirectsRemaining: Int) async throws -> HTTPResponse {
|
||||
try Task.checkCancellation()
|
||||
let settings = settingsStore.settings
|
||||
let request = settings.configure(original)
|
||||
let started = Date()
|
||||
do {
|
||||
let response: HTTPResponse
|
||||
if settings.proxy.isEmpty,
|
||||
let address = try ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] {
|
||||
response = try await HostsHTTPTransport.data(for: request, address: address, ignoreCertificateErrors: settings.ignoreCertificateErrors)
|
||||
} else {
|
||||
let session = try configuredSession(settings: settings)
|
||||
response = try await rawData(for: request, session: session)
|
||||
}
|
||||
if [301, 302, 303, 307, 308].contains(response.statusCode),
|
||||
let location = response.headers.first(where: { $0.key.lowercased() == "location" })?.value,
|
||||
let target = URL(string: location, relativeTo: response.finalURL)?.absoluteURL {
|
||||
guard redirectsRemaining > 0 else { throw URLError(.httpTooManyRedirects) }
|
||||
var next = request
|
||||
next.url = target
|
||||
if response.statusCode == 303 || ([301, 302].contains(response.statusCode) && next.httpMethod == "POST") {
|
||||
next.httpMethod = "GET"; next.httpBody = nil
|
||||
next.setValue(nil, forHTTPHeaderField: "Content-Length")
|
||||
next.setValue(nil, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
if target.host?.lowercased() != request.url?.host?.lowercased()
|
||||
|| target.scheme?.lowercased() != request.url?.scheme?.lowercased()
|
||||
|| target.port != request.url?.port {
|
||||
next.setValue(nil, forHTTPHeaderField: "Authorization")
|
||||
next.setValue(nil, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
next.setValue(nil, forHTTPHeaderField: "Host")
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return try await load(next, redirectsRemaining: redirectsRemaining - 1)
|
||||
}
|
||||
guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) }
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return response
|
||||
} catch {
|
||||
DiagnosticLog.shared.network(request, error: error, startedAt: started)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func download(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
let value = try await data(for: request)
|
||||
guard let response = HTTPURLResponse(url: value.finalURL, statusCode: value.statusCode, httpVersion: nil, headerFields: value.headers) else {
|
||||
throw HTTPClientError.nonHTTPResponse
|
||||
}
|
||||
return (value.data, response)
|
||||
}
|
||||
|
||||
public func stream(for request: URLRequest) async throws -> HTTPStreamResponse {
|
||||
let settings = settingsStore.settings
|
||||
let request = settings.configure(request)
|
||||
if !settings.hosts.isEmpty, settings.proxy.isEmpty {
|
||||
let response = try await data(for: request)
|
||||
return .init(statusCode: response.statusCode, headers: response.headers, finalURL: response.finalURL,
|
||||
expectedContentLength: Int64(response.data.count), chunks: AsyncThrowingStream { continuation in
|
||||
continuation.yield(response.data)
|
||||
continuation.finish()
|
||||
})
|
||||
}
|
||||
let session = try configuredSession(settings: settings)
|
||||
let started = Date()
|
||||
let bytes: URLSession.AsyncBytes
|
||||
let response: URLResponse
|
||||
do {
|
||||
(bytes, response) = try await session.bytes(for: request)
|
||||
} catch {
|
||||
DiagnosticLog.shared.network(request, error: error, startedAt: started)
|
||||
throw error
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse, let finalURL = http.url else {
|
||||
throw HTTPClientError.nonHTTPResponse
|
||||
}
|
||||
let headers = Self.headers(from: http)
|
||||
DiagnosticLog.shared.network(request, status: http.statusCode, startedAt: started)
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
var body = Data()
|
||||
for try await byte in bytes { body.append(byte) }
|
||||
throw HTTPClientError.statusResponse(.init(
|
||||
data: body,
|
||||
statusCode: http.statusCode,
|
||||
headers: headers,
|
||||
finalURL: finalURL
|
||||
))
|
||||
}
|
||||
let chunks = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
var chunk = Data()
|
||||
chunk.reserveCapacity(32 * 1_024)
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
chunk.append(byte)
|
||||
if chunk.count >= 32 * 1_024 {
|
||||
continuation.yield(chunk)
|
||||
chunk.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
if !chunk.isEmpty { continuation.yield(chunk) }
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
return .init(
|
||||
statusCode: http.statusCode,
|
||||
headers: headers,
|
||||
finalURL: finalURL,
|
||||
expectedContentLength: http.expectedContentLength >= 0 ? http.expectedContentLength : nil,
|
||||
chunks: chunks
|
||||
)
|
||||
}
|
||||
|
||||
public func data(for original: URLRequest, proxy: String) async throws -> HTTPResponse {
|
||||
let settings = settingsStore.settings
|
||||
let config = configuration.copy() as! URLSessionConfiguration
|
||||
try settings.configure(config, proxyOverride: proxy)
|
||||
let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore, interceptHostsRedirects: false), delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
let request = settings.configure(original)
|
||||
let started = Date()
|
||||
do {
|
||||
let response = try await rawData(for: request, session: session)
|
||||
guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) }
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return response
|
||||
} catch {
|
||||
DiagnosticLog.shared.network(request, error: error, startedAt: started)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func rawData(for request: URLRequest, session: URLSession) async throws -> HTTPResponse {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, let url = http.url else { throw HTTPClientError.nonHTTPResponse }
|
||||
return .init(data: data, statusCode: http.statusCode, headers: Self.headers(from: http), finalURL: url)
|
||||
}
|
||||
|
||||
private static func headers(from response: HTTPURLResponse) -> [String: String] {
|
||||
response.allHeaderFields.reduce(into: [:]) { result, entry in
|
||||
result[String(describing: entry.key)] = String(describing: entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class LegadoCookieStore: @unchecked Sendable {
|
||||
public static let shared = LegadoCookieStore()
|
||||
|
||||
private let storage: HTTPCookieStorage
|
||||
private let lock = NSLock()
|
||||
private let persistent = PersistentRuntimeCache.shared
|
||||
|
||||
public init(storage: HTTPCookieStorage = .shared) {
|
||||
self.storage = storage
|
||||
}
|
||||
|
||||
public func cookieHeader(for value: String) -> String {
|
||||
guard let url = normalizedURL(value) else { return "" }
|
||||
lock.lock()
|
||||
let cookies = storage.cookies(for: url) ?? []
|
||||
lock.unlock()
|
||||
let session = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? ""
|
||||
let saved = persistent.cookie(for: persistenceKey(url)) ?? ""
|
||||
return mergeCookieHeaders(saved, session)
|
||||
}
|
||||
|
||||
public func cookie(for value: String, key: String) -> String {
|
||||
guard let url = normalizedURL(value) else { return "" }
|
||||
lock.lock()
|
||||
let result = storage.cookies(for: url)?.first(where: { $0.name == key })?.value
|
||||
lock.unlock()
|
||||
if let result { return result }
|
||||
return cookieMap(persistent.cookie(for: persistenceKey(url)) ?? "")[key] ?? ""
|
||||
}
|
||||
|
||||
public func setCookie(_ cookie: String, for value: String) {
|
||||
guard let url = normalizedURL(value), !cookie.isEmpty else { return }
|
||||
var cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookie], for: url)
|
||||
if cookies.isEmpty,
|
||||
let host = url.host,
|
||||
let first = cookie.split(separator: ";").first {
|
||||
let pair = first.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
if pair.count == 2, let fallback = HTTPCookie(properties: [
|
||||
.name: pair[0].trimmingCharacters(in: .whitespaces),
|
||||
.value: pair[1],
|
||||
.domain: host,
|
||||
.path: "/",
|
||||
.secure: url.scheme == "https" ? "TRUE" : "FALSE"
|
||||
]) {
|
||||
cookies = [fallback]
|
||||
}
|
||||
}
|
||||
lock.lock()
|
||||
cookies.forEach(storage.setCookie)
|
||||
lock.unlock()
|
||||
let old = persistent.cookie(for: persistenceKey(url)) ?? ""
|
||||
persistent.putCookie(
|
||||
key: persistenceKey(url),
|
||||
value: mergeCookieHeaders(old, persistentCookieHeader(cookie))
|
||||
)
|
||||
}
|
||||
|
||||
public func removeCookies(for value: String) {
|
||||
guard let url = normalizedURL(value) else { return }
|
||||
lock.lock()
|
||||
storage.cookies(for: url)?.forEach(storage.deleteCookie)
|
||||
lock.unlock()
|
||||
persistent.deleteCookie(key: persistenceKey(url))
|
||||
}
|
||||
|
||||
private func normalizedURL(_ value: String) -> URL? {
|
||||
if let url = URL(string: value), url.scheme != nil { return url }
|
||||
return URL(string: "https://\(value)")
|
||||
}
|
||||
|
||||
private func persistenceKey(_ url: URL) -> String {
|
||||
guard let host = url.host?.lowercased() else { return url.absoluteString }
|
||||
return Self.persistenceDomain(for: host)
|
||||
}
|
||||
|
||||
static func persistenceDomain(for host: String) -> String {
|
||||
let host = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
guard !host.isEmpty, !isIPAddress(host) else { return host }
|
||||
let labels = host.split(separator: ".").map(String.init)
|
||||
guard labels.count > 2 else { return host }
|
||||
let lastTwo = labels.suffix(2).joined(separator: ".")
|
||||
let publicSecondLevels: Set<String> = [
|
||||
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn",
|
||||
"com.hk", "net.hk", "org.hk", "com.tw", "net.tw", "org.tw",
|
||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk",
|
||||
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
|
||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
||||
"co.nz", "net.nz", "org.nz", "co.kr", "or.kr", "ne.kr",
|
||||
"co.in", "firm.in", "net.in", "org.in", "gen.in", "ind.in",
|
||||
"com.sg", "net.sg", "org.sg", "com.br", "net.br", "org.br"
|
||||
]
|
||||
return publicSecondLevels.contains(lastTwo)
|
||||
? labels.suffix(3).joined(separator: ".")
|
||||
: lastTwo
|
||||
}
|
||||
|
||||
private static func isIPAddress(_ host: String) -> Bool {
|
||||
if host.contains(":") { return host.allSatisfy { $0.isHexDigit || $0 == ":" || $0 == "." } }
|
||||
let parts = host.split(separator: ".")
|
||||
return parts.count == 4 && parts.allSatisfy { part in
|
||||
guard let value = Int(part) else { return false }
|
||||
return (0...255).contains(value)
|
||||
}
|
||||
}
|
||||
|
||||
private func cookieMap(_ header: String) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
result[pair[0].trimmingCharacters(in: .whitespaces)] = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func mergeCookieHeaders(_ headers: String...) -> String {
|
||||
var values: [String: String] = [:]
|
||||
var order: [String] = []
|
||||
for header in headers {
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
let name = pair[0].trimmingCharacters(in: .whitespaces)
|
||||
if values[name] == nil { order.append(name) }
|
||||
values[name] = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ")
|
||||
}
|
||||
|
||||
private func persistentCookieHeader(_ header: String) -> String {
|
||||
let attributes = Set(["path", "domain", "expires", "max-age", "samesite"])
|
||||
return header.split(separator: ";").compactMap { part -> String? in
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { return nil }
|
||||
let name = pair[0].trimmingCharacters(in: .whitespaces)
|
||||
guard !attributes.contains(name.lowercased()) else { return nil }
|
||||
return "\(name)=\(pair[1].trimmingCharacters(in: .whitespaces))"
|
||||
}.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SourceWebViewRequestExecuting: Sendable {
|
||||
@MainActor
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse
|
||||
}
|
||||
|
||||
public final class SourceWebViewExecutorRegistry: @unchecked Sendable {
|
||||
public static let shared = SourceWebViewExecutorRegistry()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var storedExecutor: (any SourceWebViewRequestExecuting)?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func register(_ executor: any SourceWebViewRequestExecuting) {
|
||||
lock.lock()
|
||||
storedExecutor = executor
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
public func executor() -> (any SourceWebViewRequestExecuting)? {
|
||||
lock.lock()
|
||||
let value = storedExecutor
|
||||
lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
public struct SourceRateLimit: Sendable, Equatable {
|
||||
public var accessLimit: Int
|
||||
public var intervalMilliseconds: Int64
|
||||
|
||||
public init?(rawValue: String?) {
|
||||
guard let rawValue else { return nil }
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty, value != "0" else { return nil }
|
||||
if let slash = value.firstIndex(of: "/"),
|
||||
let count = Int(value[..<slash]),
|
||||
let interval = Int64(value[value.index(after: slash)...]),
|
||||
count > 0, interval > 0 {
|
||||
accessLimit = count
|
||||
intervalMilliseconds = interval
|
||||
} else if let interval = Int64(value), interval > 0 {
|
||||
accessLimit = 1
|
||||
intervalMilliseconds = interval
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SourceRateWindow: Sendable, Equatable {
|
||||
public var startedAt: Int64
|
||||
public var frequency: Int
|
||||
|
||||
public init(startedAt: Int64 = 0, frequency: Int = 0) {
|
||||
self.startedAt = startedAt
|
||||
self.frequency = frequency
|
||||
}
|
||||
|
||||
public mutating func reserve(now: Int64, limit: SourceRateLimit) -> Int64 {
|
||||
if frequency == 0 || now >= startedAt + limit.intervalMilliseconds {
|
||||
startedAt = now
|
||||
frequency = 1
|
||||
return 0
|
||||
}
|
||||
if frequency < limit.accessLimit {
|
||||
frequency += 1
|
||||
return 0
|
||||
}
|
||||
return max(startedAt + limit.intervalMilliseconds - now, 0)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SourceNetworkPolicy {
|
||||
public static let shared = SourceNetworkPolicy()
|
||||
|
||||
private struct StoredCookie: Sendable {
|
||||
var name: String
|
||||
var value: String
|
||||
var domain: String
|
||||
var path: String
|
||||
var expiresAt: Date?
|
||||
var secure: Bool
|
||||
}
|
||||
|
||||
private var windows: [String: SourceRateWindow] = [:]
|
||||
private var cookies: [String: [StoredCookie]] = [:]
|
||||
private let javaScript: JavaScriptRuleEvaluator
|
||||
|
||||
public init(javaScript: JavaScriptRuleEvaluator = .init()) { self.javaScript = javaScript }
|
||||
|
||||
public func execute(
|
||||
_ prepared: PreparedRequest,
|
||||
source: BookSource,
|
||||
transport: any HTTPTransport,
|
||||
timeout: TimeInterval,
|
||||
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
|
||||
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil,
|
||||
snapshotStage: DifferentialStage = .debug
|
||||
) async throws -> HTTPResponse {
|
||||
try await acquire(source: source)
|
||||
if prepared.url.scheme?.lowercased() == "data" {
|
||||
do {
|
||||
let response = try dataURIResponse(for: prepared)
|
||||
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
|
||||
await snapshotRecorder?.record(
|
||||
stage: snapshotStage,
|
||||
sourceID: source.id,
|
||||
prepared: prepared,
|
||||
rawResponse: response,
|
||||
response: transformed,
|
||||
timeout: timeout
|
||||
)
|
||||
return transformed
|
||||
} catch {
|
||||
await snapshotRecorder?.recordFailure(
|
||||
stage: snapshotStage,
|
||||
sourceID: source.id,
|
||||
prepared: prepared,
|
||||
response: nil,
|
||||
error: error,
|
||||
timeout: timeout
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if prepared.useWebView || prepared.webJavaScript != nil {
|
||||
guard let executor = webViewExecutor ?? SourceWebViewExecutorRegistry.shared.executor() else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "后台 WKWebView 请求")
|
||||
}
|
||||
return try await executeWebView(
|
||||
prepared,
|
||||
source: source,
|
||||
executor: executor,
|
||||
timeout: timeout,
|
||||
snapshotRecorder: snapshotRecorder,
|
||||
snapshotStage: snapshotStage
|
||||
)
|
||||
}
|
||||
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
|
||||
if source.enabledCookieJar == true {
|
||||
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
|
||||
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
let merged = mergeCookieHeaders(stored, explicit)
|
||||
if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") }
|
||||
}
|
||||
|
||||
let retryCount = max(prepared.retry, 0)
|
||||
let attempts = retryCount == Int.max ? Int.max : retryCount + 1
|
||||
var lastError: Error = HTTPClientError.nonHTTPResponse
|
||||
for attempt in 0..<attempts {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
var response: HTTPResponse
|
||||
if let proxy = prepared.proxy {
|
||||
guard let proxyTransport = transport as? any ProxyHTTPTransport else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "当前传输层不支持代理")
|
||||
}
|
||||
response = try await proxyTransport.data(for: request, proxy: proxy)
|
||||
} else {
|
||||
response = try await transport.data(for: request)
|
||||
}
|
||||
response.requestedCharset = prepared.charset
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw HTTPClientError.statusResponse(response)
|
||||
}
|
||||
if source.enabledCookieJar == true { captureCookies(response: response, sourceID: source.id) }
|
||||
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
|
||||
await snapshotRecorder?.record(stage: snapshotStage, sourceID: source.id, prepared: prepared, rawResponse: response, response: transformed, timeout: timeout)
|
||||
return transformed
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
lastError = error
|
||||
if case HTTPClientError.statusResponse(let response) = error,
|
||||
source.enabledCookieJar == true {
|
||||
captureCookies(response: response, sourceID: source.id)
|
||||
}
|
||||
guard case HTTPClientError.statusResponse = error, attempt + 1 < attempts else { break }
|
||||
}
|
||||
}
|
||||
let failedResponse: HTTPResponse? = if case HTTPClientError.statusResponse(let response) = lastError { response } else { nil }
|
||||
await snapshotRecorder?.recordFailure(stage: snapshotStage, sourceID: source.id, prepared: prepared, response: failedResponse, error: lastError, timeout: timeout)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
public func executeStream(
|
||||
_ prepared: PreparedRequest,
|
||||
source: BookSource,
|
||||
transport: any StreamingHTTPTransport,
|
||||
timeout: TimeInterval
|
||||
) async throws -> HTTPStreamResponse {
|
||||
guard prepared.url.scheme?.lowercased() != "data",
|
||||
!prepared.useWebView,
|
||||
prepared.webJavaScript == nil,
|
||||
prepared.bodyJavaScript == nil,
|
||||
prepared.responseType == nil else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式响应转换")
|
||||
}
|
||||
guard prepared.proxy == nil else {
|
||||
throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式代理请求")
|
||||
}
|
||||
try await acquire(source: source)
|
||||
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
|
||||
if source.enabledCookieJar == true {
|
||||
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
|
||||
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
let merged = mergeCookieHeaders(stored, explicit)
|
||||
if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") }
|
||||
}
|
||||
let response = try await transport.stream(for: request)
|
||||
if source.enabledCookieJar == true {
|
||||
captureCookies(
|
||||
response: .init(
|
||||
data: Data(),
|
||||
statusCode: response.statusCode,
|
||||
headers: response.headers,
|
||||
finalURL: response.finalURL
|
||||
),
|
||||
sourceID: source.id
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private func executeWebView(
|
||||
_ prepared: PreparedRequest,
|
||||
source: BookSource,
|
||||
executor: any SourceWebViewRequestExecuting,
|
||||
timeout: TimeInterval,
|
||||
snapshotRecorder: (any DifferentialSnapshotRecording)?,
|
||||
snapshotStage: DifferentialStage
|
||||
) async throws -> HTTPResponse {
|
||||
let started = Date()
|
||||
var webPrepared = prepared
|
||||
var request = prepared.urlRequest(timeout: timeout, handleCookies: false)
|
||||
if source.enabledCookieJar == true {
|
||||
let stored = cookieHeader(sourceID: source.id, url: prepared.url)
|
||||
let explicit = request.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
let merged = mergeCookieHeaders(stored, explicit)
|
||||
if !merged.isEmpty {
|
||||
request.setValue(merged, forHTTPHeaderField: "Cookie")
|
||||
webPrepared.headers["Cookie"] = merged
|
||||
}
|
||||
}
|
||||
let retryCount = max(prepared.retry, 0)
|
||||
let attempts = retryCount == Int.max ? Int.max : retryCount + 1
|
||||
var lastError: Error = HTTPClientError.nonHTTPResponse
|
||||
for attempt in 0..<attempts {
|
||||
do {
|
||||
var response = try await executor.execute(webPrepared, source: source, timeout: timeout)
|
||||
response.requestedCharset = prepared.charset
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw HTTPClientError.statusResponse(response)
|
||||
}
|
||||
if source.enabledCookieJar == true { captureCookies(response: response, sourceID: source.id) }
|
||||
let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source)
|
||||
await snapshotRecorder?.record(stage: snapshotStage, sourceID: source.id, prepared: prepared, rawResponse: response, response: transformed, timeout: timeout)
|
||||
DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started)
|
||||
return transformed
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
lastError = error
|
||||
if case HTTPClientError.statusResponse(let response) = error,
|
||||
source.enabledCookieJar == true {
|
||||
captureCookies(response: response, sourceID: source.id)
|
||||
}
|
||||
guard case HTTPClientError.statusResponse = error, attempt + 1 < attempts else { break }
|
||||
}
|
||||
}
|
||||
let failedResponse: HTTPResponse? = if case HTTPClientError.statusResponse(let response) = lastError { response } else { nil }
|
||||
await snapshotRecorder?.recordFailure(stage: snapshotStage, sourceID: source.id, prepared: prepared, response: failedResponse, error: lastError, timeout: timeout)
|
||||
DiagnosticLog.shared.network(request, error: lastError, startedAt: started)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private nonisolated func transformBodyIfNeeded(
|
||||
_ original: HTTPResponse,
|
||||
prepared: PreparedRequest,
|
||||
source: BookSource
|
||||
) async throws -> HTTPResponse {
|
||||
var response = original
|
||||
response.requestVariables = prepared.variables
|
||||
if prepared.responseType != nil {
|
||||
response.data = Data(original.data.map { String(format: "%02x", $0) }.joined().utf8)
|
||||
response.requestedCharset = "utf-8"
|
||||
return response
|
||||
}
|
||||
guard let script = prepared.bodyJavaScript else { return response }
|
||||
let transformed = try await javaScript.evaluateAsync(
|
||||
script: script,
|
||||
result: .string(response.decodedText),
|
||||
executionContext: .init(
|
||||
variables: response.requestVariables,
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
key: prepared.key,
|
||||
page: prepared.page,
|
||||
source: source,
|
||||
book: prepared.book,
|
||||
chapter: prepared.chapter
|
||||
)
|
||||
)
|
||||
response.data = Data(transformed.value.stringValue.utf8)
|
||||
response.requestedCharset = "utf-8"
|
||||
response.requestVariables = transformed.variables
|
||||
return response
|
||||
}
|
||||
|
||||
public func cookieHeader(sourceID: String, url: URL) -> String {
|
||||
let now = Date()
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let values = (cookies[sourceID] ?? []).filter { cookie in
|
||||
(cookie.expiresAt == nil || cookie.expiresAt! > now)
|
||||
&& domainMatches(host: host, domain: cookie.domain)
|
||||
&& path.hasPrefix(cookie.path)
|
||||
&& (!cookie.secure || url.scheme?.lowercased() == "https")
|
||||
}
|
||||
return values.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||
}
|
||||
|
||||
public func setCookieHeader(_ header: String, sourceID: String, url: URL) {
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
upsert(.init(name: pair[0].trimmingCharacters(in: .whitespaces), value: pair[1], domain: url.host ?? "", path: "/", expiresAt: nil, secure: url.scheme == "https"), sourceID: sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
public func removeCookies(sourceID: String) { cookies[sourceID] = nil }
|
||||
|
||||
private func acquire(source: BookSource) async throws {
|
||||
guard let limit = SourceRateLimit(rawValue: source.concurrentRate) else { return }
|
||||
while true {
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
var window = windows[source.id] ?? .init()
|
||||
let wait = window.reserve(now: now, limit: limit)
|
||||
windows[source.id] = window
|
||||
guard wait > 0 else { return }
|
||||
try await Task.sleep(nanoseconds: UInt64(wait) * 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func captureCookies(response: HTTPResponse, sourceID: String) {
|
||||
guard let header = response.headers.first(where: { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame })?.value else { return }
|
||||
LegadoCookieStore.shared.setCookie(header, for: response.finalURL.absoluteString)
|
||||
for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: response.finalURL) {
|
||||
upsert(.init(name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expiresAt: cookie.expiresDate, secure: cookie.isSecure), sourceID: sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
private func dataURIResponse(for prepared: PreparedRequest) throws -> HTTPResponse {
|
||||
let value = prepared.url.absoluteString
|
||||
guard value.lowercased().hasPrefix("data:"),
|
||||
let comma = value.firstIndex(of: ",") else {
|
||||
throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 无效")
|
||||
}
|
||||
let metadata = String(value[value.index(value.startIndex, offsetBy: 5)..<comma])
|
||||
let payload = String(value[value.index(after: comma)...])
|
||||
let isBase64 = metadata.split(separator: ";").contains { $0.caseInsensitiveCompare("base64") == .orderedSame }
|
||||
let data: Data?
|
||||
if isBase64 {
|
||||
data = Data(base64Encoded: payload.removingPercentEncoding ?? payload, options: .ignoreUnknownCharacters)
|
||||
} else {
|
||||
data = (payload.removingPercentEncoding ?? payload).data(using: .utf8)
|
||||
}
|
||||
guard let data else {
|
||||
throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 内容无法解码")
|
||||
}
|
||||
let mediaType = metadata.split(separator: ";", maxSplits: 1).first.map(String.init)
|
||||
let headers = mediaType.flatMap { $0.isEmpty ? nil : ["Content-Type": $0] } ?? [:]
|
||||
return HTTPResponse(data: data, statusCode: 200, headers: headers, finalURL: prepared.url)
|
||||
}
|
||||
|
||||
private func upsert(_ cookie: StoredCookie, sourceID: String) {
|
||||
var values = cookies[sourceID] ?? []
|
||||
values.removeAll { $0.name == cookie.name && $0.domain == cookie.domain && $0.path == cookie.path }
|
||||
values.append(cookie)
|
||||
cookies[sourceID] = values
|
||||
}
|
||||
|
||||
private func domainMatches(host: String, domain: String) -> Bool {
|
||||
let normalized = domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
return host == normalized || host.hasSuffix(".\(normalized)")
|
||||
}
|
||||
|
||||
private func mergeCookieHeaders(_ headers: String...) -> String {
|
||||
var values: [String: String] = [:]
|
||||
var order: [String] = []
|
||||
for header in headers {
|
||||
for part in header.split(separator: ";") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { continue }
|
||||
let name = pair[0].trimmingCharacters(in: .whitespaces)
|
||||
if values[name] == nil { order.append(name) }
|
||||
values[name] = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
public enum HostsHTTPTransport {
|
||||
public static func data(for request: URLRequest, address: String, ignoreCertificateErrors: Bool) async throws -> HTTPResponse {
|
||||
let operation = try HostsRequestOperation(request: request, address: address, ignoreCertificateErrors: ignoreCertificateErrors)
|
||||
return try await withTaskCancellationHandler {
|
||||
try await operation.run()
|
||||
} onCancel: { operation.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private final class HostsRequestOperation: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "legado.hosts.\(UUID().uuidString)")
|
||||
private let connection: NWConnection
|
||||
private let request: URLRequest
|
||||
private var continuation: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var buffer = Data()
|
||||
private var finished = false
|
||||
private var cancelled = false
|
||||
|
||||
init(request: URLRequest, address: String, ignoreCertificateErrors: Bool) throws {
|
||||
guard let url = request.url, let host = url.host,
|
||||
["http", "https"].contains(url.scheme?.lowercased() ?? ""),
|
||||
let port = NWEndpoint.Port(rawValue: UInt16(exactly: url.port ?? (url.scheme == "https" ? 443 : 80)) ?? 0),
|
||||
port.rawValue > 0 else { throw URLError(.badURL) }
|
||||
self.request = request
|
||||
let parameters: NWParameters
|
||||
if url.scheme?.lowercased() == "https" {
|
||||
let tls = NWProtocolTLS.Options()
|
||||
sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host)
|
||||
sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1")
|
||||
if ignoreCertificateErrors {
|
||||
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { _, _, complete in complete(true) }, queue)
|
||||
}
|
||||
parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
} else { parameters = .tcp }
|
||||
connection = NWConnection(host: NWEndpoint.Host(address), port: port, using: parameters)
|
||||
}
|
||||
|
||||
func run() async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
self.continuation = continuation
|
||||
if self.cancelled { self.finish(.failure(CancellationError())); return }
|
||||
self.connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self, !self.finished else { return }
|
||||
switch state {
|
||||
case .ready: self.send()
|
||||
case .failed(let error): self.finish(.failure(error))
|
||||
case .cancelled: self.finish(.failure(CancellationError()))
|
||||
default: break
|
||||
}
|
||||
}
|
||||
self.connection.start(queue: self.queue)
|
||||
self.queue.asyncAfter(deadline: .now() + max(1, self.request.timeoutInterval)) { [weak self] in
|
||||
self?.finish(.failure(URLError(.timedOut)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
queue.async {
|
||||
self.cancelled = true
|
||||
if self.continuation != nil { self.finish(.failure(CancellationError())) }
|
||||
}
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let url = request.url, let host = url.host,
|
||||
let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
finish(.failure(URLError(.badURL))); return
|
||||
}
|
||||
let path = (parts.percentEncodedPath.isEmpty ? "/" : parts.percentEncodedPath)
|
||||
+ (parts.percentEncodedQuery.map { "?\($0)" } ?? "")
|
||||
var headers = request.allHTTPHeaderFields ?? [:]
|
||||
for name in headers.keys.filter({ ["host", "connection", "accept-encoding", "content-length", "transfer-encoding"].contains($0.lowercased()) }) {
|
||||
headers.removeValue(forKey: name)
|
||||
}
|
||||
let hostField = host.contains(":") ? "[\(host)]" : host
|
||||
headers["Host"] = hostField + (url.port.map { ":\($0)" } ?? "")
|
||||
headers["Connection"] = "close"
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
let body = request.httpBody ?? Data()
|
||||
if !body.isEmpty || request.httpMethod == "POST" || request.httpMethod == "PUT" {
|
||||
headers["Content-Length"] = String(body.count)
|
||||
}
|
||||
if request.httpShouldHandleCookies, headers.keys.contains(where: { $0.lowercased() == "cookie" }) == false {
|
||||
headers.merge(HTTPCookie.requestHeaderFields(with: HTTPCookieStorage.shared.cookies(for: url) ?? [])) { explicit, _ in explicit }
|
||||
}
|
||||
let method = request.httpMethod ?? "GET"
|
||||
guard !method.contains(where: { $0.isWhitespace }), !path.contains(where: { $0.isNewline }),
|
||||
headers.allSatisfy({ !$0.key.contains(where: { $0.isNewline }) && !$0.value.contains(where: { $0.isNewline }) }) else {
|
||||
finish(.failure(URLError(.badURL))); return
|
||||
}
|
||||
let head = "\(method) \(path) HTTP/1.1\r\n"
|
||||
+ headers.map { "\($0.key): \($0.value)\r\n" }.joined() + "\r\n"
|
||||
var data = Data(head.utf8)
|
||||
data.append(body)
|
||||
connection.send(content: data, completion: .contentProcessed { [weak self] error in
|
||||
if let error { self?.finish(.failure(error)) } else { self?.receive() }
|
||||
})
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, complete, error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error { self.finish(.failure(error)); return }
|
||||
if let data { self.buffer.append(data) }
|
||||
do {
|
||||
if let response = try HostsHTTPResponseDecoder.decode(self.buffer, url: self.request.url!, method: self.request.httpMethod ?? "GET", complete: complete) {
|
||||
if self.request.httpShouldHandleCookies {
|
||||
let cookies = HTTPCookie.cookies(withResponseHeaderFields: response.headers, for: response.finalURL)
|
||||
cookies.forEach { HTTPCookieStorage.shared.setCookie($0) }
|
||||
}
|
||||
self.finish(.success(response))
|
||||
} else if complete { self.finish(.failure(URLError(.badServerResponse))) }
|
||||
else { self.receive() }
|
||||
} catch { self.finish(.failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
connection.stateUpdateHandler = nil
|
||||
connection.cancel()
|
||||
continuation?.resume(with: result)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum HostsHTTPResponseDecoder {
|
||||
public static func decode(_ data: Data, url: URL, method: String = "GET", complete: Bool = false) throws -> HTTPResponse? {
|
||||
guard data.count <= 64 * 1024 * 1024 else { throw URLError(.dataLengthExceedsMaximum) }
|
||||
guard let boundary = data.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
if data.count > 64 * 1024 { throw URLError(.badServerResponse) }
|
||||
return nil
|
||||
}
|
||||
guard boundary.lowerBound <= 64 * 1024 else { throw URLError(.badServerResponse) }
|
||||
let lines = String(decoding: data[..<boundary.lowerBound], as: UTF8.self).components(separatedBy: "\r\n")
|
||||
guard let statusLine = lines.first, statusLine.hasPrefix("HTTP/1."),
|
||||
let status = Int(statusLine.split(separator: " ").dropFirst().first ?? "") else { throw URLError(.badServerResponse) }
|
||||
let remaining = Data(data[boundary.upperBound...])
|
||||
if (100...199).contains(status) && status != 101 {
|
||||
return try decode(remaining, url: url, method: method, complete: complete)
|
||||
}
|
||||
var headers: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colon = line.firstIndex(of: ":") else { throw URLError(.badServerResponse) }
|
||||
let name = String(line[..<colon]).lowercased()
|
||||
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
||||
}
|
||||
let body: Data
|
||||
if method == "HEAD" || status == 204 || status == 304 { body = Data() }
|
||||
else if headers["transfer-encoding"]?.lowercased() == "chunked" {
|
||||
guard let decoded = try decodeChunks(remaining) else { return nil }
|
||||
body = decoded
|
||||
} else if let rawLength = headers["content-length"] {
|
||||
guard let length = Int(rawLength), length >= 0, length <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) }
|
||||
guard remaining.count >= length else { return nil }
|
||||
body = Data(remaining.prefix(length))
|
||||
} else {
|
||||
guard complete else { return nil }
|
||||
body = remaining
|
||||
}
|
||||
guard headers["content-encoding"] == nil || headers["content-encoding"] == "identity" || body.isEmpty else {
|
||||
throw SettingsError.invalid("Hosts 请求收到不支持的压缩响应,请移除此域名的 Hosts 覆盖后重试。")
|
||||
}
|
||||
return .init(data: body, statusCode: status, headers: headers, finalURL: url)
|
||||
}
|
||||
|
||||
private static func decodeChunks(_ data: Data) throws -> Data? {
|
||||
var offset = data.startIndex
|
||||
var body = Data()
|
||||
let newline = Data("\r\n".utf8)
|
||||
while offset < data.endIndex {
|
||||
guard let line = data.range(of: newline, in: offset..<data.endIndex) else { return nil }
|
||||
let value = String(decoding: data[offset..<line.lowerBound], as: UTF8.self).components(separatedBy: ";")[0]
|
||||
guard let count = Int(value.trimmingCharacters(in: .whitespaces), radix: 16), count >= 0,
|
||||
count <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) }
|
||||
offset = line.upperBound
|
||||
if count == 0 {
|
||||
if data[offset...].starts(with: newline) || data.range(of: Data("\r\n\r\n".utf8), in: offset..<data.endIndex) != nil { return body }
|
||||
return nil
|
||||
}
|
||||
guard data.endIndex - offset >= count + 2 else { return nil }
|
||||
body.append(data[offset..<(offset + count)])
|
||||
offset += count
|
||||
guard data[offset..<(offset + 2)] == newline else { throw URLError(.badServerResponse) }
|
||||
offset += 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import Foundation
|
||||
|
||||
public enum HTTPMethod: String, Codable, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case head = "HEAD"
|
||||
}
|
||||
|
||||
public struct URLTemplateContext: Sendable {
|
||||
public var key: String?
|
||||
public var page: Int?
|
||||
public var variables: [String: String]
|
||||
public var source: BookSource?
|
||||
public var book: Book?
|
||||
public var chapter: BookChapter?
|
||||
|
||||
public init(
|
||||
key: String? = nil,
|
||||
page: Int? = nil,
|
||||
variables: [String: String] = [:],
|
||||
source: BookSource? = nil,
|
||||
book: Book? = nil,
|
||||
chapter: BookChapter? = nil
|
||||
) {
|
||||
self.key = key
|
||||
self.page = page
|
||||
self.variables = variables
|
||||
self.source = source
|
||||
self.book = book
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
fileprivate func value(for expression: String) -> String? {
|
||||
switch expression.trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||
case "key", "searchKey": key
|
||||
case "page", "searchPage": page.map(String.init)
|
||||
default: variables[expression.trimmingCharacters(in: .whitespacesAndNewlines)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct PreparedRequest: Sendable {
|
||||
public var suppressesUserAgent: Bool
|
||||
public var ruleURL: String
|
||||
public var url: URL
|
||||
public var method: HTTPMethod
|
||||
public var headers: [String: String]
|
||||
public var body: Data?
|
||||
public var charset: String?
|
||||
public var retry: Int
|
||||
public var useWebView: Bool
|
||||
public var webJavaScript: String?
|
||||
public var bodyJavaScript: String?
|
||||
public var webViewDelayMilliseconds: Int
|
||||
public var responseType: String?
|
||||
public var proxy: String?
|
||||
public var dnsIP: String?
|
||||
public var serverID: Int64?
|
||||
public var variables: [String: String]
|
||||
public var key: String?
|
||||
public var page: Int?
|
||||
public var book: Book?
|
||||
public var chapter: BookChapter?
|
||||
|
||||
public init(
|
||||
ruleURL: String,
|
||||
url: URL,
|
||||
method: HTTPMethod,
|
||||
headers: [String: String],
|
||||
body: Data?,
|
||||
charset: String?,
|
||||
retry: Int,
|
||||
useWebView: Bool,
|
||||
webJavaScript: String?,
|
||||
bodyJavaScript: String?,
|
||||
webViewDelayMilliseconds: Int = 0,
|
||||
responseType: String? = nil,
|
||||
proxy: String? = nil,
|
||||
dnsIP: String? = nil,
|
||||
serverID: Int64? = nil,
|
||||
variables: [String: String] = [:],
|
||||
key: String? = nil,
|
||||
page: Int? = nil,
|
||||
book: Book? = nil,
|
||||
chapter: BookChapter? = nil,
|
||||
suppressesUserAgent: Bool = false
|
||||
) {
|
||||
self.suppressesUserAgent = suppressesUserAgent
|
||||
self.ruleURL = ruleURL
|
||||
self.url = url
|
||||
self.method = method
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
self.charset = charset
|
||||
self.retry = retry
|
||||
self.useWebView = useWebView
|
||||
self.webJavaScript = webJavaScript
|
||||
self.bodyJavaScript = bodyJavaScript
|
||||
self.webViewDelayMilliseconds = webViewDelayMilliseconds
|
||||
self.responseType = responseType
|
||||
self.proxy = proxy
|
||||
self.dnsIP = dnsIP
|
||||
self.serverID = serverID
|
||||
self.variables = variables
|
||||
self.key = key
|
||||
self.page = page
|
||||
self.book = book
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
public func urlRequest(timeout: TimeInterval = 30, handleCookies: Bool = true) -> URLRequest {
|
||||
let override = ApplicationSettingsStore.shared.settings.timeoutSeconds
|
||||
var request = URLRequest(url: url, timeoutInterval: override > 0 ? Double(override) : timeout)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = method == .post ? body : nil
|
||||
request.httpShouldHandleCookies = handleCookies
|
||||
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
|
||||
if suppressesUserAgent {
|
||||
let mutable = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
|
||||
URLProtocol.setProperty(true, forKey: "LegadoSuppressUserAgent", in: mutable)
|
||||
return mutable as URLRequest
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
public struct LegadoURLBuilder: Sendable {
|
||||
public static let defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
|
||||
public init() {}
|
||||
|
||||
public static func resolve(_ value: String, against baseURL: URL) -> String {
|
||||
let value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.lowercased().hasPrefix("javascript:") { return "" }
|
||||
guard let pattern = try? NSRegularExpression(pattern: #",\s*(?=\{)"#),
|
||||
let match = pattern.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
|
||||
let range = Range(match.range, in: value) else {
|
||||
return URL(string: value, relativeTo: baseURL)?.absoluteURL.absoluteString ?? value
|
||||
}
|
||||
let address = String(value[..<range.lowerBound])
|
||||
let options = String(value[range.lowerBound...])
|
||||
return (URL(string: address, relativeTo: baseURL)?.absoluteURL.absoluteString ?? address) + options
|
||||
}
|
||||
|
||||
public static func decodeLenientJSON(_ source: String, quoteUnquotedValues: Bool = false) throws -> JSONValue {
|
||||
try JSONValue.decode(data: Data(lenientJSON(source, quoteUnquotedValues: quoteUnquotedValues).utf8))
|
||||
}
|
||||
|
||||
public func prepare(
|
||||
_ template: String,
|
||||
context: URLTemplateContext = .init(),
|
||||
baseURL: URL? = nil,
|
||||
defaultHeaders: [String: String] = [:]
|
||||
) throws -> PreparedRequest {
|
||||
let renderedResult = try renderResult(template, context: context, baseURL: baseURL)
|
||||
let rendered = renderedResult.text
|
||||
let parsed = try parseOptions(rendered, rule: template)
|
||||
let options = parsed.options
|
||||
var urlText = parsed.url
|
||||
var variables = renderedResult.variables
|
||||
if let script = options.javaScript {
|
||||
let evaluated = try evaluateJavaScript(
|
||||
script,
|
||||
result: .string(urlText),
|
||||
context: context,
|
||||
variables: variables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
urlText = evaluated.value.stringValue
|
||||
variables = evaluated.variables
|
||||
}
|
||||
let charsetEncoding = options.charset.flatMap(HTTPTextDecoder.encoding(named:))
|
||||
let requestURLText = encodeQueryIfNeeded(urlText, encoding: charsetEncoding)
|
||||
var urlAllowed = CharacterSet.urlFragmentAllowed
|
||||
urlAllowed.insert(charactersIn: "%")
|
||||
let encoded = requestURLText.addingPercentEncoding(withAllowedCharacters: urlAllowed) ?? requestURLText
|
||||
guard let url = URL(string: encoded, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw RuleEngineError.malformedRule(rule: template, detail: "URL 无法解析:\(urlText)")
|
||||
}
|
||||
var headers = defaultHeaders
|
||||
let proxyKey = headers.keys.first { $0.caseInsensitiveCompare("proxy") == .orderedSame }
|
||||
let proxy = proxyKey.flatMap { headers.removeValue(forKey: $0) }
|
||||
for (key, value) in options.headers {
|
||||
for existing in Array(headers.keys) where existing.caseInsensitiveCompare(key) == .orderedSame {
|
||||
headers.removeValue(forKey: existing)
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
let userAgentKey = headers.keys.first { $0.caseInsensitiveCompare("User-Agent") == .orderedSame }
|
||||
let suppressesUserAgent = userAgentKey.flatMap { headers[$0] } == "null"
|
||||
if suppressesUserAgent, let userAgentKey {
|
||||
headers.removeValue(forKey: userAgentKey)
|
||||
}
|
||||
if !suppressesUserAgent,
|
||||
headers.first(where: { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }) == nil {
|
||||
headers["User-Agent"] = ApplicationSettingsStore.shared.settings.effectiveUserAgent
|
||||
}
|
||||
let bodyText = options.body?.stringValue
|
||||
let trimmedBody = bodyText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let isStructuredBody = trimmedBody.hasPrefix("{") || trimmedBody.hasPrefix("[") || trimmedBody.hasPrefix("<")
|
||||
let hasExplicitContentType = headers.keys.contains { $0.caseInsensitiveCompare("Content-Type") == .orderedSame }
|
||||
let encodedBodyText = if let bodyText, !isStructuredBody, !hasExplicitContentType {
|
||||
percentEncode(bodyText, encoding: charsetEncoding ?? .utf8, preserving: Self.formDelimiters, spaceAsPlus: true)
|
||||
} else {
|
||||
bodyText
|
||||
}
|
||||
let body = options.method == .post ? (encodedBodyText?.data(using: charsetEncoding ?? .utf8) ?? Data()) : nil
|
||||
if options.method == .post, !hasExplicitContentType {
|
||||
let charsetName = options.charset ?? "utf-8"
|
||||
if trimmedBody.hasPrefix("<") {
|
||||
headers["Content-Type"] = "application/xml; charset=\(charsetName)"
|
||||
} else if isStructuredBody {
|
||||
headers["Content-Type"] = "application/json; charset=\(charsetName)"
|
||||
} else {
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=\(charsetName)"
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
ruleURL: template,
|
||||
url: url,
|
||||
method: options.method,
|
||||
headers: headers,
|
||||
body: body,
|
||||
charset: options.charset,
|
||||
retry: options.retry,
|
||||
useWebView: options.useWebView,
|
||||
webJavaScript: options.webJavaScript,
|
||||
bodyJavaScript: options.bodyJavaScript,
|
||||
webViewDelayMilliseconds: options.webViewDelayMilliseconds,
|
||||
responseType: options.responseType,
|
||||
proxy: proxy,
|
||||
dnsIP: options.dnsIP,
|
||||
serverID: options.serverID,
|
||||
variables: variables,
|
||||
key: context.key,
|
||||
page: context.page,
|
||||
book: context.book,
|
||||
chapter: context.chapter,
|
||||
suppressesUserAgent: suppressesUserAgent
|
||||
)
|
||||
}
|
||||
|
||||
private static let queryDelimiters: CharacterSet = {
|
||||
var value = CharacterSet.alphanumerics
|
||||
value.insert(charactersIn: "-._~!$&'()*+,;=:@/?%")
|
||||
return value
|
||||
}()
|
||||
private static let formDelimiters: CharacterSet = {
|
||||
var value = CharacterSet.alphanumerics
|
||||
value.insert(charactersIn: "-._~&=;%+")
|
||||
return value
|
||||
}()
|
||||
|
||||
private func encodeQueryIfNeeded(_ value: String, encoding: String.Encoding?) -> String {
|
||||
guard let encoding, encoding != .utf8, let separator = value.firstIndex(of: "?") else { return value }
|
||||
let queryStart = value.index(after: separator)
|
||||
return String(value[...separator])
|
||||
+ percentEncode(String(value[queryStart...]), encoding: encoding, preserving: Self.queryDelimiters)
|
||||
}
|
||||
|
||||
private func percentEncode(
|
||||
_ value: String,
|
||||
encoding: String.Encoding,
|
||||
preserving allowed: CharacterSet,
|
||||
spaceAsPlus: Bool = false
|
||||
) -> String {
|
||||
var output = ""
|
||||
for character in value {
|
||||
let text = String(character)
|
||||
if spaceAsPlus, text == " " {
|
||||
output += "+"
|
||||
continue
|
||||
}
|
||||
if text.unicodeScalars.allSatisfy({ $0.isASCII && allowed.contains($0) }) {
|
||||
output += text
|
||||
continue
|
||||
}
|
||||
let bytes = text.data(using: encoding) ?? Data(text.utf8)
|
||||
output += bytes.map { String(format: "%%%02X", $0) }.joined()
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
public func render(_ template: String, context: URLTemplateContext) throws -> String {
|
||||
try renderResult(template, context: context, baseURL: nil).text
|
||||
}
|
||||
|
||||
private func renderResult(
|
||||
_ template: String,
|
||||
context: URLTemplateContext,
|
||||
baseURL: URL?
|
||||
) throws -> (text: String, variables: [String: String]) {
|
||||
var variables = context.variables
|
||||
let segments = try evaluateJavaScriptSegments(
|
||||
template,
|
||||
context: context,
|
||||
variables: variables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
var result = segments.text
|
||||
variables = segments.variables
|
||||
let inline = try NSRegularExpression(pattern: #"\{\{([\s\S]*?)\}\}"#)
|
||||
let matches = inline.matches(in: result, range: NSRange(result.startIndex..., in: result)).reversed()
|
||||
for match in matches {
|
||||
guard let fullRange = Range(match.range(at: 0), in: result),
|
||||
let expressionRange = Range(match.range(at: 1), in: result) else { continue }
|
||||
let expression = String(result[expressionRange])
|
||||
let value: String
|
||||
if let literal = context.value(for: expression) {
|
||||
value = literal
|
||||
} else {
|
||||
let evaluated = try evaluateJavaScript(
|
||||
expression,
|
||||
result: .string(result),
|
||||
context: context,
|
||||
variables: variables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
value = evaluated.value.stringValue
|
||||
variables = evaluated.variables
|
||||
}
|
||||
result.replaceSubrange(fullRange, with: value)
|
||||
}
|
||||
if let key = context.key {
|
||||
result = result.replacingOccurrences(of: "searchKey", with: key)
|
||||
}
|
||||
if let page = context.page {
|
||||
result = result.replacingOccurrences(of: "searchPage", with: String(page))
|
||||
}
|
||||
if let page = context.page {
|
||||
let pageExpression = try NSRegularExpression(pattern: #"<([^<>]*?)>"#)
|
||||
let pageMatches = pageExpression.matches(
|
||||
in: result,
|
||||
range: NSRange(result.startIndex..., in: result)
|
||||
).reversed()
|
||||
for match in pageMatches {
|
||||
guard let fullRange = Range(match.range(at: 0), in: result),
|
||||
let listRange = Range(match.range(at: 1), in: result) else { continue }
|
||||
let choices = result[listRange].split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !choices.isEmpty else { continue }
|
||||
let selected = choices[min(max(page - 1, 0), choices.count - 1)]
|
||||
result.replaceSubrange(fullRange, with: selected)
|
||||
}
|
||||
}
|
||||
return (result, variables)
|
||||
}
|
||||
|
||||
private func evaluateJavaScriptSegments(
|
||||
_ template: String,
|
||||
context: URLTemplateContext,
|
||||
variables: [String: String],
|
||||
baseURL: URL?
|
||||
) throws -> (text: String, variables: [String: String]) {
|
||||
let expression = try NSRegularExpression(
|
||||
pattern: #"<js>([\s\S]*?)</js>|@js:([\s\S]*)"#,
|
||||
options: .caseInsensitive
|
||||
)
|
||||
let matches = expression.matches(in: template, range: NSRange(template.startIndex..., in: template))
|
||||
guard !matches.isEmpty else { return (template, variables) }
|
||||
var output = template
|
||||
var currentVariables = variables
|
||||
var cursor = template.startIndex
|
||||
for match in matches {
|
||||
guard let matchRange = Range(match.range(at: 0), in: template) else { continue }
|
||||
let prefix = String(template[cursor..<matchRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !prefix.isEmpty { output = prefix.replacingOccurrences(of: "@result", with: output) }
|
||||
let capture = match.range(at: 2).location != NSNotFound ? match.range(at: 2) : match.range(at: 1)
|
||||
guard let scriptRange = Range(capture, in: template) else { continue }
|
||||
let evaluated = try evaluateJavaScript(
|
||||
String(template[scriptRange]),
|
||||
result: .string(output),
|
||||
context: context,
|
||||
variables: currentVariables,
|
||||
baseURL: baseURL
|
||||
)
|
||||
output = evaluated.value.stringValue
|
||||
currentVariables = evaluated.variables
|
||||
cursor = matchRange.upperBound
|
||||
}
|
||||
let suffix = String(template[cursor...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !suffix.isEmpty { output = suffix.replacingOccurrences(of: "@result", with: output) }
|
||||
return (output, currentVariables)
|
||||
}
|
||||
|
||||
private func evaluateJavaScript(
|
||||
_ script: String,
|
||||
result: JSONValue,
|
||||
context: URLTemplateContext,
|
||||
variables: [String: String],
|
||||
baseURL: URL?
|
||||
) throws -> JavaScriptRuleResult {
|
||||
try JavaScriptRuleEvaluator().evaluate(
|
||||
script: script,
|
||||
result: result,
|
||||
executionContext: .init(
|
||||
variables: variables,
|
||||
baseURL: baseURL?.absoluteString ?? context.source?.bookSourceUrl ?? "",
|
||||
key: context.key,
|
||||
page: context.page,
|
||||
source: context.source,
|
||||
book: context.book,
|
||||
chapter: context.chapter
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func splitOptions(_ value: String) -> [(url: String, options: String)] {
|
||||
guard let expression = try? NSRegularExpression(pattern: #"\s*,\s*(?=\{)"#) else {
|
||||
return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")]
|
||||
}
|
||||
let matches = expression.matches(in: value, range: NSRange(value.startIndex..., in: value))
|
||||
guard !matches.isEmpty else {
|
||||
return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")]
|
||||
}
|
||||
return matches.reversed().compactMap { match in
|
||||
guard let range = Range(match.range, in: value) else { return nil }
|
||||
return (
|
||||
String(value[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
String(value[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseOptions(_ source: String, rule: String) throws -> (url: String, options: Options) {
|
||||
let candidates = splitOptions(source)
|
||||
var firstError: Error?
|
||||
for (index, candidate) in candidates.enumerated() {
|
||||
guard !candidate.options.isEmpty else {
|
||||
return (candidate.url, .init())
|
||||
}
|
||||
do {
|
||||
let value = try decodeOptions(candidate.options)
|
||||
guard case .object(let object) = value else {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "URL 参数必须是 JSON 对象")
|
||||
}
|
||||
return (candidate.url, try makeOptions(object, rule: rule))
|
||||
} catch {
|
||||
if index == 0 { firstError = error }
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw firstError ?? RuleEngineError.malformedRule(rule: rule, detail: "URL 参数 JSON 无效")
|
||||
}
|
||||
|
||||
private func decodeOptions(_ source: String) throws -> JSONValue {
|
||||
do {
|
||||
return try JSONValue.decode(data: Data(source.utf8))
|
||||
} catch {
|
||||
do {
|
||||
return try JSONValue.decode(data: Data(Self.lenientJSON(source).utf8))
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: source, detail: "URL 参数 JSON 无效:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeOptions(_ object: [String: JSONValue], rule: String) throws -> Options {
|
||||
var options = Options()
|
||||
if let method = object["method"]?.stringValue.uppercased() {
|
||||
options.method = HTTPMethod(rawValue: method) ?? .get
|
||||
}
|
||||
options.charset = object["charset"]?.stringValue.nilIfEmpty
|
||||
options.retry = Int(object["retry"]?.stringValue ?? "") ?? 0
|
||||
options.useWebView = object["webView"].map { value in
|
||||
switch value {
|
||||
case .bool(let enabled): enabled
|
||||
case .number(let number): number != 0
|
||||
case .string(let string): !string.isEmpty && string.lowercased() != "false"
|
||||
default: false
|
||||
}
|
||||
} ?? false
|
||||
options.webJavaScript = object["webJs"]?.stringValue.nilIfEmpty
|
||||
options.bodyJavaScript = object["bodyJs"]?.stringValue.nilIfEmpty
|
||||
options.javaScript = object["js"]?.stringValue.nilIfEmpty
|
||||
options.webViewDelayMilliseconds = min(max(Int(object["webViewDelayTime"]?.stringValue ?? "") ?? 0, 0), 30_000)
|
||||
options.responseType = object["type"]?.stringValue.nilIfEmpty
|
||||
options.dnsIP = object["dnsIp"]?.stringValue.nilIfEmpty
|
||||
options.serverID = Int64(object["serverID"]?.stringValue ?? "")
|
||||
options.body = object["body"]
|
||||
if let headerValue = object["headers"] {
|
||||
options.headers = try parseHeaders(headerValue, rule: rule)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
private static func lenientJSON(_ source: String, quoteUnquotedValues: Bool = false) -> String {
|
||||
let characters = Array(source)
|
||||
var output = ""
|
||||
var index = 0
|
||||
var expectingKey = true
|
||||
|
||||
func peek(_ offset: Int = 0) -> Character? {
|
||||
let position = index + offset
|
||||
return position < characters.count ? characters[position] : nil
|
||||
}
|
||||
|
||||
while index < characters.count {
|
||||
let character = characters[index]
|
||||
index += 1
|
||||
switch character {
|
||||
case "\"":
|
||||
output.append("\"")
|
||||
while index < characters.count {
|
||||
let inner = characters[index]
|
||||
index += 1
|
||||
output.append(inner)
|
||||
if inner == "\\", index < characters.count {
|
||||
output.append(characters[index])
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if inner == "\"" { break }
|
||||
}
|
||||
expectingKey = false
|
||||
case "'":
|
||||
var text = ""
|
||||
while index < characters.count {
|
||||
let inner = characters[index]
|
||||
index += 1
|
||||
if inner == "\\", index < characters.count {
|
||||
let escaped = characters[index]
|
||||
index += 1
|
||||
text.append(escaped == "'" ? "'" : "\\\(escaped)")
|
||||
continue
|
||||
}
|
||||
if inner == "'" { break }
|
||||
text.append(inner)
|
||||
}
|
||||
output.append(escapedJSONString(text))
|
||||
expectingKey = false
|
||||
case "/":
|
||||
if peek() == "/" {
|
||||
index += 1
|
||||
while let value = peek(), value != "\n" { index += 1 }
|
||||
} else if peek() == "*" {
|
||||
index += 1
|
||||
while let value = peek() {
|
||||
if value == "*", peek(1) == "/" {
|
||||
index += 2
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
} else {
|
||||
output.append(character)
|
||||
}
|
||||
case "{", ",":
|
||||
output.append(character)
|
||||
expectingKey = true
|
||||
case ":":
|
||||
output.append(character)
|
||||
expectingKey = false
|
||||
if quoteUnquotedValues {
|
||||
var start = index
|
||||
while start < characters.count, characters[start].isWhitespace { start += 1 }
|
||||
if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) {
|
||||
var end = start
|
||||
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
|
||||
output.append(escapedJSONString(String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)))
|
||||
index = end
|
||||
}
|
||||
}
|
||||
case "}", "]":
|
||||
while output.last == "," { output.removeLast() }
|
||||
output.append(character)
|
||||
expectingKey = false
|
||||
default:
|
||||
output.append(character)
|
||||
if expectingKey, character.isLetter || character == "_" || character == "$" {
|
||||
var name = String(character)
|
||||
var position = index
|
||||
while position < characters.count {
|
||||
let inner = characters[position]
|
||||
guard inner.isLetter || inner.isNumber || inner == "_" || inner == "$" || inner == "-" else { break }
|
||||
name.append(inner)
|
||||
position += 1
|
||||
}
|
||||
var peekPosition = position
|
||||
while peekPosition < characters.count, characters[peekPosition].isWhitespace {
|
||||
peekPosition += 1
|
||||
}
|
||||
if peekPosition < characters.count, characters[peekPosition] == ":" {
|
||||
output.removeLast()
|
||||
output.append(escapedJSONString(name))
|
||||
index = position
|
||||
}
|
||||
}
|
||||
if !character.isWhitespace { expectingKey = false }
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func escapedJSONString(_ value: String) -> String {
|
||||
var text = ""
|
||||
for character in value {
|
||||
switch character {
|
||||
case "\"": text.append("\\\"")
|
||||
case "\\": text.append("\\\\")
|
||||
case "\n": text.append("\\n")
|
||||
case "\r": text.append("\\r")
|
||||
case "\t": text.append("\\t")
|
||||
default: text.append(character)
|
||||
}
|
||||
}
|
||||
return "\"\(text)\""
|
||||
}
|
||||
|
||||
private func parseHeaders(_ value: JSONValue, rule: String) throws -> [String: String] {
|
||||
let parsed: JSONValue
|
||||
if case .string(let source) = value {
|
||||
do {
|
||||
parsed = try JSONValue.decode(data: Data(source.utf8))
|
||||
} catch {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
|
||||
}
|
||||
} else {
|
||||
parsed = value
|
||||
}
|
||||
guard case .object(let object) = parsed else {
|
||||
throw RuleEngineError.malformedRule(rule: rule, detail: "headers 必须是对象")
|
||||
}
|
||||
return object.mapValues(\.stringValue)
|
||||
}
|
||||
|
||||
private struct Options {
|
||||
var method: HTTPMethod = .get
|
||||
var charset: String?
|
||||
var headers: [String: String] = [:]
|
||||
var body: JSONValue?
|
||||
var retry = 0
|
||||
var useWebView = false
|
||||
var webJavaScript: String?
|
||||
var bodyJavaScript: String?
|
||||
var javaScript: String?
|
||||
var webViewDelayMilliseconds = 0
|
||||
var responseType: String?
|
||||
var dnsIP: String?
|
||||
var serverID: Int64?
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
Reference in New Issue
Block a user