| 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: "'", with: "'")
.replacingOccurrences(of: "'", with: "'")
.replacingOccurrences(of: "'", with: "'")
.replacingOccurrences(of: """, with: "\"")
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", 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` struct | Defines the `ReservationService` struct. |