curl and jq; Python 3 with beautifulsoup4; an LLM API key that exposes an OpenAI-compatible /v1/chat/completions endpoint; about 50 MB of free disk space; a text editor.funnel.jsonl dataset plus a funnel-report.md table that maps each funnel layer (ads → social → SEO) to evidence you can open in a browser.
bash --version — you should see a version line, not command not found.curl --version. The scripts use -sS, -o, -w '%{http_code}', and -A; all are present in any current build.jq --version must print a version. Install with brew install jq (macOS) or sudo apt install jq (Debian/Ubuntu).python3 --version, then python3 -m pip install beautifulsoup4. BeautifulSoup is an HTML parser — it turns raw markup into a tree you can query by tag.LLM_API_KEY, LLM_BASE_URL, and LLM_MODEL. The exact model ID differs per provider — check your provider's documentation for the current identifier; do not guess it.https://<competitor-domain>/robots.txt and the terms of every platform you touch. Public ad libraries (Google Ads Transparency Center, Meta Ad Library) have their own access rules that change over time — verify the current URL and limits on the platform's help page before automating anything.What to do: create a workspace and pull three things per competitor: the landing page HTML, robots.txt, and sitemap.xml. Then record the ad-library and social results manually into a notes file.
Why: an LLM can only reason about text you actually give it. The funnel has three layers, and each lives in a different place:
| Funnel layer | Where to look | What to capture | What the AI does with it |
|---|---|---|---|
| Ads (top) | Public ad libraries; landing pages with UTM parameters | Ad copy, CTA, destination URL | Groups ads into offers and angles |
| Social (middle) | Public profile bios, pinned posts, link-in-bio | Bio text, recurring hooks, posting cadence | Detects the message that repeats |
| SEO (bottom) | `sitemap.xml`, `<title>`, meta description, H1/H2, `og:` tags | Page inventory and on-page fields | Clusters pages into topic silos |
mkdir -p ~/funnel/<competitor> && cd ~/funnel/<competitor>
UA="Mozilla/5.0 (funnel-research; contact: you@example.com)"
curl -sS -A "$UA" -o home.html -w "%{http_code}\n" "https://<competitor-domain>/"
curl -sS -A "$UA" -o robots.txt -w "%{http_code}\n" "https://<competitor-domain>/robots.txt"
curl -sS -A "$UA" -o sitemap.xml -w "%{http_code}\n" "https://<competitor-domain>/sitemap.xml"
grep -c "<loc>" sitemap.xml
Example: the three curl calls print 200, 200, 200, and grep prints a URL count such as 184. Replace <competitor-domain> with the bare host, e.g. example-shop.com — no https://, no trailing slash.
How to check: ls -la shows three non-empty files; grep -c "<loc>" sitemap.xml returns a number greater than 0. If sitemap.xml is empty, look for a Sitemap: line inside robots.txt and fetch that path instead.

What to do: extract a fixed set of fields from every saved page into one JSON object per line (JSONL — a text file where each line is a complete JSON record), then send that file to the model with a strict output schema.
Why: models drift when fed raw HTML. A fixed field list makes the output comparable across pages and lets you verify each claim against the source file later.
# extract.py — run inside ~/funnel/<competitor>
import json, glob
from bs4 import BeautifulSoup
def meta(soup, key):
tag = soup.find("meta", attrs={"name": key}) or soup.find("meta", attrs={"property": key})
return (tag.get("content") or "").strip() if tag else ""
rows = []
for path in sorted(glob.glob("pages/*.html")):
soup = BeautifulSoup(open(path, encoding="utf-8", errors="ignore").read(), "html.parser")
rows.append({
"source_file": path,
"title": soup.title.get_text(strip=True) if soup.title else "",
"description": meta(soup, "description"),
"og_title": meta(soup, "og:title"),
"h1": [h.get_text(strip=True) for h in soup.find_all("h1")],
"h2": [h.get_text(strip=True) for h in soup.find_all("h2")][:20],
"links": len(soup.find_all("a")),
})
with open("funnel.jsonl", "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"wrote {len(rows)} records")
Example: python3 extract.py prints wrote 184 records, and head -n 1 funnel.jsonl | jq . shows one object with title, description, h1, h2, links.
Then call the model with a schema that forbids free-form prose:
export LLM_API_KEY="<your-key>"
export LLM_BASE_URL="https://<provider-endpoint>/v1/chat/completions"
export LLM_MODEL="<model-id-from-provider-docs>"
jq -n --rawfile data funnel.jsonl --arg m "$LLM_MODEL" '{
model: $m,
temperature: 0,
messages: [
{role: "system", content: "You are a funnel analyst. Return only JSON: an array of objects with keys layer, claim, evidence_quote, source_file. layer is one of ads|social|seo. evidence_quote must be copied verbatim from the input. If you cannot quote it, omit the row."},
{role: "user", content: $data}
]
}' > payload.json
curl -sS "$LLM_BASE_URL" \
-H "Authorization: Bearer $LLM_API_KEY" \
-H "Content-Type: application/json" \
-d @payload.json | jq -r '.choices[0].message.content' > funnel-ai.json
The evidence_quote field is what makes this usable: it forces every AI claim to be a substring you can grep. Temperature 0 keeps the same input from producing different clusters on each run.
If you would rather keep the funnel map as a spreadsheet than as JSON, the same JSONL loads directly into a sheet — the workflow in ChatGPT Office: How to Create Documents and Spreadsheets with AI covers the table structure and column formatting.
How to check: jq 'length' funnel-ai.json returns a row count greater than 0, and jq -r '.[].layer' funnel-ai.json | sort | uniq -c shows how many rows landed in ads, social, and seo. A layer with zero rows means you did not feed that layer's text yet.

What to do: run three checks — quote validity, layer coverage, and a manual spot check — then write funnel-report.md.
Why: an LLM will occasionally paraphrase instead of quoting, or invent a plausible-sounding keyword that appears nowhere on the site. The grep check catches both.
# 1. Every evidence_quote must exist verbatim in the extracted dataset
jq -r '.[].evidence_quote' funnel-ai.json | while read -r q; do
[ -z "$q" ] && continue
grep -qF -- "$q" funnel.jsonl || echo "NOT FOUND: $q"
done
# 2. Layer coverage
jq -r '.[].layer' funnel-ai.json | sort | uniq -c
# 3. Manual spot check: open 3 source_file paths and confirm the claim on screen
jq -r '.[0:3][] | "\(.layer)\t\(.source_file)\t\(.claim)"' funnel-ai.json
Example of a clean run: step 1 prints nothing at all (no NOT FOUND lines), step 2 prints 31 ads, 18 social, 57 seo, and step 3 prints three tab-separated rows whose source_file values you can open locally.
Checklist before you publish the report:
NOT FOUND lines from the quote check.ads, social, seo) have at least one row.wc -l funnel.jsonl equals the number of HTML files in pages/.For a shareable deck instead of a Markdown file, the layout rules in How to Create Presentations with AI: Best Tools and Tips apply directly — one slide per funnel layer, evidence quotes in the speaker notes.
How to check: grep -c "^| " funnel-report.md returns the number of table rows, and opening the file in a viewer shows three sections titled Ads, Social, SEO with a source column in each.
Problem. curl cannot reach the domain at all.
curl: (6) Could not resolve host: competitor-domian.comdig +short <competitor-domain> (or nslookup). No output means DNS, not the site. Fix the spelling and retry.Problem. The page returns a bot-protection block instead of HTML.
HTTP/2 403 printed by -w "%{http_code}\n", and home.html contains a challenge page rather than your expected <title>.-A "Mozilla/5.0 (funnel-research; contact: you@example.com)"), add --compressed, slow down to one request per second, and if it still fails, fetch the page manually in a browser and save it into pages/ — do not attempt to bypass the protection.Problem. The model call returns prose instead of JSON.
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) when you parse funnel-ai.json.funnel.jsonl.curl -sS -i ... | head -n 20; if the input is too large, split funnel.jsonl into batches of about 40 records with split -l 40 funnel.jsonl batch- and run the same prompt per batch, then merge the arrays.You now have a reproducible pipeline: capture the raw ad, social, and SEO inputs, normalize them to JSONL, and let the model cluster them under a verbatim-quote constraint you can grep. Re-run the same three steps weekly against the same workspace so the diff between funnel-ai.json snapshots shows which offers and keywords the competitor added or dropped.
Can I run this without an API key?
Yes — paste funnel.jsonl into any chat interface manually and keep the same schema and evidence_quote rule. You lose batching and automation, not accuracy.
Is scraping a competitor's site allowed?
It depends on robots.txt, the site's terms, and your jurisdiction. Read both first; public ad libraries are usually the safer source for ad creatives.
How do I know the AI did not invent a keyword?
The evidence_quote field must be a verbatim substring of funnel.jsonl. If the grep check prints NOT FOUND, delete that row.
How large can the input be?
It is limited by your model's context window — check your provider's documentation for the current limit and batch accordingly.