A rank tracker in a cron job
Daily positions, diffed and alerted, for 1 credit per keyword per day.
One call per keyword from cron. The rest runs locally against the saved responses: extract your rank, diff against yesterday, alert on drops. No dashboard.
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, before anyone is 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_7f34stnlffnsbalonq66dpuj",
"request_id": "req_7e5465ge5fjmziqx5dyueaqyvy",
"object": "search",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"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": [
{
"title": "Best Trail Running Shoes",
"url": "https://example.com/best-running-shoes/trail",
"description": null
},
{
"title": "Best Budget Running Shoes",
"url": "https://example.com/best-running-shoes/budget",
"description": null
}
],
"price": null,
"highlighted_words": [
"running shoes"
]
}
]
}
}
Flat rate per keyword at the default depth (see the cost line above). Save the raw response for the next step.
Extract your own position.
jq --arg d yourdomain.com '[.data.items[] | select(.type == "organic" and .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["type"] == "organic" and 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.type === "organic" && i.domain === domain)?.rank ?? "miss";
console.log(rank);
One number per keyword per day: your organic rank -- ads and SERP features are never counted. "miss" when you're not in the results, so the row survives. A miss means outside the top 10 at the default depth; track deeper with depth=100. Append it to a CSV, a 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 a Slack webhook or your mail command. The rest is glue: rotating snapshots, the miss sentinel, and whatever hardening your setup wants (CSV escaping, overlap locks, retention).
A long search can 504. The connection gave up; the job didn't.
The charge stands and the job keeps running. Reconnecting to collect it is covered once, on The Contract.
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. A bad keyword (404, 504, whatever) is logged and skipped.
#!/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(.type == "organic" and .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["type"] == "organic" and 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.type === "organic" && 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)`);
}
}
}