Google sent publishers 34% less referral traffic over the past year, and small publishers lost 60% of theirs. Meanwhile something quietly flipped on the economics side: a visitor who arrives because ChatGPT or Perplexity recommended your page converts dramatically better than one who clicked a blue link.

The verified numbers: per Position Digital’s aggregation, ChatGPT referrals convert at 15.9% and Perplexity at 10.5%, versus 1.76% for Google organic. Adobe’s aggregated data puts the AI premium at 11× (1.66% vs 0.15% on sign-ups). Even if you split the difference, an AI-referred visitor is worth 5–9 Google visitors.

Now the arbitrage: the affiliate industry is still 100% optimized for Google. Ten blue links, backlinks, keyword tools. Almost nobody is building pages engineered to be quoted verbatim by answer engines — and the affiliate programs they’d quote (76–100% first-month VPN payouts, for example) still pay on every referred sale. There is no GEO commission cut. ChatGPT’s 4% fee only applies to its own Instant Checkout flow, which OpenAI itself deprioritized in March 2026 in favor of sending shoppers to merchant checkouts — where your cookie fires normally.

This article gives you the working toolkit: an audit script that scores any page for AI-citation readiness, a unit-economics calculator, a page generator, and the honest math on how many AI referrals you actually need to hit thousands per month.

Why answer engines are a commission loophole (for now)

Three structural facts make this work:

1. AI engines cite sources, and citations are click streams. Perplexity attaches visible citations to nearly every answer. ChatGPT’s search mode does the same. When an assistant answers “which VPN affiliate program pays the most,” it quotes 2–4 sources — and the reader who clicks through arrives pre-sold. That’s why conversion rates are 6–9× Google’s: the assistant did the persuading.

2. Affiliate tracking survives the dominant AI shopping flow. As of 2026, ChatGPT’s shopping answers work on discovery-and-redirect: it recommends, the shopper clicks through to the merchant’s own site, your tracking link and cookie fire exactly like a Google referral. The one flow that broke attribution (in-chat Instant Checkout) is a shrinking minority after OpenAI’s own pivot. Perplexity’s Merchant Program charges merchants zero commission — but it doesn’t touch affiliate links on independent review pages.

3. Almost no affiliate page is built for extraction. LLMs don’t rank pages; they extract answer-shaped chunks. A page that opens with a 40-word direct answer, structures headings as questions, and carries a comparison table is dramatically more likely to be quoted than a 3,000-word SEO epic that hides the answer below the fold. Most of your would-be competitors haven’t done this. That’s the gap.

The grey-area honesty: this is legal, but it’s platform-dependent income. You’re building on top of OpenAI’s and Perplexity’s citation choices, and a 70.6% chunk of AI traffic shows up as “Direct” in GA4, which makes your results look worse than they are in every dashboard. Both facts cut into the story you’ll tell yourself. Neither kills the play.

The audit tool: score any page for AI citation

Write this to geo_audit.py. It checks the ten things that actually move citation probability — answer-first structure, question headings, FAQPage schema, extraction-friendly meta descriptions, comparison tables, specific numbers, and whether your robots.txt accidentally blocks the AI search crawlers (a real and expensive mistake: 56.2% of sites that block GPTBot also block OAI-SearchBot, erasing themselves from ChatGPT’s answers entirely).

#!/usr/bin/env python3
"""
GEO Page Audit — scores a landing page for AI-citation / answer-engine visibility.

Standalone (stdlib only). Two modes:
  1. URL mode:  python3 geo_audit.py <url>           (urllib, no external deps)
  2. File mode: python3 geo_audit.py --file page.html

Exit 0 always when the audit itself runs; --strict makes a score below
--min-score exit 1 (useful as a CI gate on your content pipeline).
"""
import argparse
import json
import re
import sys
import urllib.request
from dataclasses import dataclass, field, asdict

USER_AGENT = ("Mozilla/5.0 (compatible; GeoAuditBot/1.0; +https://example.com/geo-audit)")

# AI crawlers worth caring about (search/retrieval layer, not training layer)
AI_CRAWLERS = ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot",
               "Claude-SearchBot", "PerplexityBot", "Google-Extended", "Applebot-Extended"]
SEARCH_CRAWLERS = {"OAI-SearchBot", "Claude-SearchBot", "PerplexityBot"}


@dataclass
class Check:
    name: str
    weight: int
    passed: bool
    detail: str


@dataclass
class Report:
    url: str
    score: int = 0
    max_score: int = 0
    checks: list = field(default_factory=list)

    def add(self, name, weight, passed, detail):
        self.checks.append(Check(name, weight, bool(passed), detail))
        self.max_score += weight
        if passed:
            self.score += weight


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read().decode("utf-8", errors="replace")


def strip_tags(html):
    html = re.sub(r"<(script|style).*?</\1>", " ", html, flags=re.S | re.I)
    return re.sub(r"<[^>]+>", " ", html)


def parse_robots(text):
    """Return {group_agent: [rules]} for every User-agent group."""
    groups, current = {}, []
    for raw in text.splitlines():
        line = raw.split("#")[0].strip()
        if not line:
            continue
        key, _, val = line.partition(":")
        key, val = key.strip().lower(), val.strip()
        if key == "user-agent":
            current.append(val)
            groups.setdefault(val, [])
        elif key in ("allow", "disallow") and current:
            for a in current:
                groups[a].append((key, val))
            current = current  # agents accumulate until a blank-line rule switch
        elif key in ("sitemap",) and current:
            for a in current:
                groups[a].append((key, val))
    return groups


def robots_allows(groups, agent, path="/"):
    rules = groups.get(agent)
    if rules is None:
        return True, f"no group for {agent} -> allowed by default"
    longest = ""
    verdict = True
    for key, val in rules:
        if key == "sitemap":
            continue
        if val and path.startswith(val.rstrip("*")) and len(val) > len(longest):
            longest, verdict = val, key == "allow"
    return verdict, f"longest match '{longest or 'none'}' -> {'allowed' if verdict else 'blocked'}"


def audit(url, html, robots_text=None):
    rep = Report(url=url)
    low = html.lower()

    # 1. Title: 25-70 chars, starts with the answer
    m = re.search(r"<title[^>]*>(.*?)</title>", html, re.S | re.I)
    title = m.group(1).strip() if m else ""
    rep.add("title-exists", 5, bool(title), f"title={title[:80]!r}")
    if title:
        rep.add("title-length", 5, 25 <= len(title) <= 70,
                f"{len(title)} chars (best practice 25-70)")

    # 2. Meta description <= 155
    m = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\']',
                  html, re.S | re.I) or re.search(
                  r'<meta[^>]+content=["\'](.*?)["\'][^>]+name=["\']description["\']',
                  html, re.S | re.I)
    desc = m.group(1).strip() if m else ""
    rep.add("meta-description", 10, 50 <= len(desc) <= 155,
            f"{len(desc)} chars (want 50-155, extraction-friendly)")

    # 3. Answer-first: first paragraph under 300 chars, after h1
    text = strip_tags(html)
    text = re.sub(r"\s+", " ", text).strip()
    paras = [p.strip() for p in text.split(". ") if p.strip()]
    first = paras[0] if paras else ""
    rep.add("answer-first-paragraph", 10, 0 < len(first) <= 300,
            f"first sentence {len(first)} chars (AI extracts <=300-char chunks)")

    # 4. H1 exactly one
    h1s = re.findall(r"<h1[^>]*>(.*?)</h1>", html, re.S | re.I)
    rep.add("single-h1", 5, len(h1s) == 1, f"{len(h1s)} h1 tags")

    # 5. Question headings (h2/h3 phrased as questions = FAQ extraction targets)
    heads = re.findall(r"<h[23][^>]*>(.*?)</h[23]>", html, re.S | re.I)
    q_heads = [h for h in heads if "?" in strip_tags(h)]
    rep.add("question-headings", 10, len(q_heads) >= 2,
            f"{len(q_heads)}/{len(heads)} h2/h3 are questions: "
            f"{[strip_tags(h).strip()[:40] for h in q_heads[:3]]}")

    # 6. FAQPage / Product schema
    has_schema = '"@type": "faqpage"' in low or '"@type":"faqpage"' in low \
                 or 'application/ld+json' in low
    rep.add("json-ld-schema", 10, has_schema, "FAQPage/Product JSON-LD found" if has_schema
            else "no JSON-LD (FAQPage = direct answer extraction target)")

    # 7. Numbers + dates on page (citable specificity)
    years = set(re.findall(r"\b20(2[4-6])\b", text))
    stats = re.findall(r"(?:\$\s?\d[\d,.]*|\d+(?:\.\d+)?\s?%)\s?(?:/mo|/month|m\b|k\b)?", text)
    rep.add("specific-numbers", 10, len(stats) >= 5 and bool(years),
            f"{len(stats)} price/stat strings, years present: {sorted(years)}")

    # 8. Comparison table (AI engines love tables for X-vs-Y queries)
    rep.add("comparison-table", 10, "<table" in low,
            "table present" if "<table" in low else "no table (add plan/product comparison)")

    # 9. llms.txt linked from robots.txt or root
    llms = False
    if robots_text:
        llms = any(v.lower().endswith("llms.txt") for _, v in parse_robots(robots_text).get("*", []))
    rep.add("llms-txt-reference", 5, llms, "llms.txt referenced in robots.txt" if llms
            else "no llms.txt reference (optional but emerging)")

    # 10. robots.txt: search-layer AI crawlers not blocked
    if robots_text:
        groups = parse_robots(robots_text)
        for agent in SEARCH_CRAWLERS:
            ok, why = robots_allows(groups, agent)
            rep.add(f"robots-allows-{agent}", 5, ok, why)
    else:
        rep.add("robots-txt-not-checked", 0, True, "robots.txt not fetched (offline/file mode)")

    rep.score = round(100 * rep.score / rep.max_score) if rep.max_score else 0
    return rep


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("target", help="URL or path with --file")
    ap.add_argument("--file", action="store_true", help="target is a local HTML file")
    ap.add_argument("--strict", action="store_true", help="exit 1 if score < min-score")
    ap.add_argument("--min-score", type=int, default=70)
    a = ap.parse_args()

    robots_text = None
    if a.file:
        with open(a.target, encoding="utf-8") as f:
            html = f.read()
        url = f"file://{a.target}"
    else:
        url = a.target
        html = fetch(url)
        rp = urllib.parse.urljoin(url, "/robots.txt")
        try:
            robots_text = fetch(rp)
        except Exception as e:
            print(f"[warn] robots.txt fetch failed: {e}", file=sys.stderr)

    rep = audit(url, html, robots_text)
    print(json.dumps(asdict(rep), indent=2, default=vars))
    failed = [c.name for c in rep.checks if not c.passed and c.weight > 0]
    print(f"\nGEO SCORE: {rep.score}/100  ({len(failed)} failed: {', '.join(failed) or 'none'})",
          file=sys.stderr)
    if a.strict and rep.score < a.min_score:
        print(f"STRICT GATE: {rep.score} < {a.min_score}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

I ran this against a real affiliate-style page and against a live website during writing. On a deliberately imperfect sample page it scored 62/100 and flagged four failures — a 71-character title, a 159-character meta description, and a missing llms.txt reference — then scored 94/100 after the fixes. On the live Solvinc blog homepage it returned 63/100 and correctly flagged that the homepage has zero question-format headings and no comparison table. Against a hosted page that answers “which VPN affiliate program pays the most” with a direct first sentence and a table, you should expect 90+.

One catch it will teach you fast: many high-value targets block bots outright. Auditing perplexity.ai itself returns HTTP 403 for non-browser agents — fine, you’re auditing your pages, not theirs. Keep a browser UA fallback in mind if you audit competitor pages that sit behind Cloudflare.

The weighting philosophy behind the ten checks: structural checks (answer-first paragraph, question headings, schema, table) carry the most points because they’re what extraction actually keys on, while hygiene checks like title length and llms.txt carry less. Don’t chase a perfect 100 the way you’d chase a Lighthouse score — a page at 85 with a genuinely tested comparison table beats a page at 100 that says nothing new. The score is a floor, not a target. What it’s really for is catching silent regressions: a theme update that mangles your schema, a plugin that injects a second h1, a meta description that balloons past the extraction window. Run it in --strict mode from a cron job and you’ll hear about breakage the day it happens, not the month your citations quietly disappear.

The unit economics: what an AI referral is actually worth

Before building, know your numbers. This calculator (geo_math.py) models the honest math — including the uncomfortable part where 300 AI visits lose to 10,000 Google visits in absolute dollars, and why you build for AI anyway:

#!/usr/bin/env python3
"""Unit economics: AI-referred vs Google-referred affiliate revenue.
Compares 'wait for SEO' vs 'GEO-first' play with verified 2026 benchmarks.
Stdlib only. Exit 0 = inputs valid and math consistent."""
import argparse, json, sys

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--ai-conv", type=float, default=10.5, help="AI referral conv pct, Perplexity 10.5")
    ap.add_argument("--g-conv", type=float, default=1.76, help="Google organic conv pct, Position Digital")
    ap.add_argument("--commission", type=float, default=76.0, help="first-month payout USD ")
    ap.add_argument("--recurring", type=float, default=30.0, help="recurring pct of plan revenue")
    ap.add_argument("--months", type=int, default=12, help="avg customer lifetime, months")
    ap.add_argument("--monthly-plan", type=float, default=12.99, help="VPN monthly plan USD")
    ap.add_argument("--ai-visits", type=float, default=300, help="AI referrals/mo at month 6 target")
    ap.add_argument("--g-visits", type=float, default=10000, help="Google organic/mo at month 6 target")
    a = ap.parse_args()

    for name, v in [("ai_conv", a.ai_conv), ("g_conv", a.g_conv)]:
        if not 0 < v <= 100: sys.exit(f"invalid {name}={v}")
    if min(a.commission, a.monthly_plan, a.ai_visits, a.g_visits, a.months) < 0:
        sys.exit("negative input")

    plan_rev = a.monthly_plan * a.months
    ltv = a.commission + (a.recurring / 100) * plan_rev

    def rev(visits, conv):
        sales = visits * conv / 100
        return sales * ltv, sales

    ai_rev, ai_sales = rev(a.ai_visits, a.ai_conv)
    g_rev, g_sales = rev(a.g_visits, a.g_conv)
    per_visit_ratio = a.ai_conv / a.g_conv if a.g_conv else 0

    print(json.dumps({
        "assumptions": {"ai_conversion_pct": a.ai_conv, "google_conversion_pct": a.g_conv,
                        "first_month_commission_usd": a.commission, "recurring_pct": a.recurring,
                        "lifetime_months": a.months, "monthly_plan_usd": a.monthly_plan,
                        "ai_visits_per_month": a.ai_visits, "google_visits_per_month": a.g_visits},
        "ltv_per_conversion_usd": round(ltv, 2),
        "ai_referred": {"sales_per_month": round(ai_sales, 1), "revenue_per_month_usd": round(ai_rev, 2)},
        "google_referred": {"sales_per_month": round(g_sales, 1), "revenue_per_month_usd": round(g_rev, 2)},
        "ai_visitor_worth_x_google_visitor": round(per_visit_ratio, 1),
        "verdict": ("fewer-but-hotter AI visits compete with 33x more Google visits"
                    if per_visit_ratio and (a.g_visits / a.ai_visits) / per_visit_ratio > 1
                    else "AI visits out-punch their weight per visitor")
    }, indent=2))
    return 0

if __name__ == "__main__":
    sys.exit(main())

Run with the verified defaults and here’s what falls out:

  • Perplexity scenario (10.5% conv): 300 AI visits/month → 31.5 sales → $3,867/month at a $122.76 lifetime value per conversion ($76 first-month payout + 30% recurring on a $12.99 plan over 12 months).
  • ChatGPT scenario (15.9% conv, 100%/35% program): the same 300 visits → 47.7 sales → $7,372/month.
  • Each AI visitor is worth 6–9× a Google visitor.

Now the honest counterweight: 10,000 Google visits at 1.76% still produces $21,606/month — more absolute money. AI search sends about 190× less traffic volume than Google (ChatGPT processes 12% of Google’s query volume but sends 190× less traffic to websites, per Ahrefs’ February 2026 analysis). So why bother?

Because of the competition-to-reward ratio. Ranking #1 on Google for “best VPN affiliate program” means beating 10,000-word authority sites with 15-year-old backlink profiles. Getting quoted by Perplexity for “which VPN pays the most in 2026” means being the clearest extractable answer in a field where almost nobody has optimized for extraction. The absolute ceiling is lower; the cost of entry is 100× lower. This is a 2026 arbitrage with a shelf life — which is exactly what makes it worth sprinting on.

Disclosure note: the conversion-rate figures above are measured benchmarks from Adobe and Position Digital’s aggregation of industry studies, not a guarantee — your page’s conversion depends on your niche, program, and traffic quality. The recurring-commission math assumes customers stay the full 12 months; churn cuts LTV proportionally. Run the script with your own numbers before you believe any projection, including mine.

The generator: build citation-shaped pages programmatically

The page structure answer engines extract best is formulaic: direct answer in the first sentence, question-format h2s, FAQPage JSON-LD, a comparison table. So formulaize it (make_geo_page.py):

#!/usr/bin/env python3
"""Generates a citation-optimized affiliate page skeleton (HTML) from a
program data list — question headings, answer-first paragraphs, FAQPage
JSON-LD, comparison table. Stdlib only. Exit 0 on success."""
import json, sys, html

PROGRAMS = [
    {"name": "PureVPN", "first_month": "100% flat", "recurring": "35% lifetime", "cookie": "90 days"},
    {"name": "NordVPN", "first_month": "up to 76%", "recurring": "30% on renewals", "cookie": "45 days"},
    {"name": "AdGuard VPN", "first_month": "tiered", "recurring": "varies", "cookie": "varies"},
]

def build(programs):
    rows = "\n".join(
        f"<tr><td>{html.escape(p['name'])}</td><td>{html.escape(p['first_month'])}</td>"
        f"<td>{html.escape(p['recurring'])}</td><td>{html.escape(p['cookie'])}</td></tr>"
        for p in programs)
    top = programs[0]
    faq = {
        "@context": "https://schema.org", "@type": "FAQPage",
        "mainEntity": [
            {"@type": "Question",
             "name": "Which VPN affiliate program pays the most in 2026?",
             "acceptedAnswer": {"@type": "Answer",
                "text": f"{top['name']} pays {top['first_month']} on first-month plans with "
                        f"{top['recurring']} recurring and a {top['cookie']} cookie, as of 2026."}},
            {"@type": "Question",
             "name": "Do AI assistants like ChatGPT preserve affiliate tracking links?",
             "acceptedAnswer": {"@type": "Answer",
                "text": "Yes for discovery-and-redirect shopping flows: the shopper clicks "
                        "through to the merchant site, so cookies and referrers survive."}},
        ]}
    return f"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>Best VPN Affiliate Programs 2026: Rates Compared</title>
<meta name="description" content="Current VPN affiliate rates compared: first-month payouts, recurring splits and cookie windows for the top programs, tuned for AI-search referral traffic.">
<script type="application/ld+json">{json.dumps(faq)}</script>
</head><body>
<article>
<h1>Best VPN affiliate programs for AI-search traffic (2026)</h1>
<p>AI-referred visitors convert about six times better than Google organic visitors, so a page the assistants quote directly is worth more per visit than a page that only ranks.</p>
<h2>Which VPN affiliate program pays the most in 2026?</h2>
<p>{html.escape(top['name'])} pays {html.escape(top['first_month'])} on monthly plans, {html.escape(top['recurring'])} recurring, with a {html.escape(top['cookie'])} cookie window.</p>
<h2>Do AI assistants preserve affiliate tracking links?</h2>
<p>Yes — discovery-and-redirect flows send the shopper to the merchant's own checkout, where your cookie fires normally.</p>
<table><thead><tr><th>Program</th><th>First month</th><th>Recurring</th><th>Cookie</th></tr></thead>
<tbody>
{rows}
</tbody></table>
</article></body></html>"""

if __name__ == "__main__":
    out = build(PROGRAMS)
    with open("generated-geo-page.html", "w", encoding="utf-8") as f:
        f.write(out)
    print(f"wrote generated-geo-page.html ({len(out)} bytes, {len(PROGRAMS)} programs)")
    sys.exit(0)

The generated page audits at 94/100 (only the optional llms.txt reference is missing). Chain the tools into a pipeline: generate → audit → gate:

python3 make_geo_page.py \
  && python3 geo_audit.py generated-geo-page.html --file --strict --min-score 85

If the audit exits non-zero, the page doesn’t ship. That’s your quality gate, and it costs nothing to run on every page you ever publish.

The playbook, compressed

  1. Pick a niche where commissions are fat and questions are specific. VPNs (40–100% first month, 30–35% recurring, verified across 2026 program pages), hosting, and security tools fit. The question must be answerable in under 60 words — “which X pays the most,” “is X worth it for Y,” “X vs Y in 2026.”
  2. Join 3–5 affiliate programs in the niche and put their real current rates in your data list. Rates change; re-verify quarterly, because a stale number the AI quotes is a liability, not an asset.
  3. Generate the page with the tool above, then hand-edit the two things machines can’t fake: first-hand test details (“I installed it on two devices and measured the handshake”) and a real comparison table. Assistants increasingly favor pages with primary observations.
  4. Wire the plumbing: llms.txt at your root, robots.txt explicitly allowing GPTBot, OAI-SearchBot, ClaudeBot, and PerplexityBot, FAQPage schema, and an IndexNow ping on every update. If you block GPTBot you are statistically likely (56.2% of blockers do) to also be blocking OAI-SearchBot — check this today on every site you own.
  5. Track citation share, not rankings. There is no Google Search Console for AI engines yet — four separate 2026 studies attempting to count AI traffic in GSC landed on answers 55× apart (0.34% to 18.9%). Practical proxy: run the audit weekly, watch “Direct” traffic with landing-page granularity, and just ask ChatGPT and Perplexity your target questions monthly and record whether you’re cited.
  6. Scale to thousands/month: at the verified conversion rates, ~300 AI-referred visits/month across your citation-winning pages puts you at $3,900–7,400/month depending on program. That’s roughly 10 pages each earning one quoted answer per day — an achievable, if not guaranteed, citation cadence in low-extraction-competition niches.

The maintenance loop that keeps you cited. Answer engines re-crawl and re-synthesize constantly, and they favor freshness signals — a page whose table says “verified October 2026” ages into a liability the moment your competitor’s says November. Build the refresh into the pipeline: every quarter, re-check each program’s live rate page, bump the numbers, re-run the audit, and republish with an updated date. Fifteen minutes per page. A stale answer doesn’t just lose the citation — it can get you quoted as wrong, which is worse than never being quoted at all. This is also why the generator keeps rates in one data list at the top of the script: update the list, regenerate, audit, ship. The whole loop is two commands.

The risks, straight

  • Platform dependency. OpenAI can change how ChatGPT sources answers tomorrow; Google is already testing a slot for “official” sites at the top of AI answers, which could squeeze independent review pages out of the most valuable positions. Diversify across engines and keep your email list.
  • The window closes as GEO goes mainstream. “Generative Engine Optimization” is already a consultant category. The edge is that affiliate-page operators specifically haven’t moved. When they do, extraction competition normalizes and this becomes standard SEO with worse analytics.
  • Attribution blindness. With 70.6% of AI traffic mis-attributed as Direct in GA4, you will systematically underestimate what’s working — and might kill a winning page prematurely. Use the audit script’s URL tracking plus dedicated redirect slugs per engine if you need cleaner data.
  • ToS drift on the merchant side. Some affiliate programs restrict “AI-generated content” clauses. The pages here are templates you populate with genuinely tested data — keep it that way, both for compliance and because primary observations are what earn citations.

The meta-point is the point: the biggest traffic-transition since mobile search is happening, the buyers arriving through it are pre-qualified at 6–9× normal rates, the commission pipe is untouched, and the tooling gap is one Python script deep. Arbitrages this clean don’t stay open long. The audit script is above. Run it on your own site tonight — my money says it finds something broken.

Tested and executed before publication: geo_audit.py (live URL mode against blog.solvinc.biz.id → 63/100, and file mode → 62/100 flagging real over-length title/meta, 94/100 after fixes), geo_math.py (Perplexity default scenario → $3,867/month; ChatGPT scenario → $7,372/month), make_geo_page.py (1962-byte page generated, strict audit at 85 threshold → exit 0). All three exited 0. No paid APIs used — everything runs on the Python standard library.

This article is educational, not financial or legal advice. Affiliate conversions depend on factors outside these benchmarks; earnings are not guaranteed. Follow the terms of every affiliate program and platform you touch.