1093 lines
45 KiB
Swift
1093 lines
45 KiB
Swift
#if os(macOS)
|
||
import CoreGraphics
|
||
import Foundation
|
||
import WebKit
|
||
|
||
@MainActor
|
||
public struct OpenAIDashboardFetcher {
|
||
public enum FetchError: LocalizedError {
|
||
case loginRequired
|
||
case noDashboardData(body: String)
|
||
|
||
public var errorDescription: String? {
|
||
switch self {
|
||
case .loginRequired:
|
||
"OpenAI web access requires login."
|
||
case let .noDashboardData(body):
|
||
"OpenAI dashboard data not found. Body sample: \(body.prefix(200))"
|
||
}
|
||
}
|
||
}
|
||
|
||
private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")!
|
||
private nonisolated static let dashboardAcceptLanguage = "en-US,en;q=0.9"
|
||
private nonisolated static let dashboardUsageAPIURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
|
||
|
||
public init() {}
|
||
|
||
public nonisolated static func offscreenHostWindowFrame(for visibleFrame: CGRect) -> CGRect {
|
||
let width: CGFloat = min(1200, visibleFrame.width)
|
||
let height: CGFloat = min(1600, visibleFrame.height)
|
||
|
||
// Keep the WebView "visible" for WebKit hydration, but never show it to the user.
|
||
// Place the window almost entirely off-screen; leave only a 1×1 px intersection.
|
||
let sliver: CGFloat = 1
|
||
return CGRect(
|
||
x: visibleFrame.maxX - sliver,
|
||
y: visibleFrame.maxY - sliver,
|
||
width: width,
|
||
height: height)
|
||
}
|
||
|
||
public nonisolated static func offscreenHostAlphaValue() -> CGFloat {
|
||
// Must be > 0 or WebKit can throttle hydration/timers on the Codex usage SPA.
|
||
0.001
|
||
}
|
||
|
||
private struct DashboardSnapshotComponents {
|
||
let signedInEmail: String?
|
||
let scrape: ScrapeResult
|
||
let codeReview: Double?
|
||
let codeReviewLimit: RateWindow?
|
||
let events: [CreditEvent]
|
||
let breakdown: [OpenAIDashboardDailyBreakdown]
|
||
let usageBreakdown: [OpenAIDashboardDailyBreakdown]
|
||
let rateLimits: (primary: RateWindow?, secondary: RateWindow?)
|
||
let extraRateWindows: [NamedRateWindow]
|
||
let creditsRemaining: Double?
|
||
let codexCreditLimit: CodexCreditLimitSnapshot?
|
||
let accountPlan: String?
|
||
}
|
||
|
||
private struct DashboardScrapeData {
|
||
let signedInEmail: String?
|
||
let codeReview: Double?
|
||
let codeReviewLimit: RateWindow?
|
||
let events: [CreditEvent]
|
||
let breakdown: [OpenAIDashboardDailyBreakdown]
|
||
let usageBreakdown: [OpenAIDashboardDailyBreakdown]
|
||
let rateLimits: (primary: RateWindow?, secondary: RateWindow?)
|
||
let extraRateWindows: [NamedRateWindow]
|
||
let creditsRemaining: Double?
|
||
let codexCreditLimit: CodexCreditLimitSnapshot?
|
||
let accountPlan: String?
|
||
let hasDashboardPageSignal: Bool
|
||
let hasReturnableData: Bool
|
||
}
|
||
|
||
private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents)
|
||
-> OpenAIDashboardSnapshot
|
||
{
|
||
OpenAIDashboardSnapshot(
|
||
signedInEmail: components.signedInEmail,
|
||
codeReviewRemainingPercent: components.codeReview,
|
||
codeReviewLimit: components.codeReviewLimit,
|
||
creditEvents: components.events,
|
||
dailyBreakdown: components.breakdown,
|
||
usageBreakdown: components.usageBreakdown,
|
||
creditsPurchaseURL: components.scrape.creditsPurchaseURL,
|
||
primaryLimit: components.rateLimits.primary,
|
||
secondaryLimit: components.rateLimits.secondary,
|
||
extraRateWindows: components.extraRateWindows.isEmpty ? nil : components.extraRateWindows,
|
||
creditsRemaining: components.creditsRemaining,
|
||
codexCreditLimit: components.codexCreditLimit,
|
||
accountPlan: components.accountPlan,
|
||
updatedAt: Date())
|
||
}
|
||
|
||
private static func parseDashboardScrape(
|
||
_ scrape: ScrapeResult,
|
||
apiData: DashboardAPIData?,
|
||
verifiedSignedInEmail: String?) -> DashboardScrapeData
|
||
{
|
||
let bodyText = scrape.bodyText ?? ""
|
||
let codeReview = OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText)
|
||
let events = OpenAIDashboardParser.parseCreditEvents(rows: scrape.rows)
|
||
let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30)
|
||
let usageBreakdown = scrape.usageBreakdown
|
||
let parsedRateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText)
|
||
let rateLimits = (
|
||
primary: apiData?.primaryLimit ?? parsedRateLimits.primary,
|
||
secondary: apiData?.secondaryLimit ?? parsedRateLimits.secondary)
|
||
let codeReviewLimit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: bodyText)
|
||
let parsedCreditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText)
|
||
let creditsRemaining = apiData?.creditsRemaining ?? parsedCreditsRemaining
|
||
let codexCreditLimit = apiData?.codexCreditLimit
|
||
let accountPlan = scrape.accountPlan ?? apiData?.accountPlan
|
||
let hasParsedUsageLimits = parsedRateLimits.primary != nil || parsedRateLimits.secondary != nil
|
||
let hasUsageLimits = rateLimits.primary != nil || rateLimits.secondary != nil
|
||
let hasDashboardPageData = self.hasReturnableDashboardData(.init(
|
||
codeReview: codeReview,
|
||
events: events,
|
||
usageBreakdown: usageBreakdown,
|
||
hasUsageLimits: hasParsedUsageLimits,
|
||
creditsRemaining: parsedCreditsRemaining,
|
||
codexCreditLimit: nil))
|
||
let hasReturnableData = self.hasReturnableDashboardData(.init(
|
||
codeReview: codeReview,
|
||
events: events,
|
||
usageBreakdown: usageBreakdown,
|
||
hasUsageLimits: hasUsageLimits,
|
||
creditsRemaining: creditsRemaining,
|
||
codexCreditLimit: codexCreditLimit))
|
||
|
||
// Codex `additional_rate_limits` (e.g. Codex Spark) only ship over the JSON usage API, so the
|
||
// dashboard HTML scrape never contributes here; we just forward what the apiData decoded.
|
||
let extraRateWindows = apiData?.extraRateWindows ?? []
|
||
return DashboardScrapeData(
|
||
signedInEmail: self.firstNonEmpty(scrape.signedInEmail, verifiedSignedInEmail),
|
||
codeReview: codeReview,
|
||
codeReviewLimit: codeReviewLimit,
|
||
events: events,
|
||
breakdown: breakdown,
|
||
usageBreakdown: usageBreakdown,
|
||
rateLimits: rateLimits,
|
||
extraRateWindows: extraRateWindows,
|
||
creditsRemaining: creditsRemaining,
|
||
codexCreditLimit: codexCreditLimit,
|
||
accountPlan: accountPlan,
|
||
hasDashboardPageSignal: self.hasAnyDashboardSignal(
|
||
hasReturnableData: hasDashboardPageData,
|
||
creditsHeaderPresent: scrape.creditsHeaderPresent),
|
||
hasReturnableData: hasReturnableData)
|
||
}
|
||
|
||
struct DashboardAPIData {
|
||
let primaryLimit: RateWindow?
|
||
let secondaryLimit: RateWindow?
|
||
let extraRateWindows: [NamedRateWindow]
|
||
let creditsRemaining: Double?
|
||
let codexCreditLimit: CodexCreditLimitSnapshot?
|
||
let accountPlan: String?
|
||
|
||
var hasUsageData: Bool {
|
||
self.primaryLimit != nil
|
||
|| self.secondaryLimit != nil
|
||
|| self.creditsRemaining != nil
|
||
|| self.codexCreditLimit != nil
|
||
}
|
||
}
|
||
|
||
public struct ProbeResult: Sendable {
|
||
public let href: String?
|
||
public let loginRequired: Bool
|
||
public let workspacePicker: Bool
|
||
public let cloudflareInterstitial: Bool
|
||
public let signedInEmail: String?
|
||
public let bodyText: String?
|
||
|
||
public init(
|
||
href: String?,
|
||
loginRequired: Bool,
|
||
workspacePicker: Bool,
|
||
cloudflareInterstitial: Bool,
|
||
signedInEmail: String?,
|
||
bodyText: String?)
|
||
{
|
||
self.href = href
|
||
self.loginRequired = loginRequired
|
||
self.workspacePicker = workspacePicker
|
||
self.cloudflareInterstitial = cloudflareInterstitial
|
||
self.signedInEmail = signedInEmail
|
||
self.bodyText = bodyText
|
||
}
|
||
}
|
||
|
||
nonisolated static func shouldPreserveLoadedPageAfterProbe(_ result: ProbeResult) -> Bool {
|
||
guard !result.loginRequired, !result.workspacePicker, !result.cloudflareInterstitial else {
|
||
return false
|
||
}
|
||
|
||
guard self.isUsageRoute(result.href) else { return false }
|
||
|
||
guard let signedInEmail = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!signedInEmail.isEmpty
|
||
else {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
private nonisolated static func sleepForDashboardPoll(_ duration: Duration) async throws {
|
||
try? await Task.sleep(for: duration)
|
||
try Task.checkCancellation()
|
||
}
|
||
|
||
public func loadLatestDashboard(
|
||
accountEmail: String?,
|
||
cacheScope: CookieHeaderCache.Scope? = nil,
|
||
logger: ((String) -> Void)? = nil,
|
||
debugDumpHTML: Bool = false,
|
||
allowNavigationTimeoutRetry: Bool = true,
|
||
timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot
|
||
{
|
||
let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail, scope: cacheScope)
|
||
return try await self.loadLatestDashboard(
|
||
websiteDataStore: store,
|
||
logger: logger,
|
||
debugDumpHTML: debugDumpHTML,
|
||
allowNavigationTimeoutRetry: allowNavigationTimeoutRetry,
|
||
timeout: timeout)
|
||
}
|
||
|
||
public func loadLatestDashboard(
|
||
websiteDataStore: WKWebsiteDataStore,
|
||
logger: ((String) -> Void)? = nil,
|
||
debugDumpHTML: Bool = false,
|
||
allowNavigationTimeoutRetry: Bool = true,
|
||
timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot
|
||
{
|
||
let deadline = Self.deadline(startingAt: Date(), timeout: timeout)
|
||
let preflight = try await Self.fetchDashboardAPIPreflight(
|
||
websiteDataStore: websiteDataStore,
|
||
deadline: deadline,
|
||
logger: { logger?($0) })
|
||
try Task.checkCancellation()
|
||
let (apiData, verifiedSignedInEmail) = (preflight.apiData, preflight.verifiedSignedInEmail)
|
||
let remainingTimeout = try Self.requiredRemainingTimeout(until: deadline)
|
||
|
||
let lease = try await self.makeWebView(
|
||
websiteDataStore: websiteDataStore,
|
||
logger: logger,
|
||
timeout: remainingTimeout,
|
||
allowNavigationTimeoutRetry: allowNavigationTimeoutRetry)
|
||
defer { lease.release() }
|
||
let webView = lease.webView
|
||
let log = lease.log
|
||
|
||
var lastBody: String?
|
||
var lastHref: String?
|
||
var lastFlags: (loginRequired: Bool, workspacePicker: Bool, cloudflare: Bool)?
|
||
var codeReviewFirstSeenAt: Date?
|
||
var anyDashboardSignalAt: Date?
|
||
var creditsHeaderVisibleAt: Date?
|
||
var usageBreakdownErrorFirstSeenAt: Date?
|
||
var lastUsageBreakdownDebug: String?
|
||
var lastUsageBreakdownError: String?
|
||
var lastCreditsPurchaseURL: String?
|
||
while Date() < deadline {
|
||
try Task.checkCancellation()
|
||
let scrape = try await self.scrape(webView: webView)
|
||
lastBody = scrape.bodyText ?? lastBody
|
||
|
||
if scrape.href != lastHref
|
||
|| lastFlags?.loginRequired != scrape.loginRequired
|
||
|| lastFlags?.workspacePicker != scrape.workspacePicker
|
||
|| lastFlags?.cloudflare != scrape.cloudflareInterstitial
|
||
{
|
||
lastHref = scrape.href
|
||
lastFlags = (scrape.loginRequired, scrape.workspacePicker, scrape.cloudflareInterstitial)
|
||
let href = scrape.href ?? "nil"
|
||
log(
|
||
"href=\(href) login=\(scrape.loginRequired) " +
|
||
"workspace=\(scrape.workspacePicker) cloudflare=\(scrape.cloudflareInterstitial)")
|
||
}
|
||
|
||
if scrape.workspacePicker {
|
||
try await Self.sleepForDashboardPoll(.milliseconds(500))
|
||
continue
|
||
}
|
||
|
||
try await self.handleBlockingScrapeState(
|
||
scrape,
|
||
webView: webView,
|
||
debugDumpHTML: debugDumpHTML,
|
||
logger: log)
|
||
|
||
// The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL.
|
||
if Self.shouldReloadUsageRoute(scrape) {
|
||
_ = webView.load(Self.usageURLRequest(url: self.usageURL))
|
||
try await Self.sleepForDashboardPoll(.milliseconds(500))
|
||
continue
|
||
}
|
||
|
||
let dashboardData = Self.parseDashboardScrape(
|
||
scrape,
|
||
apiData: apiData,
|
||
verifiedSignedInEmail: verifiedSignedInEmail)
|
||
let codeReview = dashboardData.codeReview
|
||
let events = dashboardData.events
|
||
let usageBreakdown = dashboardData.usageBreakdown
|
||
let hasDashboardPageSignal = dashboardData.hasDashboardPageSignal
|
||
let hasReturnableData = dashboardData.hasReturnableData
|
||
|
||
if codeReview != nil, codeReviewFirstSeenAt == nil { codeReviewFirstSeenAt = Date() }
|
||
if anyDashboardSignalAt == nil, hasDashboardPageSignal { anyDashboardSignalAt = Date() }
|
||
if codeReview != nil, usageBreakdown.isEmpty,
|
||
let debug = scrape.usageBreakdownDebug, !debug.isEmpty,
|
||
debug != lastUsageBreakdownDebug
|
||
{
|
||
lastUsageBreakdownDebug = debug
|
||
log("usage breakdown debug: \(debug)")
|
||
}
|
||
Self.updateUsageBreakdownErrorState(
|
||
usageBreakdown: usageBreakdown,
|
||
error: scrape.usageBreakdownError,
|
||
firstSeenAt: &usageBreakdownErrorFirstSeenAt,
|
||
lastError: &lastUsageBreakdownError,
|
||
logger: log)
|
||
if let purchaseURL = scrape.creditsPurchaseURL, purchaseURL != lastCreditsPurchaseURL {
|
||
lastCreditsPurchaseURL = purchaseURL
|
||
log("credits purchase url: \(purchaseURL)")
|
||
}
|
||
if events.isEmpty,
|
||
hasReturnableData,
|
||
hasDashboardPageSignal
|
||
{
|
||
log(
|
||
"credits header present=\(scrape.creditsHeaderPresent) " +
|
||
"inViewport=\(scrape.creditsHeaderInViewport) didScroll=\(scrape.didScrollToCredits) " +
|
||
"rows=\(scrape.rows.count)")
|
||
if scrape.didScrollToCredits {
|
||
log("scrollIntoView(Credits usage history) requested; waiting…")
|
||
try await Self.sleepForDashboardPoll(.milliseconds(600))
|
||
continue
|
||
}
|
||
|
||
// Avoid returning early when the usage breakdown chart hydrates before the (often virtualized)
|
||
// credits table. When we detect a dashboard signal, give credits history a moment to appear.
|
||
if scrape.creditsHeaderPresent, scrape.creditsHeaderInViewport, creditsHeaderVisibleAt == nil {
|
||
creditsHeaderVisibleAt = Date()
|
||
}
|
||
if Self.shouldWaitForCreditsHistory(.init(
|
||
now: Date(),
|
||
anyDashboardSignalAt: anyDashboardSignalAt,
|
||
creditsHeaderVisibleAt: creditsHeaderVisibleAt,
|
||
creditsHeaderPresent: scrape.creditsHeaderPresent,
|
||
creditsHeaderInViewport: scrape.creditsHeaderInViewport,
|
||
didScrollToCredits: scrape.didScrollToCredits))
|
||
{
|
||
try await Self.sleepForDashboardPoll(.milliseconds(400))
|
||
continue
|
||
}
|
||
}
|
||
|
||
if hasReturnableData, hasDashboardPageSignal {
|
||
if usageBreakdown.isEmpty,
|
||
let error = scrape.usageBreakdownError, !error.isEmpty,
|
||
Self.shouldWaitForUsageBreakdownRecovery(.init(
|
||
now: Date(),
|
||
errorFirstSeenAt: usageBreakdownErrorFirstSeenAt))
|
||
{
|
||
try await Self.sleepForDashboardPoll(.milliseconds(400))
|
||
continue
|
||
}
|
||
|
||
// The usage breakdown chart is hydrated asynchronously. When code review is already present,
|
||
// give it a moment to populate so the menu can show it.
|
||
if codeReview != nil, usageBreakdown.isEmpty {
|
||
let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date())
|
||
if elapsed < 6 {
|
||
try await Self.sleepForDashboardPoll(.milliseconds(400))
|
||
continue
|
||
}
|
||
}
|
||
return Self.makeDashboardSnapshot(.init(
|
||
signedInEmail: dashboardData.signedInEmail,
|
||
scrape: scrape,
|
||
codeReview: codeReview,
|
||
codeReviewLimit: dashboardData.codeReviewLimit,
|
||
events: events,
|
||
breakdown: dashboardData.breakdown,
|
||
usageBreakdown: usageBreakdown,
|
||
rateLimits: dashboardData.rateLimits,
|
||
extraRateWindows: dashboardData.extraRateWindows,
|
||
creditsRemaining: dashboardData.creditsRemaining,
|
||
codexCreditLimit: dashboardData.codexCreditLimit,
|
||
accountPlan: dashboardData.accountPlan))
|
||
}
|
||
|
||
try await Self.sleepForDashboardPoll(.milliseconds(500))
|
||
}
|
||
|
||
if debugDumpHTML, let html = try? await self.fetchDebugHTML(webView: webView) {
|
||
Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log)
|
||
}
|
||
throw FetchError.noDashboardData(body: lastUsageBreakdownError ?? lastBody ?? "")
|
||
}
|
||
|
||
public func clearSessionData(
|
||
accountEmail: String?,
|
||
cacheScope: CookieHeaderCache.Scope? = nil) async
|
||
{
|
||
let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail, scope: cacheScope)
|
||
OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store)
|
||
await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: accountEmail, scope: cacheScope)
|
||
}
|
||
|
||
public static func evictAllCachedWebViews() {
|
||
OpenAIDashboardWebViewCache.shared.evictAll()
|
||
}
|
||
|
||
public static func evictIdleCachedWebViews() {
|
||
OpenAIDashboardWebViewCache.shared.evictIdle()
|
||
}
|
||
|
||
#if DEBUG
|
||
public static func seedCachedWebViewsForMemoryPressureProof() {
|
||
OpenAIDashboardWebViewCache.shared.clearAllForTesting()
|
||
OpenAIDashboardWebViewCache.shared.cacheEntryForTesting(websiteDataStore: .nonPersistent())
|
||
OpenAIDashboardWebViewCache.shared.cacheEntryForTesting(websiteDataStore: .nonPersistent(), isBusy: true)
|
||
}
|
||
|
||
public static func cachedWebViewCountForTesting() -> Int {
|
||
OpenAIDashboardWebViewCache.shared.entryCount
|
||
}
|
||
#endif
|
||
|
||
public func probeUsagePage(
|
||
websiteDataStore: WKWebsiteDataStore,
|
||
logger: ((String) -> Void)? = nil,
|
||
timeout: TimeInterval = 30,
|
||
preserveLoadedPageForReuse: Bool = false) async throws -> ProbeResult
|
||
{
|
||
let deadline = Self.deadline(startingAt: Date(), timeout: timeout)
|
||
let lease = try await self.makeWebView(
|
||
websiteDataStore: websiteDataStore,
|
||
logger: logger,
|
||
timeout: Self.remainingTimeout(until: deadline),
|
||
preserveLoadedPageOnRelease: preserveLoadedPageForReuse)
|
||
defer { lease.release() }
|
||
let webView = lease.webView
|
||
let log = lease.log
|
||
|
||
var lastBody: String?
|
||
var lastHref: String?
|
||
var usageRouteSeenAt: Date?
|
||
var dashboardSignalSeenAt: Date?
|
||
|
||
while Date() < deadline {
|
||
try Task.checkCancellation()
|
||
let scrape = try await self.scrape(webView: webView)
|
||
lastBody = scrape.bodyText ?? lastBody
|
||
lastHref = scrape.href ?? lastHref
|
||
|
||
if scrape.workspacePicker {
|
||
try await Self.sleepForDashboardPoll(.milliseconds(500))
|
||
continue
|
||
}
|
||
|
||
Self.logBlockingStateIfNeeded(scrape, logger: log)
|
||
try Self.throwIfBlockingScrapeState(scrape)
|
||
|
||
if Self.shouldReloadUsageRoute(scrape) {
|
||
usageRouteSeenAt = nil
|
||
dashboardSignalSeenAt = nil
|
||
_ = webView.load(Self.usageURLRequest(url: self.usageURL))
|
||
try await Self.sleepForDashboardPoll(.milliseconds(500))
|
||
continue
|
||
}
|
||
|
||
let normalizedEmail = scrape.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let bodyText = scrape.bodyText ?? ""
|
||
let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText)
|
||
let hasDashboardSignal = normalizedEmail?.isEmpty == false ||
|
||
!scrape.rows.isEmpty ||
|
||
!scrape.usageBreakdown.isEmpty ||
|
||
scrape.creditsHeaderPresent ||
|
||
OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) != nil ||
|
||
OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) != nil ||
|
||
rateLimits.primary != nil ||
|
||
rateLimits.secondary != nil
|
||
|
||
if usageRouteSeenAt == nil {
|
||
usageRouteSeenAt = Date()
|
||
}
|
||
if hasDashboardSignal, dashboardSignalSeenAt == nil {
|
||
dashboardSignalSeenAt = Date()
|
||
}
|
||
if Self.shouldWaitForProbeReadiness(.init(
|
||
now: Date(),
|
||
usageRouteSeenAt: usageRouteSeenAt,
|
||
dashboardSignalSeenAt: dashboardSignalSeenAt,
|
||
signedInEmail: normalizedEmail,
|
||
hasDashboardSignal: hasDashboardSignal))
|
||
{
|
||
try await Self.sleepForDashboardPoll(.milliseconds(400))
|
||
continue
|
||
}
|
||
|
||
let result = ProbeResult(
|
||
href: scrape.href,
|
||
loginRequired: scrape.loginRequired,
|
||
workspacePicker: scrape.workspacePicker,
|
||
cloudflareInterstitial: scrape.cloudflareInterstitial,
|
||
signedInEmail: normalizedEmail,
|
||
bodyText: scrape.bodyText)
|
||
lease.setPreserveLoadedPageOnRelease(
|
||
preserveLoadedPageForReuse && Self.shouldPreserveLoadedPageAfterProbe(result))
|
||
return result
|
||
}
|
||
|
||
log("Probe timed out (href=\(lastHref ?? "nil"))")
|
||
let result = ProbeResult(
|
||
href: lastHref,
|
||
loginRequired: false,
|
||
workspacePicker: false,
|
||
cloudflareInterstitial: false,
|
||
signedInEmail: nil,
|
||
bodyText: lastBody)
|
||
lease.setPreserveLoadedPageOnRelease(false)
|
||
return result
|
||
}
|
||
|
||
// MARK: - JS scrape
|
||
|
||
private struct ScrapeResult {
|
||
let loginRequired: Bool
|
||
let workspacePicker: Bool
|
||
let cloudflareInterstitial: Bool
|
||
let href: String?
|
||
let bodyText: String?
|
||
let signedInEmail: String?
|
||
let authStatus: String?
|
||
let accountPlan: String?
|
||
let creditsPurchaseURL: String?
|
||
let rows: [[String]]
|
||
let usageBreakdown: [OpenAIDashboardDailyBreakdown]
|
||
let usageBreakdownDebug: String?
|
||
let usageBreakdownError: String?
|
||
let scrollY: Double
|
||
let scrollHeight: Double
|
||
let viewportHeight: Double
|
||
let creditsHeaderPresent: Bool
|
||
let creditsHeaderInViewport: Bool
|
||
let didScrollToCredits: Bool
|
||
}
|
||
|
||
private func scrape(webView: WKWebView) async throws -> ScrapeResult {
|
||
let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript)
|
||
guard let dict = any as? [String: Any] else {
|
||
return ScrapeResult(
|
||
loginRequired: true,
|
||
workspacePicker: false,
|
||
cloudflareInterstitial: false,
|
||
href: nil,
|
||
bodyText: nil,
|
||
signedInEmail: nil,
|
||
authStatus: nil,
|
||
accountPlan: nil,
|
||
creditsPurchaseURL: nil,
|
||
rows: [],
|
||
usageBreakdown: [],
|
||
usageBreakdownDebug: nil,
|
||
usageBreakdownError: nil,
|
||
scrollY: 0,
|
||
scrollHeight: 0,
|
||
viewportHeight: 0,
|
||
creditsHeaderPresent: false,
|
||
creditsHeaderInViewport: false,
|
||
didScrollToCredits: false)
|
||
}
|
||
|
||
var loginRequired = (dict["loginRequired"] as? Bool) ?? false
|
||
let workspacePicker = (dict["workspacePicker"] as? Bool) ?? false
|
||
let cloudflareInterstitial = (dict["cloudflareInterstitial"] as? Bool) ?? false
|
||
let rows = (dict["rows"] as? [[String]]) ?? []
|
||
|
||
var usageBreakdown: [OpenAIDashboardDailyBreakdown] = []
|
||
let usageBreakdownDebug = dict["usageBreakdownDebug"] as? String
|
||
let usageBreakdownError = dict["usageBreakdownError"] as? String
|
||
if let raw = dict["usageBreakdownJSON"] as? String, !raw.isEmpty {
|
||
do {
|
||
let decoder = JSONDecoder()
|
||
let decoded = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8))
|
||
usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: decoded)
|
||
} catch {
|
||
// Best-effort parse; ignore errors to avoid blocking other dashboard data.
|
||
usageBreakdown = []
|
||
}
|
||
}
|
||
|
||
var signedInEmail = dict["signedInEmail"] as? String
|
||
signedInEmail = signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let authStatus = (dict["authStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let accountPlan = (dict["accountPlan"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
if let authStatus, !authStatus.isEmpty, authStatus.lowercased() != "logged_in" {
|
||
// When logged out, the SPA can render a generic landing shell without obvious auth inputs,
|
||
// so treat it as login-required and let the caller retry cookie import.
|
||
loginRequired = true
|
||
}
|
||
|
||
return ScrapeResult(
|
||
loginRequired: loginRequired,
|
||
workspacePicker: workspacePicker,
|
||
cloudflareInterstitial: cloudflareInterstitial,
|
||
href: dict["href"] as? String,
|
||
bodyText: dict["bodyText"] as? String,
|
||
signedInEmail: signedInEmail,
|
||
authStatus: authStatus,
|
||
accountPlan: accountPlan,
|
||
creditsPurchaseURL: dict["creditsPurchaseURL"] as? String,
|
||
rows: rows,
|
||
usageBreakdown: usageBreakdown,
|
||
usageBreakdownDebug: usageBreakdownDebug,
|
||
usageBreakdownError: usageBreakdownError,
|
||
scrollY: (dict["scrollY"] as? NSNumber)?.doubleValue ?? 0,
|
||
scrollHeight: (dict["scrollHeight"] as? NSNumber)?.doubleValue ?? 0,
|
||
viewportHeight: (dict["viewportHeight"] as? NSNumber)?.doubleValue ?? 0,
|
||
creditsHeaderPresent: (dict["creditsHeaderPresent"] as? Bool) ?? false,
|
||
creditsHeaderInViewport: (dict["creditsHeaderInViewport"] as? Bool) ?? false,
|
||
didScrollToCredits: (dict["didScrollToCredits"] as? Bool) ?? false)
|
||
}
|
||
|
||
private static func throwIfBlockingScrapeState(_ scrape: ScrapeResult) throws {
|
||
if scrape.loginRequired {
|
||
throw FetchError.loginRequired
|
||
}
|
||
|
||
if scrape.cloudflareInterstitial {
|
||
throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.")
|
||
}
|
||
}
|
||
|
||
private func fetchDebugHTML(webView: WKWebView) async throws -> String? {
|
||
try await webView.evaluateJavaScript(
|
||
"document.documentElement ? String(document.documentElement.outerHTML || '') : ''") as? String
|
||
}
|
||
|
||
private func makeWebView(
|
||
websiteDataStore: WKWebsiteDataStore,
|
||
logger: ((String) -> Void)?,
|
||
timeout: TimeInterval,
|
||
allowNavigationTimeoutRetry: Bool = true,
|
||
preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease
|
||
{
|
||
try await OpenAIDashboardWebViewCache.shared.acquire(
|
||
websiteDataStore: websiteDataStore,
|
||
usageURL: self.usageURL,
|
||
logger: logger,
|
||
navigationTimeout: timeout,
|
||
allowTimeoutRetry: allowNavigationTimeoutRetry,
|
||
preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)
|
||
}
|
||
|
||
nonisolated static func sanitizedTimeout(_ timeout: TimeInterval) -> TimeInterval {
|
||
guard timeout.isFinite, timeout > 0 else { return 1 }
|
||
return timeout
|
||
}
|
||
|
||
nonisolated static func deadline(startingAt start: Date, timeout: TimeInterval) -> Date {
|
||
start.addingTimeInterval(self.sanitizedTimeout(timeout))
|
||
}
|
||
|
||
nonisolated static func remainingTimeout(until deadline: Date, now: Date = Date()) -> TimeInterval {
|
||
max(0, deadline.timeIntervalSince(now))
|
||
}
|
||
|
||
nonisolated static func isUsageRoute(_ href: String?) -> Bool {
|
||
guard let href, !href.isEmpty else { return false }
|
||
let path = (URL(string: href)?.path ?? href)
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||
return path.hasSuffix("codex/settings/usage")
|
||
|| path.hasSuffix("codex/cloud/settings/usage")
|
||
|| path.hasSuffix("codex/settings/analytics")
|
||
|| path.hasSuffix("codex/cloud/settings/analytics")
|
||
}
|
||
|
||
nonisolated static func shouldReloadUsageRoute(
|
||
href: String?,
|
||
loginRequired: Bool,
|
||
workspacePicker: Bool,
|
||
cloudflareInterstitial: Bool) -> Bool
|
||
{
|
||
guard !workspacePicker, !loginRequired, !cloudflareInterstitial else { return false }
|
||
guard let href else { return false }
|
||
return !self.isUsageRoute(href)
|
||
}
|
||
|
||
private nonisolated static func shouldReloadUsageRoute(_ scrape: ScrapeResult) -> Bool {
|
||
self.shouldReloadUsageRoute(
|
||
href: scrape.href,
|
||
loginRequired: scrape.loginRequired,
|
||
workspacePicker: scrape.workspacePicker,
|
||
cloudflareInterstitial: scrape.cloudflareInterstitial)
|
||
}
|
||
|
||
nonisolated static func usageURLRequest(url: URL) -> URLRequest {
|
||
var request = URLRequest(url: url)
|
||
request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language")
|
||
return request
|
||
}
|
||
|
||
nonisolated static func dashboardAPIData(from response: CodexUsageResponse) -> DashboardAPIData {
|
||
DashboardAPIData(
|
||
primaryLimit: self.rateWindow(from: response.rateLimit?.primaryWindow),
|
||
secondaryLimit: self.rateWindow(from: response.rateLimit?.secondaryWindow),
|
||
extraRateWindows: CodexAdditionalRateLimitMapper.extraRateWindows(
|
||
from: response.additionalRateLimits),
|
||
creditsRemaining: response.credits?.balance,
|
||
codexCreditLimit: (response.individualLimit ?? response.rateLimit?.individualLimit)?
|
||
.codexCreditLimitSnapshot(updatedAt: Date()),
|
||
accountPlan: response.planType?.rawValue)
|
||
}
|
||
|
||
private static func fetchDashboardAPIPreflight(
|
||
websiteDataStore: WKWebsiteDataStore,
|
||
deadline: Date,
|
||
logger: @escaping (String) -> Void)
|
||
async throws -> (apiData: DashboardAPIData?, verifiedSignedInEmail: String?)
|
||
{
|
||
let cookieHeader = try await self.chatGPTCookieHeader(in: websiteDataStore, deadline: deadline)
|
||
let apiData = await self.fetchDashboardUsageAPI(
|
||
cookieHeader: cookieHeader,
|
||
deadline: deadline,
|
||
logger: logger)
|
||
let verifiedEmail: String? = if apiData?.hasUsageData == true {
|
||
await self.fetchSignedInEmailFromAPI(
|
||
cookieHeader: cookieHeader,
|
||
deadline: deadline,
|
||
logger: logger)
|
||
} else {
|
||
nil
|
||
}
|
||
|
||
if apiData?.hasUsageData == true, verifiedEmail != nil {
|
||
logger("usage api supplied verified dashboard data; continuing WebView scrape")
|
||
}
|
||
return (apiData, verifiedEmail)
|
||
}
|
||
|
||
private static func fetchDashboardUsageAPI(
|
||
websiteDataStore: WKWebsiteDataStore,
|
||
logger: @escaping (String) -> Void) async -> DashboardAPIData?
|
||
{
|
||
guard let cookieHeader = try? await self.chatGPTCookieHeader(in: websiteDataStore, deadline: nil) else {
|
||
return nil
|
||
}
|
||
return await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, deadline: nil, logger: logger)
|
||
}
|
||
|
||
static func fetchDashboardUsageAPI(
|
||
cookieHeader: String,
|
||
deadline: Date?,
|
||
logger: @escaping (String) -> Void) async -> DashboardAPIData?
|
||
{
|
||
guard !cookieHeader.isEmpty else { return nil }
|
||
let remaining = deadline.map { self.remainingTimeout(until: $0) } ?? 4
|
||
guard remaining > 0 else { return nil }
|
||
|
||
do {
|
||
let (data, response) = try await CodexAuthenticatedHTTPTransport.current.data(
|
||
for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader, timeout: min(4, remaining)))
|
||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||
logger("usage api status=\(status)")
|
||
guard status >= 200, status < 300 else { return nil }
|
||
let decoded = try JSONDecoder().decode(CodexUsageResponse.self, from: data)
|
||
let result = self.dashboardAPIData(from: decoded)
|
||
if result.hasUsageData {
|
||
logger("usage api supplied language-independent rate/credit data")
|
||
}
|
||
return result
|
||
} catch {
|
||
logger("usage api unavailable: \(error.localizedDescription)")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
static func fetchSignedInEmailFromAPI(
|
||
cookieHeader: String,
|
||
deadline: Date?,
|
||
logger: @escaping (String) -> Void) async -> String?
|
||
{
|
||
guard !cookieHeader.isEmpty else { return nil }
|
||
|
||
let endpoints = [
|
||
URL(string: "https://chatgpt.com/backend-api/me"),
|
||
URL(string: "https://chatgpt.com/api/auth/session"),
|
||
].compactMap(\.self)
|
||
|
||
for url in endpoints {
|
||
let remaining = deadline.map { self.remainingTimeout(until: $0) } ?? 2
|
||
guard remaining > 0 else { return nil }
|
||
do {
|
||
let (data, response) = try await CodexAuthenticatedHTTPTransport.current.data(
|
||
for: self.dashboardIdentityAPIRequest(
|
||
url: url,
|
||
cookieHeader: cookieHeader,
|
||
timeout: min(2, remaining)))
|
||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||
logger("identity api \(url.path) status=\(status)")
|
||
guard status >= 200, status < 300 else { continue }
|
||
if let email = self.findFirstEmail(inJSONData: data) {
|
||
return email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
} catch {
|
||
logger("identity api \(url.path) unavailable: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private static func chatGPTCookieHeader(in store: WKWebsiteDataStore, deadline: Date?) async throws -> String {
|
||
let cookies = try await OpenAIDashboardBrowserCookieImporter.runBoundedValueCallback(
|
||
deadline: deadline)
|
||
{ completion in
|
||
store.httpCookieStore.getAllCookies(completion)
|
||
}
|
||
|
||
return cookies
|
||
.filter { $0.domain.lowercased().contains("chatgpt.com") }
|
||
.map { "\($0.name)=\($0.value)" }
|
||
.joined(separator: "; ")
|
||
}
|
||
|
||
nonisolated static func findFirstEmail(inJSONData data: Data) -> String? {
|
||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||
var queue: [Any] = [json]
|
||
var seen = 0
|
||
while !queue.isEmpty, seen < 2000 {
|
||
let current = queue.removeFirst()
|
||
seen += 1
|
||
if let string = current as? String, string.contains("@") {
|
||
return string
|
||
}
|
||
if let dictionary = current as? [String: Any] {
|
||
for (key, value) in dictionary {
|
||
if key.lowercased() == "email",
|
||
let string = value as? String,
|
||
string.contains("@")
|
||
{
|
||
return string
|
||
}
|
||
queue.append(value)
|
||
}
|
||
} else if let array = current as? [Any] {
|
||
queue.append(contentsOf: array)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private nonisolated static func rateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? {
|
||
guard let window else { return nil }
|
||
let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt))
|
||
return RateWindow(
|
||
usedPercent: Double(window.usedPercent),
|
||
windowMinutes: window.limitWindowSeconds / 60,
|
||
resetsAt: resetDate,
|
||
resetDescription: UsageFormatter.resetDescription(from: resetDate))
|
||
}
|
||
|
||
private nonisolated static func firstNonEmpty(_ candidates: String?...) -> String? {
|
||
for candidate in candidates {
|
||
let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed?.isEmpty == false { return trimmed }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) {
|
||
let stamp = Int(Date().timeIntervalSince1970)
|
||
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||
let htmlURL = dir.appendingPathComponent("codex-openai-dashboard-\(stamp).html")
|
||
do {
|
||
try html.write(to: htmlURL, atomically: true, encoding: .utf8)
|
||
logger("Dumped HTML: \(htmlURL.path)")
|
||
} catch {
|
||
logger("Failed to dump HTML: \(error.localizedDescription)")
|
||
}
|
||
|
||
if let bodyText, !bodyText.isEmpty {
|
||
let textURL = dir.appendingPathComponent("codex-openai-dashboard-\(stamp).txt")
|
||
do {
|
||
try bodyText.write(to: textURL, atomically: true, encoding: .utf8)
|
||
logger("Dumped text: \(textURL.path)")
|
||
} catch {
|
||
logger("Failed to dump text: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension OpenAIDashboardFetcher {
|
||
struct CreditsHistoryWaitContext {
|
||
let now: Date
|
||
let anyDashboardSignalAt: Date?
|
||
let creditsHeaderVisibleAt: Date?
|
||
let creditsHeaderPresent: Bool
|
||
let creditsHeaderInViewport: Bool
|
||
let didScrollToCredits: Bool
|
||
}
|
||
|
||
nonisolated static func shouldWaitForCreditsHistory(_ context: CreditsHistoryWaitContext) -> Bool {
|
||
if context.didScrollToCredits { return true }
|
||
|
||
// When the header is visible but rows are still empty, wait briefly for the table to render.
|
||
if context.creditsHeaderPresent, context.creditsHeaderInViewport {
|
||
if let creditsHeaderVisibleAt = context.creditsHeaderVisibleAt {
|
||
return context.now.timeIntervalSince(creditsHeaderVisibleAt) < 2.5
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Header not in view yet: allow a short grace period after we first detect any dashboard signal so
|
||
// a scroll (or hydration) can bring the credits section into the DOM.
|
||
if let anyDashboardSignalAt = context.anyDashboardSignalAt {
|
||
return context.now.timeIntervalSince(anyDashboardSignalAt) < 6.5
|
||
}
|
||
return false
|
||
}
|
||
|
||
struct ProbeReadinessContext {
|
||
let now: Date
|
||
let usageRouteSeenAt: Date?
|
||
let dashboardSignalSeenAt: Date?
|
||
let signedInEmail: String?
|
||
let hasDashboardSignal: Bool
|
||
}
|
||
|
||
struct UsageBreakdownRecoveryContext {
|
||
let now: Date
|
||
let errorFirstSeenAt: Date?
|
||
}
|
||
|
||
nonisolated static func shouldWaitForUsageBreakdownRecovery(_ context: UsageBreakdownRecoveryContext) -> Bool {
|
||
guard let errorFirstSeenAt = context.errorFirstSeenAt else { return true }
|
||
return context.now.timeIntervalSince(errorFirstSeenAt) < 4.0
|
||
}
|
||
|
||
nonisolated static func shouldWaitForProbeReadiness(_ context: ProbeReadinessContext) -> Bool {
|
||
if let signedInEmail = context.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!signedInEmail.isEmpty
|
||
{
|
||
return false
|
||
}
|
||
|
||
if context.hasDashboardSignal {
|
||
if let dashboardSignalSeenAt = context.dashboardSignalSeenAt {
|
||
return context.now.timeIntervalSince(dashboardSignalSeenAt) < 2.0
|
||
}
|
||
return true
|
||
}
|
||
|
||
if let usageRouteSeenAt = context.usageRouteSeenAt {
|
||
return context.now.timeIntervalSince(usageRouteSeenAt) < 2.0
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
nonisolated static func updateUsageBreakdownErrorState(
|
||
usageBreakdown: [OpenAIDashboardDailyBreakdown],
|
||
error: String?,
|
||
firstSeenAt: inout Date?,
|
||
lastError: inout String?,
|
||
now: Date = Date(),
|
||
logger: (String) -> Void)
|
||
{
|
||
guard usageBreakdown.isEmpty, let error, !error.isEmpty else {
|
||
firstSeenAt = nil
|
||
return
|
||
}
|
||
if firstSeenAt == nil { firstSeenAt = now }
|
||
guard error != lastError else { return }
|
||
lastError = error
|
||
logger("usage breakdown error: \(error)")
|
||
}
|
||
}
|
||
|
||
extension OpenAIDashboardFetcher {
|
||
nonisolated static func requiredRemainingTimeout(
|
||
until deadline: Date,
|
||
now: Date = Date()) throws -> TimeInterval
|
||
{
|
||
let remaining = self.remainingTimeout(until: deadline, now: now)
|
||
guard remaining > 0 else { throw URLError(.timedOut) }
|
||
return remaining
|
||
}
|
||
|
||
nonisolated static func dashboardUsageAPIRequest(
|
||
cookieHeader: String,
|
||
timeout: TimeInterval = 4) -> URLRequest
|
||
{
|
||
var request = URLRequest(
|
||
url: Self.dashboardUsageAPIURL,
|
||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||
timeoutInterval: timeout)
|
||
request.httpMethod = "GET"
|
||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language")
|
||
request.setValue("CodexBar", forHTTPHeaderField: "User-Agent")
|
||
return request
|
||
}
|
||
|
||
nonisolated static func dashboardIdentityAPIRequest(
|
||
url: URL,
|
||
cookieHeader: String,
|
||
timeout: TimeInterval = 2) -> URLRequest
|
||
{
|
||
var request = URLRequest(
|
||
url: url,
|
||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||
timeoutInterval: timeout)
|
||
request.httpMethod = "GET"
|
||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language")
|
||
request.setValue("CodexBar", forHTTPHeaderField: "User-Agent")
|
||
return request
|
||
}
|
||
|
||
private static func logBlockingStateIfNeeded(_ scrape: ScrapeResult, logger: (String) -> Void) {
|
||
guard scrape.loginRequired || scrape.cloudflareInterstitial else { return }
|
||
let route = self.isUsageRoute(scrape.href) ? "usage" : "other"
|
||
logger(
|
||
"blocking state before route reload route=\(route) " +
|
||
"login=\(scrape.loginRequired) cloudflare=\(scrape.cloudflareInterstitial)")
|
||
}
|
||
|
||
private func handleBlockingScrapeState(
|
||
_ scrape: ScrapeResult,
|
||
webView: WKWebView,
|
||
debugDumpHTML: Bool,
|
||
logger: (String) -> Void) async throws
|
||
{
|
||
if debugDumpHTML,
|
||
scrape.loginRequired || scrape.cloudflareInterstitial,
|
||
let html = try? await self.fetchDebugHTML(webView: webView)
|
||
{
|
||
Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger)
|
||
}
|
||
Self.logBlockingStateIfNeeded(scrape, logger: logger)
|
||
try Self.throwIfBlockingScrapeState(scrape)
|
||
}
|
||
}
|
||
#else
|
||
import Foundation
|
||
|
||
@MainActor
|
||
public struct OpenAIDashboardFetcher {
|
||
public enum FetchError: LocalizedError {
|
||
case loginRequired
|
||
case noDashboardData(body: String)
|
||
|
||
public var errorDescription: String? {
|
||
switch self {
|
||
case .loginRequired:
|
||
"OpenAI web access requires login."
|
||
case let .noDashboardData(body):
|
||
"OpenAI dashboard data not found. Body sample: \(body.prefix(200))"
|
||
}
|
||
}
|
||
}
|
||
|
||
public init() {}
|
||
|
||
public func loadLatestDashboard(
|
||
accountEmail _: String?,
|
||
cacheScope _: CookieHeaderCache.Scope? = nil,
|
||
logger _: ((String) -> Void)? = nil,
|
||
debugDumpHTML _: Bool = false,
|
||
allowNavigationTimeoutRetry _: Bool = true,
|
||
timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot
|
||
{
|
||
throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.")
|
||
}
|
||
}
|
||
#endif
|