initial — claude usage tray (scraper.ts + tray.py)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
323
scraper.ts
Normal file
323
scraper.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { chromium, type BrowserContext } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME!, '.local/share/claude-usage/session');
|
||||
const STATE_FILE = path.join(SESSION_DIR, 'state.json');
|
||||
const USAGE_URL = 'https://claude.ai/settings/usage';
|
||||
|
||||
async function scrapeUsage(context: BrowserContext): Promise<string | null> {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
// Wait for usage data to render (poll for "used" text)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.waitForTimeout(1000);
|
||||
const t = await page.evaluate(() => document.body?.innerText || '');
|
||||
if (t.includes('% used')) break;
|
||||
}
|
||||
|
||||
const text = await page.evaluate(() => document.body?.innerText || '');
|
||||
|
||||
if (text.includes('Log in') || text.includes('Continue with Google') || text.includes('Continue with email')) {
|
||||
return null;
|
||||
}
|
||||
if (text.includes('security verification') || text.includes('not a bot')) {
|
||||
await page.waitForTimeout(10000);
|
||||
const t2 = await page.evaluate(() => document.body?.innerText || '');
|
||||
if (t2.includes('security verification')) return null;
|
||||
return t2;
|
||||
}
|
||||
|
||||
// Re-save session state to keep it fresh
|
||||
await context.storageState({ path: STATE_FILE });
|
||||
|
||||
return text;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsage(raw: string) {
|
||||
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// Extract "Current session" block
|
||||
const sessionIdx = lines.findIndex(l => l === 'Current session');
|
||||
if (sessionIdx >= 0) {
|
||||
for (let i = sessionIdx + 1; i < Math.min(sessionIdx + 5, lines.length); i++) {
|
||||
const resetMatch = lines[i].match(/Resets (.+)/);
|
||||
if (resetMatch) result.sessionResets = resetMatch[1];
|
||||
const pctMatch = lines[i].match(/(\d+)%\s*used/);
|
||||
if (pctMatch) result.sessionUsed = parseInt(pctMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract "All models" (weekly)
|
||||
const allModelsIdx = lines.findIndex(l => l === 'All models');
|
||||
if (allModelsIdx >= 0) {
|
||||
for (let i = allModelsIdx + 1; i < Math.min(allModelsIdx + 5, lines.length); i++) {
|
||||
const resetMatch = lines[i].match(/Resets (.+)/);
|
||||
if (resetMatch) result.weeklyResets = resetMatch[1];
|
||||
const pctMatch = lines[i].match(/(\d+)%\s*used/);
|
||||
if (pctMatch) result.weeklyUsed = parseInt(pctMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract "Sonnet only"
|
||||
const sonnetIdx = lines.findIndex(l => l === 'Sonnet only');
|
||||
if (sonnetIdx >= 0) {
|
||||
for (let i = sonnetIdx + 1; i < Math.min(sonnetIdx + 5, lines.length); i++) {
|
||||
const pctMatch = lines[i].match(/(\d+)%\s*used/);
|
||||
if (pctMatch) result.sonnetUsed = parseInt(pctMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract extra usage
|
||||
const extraIdx = lines.findIndex(l => l.includes('Extra usage'));
|
||||
if (extraIdx >= 0) {
|
||||
for (let i = extraIdx + 1; i < Math.min(extraIdx + 5, lines.length); i++) {
|
||||
const spentMatch = lines[i].match(/\$([\d.]+)\s*spent/);
|
||||
if (spentMatch) result.extraSpent = spentMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert 12h to 24h format (e.g. "Sat 12:00 PM" -> "Sat 12:00", "Sun 1:30 AM" -> "Sun 01:30")
|
||||
for (const key of ['sessionResets', 'weeklyResets']) {
|
||||
if (result[key]) {
|
||||
result[key] = result[key].replace(
|
||||
/(\d{1,2}):(\d{2})\s*(AM|PM)/i,
|
||||
(_: string, h: string, m: string, ampm: string) => {
|
||||
let hour = parseInt(h);
|
||||
if (ampm.toUpperCase() === 'PM' && hour !== 12) hour += 12;
|
||||
if (ampm.toUpperCase() === 'AM' && hour === 12) hour = 0;
|
||||
return hour.toString().padStart(2, '0') + ':' + m;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderUsage(data: Record<string, any>) {
|
||||
const GREEN = '\x1b[0;32m';
|
||||
const YELLOW = '\x1b[1;33m';
|
||||
const RED = '\x1b[0;31m';
|
||||
const CYAN = '\x1b[0;36m';
|
||||
const DIM = '\x1b[2m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const NC = '\x1b[0m';
|
||||
|
||||
function bar(pct: number, width = 20): string {
|
||||
const filled = Math.round(pct * width / 100);
|
||||
const empty = width - filled;
|
||||
const color = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN;
|
||||
return color + '█'.repeat(filled) + NC + DIM + '░'.repeat(empty) + NC;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`${BOLD} Claude Usage${NC}`);
|
||||
console.log(' ──────────────────────────────');
|
||||
|
||||
if (data.sessionUsed !== undefined) {
|
||||
console.log(` ${CYAN}Session${NC} ${bar(data.sessionUsed)} ${BOLD}${data.sessionUsed}%${NC} ${DIM}resets ${data.sessionResets || '?'}${NC}`);
|
||||
}
|
||||
|
||||
if (data.weeklyUsed !== undefined) {
|
||||
console.log(` ${CYAN}Weekly${NC} ${bar(data.weeklyUsed)} ${BOLD}${data.weeklyUsed}%${NC} ${DIM}resets ${data.weeklyResets || '?'}${NC}`);
|
||||
}
|
||||
|
||||
if (data.sonnetUsed !== undefined) {
|
||||
console.log(` ${CYAN}Sonnet${NC} ${bar(data.sonnetUsed)} ${BOLD}${data.sonnetUsed}%${NC}`);
|
||||
}
|
||||
|
||||
if (data.extraSpent !== undefined) {
|
||||
console.log(` ${DIM}Extra usage: $${data.extraSpent} spent${NC}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
console.error('Opening browser — log in to Claude...');
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
});
|
||||
|
||||
// Reuse existing session if available, so user doesn't get sent to login page
|
||||
const contextOptions: any = { viewport: { width: 1280, height: 800 } };
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
contextOptions.storageState = STATE_FILE;
|
||||
}
|
||||
const context = await browser.newContext(contextOptions);
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
console.error('Waiting for usage page...');
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await page.waitForTimeout(2000);
|
||||
const text = await page.evaluate(() => document.body?.innerText || '');
|
||||
if (text.includes('used') && text.includes('Resets')) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
await context.storageState({ path: STATE_FILE });
|
||||
console.error('Session saved!');
|
||||
const data = parseUsage(text);
|
||||
renderUsage(data);
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Timed out.');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function check() {
|
||||
// Must use headed mode to bypass Cloudflare.
|
||||
// Xvfb creates a truly invisible virtual display — no windows on screen.
|
||||
const display = ':' + (99 + Math.floor(Math.random() * 100));
|
||||
const xvfb = require('child_process').spawn('Xvfb', [
|
||||
display, '-screen', '0', '1280x720x24', '-nolisten', 'tcp',
|
||||
], { stdio: 'ignore', detached: true });
|
||||
xvfb.unref();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
],
|
||||
env: { ...process.env, DISPLAY: display },
|
||||
});
|
||||
|
||||
const origClose = browser.close.bind(browser);
|
||||
(browser as any).close = async () => {
|
||||
await origClose();
|
||||
try { xvfb.kill('SIGTERM'); } catch {}
|
||||
};
|
||||
|
||||
const context = await browser.newContext({
|
||||
storageState: STATE_FILE,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
});
|
||||
const text = await scrapeUsage(context);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
if (!text) {
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ loggedIn: false }));
|
||||
} else {
|
||||
console.error('Session expired. Run: claude-usage --login');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = parseUsage(text);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ ...data, loggedIn: true }));
|
||||
} else {
|
||||
renderUsage(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function live() {
|
||||
// Keep Chromium running, read DOM every 15 seconds, output JSON lines to stdout.
|
||||
// The page updates itself live, so no need to reload.
|
||||
const display = ':' + (99 + Math.floor(Math.random() * 100));
|
||||
const xvfb = require('child_process').spawn('Xvfb', [
|
||||
display, '-screen', '0', '1280x720x24', '-nolisten', 'tcp',
|
||||
], { stdio: 'ignore', detached: true });
|
||||
xvfb.unref();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-gpu'],
|
||||
env: { ...process.env, DISPLAY: display },
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
try { await browser.close(); } catch {}
|
||||
try { xvfb.kill('SIGTERM'); } catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', cleanup);
|
||||
process.on('SIGINT', cleanup);
|
||||
|
||||
const context = await browser.newContext({
|
||||
storageState: STATE_FILE,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
// Wait for initial load
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.waitForTimeout(1000);
|
||||
const t = await page.evaluate(() => document.body?.innerText || '');
|
||||
if (t.includes('% used')) break;
|
||||
}
|
||||
|
||||
// Keep session fresh
|
||||
await context.storageState({ path: STATE_FILE });
|
||||
|
||||
// Poll DOM every 15 seconds and output JSON lines
|
||||
while (true) {
|
||||
try {
|
||||
const text = await page.evaluate(() => document.body?.innerText || '');
|
||||
if (text.includes('% used')) {
|
||||
const data = parseUsage(text);
|
||||
console.log(JSON.stringify({ ...data, loggedIn: true }));
|
||||
} else if (text.includes('Log in') || text.includes('Continue with')) {
|
||||
console.log(JSON.stringify({ loggedIn: false }));
|
||||
await cleanup();
|
||||
}
|
||||
} catch {
|
||||
console.log(JSON.stringify({ loggedIn: false }));
|
||||
await cleanup();
|
||||
}
|
||||
await page.waitForTimeout(15000);
|
||||
}
|
||||
}
|
||||
|
||||
// Main
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
|
||||
if (process.argv.includes('--login')) {
|
||||
await login();
|
||||
} else if (process.argv.includes('--live')) {
|
||||
if (!fs.existsSync(STATE_FILE)) {
|
||||
console.log(JSON.stringify({ loggedIn: false }));
|
||||
process.exit(1);
|
||||
}
|
||||
await live();
|
||||
} else if (fs.existsSync(STATE_FILE)) {
|
||||
await check();
|
||||
} else {
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ loggedIn: false }));
|
||||
} else {
|
||||
await login();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user