crossmate

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

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 }