| enum PhoneExtractor {
private static let apiKey = Secrets.anthropicAPIKey
private static let apiURL = URL(string: "https://api.anthropic.com/v1/messages")!
private static let model = "claude-haiku-4-5-20251001"
// MARK: - Public entry point
/// Extract a phone number and website URL for an entry that has a confirmationLink.
/// Calls `completion` on the main thread with (phone, website); empty string = not found.
static func extract(for entry: SpotEntry, completion: @escaping (String, String) -> Void) {
let link = entry.confirmationLink
guard !link.isEmpty else { completion("", ""); return }
if isLocalFile(link) {
extractFromFile(link, completion: completion)
} else if link.hasPrefix("http") {
extractFromURL(link, completion: completion)
} else {
completion("", "")
}
}
// MARK: - File path
private static func isLocalFile(_ link: String) -> Bool {
(link.hasSuffix(".eml") || link.hasSuffix(".pdf")) && !link.hasPrefix("http")
}
private static func extractFromFile(_ filename: String, completion: @escaping (String, String) -> Void) {
DispatchQueue.global(qos: .utility).async {
guard let resolvedPath = EmlParser.resolveActualPath(for: filename) else {
DispatchQueue.main.async { completion("", "") }
return
}
if filename.hasSuffix(".pdf") {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: resolvedPath)) else {
DispatchQueue.main.async { completion("", "") }
return
}
callWithPDF(data, completion: completion)
} else {
// .eml — extract HTML body then strip tags to get plain text
let html = EmlParser.extractHTMLBody(from: resolvedPath) ?? ""
var plain = html.replacingOccurrences(of: "<[^>]+>", with: " ",
options: .regularExpression)
plain = plain.replacingOccurrences(of: "\\s+", with: " ",
options: .regularExpression)
callWithText(String(plain.prefix(5000)), completion: completion)
}
}
}
// MARK: - URL
private static func extractFromURL(_ urlString: String, completion: @escaping (String, String) -> Void) {
guard let url = URL(string: urlString) else { completion("", ""); return }
var request = URLRequest(url: url)
request.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
request.timeoutInterval = 15
URLSession.shared.dataTask(with: request) { data, _, _ in
guard let data, let html = String(data: data, encoding: .utf8) else {
DispatchQueue.main.async { completion("", "") }
return
}
// Strip HTML tags to plain text
var plain = html.replacingOccurrences(of: "<[^>]+>",
with: " ",
options: .regularExpression)
plain = plain.replacingOccurrences(of: "\\s+", with: " ",
options: .regularExpression)
let trimmed = String(plain.prefix(5000))
callWithText(trimmed, completion: completion)
}.resume()
}
// MARK: - Claude calls
private static func callWithText(_ text: String, completion: @escaping (String, String) -> Void) {
let prompt = """
Extract the phone number and official website URL of the venue, theater, or \
restaurant from the following text. Return ONLY a JSON object with two keys: \
"phone" (phone number string or empty string) and "website" (official website \
URL string or empty string). No other text.
\(text)
"""
call(body: textBody(prompt), completion: completion)
}
private static func callWithPDF(_ data: Data, completion: @escaping (String, String) -> Void) {
let b64 = data.base64EncodedString()
let prompt = """
Extract the phone number and official website URL of the venue, theater, or \
restaurant from this document. Return ONLY a JSON object with two keys: \
"phone" (phone number string or empty string) and "website" (official website \
URL string or empty string). No other text.
"""
let body: [String: Any] = [
"model": model,
"max_tokens": 60,
"messages": [[
"role": "user",
"content": [
["type": "document",
"source": ["type": "base64",
"media_type": "application/pdf",
"data": b64]],
["type": "text", "text": prompt]
]
]]
]
call(body: body, completion: completion)
}
private static func textBody(_ prompt: String) -> [String: Any] {
["model": model,
"max_tokens": 120,
"messages": [["role": "user", "content": prompt]]]
}
private static func call(body: [String: Any], completion: @escaping (String, String) -> Void) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else {
DispatchQueue.main.async { completion("", "") }
return
}
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
request.httpBody = jsonData
request.timeoutInterval = 30
URLSession(configuration: .ephemeral).dataTask(with: request) { data, _, _ in
var phone = ""
var website = ""
if let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let content = json["content"] as? [[String: Any]],
let text = content.first?["text"] as? String {
let cleaned = text
.replacingOccurrences(of: "```json", with: "")
.replacingOccurrences(of: "```", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if let parsed = try? JSONSerialization.jsonObject(with: Data(cleaned.utf8)) as? [String: Any] {
phone = parsed["phone"] as? String ?? ""
website = parsed["website"] as? String ?? ""
}
}
DispatchQueue.main.async { completion(phone, website) }
}.resume()
}
} | `PhoneExtractor` enum | Defines the `PhoneExtractor` enum. |