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