Files
2026-07-13 12:22:33 +08:00

222 lines
9.1 KiB
Swift

import Foundation
enum AmpUsageParser {
static func parse(html: String, now: Date = Date()) throws -> AmpUsageSnapshot {
guard let usage = self.parseFreeTierUsage(html) else {
if self.looksSignedOut(html) {
throw AmpUsageError.notLoggedIn
}
throw AmpUsageError.parseFailed("Missing Amp Free usage data.")
}
return AmpUsageSnapshot(
freeQuota: usage.quota,
freeUsed: usage.used,
hourlyReplenishment: usage.hourlyReplenishment,
windowHours: usage.windowHours,
updatedAt: now)
}
static func parse(displayText: String, now: Date = Date()) throws -> AmpUsageSnapshot {
let text = TextParsing.stripANSICodes(displayText)
let identityPattern = #"(?im)^\s*Signed in as\s+([^\s(]+)(?:\s+\(([^\r\n)]+)\))?\s*$"#
let identity = self.captures(in: text, pattern: identityPattern)
if identity == nil, self.looksSignedOut(text) {
throw AmpUsageError.notLoggedIn
}
let amountPattern = #"([0-9][0-9,]*(?:\.[0-9]+)?)"#
let freePattern = #"(?im)^\s*Amp Free:\s*\$?"# + amountPattern +
#"\s*/\s*\$?"# + amountPattern +
#"\s+remaining(?:\s*\(replenishes\s*\+\$?"# + amountPattern + #"\s*/\s*hour\))?"#
let freePercentPattern = #"(?im)^\s*Amp Free:\s*"# + amountPattern +
#"\s*%\s+remaining(?:\s+today)?(?:\s*\(resets\s+daily\))?"#
let creditsPattern = #"(?im)^\s*Individual credits:\s*\$?"# + amountPattern + #"\s+remaining"#
let individualCredits = self.captures(in: text, pattern: creditsPattern)?.first
.flatMap(self.number(from:))
let workspacePattern = #"(?im)^\s*Workspace\s+(.+?):\s*\$?"# + amountPattern + #"\s+remaining"#
let workspaceBalances: [AmpWorkspaceBalance] = self.allCaptures(
in: text,
pattern: workspacePattern).compactMap { captures -> AmpWorkspaceBalance? in
guard captures.count == 2,
let name = self.nonEmpty(captures[0]),
let remaining = self.number(from: captures[1])
else { return nil }
return AmpWorkspaceBalance(name: name, remaining: remaining)
}
let freeUsage: FreeTierUsage? = {
guard let free = self.captures(in: text, pattern: freePattern),
let remaining = self.number(from: free[0]),
let quota = self.number(from: free[1])
else { return nil }
let hourlyReplenishment = self.number(from: free[2]) ?? 0
let windowHours = hourlyReplenishment > 0
? max(1, (quota / hourlyReplenishment).rounded())
: nil
return FreeTierUsage(
quota: quota,
used: max(0, quota - remaining),
hourlyReplenishment: hourlyReplenishment,
windowHours: windowHours,
resetDescription: nil)
}()
let freePercentUsage: FreeTierUsage? = {
guard let remainingText = self.captures(in: text, pattern: freePercentPattern)?.first,
let remaining = self.number(from: remainingText)
else { return nil }
let clampedRemaining = min(100, max(0, remaining))
return FreeTierUsage(
quota: 100,
used: 100 - clampedRemaining,
hourlyReplenishment: 0,
windowHours: 24,
resetDescription: "resets daily")
}()
let resolvedFreeUsage = freeUsage ?? freePercentUsage
guard resolvedFreeUsage != nil || individualCredits != nil || !workspaceBalances.isEmpty else {
throw AmpUsageError.parseFailed("Missing Amp usage data.")
}
return AmpUsageSnapshot(
freeQuota: resolvedFreeUsage?.quota,
freeUsed: resolvedFreeUsage?.used,
hourlyReplenishment: resolvedFreeUsage?.hourlyReplenishment,
windowHours: resolvedFreeUsage?.windowHours,
individualCredits: individualCredits,
workspaceBalances: workspaceBalances,
accountEmail: self.nonEmpty(identity?[0]),
accountOrganization: self.nonEmpty(identity?[1]),
updatedAt: now,
freeResetDescription: resolvedFreeUsage?.resetDescription)
}
private struct FreeTierUsage {
let quota: Double
let used: Double
let hourlyReplenishment: Double
let windowHours: Double?
let resetDescription: String?
}
private static func parseFreeTierUsage(_ html: String) -> FreeTierUsage? {
let tokens = ["freeTierUsage", "getFreeTierUsage"]
for token in tokens {
if let object = self.extractObject(named: token, in: html),
let usage = self.parseFreeTierUsageObject(object)
{
return usage
}
}
return nil
}
private static func parseFreeTierUsageObject(_ object: String) -> FreeTierUsage? {
guard let quota = self.number(for: "quota", in: object),
let used = self.number(for: "used", in: object),
let hourly = self.number(for: "hourlyReplenishment", in: object)
else { return nil }
let windowHours = self.number(for: "windowHours", in: object)
return FreeTierUsage(
quota: quota,
used: used,
hourlyReplenishment: hourly,
windowHours: windowHours,
resetDescription: nil)
}
private static func extractObject(named token: String, in text: String) -> String? {
guard let tokenRange = text.range(of: token) else { return nil }
guard let braceIndex = text[tokenRange.upperBound...].firstIndex(of: "{") else { return nil }
var depth = 0
var inString = false
var isEscaped = false
var index = braceIndex
while index < text.endIndex {
let char = text[index]
if inString {
if isEscaped {
isEscaped = false
} else if char == "\\" {
isEscaped = true
} else if char == "\"" {
inString = false
}
} else {
if char == "\"" {
inString = true
} else if char == "{" {
depth += 1
} else if char == "}" {
depth -= 1
if depth == 0 {
return String(text[braceIndex...index])
}
}
}
index = text.index(after: index)
}
return nil
}
private static func number(for key: String, in text: String) -> Double? {
let pattern = "\\b\(NSRegularExpression.escapedPattern(for: key))\\b\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
match.numberOfRanges > 1,
let valueRange = Range(match.range(at: 1), in: text)
else { return nil }
return Double(text[valueRange])
}
private static func captures(in text: String, pattern: String) -> [String]? {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range) else { return nil }
return self.captures(in: text, match: match)
}
private static func allCaptures(in text: String, pattern: String) -> [[String]] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return regex.matches(in: text, options: [], range: range).map { match in
self.captures(in: text, match: match)
}
}
private static func captures(in text: String, match: NSTextCheckingResult) -> [String] {
(1..<match.numberOfRanges).map { index in
let captureRange = match.range(at: index)
guard captureRange.location != NSNotFound,
let range = Range(captureRange, in: text)
else { return "" }
return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private static func number(from text: String) -> Double? {
Double(text.replacingOccurrences(of: ",", with: ""))
}
private static func nonEmpty(_ text: String?) -> String? {
guard let text, !text.isEmpty else { return nil }
return text
}
private static func looksSignedOut(_ html: String) -> Bool {
let lower = html.lowercased()
if lower.contains("sign in") || lower.contains("log in") || lower.contains("login") {
return true
}
if lower.contains("/login") || lower.contains("ampcode.com/login") {
return true
}
return false
}
}