From 7052227f69da9f3e17d5d6c281afa98ae2113bac Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Sat, 20 Jun 2026 06:23:33 +0000 Subject: [PATCH] feat: X API Cost Crisis Alert Chrome extension MVP with cost calculator, 8 alternatives, and 12 passing tests --- .gitignore | 7 + dist-test/src/background.js | 13 + dist-test/src/content/content.js | 92 ++ dist-test/src/data/alternatives.js | 142 +++ dist-test/src/popup/popup.js | 110 ++ dist-test/src/types.js | 1 + dist-test/test/alternatives.test.js | 73 ++ generate-icons.cjs | 65 ++ icons/icon128.png | Bin 0 -> 306 bytes icons/icon16.png | Bin 0 -> 79 bytes icons/icon48.png | Bin 0 -> 123 bytes package-lock.json | 1500 +++++++++++++++++++++++++++ package.json | 17 + src/background.ts | 13 + src/content/content.css | 137 +++ src/content/content.ts | 102 ++ src/data/alternatives.ts | 150 +++ src/manifest.json | 33 + src/popup/popup.css | 268 +++++ src/popup/popup.html | 72 ++ src/popup/popup.ts | 120 +++ src/types.ts | 19 + test/alternatives.test.ts | 85 ++ tsconfig.json | 15 + vite.config.ts | 16 + 25 files changed, 3050 insertions(+) create mode 100644 .gitignore create mode 100644 dist-test/src/background.js create mode 100644 dist-test/src/content/content.js create mode 100644 dist-test/src/data/alternatives.js create mode 100644 dist-test/src/popup/popup.js create mode 100644 dist-test/src/types.js create mode 100644 dist-test/test/alternatives.test.js create mode 100644 generate-icons.cjs create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/background.ts create mode 100644 src/content/content.css create mode 100644 src/content/content.ts create mode 100644 src/data/alternatives.ts create mode 100644 src/manifest.json create mode 100644 src/popup/popup.css create mode 100644 src/popup/popup.html create mode 100644 src/popup/popup.ts create mode 100644 src/types.ts create mode 100644 test/alternatives.test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..944ffae --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.verdict +.venv/ +.DS_Store +*.local diff --git a/dist-test/src/background.js b/dist-test/src/background.js new file mode 100644 index 0000000..e741a4f --- /dev/null +++ b/dist-test/src/background.js @@ -0,0 +1,13 @@ +"use strict"; +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.set({ installed: true, version: '1.0.0' }); +}); +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url) { + const isDevPage = tab.url.includes('developer.twitter.com') || tab.url.includes('developer.x.com'); + if (isDevPage) { + chrome.action.setBadgeText({ text: '!', tabId }); + chrome.action.setBadgeBackgroundColor({ color: '#ef4444' }); + } + } +}); diff --git a/dist-test/src/content/content.js b/dist-test/src/content/content.js new file mode 100644 index 0000000..a36d1a1 --- /dev/null +++ b/dist-test/src/content/content.js @@ -0,0 +1,92 @@ +import { xApiAlternatives, calculateXCost } from '../data/alternatives.js'; +function injectWarning() { + const existing = document.getElementById('x-api-crisis-warning'); + if (existing) + return; + const banner = document.createElement('div'); + banner.id = 'x-api-crisis-warning'; + banner.className = 'x-api-crisis-banner'; + banner.innerHTML = ` +
+ 🚨 +
+ X API Cost Crisis Alert + New pay-per-use pricing: $10k/mo for 2M reads. Check alternatives before building. +
+ See Alternatives +
+ + `; + document.body.insertBefore(banner, document.body.firstChild); + const btn = document.getElementById('x-api-crisis-show-alts'); + const alts = document.getElementById('x-api-crisis-alts'); + if (btn && alts) { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const visible = alts.style.display !== 'none'; + alts.style.display = visible ? 'none' : 'block'; + btn.textContent = visible ? 'See Alternatives' : 'Hide Alternatives'; + }); + } +} +function injectPricingCalculator() { + const existing = document.getElementById('x-api-crisis-calc'); + if (existing) + return; + const calc = document.createElement('div'); + calc.id = 'x-api-crisis-calc'; + calc.className = 'x-api-crisis-calc'; + calc.innerHTML = ` +

💰 Indie Dev Cost Calculator

+ + +
+ `; + const sidebar = document.querySelector('aside, .sidebar, [role="complementary"]'); + if (sidebar) { + sidebar.prepend(calc); + } + else { + const main = document.querySelector('main, article, .content, [role="main"]'); + if (main) { + main.prepend(calc); + } + } + const readsInput = document.getElementById('x-crisis-reads'); + const writesInput = document.getElementById('x-crisis-writes'); + const resultEl = document.getElementById('x-crisis-result'); + function update() { + if (!resultEl || !readsInput || !writesInput) + return; + const r = parseInt(readsInput.value, 10) || 0; + const w = parseInt(writesInput.value, 10) || 0; + const est = calculateXCost(r, w); + const altCost = r <= 50000 ? '$0 (Nitter/RSSHub)' : r <= 500000 ? '~$5-49/mo (ScrapingBee/Apify)' : '~$49-200/mo (Apify premium)'; + resultEl.innerHTML = ` +
X API Cost:${est.cost >= 1000 ? '$' + (est.cost / 1000).toFixed(1) + 'k' : '$' + est.cost}/mo
+
Alternative Cost:${altCost}
+
Savings:${est.cost > 0 ? '90-100%' : 'N/A'}
+ `; + } + readsInput?.addEventListener('input', update); + writesInput?.addEventListener('input', update); + update(); +} +function run() { + injectWarning(); + if (window.location.pathname.includes('pricing') || window.location.pathname.includes('api')) { + injectPricingCalculator(); + } +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); +} +else { + run(); +} diff --git a/dist-test/src/data/alternatives.js b/dist-test/src/data/alternatives.js new file mode 100644 index 0000000..094b1d9 --- /dev/null +++ b/dist-test/src/data/alternatives.js @@ -0,0 +1,142 @@ +export const xApiAlternatives = [ + { + name: 'Nitter (community instances)', + website: 'https://nitter.net', + freeTier: 'Unlimited (self-hosted or public instances)', + paidTier: 'Self-hosting ~$5/mo VPS', + pricingUrl: 'https://github.com/zedeus/nitter', + description: 'Privacy-friendly Twitter/X front-end that scrapes public profiles and tweets without API keys.', + features: ['No API key required', 'RSS feeds for any user', 'Read-only', 'Self-hostable'], + bestFor: 'Read-only use cases, RSS feeds, monitoring public accounts', + xApiReplacement: 'GET statuses/user_timeline, GET search/tweets' + }, + { + name: 'BirdsiteLive (Mastodon bridge)', + website: 'https://github.com/NicolasConstant/BirdsiteLive', + freeTier: 'Self-hosted', + paidTier: 'VPS ~$5-10/mo', + pricingUrl: 'https://github.com/NicolasConstant/BirdsiteLive', + description: 'Bridge that lets you follow Twitter/X accounts from Mastodon. No API billing.', + features: ['Follow X accounts from Mastodon', 'No X API key', 'ActivityPub federation', 'Self-hostable'], + bestFor: 'Social media monitoring via Mastodon ecosystem', + xApiReplacement: 'GET followers/ids, GET friends/ids' + }, + { + name: 'RSSHub', + website: 'https://rsshub.app', + freeTier: 'Public instance free, self-hosted free', + paidTier: 'Self-host ~$5/mo or donate', + pricingUrl: 'https://docs.rsshub.app/', + description: 'Open-source RSS feed generator that supports Twitter/X timelines without API.', + features: ['RSS feeds for tweets', 'No API key needed', '300+ sites supported', 'Self-hostable'], + bestFor: 'RSS-based monitoring, newsletters, automation pipelines', + xApiReplacement: 'GET statuses/user_timeline' + }, + { + name: 'Wayback Machine / Archive.org', + website: 'https://archive.org', + freeTier: 'Unlimited reads', + paidTier: 'Free', + pricingUrl: 'https://archive.org/donate/', + description: 'Archived snapshots of public Twitter/X pages. Good for historical data.', + features: ['Historical tweet access', 'No rate limits', 'Free forever', 'Bulk export via CDX API'], + bestFor: 'Historical research, compliance, deleted tweet recovery', + xApiReplacement: 'Historical tweet lookup' + }, + { + name: 'TweetScraper (开源 Python)', + website: 'https://github.com/twintproject/twint', + freeTier: 'Unlimited (no API)', + paidTier: 'Free (just your machine)', + pricingUrl: 'https://github.com/twintproject/twint', + description: 'Python library that scrapes tweets using Twitter\'s internal guest endpoints.', + features: ['No API key', 'Unlimited scraping', 'No rate limits', 'JSON/CSV export'], + bestFor: 'Data science, research, bulk exports', + xApiReplacement: 'GET search/tweets, GET statuses/user_timeline' + }, + { + name: 'Apify Twitter Scraper', + website: 'https://apify.com/quacker/twitter-scraper', + freeTier: '5$/mo platform credit', + paidTier: '~$49/mo for 100K tweets', + pricingUrl: 'https://apify.com/pricing', + description: 'Managed cloud scraping with proxy rotation. Pays per compute, not per API call.', + features: ['Proxy rotation', 'Structured JSON output', 'Scheduled runs', 'No API rate limits'], + bestFor: 'Production pipelines needing reliable extraction without X API billing', + xApiReplacement: 'Full read/write replacement via scraping' + }, + { + name: 'ScrapingBee', + website: 'https://www.scrapingbee.com', + freeTier: '1,000 API credits', + paidTier: 'From $49/mo (150K credits)', + pricingUrl: 'https://www.scrapingbee.com/pricing/', + description: 'Web scraping API with proxy rotation. Extract Twitter/X public pages via URL.', + features: ['JavaScript rendering', 'Proxy rotation', 'No X API key', 'Generic web scraper'], + bestFor: 'Generic web scraping including Twitter/X public pages', + xApiReplacement: 'GET any public Twitter page' + }, + { + name: 'Mastodon API (switch platform)', + website: 'https://docs.joinmastodon.org/api/', + freeTier: 'Unlimited on most instances', + paidTier: 'Free (most instances) or ~$5/mo hosting', + pricingUrl: 'https://joinmastodon.org/servers', + description: 'Federated Twitter alternative with fully open, free API. No pay-per-use.', + features: ['REST + Streaming API', 'No rate limits on most instances', 'OAuth 2.0', 'Free forever'], + bestFor: 'Building social tools without platform risk or billing surprises', + xApiReplacement: 'Full Twitter API replacement (different platform)' + } +]; +export const xApiTiers = { + free: { + name: 'Free', + readLimit: 1500, + writeLimit: 0, + monthlyCost: 0 + }, + basic: { + name: 'Basic', + readLimit: 10000, + writeLimit: 50000, + monthlyCost: 100 + }, + pro: { + name: 'Pro', + readLimit: 1000000, + writeLimit: 300000, + monthlyCost: 5000 + }, + enterprise: { + name: 'Enterprise', + readLimit: 20000000, + writeLimit: 10000000, + monthlyCost: 42000 + } +}; +export function calculateXCost(reads, writes) { + const tiers = [ + { name: 'Free', maxReads: 1500, maxWrites: 0, cost: 0 }, + { name: 'Basic', maxReads: 10000, maxWrites: 50000, cost: 100 }, + { name: 'Pro', maxReads: 1000000, maxWrites: 300000, cost: 5000 }, + { name: 'Enterprise', maxReads: 20000000, maxWrites: 10000000, cost: 42000 }, + ]; + for (const tier of tiers) { + if (reads <= tier.maxReads && writes <= tier.maxWrites) { + return { tier: tier.name, cost: tier.cost, overage: false }; + } + } + return { tier: 'Enterprise+', cost: 42000 + Math.ceil((reads - 20000000) / 1000000) * 10000, overage: true }; +} +export function getAlternativesForUseCase(useCase) { + const map = { + 'rss': ['RSSHub', 'Nitter (community instances)'], + 'scraping': ['TweetScraper (开源 Python)', 'Apify Twitter Scraper', 'ScrapingBee'], + 'monitoring': ['Nitter (community instances)', 'RSSHub', 'BirdsiteLive (Mastodon bridge)'], + 'historical': ['Wayback Machine / Archive.org'], + 'social-bot': ['Mastodon API (switch platform)', 'BirdsiteLive (Mastodon bridge)'], + 'default': ['Nitter (community instances)', 'RSSHub', 'TweetScraper (开源 Python)'] + }; + const names = map[useCase] || map['default']; + return xApiAlternatives.filter(a => names.includes(a.name)); +} diff --git a/dist-test/src/popup/popup.js b/dist-test/src/popup/popup.js new file mode 100644 index 0000000..30bf5f1 --- /dev/null +++ b/dist-test/src/popup/popup.js @@ -0,0 +1,110 @@ +import { xApiAlternatives, calculateXCost, getAlternativesForUseCase } from '../data/alternatives.js'; +function formatCurrency(n) { + if (n >= 1000) + return '$' + (n / 1000).toFixed(1) + 'k'; + return '$' + n.toString(); +} +function updateCostEstimate() { + const readsInput = document.getElementById('reads'); + const writesInput = document.getElementById('writes'); + const tierNameEl = document.getElementById('tier-name'); + const xCostEl = document.getElementById('x-cost'); + const warningEl = document.getElementById('warning'); + if (!readsInput || !writesInput || !tierNameEl || !xCostEl || !warningEl) + return; + const reads = parseInt(readsInput.value, 10) || 0; + const writes = parseInt(writesInput.value, 10) || 0; + const estimate = calculateXCost(reads, writes); + tierNameEl.textContent = estimate.tier; + xCostEl.textContent = formatCurrency(estimate.cost); + if (estimate.overage) { + warningEl.textContent = '⚠️ Exceeds Enterprise tier. Contact X sales or use alternatives below.'; + warningEl.classList.add('show'); + } + else if (estimate.cost === 0) { + warningEl.textContent = '✅ Free tier covers your usage. Keep an eye on the 1,500 read limit.'; + warningEl.classList.add('show'); + } + else if (estimate.tier === 'Enterprise') { + warningEl.textContent = '⚠️ $42k/mo. Strongly consider alternatives below.'; + warningEl.classList.add('show'); + } + else if (estimate.tier === 'Pro') { + warningEl.textContent = '⚠️ $5k/mo. Alternatives can save 90%+'; + warningEl.classList.add('show'); + } + else { + warningEl.textContent = ''; + warningEl.classList.remove('show'); + } +} +function renderAlternatives(filter) { + const list = document.getElementById('alt-list'); + if (!list) + return; + const alts = filter === 'all' ? xApiAlternatives : getAlternativesForUseCase(filter); + if (alts.length === 0) { + list.innerHTML = '
No alternatives found for this category.
'; + return; + } + list.innerHTML = alts.map((alt) => { + const isFree = alt.paidTier.includes('Free') || alt.paidTier.includes('self-hosted') || alt.paidTier.includes('Unlimited'); + const badgeClass = isFree ? 'badge' : alt.paidTier.includes('$') ? 'badge paid' : 'badge scraping'; + const badgeText = isFree ? 'FREE' : 'PAID'; + return ` +
+

+ ${alt.name} + ${badgeText} +

+

${alt.description}

+
+ 💰 ${alt.freeTier} + Website → + Pricing → +
+
+ ${alt.features.map(f => `${f}`).join('')} +
+
+ `; + }).join(''); +} +function renderComparison() { + const tbody = document.querySelector('#comparison-table tbody'); + if (!tbody) + return; + tbody.innerHTML = xApiAlternatives.map(alt => ` + + ${alt.name} + ${alt.freeTier} + ${alt.paidTier} + ${alt.bestFor} + + `).join(''); +} +function init() { + const readsInput = document.getElementById('reads'); + const writesInput = document.getElementById('writes'); + if (readsInput) + readsInput.addEventListener('input', updateCostEstimate); + if (writesInput) + writesInput.addEventListener('input', updateCostEstimate); + updateCostEstimate(); + const filterButtons = document.querySelectorAll('.filter-btn'); + filterButtons.forEach(btn => { + btn.addEventListener('click', () => { + filterButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderAlternatives(btn.dataset.filter || 'all'); + }); + }); + renderAlternatives('all'); + renderComparison(); +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} +else { + init(); +} diff --git a/dist-test/src/types.js b/dist-test/src/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist-test/src/types.js @@ -0,0 +1 @@ +export {}; diff --git a/dist-test/test/alternatives.test.js b/dist-test/test/alternatives.test.js new file mode 100644 index 0000000..6cee864 --- /dev/null +++ b/dist-test/test/alternatives.test.js @@ -0,0 +1,73 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { calculateXCost, getAlternativesForUseCase, xApiAlternatives } from '../src/data/alternatives.js'; +describe('calculateXCost', () => { + it('returns Free tier for 0 reads and 0 writes', () => { + const result = calculateXCost(0, 0); + assert.strictEqual(result.tier, 'Free'); + assert.strictEqual(result.cost, 0); + assert.strictEqual(result.overage, false); + }); + it('returns Free tier for 1500 reads and 0 writes', () => { + const result = calculateXCost(1500, 0); + assert.strictEqual(result.tier, 'Free'); + assert.strictEqual(result.cost, 0); + }); + it('returns Basic tier for 5000 reads and 100 writes', () => { + const result = calculateXCost(5000, 100); + assert.strictEqual(result.tier, 'Basic'); + assert.strictEqual(result.cost, 100); + }); + it('returns Pro tier for 500000 reads and 100000 writes', () => { + const result = calculateXCost(500000, 100000); + assert.strictEqual(result.tier, 'Pro'); + assert.strictEqual(result.cost, 5000); + }); + it('returns Enterprise tier for 5M reads and 500K writes', () => { + const result = calculateXCost(5000000, 500000); + assert.strictEqual(result.tier, 'Enterprise'); + assert.strictEqual(result.cost, 42000); + }); + it('returns Enterprise+ with overage for 25M reads', () => { + const result = calculateXCost(25000000, 1000000); + assert.strictEqual(result.tier, 'Enterprise+'); + assert.strictEqual(result.overage, true); + assert.strictEqual(result.cost, 92000); + }); +}); +describe('getAlternativesForUseCase', () => { + it('returns RSS alternatives for rss filter', () => { + const alts = getAlternativesForUseCase('rss'); + assert.ok(alts.length > 0); + assert.ok(alts.some(a => a.name.includes('RSSHub'))); + }); + it('returns scraping alternatives for scraping filter', () => { + const alts = getAlternativesForUseCase('scraping'); + assert.ok(alts.some(a => a.name.includes('TweetScraper'))); + }); + it('returns default alternatives for unknown filter', () => { + const alts = getAlternativesForUseCase('unknown-category'); + assert.ok(alts.length > 0); + }); +}); +describe('xApiAlternatives data', () => { + it('has at least 8 alternatives', () => { + assert.ok(xApiAlternatives.length >= 8); + }); + it('every alternative has required fields', () => { + for (const alt of xApiAlternatives) { + assert.ok(alt.name, 'name is required'); + assert.ok(alt.website, 'website is required'); + assert.ok(alt.freeTier, 'freeTier is required'); + assert.ok(alt.paidTier, 'paidTier is required'); + assert.ok(alt.description, 'description is required'); + assert.ok(alt.features.length > 0, 'features must not be empty'); + assert.ok(alt.bestFor, 'bestFor is required'); + } + }); + it('all websites are valid URLs', () => { + for (const alt of xApiAlternatives) { + assert.ok(alt.website.startsWith('http'), `${alt.name} website must start with http`); + } + }); +}); diff --git a/generate-icons.cjs b/generate-icons.cjs new file mode 100644 index 0000000..8d4c1fe --- /dev/null +++ b/generate-icons.cjs @@ -0,0 +1,65 @@ +const fs = require('fs'); +const path = require('path'); + +// Minimal valid 1x1 red PNG (base64) - scaled up to requested sizes via simple pixel repetition +// For a real extension build, we just need valid PNG files. Chrome won't validate content during build. +// We'll create minimal valid PNG files. + +function createSolidColorPNG(width, height, r, g, b) { + // PNG signature + const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + // Helper: create PNG chunk + function makeChunk(type, data) { + const typeBuf = Buffer.from(type, 'ascii'); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + const crc = require('zlib').crc32(Buffer.concat([typeBuf, data])); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc, 0); + return Buffer.concat([lenBuf, typeBuf, data, crcBuf]); + } + + // IHDR chunk + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // color type RGB + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter method + ihdr[12] = 0; // interlace + + // IDAT chunk - raw image data: filter byte + RGB pixels per row + const rowSize = 1 + width * 3; + const rawData = Buffer.alloc(height * rowSize); + for (let y = 0; y < height; y++) { + rawData[y * rowSize] = 0; // filter byte: none + for (let x = 0; x < width; x++) { + rawData[y * rowSize + 1 + x * 3] = r; + rawData[y * rowSize + 1 + x * 3 + 1] = g; + rawData[y * rowSize + 1 + x * 3 + 2] = b; + } + } + + const compressed = require('zlib').deflateSync(rawData); + + // IEND chunk + const iend = makeChunk('IEND', Buffer.alloc(0)); + + return Buffer.concat([ + signature, + makeChunk('IHDR', ihdr), + makeChunk('IDAT', compressed), + iend + ]); +} + +const outDir = path.join(__dirname, 'icons'); +fs.mkdirSync(outDir, { recursive: true }); + +fs.writeFileSync(path.join(outDir, 'icon16.png'), createSolidColorPNG(16, 16, 239, 68, 68)); +fs.writeFileSync(path.join(outDir, 'icon48.png'), createSolidColorPNG(48, 48, 239, 68, 68)); +fs.writeFileSync(path.join(outDir, 'icon128.png'), createSolidColorPNG(128, 128, 239, 68, 68)); + +console.log('Icons generated successfully'); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..469a5e67135f3fa8ad7e28c4f979cc97b9be6a8e GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRdwrKRsO>Ln`LHz39lvV8C`Mcz~JfX=d#Wzp$P!-{7i!Y literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..01ce33d03716a3e92be235e40958f8a896499501 GIT binary patch literal 79 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|VxBIJAr*6yE8ahH5=xS|YOpHt cM-~GE`@wR7oADC2fGQX~UHx3vIVCg!0GE{%Y5)KL literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..12745df8ee7bc153436988e9389c6986267b3de2 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1SD@H=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.268", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.268.tgz", + "integrity": "sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@webcomponents/custom-elements": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", + "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz", + "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", + "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1cf68aa --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "x-api-cost-crisis-for-indie-dev-tools", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "vite build", + "test": "node --test dist-test/test/alternatives.test.js", + "dev": "vite" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "@types/node": "^20.12.0", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } +} diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..4a531e7 --- /dev/null +++ b/src/background.ts @@ -0,0 +1,13 @@ +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.set({ installed: true, version: '1.0.0' }) +}) + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url) { + const isDevPage = tab.url.includes('developer.twitter.com') || tab.url.includes('developer.x.com') + if (isDevPage) { + chrome.action.setBadgeText({ text: '!', tabId }) + chrome.action.setBadgeBackgroundColor({ color: '#ef4444' }) + } + } +}) diff --git a/src/content/content.css b/src/content/content.css new file mode 100644 index 0000000..9c81c30 --- /dev/null +++ b/src/content/content.css @@ -0,0 +1,137 @@ +.x-api-crisis-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999999; + background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); + color: #fff; + padding: 12px 16px; + font-family: system-ui, -apple-system, sans-serif; + font-size: 14px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +.x-api-crisis-inner { + display: flex; + align-items: center; + gap: 12px; + max-width: 1200px; + margin: 0 auto; +} + +.x-api-crisis-icon { + font-size: 20px; + flex-shrink: 0; +} + +.x-api-crisis-text { + display: flex; + flex-direction: column; + flex: 1; + gap: 2px; +} + +.x-api-crisis-text strong { + font-size: 15px; +} + +.x-api-crisis-text span { + font-size: 13px; + opacity: 0.95; +} + +.x-api-crisis-btn { + background: #fff; + color: #dc2626; + padding: 6px 14px; + border-radius: 6px; + text-decoration: none; + font-weight: 600; + font-size: 13px; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s; +} + +.x-api-crisis-btn:hover { + background: #fef2f2; +} + +.x-api-crisis-alts { + max-width: 1200px; + margin: 10px auto 0; + padding: 12px 16px; + background: rgba(0,0,0,0.25); + border-radius: 8px; + font-size: 13px; +} + +.x-api-crisis-alts ul { + margin: 8px 0; + padding-left: 20px; +} + +.x-api-crisis-alts li { + margin: 4px 0; +} + +.x-api-crisis-alts a { + color: #fecaca; + text-decoration: underline; +} + +.x-api-crisis-calc { + background: #1e293b; + border: 1px solid #334155; + border-radius: 8px; + padding: 14px; + margin: 12px 0; + color: #f1f5f9; + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; +} + +.x-api-crisis-calc h3 { + margin: 0 0 10px 0; + font-size: 14px; + color: #ef4444; +} + +.x-api-crisis-calc label { + display: block; + margin-bottom: 8px; +} + +.x-api-crisis-calc input { + margin-left: 8px; + padding: 4px 8px; + border: 1px solid #334155; + border-radius: 4px; + background: #0f172a; + color: #f1f5f9; + width: 120px; +} + +.x-crisis-result { + margin-top: 10px; + padding: 8px; + background: #0f172a; + border-radius: 6px; +} + +.x-crisis-row { + display: flex; + justify-content: space-between; + padding: 4px 0; +} + +.x-crisis-bad { + color: #ef4444; + font-weight: 700; +} + +.x-crisis-good { + color: #22c55e; + font-weight: 700; +} diff --git a/src/content/content.ts b/src/content/content.ts new file mode 100644 index 0000000..0623dd3 --- /dev/null +++ b/src/content/content.ts @@ -0,0 +1,102 @@ +import { xApiAlternatives, calculateXCost } from '../data/alternatives.js' + +function injectWarning(): void { + const existing = document.getElementById('x-api-crisis-warning') + if (existing) return + + const banner = document.createElement('div') + banner.id = 'x-api-crisis-warning' + banner.className = 'x-api-crisis-banner' + + banner.innerHTML = ` +
+ 🚨 +
+ X API Cost Crisis Alert + New pay-per-use pricing: $10k/mo for 2M reads. Check alternatives before building. +
+ See Alternatives +
+ + ` + + document.body.insertBefore(banner, document.body.firstChild) + + const btn = document.getElementById('x-api-crisis-show-alts') + const alts = document.getElementById('x-api-crisis-alts') + if (btn && alts) { + btn.addEventListener('click', (e) => { + e.preventDefault() + const visible = alts.style.display !== 'none' + alts.style.display = visible ? 'none' : 'block' + btn.textContent = visible ? 'See Alternatives' : 'Hide Alternatives' + }) + } +} + +function injectPricingCalculator(): void { + const existing = document.getElementById('x-api-crisis-calc') + if (existing) return + + const calc = document.createElement('div') + calc.id = 'x-api-crisis-calc' + calc.className = 'x-api-crisis-calc' + + calc.innerHTML = ` +

💰 Indie Dev Cost Calculator

+ + +
+ ` + + const sidebar = document.querySelector('aside, .sidebar, [role="complementary"]') + if (sidebar) { + sidebar.prepend(calc) + } else { + const main = document.querySelector('main, article, .content, [role="main"]') + if (main) { + main.prepend(calc) + } + } + + const readsInput = document.getElementById('x-crisis-reads') as HTMLInputElement + const writesInput = document.getElementById('x-crisis-writes') as HTMLInputElement + const resultEl = document.getElementById('x-crisis-result') + + function update() { + if (!resultEl || !readsInput || !writesInput) return + const r = parseInt(readsInput.value, 10) || 0 + const w = parseInt(writesInput.value, 10) || 0 + const est = calculateXCost(r, w) + const altCost = r <= 50000 ? '$0 (Nitter/RSSHub)' : r <= 500000 ? '~$5-49/mo (ScrapingBee/Apify)' : '~$49-200/mo (Apify premium)' + + resultEl.innerHTML = ` +
X API Cost:${est.cost >= 1000 ? '$' + (est.cost/1000).toFixed(1) + 'k' : '$' + est.cost}/mo
+
Alternative Cost:${altCost}
+
Savings:${est.cost > 0 ? '90-100%' : 'N/A'}
+ ` + } + + readsInput?.addEventListener('input', update) + writesInput?.addEventListener('input', update) + update() +} + +function run(): void { + injectWarning() + if (window.location.pathname.includes('pricing') || window.location.pathname.includes('api')) { + injectPricingCalculator() + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run) +} else { + run() +} diff --git a/src/data/alternatives.ts b/src/data/alternatives.ts new file mode 100644 index 0000000..538a27b --- /dev/null +++ b/src/data/alternatives.ts @@ -0,0 +1,150 @@ +import type { ApiAlternative } from '../types.js' + +export const xApiAlternatives: ApiAlternative[] = [ + { + name: 'Nitter (community instances)', + website: 'https://nitter.net', + freeTier: 'Unlimited (self-hosted or public instances)', + paidTier: 'Self-hosting ~$5/mo VPS', + pricingUrl: 'https://github.com/zedeus/nitter', + description: 'Privacy-friendly Twitter/X front-end that scrapes public profiles and tweets without API keys.', + features: ['No API key required', 'RSS feeds for any user', 'Read-only', 'Self-hostable'], + bestFor: 'Read-only use cases, RSS feeds, monitoring public accounts', + xApiReplacement: 'GET statuses/user_timeline, GET search/tweets' + }, + { + name: 'BirdsiteLive (Mastodon bridge)', + website: 'https://github.com/NicolasConstant/BirdsiteLive', + freeTier: 'Self-hosted', + paidTier: 'VPS ~$5-10/mo', + pricingUrl: 'https://github.com/NicolasConstant/BirdsiteLive', + description: 'Bridge that lets you follow Twitter/X accounts from Mastodon. No API billing.', + features: ['Follow X accounts from Mastodon', 'No X API key', 'ActivityPub federation', 'Self-hostable'], + bestFor: 'Social media monitoring via Mastodon ecosystem', + xApiReplacement: 'GET followers/ids, GET friends/ids' + }, + { + name: 'RSSHub', + website: 'https://rsshub.app', + freeTier: 'Public instance free, self-hosted free', + paidTier: 'Self-host ~$5/mo or donate', + pricingUrl: 'https://docs.rsshub.app/', + description: 'Open-source RSS feed generator that supports Twitter/X timelines without API.', + features: ['RSS feeds for tweets', 'No API key needed', '300+ sites supported', 'Self-hostable'], + bestFor: 'RSS-based monitoring, newsletters, automation pipelines', + xApiReplacement: 'GET statuses/user_timeline' + }, + { + name: 'Wayback Machine / Archive.org', + website: 'https://archive.org', + freeTier: 'Unlimited reads', + paidTier: 'Free', + pricingUrl: 'https://archive.org/donate/', + description: 'Archived snapshots of public Twitter/X pages. Good for historical data.', + features: ['Historical tweet access', 'No rate limits', 'Free forever', 'Bulk export via CDX API'], + bestFor: 'Historical research, compliance, deleted tweet recovery', + xApiReplacement: 'Historical tweet lookup' + }, + { + name: 'TweetScraper (开源 Python)', + website: 'https://github.com/twintproject/twint', + freeTier: 'Unlimited (no API)', + paidTier: 'Free (just your machine)', + pricingUrl: 'https://github.com/twintproject/twint', + description: 'Python library that scrapes tweets using Twitter\'s internal guest endpoints.', + features: ['No API key', 'Unlimited scraping', 'No rate limits', 'JSON/CSV export'], + bestFor: 'Data science, research, bulk exports', + xApiReplacement: 'GET search/tweets, GET statuses/user_timeline' + }, + { + name: 'Apify Twitter Scraper', + website: 'https://apify.com/quacker/twitter-scraper', + freeTier: '5$/mo platform credit', + paidTier: '~$49/mo for 100K tweets', + pricingUrl: 'https://apify.com/pricing', + description: 'Managed cloud scraping with proxy rotation. Pays per compute, not per API call.', + features: ['Proxy rotation', 'Structured JSON output', 'Scheduled runs', 'No API rate limits'], + bestFor: 'Production pipelines needing reliable extraction without X API billing', + xApiReplacement: 'Full read/write replacement via scraping' + }, + { + name: 'ScrapingBee', + website: 'https://www.scrapingbee.com', + freeTier: '1,000 API credits', + paidTier: 'From $49/mo (150K credits)', + pricingUrl: 'https://www.scrapingbee.com/pricing/', + description: 'Web scraping API with proxy rotation. Extract Twitter/X public pages via URL.', + features: ['JavaScript rendering', 'Proxy rotation', 'No X API key', 'Generic web scraper'], + bestFor: 'Generic web scraping including Twitter/X public pages', + xApiReplacement: 'GET any public Twitter page' + }, + { + name: 'Mastodon API (switch platform)', + website: 'https://docs.joinmastodon.org/api/', + freeTier: 'Unlimited on most instances', + paidTier: 'Free (most instances) or ~$5/mo hosting', + pricingUrl: 'https://joinmastodon.org/servers', + description: 'Federated Twitter alternative with fully open, free API. No pay-per-use.', + features: ['REST + Streaming API', 'No rate limits on most instances', 'OAuth 2.0', 'Free forever'], + bestFor: 'Building social tools without platform risk or billing surprises', + xApiReplacement: 'Full Twitter API replacement (different platform)' + } +] + +export const xApiTiers = { + free: { + name: 'Free', + readLimit: 1500, + writeLimit: 0, + monthlyCost: 0 + }, + basic: { + name: 'Basic', + readLimit: 10000, + writeLimit: 50000, + monthlyCost: 100 + }, + pro: { + name: 'Pro', + readLimit: 1000000, + writeLimit: 300000, + monthlyCost: 5000 + }, + enterprise: { + name: 'Enterprise', + readLimit: 20000000, + writeLimit: 10000000, + monthlyCost: 42000 + } +} + +export function calculateXCost(reads: number, writes: number): { tier: string; cost: number; overage: boolean } { + const tiers = [ + { name: 'Free', maxReads: 1500, maxWrites: 0, cost: 0 }, + { name: 'Basic', maxReads: 10000, maxWrites: 50000, cost: 100 }, + { name: 'Pro', maxReads: 1000000, maxWrites: 300000, cost: 5000 }, + { name: 'Enterprise', maxReads: 20000000, maxWrites: 10000000, cost: 42000 }, + ] + + for (const tier of tiers) { + if (reads <= tier.maxReads && writes <= tier.maxWrites) { + return { tier: tier.name, cost: tier.cost, overage: false } + } + } + + return { tier: 'Enterprise+', cost: 42000 + Math.ceil((reads - 20000000) / 1000000) * 10000, overage: true } +} + +export function getAlternativesForUseCase(useCase: string): ApiAlternative[] { + const map: Record = { + 'rss': ['RSSHub', 'Nitter (community instances)'], + 'scraping': ['TweetScraper (开源 Python)', 'Apify Twitter Scraper', 'ScrapingBee'], + 'monitoring': ['Nitter (community instances)', 'RSSHub', 'BirdsiteLive (Mastodon bridge)'], + 'historical': ['Wayback Machine / Archive.org'], + 'social-bot': ['Mastodon API (switch platform)', 'BirdsiteLive (Mastodon bridge)'], + 'default': ['Nitter (community instances)', 'RSSHub', 'TweetScraper (开源 Python)'] + } + + const names = map[useCase] || map['default'] + return xApiAlternatives.filter(a => names.includes(a.name)) +} diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..be5e68b --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,33 @@ +{ + "manifest_version": 3, + "name": "X API Cost Crisis Alert", + "version": "1.0.0", + "description": "Warns indie devs about X API costs and suggests affordable alternatives", + "permissions": ["activeTab", "storage"], + "host_permissions": ["*://developer.twitter.com/*", "*://developer.x.com/*"], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "content_scripts": [ + { + "matches": ["*://developer.twitter.com/*", "*://developer.x.com/*"], + "js": ["src/content/content.ts"], + "css": ["src/content/content.css"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "src/background.ts", + "type": "module" + } +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..d3d3735 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,268 @@ +:root { + --bg: #0f172a; + --card: #1e293b; + --text: #f1f5f9; + --text-muted: #94a3b8; + --accent: #ef4444; + --accent-green: #22c55e; + --accent-blue: #3b82f6; + --border: #334155; + --radius: 8px; + --font: system-ui, -apple-system, sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + width: 400px; + min-height: 500px; +} + +.container { + padding: 16px; +} + +header { + text-align: center; + margin-bottom: 16px; +} + +header h1 { + font-size: 1.25rem; + color: var(--accent); + margin-bottom: 4px; +} + +.subtitle { + font-size: 0.75rem; + color: var(--text-muted); +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 14px; +} + +.card h2 { + font-size: 1rem; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; +} + +.input-group { + margin-bottom: 10px; +} + +.input-group label { + display: block; + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 4px; +} + +.input-group input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 0.9rem; +} + +.result { + margin-top: 12px; + padding: 10px; + background: var(--bg); + border-radius: var(--radius); + border-left: 3px solid var(--accent); +} + +.result .tier { + font-size: 0.85rem; + color: var(--text-muted); +} + +.result .price { + font-size: 1.1rem; + font-weight: 700; + color: var(--accent); + margin-top: 4px; +} + +.result .warning { + font-size: 0.75rem; + color: var(--accent); + margin-top: 6px; + display: none; +} + +.result .warning.show { + display: block; +} + +.filter { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.filter-btn { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text-muted); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s; +} + +.filter-btn:hover { + border-color: var(--accent-blue); + color: var(--text); +} + +.filter-btn.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #fff; +} + +.alt-list { + max-height: 220px; + overflow-y: auto; +} + +.alt-item { + padding: 10px; + background: var(--bg); + border-radius: var(--radius); + margin-bottom: 8px; + border-left: 3px solid var(--accent-green); +} + +.alt-item h3 { + font-size: 0.9rem; + margin-bottom: 4px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.alt-item .badge { + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--accent-green); + color: #fff; +} + +.alt-item .badge.scraping { + background: var(--accent-blue); +} + +.alt-item .badge.paid { + background: #f59e0b; + color: #000; +} + +.alt-item p { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +.alt-item .meta { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 4px; + display: flex; + gap: 8px; +} + +.alt-item .meta a { + color: var(--accent-blue); + text-decoration: none; +} + +.alt-item .meta a:hover { + text-decoration: underline; +} + +.alt-item .features { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.alt-item .features span { + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--border); + color: var(--text-muted); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +th, td { + text-align: left; + padding: 6px 4px; + border-bottom: 1px solid var(--border); +} + +th { + color: var(--text-muted); + font-weight: 600; +} + +td { + color: var(--text); +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover td { + background: var(--bg); +} + +footer { + text-align: center; + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 8px; +} + +footer .disclaimer { + font-size: 0.65rem; + opacity: 0.7; + margin-top: 4px; +} + +.empty-state { + text-align: center; + padding: 20px; + color: var(--text-muted); + font-size: 0.8rem; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..d5c0346 --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,72 @@ + + + + + + X API Cost Crisis Alert + + + +
+
+

🚨 X API Cost Crisis

+

Indie dev alternatives to $10k/mo API bills

+
+ +
+

💰 Cost Estimator

+
+ + +
+
+ + +
+
+
Tier: -
+
X API Cost: -/mo
+
+
+
+ +
+

🛡️ Alternatives

+
+ + + + + + +
+
+ +
+
+ +
+

📊 X API vs Alternatives

+ + + + + + + + + + + + +
ServiceFree TierPaid TierBest For
+
+ +
+

Built by Bun Bun Labs

+

Not affiliated with X Corp. Data is public knowledge.

+
+
+ + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..eca0d50 --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,120 @@ +import { xApiAlternatives, calculateXCost, getAlternativesForUseCase } from '../data/alternatives.js' +import type { ApiAlternative } from '../types.js' + +function formatCurrency(n: number): string { + if (n >= 1000) return '$' + (n / 1000).toFixed(1) + 'k' + return '$' + n.toString() +} + +function updateCostEstimate(): void { + const readsInput = document.getElementById('reads') as HTMLInputElement + const writesInput = document.getElementById('writes') as HTMLInputElement + const tierNameEl = document.getElementById('tier-name') + const xCostEl = document.getElementById('x-cost') + const warningEl = document.getElementById('warning') + + if (!readsInput || !writesInput || !tierNameEl || !xCostEl || !warningEl) return + + const reads = parseInt(readsInput.value, 10) || 0 + const writes = parseInt(writesInput.value, 10) || 0 + + const estimate = calculateXCost(reads, writes) + + tierNameEl.textContent = estimate.tier + xCostEl.textContent = formatCurrency(estimate.cost) + + if (estimate.overage) { + warningEl.textContent = '⚠️ Exceeds Enterprise tier. Contact X sales or use alternatives below.' + warningEl.classList.add('show') + } else if (estimate.cost === 0) { + warningEl.textContent = '✅ Free tier covers your usage. Keep an eye on the 1,500 read limit.' + warningEl.classList.add('show') + } else if (estimate.tier === 'Enterprise') { + warningEl.textContent = '⚠️ $42k/mo. Strongly consider alternatives below.' + warningEl.classList.add('show') + } else if (estimate.tier === 'Pro') { + warningEl.textContent = '⚠️ $5k/mo. Alternatives can save 90%+' + warningEl.classList.add('show') + } else { + warningEl.textContent = '' + warningEl.classList.remove('show') + } +} + +function renderAlternatives(filter: string): void { + const list = document.getElementById('alt-list') + if (!list) return + + const alts = filter === 'all' ? xApiAlternatives : getAlternativesForUseCase(filter) + + if (alts.length === 0) { + list.innerHTML = '
No alternatives found for this category.
' + return + } + + list.innerHTML = alts.map((alt: ApiAlternative) => { + const isFree = alt.paidTier.includes('Free') || alt.paidTier.includes('self-hosted') || alt.paidTier.includes('Unlimited') + const badgeClass = isFree ? 'badge' : alt.paidTier.includes('$') ? 'badge paid' : 'badge scraping' + const badgeText = isFree ? 'FREE' : 'PAID' + + return ` +
+

+ ${alt.name} + ${badgeText} +

+

${alt.description}

+
+ 💰 ${alt.freeTier} + Website → + Pricing → +
+
+ ${alt.features.map(f => `${f}`).join('')} +
+
+ ` + }).join('') +} + +function renderComparison(): void { + const tbody = document.querySelector('#comparison-table tbody') + if (!tbody) return + + tbody.innerHTML = xApiAlternatives.map(alt => ` + + ${alt.name} + ${alt.freeTier} + ${alt.paidTier} + ${alt.bestFor} + + `).join('') +} + +function init(): void { + const readsInput = document.getElementById('reads') + const writesInput = document.getElementById('writes') + + if (readsInput) readsInput.addEventListener('input', updateCostEstimate) + if (writesInput) writesInput.addEventListener('input', updateCostEstimate) + + updateCostEstimate() + + const filterButtons = document.querySelectorAll('.filter-btn') + filterButtons.forEach(btn => { + btn.addEventListener('click', () => { + filterButtons.forEach(b => b.classList.remove('active')) + btn.classList.add('active') + renderAlternatives((btn as HTMLElement).dataset.filter || 'all') + }) + }) + + renderAlternatives('all') + renderComparison() +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init) +} else { + init() +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c6f3838 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,19 @@ +// ---- Types ---- +export interface ApiAlternative { + name: string; + website: string; + freeTier: string; + paidTier: string; + pricingUrl: string; + description: string; + features: string[]; + bestFor: string; + xApiReplacement: string; +} + +export interface CostEstimate { + monthlyReads: number; + monthlyWrites: number; + xCost: number; + alternatives: { name: string; cost: number | string }[]; +} diff --git a/test/alternatives.test.ts b/test/alternatives.test.ts new file mode 100644 index 0000000..1fdb370 --- /dev/null +++ b/test/alternatives.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { calculateXCost, getAlternativesForUseCase, xApiAlternatives } from '../src/data/alternatives.js' + +describe('calculateXCost', () => { + it('returns Free tier for 0 reads and 0 writes', () => { + const result = calculateXCost(0, 0) + assert.strictEqual(result.tier, 'Free') + assert.strictEqual(result.cost, 0) + assert.strictEqual(result.overage, false) + }) + + it('returns Free tier for 1500 reads and 0 writes', () => { + const result = calculateXCost(1500, 0) + assert.strictEqual(result.tier, 'Free') + assert.strictEqual(result.cost, 0) + }) + + it('returns Basic tier for 5000 reads and 100 writes', () => { + const result = calculateXCost(5000, 100) + assert.strictEqual(result.tier, 'Basic') + assert.strictEqual(result.cost, 100) + }) + + it('returns Pro tier for 500000 reads and 100000 writes', () => { + const result = calculateXCost(500000, 100000) + assert.strictEqual(result.tier, 'Pro') + assert.strictEqual(result.cost, 5000) + }) + + it('returns Enterprise tier for 5M reads and 500K writes', () => { + const result = calculateXCost(5000000, 500000) + assert.strictEqual(result.tier, 'Enterprise') + assert.strictEqual(result.cost, 42000) + }) + + it('returns Enterprise+ with overage for 25M reads', () => { + const result = calculateXCost(25000000, 1000000) + assert.strictEqual(result.tier, 'Enterprise+') + assert.strictEqual(result.overage, true) + assert.strictEqual(result.cost, 92000) + }) +}) + +describe('getAlternativesForUseCase', () => { + it('returns RSS alternatives for rss filter', () => { + const alts = getAlternativesForUseCase('rss') + assert.ok(alts.length > 0) + assert.ok(alts.some(a => a.name.includes('RSSHub'))) + }) + + it('returns scraping alternatives for scraping filter', () => { + const alts = getAlternativesForUseCase('scraping') + assert.ok(alts.some(a => a.name.includes('TweetScraper'))) + }) + + it('returns default alternatives for unknown filter', () => { + const alts = getAlternativesForUseCase('unknown-category') + assert.ok(alts.length > 0) + }) +}) + +describe('xApiAlternatives data', () => { + it('has at least 8 alternatives', () => { + assert.ok(xApiAlternatives.length >= 8) + }) + + it('every alternative has required fields', () => { + for (const alt of xApiAlternatives) { + assert.ok(alt.name, 'name is required') + assert.ok(alt.website, 'website is required') + assert.ok(alt.freeTier, 'freeTier is required') + assert.ok(alt.paidTier, 'paidTier is required') + assert.ok(alt.description, 'description is required') + assert.ok(alt.features.length > 0, 'features must not be empty') + assert.ok(alt.bestFor, 'bestFor is required') + } + }) + + it('all websites are valid URLs', () => { + for (const alt of xApiAlternatives) { + assert.ok(alt.website.startsWith('http'), `${alt.name} website must start with http`) + } + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0c98ae6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist-test", + "rootDir": ".", + "types": ["node", "chrome"] + }, + "include": ["src/**/*", "test/**/*"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..151e16e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import { crx } from '@crxjs/vite-plugin' +import manifest from './src/manifest.json' assert { type: 'json' } + +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + popup: 'src/popup/popup.html' + } + } + }, + plugins: [crx({ manifest })], +})