NYTPuzzleFetcher.swift (6094B)
1 import Foundation 2 import SwiftUI 3 4 extension EnvironmentValues { 5 @Entry var nytPuzzleFetcher: NYTPuzzleFetcher? = nil 6 } 7 8 actor NYTPuzzleFetcher { 9 private let cookieProvider: @Sendable () -> NYTCookieLoadResult 10 11 init(cookieProvider: @escaping @Sendable () -> NYTCookieLoadResult) { 12 self.cookieProvider = cookieProvider 13 } 14 15 /// Fetches the puzzle list for a given date to get the puzzle ID, 16 /// then fetches the game data. For debugging, logs both responses. 17 func fetchPuzzleList(for date: Date) async throws -> String { 18 let cookie = try currentCookie() 19 20 let formatter = DateFormatter() 21 formatter.dateFormat = "yyyy-MM-dd" 22 formatter.timeZone = TimeZone(identifier: "America/New_York") 23 let dateString = formatter.string(from: date) 24 25 // Step 1: Get puzzle ID from list endpoint 26 let listURL = URL(string: "https://www.nytimes.com/svc/crosswords/v3/36569100/puzzles.json?publish_type=daily&date_start=\(dateString)&date_end=\(dateString)&limit=1")! 27 28 var listRequest = URLRequest(url: listURL) 29 listRequest.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie") 30 31 let (listData, listResponse) = try await URLSession.shared.data(for: listRequest) 32 33 guard let listHTTP = listResponse as? HTTPURLResponse else { 34 throw NYTFetchError.invalidResponse 35 } 36 37 print("=== NYT LIST RESPONSE ===") 38 print("Status: \(listHTTP.statusCode)") 39 if let body = String(data: listData, encoding: .utf8) { 40 print("Body: \(body.prefix(2000))") 41 } 42 print("=== END LIST RESPONSE ===") 43 44 guard listHTTP.statusCode == 200 else { 45 throw NYTFetchError.httpError(statusCode: listHTTP.statusCode) 46 } 47 48 // Extract puzzle ID from the list 49 guard let listJSON = try? JSONSerialization.jsonObject(with: listData) as? [String: Any], 50 let results = listJSON["results"] as? [[String: Any]], 51 let first = results.first, 52 let puzzleID = first["puzzle_id"] as? Int else { 53 throw NYTFetchError.invalidResponse 54 } 55 56 print("=== PUZZLE ID: \(puzzleID) ===") 57 58 // Step 2: Fetch game data using the puzzle ID 59 let gameURL = URL(string: "https://www.nytimes.com/svc/crosswords/v6/game/\(puzzleID).json")! 60 61 var gameRequest = URLRequest(url: gameURL) 62 gameRequest.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie") 63 64 let (gameData, gameResponse) = try await URLSession.shared.data(for: gameRequest) 65 66 guard let gameHTTP = gameResponse as? HTTPURLResponse else { 67 throw NYTFetchError.invalidResponse 68 } 69 70 print("=== NYT GAME RESPONSE ===") 71 print("Status: \(gameHTTP.statusCode)") 72 if let body = String(data: gameData, encoding: .utf8) { 73 print("Body: \(body.prefix(3000))") 74 } 75 print("=== END GAME RESPONSE ===") 76 77 guard gameHTTP.statusCode == 200 else { 78 throw NYTFetchError.httpError(statusCode: gameHTTP.statusCode) 79 } 80 81 return String(data: gameData, encoding: .utf8) ?? "" 82 } 83 84 func fetchPuzzle(for date: Date) async throws -> String { 85 let cookie = try currentCookie() 86 87 let formatter = DateFormatter() 88 formatter.dateFormat = "yyyy-MM-dd" 89 formatter.timeZone = TimeZone(identifier: "America/New_York") 90 let dateString = formatter.string(from: date) 91 92 let url = URL(string: "https://www.nytimes.com/svc/crosswords/v6/puzzle/daily/\(dateString).json")! 93 94 var request = URLRequest(url: url) 95 request.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie") 96 97 print("=== NYT FETCH REQUEST ===") 98 print("URL: \(url)") 99 print("Cookie header: NYT-S=\(cookie.prefix(40))...") 100 print("=== END FETCH REQUEST ===") 101 102 let (data, response) = try await URLSession.shared.data(for: request) 103 104 guard let httpResponse = response as? HTTPURLResponse else { 105 throw NYTFetchError.invalidResponse 106 } 107 108 print("=== NYT FETCH RESPONSE ===") 109 print("Status: \(httpResponse.statusCode)") 110 if httpResponse.statusCode != 200, let body = String(data: data, encoding: .utf8) { 111 print("Body: \(body.prefix(1000))") 112 } 113 print("=== END FETCH RESPONSE ===") 114 115 switch httpResponse.statusCode { 116 case 200: 117 break 118 case 401, 403: 119 throw NYTFetchError.unauthorized 120 case 429: 121 throw NYTFetchError.rateLimited 122 default: 123 throw NYTFetchError.httpError(statusCode: httpResponse.statusCode) 124 } 125 126 // Convert NYT JSON to .xd format 127 let xdSource = try NYTToXDConverter.convert(jsonData: data) 128 129 print("=== CONVERTED XD ===") 130 print(xdSource) 131 print("=== END XD ===") 132 133 return xdSource 134 } 135 136 private func currentCookie() throws -> String { 137 switch cookieProvider() { 138 case .available(let cookie): 139 cookie 140 case .missing: 141 throw NYTFetchError.notSignedIn 142 case .temporarilyUnavailable: 143 throw NYTFetchError.sessionStatusUnavailable 144 } 145 } 146 } 147 148 enum NYTFetchError: LocalizedError { 149 case notSignedIn 150 case sessionStatusUnavailable 151 case invalidResponse 152 case unauthorized 153 case rateLimited 154 case httpError(statusCode: Int) 155 156 var errorDescription: String? { 157 switch self { 158 case .notSignedIn: 159 "Not signed in to NYT." 160 case .sessionStatusUnavailable: 161 "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again." 162 case .invalidResponse: 163 "Received an invalid response." 164 case .unauthorized: 165 "Your NYT session has expired. Sign in again from Settings." 166 case .rateLimited: 167 "Too many requests. Wait a moment and try again." 168 case .httpError(let statusCode): 169 "Fetch failed (HTTP \(statusCode))." 170 } 171 } 172 }