908 lines
37 KiB
Swift
908 lines
37 KiB
Swift
#if os(macOS)
|
|
let openAIDashboardScrapeScript = """
|
|
|
|
(() => {
|
|
const textOf = el => {
|
|
const raw = el && (el.innerText || el.textContent) ? String(el.innerText || el.textContent) : '';
|
|
return raw.trim();
|
|
};
|
|
const parseHexColor = (color) => {
|
|
if (!color) return null;
|
|
const c = String(color).trim().toLowerCase();
|
|
if (c.startsWith('#')) {
|
|
if (c.length === 4) {
|
|
return '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3];
|
|
}
|
|
if (c.length === 7) return c;
|
|
return c;
|
|
}
|
|
const m = c.match(/^rgba?\\(([^)]+)\\)$/);
|
|
if (m) {
|
|
const parts = m[1].split(',').map(x => parseFloat(x.trim())).filter(x => Number.isFinite(x));
|
|
if (parts.length >= 3) {
|
|
const r = Math.max(0, Math.min(255, Math.round(parts[0])));
|
|
const g = Math.max(0, Math.min(255, Math.round(parts[1])));
|
|
const b = Math.max(0, Math.min(255, Math.round(parts[2])));
|
|
const toHex = n => n.toString(16).padStart(2, '0');
|
|
return '#' + toHex(r) + toHex(g) + toHex(b);
|
|
}
|
|
}
|
|
return c;
|
|
};
|
|
const reactPropsOf = (el) => {
|
|
if (!el) return null;
|
|
try {
|
|
const keys = Object.keys(el);
|
|
const propsKey = keys.find(k => k.startsWith('__reactProps$'));
|
|
if (propsKey) return el[propsKey] || null;
|
|
const fiberKey = keys.find(k => k.startsWith('__reactFiber$'));
|
|
if (fiberKey) {
|
|
const fiber = el[fiberKey];
|
|
return (fiber && (fiber.memoizedProps || fiber.pendingProps)) || null;
|
|
}
|
|
} catch {}
|
|
return null;
|
|
};
|
|
const reactFiberOf = (el) => {
|
|
if (!el) return null;
|
|
try {
|
|
const keys = Object.keys(el);
|
|
const fiberKey = keys.find(k => k.startsWith('__reactFiber$'));
|
|
return fiberKey ? (el[fiberKey] || null) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const nestedBarMetaOf = (root) => {
|
|
if (!root || typeof root !== 'object') return null;
|
|
const queue = [root];
|
|
const seen = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
|
let steps = 0;
|
|
while (queue.length && steps < 250) {
|
|
const cur = queue.shift();
|
|
steps++;
|
|
if (!cur || typeof cur !== 'object') continue;
|
|
if (seen) {
|
|
if (seen.has(cur)) continue;
|
|
seen.add(cur);
|
|
}
|
|
if (cur.payload && (cur.dataKey || cur.name || cur.value !== undefined)) return cur;
|
|
const values = Array.isArray(cur) ? cur : Object.values(cur);
|
|
for (const v of values) {
|
|
if (v && typeof v === 'object') queue.push(v);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const barMetaFromElement = (el) => {
|
|
const direct = reactPropsOf(el);
|
|
if (direct && direct.payload && (direct.dataKey || direct.name || direct.value !== undefined)) return direct;
|
|
|
|
const fiber = reactFiberOf(el);
|
|
if (fiber) {
|
|
let cur = fiber;
|
|
for (let i = 0; i < 10 && cur; i++) {
|
|
const props = (cur.memoizedProps || cur.pendingProps) || null;
|
|
if (props && props.payload && (props.dataKey || props.name || props.value !== undefined)) return props;
|
|
const nested = props ? nestedBarMetaOf(props) : null;
|
|
if (nested) return nested;
|
|
cur = cur.return || null;
|
|
}
|
|
}
|
|
|
|
if (direct) {
|
|
const nested = nestedBarMetaOf(direct);
|
|
if (nested) return nested;
|
|
}
|
|
return null;
|
|
};
|
|
const normalizeHref = (raw) => {
|
|
if (!raw) return null;
|
|
const href = String(raw).trim();
|
|
if (!href) return null;
|
|
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
|
if (href.startsWith('//')) return window.location.protocol + href;
|
|
if (href.startsWith('/')) return window.location.origin + href;
|
|
return window.location.origin + '/' + href;
|
|
};
|
|
const isLikelyCreditsURL = (raw) => {
|
|
if (!raw) return false;
|
|
try {
|
|
const url = new URL(raw, window.location.origin);
|
|
if (!url.host || !url.host.includes('chatgpt.com')) return false;
|
|
const path = url.pathname.toLowerCase();
|
|
return (
|
|
path.includes('settings') ||
|
|
path.includes('usage') ||
|
|
path.includes('billing') ||
|
|
path.includes('credits')
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const purchaseTextMatches = (text) => {
|
|
const lower = String(text || '').trim().toLowerCase();
|
|
if (!lower) return false;
|
|
if (lower.includes('add more')) return true;
|
|
if (!lower.includes('credit')) return false;
|
|
return (
|
|
lower.includes('buy') ||
|
|
lower.includes('add') ||
|
|
lower.includes('purchase') ||
|
|
lower.includes('top up') ||
|
|
lower.includes('top-up')
|
|
);
|
|
};
|
|
const elementLabel = (el) => {
|
|
if (!el) return '';
|
|
return (
|
|
textOf(el) ||
|
|
el.getAttribute('aria-label') ||
|
|
el.getAttribute('title') ||
|
|
''
|
|
);
|
|
};
|
|
const urlFromProps = (props) => {
|
|
if (!props || typeof props !== 'object') return null;
|
|
const candidates = [
|
|
props.href,
|
|
props.to,
|
|
props.url,
|
|
props.link,
|
|
props.destination,
|
|
props.navigateTo
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === 'string' && candidate.trim()) {
|
|
return normalizeHref(candidate);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const purchaseURLFromElement = (el) => {
|
|
if (!el) return null;
|
|
const isAnchor = el.tagName && el.tagName.toLowerCase() === 'a';
|
|
const anchor = isAnchor ? el : (el.closest ? el.closest('a') : null);
|
|
const anchorHref = anchor ? anchor.getAttribute('href') : null;
|
|
const dataHref = el.getAttribute
|
|
? (el.getAttribute('data-href') ||
|
|
el.getAttribute('data-url') ||
|
|
el.getAttribute('data-link') ||
|
|
el.getAttribute('data-destination'))
|
|
: null;
|
|
const propHref = urlFromProps(reactPropsOf(el)) || urlFromProps(reactPropsOf(anchor));
|
|
const normalized = normalizeHref(anchorHref || dataHref || propHref);
|
|
return normalized && isLikelyCreditsURL(normalized) ? normalized : null;
|
|
};
|
|
const cleanPlanName = (raw) => String(raw || '')
|
|
.replace(/\\b(claude|codex|account|plan)\\b/gi, ' ')
|
|
.replace(/_/g, ' ')
|
|
.replace(/-/g, ' ')
|
|
.replace(/\\s+/g, ' ')
|
|
.trim();
|
|
const codexPlanDisplayName = (raw) => {
|
|
const trimmed = String(raw || '').trim();
|
|
if (!trimmed) return null;
|
|
const lower = trimmed.toLowerCase();
|
|
const exact = {
|
|
pro: 'Pro 20x',
|
|
prolite: 'Pro 5x',
|
|
'pro_lite': 'Pro 5x',
|
|
'pro-lite': 'Pro 5x',
|
|
'pro lite': 'Pro 5x'
|
|
};
|
|
if (exact[lower]) return exact[lower];
|
|
const cleaned = cleanPlanName(trimmed);
|
|
if (!cleaned) return trimmed;
|
|
if (exact[cleaned.toLowerCase()]) return exact[cleaned.toLowerCase()];
|
|
return cleaned.split(' ')
|
|
.filter(Boolean)
|
|
.map(word => {
|
|
const wordLower = word.toLowerCase();
|
|
if (wordLower === 'cbp' || wordLower === 'k12') return wordLower.toUpperCase();
|
|
if (word === word.toUpperCase() && /[a-z]/i.test(word)) return word;
|
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
})
|
|
.join(' ') || cleaned;
|
|
};
|
|
const normalizePlanValue = (value) => {
|
|
const trimmed = String(value || '').trim();
|
|
if (!trimmed) return null;
|
|
const lower = trimmed.toLowerCase();
|
|
const allowed = [
|
|
'free',
|
|
'plus',
|
|
'pro',
|
|
'team',
|
|
'enterprise',
|
|
'business',
|
|
'edu',
|
|
'education',
|
|
'gov',
|
|
'premium',
|
|
'essential'
|
|
];
|
|
if (!allowed.some(token => lower.includes(token))) return null;
|
|
return codexPlanDisplayName(trimmed) || cleanPlanName(trimmed);
|
|
};
|
|
const planCandidate = (key, value) => {
|
|
const lower = String(key || '').toLowerCase();
|
|
if (!lower.includes('plan') && !lower.includes('tier') && !lower.includes('subscription')) return null;
|
|
if (typeof value === 'string') return normalizePlanValue(value);
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return normalizePlanValue(value.name) ||
|
|
normalizePlanValue(value.displayName) ||
|
|
normalizePlanValue(value.tier);
|
|
}
|
|
return null;
|
|
};
|
|
const findPlan = (root) => {
|
|
if (!root || typeof root !== 'object') return null;
|
|
const queue = [root];
|
|
const seenObjects = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
|
|
let index = 0;
|
|
let seen = 0;
|
|
while (index < queue.length && seen < 6000) {
|
|
const cur = queue[index++];
|
|
seen++;
|
|
if (!cur || typeof cur !== 'object') continue;
|
|
if (seenObjects) {
|
|
if (seenObjects.has(cur)) continue;
|
|
seenObjects.add(cur);
|
|
}
|
|
if (Array.isArray(cur)) {
|
|
for (const v of cur) {
|
|
if (v && typeof v === 'object') queue.push(v);
|
|
}
|
|
continue;
|
|
}
|
|
for (const [k, v] of Object.entries(cur)) {
|
|
const plan = planCandidate(k, v);
|
|
if (plan) return plan;
|
|
if (v && typeof v === 'object') queue.push(v);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const parseJSONScript = (id) => {
|
|
try {
|
|
const node = document.getElementById(id);
|
|
const raw = node && node.textContent ? String(node.textContent) : '';
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const pickLikelyPurchaseButton = (buttons) => {
|
|
if (!buttons || buttons.length === 0) return null;
|
|
const labeled = buttons.find(btn => {
|
|
const label = elementLabel(btn);
|
|
if (purchaseTextMatches(label)) return true;
|
|
const aria = String(btn.getAttribute('aria-label') || '').toLowerCase();
|
|
return aria.includes('credit') || aria.includes('buy') || aria.includes('add');
|
|
});
|
|
return labeled || buttons[0];
|
|
};
|
|
const findCreditsPurchaseButton = () => {
|
|
const nodes = Array.from(document.querySelectorAll('h1,h2,h3,div,span,p'));
|
|
const labelMatch = nodes.find(node => {
|
|
const lower = textOf(node).toLowerCase();
|
|
return lower === 'credits remaining' || (lower.includes('credits') && lower.includes('remaining'));
|
|
});
|
|
if (!labelMatch) return null;
|
|
let cur = labelMatch;
|
|
for (let i = 0; i < 6 && cur; i++) {
|
|
const buttons = Array.from(cur.querySelectorAll('button, a'));
|
|
const picked = pickLikelyPurchaseButton(buttons);
|
|
if (picked) return picked;
|
|
cur = cur.parentElement;
|
|
}
|
|
return null;
|
|
};
|
|
const dayKeyFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
const localDayKeyForDate = (date) => {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
const keys = ['day', 'date', 'name', 'label', 'x', 'time', 'timestamp'];
|
|
for (const k of keys) {
|
|
const v = payload[k];
|
|
if (typeof v === 'string') {
|
|
const s = v.trim();
|
|
if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s;
|
|
const iso = s.match(/^(\\d{4}-\\d{2}-\\d{2})/);
|
|
if (iso) return iso[1];
|
|
}
|
|
if (typeof v === 'number' && Number.isFinite(v) && (k === 'timestamp' || k === 'time' || k === 'x')) {
|
|
try {
|
|
const d = new Date(v);
|
|
if (!isNaN(d.getTime())) return localDayKeyForDate(d);
|
|
} catch {}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const isSkillUsageServiceKey = (raw) => {
|
|
const key = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase();
|
|
return key.startsWith('skillusage:');
|
|
};
|
|
const displayNameForUsageServiceKey = (raw) => {
|
|
const key = raw === null || raw === undefined ? '' : String(raw).trim();
|
|
if (!key) return key;
|
|
if (isSkillUsageServiceKey(key)) return null;
|
|
if (key.toUpperCase() === key && key.length <= 6) return key;
|
|
const lower = key.toLowerCase();
|
|
if (lower === 'cli') return 'CLI';
|
|
if (lower.includes('github') && lower.includes('review')) return 'GitHub Code Review';
|
|
const words = lower.replace(/[_-]+/g, ' ').split(' ').filter(Boolean);
|
|
return words.map(w => w.length <= 2 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
};
|
|
const isLikelyCodexUsageService = (raw) => {
|
|
const service = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase();
|
|
return (
|
|
service === 'cli' ||
|
|
service === 'desktop' ||
|
|
service === 'desktop app' ||
|
|
service === 'vscode' ||
|
|
service === 'vs code' ||
|
|
service === 'unknown' ||
|
|
(service.includes('github') && service.includes('review'))
|
|
);
|
|
};
|
|
const usageChartRootForPath = (path) => {
|
|
if (!path || !path.closest) return null;
|
|
return (
|
|
path.closest('.recharts-wrapper') ||
|
|
path.closest('svg.recharts-surface') ||
|
|
path.closest('section') ||
|
|
path.parentElement ||
|
|
null
|
|
);
|
|
};
|
|
const uniqueUsageChartRoots = (paths) => {
|
|
const roots = [];
|
|
for (const path of paths) {
|
|
const root = usageChartRootForPath(path);
|
|
if (root && !roots.includes(root)) roots.push(root);
|
|
}
|
|
return roots;
|
|
};
|
|
const usageBreakdownTitleScore = (title) => {
|
|
const lower = String(title || '').trim().toLowerCase().replace(/\\s+/g, ' ');
|
|
if (!lower) return 0;
|
|
if (lower === 'usage breakdown') return 1000000;
|
|
if (lower.includes('usage breakdown')) return 900000;
|
|
if (lower === 'personal usage') return 800000;
|
|
if (lower.includes('threads') ||
|
|
lower.includes('turns') ||
|
|
lower.includes('client') ||
|
|
lower.includes('skill') ||
|
|
lower.includes('invocation')) return -1000000;
|
|
return 0;
|
|
};
|
|
const titleLikeElements = (scope) => {
|
|
try {
|
|
return Array.from(scope.querySelectorAll('h1,h2,h3,[role=\"heading\"],div,span,p'))
|
|
.filter(el => {
|
|
const title = textOf(el);
|
|
const lower = title.toLowerCase();
|
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
const isHeading = tag === 'h1' ||
|
|
tag === 'h2' ||
|
|
tag === 'h3' ||
|
|
String(el.getAttribute('role') || '').toLowerCase() === 'heading';
|
|
return title.length > 0 &&
|
|
title.length <= 80 &&
|
|
(
|
|
isHeading ||
|
|
usageBreakdownTitleScore(title) !== 0 ||
|
|
lower.includes('usage breakdown') ||
|
|
lower.includes('threads') ||
|
|
lower.includes('turns') ||
|
|
lower.includes('client') ||
|
|
lower.includes('skill') ||
|
|
lower.includes('invocation')
|
|
);
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
const titleNodePrecedesRoot = (titleNode, root) => {
|
|
if (!titleNode || titleNode === root || root.contains(titleNode) || titleNode.contains(root)) return false;
|
|
const relation = titleNode.compareDocumentPosition(root);
|
|
return Boolean(relation & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
};
|
|
const nearestScoredChartTitleInScope = (scope, root) => {
|
|
let best = null;
|
|
for (const titleNode of titleLikeElements(scope)) {
|
|
if (!titleNodePrecedesRoot(titleNode, root)) continue;
|
|
const title = textOf(titleNode);
|
|
const score = usageBreakdownTitleScore(title);
|
|
if (score === 0) continue;
|
|
if (!best || score >= best.score) best = { title, score };
|
|
}
|
|
return best ? best.title : '';
|
|
};
|
|
const chartTitleBoundaryForRoot = (root) => {
|
|
if (!root) return null;
|
|
try {
|
|
return root.closest('section,[role=\"region\"],article') || root.parentElement || null;
|
|
} catch {
|
|
return root.parentElement || null;
|
|
}
|
|
};
|
|
const nearestTitleTextInScope = (scope, root) => {
|
|
if (!scope) return '';
|
|
let nearest = null;
|
|
for (const titleNode of titleLikeElements(scope)) {
|
|
if (titleNodePrecedesRoot(titleNode, root)) nearest = titleNode;
|
|
}
|
|
return textOf(nearest);
|
|
};
|
|
const nearestChartTitleTextForRoot = (root) => {
|
|
if (!root) return '';
|
|
try {
|
|
const boundary = chartTitleBoundaryForRoot(root) || root.parentElement || null;
|
|
let ancestor = root.parentElement || null;
|
|
for (let i = 0; i < 8 && ancestor; i++) {
|
|
const scoredTitle = nearestScoredChartTitleInScope(ancestor, root);
|
|
if (scoredTitle) return scoredTitle;
|
|
if (ancestor === boundary) break;
|
|
ancestor = ancestor.parentElement || null;
|
|
}
|
|
|
|
return nearestTitleTextInScope(boundary, root);
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
const legendMapForUsageChartRoot = (root) => {
|
|
const legendMap = {};
|
|
const scopes = [
|
|
root,
|
|
root && root.parentElement,
|
|
root && root.closest ? root.closest('section') : null
|
|
].filter(Boolean);
|
|
for (const scope of scopes) {
|
|
try {
|
|
const legendItems = Array.from(scope.querySelectorAll('div[title]'));
|
|
for (const item of legendItems) {
|
|
const title = item.getAttribute('title') ? String(item.getAttribute('title')).trim() : '';
|
|
const square = item.querySelector('div[style*=\"background-color\"]');
|
|
const color = (square && square.style && square.style.backgroundColor)
|
|
? square.style.backgroundColor
|
|
: null;
|
|
const hex = parseHexColor(color);
|
|
if (title && hex) legendMap[hex] = title;
|
|
}
|
|
} catch {}
|
|
if (Object.keys(legendMap).length > 0) break;
|
|
}
|
|
return legendMap;
|
|
};
|
|
const parseUsageBreakdownFromChartPaths = (paths, legendMap) => {
|
|
const totalsByDay = {}; // day -> service -> value
|
|
const addValue = (day, service, value) => {
|
|
if (!day || !service || isSkillUsageServiceKey(service)) return false;
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return false;
|
|
if (!totalsByDay[day]) totalsByDay[day] = {};
|
|
totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value;
|
|
return true;
|
|
};
|
|
let pointCount = 0;
|
|
for (const path of paths) {
|
|
const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null;
|
|
if (!meta) continue;
|
|
|
|
const payload = meta.payload || null;
|
|
const day = dayKeyFromPayload(payload);
|
|
if (!day) continue;
|
|
|
|
const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null;
|
|
if (valuesObj) {
|
|
for (const [k, v] of Object.entries(valuesObj)) {
|
|
const service = displayNameForUsageServiceKey(k);
|
|
if (addValue(day, service, v)) pointCount++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let value = null;
|
|
if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value;
|
|
if (value === null && typeof meta.value === 'string') {
|
|
const v = parseFloat(meta.value.replace(/,/g, ''));
|
|
if (Number.isFinite(v)) value = v;
|
|
}
|
|
if (value === null) continue;
|
|
|
|
const fill = parseHexColor(meta.fill || path.getAttribute('fill'));
|
|
const service =
|
|
(fill && legendMap[fill]) ||
|
|
(typeof meta.name === 'string' && meta.name) ||
|
|
null;
|
|
if (addValue(day, service, value)) pointCount++;
|
|
}
|
|
|
|
const dayKeys = Object.keys(totalsByDay)
|
|
.filter(day => Object.keys(totalsByDay[day] || {}).length > 0)
|
|
.sort((a, b) => b.localeCompare(a))
|
|
.slice(0, 30);
|
|
const breakdown = dayKeys.map(day => {
|
|
const servicesMap = totalsByDay[day] || {};
|
|
const services = Object.keys(servicesMap).map(service => ({
|
|
service,
|
|
creditsUsed: servicesMap[service]
|
|
})).sort((a, b) => {
|
|
if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service);
|
|
return b.creditsUsed - a.creditsUsed;
|
|
});
|
|
const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0);
|
|
return { day, services, totalCreditsUsed };
|
|
});
|
|
const services = Array.from(new Set(breakdown.flatMap(day => day.services.map(service => service.service))));
|
|
const totalCreditsUsed = breakdown.reduce((sum, day) => sum + (Number(day.totalCreditsUsed) || 0), 0);
|
|
const likelyCodexServiceCount = services.filter(isLikelyCodexUsageService).length;
|
|
return {
|
|
breakdown,
|
|
pointCount,
|
|
services,
|
|
totalCreditsUsed,
|
|
likelyCodexServiceCount,
|
|
score: likelyCodexServiceCount * 1000 + services.length * 100 + pointCount + totalCreditsUsed / 1000
|
|
};
|
|
};
|
|
const usageBreakdownJSON = (() => {
|
|
try {
|
|
if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON;
|
|
|
|
const paths = Array.from(document.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle'));
|
|
let debug = {
|
|
pathCount: paths.length,
|
|
chartCount: 0,
|
|
eligibleCandidateCount: 0,
|
|
selectedCandidateTitle: null,
|
|
candidateSummaries: [],
|
|
sampleReactKeys: null,
|
|
sampleMetaKeys: null,
|
|
samplePayloadKeys: null,
|
|
sampleValuesKeys: null,
|
|
sampleDayKey: null
|
|
};
|
|
try {
|
|
const sample = paths[0] || null;
|
|
if (sample) {
|
|
const names = Object.getOwnPropertyNames(sample);
|
|
debug.sampleReactKeys = names.filter(k => k.includes('react')).slice(0, 10);
|
|
const metaSample = barMetaFromElement(sample) || barMetaFromElement(sample.parentElement) || null;
|
|
if (metaSample) {
|
|
debug.sampleMetaKeys = Object.keys(metaSample).slice(0, 12);
|
|
const payload = metaSample.payload || null;
|
|
if (payload && typeof payload === 'object') {
|
|
debug.samplePayloadKeys = Object.keys(payload).slice(0, 12);
|
|
debug.sampleDayKey = dayKeyFromPayload(payload);
|
|
const values = payload.values || null;
|
|
if (values && typeof values === 'object') {
|
|
debug.sampleValuesKeys = Object.keys(values).slice(0, 12);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
const roots = uniqueUsageChartRoots(paths);
|
|
debug.chartCount = roots.length;
|
|
const candidates = roots.map(root => {
|
|
const chartPaths = paths.filter(path => usageChartRootForPath(path) === root);
|
|
const title = nearestChartTitleTextForRoot(root);
|
|
const titleScore = usageBreakdownTitleScore(title);
|
|
const parsed = parseUsageBreakdownFromChartPaths(chartPaths, legendMapForUsageChartRoot(root));
|
|
return {
|
|
root,
|
|
title,
|
|
titleScore,
|
|
pathCount: chartPaths.length,
|
|
...parsed,
|
|
score: titleScore + parsed.score
|
|
};
|
|
}).filter(candidate => candidate.breakdown.length > 0);
|
|
const rejectedTitleCandidates = candidates.filter(candidate => candidate.titleScore < 0);
|
|
const titledCandidates = candidates.filter(candidate => candidate.titleScore > 0);
|
|
const unknownTitleCandidates = candidates.filter(candidate => candidate.titleScore === 0);
|
|
const eligibleCandidates = titledCandidates;
|
|
eligibleCandidates.sort((a, b) => b.score - a.score);
|
|
debug.eligibleCandidateCount = eligibleCandidates.length;
|
|
debug.selectedCandidateTitle = eligibleCandidates[0] ? eligibleCandidates[0].title : null;
|
|
if (eligibleCandidates.length === 0 && candidates.length > 0) {
|
|
if (unknownTitleCandidates.length > 0) {
|
|
debug.error = 'No English usage breakdown chart title found. Candidate titles: ' +
|
|
candidates.map(candidate => candidate.title || 'Untitled chart').join(', ');
|
|
} else if (rejectedTitleCandidates.length > 0) {
|
|
debug.error = 'Only non-usage chart candidates found: ' +
|
|
rejectedTitleCandidates.map(candidate => candidate.title || 'Untitled chart').join(', ');
|
|
}
|
|
}
|
|
debug.candidateSummaries = candidates.slice(0, 6).map(candidate => ({
|
|
title: candidate.title,
|
|
titleScore: candidate.titleScore,
|
|
pathCount: candidate.pathCount,
|
|
dayCount: candidate.breakdown.length,
|
|
pointCount: candidate.pointCount,
|
|
serviceCount: candidate.services.length,
|
|
likelyCodexServiceCount: candidate.likelyCodexServiceCount,
|
|
services: candidate.services.slice(0, 8)
|
|
}));
|
|
|
|
const breakdown = eligibleCandidates[0] ? eligibleCandidates[0].breakdown : [];
|
|
const json = (breakdown.length > 0) ? JSON.stringify(breakdown) : null;
|
|
window.__codexbarUsageBreakdownJSON = json;
|
|
window.__codexbarUsageBreakdownDebug = json ? null : JSON.stringify(debug);
|
|
return json;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const usageBreakdownDebug = (() => {
|
|
try {
|
|
return window.__codexbarUsageBreakdownDebug || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const usageBreakdownError = (() => {
|
|
try {
|
|
if (!usageBreakdownDebug) return null;
|
|
const parsed = JSON.parse(usageBreakdownDebug);
|
|
return parsed && parsed.error ? String(parsed.error) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const bodyText = document.body ? String(document.body.innerText || '').trim() : '';
|
|
const href = window.location ? String(window.location.href || '') : '';
|
|
const workspacePicker = bodyText.includes('Select a workspace');
|
|
const title = document.title ? String(document.title || '') : '';
|
|
const cloudflareInterstitial =
|
|
title.toLowerCase().includes('just a moment') ||
|
|
bodyText.toLowerCase().includes('checking your browser') ||
|
|
bodyText.toLowerCase().includes('cloudflare');
|
|
const authSelector = [
|
|
'input[type="email"]',
|
|
'input[type="password"]',
|
|
'input[name="username"]'
|
|
].join(', ');
|
|
const hasAuthInputs = !!document.querySelector(authSelector);
|
|
const lower = bodyText.toLowerCase();
|
|
const loginCTA =
|
|
lower.includes('sign in') ||
|
|
lower.includes('log in') ||
|
|
lower.includes('continue with google') ||
|
|
lower.includes('continue with apple') ||
|
|
lower.includes('continue with microsoft');
|
|
const loginRequired =
|
|
href.includes('/auth/') ||
|
|
href.includes('/login') ||
|
|
(hasAuthInputs && loginCTA) ||
|
|
(!hasAuthInputs && loginCTA && href.includes('chatgpt.com'));
|
|
const scrollY = (typeof window.scrollY === 'number') ? window.scrollY : 0;
|
|
const scrollHeight = document.documentElement ? (document.documentElement.scrollHeight || 0) : 0;
|
|
const viewportHeight = (typeof window.innerHeight === 'number') ? window.innerHeight : 0;
|
|
|
|
let creditsHeaderPresent = false;
|
|
let creditsHeaderInViewport = false;
|
|
let didScrollToCredits = false;
|
|
let rows = [];
|
|
try {
|
|
const looksLikeCreditsEventRow = (cells) => {
|
|
if (!cells || cells.length < 3) return false;
|
|
const first = String(cells[0] || '');
|
|
const amount = String(cells[2] || '');
|
|
return /\\d{4}|\\d{1,2}[\\/.\\-]\\d{1,2}/.test(first) && /\\d/.test(amount);
|
|
};
|
|
const allTableRows = () => Array.from(document.querySelectorAll('tbody tr')).map(tr => {
|
|
const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td));
|
|
return cells;
|
|
}).filter(looksLikeCreditsEventRow);
|
|
const headings = Array.from(document.querySelectorAll('h1,h2,h3'));
|
|
const header = headings.find(h => textOf(h).toLowerCase() === 'credits usage history');
|
|
if (header) {
|
|
creditsHeaderPresent = true;
|
|
const rect = header.getBoundingClientRect();
|
|
creditsHeaderInViewport = rect.top >= 0 && rect.top <= viewportHeight;
|
|
|
|
// Only scrape rows from the *credits usage history* table. The page can contain other tables,
|
|
// and treating any <table> as credits history can prevent our scroll-to-load logic from running.
|
|
const container = header.closest('section') || header.parentElement || document;
|
|
const table = container.querySelector('table') || null;
|
|
const scope = table || container;
|
|
rows = Array.from(scope.querySelectorAll('tbody tr')).map(tr => {
|
|
const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td));
|
|
return cells;
|
|
}).filter(r => r.length >= 3);
|
|
if (rows.length === 0) {
|
|
rows = allTableRows();
|
|
}
|
|
if (rows.length === 0 && !window.__codexbarDidScrollToCredits) {
|
|
window.__codexbarDidScrollToCredits = true;
|
|
// If the table is virtualized/lazy-loaded, we need to scroll to trigger rendering even if the
|
|
// header is already in view.
|
|
header.scrollIntoView({ block: 'start', inline: 'nearest' });
|
|
if (creditsHeaderInViewport) {
|
|
window.scrollBy(0, Math.max(220, viewportHeight * 0.6));
|
|
}
|
|
didScrollToCredits = true;
|
|
}
|
|
} else if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) {
|
|
rows = allTableRows();
|
|
if (rows.length > 0) {
|
|
creditsHeaderPresent = true;
|
|
creditsHeaderInViewport = true;
|
|
}
|
|
}
|
|
if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) {
|
|
// The credits history section often isn't part of the DOM until you scroll down. Nudge the page
|
|
// once so subsequent scrapes can find the header and rows.
|
|
window.__codexbarDidScrollToCredits = true;
|
|
window.scrollTo(0, Math.max(0, scrollHeight - viewportHeight - 40));
|
|
didScrollToCredits = true;
|
|
}
|
|
} catch {}
|
|
|
|
let creditsPurchaseURL = null;
|
|
try {
|
|
const creditsButton = findCreditsPurchaseButton();
|
|
if (creditsButton) {
|
|
const url = purchaseURLFromElement(creditsButton);
|
|
if (url) creditsPurchaseURL = url;
|
|
}
|
|
const candidates = Array.from(document.querySelectorAll('a, button'));
|
|
for (const node of candidates) {
|
|
const label = elementLabel(node);
|
|
if (!purchaseTextMatches(label)) continue;
|
|
const url = purchaseURLFromElement(node);
|
|
if (url) {
|
|
creditsPurchaseURL = url;
|
|
break;
|
|
}
|
|
}
|
|
if (!creditsPurchaseURL) {
|
|
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
for (const anchor of anchors) {
|
|
const label = elementLabel(anchor);
|
|
const href = anchor.getAttribute('href') || '';
|
|
const hrefLooksRelevant = /credits|billing/i.test(href);
|
|
if (!hrefLooksRelevant && !purchaseTextMatches(label)) continue;
|
|
const url = normalizeHref(href);
|
|
if (url) {
|
|
creditsPurchaseURL = url;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
let signedInEmail = null;
|
|
let authStatus = null;
|
|
let accountPlan = null;
|
|
try {
|
|
const next = window.__NEXT_DATA__ || null;
|
|
const props = (next && next.props && next.props.pageProps) ? next.props.pageProps : null;
|
|
const userEmail = (props && props.user) ? props.user.email : null;
|
|
const sessionEmail = (props && props.session && props.session.user) ? props.session.user.email : null;
|
|
signedInEmail = userEmail || sessionEmail || null;
|
|
} catch {}
|
|
|
|
const clientBootstrap = parseJSONScript('client-bootstrap');
|
|
if (clientBootstrap) {
|
|
try {
|
|
authStatus = typeof clientBootstrap.authStatus === 'string' ? clientBootstrap.authStatus : null;
|
|
if (!signedInEmail) {
|
|
const session = clientBootstrap.session || null;
|
|
const user = (session && session.user) || clientBootstrap.user || null;
|
|
const email = user && typeof user.email === 'string' ? user.email : null;
|
|
if (email && email.includes('@')) signedInEmail = email;
|
|
}
|
|
if (!accountPlan) accountPlan = findPlan(clientBootstrap);
|
|
} catch {}
|
|
}
|
|
if (!accountPlan) {
|
|
try {
|
|
accountPlan = findPlan(window.__NEXT_DATA__ || parseJSONScript('__NEXT_DATA__'));
|
|
} catch {}
|
|
}
|
|
|
|
if (!signedInEmail) {
|
|
try {
|
|
const obj = parseJSONScript('__NEXT_DATA__');
|
|
if (obj) {
|
|
const queue = [obj];
|
|
let seen = 0;
|
|
while (queue.length && seen < 2000 && !signedInEmail) {
|
|
const cur = queue.shift();
|
|
seen++;
|
|
if (!cur) continue;
|
|
if (typeof cur === 'string') {
|
|
if (cur.includes('@')) signedInEmail = cur;
|
|
continue;
|
|
}
|
|
if (typeof cur !== 'object') continue;
|
|
for (const [k, v] of Object.entries(cur)) {
|
|
if (signedInEmail) break;
|
|
if (k === 'email' && typeof v === 'string' && v.includes('@')) {
|
|
signedInEmail = v;
|
|
break;
|
|
}
|
|
if (typeof v === 'object' && v) queue.push(v);
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (!signedInEmail) {
|
|
try {
|
|
const emailRe = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/ig;
|
|
const found = (bodyText.match(emailRe) || []).map(x => String(x).trim().toLowerCase());
|
|
const unique = Array.from(new Set(found));
|
|
if (unique.length === 1) {
|
|
signedInEmail = unique[0];
|
|
} else if (unique.length > 1) {
|
|
signedInEmail = unique[0];
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (!signedInEmail) {
|
|
// Last resort: open the account menu so the email becomes part of the DOM text.
|
|
try {
|
|
const emailRe = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/ig;
|
|
const hasMenu = Boolean(document.querySelector('[role="menu"]'));
|
|
if (!hasMenu) {
|
|
const button =
|
|
document.querySelector('button[aria-haspopup="menu"]') ||
|
|
document.querySelector('button[aria-expanded]');
|
|
if (button && !button.disabled) {
|
|
button.click();
|
|
}
|
|
}
|
|
const afterText = document.body ? String(document.body.innerText || '').trim() : '';
|
|
const found = (afterText.match(emailRe) || []).map(x => String(x).trim().toLowerCase());
|
|
const unique = Array.from(new Set(found));
|
|
if (unique.length === 1) {
|
|
signedInEmail = unique[0];
|
|
} else if (unique.length > 1) {
|
|
signedInEmail = unique[0];
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return {
|
|
loginRequired,
|
|
workspacePicker,
|
|
cloudflareInterstitial,
|
|
href,
|
|
bodyText,
|
|
signedInEmail,
|
|
authStatus,
|
|
accountPlan,
|
|
creditsPurchaseURL,
|
|
rows,
|
|
usageBreakdownJSON,
|
|
usageBreakdownDebug,
|
|
usageBreakdownError,
|
|
scrollY,
|
|
scrollHeight,
|
|
viewportHeight,
|
|
creditsHeaderPresent,
|
|
creditsHeaderInViewport,
|
|
didScrollToCredits
|
|
};
|
|
})();
|
|
|
|
"""
|
|
#endif
|