Find out who you're actually up against
Competitors ranked by keyword overlap in the SERP archive, rather than who you assume they are.
Who you think you compete with and who shares your SERPs are usually two different lists. Three endpoints, five requests, one shortlist. Responses on this page use semrush.com as the example domain.
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 the archive who ranks where you rank.
curl https://api.seofetch.com/v1/domains/competitors \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "semrush.com", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 25}' \
-o competitors.json
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/domains/competitors",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domain": "semrush.com", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 25},
)
with open("competitors.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/competitors", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain: "semrush.com", engine: "google", location: 2840, language: "en", device: "desktop", limit: 25 }),
});
const body = await resp.json();
writeFileSync("competitors.json", JSON.stringify(body, null, 2));
{
"id": "doma_5vtgytujnbkbpbjdh2bklahb",
"request_id": "req_6wypstdm7rocfc4krjiidkdpym",
"object": "domain_competitors",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"credits": {
"charged": 30,
"balance": 9857
},
"data": {
"domain": "semrush.com",
"keywords_total": 2834,
"keywords_considered": 200,
"total_competitors": 1743,
"competitors": [
{
"domain": "reddit.com",
"shared_keywords": 106,
"keywords_total": 114768,
"avg_rank": 3.9,
"target_avg_rank": 1,
"overlap": 0.53,
"shared_volume": 370910,
"sample_keywords": [
{
"keyword": "google search console",
"volume": 368000
},
"…"
]
},
"…"
],
"coverage": {
"from": "2026-05-20",
"observations": 200
}
}
}
Ranked by overlap: shared keywords, weighted by how often you co-occur. Expect a giant or two at the top. reddit.com intersects everyone, and pruning them is your first edit. Built from the domain's most recent archived keywords; keywords_considered is the sample size.
Prune the giants; keep the 3-5 with real overlap.
jq -c '[.data.competitors[] | select(.overlap >= 0.3) | select(.domain as $d | ["reddit.com", "wikipedia.org", "quora.com"] | index($d) | not)]' competitors.json
import json
BLOCKLIST = {"reddit.com", "wikipedia.org", "quora.com"}
competitors = json.load(open("competitors.json"))["data"]["competitors"]
survivors = [c for c in competitors
if c["overlap"] >= 0.3 and c["domain"] not in BLOCKLIST]
print(survivors)
import { readFileSync } from "node:fs";
const BLOCKLIST = new Set(["reddit.com", "wikipedia.org", "quora.com"]);
const { competitors } = JSON.parse(readFileSync("competitors.json")).data;
const survivors = competitors.filter(
(c) => c.overlap >= 0.3 && !BLOCKLIST.has(c.domain));
console.log(survivors);
The blocklist is yours; you know which platforms aren't competing with you. The 0.3 floor is a default too. Adjust both for your market.
Size up each survivor.
curl https://api.seofetch.com/v1/domains/overview \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "ahrefs.com", "location": 2840, "language": "en"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/domains/overview",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domain": "ahrefs.com", "location": 2840, "language": "en"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/domains/overview", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain: "ahrefs.com", location: 2840, language: "en" }),
});
const { data } = await resp.json();
{
"id": "doma_l5sttkwwhbltpdaj4ocitm6l",
"request_id": "req_vglyeybggrlz7kagqfagvj4kiy",
"object": "domain_overview",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"credits": {
"charged": 10,
"balance": 9857
},
"data": {
"organic": {
"keywords_count": 101,
"traffic_estimate": 70.17,
"traffic_value": 704.38,
"positions": {
"top_3": 0,
"top_10": 3,
"top_20": 11,
"top_100": 101
}
},
"paid": {
"keywords_count": 0,
"traffic_estimate": 0,
"traffic_value": 0,
"positions": {
"top_3": 0,
"top_10": 0,
"top_20": 0,
"top_100": 0
}
}
}
}
Keyword count, traffic estimate, position spread. 10 credits per competitor. Small overlap plus a big footprint is an aspiration, not a rival.
Turn the closest one into a keyword list.
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}'
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},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
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 { data } = await resp.json();
{
"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
}
}
mode "gap" returns keywords they rank for that you don't. The content-gaps walkthrough picks up from there.
Run the whole thing.
Each language below is the full pipeline. Swap in your domain, export SEOFETCH_KEY, run it. The survivors land in cohort.csv, best footprint first, with a gap pull against the closest one waiting in gap.json.
#!/usr/bin/env bash
# competitors.sh -- who ranks where you rank -> pruned cohort -> gap vs the closest one.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
DOMAIN="${TARGET_DOMAIN:-yourdomain.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. who ranks where you rank
api /v1/domains/competitors \
"$(jq -cn --arg d "$DOMAIN" '{domain: $d, engine: "google", location: 2840, language: "en", device: "desktop", limit: 25}')" \
> competitors.json
# 2. prune giants, keep real overlap (0.3 floor -- policy knob)
jq -c '[.data.competitors[] | select(.overlap >= 0.3) | select(.domain as $d | ["reddit.com","wikipedia.org","quora.com"] | index($d) | not)]' \
competitors.json > cohort.json
[ "$(jq length cohort.json)" -gt 0 ] || { echo "nothing survives the prune -- lower the floor or widen the blocklist"; exit 0; }
# 3. size up the top 3 survivors by overlap -- best-matched cohort first
SURVIVORS=3 # size up more: +10cr each
top_survivors=$(jq -c "sort_by(-.overlap) | .[0:${SURVIVORS}]" cohort.json)
: > cohort.csv
echo "$top_survivors" | jq -r '.[].domain' | while IFS= read -r d; do
api /v1/domains/overview \
"$(jq -cn --arg d "$d" '{domain: $d, location: 2840, language: "en"}')" > "overview-$d.json"
jq -r --arg d "$d" \
'[$d, (.data.organic.keywords_count // 0), (.data.organic.traffic_estimate // 0)] | @csv' \
"overview-$d.json" >> cohort.csv
done
sort -t, -k3 -rn cohort.csv -o cohort.csv
echo "cohort.csv: domain, organic keywords, traffic estimate -- best first (footprint, not overlap)"
# 4. turn the highest-overlap survivor into a keyword list
top=$(echo "$top_survivors" | jq -r 'sort_by(-.overlap) | .[0].domain')
api /v1/domains/gap \
"$(jq -cn --arg a "$DOMAIN" --arg b "$top" '{domains: [$a, $b], mode: "gap", engine: "google", location: 2840, language: "en", device: "desktop", limit: 100, offset: 0}')" \
> gap.json
echo "gap.json: $(jq '.data.matched_count' gap.json) keywords $top ranks for that $DOMAIN doesn't"
#!/usr/bin/env python3
"""competitors.py -- who ranks where you rank -> pruned cohort -> gap vs the closest one."""
import csv
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
DOMAIN = os.environ.get("TARGET_DOMAIN", "yourdomain.com")
BLOCKLIST = {"reddit.com", "wikipedia.org", "quora.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. who ranks where you rank
competitors = api("/v1/domains/competitors",
{"domain": DOMAIN, "engine": "google", "location": 2840, "language": "en",
"device": "desktop", "limit": 25})["competitors"]
# 2. prune giants, keep real overlap (0.3 floor -- policy knob)
cohort = [c for c in competitors if c["overlap"] >= 0.3 and c["domain"] not in BLOCKLIST]
if not cohort:
raise SystemExit("nothing survives the prune -- lower the floor or widen the blocklist")
# 3. size up the top 3 survivors by overlap -- best-matched cohort first
SURVIVORS = 3 # size up more: +10cr each
top_survivors = sorted(cohort, key=lambda c: -c["overlap"])[:SURVIVORS]
rows = []
for c in top_survivors:
d = c["domain"]
overview = api("/v1/domains/overview", {"domain": d, "location": 2840, "language": "en"})
rows.append((d, overview["organic"]["keywords_count"], overview["organic"]["traffic_estimate"]))
rows.sort(key=lambda r: -r[2])
with open("cohort.csv", "w", newline="") as f:
csv.writer(f).writerows(rows)
print("cohort.csv: domain, organic keywords, traffic estimate -- best first (footprint, not overlap)")
# 4. turn the highest-overlap survivor into a keyword list
top = top_survivors[0]["domain"]
gap = api("/v1/domains/gap",
{"domains": [DOMAIN, top], "mode": "gap", "engine": "google", "location": 2840,
"language": "en", "device": "desktop", "limit": 100, "offset": 0})
json.dump(gap, open("gap.json", "w"), indent=2)
print(f"gap.json: {gap['matched_count']} keywords {top} ranks for that {DOMAIN} doesn't")
#!/usr/bin/env node
// competitors.mjs -- who ranks where you rank -> pruned cohort -> gap vs the closest one.
import { 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";
const BLOCKLIST = new Set(["reddit.com", "wikipedia.org", "quora.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. who ranks where you rank
const { competitors } = await api("/v1/domains/competitors",
{ domain: DOMAIN, engine: "google", location: 2840, language: "en", device: "desktop", limit: 25 });
// 2. prune giants, keep real overlap (0.3 floor -- policy knob)
const cohort = competitors.filter((c) => c.overlap >= 0.3 && !BLOCKLIST.has(c.domain));
if (!cohort.length) throw new Error("nothing survives the prune -- lower the floor or widen the blocklist");
// 3. size up the top 3 survivors by overlap -- best-matched cohort first
const SURVIVORS = 3; // size up more: +10cr each
const topSurvivors = [...cohort].sort((a, b) => b.overlap - a.overlap).slice(0, SURVIVORS);
const rows = [];
for (const c of topSurvivors) {
const overview = await api("/v1/domains/overview", { domain: c.domain, location: 2840, language: "en" });
rows.push([c.domain, overview.organic.keywords_count, overview.organic.traffic_estimate]);
}
rows.sort((a, b) => b[2] - a[2]);
writeFileSync("cohort.csv", rows.map((r) => r.join(",")).join("\n") + "\n");
console.log("cohort.csv: domain, organic keywords, traffic estimate -- best first (footprint, not overlap)");
// 4. turn the highest-overlap survivor into a keyword list
const top = topSurvivors[0].domain;
const gap = await api("/v1/domains/gap",
{ domains: [DOMAIN, top], mode: "gap", engine: "google", location: 2840, language: "en",
device: "desktop", limit: 100, offset: 0 });
writeFileSync("gap.json", JSON.stringify(gap, null, 2));
console.log(`gap.json: ${gap.matched_count} keywords ${top} ranks for that ${DOMAIN} doesn't`);
Got a shortlist? The content-gaps walkthrough turns your closest competitor into a keyword list.