The contract
Who should use this | Teams writing "X vs Y", "alternatives" or "best tools" pages |
The finished outcome | A source-backed fact sheet and comparison matrix for 3+ products, plus a page outline with every claim tied to a URL and a date |
What you need before you start | Python 3, the URLs of the competitor pages you want to compare |
Time to install and verify | I have never seen this take more than 10 minutes |
Definition of done | The matrix prints with source URLs, every "none" row you saw becomes a deliberate "Not publicly available" in the page, and no sentence on the page states a fact the script did not return |
The one-paragraph version
Comparison pages fail one fact at a time. A price is three months old, a plan name is wrong, a rating came from nowhere. Nobody needs to catch it; the person reading your "A vs B" page is already comparing it against the actual pricing page they have open in the next tab. So this skill inverts the usual drafting order: first you run a tiny script over the competitor pages and every fact you can verify comes out with its source URL and a date. Then you write. Whatever the script could not verify, the page says "Not publicly available" instead of a number. The content itself is still your job - the verdict, the balance, the shape of the argument. But the difference between a comparison page that earns trust and one that quietly damages it is never the writing. It is whether the rows are real.
Why comparison pages decay silently
Competitive-intent keywords are expensive for a reason: the person searching "Slack vs Teams" or "Notion alternatives 2026" is mid-decision. The traffic converts when the page answers a real question, and it converts badly when the page gets the numbers wrong. And the numbers decay. Pricing changes are shipped every quarter at most SaaS companies, plan structures change more often than that, and the pricing pages themselves often carry a different canonical than the page you wrote about.
There is also a second, newer problem. LLMs now generate a lot of comparison content, and generated comparison content has a predictable failure shape: plausible prices, confidently invented ratings, and balanced-sounding verdicts that were never checked against anything. Google's guidance on scaled content abuse and site reputation both point the same direction - templates and AI-generated pages survive only where the underlying facts are real and verifiable. A comparison page is the one page type where every factual sentence is checkable by the reader from a link. That is the discipline you need, and it is exactly what a script can enforce and an LLM alone will not.
What the skill actually does
Three movements, one rule.
1. Pick the page type, then stay inside it. Four shapes exist, and the shape tells the page what to do.
Type | Shape | Target keyword |
|---|---|---|
X vs Y | Head-to-head, feature-by-feature, one verdict with a why | "A vs B" |
Alternatives to X | Each alternative: summary, pros, cons, best-for | "A alternatives" |
Best [category] tools | Ranked list, stated criteria | "best [category] tools 2026" |
Feature comparison table | Matrix plus one deciding line | "[category] comparison" |
Mixing two shapes on one page is the most common structural mistake. A "vs" page that grows an alternatives section drops the exact-intent match it was built for.
2. Run the extraction, one row at a time. The script fetches each competitor URL and reports the facts it could confirm from the markup: product name, title, description, canonical, H1, prices, free-plan wording, plan names, heading and list-item counts, JSON-LD types, schema ratings, trust signals (G2, Capterra, Trustpilot, testimonials, case studies), and update markers. Each row prints with its source URL and an accessed date, so nothing can silently become an unanchored claim.
3. Hold the rule: publish only what came back. Every row the script could not verify - page 403, prices rendered by JavaScript, rating absent from markup - is written into the page as "Not publicly available", never estimated. An honest blank reads better than a guessed number, because your reader will check.
The SKILL.md below spells out the page-type table, the row definitions, the fairness rules, the schema choices, and the error cases. The script is next, then the workflow.
---
name: codex-seo-competitor-pages
description: Use when the user asks to create a competitor comparison page, an "X vs Y" page, an alternatives page, a "best [category] tools" roundup, a feature comparison matrix, or to fact-check claims about a competitor's pricing, plans, features, ratings, or schema. Collects verifiable facts from competitor pages and turns them into a source-backed comparison draft.
---
# Competitor Comparison & Alternatives Pages
Comparison pages win on accuracy. An "X vs Y" page that gets one price
wrong - or states a feature as fact when the source says otherwise - costs more
than the page earns, because the people who read it are mid-decision and will
check. Everything below exists to make every claim on the page traceable to a
real page and a real date.
## Commands
```
python3 competitor_facts.py <url> [<url> ...] [--matrix] [--json]
```
- `competitor_facts.py` fetches each URL and extracts: product name, title,
meta description, canonical, H1, prices, free-plan wording, plan names,
heading/list-item counts, JSON-LD types, schema ratings, trust signals
(G2/Capterra/Trustpilot/testimonials), update markers.
- Pass 2+ URLs to get a side-by-side row table, or add `--matrix` to print
the table before the per-page cards.
- `--json` prints cards as JSON for larger scripts.
- Every row carries its source URL and an `accessed <date>` stamp, so a claim
can never become untraceable.
## The rule that everything hangs on
Publish a row only if it came out of the script, and keep the `as of` label.
For any row the script could not verify - page blocked, price not rendered,
rating not in markup - write **"Not publicly available"** in the table
instead of estimating. A blank checked honestly reads as more trustworthy
than a number you filled in.
## Page types (choose one, then stick to it)
| Type | Shape | Target keyword |
|------|-------|----------------|
| X vs Y | Head-to-head, feature-by-feature, one verdict with a why | `[A] vs [B]` |
| Alternatives to X | Each alternative: summary, pros, cons, best-for | `[Product] alternatives` |
| Best [category] tools | Ranked list, stated criteria, no ties at the top | `best [category] tools [year]` |
| Feature comparison table | Matrix only, plus the one line that decides | `[category] comparison` |
Do not combine two types on one page. A "vs" page with an unsorted
alternatives section confuses the intent the page was created to match.
## What the script extracts and why it matters
| Row | How | Why it matters |
|-----|-----|----------------|
| Product | og:site_name, else title/H1 heuristics | Names the row correctly - never copy a marketing phrase as if it were the product |
| Prices | `$`/`€`/`£` amounts within 40 chars of a time unit | The number readers check first; also the first thing to go stale |
| Free plan wording | Case-insensitive "free forever/tier/plan/trial" | Free tier flips use-case fit; someone on it is closer to a decision than any visitor |
| Plan names | H2/H3 headings under 45 chars mentioning $ or plan words | The shape of the pricing page: tiers, seat counts, contract lengths |
| JSON-LD types | Recursive @type walk of every ld+json block | Product/FAQPage/ItemList markup is what you mirror on your own page |
| Schema ratings | AggregateRating/Rating blocks with value+count | If their rating is in markup, cite it in markup on your comparison |
| Trust signals | Mentions of G2/Capterra/Trustpilot/testimonial/case study | They court reviewers in public; your page should reflect that evidence |
| Update marker | "Last updated"/"Updated" plus capture date | Nobody reads a stale comparison; put an explicit date on yours |
| Headings/list counts | H2+H3 count, and raw `<li>` count | A cheap read on how deep the pricing page compares |
None of these rows are the final content. They are the raw material the
content is built from - unless they verify it, the sentence does not get
written.
## Content requirements
- Minimum 1,500 words for the comparison itself; short intro, then the
decision, then the footnotes.
- Every positive claim about a competitor links to the source page directly
(competitor's own pricing/feature page first, review site second).
- Every row from the script has the `as of` date attached, and the page has
its own visible "Last updated" line.
- Feature claims come with a line explaining how they were verified (fetch in
script output, or a product I tested; if neither, it is not a claim).
- Verdicts are okay. Cheap jabs are not: acknowledge what the competitor does
better. Balanced pages outperform pages written for the home team.
## Schema markup to use on the comparison page
For a two-product page, use `Product` in your own listing (no
AggregateRating unless you have a real review list to attach to it), and mark
the page types outside of it. For roundups / alternatives, use `ItemList`:
```json
{
"@context": "https://schema.org",
"@type": "ItemList",
"name": "Best [Category] Tools [Year]",
"itemListOrder": "https://schema.org/ItemListOrderDescending",
"numberOfItems": 7,
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "[Product]", "url": "[URL]" }
]
}
```
Add a `FAQPage` when the page answers genuine open questions; extract the
question set from the script output rather than inventing an FAQ. Never
invent an AggregateRating or a reviewCount - ratings on a comparison page
are exactly the kind of data that gets checked against the source.
## Keyword targeting
| Pattern | Example | Volume signal |
|---------|---------|---------------|
| `[A] vs [B]` | "Slack vs Teams" | High |
| `[A] alternative` | "Figma alternatives" | High |
| `[A] alternatives [year]` | "Notion alternatives 2026" | High |
| `best [category] tools` | "best project management tools" | High |
| `[A] vs [B] for [use case]` | "AWS vs Azure for startups" | Medium |
| `[A] review [year]` | "Monday.com review 2026" | Medium |
| `is [A] better than [B]` | "is Notion better than Confluence" | Medium |
Title formulas: `[A] vs [B]: [Key Differentiator] ([Year])`,
`[N] Best [A] Alternatives in [Year] (Free & Paid)`. Keep the H1 under 70
characters.
## Conversion layout
- Above the fold: one-line verdict from the script data (the actual
differentiator, e.g. "Copy.ai's $29/mo plan is the cheapest self-serve
entry; Writesonic ships structured data and an FAQ"). CTA text: the verdict
link, not a banner.
- After the table: the recommendation section with its evidence lines.
- Trust block: the rating citations from the script output, linked (source
URL from the row, not a logo).
- Disclosure: name your own product in the comparison (hide it and the
negative feedback in comments will out you). This is a requirement, not a
style suggestion.
- No aggressive CTAs inside competitor profiles; the body of the
comparison must look as if it could have been written by the competitor.
## Workflow
1. Choose the intent keyword and its page type (table above).
2. Run the script on the category: 3-5 products including your own. If a
competitor URL fails, mark the rows it fed "Not publicly available"
rather than substituting a screenshot or an estimate.
3. Read the matrix: the rows that differ are the page's content. Rows that
are identical are context; keep them in the table, not in prose.
4. For each differing row, verify the claim still holds today - pricing,
plan names, trial length.
5. Write the page: intro (who this is for), matrix, two or three paragraphs
per product on differentiated rows, recommendation with evidence,
related comparisons.
6. Link from related pages ("A vs B" links to "A vs C"), and add
BreadcrumbList.
7. Emit `COMPARISON-PAGE.md` (structure + outline + matrix), the schema
block, and the keyword set with content gaps vs existing competitor
pages.
## Output contract
1. `COMPARISON-PAGE.md`: ready-to-implement structure, feature matrix with
source URLs and `as of` date, outline with per-section word targets
(minimum 1,500 words total).
2. Schema markup: Product/SoftwareApplication or ItemList JSON-LD plus
FAQPage when warranted.
3. Keyword strategy: primary + secondary keywords, long-tail list, content
gaps vs the competitor pages you scanned.
4. Recommendations: improvements to existing comparison pages, new page
opportunities, schema additions, conversion suggestions - each one
labeled with the row in the script output it came from.
## Error handling
| Scenario | Action |
|----------|--------|
| Competitor URL unreachable (403/timeout/SSL) | Report the failed URL, keep the remaining pages, mark affected rows "Not publicly available" - never estimate |
| Competitor page is JS-only, no data in HTML | Mark those rows not in public markup; add a manual check step, do not invent after a partial render |
| Pricing/features not found on pricing page | "Not publicly available" in the table; link to the competitor's own pricing page instead |
| No product overlap found | Stop, report that the competition page is wrong, recommend a different set or a category roundup |
| JSON-LD parse failure | Note the block exists but could not be parsed - same rule as above |#!/usr/bin/env python3
"""Extract verifiable product facts from a competitor's page for comparison content.
stdlib only. Usage:
python3 competitor_facts.py <url> [<url> ...]
python3 competitor_facts.py <url> <url> --matrix
python3 competitor_facts.py <url> --json
"""
import gzip
import html as htmlmod
import json
import re
import sys
import urllib.request
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
TIMEOUT = 20
CAP = 6 * 1024 * 1024
TODAY = __import__("datetime").date.today().isoformat()
CURRENCY_RE = re.compile(r"[$€£¥]\s?\d[\d,]*(?:\.\d{2})?")
PRICE_UNIT_RE = re.compile(r"(?:per\s+|/| )?(?:mo|month|yr|year|annual|annually|one-?time|on-?time"
r"|seat|user|license|device|session)", re.I)
FREE_RE = re.compile(r"free\s+(?:forever|plan|trial|tier|version|account|basic)", re.I)
PLAN_KW = re.compile(r"\$|free|pro|business|enterprise|plan|starter|team|premium|basic", re.I)
H2_H3_RE = re.compile(r"<h[23][^>]*>(.*?)</h[23]>", re.I | re.S)
LDJSON_RE = re.compile(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.I | re.S)
UPDATED_RE = re.compile(r"(?:last\s+updated|updated\s*(?:on)?|last\s+modified)\s*:?\s*"
r"((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s"
r"\d{1,2}(?:,?\s*20?\d\d)?|\d{4}-\d{2}-\d{2}|\d{1,2}[/.]\d{1,2}[/.]\d{2,4})",
re.I)
def fetch(url, _retried=False):
req = urllib.request.Request(url, headers={
"User-Agent": UA, "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
"Accept-Encoding": "gzip", "Accept-Language": "en-US,en;q=0.9",
})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
body = r.read(CAP + 1)
if len(body) > CAP:
return None, "body too large (over 6MB)"
if r.headers.get("Content-Encoding", "").lower() == "gzip":
body = gzip.decompress(body)
return body.decode("utf-8", "replace"), None
except urllib.error.HTTPError as e:
return None, f"HTTP {e.code}"
except urllib.error.URLError as e:
err = re.sub(r"\s+", " ", str(e.reason).strip("<> "))[:60]
if not _retried and re.search(r"(?i)EOF|ssl|connect|reset|timeout", err):
return fetch(url, _retried=True)
return None, err
except Exception as e: # SSL errors, timeouts, decode failures
err = f"{type(e).__name__}: {e}"[:60]
if not _retried and re.search(r"(?i)EOF|ssl|connect|reset|timeout", err):
return fetch(url, _retried=True)
return None, err
def text_of(html):
html = re.sub(r"<script.*?</script>|<style.*?</style>|<noscript.*?</noscript>", " ", html,
flags=re.I | re.S)
html = re.sub(r"<[^>]+>", " ", html)
return re.sub(r"\s+", " ", html)
def metal(html, needle):
"""Content of a <meta name|property="needle"> tag, in either attribute order."""
for pat in (r'<meta[^>]+(?:name|property)=["\']' + needle + r'["\'][^>]*content=["\']([^"\']+)',
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]*(?:name|property)=["\']' + needle +
r'["\']'):
m = re.search(pat, html, re.I)
if m:
return m.group(1).replace(r"\/", "/")
return None
def link_href(html, rel):
m = re.search(r'<link[^>]+rel=["\']' + rel + r'["\'][^>]*href=["\']([^"\']+)["\']', html, re.I) \
or re.search(r'<link[^>]+href=["\']([^"\']+)["\'][^>]*rel=["\']' + rel + r'["\']', html, re.I)
return m.group(1) if m else None
def ld_types(obj, out):
if isinstance(obj, dict):
t = obj.get("@type")
for x in (t if isinstance(t, list) else [t]):
if isinstance(x, str):
out.add(x)
for v in obj.values():
ld_types(v, out)
elif isinstance(obj, list):
for v in obj:
ld_types(v, out)
def ld_ratings(html):
"""Return (count, name, ratingValue, reviewCount, bestRating) for first AggregateRating."""
out = []
for block in LDJSON_RE.findall(html):
try:
data = json.loads(block.strip())
except Exception:
continue
found = []
stack = [data]
while stack:
node = stack.pop()
if isinstance(node, dict):
if node.get("@type") in ("AggregateRating", "Rating"):
found.append(node)
stack.extend(v for v in node.values() if isinstance(v, (dict, list)))
elif isinstance(node, list):
stack.extend(node)
for f in found:
out.append({
"type": f.get("@type"),
"name": f.get("name", ""),
"ratingValue": f.get("ratingValue", ""),
"reviewCount": f.get("reviewCount", ""),
"bestRating": f.get("bestRating", ""),
})
return out
def headings(html):
out = []
for m in H2_H3_RE.finditer(html):
t = htmlmod.unescape(re.sub(r"<[^>]+>", " ", m.group(1)))
t = re.sub(r"\s+", " ", t).strip()
if t and len(t) <= 45:
out.append(t)
return out
def guess_product(title, h1, url):
"""Fallback when og:site_name is missing: last title segment, then short h1, then host."""
for seg in re.split(r"\s*[|:]\s*", title):
seg = seg.strip()
if seg and len(seg) <= 30 and not re.search(r"(?i)pricing|plans?|cost|buy", seg):
return seg
if h1 and len(h1) <= 30 and not re.search(r"(?i)pricing|plans?|cost", h1):
return h1
host = url.split("//")[-1].split("/")[0]
return re.sub(r"^www\.", "", host).split(".")[0].title()
def page_facts(url):
card = {"url": url, "status": 200, "errors": []}
html, err = fetch(url)
if err:
card["status"] = "fetch-error"
card["errors"].append(err)
card["facts"] = [{"row": "Page", "value": f"NOT AVAILABLE ({err})", "via": url}]
return card
html = re.sub(r"", " ", html, flags=re.S)
text = text_of(html)
un = lambda s: htmlmod.unescape(s or "")
title = un(metal(html, "(?:og:title|twitter:title)") or (
re.search(r"<title[^>]*>(.*?)</title>", html, re.I | re.S) or ["", ""])[1])
site_name = un(metal(html, "og:site_name"))
desc = un(metal(html, "(?:og:description|twitter:description|description)"))
canonical = link_href(html, "canonical")
h1s = [un(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip())
for m in re.finditer(r"<h1[^>]*>(.*?)</h1>", html, re.I | re.S)]
h1s = [h for h in h1s if h]
product = site_name or guess_product(title, h1s[0] if h1s else "", url)
prices = []
for m in CURRENCY_RE.finditer(text):
ok = False
for ctx in (text[max(0, m.start() - 40):m.end() + 30],):
if PRICE_UNIT_RE.search(ctx):
ok = True
break
if ok and m.group(0) not in prices:
prices.append(m.group(0))
prices = prices[:8]
free = sorted({m.group(0).lower() for m in FREE_RE.finditer(text)})[:5]
plans = []
for h in headings(html):
if PLAN_KW.search(h) and h not in plans:
plans.append(h)
plans = [h for h in plans][:10]
lis = len(re.findall(r"<li[^>]*>", html, re.I))
kinds = set()
for m in LDJSON_RE.finditer(html):
try:
data = json.loads(m.group(1).strip())
except Exception:
continue
if not isinstance(data, (dict, list)):
continue
ld_types(data, kinds)
trusts = {}
for name, pat in (("G2", r"g2\.com|G2\b"), ("Capterra", r"capterra"),
("Trustpilot", r"trustpilot"), ("testimonials", r"testimonial"),
("case studies", r"case stud")):
n = len(re.findall(pat, html, re.I))
if n:
trusts[name] = n
stars = sorted({m.group(0) for m in re.finditer(
r"\d\.\d\s*(?:/\s*5\b|/?5\b|stars?|out of 5|rating)", text, re.I)})[:5]
upd = UPDATED_RE.search(text)
ratings = ld_ratings(html)
h23 = len(H2_H3_RE.findall(html))
facts = [
("Product", product, url),
("Title", title[:120], url),
("Meta description", (desc or "none")[:140], url),
("Canonical", canonical or "none", url),
("H1", " | ".join(h1s[:2])[:120] if h1s else "none", url),
("Prices found", "; ".join(prices) or "none (maybe rendered dynamically)", url),
("Free plan wording", "; ".join(free) or "none", url),
("Plan names (headings)", "; ".join(plans) or "none", url),
("Headings h2/h3", str(h23), url),
("List items", str(lis), url),
("JSON-LD types", ", ".join(sorted(kinds)) or "none", url),
("Schema ratings", "; ".join(
f"{r.get('name','?')} {r.get('ratingValue','?')}"
f"{'/'+str(r.get('bestRating','')) if r.get('bestRating') else ''}"
f" ({r.get('reviewCount','?')} reviews)" for r in ratings) or "none", url),
("Trust signals", ", ".join(f"{k}: {v}" for k, v in trusts.items()) or "none", url),
("Ratings in text", "; ".join(stars) or "none", url),
("Update marker", upd.group(0) if upd else "none", url),
]
card["facts"] = [{"row": r, "value": v, "via": u} for r, v, u in facts]
return card
def card_lines(card):
out = [f"### {card['facts'][0]['value'] if card['facts'] else card['url']}",
f"- Source: <{card['url']}> (accessed {TODAY})"]
if card["status"] != 200:
out.append(f"- Status: {card['status']} - {', '.join(card['errors'])}")
out.append("- Rule: mark the affected rows NOT AVAILABLE; do not guess values.")
return "\n".join(out)
for f in card["facts"]:
marker = "NOT AVAILABLE" if "NOT AVAILABLE" in f["value"] else f["value"]
out.append(f"- {f['row']}: {marker} <{f['via']} (accessed {TODAY})>")
return "\n".join(out)
def matrix(cards):
rows = ["| Fact | " + " | ".join(
(c["facts"][0]["value"] if c["facts"] else c["url"]) for c in cards) + " |",
"|---|" + "---|" * len(cards)]
keys = {f["row"] for c in cards for f in c["facts"]}
order = [f["row"] for f in (cards[0]["facts"] if cards else [])]
for k in order + sorted(keys - set(order)):
vals = []
for c in cards:
v = next((f["value"] for f in c["facts"] if f["row"] == k), "n/a")
vals.append(v.replace("|", "\\|")[:60])
rows.append(f"| {k} | " + " | ".join(vals) + " |")
return "\n".join(rows)
def main():
args = [a for a in sys.argv[1:]]
matrix_mode = "--matrix" in args
json_mode = "--json" in args
urls = [a for a in args if not a.startswith("--")]
if not urls:
print("usage: competitor_facts.py <url> [url ...] [--matrix] [--json]", file=sys.stderr)
return 2
cards = [page_facts(u) for u in urls]
if json_mode:
print(json.dumps(cards, indent=1))
return 0
print("Comparison fact sheet")
print("=" * 40)
if matrix_mode and len(cards) >= 2:
print(matrix(cards))
print()
for c in cards:
print(card_lines(c))
print()
print(f"Rule: keep the 'as of' date on every row you publish, and mark anything not "
f"returned above as 'Not publicly available' instead of filling it in.")
return 0
if __name__ == "__main__":
sys.exit(main())Install the skill
Three commands, then a smoke test.
# 1) create the skill directory
mkdir -p ~/.codex/skills/codex-seo-competitor-pages/scripts
# 2) save the SKILL.md (paste the first code block above into the file below)
nano ~/.codex/skills/codex-seo-competitor-pages/SKILL.md
# 3) save the script (paste the second code block above)
nano ~/.codex/skills/codex-seo-competitor-pages/scripts/competitor_facts.py
# 4) smoke test against two pages you can reach
python3 ~/.codex/skills/codex-seo-competitor-pages/scripts/competitor_facts.py \
https://example.com/pricing https://competitor.com/pricing --matrixIf you want Codex to do the file-writing instead of pasting, jump to the paste-it-in instruction below; it does the same thing.
Verify the install
Run it on two of your own pages first. That is the cheapest possible self-test, because you already know the right answers.
python3 ~/.codex/skills/codex-seo-competitor-pages/scripts/competitor_facts.py \
https://your-site.com/pricing https://your-site.com/blog/feature-announcementYou should see two fact cards. Confirm three things specifically: your product name shows up in the Product row (not a marketing slogan), the prices you actually charge appear in Prices found, and every row ends with an accessed date. If any of those fail, the extraction worked but you may be running it on a page that renders data through JavaScript - see the first row of the troubleshooting table.
A real run: three AI writing tools
There is nothing synthetic about the example below. This is the actual output of the script on 2026-09-01, run against the pricing pages of Copy.ai, Writesonic, and Rytr - three tools serving the same "AI writing assistant" search space. It takes about a minute, uses no API keys, and costs nothing.
Comparison fact sheet ========================================
Fact | Copy.ai | Writesonic | Rytr |
|---|---|---|---|
Product | Copy.ai | Writesonic | Rytr |
Title | Copy.ai: The only AI platform purpose-built for outcomes | Pricing | Writesonic AI Search Visibility Platform | Pricing - Rytr |
Meta description | Whether you need simple generative AI tools or complex AI-po | Plans for growth teams, agencies, and enterprises. Track AI | We've got the perfect plan for you. |
Canonical | https://www.copy.ai/prices | https://writesonic.com/pricing | none |
H1 | Copy.ai pricing | Plans & Pricing | We've got the perfect plan for you. |
Prices found | $29; $24; $288; $1,000; $12,000; $2,000; $24,000; $3,000 | $ 79; $240; $ 199; $600; $ 399; $1200; $100; $50 | none (maybe rendered dynamically) |
Free plan wording | none | none | free forever |
Plan names (headings) | Enterprise | Starter; $ 79; Basic; $ 199; $ 399; Enterprise; Real results | none |
Headings h2/h3 | 13 | 55 | 2 |
List items | 76 | 153 | 39 |
JSON-LD types | none | Answer, BreadcrumbList, ContactPoint, FAQPage, ListItem, Org | none |
Schema ratings | none | none | none |
Trust signals | none | G2: 10, Capterra: 4, Trustpilot: 7, testimonials: 8, case st | none |
Ratings in text | none | none | none |
Update marker | none | none | none |
Copy.ai
- Source: <https://www.copy.ai/pricing> (accessed 2026-09-01)
- Product: Copy.ai <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Title: Copy.ai: The only AI platform purpose-built for outcomes <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Meta description: Whether you need simple generative AI tools or complex AI-powered workflows, Copy.ai offers a secure and reliable generative AI platform as <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Canonical: https://www.copy.ai/prices <https://www.copy.ai/pricing (accessed 2026-09-01)>
- H1: Copy.ai pricing <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Prices found: $29; $24; $288; $1,000; $12,000; $2,000; $24,000; $3,000 <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Free plan wording: none <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Plan names (headings): Enterprise <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Headings h2/h3: 13 <https://www.copy.ai/pricing (accessed 2026-09-01)>
- List items: 76 <https://www.copy.ai/pricing (accessed 2026-09-01)>
- JSON-LD types: none <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Schema ratings: none <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Trust signals: none <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Ratings in text: none <https://www.copy.ai/pricing (accessed 2026-09-01)>
- Update marker: none <https://www.copy.ai/pricing (accessed 2026-09-01)>
Writesonic
- Source: <https://writesonic.com/pricing> (accessed 2026-09-01)
- Product: Writesonic <https://writesonic.com/pricing (accessed 2026-09-01)>
- Title: Pricing | Writesonic AI Search Visibility Platform <https://writesonic.com/pricing (accessed 2026-09-01)>
- Meta description: Plans for growth teams, agencies, and enterprises. Track AI visibility across 10 AI platforms including ChatGPT, Perplexity, and Google AI. <https://writesonic.com/pricing (accessed 2026-09-01)>
- Canonical: https://writesonic.com/pricing <https://writesonic.com/pricing (accessed 2026-09-01)>
- H1: Plans & Pricing <https://writesonic.com/pricing (accessed 2026-09-01)>
- Prices found: $ 79; $240; $ 199; $600; $ 399; $1200; $100; $50 <https://writesonic.com/pricing (accessed 2026-09-01)>
- Free plan wording: none <https://writesonic.com/pricing (accessed 2026-09-01)>
- Plan names (headings): Starter; $ 79; Basic; $ 199; $ 399; Enterprise; Real results from marketing teams like yours.; Compare plans; $ 79 /mo; $ 199 /mo <https://writesonic.com/pricing (accessed 2026-09-01)>
- Headings h2/h3: 55 <https://writesonic.com/pricing (accessed 2026-09-01)>
- List items: 153 <https://writesonic.com/pricing (accessed 2026-09-01)>
- JSON-LD types: Answer, BreadcrumbList, ContactPoint, FAQPage, ListItem, Organization, PostalAddress, Question, WebSite <https://writesonic.com/pricing (accessed 2026-09-01)>
- Schema ratings: none <https://writesonic.com/pricing (accessed 2026-09-01)>
- Trust signals: G2: 10, Capterra: 4, Trustpilot: 7, testimonials: 8, case studies: 2 <https://writesonic.com/pricing (accessed 2026-09-01)>
- Ratings in text: none <https://writesonic.com/pricing (accessed 2026-09-01)>
- Update marker: none <https://writesonic.com/pricing (accessed 2026-09-01)>
Rytr
- Source: <https://rytr.me/pricing> (accessed 2026-09-01)
- Product: Rytr <https://rytr.me/pricing (accessed 2026-09-01)>
- Title: Pricing - Rytr <https://rytr.me/pricing (accessed 2026-09-01)>
- Meta description: We've got the perfect plan for you. <https://rytr.me/pricing (accessed 2026-09-01)>
- Canonical: none <https://rytr.me/pricing (accessed 2026-09-01)>
- H1: We've got the perfect plan for you. <https://rytr.me/pricing (accessed 2026-09-01)>
- Prices found: none (maybe rendered dynamically) <https://rytr.me/pricing (accessed 2026-09-01)>
- Free plan wording: free forever <https://rytr.me/pricing (accessed 2026-09-01)>
- Plan names (headings): none <https://rytr.me/pricing (accessed 2026-09-01)>
- Headings h2/h3: 2 <https://rytr.me/pricing (accessed 2026-09-01)>
- List items: 39 <https://rytr.me/pricing (accessed 2026-09-01)>
- JSON-LD types: none <https://rytr.me/pricing (accessed 2026-09-01)>
- Schema ratings: none <https://rytr.me/pricing (accessed 2026-09-01)>
- Trust signals: none <https://rytr.me/pricing (accessed 2026-09-01)>
- Ratings in text: none <https://rytr.me/pricing (accessed 2026-09-01)>
- Update marker: none <https://rytr.me/pricing (accessed 2026-09-01)>
Rule: keep the 'as of' date on every row you publish, and mark anything not returned above as 'Not publicly available' instead of filling it in.
Reading the run
The matrix is the interesting part, because the differences between products are the content. A few rows matter more than others.
- Prices found: the script reports values, not a pricing table. Copy.ai returns $29, $24, $288, $1,000, $12,000, $2,000 and more; Writesonic returns $79, $199, $240, $600, $1,200. The script extracts amounts that sit next to a time unit, and both pages list monthly and annual figures. The pairing of which amount belongs to which plan is not something the script asserts - it reports what it found and the human confirms the pairing from the page. What this row does catch is the important thing: a page that quotes only "$29/mo" undersells a competitor whose real range runs into the hundreds, and the reader on the annual plan will know it. $29 and $288 sit in a 12-times relationship, which is consistent with a monthly-plus-discounted-annual structure; write that only after the billing page says so.
- The canonical row caught an alias. Copy.ai's pricing page canonicals to
https://www.copy.ai/prices. The page you fetched lives at/pricing. That is a real duplicate-content signal, and it matters if you quote "Zendesk pricing page" as a source in your comparison: link the canonical, not the alternate host. - Writesonic ships JSON-LD where copy.ai and Rytr ship none. The
JSON-LD typesrow shows FAQPage, Organization, BreadcrumbList, WebSite, and Answer/Question. That is a direct instruction for your own comparison page: their pricing FAQ is structured in markup, and a comparison page that answers their top questions well can earn auxiliary value. It is also a reminder to mirror the schema yourself, because theItemListandFAQPageblocks are free ranking surface. - Trust signals exist for one product only. G2, Capterra, Trustpilot, testimonials, and case study mentions show up on Writesonic. Nothing on Copy.ai or Rytr's pricing pages. That is a factual observation about where each company spends; your comparison page can say "Writesonic surfaces over 30review mentions on its pricing page" with a source URL, but it cannot say the competitor without a source.
- Rytr: no prices, no plan names visible, and free plan wording `free forever`. This is the row set that separates a good comparison page from a machine-generated one. The script could not confirm any prices. The honest page says "Rytr's pricing is not exposed in static markup (checked 2026-09-01); it reports a free-forever plan and a 39-item feature list." Writing the number would be speculation. Writing the absence is a fact.
- None of the three pages carries an update marker. All three show
Update marker: none. So the "as of" date on your page is not a nice-to-have; it is the only freshness signal in the entire comparison. Three competitors publishing pricing without a visible update tag is an opportunity to be the only page in the cluster that names its date.
That is roughly 500 words of page content, all of it traceable. The table above gives it columns; the cards below it give it sources.
Where the judgment goes
The script ends where the writing begins. Three judgment calls decide whether the page works.
The verdict. "X vs Y" pages need a recommendation, but the recommendation has to follow from a row in the matrix. If you recommend product A because of pricing, the sentence must sit next to the pricing row. If the deciding factor is your own product's plan structure, say that this is a comparison of what you offer and link your pricing page. Which product you are does not need to be a secret - disclosures outperform hidden affiliations in competition pages, and hiding it gets you reviewed as biased.
Balance as a strategy, not a virtue. Acknowledge the competitor's genuine advantages with a source. A page that concedes one real point earns the right to make a strong argument elsewhere; a page that will not concede anything reads like an ad, and comparison pages that read like ads die in the middle of the buying cycle.
When to stop. The script pulls raw materials for one page, not for a content farm. It is worth running on dozens of competing tools for a "best [category] tools" roundup, and worth running on four for a "vs" page. One page per intent, cross-linked, is the pattern that holds up; forty pages of the same template is the pattern that gets flagged no matter how true the rows are.
Concretely, a sentence like:
Writesonic's pricing page publishes a FAQPage schema with ten questions.
is good - it is verifiable from the run above. A sentence like:
Writesonic offers a generous free trial.
is not from anywhere, because the script found no free-plan wording at all. Write the first kind, and when you want to make a case that belongs to the second kind, either go find the source page or leave the claim off.
Troubleshooting
Problem | What happened | Fix |
|---|---|---|
| The site blocks non-browser agents, or the TLS handshake is flaky. The script retries once on SSL-style errors already. | Rerun once. If the URL still fails, fetch the page in a browser, save it as a file, and run |
| The pricing table is painted by JavaScript after load. | Check the raw HTML in the file. If prices truly are not in it, mark the row "Not publicly available" on the page and link the competitor's own pricing URL, or take the screenshot-only approach for that one number. |
Everything runs, but the | The page has no og:site_name and its H1 is a marketing line. | Pass it the real product name in the page, or run |
Schema ratings: none, but the site shows 4.8 stars | Ratings come from a third-party badge image, not JSON-LD. | The rating line on your comparison page needs the review-site URL as its source; the script can only certify what is in the markup. |
A competitor returns pages but the matrix looks identical | Either the products genuinely overlap or the extraction is reading only the header. | Check |
| The two products do not compete on the same intent. | Stop. Report the mismatch and suggest a different competitor set or a pivot to a category roundup, where the intent is a list rather than a duel. |
You pasted the SKILL.md into Codex and nothing happens | Codex triggers skills by description match. | Ask it explicitly by name: |
Windows paths | The script is pure Python 3; the only difference is how you write | Use |
Install this skill with one paste
Copy everything between the markers below and paste it into your Codex session. It reads the two code blocks from this page, writes them into the right place, runs the smoke test on your site, and reports what it found. (If you are using Claude Code instead, paste the same text - it handles the same instructions.)
<PASTE_TO_CODEX>
You will install a Codex skill. Read the two code blocks in the page you were pasted from: the block that starts with the YAML frontmatter `name: codex-seo-competitor-pages` and the block that starts with `#!/usr/bin/env python3` (which contains the string `competitor_facts.py`).
1. Create the directory ~/.codex/skills/codex-seo-competitor-pages/scripts.
2. Save the YAML block to ~/.codex/skills/codex-seo-competitor-pages/SKILL.md (only the frontmatter and body; do not include the fence lines).
3. Save the Python block to ~/.codex/skills/codex-seo-competitor-pages/scripts/competitor_facts.py (keep the shebang and everything after it).
4. Run: python3 ~/.codex/skills/codex-seo-competitor-pages/scripts/competitor_facts.py https://example.com/pricing https://example.com/blog --matrix
5. If example.com fails to fetch, try the user's real site when they provide its URL.
6. Report back: the list of row names you got, and the number of rows whose value was "none".
Do not modify any existing skill directories. Do not touch ~/.codex/skills/ outside the codex-seo-competitor-pages directory. Timebox the smoke test to one run unless the user asks for more.
</PASTE_TO_CODEX>FAQ
Should the comparison always feature our own product? Only when it genuinely competes on the same intent. Comparing your tool to a competitor you do not overlap with produces a page nobody trusts. When the page is an honest comparison of competitors in a category you serve, label your own product clearly (the fairness rules require it) and let the rows speak. Wrong comparisons poison the whole cluster.
How often should I refresh the matrix? Every quarter, or when a competitor ships a major change - use that moment as the re-run trigger. With the script, the actual work is two commands and a paste. The visible "Last updated" date on your page is what tells readers the page is alive; if it is stale, the comparison loses the trust that the verified rows earned.
What if prices are $29/mo annual vs $49/mo monthly? The script reports everything it sees; the human derives "from $24/mo annualized" only if the source page states the arithmetic. If the competitor publishes both monthly and annual, both numbers go in the matrix and the prose says what each is. Never divide a displayed price yourself to create a claim the source did not make.
The competitor page 403s bots. Can I still compare? Yes - the honest path is "Not publicly available" rows plus your own links. There is a legitimate version of this where you use the public review-site data and archive the page from a browser session. What you cannot do is report a blocked price you inferred.
Does this replace my knowledge of the market? No. It replaces the guessing. The source tier is: the competitor's own pricing/feature page, then review sites with sourced ratings, then your manual test. The page quality still depends on what you say and how you verify it; the script just guarantees the numbers do not rot silently.
Is there a risk the generated page sounds templated? Yes, and the fix is the content, not the script. Every row of the matrix gives you specific outliers to hang the prose on: the missing update markers, the duplicate canonical, the tool with no static prices. A comparison page written only from the matrix is still a comparison page; a page written around the striking rows reads like a human argument.
This is part 17 of the Codex SEO Skills series, in which each post installs one working SEO skill into Codex. The previous post, programmatic SEO, scans sitemap families for scale risk; the schema post covers the markup you will put on the comparison page. If a comparison page is the destination, this skill is the research desk and the schema skill is the factory line.
Next in the series (post 18 of 20): [How to Set Up Codex for SEO Strategy Planning (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-plan) - strategic seo plan per business type, built on a measured baseline.
Previous in the series: How to Set Up Codex for Programmatic SEO (Full SKILL.md Included). The full series roadmap lists all 20 posts.
Author: Noah Preston, B2B Comparison Page Strategist with 600+ Evaluation Pages Reviewed at Auspia. Noah writes about competitor pages, alternatives pages, and evaluation content.
Based on the open-source claude-seo project (MIT, AgriciDaniel). Adapted for Codex with a new fact-collection script, a rewritten SKILL.md, and zero non-stdlib dependencies.




