NYTPuzzleFetcher.swift (7114B)
1 import Foundation 2 import SwiftUI 3 4 extension EnvironmentValues { 5 @Entry var nytPuzzleFetcher: NYTPuzzleFetcher? = nil 6 } 7 8 actor NYTPuzzleFetcher { 9 /// Upper bound on a puzzle response body. Real daily puzzle JSON is well 10 /// under 200 KB, so 2 MB rejects nothing genuine while keeping an 11 /// unexpected server response from buffering unbounded data. Checked 12 /// against `Content-Length` before reading and enforced again while 13 /// streaming, since the header is optional. 14 static let maxResponseBytes = 2_097_152 15 16 /// Upper bound on an overlay asset. The two real examples are 13 KB and 17 /// 68 KB — they're mostly transparent — so 1 MB rejects nothing genuine 18 /// while capping what a mis-served asset can buffer. Anything larger would 19 /// blow the slicer's own payload budget long before it reached the `.xd`. 20 static let maxOverlayBytes = 1_048_576 21 22 private let cookieProvider: @Sendable () -> NYTCookieLoadResult 23 24 init(cookieProvider: @escaping @Sendable () -> NYTCookieLoadResult) { 25 self.cookieProvider = cookieProvider 26 } 27 28 func fetchPuzzle(for date: Date) async throws -> String { 29 let cookie = try currentCookie() 30 31 let formatter = DateFormatter() 32 formatter.dateFormat = "yyyy-MM-dd" 33 formatter.timeZone = TimeZone(identifier: "America/New_York") 34 let dateString = formatter.string(from: date) 35 36 let url = URL(string: "https://www.nytimes.com/svc/crosswords/v6/puzzle/daily/\(dateString).json")! 37 38 var request = URLRequest(url: url) 39 request.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie") 40 41 let (bytes, response) = try await URLSession.shared.bytes(for: request) 42 43 guard let httpResponse = response as? HTTPURLResponse else { 44 throw NYTFetchError.invalidResponse 45 } 46 47 switch httpResponse.statusCode { 48 case 200: 49 break 50 case 401, 403: 51 throw NYTFetchError.unauthorized 52 case 429: 53 throw NYTFetchError.rateLimited 54 default: 55 throw NYTFetchError.httpError(statusCode: httpResponse.statusCode) 56 } 57 58 // A success from this endpoint is always JSON; anything else (a 59 // captive portal, an HTML error page served with 200) is not a 60 // puzzle, so reject it before buffering the body. 61 guard Self.isJSONContentType(httpResponse.value(forHTTPHeaderField: "Content-Type")) else { 62 throw NYTFetchError.unexpectedContentType 63 } 64 65 let data = try await Self.boundedResponseData( 66 from: bytes, 67 limit: Self.maxResponseBytes, 68 expectedLength: httpResponse.expectedContentLength 69 ) 70 71 // Convert NYT JSON to .xd format, baking in both overlay phases when 72 // the puzzle has them and their assets can be fetched. 73 async let beforeStartImage = overlayImage( 74 at: try? NYTToXDConverter.beforeStartImageURL(jsonData: data) 75 ) 76 async let afterSolveImage = overlayImage( 77 at: try? NYTToXDConverter.afterSolveImageURL(jsonData: data) 78 ) 79 return try await NYTToXDConverter.convert( 80 jsonData: data, 81 beforeStartImage: beforeStartImage, 82 afterSolveImage: afterSolveImage 83 ) 84 } 85 86 /// Fetches one puzzle overlay asset, or nil if it has none or the fetch fails. 87 /// 88 /// Deliberately swallows every error. Letting a CDN hiccup fail the whole 89 /// conversion would cost the player the underlying crossword to save its 90 /// artwork. Assets are fetched anonymously, so this adds no authenticated 91 /// traffic. 92 private func overlayImage(at url: URL?) async -> Data? { 93 guard let url else { return nil } 94 do { 95 let (bytes, response) = try await URLSession.shared.bytes(from: url) 96 guard let httpResponse = response as? HTTPURLResponse, 97 httpResponse.statusCode == 200 else { 98 return nil 99 } 100 return try await Self.boundedResponseData( 101 from: bytes, 102 limit: Self.maxOverlayBytes, 103 expectedLength: httpResponse.expectedContentLength 104 ) 105 } catch { 106 return nil 107 } 108 } 109 110 /// Whether a response `Content-Type` permits JSON. A missing header is 111 /// accepted (the body still has to survive JSON parsing); a supplied 112 /// non-JSON type is rejected. 113 static func isJSONContentType(_ header: String?) -> Bool { 114 guard let header else { return true } 115 let mime = header.split(separator: ";").first.map { 116 $0.trimmingCharacters(in: .whitespaces).lowercased() 117 } ?? "" 118 return mime == "application/json" || mime.hasSuffix("+json") 119 } 120 121 /// Accumulates a response byte stream up to `limit`, failing as soon as 122 /// the budget is exceeded (throwing drops the stream, which cancels the 123 /// transfer). A known `expectedLength` over the budget fails before 124 /// reading at all; an unknown length (-1) streams against the cap. 125 static func boundedResponseData<S: AsyncSequence>( 126 from bytes: S, 127 limit: Int, 128 expectedLength: Int64 129 ) async throws -> Data where S.Element == UInt8 { 130 guard expectedLength <= Int64(limit) else { 131 throw NYTFetchError.responseTooLarge 132 } 133 var data = Data() 134 data.reserveCapacity(expectedLength > 0 ? Int(expectedLength) : 0) 135 for try await byte in bytes { 136 guard data.count < limit else { 137 throw NYTFetchError.responseTooLarge 138 } 139 data.append(byte) 140 } 141 return data 142 } 143 144 private func currentCookie() throws -> String { 145 switch cookieProvider() { 146 case .available(let cookie): 147 cookie 148 case .missing: 149 throw NYTFetchError.notSignedIn 150 case .temporarilyUnavailable: 151 throw NYTFetchError.sessionStatusUnavailable 152 } 153 } 154 } 155 156 enum NYTFetchError: LocalizedError { 157 case notSignedIn 158 case sessionStatusUnavailable 159 case invalidResponse 160 case unexpectedContentType 161 case responseTooLarge 162 case unauthorized 163 case rateLimited 164 case httpError(statusCode: Int) 165 166 var errorDescription: String? { 167 switch self { 168 case .notSignedIn: 169 "Not signed in to NYT." 170 case .sessionStatusUnavailable: 171 "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again." 172 case .invalidResponse: 173 "Received an invalid response." 174 case .unexpectedContentType: 175 "Received an unexpected response type." 176 case .responseTooLarge: 177 "Received an unexpectedly large response." 178 case .unauthorized: 179 "Your NYT session has expired. Sign in again from Settings." 180 case .rateLimited: 181 "Too many requests. Wait a moment and try again." 182 case .httpError(let statusCode): 183 "Fetch failed (HTTP \(statusCode))." 184 } 185 } 186 }