feat: X API Cost Crisis Alert Chrome extension MVP with cost calculator, 8 alternatives, and 12 passing tests

This commit is contained in:
Bun Bun
2026-06-20 06:23:33 +00:00
commit 7052227f69
25 changed files with 3050 additions and 0 deletions
+13
View File
@@ -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' });
}
}
});
+92
View File
@@ -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 = `
<div class="x-api-crisis-inner">
<span class="x-api-crisis-icon">🚨</span>
<div class="x-api-crisis-text">
<strong>X API Cost Crisis Alert</strong>
<span>New pay-per-use pricing: $10k/mo for 2M reads. Check alternatives before building.</span>
</div>
<a href="#" class="x-api-crisis-btn" id="x-api-crisis-show-alts">See Alternatives</a>
</div>
<div class="x-api-crisis-alts" id="x-api-crisis-alts" style="display:none">
<p><strong>8 indie-friendly alternatives to X API:</strong></p>
<ul>
${xApiAlternatives.slice(0, 5).map(a => `<li><a href="${a.website}" target="_blank" rel="noopener">${a.name}</a> — ${a.freeTier}</li>`).join('')}
</ul>
<p><em>Open the extension popup for full comparison & cost calculator.</em></p>
</div>
`;
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 = `
<h3>💰 Indie Dev Cost Calculator</h3>
<label>Monthly API Reads: <input type="number" id="x-crisis-reads" value="2000000" min="0" step="10000"></label>
<label>Monthly API Writes: <input type="number" id="x-crisis-writes" value="0" min="0" step="1000"></label>
<div class="x-crisis-result" id="x-crisis-result"></div>
`;
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 = `
<div class="x-crisis-row"><span>X API Cost:</span><span class="x-crisis-bad">${est.cost >= 1000 ? '$' + (est.cost / 1000).toFixed(1) + 'k' : '$' + est.cost}/mo</span></div>
<div class="x-crisis-row"><span>Alternative Cost:</span><span class="x-crisis-good">${altCost}</span></div>
<div class="x-crisis-row"><span>Savings:</span><span class="x-crisis-good">${est.cost > 0 ? '90-100%' : 'N/A'}</span></div>
`;
}
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();
}
+142
View File
@@ -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));
}
+110
View File
@@ -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 = '<div class="empty-state">No alternatives found for this category.</div>';
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 `
<div class="alt-item">
<h3>
${alt.name}
<span class="${badgeClass}">${badgeText}</span>
</h3>
<p>${alt.description}</p>
<div class="meta">
<span>💰 ${alt.freeTier}</span>
<a href="${alt.website}" target="_blank" rel="noopener">Website →</a>
<a href="${alt.pricingUrl}" target="_blank" rel="noopener">Pricing →</a>
</div>
<div class="features">
${alt.features.map(f => `<span>${f}</span>`).join('')}
</div>
</div>
`;
}).join('');
}
function renderComparison() {
const tbody = document.querySelector('#comparison-table tbody');
if (!tbody)
return;
tbody.innerHTML = xApiAlternatives.map(alt => `
<tr>
<td><strong>${alt.name}</strong></td>
<td>${alt.freeTier}</td>
<td>${alt.paidTier}</td>
<td>${alt.bestFor}</td>
</tr>
`).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();
}
+1
View File
@@ -0,0 +1 @@
export {};