Skip to content

Instantly share code, notes, and snippets.

@jongan69
Created November 18, 2025 04:06
Show Gist options
  • Select an option

  • Save jongan69/0773549415e2927de811aeeac4b2a799 to your computer and use it in GitHub Desktop.

Select an option

Save jongan69/0773549415e2927de811aeeac4b2a799 to your computer and use it in GitHub Desktop.
CLI Github runnable action for observing page speed and seo
#!/usr/bin/env node
/**
* PageSpeed Insights CLI Tool (Node.js)
* No API key required β€’ Supports mobile & desktop
* Works in GitHub Actions
*/
import https from 'https';
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { writeFileSync } from 'fs';
// Load .env files (supports .env, .env.local, .env.production, etc.)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, '..');
// Try to load .env files (in order of priority)
config({ path: join(projectRoot, '.env.local') });
config({ path: join(projectRoot, '.env') });
// Try to load chalk, but work without it for GitHub Actions
let chalk;
try {
const chalkModule = await import('chalk');
chalk = chalkModule.default || chalkModule;
} catch (e) {
// Fallback for environments without chalk
chalk = {
cyan: (s) => s,
green: (s) => s,
yellow: (s) => s,
red: (s) => s,
bold: (s) => s,
underline: (s) => s,
gray: (s) => s,
};
}
const API_ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
/**
* Run PageSpeed Insights analysis
* @param {string} url - The URL to analyze
* @param {string} strategy - 'mobile' or 'desktop'
* @param {string[]} categories - Array of categories: 'PERFORMANCE', 'ACCESSIBILITY', 'BEST_PRACTICES', 'SEO'
* @param {string|null} apiKey - Optional Google Cloud API key for higher quotas
* @returns {Promise<Object>} PageSpeed Insights result
*/
async function runPageSpeed(url, strategy = 'mobile', categories = ['performance'], apiKey = null) {
// Build URLSearchParams properly - multiple category parameters need to be appended separately
const params = new URLSearchParams({
url,
strategy,
utm_source: 'psi_node_script',
utm_campaign: 'speed_test',
});
// Add API key if provided (enables higher quotas: 25,000/day vs lower free tier limits)
if (apiKey) {
params.append('key', apiKey);
}
// Append each category as a separate query parameter
// Categories should be: ACCESSIBILITY, BEST_PRACTICES, PERFORMANCE, SEO
categories.forEach(cat => {
// Convert to uppercase and handle hyphenated names
const categoryName = cat.toUpperCase().replace('-', '_');
params.append('category', categoryName);
});
const requestUrl = `${API_ENDPOINT}?${params.toString()}`;
return new Promise((resolve, reject) => {
https.get(requestUrl, { timeout: 30000 }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 500)}`));
} else {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
}
});
}).on('error', reject);
});
}
function getScoreColor(score) {
if (score >= 90) return chalk.green.bold;
if (score >= 50) return chalk.yellow.bold;
return chalk.red.bold;
}
function extractScores(result) {
const categories = result.lighthouseResult?.categories || {};
const scores = {};
for (const [key, cat] of Object.entries(categories)) {
if (cat.score !== null) {
const score = Math.round(cat.score * 100);
scores[cat.title || key] = score;
}
}
return scores;
}
function extractKeyMetrics(result) {
const audits = result.lighthouseResult?.audits || {};
const metrics = {
'First Contentful Paint': audits['first-contentful-paint']?.displayValue,
'Largest Contentful Paint': audits['largest-contentful-paint']?.displayValue,
'Total Blocking Time': audits['total-blocking-time']?.displayValue,
'Cumulative Layout Shift': audits['cumulative-layout-shift']?.displayValue,
'Speed Index': audits['speed-index']?.displayValue,
'Time to Interactive': audits['interactive']?.displayValue,
};
return Object.fromEntries(Object.entries(metrics).filter(([_, v]) => v));
}
async function testUrl(targetUrl, strategy, apiKey = null) {
console.log(chalk.cyan(`\nRunning PageSpeed Insights (${strategy.toUpperCase()}) for: ${targetUrl}\n`));
if (apiKey) {
console.log(chalk.gray('Using API key for higher quota limits\n'));
} else {
console.log(chalk.yellow('⚠️ No API key provided - using free tier (lower quota limits)\n'));
}
try {
// Test all categories: PERFORMANCE, ACCESSIBILITY, BEST_PRACTICES, SEO
// Note: API expects uppercase with underscores for hyphenated names
const data = await runPageSpeed(targetUrl, strategy, ['PERFORMANCE', 'ACCESSIBILITY', 'BEST_PRACTICES', 'SEO'], apiKey);
const scores = extractScores(data);
const metrics = extractKeyMetrics(data);
console.log(chalk.bold.underline(`Results for ${strategy.toUpperCase()}`));
console.log(`Final URL: ${data.id}`);
console.log(`Tested at: ${data.analysisUTCTimestamp.split('T')[0]}\n`);
// Scores
for (const [name, score] of Object.entries(scores)) {
const emoji = score >= 90 ? '🟒' : score >= 50 ? '🟠' : 'πŸ”΄';
console.log(` ${emoji} ${chalk.bold(name)}: ${getScoreColor(score)(score + '/100')}`);
}
// Key Metrics
if (Object.keys(metrics).length > 0) {
console.log(chalk.bold('\nCore Web Vitals & Metrics:'));
for (const [name, value] of Object.entries(metrics)) {
console.log(` β€’ ${name}: ${chalk.cyan(value)}`);
}
}
// Final verdict
const perf = scores['Performance'] || 0;
if (perf >= 90) console.log(chalk.green.bold('\nπŸš€ Excellent performance!'));
else if (perf >= 50) console.log(chalk.yellow.bold('\n⚑ Needs improvement – focus on images, JS, and caching'));
else console.log(chalk.red.bold('\n🐌 Slow – major optimizations needed'));
// Return scores for GitHub Actions
return { scores, metrics, performance: perf };
} catch (err) {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
}
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(chalk.red('Usage: pagespeed <url> [mobile|desktop|both] [--key=API_KEY]'));
console.log(chalk.gray('Example: pagespeed https://example.com both'));
console.log(chalk.gray('Example with API key: pagespeed https://example.com both --key=YOUR_API_KEY'));
console.log(chalk.gray('\nQuota Information:'));
console.log(chalk.gray(' β€’ Free tier (no API key): Lower limits, may hit 429 errors'));
console.log(chalk.gray(' β€’ With API key: 25,000 queries/day, 240 queries/minute'));
console.log(chalk.gray(' β€’ Request quota increase: https://console.cloud.google.com/apis/api/pagespeedonline.googleapis.com/quotas'));
process.exit(1);
}
let url = args[0];
let mode = 'both';
let apiKey = process.env.PAGESPEED_API_KEY || null;
// Parse arguments
for (let i = 1; i < args.length; i++) {
const arg = args[i].toLowerCase();
if (['mobile', 'desktop', 'both'].includes(arg)) {
mode = arg;
} else if (arg.startsWith('--key=')) {
apiKey = arg.split('=')[1];
} else if (arg === '--key' && args[i + 1]) {
apiKey = args[++i];
}
}
if (!url.match(/^https?:\/\//i)) {
url = 'https://' + url;
}
const strategies = mode === 'both' ? ['mobile', 'desktop'] : [mode];
// Check for --output flag
let outputFile = null;
const outputIndex = args.findIndex(arg => arg === '--output' || arg.startsWith('--output='));
if (outputIndex !== -1) {
if (args[outputIndex].includes('=')) {
outputFile = args[outputIndex].split('=')[1];
} else if (args[outputIndex + 1]) {
outputFile = args[outputIndex + 1];
}
}
const results = {};
for (const strat of strategies) {
if (!['mobile', 'desktop'].includes(strat)) {
console.log(chalk.red(`Invalid strategy: ${strat}. Use mobile, desktop, or both.`));
process.exit(1);
}
const result = await testUrl(url, strat, apiKey);
results[strat] = result;
if (strategies.length > 1) console.log('\n' + '='.repeat(60) + '\n');
}
// Write results to JSON file if --output is specified
if (outputFile) {
const outputData = {
url,
timestamp: new Date().toISOString(),
results,
};
writeFileSync(outputFile, JSON.stringify(outputData, null, 2));
console.log(chalk.green(`\nβœ… Results saved to ${outputFile}`));
}
}
// Handle top-level await for chalk import
(async () => {
await main();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment