A rank tracker in a cron job
Daily positions, diffed and alerted, for 1 credit per keyword per day.
One seofetch call per keyword, kicked off by cron; the rest runs locally against the saved responses — extract your rank, diff against yesterday, alert on drops. No dashboard, no extra API surface.
Export your key once.
export SEOFETCH_KEY=sof_live_… # the scripts below use jq -- brew install jq / apt-get install jq
export SEOFETCH_KEY=sof_live_… pip install requests
export SEOFETCH_KEY=sof_live_… # Node 18+ -- built-in fetch, no npm install needed
A cron entry — one run per day, well before anyone's watching:
15 6 * * * cd /path/to/tracker && ./tracker.sh
Run one search per tracked keyword from cron.
curl https://api.seofetch.com/v1/search \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "best running shoes"}' \
-o today.json
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/search",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"query": "best running shoes"},
)
with open("today.json", "w") as f:
json.dump(resp.json(), f, indent=2)
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
import { writeFileSync } from "node:fs";
const resp = await fetch("https://api.seofetch.com/v1/search", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ query: "best running shoes" }),
});
const body = await resp.json();
writeFileSync("today.json", JSON.stringify(body, null, 2));
{
"id": "srch_vaqa36dz2qble5iobr3wz2eb",
"request_id": "req_kp6yi3y2ojevfnzoqeemujf2my",
"object": "search",
"created_at": "2026-08-08T17:16:27Z",
"elapsed_ms": 142,
"cache": "miss",
"credits": {
"charged": 1,
"balance": 9857
},
"data": {
"query": "best running shoes",
"engine": "google",
"location": 2840,
"language": "en",
"device": "desktop",
"total_results": 84900000,
"serp_url": "https://www.google.com/search?q=best+running+shoes",
"result_types": [
"organic"
],
"results_count": 10,
"items": [
{
"type": "organic",
"rank": 1,
"page": 1,
"domain": "example.com",
"title": "The 12 Best Running Shoes",
"url": "https://example.com/best-running-shoes",
"description": "Our team tested 40 pairs...",
"displayed_link": "example.com › reviews › shoes",
"date": null,
"site_name": "Example Running Co.",
"rating": {
"value": 4.6,
"votes": 1284,
"max": 5
},
"sitelinks": [
{
"type": "sitelink",
"title": "Best Trail Running Shoes",
"description": null,
"url": "https://example.com/best-running-shoes/trail"
},
{
"type": "sitelink",
"title": "Best Budget Running Shoes",
"description": null,
"url": "https://example.com/best-running-shoes/budget"
}
],
"price": null
}
]
}
}
Flat per-keyword rate at the default depth — see the cost line above. Save the raw response so the next step can read it.
Extract your own position.
jq --arg d yourdomain.com '[.data.items[] | select(.domain == $d) | .rank][0] // "miss"' today.json
import json
domain = "yourdomain.com"
items = json.load(open("today.json"))["data"]["items"]
rank = next((i["rank"] for i in items if i["domain"] == domain), "miss")
print(rank)
import { readFileSync } from "node:fs";
const domain = "yourdomain.com";
const { items } = JSON.parse(readFileSync("today.json")).data;
const rank = items.find((i) => i.domain === domain)?.rank ?? "miss";
console.log(rank);
One number per keyword per day — "miss" when you're not in the results, so the row survives instead of vanishing. A miss means not in the top 10 (the default depth); track deeper with depth=100. Append it to a CSV, SQLite table, wherever.
Diff against yesterday and alert on drops.
awk -F, 'NR==FNR { prev[$1]=$2; next }
($1 in prev) && prev[$1] != "miss" && $2 != "miss" && ($2+0) > (prev[$1]+0) { print $1 ": " prev[$1] " -> " $2 }
($1 in prev) && prev[$1] != "miss" && $2 == "miss" { print $1 ": " prev[$1] " -> miss (dropped out)" }' yesterday.csv today.csv
import csv
yesterday = dict(csv.reader(open("yesterday.csv")))
today = dict(csv.reader(open("today.csv")))
for kw, new_rank in today.items():
old_rank = yesterday.get(kw)
if old_rank and old_rank != "miss" and new_rank != "miss" and int(new_rank) > int(old_rank):
print(f"{kw}: {old_rank}→{new_rank}")
elif old_rank and old_rank != "miss" and new_rank == "miss":
print(f"{kw}: {old_rank} -> miss (dropped out)")
import { readFileSync } from "node:fs";
const parse = (f) => Object.fromEntries(
readFileSync(f, "utf8").trim().split("\n").map((l) => l.split(",")));
const yesterday = parse("yesterday.csv");
const today = parse("today.csv");
for (const [kw, newRank] of Object.entries(today)) {
const oldRank = yesterday[kw];
if (oldRank && oldRank !== "miss" && newRank !== "miss" && Number(newRank) > Number(oldRank)) {
console.log(`${kw}: ${oldRank}→${newRank}`);
} else if (oldRank && oldRank !== "miss" && newRank === "miss") {
console.log(`${kw}: ${oldRank} -> miss (dropped out)`);
}
}
Pipe the output to your Slack webhook or mail command. What's left — rotating snapshots, the miss sentinel — is glue, and it's yours — plus whatever production hardening your setup wants (CSV escaping, overlap locks, retention).
A long search can 504 — the connection gave up, not the job.
The charge stands and the job keeps running server-side — reconnecting to collect it is an advanced topic covered once, completely, on The Contract, not repeated here.
Run the whole thing.
Each language below is the full tracker, cron-ready. keywords.txt in, a dated snapshot out, misses recorded, drops printed for you to pipe wherever you like. A bad keyword (404, 504, whatever) is skipped and logged, not fatal.
#!/usr/bin/env bash
# tracker.sh -- daily rank tracker, cron-ready. keywords.txt: one keyword per line.
# cron: 15 6 * * * cd /path/to/tracker && ./tracker.sh
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
DOMAIN="${TRACK_DOMAIN:-yourdomain.com}"
TODAY=$(date -u +%F)
mkdir -p snapshots
OUT="snapshots/$TODAY.csv"
: > "$OUT"
while IFS= read -r kw; do
[ -z "$kw" ] && continue
# -w tacks the status code onto the last line.
resp=$(curl -sS https://api.seofetch.com/v1/search \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -cn --arg q "$kw" '{query: $q}')" \
-w $'\n%{http_code}')
code=${resp##*$'\n'}
body=${resp%$'\n'*}
if [ "$code" != "200" ]; then
# a 504 means the job kept running server-side past your connection --
# the charge stands either way. Any non-2xx (401/429/504/500/...) is
# never a "miss" -- skip it rather than parse the error body as data.
echo "warning: $kw returned HTTP $code, skipping" >&2
continue
fi
# "miss" = not in the returned results (top 10 at the default depth) --
# the row survives so the diff below can see you drop out entirely
rank=$(printf '%s' "$body" | jq -r --arg d "$DOMAIN" \
'[.data.items[]? | select(.domain == $d) | .rank][0] // "miss"')
printf '%s,%s\n' "$kw" "$rank" >> "$OUT"
done < keywords.txt
# diff against yesterday, alert on drops (macOS date -v / GNU date -d both handled)
PREV="snapshots/$(date -u -v-1d +%F 2>/dev/null || date -u -d yesterday +%F).csv"
if [ -f "$PREV" ]; then
awk -F, 'NR==FNR { prev[$1]=$2; next }
($1 in prev) && prev[$1] != "miss" && $2 != "miss" && ($2+0) > (prev[$1]+0) \
{ print $1 ": " prev[$1] " -> " $2 }
($1 in prev) && prev[$1] != "miss" && $2 == "miss" \
{ print $1 ": " prev[$1] " -> miss (dropped out)" }' "$PREV" "$OUT" |
sh -c "${ALERT_CMD:-cat}" # e.g. ALERT_CMD="mail -s 'rank drops' you@example.com"
fi
#!/usr/bin/env python3
"""tracker.py -- daily rank tracker, cron-ready. keywords.txt: one keyword per line.
cron: 15 6 * * * cd /path/to/tracker && python3 tracker.py
"""
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
DOMAIN = os.environ.get("TRACK_DOMAIN", "yourdomain.com")
TODAY = datetime.now(timezone.utc).strftime("%Y-%m-%d")
Path("snapshots").mkdir(exist_ok=True)
rows = {}
for kw in (line.strip() for line in open("keywords.txt") if line.strip()):
resp = requests.post(
"https://api.seofetch.com/v1/search",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"query": kw},
)
if resp.status_code != 200:
# a 504 means the job kept running server-side past your connection --
# the charge stands either way. Any non-2xx is never a "miss" -- skip
# it rather than parse the error body as data.
print(f"warning: {kw} returned HTTP {resp.status_code}, skipping")
continue
# "miss" = not in the returned results (top 10 at the default depth) --
# the row survives so the diff below can see you drop out entirely
items = resp.json().get("data", {}).get("items", [])
rows[kw] = next((i["rank"] for i in items if i["domain"] == DOMAIN), "miss")
out = Path(f"snapshots/{TODAY}.csv")
out.write_text("\n".join(f"{kw},{rank}" for kw, rank in rows.items()) + "\n")
# diff against yesterday, alert on drops
prev_path = Path(f"snapshots/{(datetime.now(timezone.utc) - timedelta(days=1)).strftime('%Y-%m-%d')}.csv")
if prev_path.exists():
prev = dict(line.split(",") for line in prev_path.read_text().splitlines())
for kw, rank in rows.items():
old = prev.get(kw)
if old and old != "miss" and rank != "miss" and int(rank) > int(old):
print(f"{kw}: {old}→{rank}")
elif old and old != "miss" and rank == "miss":
print(f"{kw}: {old} -> miss (dropped out)")
#!/usr/bin/env node
// tracker.mjs -- daily rank tracker, cron-ready. keywords.txt: one keyword per line.
// cron: 15 6 * * * cd /path/to/tracker && node tracker.mjs
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const DOMAIN = process.env.TRACK_DOMAIN || "yourdomain.com";
const TODAY = new Date().toISOString().slice(0, 10);
mkdirSync("snapshots", { recursive: true });
const keywords = readFileSync("keywords.txt", "utf8").split("\n").map((k) => k.trim()).filter(Boolean);
const rows = {};
for (const kw of keywords) {
const resp = await fetch("https://api.seofetch.com/v1/search", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ query: kw }),
});
if (!resp.ok) {
// a 504 means the job kept running server-side past your connection --
// the charge stands either way. Any non-2xx is never a "miss" -- skip
// it rather than parse the error body as data.
console.warn(`warning: ${kw} returned HTTP ${resp.status}, skipping`);
continue;
}
// "miss" = not in the returned results (top 10 at the default depth) --
// the row survives so the diff below can see you drop out entirely
const items = (await resp.json()).data?.items || [];
rows[kw] = items.find((i) => i.domain === DOMAIN)?.rank ?? "miss";
}
writeFileSync(`snapshots/${TODAY}.csv`, Object.entries(rows).map(([k, r]) => `${k},${r}`).join("\n") + "\n");
// diff against yesterday, alert on drops
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const prevPath = `snapshots/${yesterday}.csv`;
if (existsSync(prevPath)) {
const prev = Object.fromEntries(readFileSync(prevPath, "utf8").trim().split("\n").map((l) => l.split(",")));
for (const [kw, rank] of Object.entries(rows)) {
const old = prev[kw];
if (old && old !== "miss" && rank !== "miss" && Number(rank) > Number(old)) {
console.log(`${kw}: ${old}→${rank}`);
} else if (old && old !== "miss" && rank === "miss") {
console.log(`${kw}: ${old} -> miss (dropped out)`);
}
}
}