Created
November 18, 2025 04:06
-
-
Save jongan69/0773549415e2927de811aeeac4b2a799 to your computer and use it in GitHub Desktop.
CLI Github runnable action for observing page speed and seo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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