crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }