Files
legado-ios/Sources/LegadoCore/Networking/LegadoURLBuilder.swift
T

647 lines
26 KiB
Swift

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 }
}