crossmate

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

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 }