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

188 lines
7.3 KiB
Swift

import Foundation
enum OllamaUsageParser {
private static let primaryUsageLabels = ["Session usage", "Hourly usage"]
private static let usageLabels = primaryUsageLabels + ["Weekly usage"]
enum ParseFailure: Equatable {
case notLoggedIn
case missingUsageData
}
enum ClassifiedParseResult {
case success(OllamaUsageSnapshot)
case failure(ParseFailure)
}
static func parse(html: String, now: Date = Date()) throws -> OllamaUsageSnapshot {
switch self.parseClassified(html: html, now: now) {
case let .success(snapshot):
return snapshot
case .failure(.notLoggedIn):
throw OllamaUsageError.notLoggedIn
case .failure(.missingUsageData):
throw OllamaUsageError.parseFailed("Missing Ollama usage data.")
}
}
static func parseClassified(html: String, now: Date = Date()) -> ClassifiedParseResult {
let plan = self.parsePlanName(html)
let email = self.parseAccountEmail(html)
let session = self.parseUsageBlock(labels: self.primaryUsageLabels, html: html)
let weekly = self.parseUsageBlock(label: "Weekly usage", html: html)
if session == nil, weekly == nil {
if self.looksSignedOut(html) {
return .failure(.notLoggedIn)
}
return .failure(.missingUsageData)
}
return .success(OllamaUsageSnapshot(
planName: plan,
accountEmail: email,
sessionUsedPercent: session?.usedPercent,
weeklyUsedPercent: weekly?.usedPercent,
sessionResetsAt: session?.resetsAt,
weeklyResetsAt: weekly?.resetsAt,
sessionWindowMinutes: session?.windowMinutes,
updatedAt: now))
}
private struct UsageBlock {
let usedPercent: Double
let resetsAt: Date?
let windowMinutes: Int?
}
private static func parsePlanName(_ html: String) -> String? {
let pattern = #"Cloud Usage\s*</span>\s*<span[^>]*>([^<]+)</span>"#
guard let raw = self.firstCapture(in: html, pattern: pattern, options: [.dotMatchesLineSeparators])
else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func parseAccountEmail(_ html: String) -> String? {
let pattern = #"id=\"header-email\"[^>]*>([^<]+)<"#
guard let raw = self.firstCapture(in: html, pattern: pattern, options: [.dotMatchesLineSeparators])
else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.contains("@") else { return nil }
return trimmed
}
private static func parseUsageBlock(label: String, html: String) -> UsageBlock? {
guard let labelRange = html.range(of: label) else { return nil }
let tail = String(html[labelRange.upperBound...])
let window = self.usageBlockWindow(after: label, in: tail)
guard let usedPercent = self.parsePercent(in: window) else { return nil }
let resetsAt = self.parseISODate(in: window)
let windowMinutes = label == "Session usage" ? 5 * 60 : nil
return UsageBlock(
usedPercent: usedPercent,
resetsAt: resetsAt,
windowMinutes: windowMinutes)
}
private static func parseUsageBlock(labels: [String], html: String) -> UsageBlock? {
for label in labels {
if let parsed = self.parseUsageBlock(label: label, html: html) {
return parsed
}
}
return nil
}
private static func usageBlockWindow(after label: String, in tail: String) -> String {
let maxLength = 4000
let boundary = self.usageLabels
.filter { $0 != label }
.compactMap { tail.range(of: $0)?.lowerBound }
.min()
let bounded = boundary.map { String(tail[..<$0]) } ?? String(tail.prefix(maxLength))
return String(bounded.prefix(maxLength))
}
private static func parsePercent(in text: String) -> Double? {
let usedPattern = #"([0-9]+(?:\.[0-9]+)?)\s*%\s*used"#
if let raw = self.firstCapture(in: text, pattern: usedPattern, options: [.caseInsensitive]) {
return Double(raw)
}
let widthPattern = #"width:\s*([0-9]+(?:\.[0-9]+)?)%"#
if let raw = self.firstCapture(in: text, pattern: widthPattern, options: [.caseInsensitive]) {
return Double(raw)
}
return nil
}
private static func parseISODate(in text: String) -> Date? {
let pattern = #"data-time=\"([^\"]+)\""#
guard let raw = self.firstCapture(in: text, pattern: pattern, options: []) else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: raw) {
return date
}
let fallback = ISO8601DateFormatter()
fallback.formatOptions = [.withInternetDateTime]
return fallback.date(from: raw)
}
private static func firstCapture(
in text: String,
pattern: String,
options: NSRegularExpression.Options) -> String?
{
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return nil }
return Self.performMatch(regex: regex, text: text)
}
private static func performMatch(
regex: NSRegularExpression,
text: String) -> String?
{
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range), match.numberOfRanges > 1,
let captureRange = Range(match.range(at: 1), in: text)
else { return nil }
return String(text[captureRange])
}
private static func looksSignedOut(_ html: String) -> Bool {
let lower = html.lowercased()
let hasSignInHeading = lower.contains("sign in to ollama") || lower.contains("log in to ollama")
let hasAuthRoute = lower.contains("/api/auth/signin") || lower.contains("/auth/signin")
let hasLoginRoute = lower.contains("action=\"/login\"")
|| lower.contains("action='/login'")
|| lower.contains("href=\"/login\"")
|| lower.contains("href='/login'")
|| lower.contains("action=\"/signin\"")
|| lower.contains("action='/signin'")
|| lower.contains("href=\"/signin\"")
|| lower.contains("href='/signin'")
let hasPasswordField = lower.contains("type=\"password\"")
|| lower.contains("type='password'")
|| lower.contains("name=\"password\"")
|| lower.contains("name='password'")
let hasEmailField = lower.contains("type=\"email\"")
|| lower.contains("type='email'")
|| lower.contains("name=\"email\"")
|| lower.contains("name='email'")
let hasAuthForm = lower.contains("<form")
let hasAuthEndpoint = hasAuthRoute || hasLoginRoute
if hasSignInHeading, hasAuthForm, hasEmailField || hasPasswordField || hasAuthEndpoint {
return true
}
if hasAuthForm, hasAuthEndpoint {
return true
}
if hasAuthForm, hasPasswordField, hasEmailField {
return true
}
return false
}
}