Let an LLM pick what to optimize next
An agent ranks your pages by winnable upside — positions, volume, difficulty, on-page gaps.
Every request and response here is JSON with a fixed envelope — no scraping a SERP, no parsing HTML for a title tag — which is what makes the loop automatable. The four calls below are the data layer; the filtering between them (what counts as winnable, where your volume floor sits) is policy you give the agent, and yours will differ from ours.
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
Ask where you currently rank for each target query.
curl https://api.seofetch.com/v1/search \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "best running shoes"}'
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"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
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 { data } = await resp.json();
{
"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
}
]
}
}
Feed the agent your query list. A common filter is positions 4-10 — close enough that on-page work can move them (the default depth returns the top 10; depth 100 widens the window at 10x the price). Treat the cutoff as a heuristic; where it sits for you depends on the SERP.
Size the demand: how many searches does each query actually get?
curl https://api.seofetch.com/v1/keywords/volume \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"keywords": ["running shoes", "trail running shoes"], "location": "US", "language": "en"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/keywords/volume",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"keywords": ["running shoes", "trail running shoes"], "location": "US", "language": "en"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/keywords/volume", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keywords: ["running shoes", "trail running shoes"], location: "US", language: "en" }),
});
const { data } = await resp.json();
{
"id": "keyw_5g6y56kzpejnu4bocf4x45ya",
"request_id": "req_ggqiritzjnaevlnekimax3ggj4",
"object": "keyword_volume",
"created_at": "2026-08-09T07:25:14Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 20,
"balance": 9857
},
"data": {
"location": {
"code": 2840,
"name": "United States"
},
"language": {
"code": "en",
"name": "English"
},
"network": "google_search",
"items_count": 2,
"items": [
{
"keyword": "running shoes",
"avg_monthly_searches": 90500,
"competition": "HIGH",
"competition_index": 88,
"cpc": 1.24,
"low_bid": 0.42,
"high_bid": 2.1,
"monthly": [
{
"month": "2026-06",
"search_volume": 91000
},
"…"
]
},
{
"keyword": "trail running shoes",
"avg_monthly_searches": 8100,
"competition": "MEDIUM",
"competition_index": 54,
"cpc": 0.87,
"low_bid": 0.31,
"high_bid": 1.55,
"monthly": [
{
"month": "2026-06",
"search_volume": 8300
},
"…"
]
}
]
}
}
Drop queries under your volume floor before paying for difficulty. Volume is searches, not visits — what you'd capture depends on rank and what else is on the SERP.
Filter to winnable: keyword difficulty for the survivors.
curl https://api.seofetch.com/v1/keywords/difficulty \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"keywords": ["buy running shoes", "cold keyword"], "location": 2840, "language": "en"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/keywords/difficulty",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"keywords": ["buy running shoes", "cold keyword"], "location": 2840, "language": "en"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/keywords/difficulty", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keywords: ["buy running shoes", "cold keyword"], location: 2840, language: "en" }),
});
const { data } = await resp.json();
{
"id": "keyw_guvfrs2kjjugvvblqe6osqdn",
"request_id": "req_wkjcaqntlra7hh4dmtecplsfoa",
"object": "keyword_difficulty",
"created_at": "2026-08-09T07:25:14Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 110,
"balance": 9857
},
"data": {
"items_count": 2,
"items": [
{
"keyword": "buy running shoes",
"difficulty": 42,
"status": "available"
},
{
"keyword": "cold keyword",
"difficulty": null,
"status": "pending"
}
]
}
}
The agent keeps low-difficulty/high-volume pairs — the shortlist. The ceiling and floor are your policy, not ours; a keyword still status "pending" gets its score on a later call.
Find the on-page gaps on each shortlisted URL.
curl https://api.seofetch.com/v1/page/crawl \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/page/crawl",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"url": "https://example.com/"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/page/crawl", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com/" }),
});
const { data } = await resp.json();
{
"id": "craw_gi3yunmyhr5dam3bljwvyspg",
"request_id": "req_zj76u4j5cvanfd5jnmrhxl3tqy",
"object": "crawl",
"created_at": "2026-08-09T07:25:14Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 2,
"balance": 9857
},
"data": {
"url": "https://example.com/",
"final_url": "https://example.com/",
"http_status": 200,
"title": "Example Domain",
"meta_description": "An example page used for documentation.",
"canonical": "https://example.com/",
"headings": {
"h1_count": 1,
"h2_count": 2
},
"links": {
"internal": 2,
"external": 1
},
"images": {
"total": 2,
"missing_alt": 1
},
"is_html": true,
"load_ms": 812
}
}
Missing title, meta, or canonical are the cheapest wins — the crawl reports presence, judging quality is your agent's policy. Either way it now has a ranked TODO with evidence.
Run the whole thing.
Each language below is the full pipeline — save it, run it after exporting SEOFETCH_KEY (chmod +x first for the shell version). queries.txt in, todo.json out. The 4-10 window, the 1,000 volume floor, and the 50 difficulty ceiling are the knobs — ours, not gospel. Every entry carries its rank, volume, difficulty, and gaps — the evidence your agent ranks with.
#!/usr/bin/env bash
# optimize.sh -- queries -> your ranks -> demand -> difficulty -> on-page gaps.
# queries.txt: one query per line. Output: todo.json, most gaps first.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
DOMAIN="${TARGET_DOMAIN:-yourdomain.com}"
# SEARCH_DEPTH=100 MAX_RANK=15 widens the window -- 10 credits per query instead of 1
SEARCH_DEPTH="${SEARCH_DEPTH:-10}"
MAX_RANK="${MAX_RANK:-10}"
api() { curl -sS --fail-with-body "https://api.seofetch.com$1" \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$2"; }
# 1. where you rank; keep 4-10 (a heuristic -- tune the window to your SERP)
: > ranked.jsonl
while IFS= read -r q; do
[ -z "$q" ] && continue
api /v1/search "$(jq -cn --arg q "$q" --argjson d "$SEARCH_DEPTH" '{query: $q, depth: $d}')" |
jq -c --arg d "$DOMAIN" --arg q "$q" \
'.data.items[]? | select(.domain == $d) | {query: $q, rank, url}' >> ranked.jsonl
done < queries.txt
jq -c --argjson m "$MAX_RANK" 'select(.rank >= 4 and .rank <= $m)' ranked.jsonl > window.jsonl
[ -s window.jsonl ] || { echo "nothing in the 4-$MAX_RANK window today"; exit 0; }
# 2. demand for the window; drop what's under your floor (1000 here -- policy knob)
api /v1/keywords/volume \
"$(jq -s '{keywords: [.[].query], location: 2840, language: "en"}' window.jsonl)" > volume.json
jq -c '.data.items[] | select(.avg_monthly_searches >= 1000)' volume.json > loud.jsonl
[ -s loud.jsonl ] || { echo "window is all low-volume; lower the floor or add queries"; exit 0; }
# 3. difficulty; keep < 50. status "pending" keywords score on a later run -- same flow.
# winnable.jsonl carries volume through so the final TODO doesn't lose it.
api /v1/keywords/difficulty \
"$(jq -s '{keywords: [.[].keyword], location: 2840, language: "en"}' loud.jsonl)" > difficulty.json
jq -c --slurpfile loud <(jq -s '.' loud.jsonl) \
'($loud[0] | map({(.keyword): .avg_monthly_searches}) | add) as $vmap |
.data.items[] | select(.status == "available" and .difficulty < 50) |
{keyword, avg_monthly_searches: $vmap[.keyword], difficulty, status}' \
difficulty.json > winnable.jsonl
jq -c '.data.items[] | select(.status == "pending")' difficulty.json > pending.jsonl
# 4. crawl each shortlisted URL; name the gaps; rank the TODO -- every entry
# carries query, url, rank, avg_monthly_searches, difficulty, and gaps
: > todo.jsonl
while IFS= read -r w; do
kw=$(jq -r '.keyword' <<<"$w")
match=$(jq -c --arg q "$kw" 'select(.query == $q)' window.jsonl | head -n1)
url=$(jq -r '.url // empty' <<<"$match")
[ -n "$url" ] || continue
api /v1/page/crawl "$(jq -cn --arg u "$url" '{url: $u}')" |
jq -c --argjson w "$w" --argjson m "$match" '{
query: $w.keyword, url: .data.url, rank: $m.rank,
avg_monthly_searches: $w.avg_monthly_searches, difficulty: $w.difficulty,
gaps: [
(if .data.title == null then "title" else empty end),
(if .data.meta_description == null then "meta_description" else empty end),
(if .data.canonical == null then "canonical" else empty end)
]
}' >> todo.jsonl
done < winnable.jsonl
jq -s 'sort_by(-(.gaps | length), -.avg_monthly_searches)' todo.jsonl > todo.json
echo "todo.json: $(jq length todo.json) pages, most gaps first (volume breaks ties). pending.jsonl re-scores tomorrow."
#!/usr/bin/env python3
"""optimize.py -- queries -> your ranks -> demand -> difficulty -> on-page gaps.
queries.txt: one query per line. Output: todo.json, most gaps first.
"""
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
DOMAIN = os.environ.get("TARGET_DOMAIN", "yourdomain.com")
# SEARCH_DEPTH=100 MAX_RANK=15 widens the window -- 10 credits per query instead of 1
SEARCH_DEPTH = int(os.environ.get("SEARCH_DEPTH", 10))
MAX_RANK = int(os.environ.get("MAX_RANK", 10))
def api(path, payload):
resp = requests.post(
f"https://api.seofetch.com{path}",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json=payload,
)
resp.raise_for_status()
return resp.json()["data"]
queries = [q.strip() for q in open("queries.txt") if q.strip()]
# 1. where you rank; keep 4-10 (a heuristic -- tune the window to your SERP)
window = []
for q in queries:
data = api("/v1/search", {"query": q, "depth": SEARCH_DEPTH})
for item in data["items"]:
if item["domain"] == DOMAIN and 4 <= item["rank"] <= MAX_RANK:
window.append({"query": q, "rank": item["rank"], "url": item["url"]})
if not window:
raise SystemExit(f"nothing in the 4-{MAX_RANK} window today")
# 2. demand for the window; drop what's under your floor (1000 here -- policy knob)
volume = api("/v1/keywords/volume",
{"keywords": [w["query"] for w in window], "location": 2840, "language": "en"})
loud = [i for i in volume["items"] if i["avg_monthly_searches"] >= 1000]
if not loud:
raise SystemExit("window is all low-volume; lower the floor or add queries")
# 3. difficulty; keep < 50. status "pending" keywords score on a later run -- same flow.
# volumes maps keyword -> avg_monthly_searches so the final TODO doesn't lose it.
volumes = {i["keyword"]: i["avg_monthly_searches"] for i in loud}
difficulty = api("/v1/keywords/difficulty",
{"keywords": [i["keyword"] for i in loud], "location": 2840, "language": "en"})
winnable = [i for i in difficulty["items"] if i["status"] == "available" and i["difficulty"] < 50]
pending = [i for i in difficulty["items"] if i["status"] == "pending"]
json.dump(pending, open("pending.json", "w"), indent=2)
# 4. crawl each shortlisted URL; name the gaps; rank the TODO -- every entry
# carries query, url, rank, avg_monthly_searches, difficulty, and gaps
todo = []
for i in winnable:
match = next((w for w in window if w["query"] == i["keyword"]), None)
if not match:
continue
page = api("/v1/page/crawl", {"url": match["url"]})
gaps = [name for name, present in (
("title", page["title"]), ("meta_description", page["meta_description"]),
("canonical", page["canonical"]),
) if present is None]
todo.append({
"query": i["keyword"], "url": page["url"], "rank": match["rank"],
"avg_monthly_searches": volumes.get(i["keyword"]), "difficulty": i["difficulty"],
"gaps": gaps,
})
todo.sort(key=lambda t: (-len(t["gaps"]), -(t["avg_monthly_searches"] or 0)))
json.dump(todo, open("todo.json", "w"), indent=2)
print(f"todo.json: {len(todo)} pages, most gaps first (volume breaks ties). pending.json re-scores tomorrow.")
#!/usr/bin/env node
// optimize.mjs -- queries -> your ranks -> demand -> difficulty -> on-page gaps.
// queries.txt: one query per line. Output: todo.json, most gaps first.
import { 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.TARGET_DOMAIN || "yourdomain.com";
// SEARCH_DEPTH=100 MAX_RANK=15 widens the window -- 10 credits per query instead of 1
const SEARCH_DEPTH = Number(process.env.SEARCH_DEPTH || 10);
const MAX_RANK = Number(process.env.MAX_RANK || 10);
async function api(path, payload) {
const resp = await fetch(`https://api.seofetch.com${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${SEOFETCH_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
return (await resp.json()).data;
}
const queries = readFileSync("queries.txt", "utf8").split("\n").map((q) => q.trim()).filter(Boolean);
// 1. where you rank; keep 4-10 (a heuristic -- tune the window to your SERP)
const window = [];
for (const q of queries) {
const data = await api("/v1/search", { query: q, depth: SEARCH_DEPTH });
for (const item of data.items) {
if (item.domain === DOMAIN && item.rank >= 4 && item.rank <= MAX_RANK) {
window.push({ query: q, rank: item.rank, url: item.url });
}
}
}
if (!window.length) throw new Error(`nothing in the 4-${MAX_RANK} window today`);
// 2. demand for the window; drop what's under your floor (1000 here -- policy knob)
const volume = await api("/v1/keywords/volume",
{ keywords: window.map((w) => w.query), location: 2840, language: "en" });
const loud = volume.items.filter((i) => i.avg_monthly_searches >= 1000);
if (!loud.length) throw new Error("window is all low-volume; lower the floor or add queries");
// 3. difficulty; keep < 50. status "pending" keywords score on a later run -- same flow.
// volumes maps keyword -> avg_monthly_searches so the final TODO doesn't lose it.
const volumes = Object.fromEntries(loud.map((i) => [i.keyword, i.avg_monthly_searches]));
const difficulty = await api("/v1/keywords/difficulty",
{ keywords: loud.map((i) => i.keyword), location: 2840, language: "en" });
const winnable = difficulty.items.filter((i) => i.status === "available" && i.difficulty < 50);
const pending = difficulty.items.filter((i) => i.status === "pending");
writeFileSync("pending.json", JSON.stringify(pending, null, 2));
// 4. crawl each shortlisted URL; name the gaps; rank the TODO -- every entry
// carries query, url, rank, avg_monthly_searches, difficulty, and gaps
const todo = [];
for (const i of winnable) {
const match = window.find((w) => w.query === i.keyword);
if (!match) continue;
const page = await api("/v1/page/crawl", { url: match.url });
const gaps = [["title", page.title], ["meta_description", page.meta_description], ["canonical", page.canonical]]
.filter(([, v]) => v === null).map(([k]) => k);
todo.push({
query: i.keyword, url: page.url, rank: match.rank,
avg_monthly_searches: volumes[i.keyword], difficulty: i.difficulty, gaps,
});
}
todo.sort((a, b) => b.gaps.length - a.gaps.length || (b.avg_monthly_searches || 0) - (a.avg_monthly_searches || 0));
writeFileSync("todo.json", JSON.stringify(todo, null, 2));
console.log(`todo.json: ${todo.length} pages, most gaps first (volume breaks ties). pending.json re-scores tomorrow.`);