Files
claude-usage/scraper.ts

324 lines
10 KiB
TypeScript
Raw Normal View History

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();
}
}