Scenario
Report on ranking changes — which keywords improved, which dropped, and what to do. Pull current positions fromdomain_organic, compare against a stored baseline, then send change data to Mave for an executive-ready narrative report.
Architecture
Code
import os, requests, csv, io, json
from pathlib import Path
SR, MV = os.environ["SEMRUSH_API_KEY"], os.environ["MAVERA_API_KEY"]
MB = "https://app.mavera.io/api/v1"
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
DOMAIN, BF = "yourdomain.com", "semrush_baseline.json"
resp = requests.get("https://api.semrush.com/", params={
"type": "domain_organic", "key": SR, "domain": DOMAIN,
"database": "us", "display_limit": 100, "display_sort": "tr_desc",
"export_columns": "Ph,Po,Pp,Nq,Cp,Ur,Tr,Tc,Co,Kd",
})
reader = csv.reader(io.StringIO(resp.text), delimiter=";")
next(reader)
cur = {}
for r in reader:
if len(r) < 10: continue
cur[r[0]] = {"kw": r[0], "pos": int(r[1] or 100), "prev": int(r[2] or 100),
"vol": int(r[3] or 0), "url": r[5], "tr": float(r[6] or 0)}
bl = json.loads(Path(BF).read_text()) if Path(BF).exists() else {}
up, down, new, lost = [], [], [], []
for kw, d in cur.items():
if kw in bl:
d["chg"] = bl[kw]["pos"] - d["pos"]
(up if d["chg"] > 0 else down if d["chg"] < 0 else []).append(d)
else: d["chg"] = 0; new.append(d)
for kw in bl:
if kw not in cur: lost.append({"kw": kw, "pos": bl[kw]["pos"], "vol": bl[kw].get("vol", 0)})
up.sort(key=lambda x: x["chg"], reverse=True)
down.sort(key=lambda x: x["chg"])
Path(BF).write_text(json.dumps({k: {"pos": v["pos"], "vol": v["vol"]} for k, v in cur.items()}, indent=2))
def fmt(m):
return "\n".join(f"- \"{x['kw']}\" (vol: {x['vol']}): {x.get('prev','?')} → {x['pos']} "
f"({'+' if x.get('chg',0)>0 else ''}{x.get('chg',0)})" for x in m[:10])
total_tr = sum(d["tr"] for d in cur.values())
t10 = sum(1 for d in cur.values() if d["pos"] <= 10)
report = (f"RANKING: {DOMAIN} | KWs: {len(cur)} | Traffic: {total_tr:.0f} | Top10: {t10}\n"
f"Up: {len(up)} | Down: {len(down)} | New: {len(new)} | Lost: {len(lost)}\n\n"
f"IMPROVED:\n{fmt(up)}\n\nDECLINED:\n{fmt(down)}")
mave = requests.post(f"{MB}/mave/chat", headers=MH, json={
"message": f"Executive SEO report.\n\n{report}\n\n"
f"Include: 1) Summary (3 sentences) 2) Wins 3) Risks "
f"4) Priority actions for next 2 weeks. Plain language for VP Marketing.",
}).json()
print(mave.get("content", ""))
const SR = process.env.SEMRUSH_API_KEY, MV = process.env.MAVERA_API_KEY;
const MB = "https://app.mavera.io/api/v1";
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const fs = require("fs");
const DOMAIN = "yourdomain.com", BF = "semrush_baseline.json";
const params = new URLSearchParams({
type: "domain_organic", key: SR, domain: DOMAIN,
database: "us", display_limit: "100", display_sort: "tr_desc",
export_columns: "Ph,Po,Pp,Nq,Cp,Ur,Tr,Tc,Co,Kd",
});
const text = await fetch(`https://api.semrush.com/?${params}`).then((r) => r.text());
const cur = {};
for (const line of text.trim().split("\n").slice(1)) {
const c = line.split(";");
if (c.length < 10) continue;
cur[c[0]] = { kw: c[0], pos: parseInt(c[1]) || 100, prev: parseInt(c[2]) || 100,
vol: parseInt(c[3]) || 0, url: c[5], tr: parseFloat(c[6]) || 0 };
}
let bl = {};
try { bl = JSON.parse(fs.readFileSync(BF, "utf8")); } catch {}
const up = [], down = [], nw = [], lost = [];
for (const [kw, d] of Object.entries(cur)) {
if (bl[kw]) { d.chg = bl[kw].pos - d.pos; (d.chg > 0 ? up : d.chg < 0 ? down : []).push(d); }
else { d.chg = 0; nw.push(d); }
}
for (const kw of Object.keys(bl)) if (!cur[kw]) lost.push({ kw, pos: bl[kw].pos, vol: bl[kw].vol || 0 });
up.sort((a, b) => b.chg - a.chg); down.sort((a, b) => a.chg - b.chg);
const nb = {}; for (const [k, v] of Object.entries(cur)) nb[k] = { pos: v.pos, vol: v.vol };
fs.writeFileSync(BF, JSON.stringify(nb, null, 2));
const fmt = (a) => a.slice(0, 10).map((x) =>
`- "${x.kw}" (vol: ${x.vol}): ${x.prev||"?"} → ${x.pos} (${x.chg>0?"+":""}${x.chg})`).join("\n");
const tTr = Object.values(cur).reduce((s, d) => s + d.tr, 0);
const t10 = Object.values(cur).filter((d) => d.pos <= 10).length;
const report = `RANKING: ${DOMAIN} | KWs: ${Object.keys(cur).length} | Traffic: ${tTr.toFixed(0)} | Top10: ${t10}\nUp: ${up.length} | Down: ${down.length} | New: ${nw.length} | Lost: ${lost.length}\n\nIMPROVED:\n${fmt(up)}\n\nDECLINED:\n${fmt(down)}`;
const mave = await fetch(`${MB}/mave/chat`, { method: "POST", headers: MH,
body: JSON.stringify({
message: `Executive SEO report.\n\n${report}\n\n1) Summary 2) Wins 3) Risks 4) 2-week actions. Plain language for VP.`,
}),
}).then((r) => r.json());
console.log(mave.content || "");
Example Output
Summary: 23 up, 14 down, 8 new. Traffic: 12,400/mo. 31 in top 10.
Wins: "marketing persona tool" 18→6 (+12, 2400 vol). "content testing
platform" 24→11 (+13). Close to page 1 — backlinks needed.
Risks: "focus group software" 8→15 (-7). Lost ~800/mo. Check competitors.
Actions: 1) Audit /product/focus-groups 2) Build backlinks for "content
testing platform" 3) Internal-link to new ranking pages 4) Weekly alerts
Error Handling
First run has no baseline
First run has no baseline
On first run, all keywords appear as “new.” Run twice with a gap (daily/weekly) for meaningful change data.
Position 0 for featured snippets
Position 0 for featured snippets
SEMrush may return position 0 for SERP features. The code defaults to 100 for unparseable values — adjust as needed.
Database mismatch
Database mismatch
Match the
database parameter to your target market. US rankings won’t match UK Search Console data.Stale baseline after migrations
Stale baseline after migrations
Delete the baseline file and rebuild after domain migrations, URL restructures, or redirect changes.