feat: X API Cost Crisis Alert Chrome extension MVP with cost calculator, 8 alternatives, and 12 passing tests
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.verdict
|
||||||
|
.venv/
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Generated
+1500
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<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(): 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 = `
|
||||||
|
<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') 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 = `
|
||||||
|
<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(): void {
|
||||||
|
injectWarning()
|
||||||
|
if (window.location.pathname.includes('pricing') || window.location.pathname.includes('api')) {
|
||||||
|
injectPricingCalculator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', run)
|
||||||
|
} else {
|
||||||
|
run()
|
||||||
|
}
|
||||||
@@ -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<string, string[]> = {
|
||||||
|
'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))
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>X API Cost Crisis Alert</title>
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🚨 X API Cost Crisis</h1>
|
||||||
|
<p class="subtitle">Indie dev alternatives to $10k/mo API bills</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="cost-calculator" class="card">
|
||||||
|
<h2>💰 Cost Estimator</h2>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="reads">Monthly Reads</label>
|
||||||
|
<input type="number" id="reads" value="50000" min="0" step="1000" />
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="writes">Monthly Writes</label>
|
||||||
|
<input type="number" id="writes" value="1000" min="0" step="100" />
|
||||||
|
</div>
|
||||||
|
<div class="result" id="result">
|
||||||
|
<div class="tier">Tier: <span id="tier-name">-</span></div>
|
||||||
|
<div class="price">X API Cost: <span id="x-cost">-</span>/mo</div>
|
||||||
|
<div class="warning" id="warning"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="alternatives" class="card">
|
||||||
|
<h2>🛡️ Alternatives</h2>
|
||||||
|
<div class="filter">
|
||||||
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-filter="rss">RSS</button>
|
||||||
|
<button class="filter-btn" data-filter="scraping">Scraping</button>
|
||||||
|
<button class="filter-btn" data-filter="monitoring">Monitoring</button>
|
||||||
|
<button class="filter-btn" data-filter="historical">Historical</button>
|
||||||
|
<button class="filter-btn" data-filter="social-bot">Social Bot</button>
|
||||||
|
</div>
|
||||||
|
<div id="alt-list" class="alt-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="comparison" class="card">
|
||||||
|
<h2>📊 X API vs Alternatives</h2>
|
||||||
|
<table id="comparison-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Free Tier</th>
|
||||||
|
<th>Paid Tier</th>
|
||||||
|
<th>Best For</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Built by Bun Bun Labs</p>
|
||||||
|
<p class="disclaimer">Not affiliated with X Corp. Data is public knowledge.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="popup.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = '<div class="empty-state">No alternatives found for this category.</div>'
|
||||||
|
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 `
|
||||||
|
<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(): void {
|
||||||
|
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(): 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()
|
||||||
|
}
|
||||||
@@ -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 }[];
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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 })],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user