Files
steipete--codexbar/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift
T
2026-07-13 12:22:33 +08:00

552 lines
22 KiB
Swift

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:
/// `<script type="application/json" id="client-bootstrap">…</script>`.
/// 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..<cleaned.endIndex, in: cleaned)
guard let match = regex.firstMatch(in: cleaned, options: [], range: range),
match.numberOfRanges >= 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
match.numberOfRanges >= 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("</script>".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.endIndex)
else {
return nil
}
let rawData = data[contentStart..<closeRange.lowerBound]
let trimmed = self.trimASCIIWhitespace(Data(rawData))
return trimmed.isEmpty ? nil : trimmed
}
private static func nextDataJSONData(fromHTML html: String) -> 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.endIndex)
else {
return nil
}
let rawData = data[contentStart..<closeRange.lowerBound]
let trimmed = self.trimASCIIWhitespace(Data(rawData))
return trimmed.isEmpty ? nil : trimmed
}
private static func trimASCIIWhitespace(_ data: Data) -> 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..<end)
}
private static func parseRateWindow(
lines: [String],
match: (String) -> 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
let r = Range(match.range(at: 0), in: text)
else { return nil }
let matched = String(text[r])
let lower = matched.lowercased()
let weekday = switch lower.prefix(3) {
case "mon": 2
case "tue": 3
case "wed": 4
case "thu": 5
case "fri": 6
case "sat": 7
default: 1
}
return WeekdayMatch(matched: matched, weekday: weekday)
}
private static func nextWeekdayDate(weekday: Int, now: Date, calendar: Calendar) -> 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
}
}
}