← Back to index

ReservationService

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import FoundationFramework importsImports Foundation.
struct ReservationService { // MARK: - Public entry point /// Returns a Resy or OpenTable URL for the given entry, or nil if neither is found. static func lookup(for entry: SpotEntry) async -> String? { // 1a. Stored links that are already reservation URLs — no network call. for link in [entry.confirmationLink, entry.website] where !link.isEmpty { if link.contains("resy.com") || link.contains("opentable.com") { return link } } // 1b. Fetch confirmationLink / website pages and scan for embedded // reservation URLs (e.g. an Infatuation review that links to Resy/OT). for link in [entry.confirmationLink, entry.website] where !link.isEmpty && link.hasPrefix("http") { if let found = await scrapeReservationLink(from: link) { return found } } guard !entry.playName.isEmpty else { return nil } // 2. Resy venue-search API if let resyURL = await searchResy(entry: entry) { return resyURL } // 3. OpenTable search page (JSON-LD) if let otURL = await searchOpenTable(entry: entry) { return otURL } return nil } // MARK: - Page scraper /// Fetches `pageURL` and returns the first resy.com or opentable.com URL found in the HTML. private static func scrapeReservationLink(from pageURL: String) async -> String? { guard let url = URL(string: pageURL) else { return nil } var req = URLRequest(url: url) req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent") req.timeoutInterval = 8 guard let (data, _) = try? await URLSession.shared.data(for: req), let html = String(data: data, encoding: .utf8) else { return nil } // Match any absolute resy.com or opentable.com URL in the page source. let pattern = #"https?://(?:www\.)?(?:resy\.com|opentable\.com)/[^\s"'<>]+"# guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return nil } let ns = html as NSString let range = NSRange(location: 0, length: ns.length) guard let match = regex.firstMatch(in: html, options: [], range: range) else { return nil } return ns.substring(with: match.range) } // MARK: - Description fetcher /// Fetches a restaurant description for `entry`. /// /// Strategy: /// 1. Fetch `confirmationLink` / `website` (the source review page, e.g. Infatuation) /// and extract from JSON-LD `description` or `og:description`. This works for Resy /// restaurants because Resy's own site blocks all non-browser requests; the review /// page that links to Resy is the reliable source. /// 2. If the reservation is OpenTable, also try the OpenTable page JSON-LD as a fallback. static func fetchDescription(for entry: SpotEntry) async -> String? { // 1. Source review page (confirmationLink or website, skipping reservation URLs). for link in [entry.confirmationLink, entry.website] where !link.isEmpty && link.hasPrefix("http") && !link.contains("resy.com") && !link.contains("opentable.com") { if let desc = await fetchDescriptionFromPage(link) { return desc } } // 2. OpenTable page JSON-LD fallback. if entry.reservation.contains("opentable.com") { return await fetchDescriptionFromPage(entry.reservation) } return nil } /// Fetches `pageURL` and extracts a restaurant description from: /// a) Any JSON-LD block that has a `description` key, or /// b) The `og:description` / `name="description"` meta tag. /// HTML entities in the result are unescaped. private static func fetchDescriptionFromPage(_ pageURL: String) async -> String? { guard let url = URL(string: pageURL) else { return nil } var req = URLRequest(url: url) req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", forHTTPHeaderField: "User-Agent") req.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") req.timeoutInterval = 10 guard let (data, _) = try? await URLSession.shared.data(for: req), let html = String(data: data, encoding: .utf8) else { return nil } // a) Walk JSON-LD blocks — accept any schema type that carries a description. var searchRange = html.startIndex..<html.endIndex while let scriptStart = html.range(of: "application/ld+json", range: searchRange) { guard let blockStart = html.range(of: ">", range: scriptStart.upperBound..<html.endIndex), let blockEnd = html.range(of: "</script>", range: blockStart.upperBound..<html.endIndex) else { break } let jsonText = String(html[blockStart.upperBound..<blockEnd.lowerBound]) if let jsonData = jsonText.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], let desc = obj["description"] as? String, !desc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return htmlUnescape(desc.trimmingCharacters(in: .whitespacesAndNewlines)) } searchRange = blockEnd.upperBound..<html.endIndex } // b) og:description meta tag. let ns = html as NSString for pattern in [ #"property="og:description"\s+content="([^"]+)""#, #"name="description"\s+content="([^"]+)""#, ] { if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), let match = regex.firstMatch(in: html, range: NSRange(location: 0, length: ns.length)), match.numberOfRanges > 1 { let raw = ns.substring(with: match.range(at: 1)) if !raw.isEmpty { return htmlUnescape(raw) } } } return nil } private static func htmlUnescape(_ s: String) -> String { s.replacingOccurrences(of: "&apos;", with: "'") .replacingOccurrences(of: "&#x27;", with: "'") .replacingOccurrences(of: "&#39;", with: "'") .replacingOccurrences(of: "&quot;", with: "\"") .replacingOccurrences(of: "&amp;", with: "&") .replacingOccurrences(of: "&lt;", with: "<") .replacingOccurrences(of: "&gt;", with: ">") } // MARK: - Platform detection helper static func platform(for url: String) -> Platform? { if url.contains("resy.com") { return .resy } if url.contains("opentable.com") { return .openTable } return nil } enum Platform { case resy, openTable } // MARK: - Resy private static let resyAPIKey = "VbWk7s3L4KiK5fzlO7JD3Q5EYolJI7n5" private static func searchResy(entry: SpotEntry) async -> String? { // Build query — use stored coordinates when available for better matching. var comps = URLComponents(string: "https://api.resy.com/3/venue/search")! var items: [URLQueryItem] = [URLQueryItem(name: "query", value: entry.playName)] if let lat = entry.latitude, let lon = entry.longitude { items += [ URLQueryItem(name: "geo[lat]", value: String(lat)), URLQueryItem(name: "geo[lon]", value: String(lon)), ] } else { // Default to midtown Manhattan so the search still works. items += [ URLQueryItem(name: "geo[lat]", value: "40.7549"), URLQueryItem(name: "geo[lon]", value: "-73.9840"), ] } comps.queryItems = items guard let url = comps.url else { return nil } var req = URLRequest(url: url) req.setValue("ResyAPI api_key=\"\(resyAPIKey)\"", forHTTPHeaderField: "Authorization") req.setValue("https://resy.com", forHTTPHeaderField: "Referer") req.setValue("application/json", forHTTPHeaderField: "Accept") req.timeoutInterval = 8 guard let (data, _) = try? await URLSession.shared.data(for: req), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let search = json["search"] as? [String: Any], let hits = search["hits"] as? [[String: Any]], let first = hits.first, let venue = first["venue"] as? [String: Any], let urlSlug = venue["url_slug"] as? String else { return nil } // Verify name similarity before returning. let hitName = (venue["name"] as? String) ?? "" guard isSimilarName(hitName, entry.playName) else { return nil } return "https://resy.com/cities/ny/\(urlSlug)" } // MARK: - OpenTable private static func searchOpenTable(entry: SpotEntry) async -> String? { // OpenTable's public search endpoint used by their web app. var comps = URLComponents(string: "https://www.opentable.com/s/")! comps.queryItems = [ URLQueryItem(name: "term", value: entry.playName), URLQueryItem(name: "covers", value: "2"), URLQueryItem(name: "metroId", value: "9"), // 9 = New York City ] guard let url = comps.url else { return nil } var req = URLRequest(url: url) req.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent") req.timeoutInterval = 8 guard let (data, _) = try? await URLSession.shared.data(for: req), let html = String(data: data, encoding: .utf8) else { return nil } // Parse JSON-LD blocks for Restaurant schema. return parseOpenTableURL(from: html, name: entry.playName) } private static func parseOpenTableURL(from html: String, name: String) -> String? { // Find all <script type="application/ld+json"> blocks and look for Restaurant schema. var searchRange = html.startIndex..<html.endIndex while let scriptStart = html.range(of: "application/ld+json", range: searchRange) { guard let blockStart = html.range(of: ">", range: scriptStart.upperBound..<html.endIndex), let blockEnd = html.range(of: "</script>", range: blockStart.upperBound..<html.endIndex) else { break } let jsonText = String(html[blockStart.upperBound..<blockEnd.lowerBound]) if let data = jsonText.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = obj["@type"] as? String, type == "Restaurant", let hitName = obj["name"] as? String, isSimilarName(hitName, name), let urlStr = obj["url"] as? String, urlStr.contains("opentable.com") { return urlStr } searchRange = blockEnd.upperBound..<html.endIndex } return nil } // MARK: - Name similarity /// Returns true when the two names are close enough to be the same venue. /// Strips punctuation/articles and checks that one contains the other. private static func isSimilarName(_ a: String, _ b: String) -> Bool { func normalize(_ s: String) -> String { s.lowercased() .replacingOccurrences(of: #"[^\w\s]"#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\b(the|a|an)\b"#, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespaces) .components(separatedBy: .whitespacesAndNewlines) .filter { !$0.isEmpty } .joined(separator: " ") } let na = normalize(a) let nb = normalize(b) return na.contains(nb) || nb.contains(na) || levenshtein(na, nb) <= 2 } /// Simple Levenshtein distance for short strings. private static func levenshtein(_ s: String, _ t: String) -> Int { let s = Array(s), t = Array(t) let m = s.count, n = t.count if m == 0 { return n } if n == 0 { return m } var dp = Array(0...n) for i in 1...m { var prev = dp[0] dp[0] = i for j in 1...n { let temp = dp[j] dp[j] = s[i-1] == t[j-1] ? prev : min(prev, min(dp[j], dp[j-1])) + 1 prev = temp } } return dp[n] } }`ReservationService` structDefines the `ReservationService` struct.