import Foundation public enum OpenAIDashboardParser { /// Extracts the signed-in email from the embedded `client-bootstrap` JSON payload, if present. /// /// The Codex usage dashboard currently ships a JSON blob in: /// ``. /// WebKit `document.body.innerText` often does not include the email, so we parse it from HTML. public static func parseSignedInEmailFromClientBootstrap(html: String) -> String? { guard let data = self.clientBootstrapJSONData(fromHTML: html) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } // Fast path: common structure. if let dict = json as? [String: Any] { if let session = dict["session"] as? [String: Any], let user = session["user"] as? [String: Any], let email = user["email"] as? String, email.contains("@") { return email.trimmingCharacters(in: .whitespacesAndNewlines) } if let user = dict["user"] as? [String: Any], let email = user["email"] as? String, email.contains("@") { return email.trimmingCharacters(in: .whitespacesAndNewlines) } } // Fallback: BFS scan for an email key/value. var queue: [Any] = [json] var seen = 0 while !queue.isEmpty, seen < 4000 { let cur = queue.removeFirst() seen += 1 if let dict = cur as? [String: Any] { for (k, v) in dict { if k.lowercased() == "email", let email = v as? String, email.contains("@") { return email.trimmingCharacters(in: .whitespacesAndNewlines) } queue.append(v) } } else if let arr = cur as? [Any] { queue.append(contentsOf: arr) } } return nil } /// Extracts the auth status from `client-bootstrap`, if present. /// Expected values include `logged_in` and `logged_out`. public static func parseAuthStatusFromClientBootstrap(html: String) -> String? { guard let data = self.clientBootstrapJSONData(fromHTML: html) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } guard let dict = json as? [String: Any] else { return nil } if let authStatus = dict["authStatus"] as? String, !authStatus.isEmpty { return authStatus.trimmingCharacters(in: .whitespacesAndNewlines) } return nil } public static func parseCodeReviewRemainingPercent(bodyText: String) -> Double? { let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n") for regex in self.codeReviewRegexes { let range = NSRange(cleaned.startIndex..= 2, let r = Range(match.range(at: 1), in: cleaned) else { continue } if let val = Double(cleaned[r]) { return min(100, max(0, val)) } } return nil } public static func parseCreditsRemaining(bodyText: String) -> Double? { let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n") let patterns = [ #"credits\s*remaining[^0-9]*([0-9][0-9.,]*)"#, #"remaining\s*credits[^0-9]*([0-9][0-9.,]*)"#, #"credit\s*balance[^0-9]*([0-9][0-9.,]*)"#, ] for pattern in patterns { if let val = TextParsing.firstNumber(pattern: pattern, text: cleaned) { return val } } return nil } public static func parseRateLimits( bodyText: String, now: Date = .init()) -> (primary: RateWindow?, secondary: RateWindow?) { let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n") let lines = cleaned .split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } let primary = self.parseRateWindow( lines: lines, match: self.isFiveHourLimitLine, windowMinutes: 5 * 60, now: now) let secondary = self.parseRateWindow( lines: lines, match: self.isWeeklyLimitLine, windowMinutes: 7 * 24 * 60, now: now) return (primary, secondary) } public static func parseCodeReviewLimit(bodyText: String, now: Date = .init()) -> RateWindow? { let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n") let lines = cleaned .split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } return self.parseRateWindow( lines: lines, match: self.isCodeReviewLimitLine, windowMinutes: nil, now: now) } public static func parsePlanFromHTML(html: String) -> String? { if let data = self.clientBootstrapJSONData(fromHTML: html), let plan = self.findPlan(in: data) { return plan } if let data = self.nextDataJSONData(fromHTML: html), let plan = self.findPlan(in: data) { return plan } return nil } public static func parseCreditEvents(rows: [[String]]) -> [CreditEvent] { let formatter = self.creditDateFormatter() return rows.compactMap { row in guard row.count >= 3 else { return nil } let dateString = row[0] let service = row[1].trimmingCharacters(in: .whitespacesAndNewlines) let amountString = row[2] guard let date = formatter.date(from: dateString) else { return nil } let creditsUsed = Self.parseCreditsUsed(amountString) return CreditEvent(date: date, service: service, creditsUsed: creditsUsed) } .sorted { $0.date > $1.date } } private static func parseCreditsUsed(_ text: String) -> Double { guard let raw = self.firstNumberToken(in: text) else { return 0 } let token = raw .replacingOccurrences(of: "\u{00A0}", with: "") .replacingOccurrences(of: "\u{202F}", with: "") .replacingOccurrences(of: " ", with: "") let hasComma = token.contains(",") let hasDot = token.contains(".") if hasComma, hasDot { return TextParsing.firstNumber(pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, text: token) ?? 0 } if hasComma { if self.usesLocalizedDecimalCommaCreditLabel(text) { return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 } if token.range(of: #"^\d{1,3}(,\d{3})+$"#, options: .regularExpression) != nil { return Double(token.replacingOccurrences(of: ",", with: "")) ?? 0 } return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 } return Double(token) ?? 0 } private static func usesLocalizedDecimalCommaCreditLabel(_ text: String) -> Bool { text .lowercased() .contains("crédit") } private static func firstNumberToken(in text: String) -> String? { guard let regex = try? NSRegularExpression( pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, options: []) else { return nil } let range = NSRange(text.startIndex..= 2, let tokenRange = Range(match.range(at: 1), in: text) else { return nil } return String(text[tokenRange]) } // MARK: - Private private static let codeReviewRegexes: [NSRegularExpression] = { let patterns = [ #"Code\s*review[^0-9%]*([0-9]{1,3})%\s*remaining"#, #"Core\s*review[^0-9%]*([0-9]{1,3})%\s*remaining"#, ] return patterns.compactMap { pattern in try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) } }() private static let creditDateFormatterKey = "OpenAIDashboardParser.creditDateFormatter" private static let clientBootstrapNeedle = Data("id=\"client-bootstrap\"".utf8) private static let nextDataNeedle = Data("id=\"__NEXT_DATA__\"".utf8) private static let scriptCloseNeedle = Data("".utf8) private static func creditDateFormatter() -> DateFormatter { let threadDict = Thread.current.threadDictionary if let cached = threadDict[self.creditDateFormatterKey] as? DateFormatter { return cached } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "MMM d, yyyy" threadDict[self.creditDateFormatterKey] = formatter return formatter } private static func clientBootstrapJSONData(fromHTML html: String) -> Data? { let data = Data(html.utf8) guard let idRange = data.range(of: self.clientBootstrapNeedle) else { return nil } guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil } let contentStart = data.index(after: openTagEnd) guard let closeRange = data.range( of: self.scriptCloseNeedle, options: [], in: contentStart.. Data? { let data = Data(html.utf8) guard let idRange = data.range(of: self.nextDataNeedle) else { return nil } guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil } let contentStart = data.index(after: openTagEnd) guard let closeRange = data.range( of: self.scriptCloseNeedle, options: [], in: contentStart.. Data { guard !data.isEmpty else { return data } var start = data.startIndex var end = data.endIndex while start < end, data[start].isASCIIWhitespace { start = data.index(after: start) } while end > start { let prev = data.index(before: end) if data[prev].isASCIIWhitespace { end = prev } else { break } } return data.subdata(in: start.. Bool, windowMinutes: Int?, now: Date) -> RateWindow? { for idx in lines.indices where match(lines[idx]) { let end = min(lines.count - 1, idx + 5) let windowLines = Array(lines[idx...end]) var percentValue: Double? var isRemaining = true for line in windowLines { if let percent = self.parsePercent(from: line) { percentValue = percent.value isRemaining = percent.isRemaining break } } guard let percentValue else { continue } let usedPercent = isRemaining ? max(0, min(100, 100 - percentValue)) : max(0, min(100, percentValue)) let resetLine = windowLines.first { $0.localizedCaseInsensitiveContains("reset") } let resetDescription = resetLine?.trimmingCharacters(in: .whitespacesAndNewlines) let resetsAt = resetLine.flatMap { self.parseResetDate(from: $0, now: now) } let fallbackDescription = resetsAt.map { UsageFormatter.resetDescription(from: $0) } return RateWindow( usedPercent: usedPercent, windowMinutes: windowMinutes, resetsAt: resetsAt, resetDescription: resetDescription ?? fallbackDescription) } return nil } private static func parsePercent(from line: String) -> (value: Double, isRemaining: Bool)? { guard let percent = TextParsing.firstNumber(pattern: #"([0-9]{1,3})\s*%"#, text: line) else { return nil } let lower = line.lowercased() let isRemaining = lower.contains("remaining") || lower.contains("left") let isUsed = lower.contains("used") || lower.contains("spent") || lower.contains("consumed") if isUsed { return (percent, false) } if isRemaining { return (percent, true) } return (percent, true) } private static func isFiveHourLimitLine(_ line: String) -> Bool { let lower = line.lowercased() if lower.contains("5h") { return true } if lower.range(of: #"\b5\s*h\b"#, options: .regularExpression) != nil { return true } if lower.contains("5-hour") { return true } if lower.contains("5 hour") { return true } return false } private static func isWeeklyLimitLine(_ line: String) -> Bool { let lower = line.lowercased() if lower.contains("weekly") { return true } if lower.contains("7-day") { return true } if lower.contains("7 day") { return true } if lower.contains("7d") { return true } if lower.range(of: #"\b7\s*d\b"#, options: .regularExpression) != nil { return true } return false } private static func isCodeReviewLimitLine(_ line: String) -> Bool { let lower = line.lowercased() guard lower.contains("code review") || lower.contains("core review") else { return false } if lower.contains("github code review") { return false } return true } private static func parseResetDate(from line: String, now: Date) -> Date? { var raw = line.trimmingCharacters(in: .whitespacesAndNewlines) raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression) raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive) raw = raw.replacingOccurrences(of: " on ", with: " ", options: .caseInsensitive) raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) let calendar = Calendar(identifier: .gregorian) let monthDayFormatter = DateFormatter() monthDayFormatter.locale = Locale(identifier: "en_US_POSIX") monthDayFormatter.timeZone = TimeZone.current monthDayFormatter.dateFormat = "MMM d" var candidate = raw let lower = candidate.lowercased() var usedRelativeDay = false if lower.contains("today") { usedRelativeDay = true let dateText = monthDayFormatter.string(from: now) candidate = candidate.replacingOccurrences(of: "today", with: dateText, options: .caseInsensitive) } else if lower.contains("tomorrow") { usedRelativeDay = true if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) { let dateText = monthDayFormatter.string(from: tomorrow) candidate = candidate.replacingOccurrences(of: "tomorrow", with: dateText, options: .caseInsensitive) } } if let weekdayMatch = self.weekdayMatch(in: candidate) { usedRelativeDay = true let target = self.nextWeekdayDate(weekday: weekdayMatch.weekday, now: now, calendar: calendar) let dateText = monthDayFormatter.string(from: target) candidate = candidate.replacingOccurrences( of: weekdayMatch.matched, with: dateText, options: .caseInsensitive) } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current formatter.defaultDate = now let formats = [ "MMM d h:mma", "MMM d, h:mma", "MMM d h:mm a", "MMM d, h:mm a", "MMM d HH:mm", "MMM d, HH:mm", "MMM d", "M/d h:mma", "M/d h:mm a", "M/d/yyyy h:mm a", "M/d/yy h:mm a", "M/d", "yyyy-MM-dd HH:mm", "yyyy-MM-dd h:mm a", "yyyy-MM-dd", ] for format in formats { formatter.dateFormat = format if let date = formatter.date(from: candidate) { if usedRelativeDay, date < now { if lower.contains("today"), let bumped = calendar.date(byAdding: .day, value: 1, to: date) { return bumped } if let bumped = calendar.date(byAdding: .day, value: 7, to: date) { return bumped } } return date } } return nil } private struct WeekdayMatch { let matched: String let weekday: Int } private static func weekdayMatch(in text: String) -> WeekdayMatch? { // The optional "(day)?" suffix requires each alternative to be long enough that // adding "day" reproduces the full weekday name. "wed"+"day"="wedday" and // "sat"+"day"="satday", so the longer prefixes "wednes" and "satur" are needed // to cover the full "Wednesday" and "Saturday" forms — mirroring how the existing // "tue|tues" and "thu|thur|thurs" alternatives layer up to "tuesday"/"thursday". let pattern = #"\b(mon|tue|tues|wed|wednes|thu|thur|thurs|fri|sat|satur|sun)(day)?\b"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(text.startIndex.. Date { let currentWeekday = calendar.component(.weekday, from: now) var delta = weekday - currentWeekday if delta < 0 { delta += 7 } guard let next = calendar.date(byAdding: .day, value: delta, to: calendar.startOfDay(for: now)) else { return now } return next } private static func findPlan(in data: Data) -> String? { guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } return self.findPlan(in: json) } private static func findPlan(in json: Any) -> String? { var queue: [Any] = [json] var seen = 0 while !queue.isEmpty, seen < 6000 { let cur = queue.removeFirst() seen += 1 if let dict = cur as? [String: Any] { for (k, v) in dict { if let plan = self.planCandidate(forKey: k, value: v) { return plan } queue.append(v) } } else if let arr = cur as? [Any] { queue.append(contentsOf: arr) } } return nil } private static func planCandidate(forKey key: String, value: Any) -> String? { guard self.isPlanKey(key) else { return nil } if let str = value as? String { return self.normalizePlanValue(str) } if let dict = value as? [String: Any] { if let name = dict["name"] as? String, let plan = self.normalizePlanValue(name) { return plan } if let display = dict["displayName"] as? String, let plan = self.normalizePlanValue(display) { return plan } if let tier = dict["tier"] as? String, let plan = self.normalizePlanValue(tier) { return plan } } return nil } private static func isPlanKey(_ key: String) -> Bool { let lower = key.lowercased() return lower.contains("plan") || lower.contains("tier") || lower.contains("subscription") } private static func normalizePlanValue(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let lower = trimmed.lowercased() let allowed = [ "free", "plus", "pro", "team", "enterprise", "business", "edu", "education", "gov", "premium", "essential", ] guard allowed.contains(where: { lower.contains($0) }) else { return nil } return CodexPlanFormatting.displayName(trimmed) ?? UsageFormatter.cleanPlanName(trimmed) } } extension UInt8 { fileprivate var isASCIIWhitespace: Bool { switch self { case 9, 10, 13, 32: true default: false } } }