552 lines
22 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|