Find the content you're missing
Keywords a competitor ranks for that you don't, with volume and the questions people ask.
A competitor's rankings are a to-do list someone else wrote for you. One gap pull becomes outlines, ordered by volume and difficulty.
Don't know who to run the gap against? Start there. It ends with the closest competitor picked out.
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
Pull the gap: what do they rank for that you don't?
curl https://api.seofetch.com/v1/domains/gap \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": ["semrush.com", "ahrefs.com"], "mode": "gap", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 100, "offset": 0}' \
-o gap.json
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/domains/gap",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domains": ["semrush.com", "ahrefs.com"], "mode": "gap", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 100, "offset": 0},
)
with open("gap.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/domains/gap", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domains: ["semrush.com", "ahrefs.com"], mode: "gap", engine: "google", location: 2840, language: "en", device: "desktop", limit: 100, offset: 0 }),
});
const body = await resp.json();
writeFileSync("gap.json", JSON.stringify(body, null, 2));
{
"id": "doma_6ulpwpudmvigzhrj4wf4jm2k",
"request_id": "req_5xqa6h3lmbmv7d3zqj55xkrzlu",
"object": "domain_gap",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"credits": {
"charged": 15,
"balance": 9857
},
"data": {
"mode": "gap",
"domains": [
"semrush.com",
"ahrefs.com"
],
"items": [
{
"keyword": "search engine optimization",
"volume": 18100,
"ranks": [
null,
{
"rank": 2,
"url": "https://ahrefs.com/blog/what-is-seo/",
"date": "2026-06-28"
}
]
},
"…"
],
"totals": [
2834,
1315
],
"matched_count": 409,
"coverage": {
"from": "2026-06-28",
"observations": 409
},
"limit": 100,
"offset": 0
}
}
Volume comes attached. A null on your side of ranks is the article you haven't written.
Floor it by volume and take the top of the list.
jq -c '[.data.items[] | select(.volume >= 1000)] | sort_by(-.volume) | .[:20]' gap.json
import json
items = json.load(open("gap.json"))["data"]["items"]
loud = sorted((i for i in items if i["volume"] >= 1000), key=lambda i: -i["volume"])
top20 = loud[:20]
print(top20)
import { readFileSync } from "node:fs";
const { items } = JSON.parse(readFileSync("gap.json")).data;
const top20 = items.filter((i) => i.volume >= 1000)
.sort((a, b) => b.volume - a.volume).slice(0, 20);
console.log(top20);
The 1,000 floor is a default. Niche sites live happily below it.
Pull the questions people actually ask for your top gap keyword — shown here for a keyword from another niche; same call, any keyword.
curl https://api.seofetch.com/v1/keywords/questions \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"keyword": "running shoes", "location": 2840, "language": "en", "limit": 50}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/keywords/questions",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"keyword": "running shoes", "location": 2840, "language": "en", "limit": 50},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/keywords/questions", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keyword: "running shoes", location: 2840, language: "en", limit: 50 }),
});
const { data } = await resp.json();
{
"id": "keyw_e2xp2us5fjowpovkxd3kg5ox",
"request_id": "req_klhjkyye3fn47cl5uvqmv2z3g4",
"object": "keyword_questions",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"credits": {
"charged": 20,
"balance": 9857
},
"data": {
"keyword": "running shoes",
"items": [
{
"question": "How often should you replace running shoes?",
"first_seen": "2026-06-11",
"last_seen": "2026-07-25",
"times_seen": 18
},
{
"question": "Are carbon plate shoes worth it?",
"first_seen": "2026-07-02",
"last_seen": "2026-07-27",
"times_seen": 7
},
"…"
],
"count": 42,
"coverage": {
"from": "2026-07-01",
"observations": 130
}
}
}
People-Also-Ask, deduplicated, with first- and last-seen dates. An outline that answers what people ask beats one that guesses.
Score the shortlist; write the winnable ones first.
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_y4zlhnwzb5oofgqslo64leja",
"request_id": "req_wtumkpqpubojpcq43dh5hehjja",
"object": "keyword_difficulty",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"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"
}
]
}
}
Difficulty and volume order the shortlist. Difficulty is comparative; your authority and intent match decide the rest. A keyword still "pending" gets its score on a later call, so re-ask rather than dropping it.
Run the whole thing.
Each language below is the full pipeline. Swap in your domain and a competitor's, export SEOFETCH_KEY, run it. gaps.json comes out sorted by difficulty, pending scores last, questions attached to your pick.
#!/usr/bin/env bash
# gaps.sh -- gap pull -> volume floor/top 20 -> questions on top pick -> difficulty batch -> gaps.json.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
YOU="${TARGET_DOMAIN:-yourdomain.com}"
THEM="${COMPETITOR_DOMAIN:-competitor.com}"
api() { curl -sS --fail-with-body "https://api.seofetch.com$1" \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$2"; }
# 1. what do they rank for that you don't
api /v1/domains/gap \
"$(jq -cn --arg y "$YOU" --arg t "$THEM" '{domains: [$y, $t], mode: "gap", engine: "google", location: 2840, language: "en", device: "desktop", limit: 100, offset: 0}')" \
> gap.json
# 2. floor by volume (1000 -- policy knob), top 20
jq -c '[.data.items[] | select(.volume >= 1000)] | sort_by(-.volume) | .[:20]' gap.json > top20.json
[ "$(jq length top20.json)" -gt 0 ] || { echo "nothing clears the volume floor"; exit 0; }
# 3. questions for the top pick
PICKS=1 # probe more picks: +20cr each
: > questions.jsonl
jq -r ".[0:${PICKS}][].keyword" top20.json | while IFS= read -r kw; do
api /v1/keywords/questions \
"$(jq -cn --arg k "$kw" '{keyword: $k, location: 2840, language: "en", limit: 50}')" |
jq -c --arg k "$kw" '{keyword: $k, questions: [.data.items[].question]}' >> questions.jsonl
done
# 4. difficulty on the whole shortlist
api /v1/keywords/difficulty \
"$(jq -s '{keywords: [.[].keyword], location: 2840, language: "en"}' top20.json)" > difficulty.json
# 5. merge: keyword, volume, difficulty, questions[] -- winnable first
jq -n --slurpfile top20 top20.json --slurpfile diff difficulty.json --slurpfile qs <(jq -s '.' questions.jsonl) '
($diff[0].data.items | map({(.keyword): .}) | add) as $dmap |
($qs[0] | map({(.keyword): .questions}) | add // {}) as $qmap |
[$top20[0][] | {
keyword, volume,
difficulty: ($dmap[.keyword].difficulty // null),
status: ($dmap[.keyword].status // "pending"),
questions: ($qmap[.keyword] // [])
}] | sort_by(if .difficulty == null then 999 else .difficulty end)
' > gaps.json
echo "gaps.json: $(jq length gaps.json) keywords, winnable first"
#!/usr/bin/env python3
"""gaps.py -- gap pull -> volume floor/top 20 -> questions on top pick -> difficulty batch -> gaps.json."""
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
YOU = os.environ.get("TARGET_DOMAIN", "yourdomain.com")
THEM = os.environ.get("COMPETITOR_DOMAIN", "competitor.com")
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"]
# 1. what do they rank for that you don't
gap = api("/v1/domains/gap",
{"domains": [YOU, THEM], "mode": "gap", "engine": "google", "location": 2840,
"language": "en", "device": "desktop", "limit": 100, "offset": 0})
# 2. floor by volume (1000 -- policy knob), top 20
top20 = sorted((i for i in gap["items"] if i["volume"] >= 1000), key=lambda i: -i["volume"])[:20]
if not top20:
raise SystemExit("nothing clears the volume floor")
# 3. questions for the top pick
PICKS = 1 # probe more picks: +20cr each
questions = {}
for item in top20[:PICKS]:
kw = item["keyword"]
data = api("/v1/keywords/questions", {"keyword": kw, "location": 2840, "language": "en", "limit": 50})
questions[kw] = [q["question"] for q in data["items"]]
# 4. difficulty on the whole shortlist
difficulty = api("/v1/keywords/difficulty",
{"keywords": [i["keyword"] for i in top20], "location": 2840, "language": "en"})
scores = {i["keyword"]: i for i in difficulty["items"]}
# 5. merge: keyword, volume, difficulty, questions[] -- winnable first
gaps = [
{"keyword": i["keyword"], "volume": i["volume"],
"difficulty": scores[i["keyword"]]["difficulty"],
"status": scores[i["keyword"]]["status"],
"questions": questions.get(i["keyword"], [])}
for i in top20
]
gaps.sort(key=lambda g: g["difficulty"] if g["difficulty"] is not None else 999)
json.dump(gaps, open("gaps.json", "w"), indent=2)
print(f"gaps.json: {len(gaps)} keywords, winnable first")
#!/usr/bin/env node
// gaps.mjs -- gap pull -> volume floor/top 20 -> questions on top pick -> difficulty batch -> gaps.json.
import { writeFileSync } from "node:fs";
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const YOU = process.env.TARGET_DOMAIN || "yourdomain.com";
const THEM = process.env.COMPETITOR_DOMAIN || "competitor.com";
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;
}
// 1. what do they rank for that you don't
const gap = await api("/v1/domains/gap",
{ domains: [YOU, THEM], mode: "gap", engine: "google", location: 2840, language: "en",
device: "desktop", limit: 100, offset: 0 });
// 2. floor by volume (1000 -- policy knob), top 20
const top20 = gap.items.filter((i) => i.volume >= 1000).sort((a, b) => b.volume - a.volume).slice(0, 20);
if (!top20.length) throw new Error("nothing clears the volume floor");
// 3. questions for the top pick
const PICKS = 1; // probe more picks: +20cr each
const questions = {};
for (const item of top20.slice(0, PICKS)) {
const data = await api("/v1/keywords/questions",
{ keyword: item.keyword, location: 2840, language: "en", limit: 50 });
questions[item.keyword] = data.items.map((q) => q.question);
}
// 4. difficulty on the whole shortlist
const difficulty = await api("/v1/keywords/difficulty",
{ keywords: top20.map((i) => i.keyword), location: 2840, language: "en" });
const scores = Object.fromEntries(difficulty.items.map((i) => [i.keyword, i]));
// 5. merge: keyword, volume, difficulty, questions[] -- winnable first
const gaps = top20.map((i) => ({
keyword: i.keyword, volume: i.volume,
difficulty: scores[i.keyword].difficulty, status: scores[i.keyword].status,
questions: questions[i.keyword] || [],
})).sort((a, b) => (a.difficulty ?? 999) - (b.difficulty ?? 999));
writeFileSync("gaps.json", JSON.stringify(gaps, null, 2));
console.log(`gaps.json: ${gaps.length} keywords, winnable first`);