Rank trackers need to run daily but do not justify a full server. This tutorial builds a rank tracker on Cloudflare Workers that runs on a cron schedule, checks your keyword positions via the Scavio API, stores results in KV, and exposes a dashboard endpoint. Zero infrastructure, free Workers tier, $0.005/keyword.
Prerequisites
- Node.js 18+
- Wrangler CLI (npm install -g wrangler)
- A Scavio API key from scavio.dev
- Cloudflare account (free tier works)
Walkthrough
Step 1: Set up the Worker with scheduled trigger
Create a Cloudflare Worker that runs daily via cron trigger.
// wrangler.toml config:
// name = "rank-tracker"
// [triggers]
// crons = ["0 6 * * *"] # Daily at 6 AM UTC
// [[kv_namespaces]]
// binding = "RANKINGS"
// id = "your-kv-namespace-id"
export default {
async scheduled(event, env, ctx) {
const keywords = ['search api python', 'mcp search tool', 'serp api alternative', 'web search api pricing'];
const domain = 'scavio.dev';
const results = [];
for (const kw of keywords) {
const resp = await fetch('https://api.scavio.dev/api/v1/search', {
method: 'POST',
headers: { 'x-api-key': env.SCAVIO_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: kw, country_code: 'us' }),
});
const data = await resp.json();
const organic = data.organic_results || [];
const pos = organic.findIndex(r => r.link?.includes(domain));
results.push({ keyword: kw, position: pos >= 0 ? pos + 1 : null, date: new Date().toISOString().split('T')[0] });
}
// Store in KV
const today = new Date().toISOString().split('T')[0];
await env.RANKINGS.put(`rankings:${today}`, JSON.stringify(results));
console.log(`Tracked ${results.length} keywords. Cost: $${(keywords.length * 0.005).toFixed(3)}`);
},
async fetch(request, env) {
// Dashboard endpoint
const url = new URL(request.url);
if (url.pathname === '/api/rankings') {
const today = new Date().toISOString().split('T')[0];
const data = await env.RANKINGS.get(`rankings:${today}`);
return new Response(data || '[]', { headers: { 'Content-Type': 'application/json' } });
}
return new Response('Rank Tracker. GET /api/rankings for today.');
}
};Step 2: Add historical trend storage
Store 30 days of ranking history and calculate trends.
async function getHistory(env, days = 30) {
const history = [];
for (let i = 0; i < days; i++) {
const date = new Date(Date.now() - i * 86400000).toISOString().split('T')[0];
const data = await env.RANKINGS.get(`rankings:${date}`);
if (data) {
history.push({ date, rankings: JSON.parse(data) });
}
}
return history.reverse();
}
async function getTrends(env, keyword) {
const history = await getHistory(env);
const positions = [];
for (const day of history) {
const entry = day.rankings.find(r => r.keyword === keyword);
if (entry) {
positions.push({ date: day.date, position: entry.position });
}
}
if (positions.length < 2) return { trend: 'insufficient_data', positions };
const latest = positions[positions.length - 1].position;
const previous = positions[positions.length - 2].position;
let trend = 'stable';
if (latest && previous) {
if (latest < previous) trend = 'improving';
else if (latest > previous) trend = 'declining';
} else if (latest && !previous) {
trend = 'new_ranking';
} else if (!latest && previous) {
trend = 'lost_ranking';
}
return { trend, positions, latest, previous };
}
// Usage in fetch handler:
// const trends = await getTrends(env, 'search api python');
// return new Response(JSON.stringify(trends));
console.log('Trend tracking configured with 30-day KV history');Step 3: Deploy and verify
Deploy the Worker and test the ranking endpoint.
// Deploy steps:
// 1. wrangler secret put SCAVIO_API_KEY
// 2. wrangler deploy
// 3. wrangler kv:namespace create RANKINGS
// Test locally:
// wrangler dev
// curl http://localhost:8787/api/rankings
// Test the scheduled trigger:
// wrangler dev --test-scheduled
// Verify in production:
async function verifyDeployment() {
const resp = await fetch('https://rank-tracker.your-subdomain.workers.dev/api/rankings');
const data = await resp.json();
console.log(`Rankings for today: ${data.length} keywords`);
for (const r of data) {
const pos = r.position ? `#${r.position}` : 'not found';
console.log(` ${r.keyword.padEnd(30)} | ${pos}`);
}
}
// Cost breakdown:
// Cloudflare Workers: Free tier (100K req/day)
// Scavio API: 4 keywords x $0.005 = $0.020/day
// Monthly: $0.60/month for daily tracking
// vs. Ahrefs: $99/mo, SEMrush: $129/mo
console.log('Deployed. $0.020/day for 4 keywords.');
console.log('Monthly: $0.60 vs Ahrefs $99/mo');Python Example
import os, requests
SH = {'x-api-key': os.environ['SCAVIO_API_KEY'], 'Content-Type': 'application/json'}
def track(keyword, domain):
data = requests.post('https://api.scavio.dev/api/v1/search',
headers=SH, json={'query': keyword, 'country_code': 'us'}, timeout=10).json()
pos = next((i+1 for i, r in enumerate(data.get('organic_results', [])) if domain in r.get('link', '')), None)
print(f'{keyword[:30]:30} | {f"#{pos}" if pos else "absent"}')
for kw in ['search api python', 'mcp search tool']:
track(kw, 'scavio.dev')JavaScript Example
const SH = { 'x-api-key': process.env.SCAVIO_API_KEY, 'Content-Type': 'application/json' };
for (const kw of ['search api python', 'mcp search tool']) {
const data = await fetch('https://api.scavio.dev/api/v1/search', {
method: 'POST', headers: SH,
body: JSON.stringify({ query: kw, country_code: 'us' })
}).then(r => r.json());
const pos = (data.organic_results || []).findIndex(r => r.link?.includes('scavio.dev'));
console.log(`${kw}: ${pos >= 0 ? '#' + (pos+1) : 'absent'}`);
}Expected Output
Tracked 4 keywords. Cost: $0.020
Rankings for today: 4 keywords
search api python | #3
mcp search tool | #2
serp api alternative | #5
web search api pricing | #4
Deployed. $0.020/day for 4 keywords.
Monthly: $0.60 vs Ahrefs $99/mo