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[.. 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: #"([\s\S]*?)|@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.. 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[.. (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.. 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 } }