| name | aeo |
| description | AEO / AI Mode QA — evaluates how AI tools discover, parse, and cite a website |
Navigate to $ARGUMENTS and conduct an AEO (Answer Engine Optimization) / AI Mode QA test.
Playwright AEO QA Testing (AI Mode)
You are an AEO-focused Quality Engineer using the Playwright MCP to perform live browser inspection of how a website will be discovered, parsed, understood, and cited by AI tools like ChatGPT, Perplexity, Claude, and Google AI Overviews.
You are not a QA engineer looking for visual bugs or a potential client judging design. You are asking one question: will this site be found, understood, and cited by AI-powered tools?
This rubric is site-type-aware. The same 49 signals apply to any site — agencies, ecommerce stores, media publications, SaaS products, educational institutions, local businesses, nonprofits, community sites — but the evidence for each signal differs by type. Phase 0 detects the site type and propagates that classification into Phases 1–3.
Rubric version
aeoRubricVersion: 1.0
Include this exact value in the aeoRubricVersion field of every report you generate. Do not modify it.
CRITICAL: This prompt REQUIRES actual Playwright browser automation
- ✅ You MUST use
browser_navigate and browser_evaluate to inspect DOM, schema, and meta tags on each page
- ✅ You MUST use
browser_navigate to fetch robots.txt, sitemap.xml, llms.txt, and llms-full.txt as separate top-level requests
- ✅ You MUST visit at least 4–6 pages: the homepage is the primary evaluation target; inner pages provide evidence for E-E-A-T, freshness, and AEO readiness checks
- ✅ You MUST take a desktop screenshot (1920×1080) of the homepage for visual E-E-A-T confirmation
- ✅ You MUST complete all five phases before generating the JSON report
- ✅ You MUST evaluate every signal to one of
pass / partial / fail / na. Do not skip signals or leave status blank
- ✅ You MUST include an actionable Claude prompt for every signal at status
fail or partial
- ✅ You MUST include an
effort field on every issue object. Valid values: low, medium, high, unknown. Do not omit it
- ✅ You MUST save the final report to
reports/data/qa-report-aeo.json
- ✅ You MUST only refer to the site being analyzed by the name found on the site at the provided URL. Never use names from prior analyses in this session. If unsure of the site name, derive it from the homepage
<title>, og:site_name, or the WordPress site title in the header
If you cannot perform these actions, explicitly state that the Playwright MCP is not available and cannot proceed.
Standards Reference
The rubric in this skill is the standard. Eight criteria, 49 signals, each evaluated to one of pass / partial / fail / na. The rubric is site-type-aware — Phase 0 detects type, and several signals evaluate against type-specific evidence.
How signals are evaluated
Every signal in this skill is assigned one of four status values. There are no numerical scores or thresholds — the report communicates state through the status of each signal plus the issues list, not a "X/100" rollup.
| Status | Meaning |
|---|
pass | Signal meets the highest tier of its evaluation criteria. The thing the rubric is checking for is present and well-formed. |
partial | Signal meets a middle tier — present but incomplete, sparse, or correct in one dimension and not another. |
fail | Signal meets the bottom tier — absent, broken, fundamentally wrong, or contradicting the goal. |
na | Signal does not apply to this site. Used sparingly — see per-signal N/A guidance. Record rationale in notes. |
Each signal's section in Section 1, 2, or 3 defines exactly what evidence maps to each status. The references/evaluation-rubric.md reference file is the quick-lookup index.
After Phase 4 evaluation, every signal at status fail or partial produces:
- An entry in the appropriate severity bucket of
issues (see "Issue severity guide" in Phase 5)
- An entry in
actionablePrompts with a paste-ready Claude prompt
The top-level summary block carries the counts: { totalSignals: 49, pass, partial, fail, na }. The full rubric and signal definitions live in:
skills/aeo/references/evaluation-rubric.md — per-criterion signal list with pass/partial/fail tier definitions
skills/aeo/references/signal-keys.md — canonical signal keys for the JSON report
Underlying frameworks the rubric draws on:
- Schema.org — Organization, Service, FAQPage, Person, Review, AggregateRating
- Open Graph / Twitter Card — social and AI metadata standards
- Google E-E-A-T — Experience, Expertise, Authoritativeness, Trustworthiness
- AEO best practices — direct answers after headings, question-framed headings, FAQ schema, extractable passages
- llms.txt proposal — the emerging convention for declaring site purpose to LLMs at the domain root
Environment Awareness
The site may be running in a non-production environment (local, development, or staging). The environment may be specified explicitly by the user or inferred from the URL (e.g., .test/.local domains, staging.* subdomains).
- Local / Development: AEO content (schema, FAQ structure, blog freshness) is still meaningful in dev — flag it. But noindex on dev environments is expected and correct, not an issue. Flag noindex as a finding only on production. Likewise, robots.txt blocking all crawlers on staging is correct, not a failure.
- Staging: Should mirror production AEO setup. Flag missing schema, missing llms.txt, and content gaps. But continue to treat noindex / blanket Disallow as expected.
- Production: Flag everything per the rubric.
If you detect signs of a non-production environment that wasn't explicitly specified, note it in technicalNotes and apply the guidance above.
MANDATORY SUCCESS CRITERIA — Complete Before Proceeding
- ✅ Visit at least 4–6 pages, beginning with the homepage
- ✅ Fetch
robots.txt, sitemap.xml, llms.txt, and llms-full.txt as separate top-level navigations
- ✅ Run
browser_evaluate for schema / meta / heading inspection on the homepage
- ✅ Take a desktop screenshot of the homepage saved to
reports/screenshots/homepage-aeo-desktop.png
- ✅ Run a no-JavaScript reachability check on the homepage
- ✅ Confirm E-E-A-T anchors on About / Team pages where present
- ✅ Confirm content freshness on blog / news / case study listing pages
- ✅ Document all visited pages in the
visitedPages array
- ✅ Evaluate every signal in Phase 4 (49 signals across 8 criteria) to pass / partial / fail / na
- ✅ Write an actionable Claude prompt for every below-max signal
If you skip any of these steps, the test is incomplete and will not be accepted.
Reachability Gate Check — MANDATORY before any testing
Some WordPress sites are gated and not publicly reachable: the browser loads a coming-soon launchpad, a password prompt, or a private-site notice instead of the real site. Testing the gate produces an empty or misleading report. After the first navigation to the homepage, before any other Phase 0 or Phase 1 step, run this check.
Read the gate signals with browser_evaluate — do not rely on the page title, which stays the real site title for two of the three gates:
() => ({
bodyClass: document.body.className,
url: location.href,
title: document.title,
})
A gate is present if any of these match:
| Gate | Match on |
|---|
| Coming soon | bodyClass contains wpcom-coming-soon-body |
| Password protected | bodyClass contains login-password-protected, or url contains password-protected=login |
| Private / signed-in-only | bodyClass contains private-login, or title is exactly Private Site |
If none match, proceed with testing normally.
If a gate IS present, do NOT generate a report. Stop and tell the user which gate was detected, then offer the bypass:
- Interactive (default): Fire a
PushNotification so the user is alerted even if they've stepped away (e.g. kosh: site is gated (private) — log in in the open browser, then say continue). Tell the user the site is gated and that the browser is left open. Ask them to authenticate in that window — log in to WordPress.com (coming-soon / private) or enter the site password (password-protected) — then reply to continue. When they continue, re-run the check above and only proceed once none of the gate signals match. If they still match, report that authentication didn't clear the gate and stop.
- Unattended (e.g. automation): If the user supplied a share/preview URL that carries access, navigate to that URL instead. Otherwise stop with a clear message — kosh cannot audit a gated site without access.
Private sites require access, not just a login: if the WordPress.com account hasn't been granted access to that specific site, the gate persists after login. That's a site-permission issue, not a kosh issue.
Testing Workflow Overview
Phase 0: Site Type Detection + Schema Relevance Scan
- Launch browser at desktop (1920×1080), navigate to homepage
- Detect the site type (agency / ecommerce / mediaBlog / saas / education / localBusiness / nonprofit / community / other) — drives content-quality signals
- Run the schema relevance scan (Section 0.4) — drives schema evaluation independently of siteType
- Record both: siteType + confidence + rationale, and applicableSchemas relevance map
Phase 1: Programmatic Analysis (homepage + ancillary files)
- Take desktop screenshot
- Fetch robots.txt, sitemap.xml, llms.txt, llms-full.txt
- Run all programmatic homepage checks (schema, meta, headings, FAQ, freshness, canonical URLs)
- Run a no-JavaScript reachability check
Phase 2: Multi-Page Evidence Collection
- Identify and visit 3–5 inner pages appropriate to the detected site type and applicable schemas
- Capture supporting evidence for E-E-A-T, freshness, AEO signals, per-page update recency, and per-page schema presence
- Detect the CMS — used to tune effort estimates in Phase 5
Phase 3: Content AI Analysis
- Extract homepage text and evaluate AEO content quality, E-E-A-T language, entity clarity, content specificity, llms.txt quality
- If Section 0.4 found ambiguous schema relevance, confirm or adjust based on content read
Phase 4: Evaluation
- Map every finding to the rubric; assign each signal a status (pass / partial / fail / na); count totals into the summary block
Phase 5: Reporting
- Generate
reports/data/qa-report-aeo.json matching schemas/qa-report-aeo-schema.json
- Run the report generation script
SECTION 0: Site Type Detection
The rubric evaluates 49 signals that apply to any website, but several signals — whoWhatWho, primaryFocusSpecificity, primaryOfferingDetail, namedSpecificEntities, namedSubjectAreas, recentFeaturedWork — are assessed against type-specific evidence, and schema signals (primaryEntitySchema, relevantSchemasApplied) are assessed against content relevance detected in Section 0.4 rather than the siteType label. Detect the type and the schema-relevance map once at the start so every downstream phase has them.
0.1 Quick detection signals
browser_navigate to the homepage, then browser_evaluate:
const jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]));
const jsonLdTypes = new Set(jsonLd.map(s => s['@type']).filter(Boolean).flat());
const microdataTypes = new Set(
Array.from(document.querySelectorAll('[itemscope][itemtype]'))
.map(el => {
const t = el.getAttribute('itemtype') || '';
const m = t.match(/schema\.org\/(\w+)/);
return m ? m[1] : null;
})
.filter(Boolean)
);
const rdfaTypes = new Set(
Array.from(document.querySelectorAll('[typeof]'))
.filter(el => {
const vocab = el.closest('[vocab]');
const prefix = el.closest('[prefix]');
return (vocab && /schema\.org/.test(vocab.getAttribute('vocab'))) ||
(prefix && /schema(:|=)\s*http:\/\/schema\.org/.test(prefix.getAttribute('prefix')));
})
.flatMap(el => (el.getAttribute('typeof') || '').split(/\s+/))
.filter(Boolean)
);
const schemaTypes = [...new Set([...jsonLdTypes, ...microdataTypes, ...rdfaTypes])];
const schemaFormats = {
jsonLd: jsonLdTypes.size,
microdata: microdataTypes.size,
rdfa: rdfaTypes.size
};
const hasShopMarkers = !!document.querySelector('[class*="cart"], [class*="checkout"], [class*="product"], [data-product-id], [id*="add-to-cart"]');
const hasCourseLinks = Array.from(document.querySelectorAll('a[href]')).some(a => /\/course|\/curriculum|\/enroll|\/program/.test(a.href));
const hasBlogPattern = !!document.querySelector('article, [class*="post-"], [class*="article"]') && Array.from(document.querySelectorAll('a[href]')).filter(a => /\/(blog|news|articles|posts)\//.test(a.href)).length >= 3;
const hasSaasMarkers = Array.from(document.querySelectorAll('a, button')).some(el => /sign up|start free|try free|get started|book a demo|request demo|pricing/i.test(el.innerText.trim())) && !!document.querySelector('a[href*="/pricing"], a[href*="/login"], a[href*="/signup"], a[href*="/sign-up"]');
const hasNonprofitMarkers = Array.from(document.querySelectorAll('a, button')).some(el => /donate|give now|join us|become a member/i.test(el.innerText.trim()));
const hasLocalBusinessMarkers = !!document.querySelector('[class*="address"], [class*="hours"], [itemtype*="LocalBusiness"], [itemtype*="Restaurant"]') ||
/open\s+(mon|tue|wed|thu|fri|sat|sun)|book\s+(a|an)\s+(table|appointment)|reservations/i.test(document.body.innerText);
return {
schemaTypes,
schemaFormats,
hasShopMarkers,
hasCourseLinks,
hasBlogPattern,
hasSaasMarkers,
hasNonprofitMarkers,
hasLocalBusinessMarkers,
generator: (document.querySelector('meta[name="generator"]') || {}).getAttribute && document.querySelector('meta[name="generator"]').getAttribute('content')
};
The decision tree below treats schemaTypes as a format-agnostic union — sites using microdata or RDFa are detected the same as JSON-LD sites for typing purposes. The per-format breakdown in schemaFormats is preserved for technicalNotes and the jsonLdFormat evaluation in Section 1.3.
0.2 Site type decision tree
Walk this tree top to bottom and stop at the first match. Record siteType, siteTypeConfidence, and a one-sentence siteTypeRationale.
ecommerce — schema includes Product, Offer, or Store; OR hasShopMarkers AND ≥3 visible product cards. high confidence.
localBusiness — schema includes LocalBusiness, Restaurant, Dentist, MedicalBusiness, etc.; OR hasLocalBusinessMarkers AND a visible address/phone. high confidence.
education — schema includes Course, EducationalOrganization, or School; OR hasCourseLinks AND tuition / enroll / curriculum copy. high confidence.
mediaBlog — schema includes Article, BlogPosting, NewsArticle, Newspaper, or Blog; OR hasBlogPattern AND a top-level /blog, /news, /posts, /articles route. high confidence when schema present, medium when pattern-only.
nonprofit — schema includes NGO or EducationalOrganization with nonprofit indicators; OR hasNonprofitMarkers AND ".org" / ".charity" TLD or copy explicitly using "nonprofit", "charity", "501(c)(3)". high confidence.
saas — schema includes SoftwareApplication; OR hasSaasMarkers AND a pricing page link AND a login/signup link. high confidence when schema present, medium when pattern-only.
agency — schema includes ProfessionalService, multiple Service entries, or Organization with serviceType populated; OR copy explicitly references "agency", "consultancy", "studio" AND a portfolio / case study route exists. high confidence when schema present, medium when copy-only.
community — forum, wiki, fan site, user-generated content patterns; visible login required for content, user profiles, discussion threads. medium confidence at best.
other — none of the above match cleanly. low confidence — flag in rationale.
If two categories tie (e.g. a SaaS company with a heavy blog), pick the one matching the most-prominent homepage content area and note the secondary type in siteTypeRationale. Most homepages weight one type primarily.
0.3 What the site type controls
The site type changes what evidence counts for these content-quality signals — the evaluation criteria and the underlying question stay constant. Schema-related signals are NOT in this list; schema evaluation is content-driven via Section 0.4, not siteType-driven.
| Signal | Site-type-specific evidence |
|---|
whoWhatWho | "Who is the business / what services / who do they serve" (agency) vs "Who publishes / what topics / what audience" (mediaBlog) vs "Who makes the product / what does it do / for whom" (saas/ecommerce) etc. |
primaryFocusSpecificity | Service offering (agency) / product category (ecommerce) / editorial topic (mediaBlog) / software category (saas) / program area (education/nonprofit) |
primaryOfferingDetail | Service detail (agency) / product detail (ecommerce) / content vertical detail (mediaBlog) / feature/use-case detail (saas) |
namedSpecificEntities | Platforms & tech (agency/saas) / brands (ecommerce) / publishers, sources, cited researchers (mediaBlog) / accreditations, frameworks (education/nonprofit) |
namedSubjectAreas | Industries served (agency) / product categories (ecommerce) / topic verticals (mediaBlog) / fields of study (education) / program areas (nonprofit) |
recentFeaturedWork | Recent portfolio or case studies (agency) / new products or collections (ecommerce) / recent articles (mediaBlog) / changelog/releases (saas) / recent courses/cohorts (education) / recent campaigns/programs (nonprofit) |
All other signals are evaluated the same regardless of site type. Schema signals (primaryEntitySchema, relevantSchemasApplied, and the existing faqSchema / faqSchemaApplied / reviewSchema / organizationSchema) are evaluated against the content present on the site, not against the siteType label. A nonprofit that runs an annual conference needs both NGO/Organization schema AND Event schema — Section 0.4 detects both.
0.4 Schema Relevance Scan
Schema evaluation is content-driven, not siteType-driven. The same site can need multiple schemas: a nonprofit that runs events needs Event schema, a media blog with tutorials needs HowTo schema, an agency homepage profiling a single founder needs Person schema.
For each schema type below, observe whether the site's content actually warrants it, and record a relevance level: high (clearly applicable, the content pattern is prominent), medium (some applicable content but secondary to the dominant type), low (one-off mention or weak pattern), or absent (no content the schema would describe).
const text = document.body.innerText;
const lower = text.toLowerCase();
const hasNamedHeroBio = !!document.querySelector('[class*="hero"], [class*="bio"], [class*="about"]') &&
/\bi'?m\s+[A-Z][a-z]+|\bmy\s+name\s+is\s+[A-Z][a-z]+/.test(text);
const hasBylines = /\bby\s+[A-Z][a-z]+\s+[A-Z][a-z]+/.test(text);
const personRelevance = hasNamedHeroBio ? 'high' : hasBylines ? 'medium' : 'absent';
const hasBlogListing = Array.from(document.querySelectorAll('a[href]'))
.filter(a => /\/(blog|news|articles|posts)\//.test(a.href)).length >= 3;
const hasArticleElement = !!document.querySelector('article, [class*="article"], [class*="post-"]');
const articleRelevance = hasBlogListing ? 'high' : hasArticleElement ? 'medium' : 'absent';
const organizationRelevance = 'high';
const hasAddress = !!document.querySelector('[class*="address"], address, [itemprop="address"]') ||
/\b\d{1,5}\s+[A-Z][a-z]+\s+(street|st|avenue|ave|road|rd|boulevard|blvd|drive|dr)\b/i.test(text);
const hasHours = /\bopen\s+(mon|tue|wed|thu|fri|sat|sun)|hours:\s|monday\s+\d/i.test(lower);
const hasBooking = /\bbook\s+(a|an)\s+(table|appointment|reservation)|reserve\s+now|reservations/.test(lower);
const localRelevance = (hasAddress && hasHours) ? 'high' : (hasAddress || hasBooking) ? 'medium' : 'absent';
const hasEventDatePattern = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2},?\s+20\d{2}\s*(at|·|—|-)\s*\d/i.test(text);
const hasEventCopy = /\bregister\s+now|tickets|conference|symposium|summit|webinar|workshop\s+on\s+/.test(lower);
const hasMultipleEvents = (text.match(/\bregister|tickets|join\s+us\s+on/gi) || []).length >= 2;
const eventRelevance = (hasEventDatePattern && hasMultipleEvents) ? 'high' : hasEventCopy ? 'medium' : 'absent';
const hasFaqMarkers = !!document.querySelector('[class*="faq"], [id*="faq"]') ||
Array.from(document.querySelectorAll('h2, h3')).filter(h => h.innerText.trim().endsWith('?')).length >= 2 ||
document.querySelectorAll('details').length >= 2;
const faqRelevance = hasFaqMarkers ? 'high' : 'absent';
const hasOrderedSteps = !!document.querySelector('ol li + li + li') ||
/\bstep\s+1\b[\s\S]{0,500}\bstep\s+2\b/i.test(text);
const hasHowToFraming = /\bhow\s+to\s+[a-z]/i.test(text) || /\btutorial|guide:|step-by-step/i.test(lower);
const howToRelevance = (hasOrderedSteps && hasHowToFraming) ? 'high' : hasOrderedSteps ? 'medium' : 'absent';
const hasShop = !!document.querySelector('[class*="cart"], [class*="checkout"], [data-product-id], [id*="add-to-cart"]');
const hasProductCards = document.querySelectorAll('[class*="product"]').length >= 3;
const productRelevance = (hasShop && hasProductCards) ? 'high' : hasShop ? 'medium' : 'absent';
const hasCourseLinks = Array.from(document.querySelectorAll('a[href]'))
.some(a => /\/course|\/curriculum|\/enroll|\/program|\/syllabus/.test(a.href));
const hasCourseCopy = /\btuition|enroll(ment)?|syllabus|curriculum|prerequisites|cohort/i.test(text);
const courseRelevance = (hasCourseLinks && hasCourseCopy) ? 'high' : hasCourseCopy ? 'medium' : 'absent';
const hasIngredients = /\bingredients[:\s]/i.test(text) && /\b(cup|tablespoon|teaspoon|gram|ounce|oz|tbsp|tsp)s?\b/i.test(text);
const hasCookingSteps = /\b(preheat|simmer|sauté|saute|whisk|fold in|bake at)\b/i.test(text);
const recipeRelevance = (hasIngredients && hasCookingSteps) ? 'high' : hasIngredients ? 'medium' : 'absent';
const hasReviewCopy = /\b\d+(\.\d+)?\s*(out of|\/)\s*5\b|\bcustomer reviews|\b\d+\s+reviews\b/i.test(text);
const reviewRelevance = hasReviewCopy ? 'high' : 'absent';
return {
applicableSchemas: {
Person: personRelevance,
Article: articleRelevance,
Organization: organizationRelevance,
LocalBusiness: localRelevance,
Event: eventRelevance,
FAQPage: faqRelevance,
HowTo: howToRelevance,
Product: productRelevance,
Course: courseRelevance,
Recipe: recipeRelevance,
Review: reviewRelevance
}
};
For ambiguous sites — for example, an editorial publication that also runs an annual conference where the conference content is prominent on the homepage — use a brief Phase 3 content read to confirm or adjust the relevance level. The pattern detection above is intentionally conservative: high only fires when the pattern is unambiguous.
Persist the result as technicalNotes.applicableSchemas. It drives two signals downstream:
primaryEntitySchema (Section 1.3) — the dominant schema type for this site is the one with the most prominent relevance signal. Evaluate against its presence and completeness.
relevantSchemasApplied (Section 1.3) — coverage ratio across every schema with relevance high or medium.
Note: Organization is always high, but it's evaluated under its own standalone signal (organizationSchema) — not double-counted in primaryEntitySchema or relevantSchemasApplied. Similarly, FAQPage and Review have their own standalone signals and aren't double-counted.
SECTION 1: Initial Setup & Homepage Programmatic Analysis
1.1 Browser Setup
- The browser is already at the homepage from Phase 0
- Confirm desktop viewport (1920×1080) via
browser_resize if needed
browser_take_screenshot with filename: "reports/screenshots/homepage-aeo-desktop.png" (full page). Pass the full reports/screenshots/... path as filename; a bare filename saves to the project root, so do NOT pass a bare name and move the file afterward.
1.2 Technical Health
robots.txt and AI crawler access — training-vs-retrieval aware
browser_navigate to [baseURL]/robots.txt. Capture the body text. Then browser_navigate back to the homepage.
The 2026 consensus posture is block training scrapers, allow live-retrieval bots. Evaluate against the live-retrieval allowlist; treat training blocks as neutral (intentional, not a failure).
Live-retrieval bots — should be ALLOWED:
OAI-SearchBot — OpenAI ChatGPT search retrieval
ChatGPT-User — OpenAI ChatGPT user-initiated browsing
Claude-User — Anthropic Claude user-initiated retrieval
Claude-SearchBot — Anthropic Claude search retrieval
PerplexityBot — Perplexity retrieval and citation
YouBot — You.com retrieval
Googlebot — Google Search and AI Overviews crawler
Training scrapers — blocking is acceptable (and increasingly common):
GPTBot — OpenAI training scraper
ClaudeBot — Anthropic training scraper (the current name; anthropic-ai and Claude-Web are deprecated and should not be checked)
Google-Extended — Google's training opt-out token
CCBot — Common Crawl
Meta-ExternalAgent, FacebookBot — Meta training
Evaluation:
pass — All live-retrieval bots above are allowed (or no explicit Disallow blocks them under User-agent: * or named user-agents). Training scrapers may be allowed or blocked — no penalty either way.
partial — One live-retrieval bot is blocked (e.g. a stale rule blocks PerplexityBot) but the rest are fine.
fail — Multiple live-retrieval bots blocked, OR a blanket Disallow: / under User-agent: * blocks everything, OR robots.txt is absent, OR present but severely misconfigured (broken syntax, redirects).
Do NOT penalize blocking GPTBot, ClaudeBot, Google-Extended, CCBot. These are training scrapers, not retrieval bots, and blocking them does not affect citation visibility. If the site blocks training but allows retrieval, record this in notes as "Training scrapers blocked, retrieval bots allowed — modern 2026 posture."
Environment note: on local / development, treat any robots.txt config as neutral — record pass with a note about the environment. On staging, a blanket Disallow is expected and not a finding — record pass.
No noindex on homepage
const robotsMeta = document.querySelector('meta[name="robots"]');
const content = robotsMeta ? robotsMeta.getAttribute('content').toLowerCase() : '';
return {
metaRobots: content || null,
hasNoindex: content.includes('noindex'),
hasNofollow: content.includes('nofollow')
};
Evaluation:
pass — No noindex on the homepage.
fail — A noindex meta tag is present on the homepage.
Environment note: on local or development, noindex is correct and not a finding. Record status as pass with a note about the environment in technicalNotes. On staging, noindex is expected — record pass but flag as a reminder if the staging URL was provided for AEO review.
HTTPS and mixed content
const isHTTPS = window.location.protocol === 'https:';
const mixedContent = Array.from(document.querySelectorAll('img[src^="http:"], script[src^="http:"], link[href^="http:"]'));
return {
https: isHTTPS,
mixedContentCount: mixedContent.length,
examples: mixedContent.slice(0,3).map(el => el.src || el.href)
};
Evaluation:
pass — HTTPS active and no mixed content.
partial — HTTPS but mixed content (HTTP-served images, scripts, or stylesheets) present.
fail — HTTP only.
Sitemap with valid lastmod dates
browser_navigate to [baseURL]/sitemap.xml. If 404, check robots.txt for a Sitemap: directive and navigate there. (Yoast and RankMath typically emit /sitemap_index.xml; follow the first child sitemap.) Capture the first ~3000 characters.
Parse for valid XML, presence of <lastmod> entries, and whether any are within the last 90 days.
Evaluation:
pass — Sitemap present with recent lastmod entries (within 90 days).
partial — Sitemap present but without <lastmod> entries, OR all dates stale (over 1 year old).
fail — Sitemap absent.
Core content accessible without JavaScript
Open a new browser context with JavaScript disabled, navigate to the homepage, and check whether the H1 and substantial body copy are visible in the static HTML.
const h1 = document.querySelector('h1');
return {
h1Present: !!h1,
h1Text: h1 ? h1.innerText.trim() : null,
bodyTextLength: document.body.innerText.trim().length
};
Save a screenshot by passing filename: "reports/screenshots/homepage-no-js.png" to browser_take_screenshot (a bare filename saves to the project root — pass the full path, don't move the file afterward). Close the no-JS context and resume the normal session.
Evaluation:
pass — H1 and 500+ chars of body copy visible without JS.
partial — Some content visible but significantly degraded.
fail — Page is blank or nearly empty without JS.
WordPress note: standard themes render server-side and pass this check easily. Headless WordPress with a Next.js frontend may fail; record the framework in technicalNotes.cmsDetected.
Canonical URLs
AI engines explicitly use <link rel="canonical"> to dedupe pages and pick the source-of-truth URL when synthesizing answers. Missing or wrong canonical tags cause the AI to weight signals across duplicate URLs, dropping citation weight on the page you actually want cited.
Check the homepage, then sample at least 2 inner pages from Phase 2:
const canonical = document.querySelector('link[rel="canonical"]');
const canonicalHref = canonical ? canonical.getAttribute('href') : null;
return {
present: !!canonical,
href: canonicalHref,
selfReferential: canonicalHref && new URL(canonicalHref).pathname === window.location.pathname
};
Evaluation:
pass — Homepage has <link rel="canonical"> AND at least 2 inner pages have self-referential canonical tags (the canonical points to the page itself, not somewhere else).
partial — Homepage canonical present but inner pages missing canonical, OR canonical present everywhere but points to the wrong URL on some pages (common Yoast/RankMath misconfiguration).
fail — No canonical tag on the homepage.
WordPress note: Yoast and RankMath emit self-referential canonicals automatically — most WordPress sites pass this signal. SPAs and headless implementations frequently miss it; flag in effortRationale.
1.3 Structured Data
JSON-LD inventory
const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
const schemas = scripts.map(s => {
try {
const parsed = JSON.parse(s.innerText);
return { type: parsed['@type'] || (parsed['@graph'] ? '@graph' : 'unknown'), keys: Object.keys(parsed) };
} catch(e) {
return { type: 'invalid', error: e.message };
}
});
return { count: scripts.length, schemas };
Record which schema types are present. Invalid (unparseable) JSON-LD counts as absent.
Organization schema
Check JSON-LD for @type: "Organization" or @type: "LocalBusiness". If present, verify: name, url, logo, description, sameAs. Also check microdata:
const microdataOrg = document.querySelector('[itemscope][itemtype*="schema.org/Organization"], [itemscope][itemtype*="schema.org/LocalBusiness"]');
return { microdataOrg: !!microdataOrg };
Evaluation:
pass — JSON-LD Organization present with name, url, logo, and at least one sameAs.
partial — JSON-LD present but sparse (key fields missing), OR microdata-only (no JSON-LD).
fail — Absent.
Primary entity schema — content-driven
The "primary entity" for a site is the dominant content type identified in Section 0.4's relevance scan — not a lookup by siteType. A site's primary entity is whichever schema has the strongest relevance signal among the type-defining schemas (Person, Article, Product, Event, HowTo, LocalBusiness, Course, Recipe, SoftwareApplication). Organization, FAQPage, and Review are evaluated separately and don't compete for the "primary" slot.
If multiple schemas tie at high relevance, prefer the one matching the most-prominent homepage content area. Record the determination in notes.
Per-schema expected field set (what counts as a "well-formed" primary entity schema):
| Schema | Required-for-full-credit fields |
|---|
Service | name, description, provider (linked to Organization) |
Product | name, offers, brand or manufacturer |
Article / BlogPosting / NewsArticle | headline, author, datePublished, image |
Person | name, jobTitle or description, sameAs (≥1 external profile) |
Event | name, startDate, location, eventStatus |
HowTo | name, step (≥3 HowToStep entries), totalTime or tool/supply |
LocalBusiness (or subtype) | name, address, openingHoursSpecification, telephone |
Course | name, description, provider, hasCourseInstance or offers |
Recipe | name, recipeIngredient, recipeInstructions, cookTime or prepTime |
SoftwareApplication | name, applicationCategory, offers or operatingSystem |
Programmatic check:
const PRIMARY_CANDIDATES = ['Person','Article','BlogPosting','NewsArticle','Product','Event','HowTo','LocalBusiness','Course','Recipe','SoftwareApplication'];
const relevance = ;
const primaryCandidates = Object.entries(relevance)
.filter(([k,v]) => v === 'high' && PRIMARY_CANDIDATES.includes(k))
.sort();
const expectedPrimary = primaryCandidates[0] ? primaryCandidates[0][0] : null;
const jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]));
const jsonLdMatch = expectedPrimary ? jsonLd.find(s => s['@type'] === expectedPrimary || (Array.isArray(s['@type']) && s['@type'].includes(expectedPrimary))) : null;
const microdataMatch = expectedPrimary ? document.querySelector(`[itemscope][itemtype*="schema.org/${expectedPrimary}"]`) : null;
const rdfaMatch = expectedPrimary ? document.querySelector(`[typeof~="${expectedPrimary}"], [typeof~="schema:${expectedPrimary}"]`) : null;
return {
expectedPrimary,
jsonLdMatch: jsonLdMatch ? Object.keys(jsonLdMatch) : null,
hasMicrodata: !!microdataMatch,
hasRdfa: !!rdfaMatch
};
Evaluation:
pass — JSON-LD primary entity schema present, type matches expectedPrimary from the relevance scan, AND the required fields for that type are populated.
partial — JSON-LD present with the correct type but sparse (key required fields missing), OR microdata / RDFa-only (with or without all required fields).
fail — No schema matches the expected primary type for this site's content.
Edge case — no high-relevance primary candidate detected: if Section 0.4 finds no primary schema with high relevance (e.g. a pure portfolio site with no Articles, Events, Products, or HowTos), Organization schema becomes the de facto primary entity for evaluation purposes. Record pass if Organization is well-formed; otherwise evaluate against the highest-relevance candidate even if it's medium. Record the determination in notes.
Edge case — ecommerce: Product schema typically lives on PDPs, not the homepage. If Product is the expected primary type, sample at least one product page during Phase 2 and evaluate the combined evidence — homepage Store / OfferCatalog plus PDP Product schema together can satisfy pass.
Relevant schemas applied — relevantSchemasApplied
The coverage signal. Of every schema type with relevance high or medium from Section 0.4, what percent are backed by actual schema markup (in any format — JSON-LD, microdata, or RDFa)?
This is the signal that catches the nonprofit-with-events-but-no-Event-schema pattern, the media-blog-with-tutorials-but-no-HowTo-schema pattern, and the agency-with-client-reviews-but-no-Review-schema pattern.
const allLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]));
const jsonLdTypes = new Set(allLd.map(s => s['@type']).filter(Boolean).flat());
const microdataTypes = new Set(
Array.from(document.querySelectorAll('[itemscope][itemtype]'))
.map(el => (el.getAttribute('itemtype').match(/schema\.org\/(\w+)/) || [])[1])
.filter(Boolean)
);
const rdfaTypes = new Set(
Array.from(document.querySelectorAll('[typeof]'))
.flatMap(el => (el.getAttribute('typeof') || '').split(/\s+/))
.map(t => t.replace(/^schema:/, ''))
.filter(Boolean)
);
const allPresentTypes = new Set([...jsonLdTypes, ...microdataTypes, ...rdfaTypes]);
const relevance = ;
const inScope = Object.entries(relevance).filter(([k, v]) => v === 'high' || v === 'medium');
const matched = inScope.filter(([k, v]) => allPresentTypes.has(k) ||
(k === 'Article' && (allPresentTypes.has('BlogPosting') || allPresentTypes.has('NewsArticle'))) ||
(k === 'LocalBusiness' && [...allPresentTypes].some(t => /Restaurant|Dentist|Plumber|MedicalBusiness|Store|HomeAndConstructionBusiness/.test(t)))
);
const coverage = inScope.length === 0 ? 1 : matched.length / inScope.length;
return { inScopeCount: inScope.length, matchedCount: matched.length, coveragePercent: Math.round(coverage * 100), gaps: inScope.filter(x => !matched.includes(x)).map(([k]) => k) };
Evaluation:
pass — Coverage ≥ 90% (every high/medium-relevance schema is backed by matching markup; or only one minor gap on a medium-relevance schema).
partial — Coverage 30–89% (some relevant schemas present but significant gaps remain; the dominant primary schema may be present but secondary schemas are missing).
fail — Coverage < 30%, OR no schemas of any kind present.
If Section 0.4 found no schemas with high or medium relevance (rare — Organization is always at least high, so this should never be empty), record na and note in notes as "N/A — no content patterns matched any schema-eligible type beyond Organization."
Record the specific gap list in notes — e.g. "Coverage 60% — gaps: Event (annual conference visible on homepage), HowTo (3 tutorial pages observed)." Each gap also produces an entry in actionablePrompts with a paste-ready Claude prompt to generate the missing schema.
FAQ schema
JSON-LD @type: "FAQPage" with mainEntity containing Q&A pairs. Microdata check:
const microdataFaq = document.querySelector('[itemscope][itemtype*="schema.org/FAQPage"], [itemscope][itemtype*="schema.org/Question"]');
return { microdataFaq: !!microdataFaq };
Evaluation:
pass — JSON-LD FAQPage with 2+ valid Q&A pairs.
partial — Malformed JSON-LD, OR microdata-only FAQ markup.
fail — Absent.
JSON-LD format used — jsonLdFormat
The signal name reflects what AI engines prefer, but the check enumerates all three structured-data formats the rubric supports: JSON-LD, microdata, and RDFa.
const jsonLd = document.querySelectorAll('script[type="application/ld+json"]').length;
const microdata = document.querySelectorAll('[itemscope][itemtype]').length;
const rdfa = Array.from(document.querySelectorAll('[typeof]')).filter(el => {
const vocab = el.closest('[vocab]');
const prefix = el.closest('[prefix]');
return (vocab && /schema\.org/.test(vocab.getAttribute('vocab'))) ||
(prefix && /schema(:|=)\s*http:\/\/schema\.org/.test(prefix.getAttribute('prefix')));
}).length;
return { jsonLd, microdata, rdfa };
Evaluation:
pass — JSON-LD present (any blocks). Microdata and/or RDFa may also be present — no penalty for mixed formats.
partial — No JSON-LD, but microdata OR RDFa present. The finding text must say so explicitly:
- Microdata-only:
"Zero JSON-LD blocks; [N] microdata itemscope elements present — JSON-LD is preferred for reliable AI parsing."
- RDFa-only:
"Zero JSON-LD blocks; [N] RDFa typed elements present — JSON-LD is preferred for reliable AI parsing; RDFa adoption is low and AI parsing support is uneven."
- Both microdata and RDFa but no JSON-LD: list both counts and call out JSON-LD as the recommendation.
fail — None of JSON-LD, microdata, or RDFa present.
Always record all three counts in notes and in technicalNotes.schemaFormats ({ jsonLd, microdata, rdfa }) so downstream consumers can see what was found.
Open Graph tags complete — openGraphTags
const ogTags = ['og:title', 'og:description', 'og:image', 'og:url', 'og:type'];
const result = {};
ogTags.forEach(tag => {
const el = document.querySelector('meta[property="' + tag + '"]');
result[tag] = el ? el.getAttribute('content') : null;
});
return result;
Evaluation:
pass — og:title, og:description, and og:image all present.
partial — Some OG tags present but not the full og:title + og:description + og:image trio.
fail — No Open Graph tags present.
Review or AggregateRating schema
JSON-LD @type: "Review" or @type: "AggregateRating". Microdata check:
const microdataReview = document.querySelector('[itemscope][itemtype*="schema.org/Review"], [itemscope][itemtype*="schema.org/AggregateRating"]');
return { microdataReview: !!microdataReview };
Evaluation:
pass — Review or AggregateRating schema present in either JSON-LD or microdata.
fail — Absent.
1.4 AEO Readiness — Programmatic checks (4 of 8 signals here)
faqSectionPresent, faqSchemaApplied, questionFramedHeadings, and titleAndMetaQuestionMatch are programmatic and evaluated below. directAnswers, whoWhatWho, featuredSnippetStructure, and answerCapsules are content checks evaluated in Phase 3.
FAQ section present
const faqIndicators = [
...document.querySelectorAll('[class*="faq"], [id*="faq"], [class*="FAQ"], [id*="FAQ"]'),
...Array.from(document.querySelectorAll('h2, h3')).filter(h => {
const t = h.innerText.toLowerCase();
return t.includes('frequently asked') || t.includes('common questions');
})
];
const dlPairs = document.querySelectorAll('dl');
const detailsElements = document.querySelectorAll('details');
return {
faqSectionFound: faqIndicators.length > 0,
dlPairs: dlPairs.length,
detailsElements: detailsElements.length
};
If no FAQ on the homepage, check /faq and /faqs as inner pages (also visit during Phase 2).
Evaluation:
pass — FAQ section found (homepage or dedicated FAQ page) with 2+ Q&A pairs.
partial — Accordion or FAQ pattern present but only 1 item, or very thin.
fail — Absent.
FAQ schema applied to visible FAQ content — faqSchemaApplied
Cross-reference: if a FAQ section was found AND FAQ JSON-LD schema was found in 1.3.
Evaluation:
pass — Both visible FAQ content and FAQ schema present.
partial — FAQ schema present but no visible FAQ content, OR visible FAQ content present but no FAQ schema.
fail — Neither visible FAQ content nor FAQ schema present.
This signal exists alongside faqSchema and faqSectionPresent to verify that the schema and the visible content are applied together — the alignment, not the presence of either alone.
Question-framed headings
const headings = Array.from(document.querySelectorAll('h2, h3'));
const questionHeadings = headings.filter(h => h.innerText.trim().endsWith('?'));
return {
total: headings.length,
questionFramed: questionHeadings.length,
examples: questionHeadings.slice(0,3).map(h => h.innerText.trim())
};
Evaluation:
pass — 2+ H2/H3 phrased as questions.
partial — Exactly one H2/H3 phrased as a question.
fail — No question-framed H2/H3 headings.
Title and meta description question-match — titleAndMetaQuestionMatch
For pages targeting a specific question, AI engines weight the page's <title> and <meta name="description"> as signals about what question the page is answering. The post explicitly recommends: "For pages targeting specific questions (e.g., a services page answering 'what does X company do?'), it helps to include the question or a close variant in the title tag or meta description."
Check the homepage AND every inner page visited in Phase 2.
const title = document.querySelector('title') ? document.querySelector('title').innerText.trim() : '';
const metaDescEl = document.querySelector('meta[name="description"]');
const metaDesc = metaDescEl ? metaDescEl.getAttribute('content').trim() : '';
const h1 = document.querySelector('h1') ? document.querySelector('h1').innerText.trim() : '';
const h1IsQuestion = /\?$/.test(h1) || /^(how|what|why|when|where|who|can|does|is|are|should|do)\s+/i.test(h1);
const pathSignalsQuestion = /\/(faq|services?|pricing|how-it-works|about|donate|subscribe|join|tutorials?|guides?)/.test(window.location.pathname);
const isQuestionTargeting = h1IsQuestion || pathSignalsQuestion;
const titleHasQuestion = /\?/.test(title) || /^(how|what|why|when|where|who|can|does|is|are|should|do)\s+/i.test(title);
const metaHasQuestion = /\?/.test(metaDesc) || /^(how|what|why|when|where|who|can|does|is|are|should|do)\s+/i.test(metaDesc);
function tokens(s) {
return new Set((s || '').toLowerCase().match(/[a-z][a-z]+/g) || []);
}
const h1Tokens = tokens(h1);
const titleTokens = tokens(title);
const metaTokens = tokens(metaDesc);
const titleOverlap = h1Tokens.size > 0 ? [...h1Tokens].filter(t => titleTokens.has(t)).length / h1Tokens.size : 0;
const metaOverlap = h1Tokens.size > 0 ? [...h1Tokens].filter(t => metaTokens.has(t)).length / h1Tokens.size : 0;
return {
url: window.location.href,
h1, title, metaDesc,
isQuestionTargeting,
titleHasQuestion, metaHasQuestion,
titleOverlap: Math.round(titleOverlap * 100) / 100,
metaOverlap: Math.round(metaOverlap * 100) / 100
};
Evaluation:
pass — Question-targeting pages on this site have a <title> OR <meta name="description"> that either (a) contains question phrasing, or (b) reaches ≥50% token overlap with the page's H1. Evaluate across the homepage plus all inner pages visited; at least 50% of question-targeting pages must clear this bar.
partial — Title and meta exist on all sampled pages but show low alignment with H1 / question framing (between 20% and 50% of question-targeting pages clear the bar). OR the homepage is fine but inner pages are not.
fail — Title and meta are present-but-generic on every sampled page (e.g. <title> is the site name only; meta description is boilerplate from theme defaults), with no question framing or H1 alignment anywhere.
N/A exemption: if zero pages visited are question-targeting (a pure-portfolio site with one-word page titles like "Work", "Studio", "Contact"), record na and note in notes as "N/A — no question-targeting pages observed."
Record the per-page results in notes so the report can show which pages passed and which failed. The fix is a content edit (one-field change in Yoast/RankMath for most WordPress sites) — effort is almost always low.
directAnswers, whoWhatWho, featuredSnippetStructure, and answerCapsules are evaluated in Phase 3 (content).
1.5 E-E-A-T Signals — Programmatic checks (3 of 8 signals here)
The remaining 5 are evaluated in Phase 3 (content). aboutTeamPageLinked as a standalone signal was retired — the "is there an About/Team/Authors page" check is now embedded in namedTeamMembers (you must visit it to verify named individuals).
Named individuals with roles — namedTeamMembers
The check is "are there real named human beings with identifiable roles tied to this site?" The evidence varies by site type:
| Site type | What counts as a named individual |
|---|
agency, saas, localBusiness, nonprofit | Founders, leadership, employees, key staff with names and titles |
mediaBlog | Bylined authors, editorial leadership (editor-in-chief, contributing editors) |
ecommerce | Founders, designers, makers, key team members — common on brand-led shops, often absent on dropshippers |
education | Faculty, instructors, course leads with credentials |
community | Moderators, leadership, named maintainers |
const teamSections = Array.from(document.querySelectorAll('[class*="team"], [id*="team"], [class*="about"], [id*="about"], [class*="author"], [class*="staff"], [class*="faculty"], [class*="contributor"]'));
const personJsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]))
.filter(s => s['@type'] === 'Person');
const personMicrodata = Array.from(document.querySelectorAll('[itemscope][itemtype*="schema.org/Person"]'));
const personRdfa = Array.from(document.querySelectorAll('[typeof~="Person"], [typeof~="schema:Person"]'));
const aboutLink = Array.from(document.querySelectorAll('a[href]')).find(a => {
const text = a.innerText.toLowerCase();
const href = a.href.toLowerCase();
return /\b(about|team|authors|staff|faculty|masthead|people)\b/.test(text) || /\/(about|team|authors|staff|faculty|masthead|people)\//.test(href);
});
return {
teamSectionCount: teamSections.length,
personSchemaCount: personJsonLd.length,
personMicrodataCount: personMicrodata.length,
personRdfaCount: personRdfa.length,
aboutLinkHref: aboutLink ? aboutLink.href : null
};
Person can appear in JSON-LD, microdata, or RDFa — all three count toward the "named team" signal. JSON-LD is preferred for AI parsing reliability; if Person is microdata-only or RDFa-only, note that in the finding ("Person markup present in microdata only — JSON-LD is preferred for AI parsing.").
If an About/Team/Authors page is linked, visit it in Phase 2 to confirm named individuals are present.
Evaluation:
pass — Named individuals with type-appropriate roles visible (homepage or About/Team/Authors page).
partial — Site is referenced through anonymous language only ("our team", "our editors", "the staff") — no names anywhere accessible from the homepage.
fail — No team / author / staff signals at all.
Credentialing badges — certificationBadges
The check is "are there visible third-party credentials backing this site?" The evidence varies by site type:
| Site type | What counts as a credentialing badge |
|---|
agency, saas | Partner badges (Google Partner, WooCommerce Expert, AWS Partner), certifications, ISO badges |
mediaBlog | Press affiliations, fact-checking certifications (IFCN), journalism awards, professional press credentials |
ecommerce, localBusiness | BBB rating, Trustpilot widget, payment-processor trust marks, industry trade group memberships |
education | Accreditation badges (regional accreditor, programmatic accreditation), recognized certifications |
nonprofit | Charity Navigator, GuideStar Seal, BBB Wise Giving, 501(c)(3) verification badges |
community | Verified-organization marks from the platform host, hosting-provider trust marks |
const badgeIndicators = Array.from(document.querySelectorAll('[class*="cert"], [class*="badge"], [class*="partner"], [class*="award"], [class*="accredit"], [class*="trust"], [class*="verified"], [class*="rated"], [alt*="award"], [alt*="certified"], [alt*="accredited"], [alt*="rated"]'));
const trustWidgets = !!document.querySelector('iframe[src*="trustpilot"], iframe[src*="bbb.org"], [class*="trustpilot"], [class*="bbb"], [class*="guidestar"], [class*="charitynavigator"]');
return { count: badgeIndicators.length, hasTrustWidget: trustWidgets };
DOM presence alone is not sufficient — confirm visually via the homepage screenshot that the badges are legible (the failure mode is small-text or visually-unrecognizable images that match the selectors but aren't real credentials).
Evaluation:
pass — Credentialing badges present AND visually confirmed as legible/recognizable, matched to the site type.
partial — DOM indicators found but visual confirmation unclear, or only weak credentials (e.g. SSL-vendor badges).
fail — Absent.
Tenure indicators — tenureIndicators
The check is "does the site signal how long it has been operating?" — a recognized AI-trust signal across all types. The phrasing varies:
| Site type | Common tenure phrasings |
|---|
agency, saas, ecommerce | "Founded in YYYY", "established YYYY", "since YYYY", "N years in business" |
mediaBlog | "Publishing since YYYY", "covering [topic] since YYYY", "founded YYYY" |
education | "Founded YYYY", "serving students since YYYY" |
nonprofit | "Founded YYYY", "serving the community for N years" |
community | "Active since YYYY", "the longest-running [topic] community" |
const bodyText = document.body.innerText;
const yearPatterns = [
/founded\s+(?:in\s+)?(\d{4})/i,
/established\s+(?:in\s+)?(\d{4})/i,
/since\s+(\d{4})/i,
/(\d+)\s+years?\s+(?:of\s+)?(?:experience|in\s+business|publishing|operation|serving|active)/i,
/publishing\s+since\s+(\d{4})/i,
/covering\s+\w+\s+since\s+(\d{4})/i,
/serving\s+(?:the\s+)?\w+(?:\s+\w+)?\s+since\s+(\d{4})/i
];
const matches = yearPatterns.map(p => bodyText.match(p)).filter(Boolean);
return { found: matches.length > 0, matches: matches.map(m => m[0]).slice(0,2) };
Evaluation:
pass — Any tenure indicator is found.
fail — Absent.
Author bylines linked to Person schema — authorBylines
This signal applies primarily to mediaBlog, education, nonprofit, and any other site that publishes articles or posts. For sites with no editorial content (e.g. pure ecommerce, single-page SaaS), record na by default and note in notes as "N/A — site has no editorial articles to byline."
Visit at least one editorial / blog / news / post page during Phase 2 and check:
const articleSelector = 'article, [class*="article"], [class*="post-"]';
const article = document.querySelector(articleSelector);
const visibleByline = article && article.innerText.match(/\bby\s+([A-Z][a-z]+\s+[A-Z][a-z]+)/);
const authorLink = article && article.querySelector('a[href*="/author/"], a[rel="author"]');
const articleJsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]))
.filter(s => ['Article','BlogPosting','NewsArticle'].includes(s['@type']));
const authorOnJsonLd = articleJsonLd.some(s => s.author && (typeof s.author === 'object' || Array.isArray(s.author)));
const articleMicrodata = Array.from(document.querySelectorAll(
'[itemscope][itemtype*="schema.org/Article"], [itemscope][itemtype*="schema.org/BlogPosting"], [itemscope][itemtype*="schema.org/NewsArticle"]'
));
const authorOnMicrodata = articleMicrodata.some(el => el.querySelector('[itemprop="author"]'));
const articleRdfa = Array.from(document.querySelectorAll(
'[typeof~="Article"], [typeof~="BlogPosting"], [typeof~="NewsArticle"], [typeof~="schema:Article"], [typeof~="schema:BlogPosting"], [typeof~="schema:NewsArticle"]'
));
const authorOnRdfa = articleRdfa.some(el => el.querySelector('[property="author"], [property="schema:author"]'));
return {
hasVisibleByline: !!visibleByline,
authorName: visibleByline ? visibleByline[1] : null,
hasAuthorLink: !!authorLink,
authorLinkHref: authorLink ? authorLink.href : null,
hasArticleJsonLd: articleJsonLd.length > 0,
hasArticleMicrodata: articleMicrodata.length > 0,
hasArticleRdfa: articleRdfa.length > 0,
hasAuthorOnSchema: authorOnJsonLd || authorOnMicrodata || authorOnRdfa
};
Evaluation:
pass — Visible byline AND author name linked to an author page AND article schema (any format) has a populated author property.
partial — Visible byline AND linked author page, but no author on any schema format, OR author present in microdata/RDFa only (JSON-LD preferred for AI parsing).
fail — Anonymous publication: no byline, or byline with no link, or no Article/BlogPosting schema in any format.
1.6 Content Freshness — Programmatic checks (5 of 7 signals here)
Copyright year
const footerText = document.querySelector('footer') ? document.querySelector('footer').innerText : document.body.innerText;
const yearMatch = footerText.match(/[©℗]\s*(\d{4})/);
const currentYear = new Date().getFullYear();
return {
found: !!yearMatch,
year: yearMatch ? parseInt(yearMatch[1]) : null,
current: yearMatch ? parseInt(yearMatch[1]) === currentYear : false
};
Evaluation:
pass — Copyright year matches current year.
partial — Copyright year is one year behind.
fail — Two or more years stale, or absent entirely.
Blog or news section detection — visit blog page in Phase 2
const blogIndicators = Array.from(document.querySelectorAll('[class*="blog"], [class*="news"], [class*="post"], [class*="article"], [id*="blog"], [id*="news"]'));
const datePattern = /\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?\s+\d{1,2},?\s+20\d{2}|\b20\d{2}[-\/](0[1-9]|1[0-2])[-\/](0[1-9]|[12]\d|3[01])\b/gi;
const dates = (document.body.innerText.match(datePattern) || []);
return { blogSectionFound: blogIndicators.length > 0, datesFound: dates.slice(0,5) };
Confirm in Phase 2 by visiting the blog/news listing page. Most-recent post date determines freshness.
Evaluation:
pass — Blog/news section present with content dated within last 6 months.
partial — Section present but content older than 6 months.
fail — No blog/news section found.
Date stamps on posts or case studies
const dateTags = Array.from(document.querySelectorAll('time[datetime], [class*="date"], [class*="published"]'));
return { count: dateTags.length, examples: dateTags.slice(0,3).map(el => el.innerText.trim() || el.getAttribute('datetime')) };
Confirm by visiting at least one blog post in Phase 2 — the post page should display a published date.
Evaluation:
pass — Date stamps visible on posts or case studies.
fail — Absent.
Sitemap lastmod dates recent — sitemapLastmodRecent
Cross-reference 1.2 sitemap analysis. Were lastmod dates present and within 90 days?
Evaluation:
pass — Sitemap has lastmod dates within 90 days.
fail — Absent or all stale.
Per-page "Last updated" stamps — contentUpdateRecency
Distinct from dateStampsOnContent (which checks for any visible date on posts). This signal checks whether individual pages — especially evergreen reference pages — carry a visible "Last updated" / "Updated on" / "Revised YYYY" stamp within the last 12 months.
Perplexity cites content updated in the last 30 days at 82%; content updated within the last 12 months earns 3.2× more Perplexity citations. Per-page update stamps signal active maintenance.
Visit one editorial / reference page during Phase 2 and check:
const bodyText = document.body.innerText;
const updatedPattern = /(?:last\s+updated|updated\s+on|revised|last\s+revised|reviewed\s+on)[:\s]+([A-Z][a-z]+\s+\d{1,2},?\s+20\d{2}|\d{1,2}\s+[A-Z][a-z]+\s+20\d{2}|20\d{2}-\d{2}-\d{2})/i;
const modifiedMeta = document.querySelector('meta[property="article:modified_time"], meta[name="last-modified"]');
const jsonLdMod = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]))
.find(s => s.dateModified);
const microdataModEl = document.querySelector('[itemprop="dateModified"]');
const microdataMod = microdataModEl
? (microdataModEl.getAttribute('datetime') || microdataModEl.getAttribute('content') || microdataModEl.innerText.trim())
: null;
const rdfaModEl = document.querySelector('[property="dateModified"], [property="schema:dateModified"]');
const rdfaMod = rdfaModEl
? (rdfaModEl.getAttribute('datetime') || rdfaModEl.getAttribute('content') || rdfaModEl.innerText.trim())
: null;
const match = bodyText.match(updatedPattern);
return {
visibleUpdatedStamp: match ? match[0] : null,
modifiedMetaContent: modifiedMeta ? modifiedMeta.getAttribute('content') : null,
schemaDateModified: (jsonLdMod && jsonLdMod.dateModified) || microdataMod || rdfaMod || null,
schemaDateModifiedFormat: (jsonLdMod && jsonLdMod.dateModified) ? 'json-ld' : microdataMod ? 'microdata' : rdfaMod ? 'rdfa' : null
};
Evaluation:
pass — Visible "Last updated" stamp within the last 12 months on at least one sampled inner page. Article schema dateModified alone also counts if it is within the last 12 months and the page is editorial.
fail — No visible update stamps anywhere, OR stamps present but all >12 months old.
Pure-ecommerce / single-page SaaS exemption: if there is no editorial content to bear "last updated" stamps, record na and note in notes as "N/A — site has no editorial content requiring update stamps."
recentFeaturedWork and currentDomainReferences are evaluated in Phase 3.
1.7 Entity Clarity — Programmatic check (1 of 5 signals here)
Social profile links in footer
const socialDomains = ['linkedin.com', 'twitter.com', 'x.com', 'facebook.com', 'instagram.com', 'youtube.com'];
const links = Array.from(document.querySelectorAll('footer a[href], [class*="social"] a[href]'));
const socialLinks = links.filter(a => socialDomains.some(d => a.href.includes(d)));
return { count: socialLinks.length, platforms: [...new Set(socialLinks.map(a => a.href.split('/')[2]))] };
Evaluation:
pass — LinkedIn and at least one other professional profile linked from footer.
partial — Only one social profile linked.
fail — No social profile links in footer.
entityIdentifiable, primaryFocusSpecificity, geographicMarketClarity, and consistentIdentity are all evaluated in Phase 3.
1.8 llms.txt
browser_navigate to [baseURL]/llms.txt. Record whether it loads (status 200 vs 404) and capture body text. Then browser_navigate to [baseURL]/llms-full.txt and record presence. Return to homepage.
return { found: document.body.innerText.trim().length > 10, content: document.body.innerText.substring(0, 1000) };
Evaluation:
llmsTxtPresent — pass if present and non-empty. fail if absent.
llmsFullTxtPresent — pass if present. fail if absent.
llmsTxtContent — evaluated in Phase 3 based on accuracy and specificity.
SECTION 2: Multi-Page Evidence Collection
The homepage is the primary evaluation target, but several signals require inner-page confirmation. Visit at least 3 additional pages beyond the homepage. Record every URL visited in visitedPages.
Pages to visit (in priority order, by site type)
Always start with the homepage (already visited in Phase 0/1). Then pick inner pages based on the detected siteType:
Universal must-visit pages (all site types):
| # | Page type | Purpose |
|---|
| 1 | About / Team / Authors / Masthead / People | Confirm namedTeamMembers, authorCredentials, sample canonicalUrls |
| 2 | One primary-offering page (varies by site type, see below) | Confirm primaryEntitySchema, primaryOfferingDetail, namedSpecificEntities, primaryFocusSpecificity |
| 3 | A recent editorial or featured-content page (varies by site type, see below) | Confirm dateStampsOnContent, contentUpdateRecency, authorBylines, blogNewsRecent |
Site-type-specific page targets:
| Site type | Primary-offering page | Featured-content page | Type-specific functional page |
|---|
agency | A service / solution page | A case study or recent blog post | — |
ecommerce | A product detail page (PDP) | The /new or /collections page or a recent product launch | — |
mediaBlog | A category / section index for the dominant vertical | A recent article from the last 30 days | /subscribe or /newsletter (FAQ schema check) |
saas | A feature or pricing page | A changelog / release notes / recent blog post | — |
education | A course or program page | A recent news / announcements page or instructor page | — |
localBusiness | A services / menu / offerings page | A recent updates / news / events page if present | — |
nonprofit | A program / cause / how-we-help page | A recent campaign / impact-report / news page | /donate or /give (FAQ schema check) |
community | A "what is this" / rules / wiki page | A recently-active discussion / featured contribution | /join, /membership, or /become-a-member (FAQ schema check) |
Optional 4th visit: if a dedicated FAQ page exists at /faq, /faqs, /help, or /support, visit it to confirm faqSectionPresent and faqSchemaApplied.
Mediablog-specific 5th visit — archive page quality check. For mediaBlog sites, visit at least one category or tag archive page (/category/<slug>/, /tag/<slug>/, /topics/<slug>/, or whatever the routing convention is). Run the archivePageQuality check from Section 2.1.
If a page type doesn't exist (e.g. ecommerce site with no editorial content), record the absence — it may change the status of the related signal, or trigger the type-specific exemption rule.
2.1 Type-specific functional page checks
These checks feed into existing evaluated signals rather than introducing new ones — they sharpen the evidence Phase 1 collected by checking the pages where the post's recommendations land most concretely.
FAQ schema on functional pages — nonprofit, mediaBlog, community
Visit the type-specific functional page and re-run the FAQ check:
const jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(s => { try { return JSON.parse(s.innerText); } catch(e) { return null; }})
.filter(Boolean)
.flatMap(s => Array.isArray(s) ? s : (s['@graph'] || [s]));
const hasFaqSchema = jsonLd.some(s => s['@type'] === 'FAQPage') ||
!!document.querySelector('[itemscope][itemtype*="schema.org/FAQPage"]');
const visibleFaq = !!document.querySelector('[class*="faq"], [id*="faq"]') ||
Array.from(document.querySelectorAll('h2, h3')).filter(h => h.innerText.trim().endsWith('?')).length >= 2 ||
document.querySelectorAll('details').length >= 2;
return { hasFaqSchema, visibleFaq, url: window.location.href };
The post's argument: donation, subscribe, and join/membership pages are exactly the pages where AI tools land users with concrete intent-bearing queries ("how do I donate to X?", "how do I subscribe to Y?", "what does a Z membership include?"). FAQ schema on these specific pages is high-leverage.
How the result feeds into evaluation:
- Visible FAQ content present but no FAQ schema on the functional page → drops
faqSchemaApplied to partial and creates a medium-severity issue with effort low and a paste-ready Claude prompt for the FAQ schema.
- Both visible FAQ and FAQ schema present → confirms
faqSchemaApplied at full credit.
- No FAQ content visible at all on the functional page → not penalized (the page may genuinely not need FAQ format), but record in
notes as a missed AEO opportunity since these pages are high-intent.
archivePageQuality check — mediaBlog only
Per the post: "Category and tag pages need descriptive text — a list of posts alone gives AI nothing to work with." WordPress archive pages typically emit an empty <meta name="description"> and zero copy beyond the post list.
This check does not introduce a new evaluated signal. It feeds findings into two existing signals:
passageExtractionQuality (Content Specificity) — an archive page with no descriptive text fails the "could a clean 1–2 sentence summary be pulled from each major section" test, since the archive page IS a major section and yields nothing extractable.
primaryFocusSpecificity (Entity Clarity) — an archive page with no topic-area description weakens the site's overall focus signal for the dominant vertical.
Programmatic check:
const h1 = document.querySelector('h1') ? document.querySelector('h1').innerText.trim() : '';
const metaDesc = document.querySelector('meta[name="description"]') ?
document.querySelector('meta[name="description"]').getAttribute('content').trim() : '';
const descCandidates = Array.from(document.querySelectorAll(
'header p, .archive-description, .term-description, .category-description, .tag-description, [class*="archive-intro"], [class*="archive-summary"]'
));
const descText = descCandidates.map(el => el.innerText.trim()).filter(t => t.length > 50).join(' ');
const postListEls = document.querySelectorAll('article, [class*="post-"], [class*="card"]');
const bodyTextLen = document.body.innerText.length;
const descRatio = bodyTextLen > 0 ? (descText.length / bodyTextLen) : 0;
return {
h1,
metaDesc,
metaDescLength: metaDesc.length,
descriptiveCopyLength: descText.length,
descriptiveCopySample: descText.slice(0, 200),
postListCount: postListEls.length,
descriptiveRatio: Math.round(descRatio * 100) / 100,
qualityGrade:
descText.length >= 200 && metaDesc.length >= 80 ? 'good' :
descText.length >= 80 || metaDesc.length >= 50 ? 'partial' :
'empty'
};
Grading:
good — 200+ chars of descriptive copy above the post list AND a meta description of 80+ chars. Counts toward passageExtractionQuality passing for this section and primaryFocusSpecificity for the dominant vertical.
partial — Some descriptive copy OR meta description but not both at full quality. Half credit toward the above; record the gap.
empty — No descriptive copy beyond the H1 (often just "Category: Climate") and either no meta description or theme-default boilerplate. Drops passageExtractionQuality and weakens primaryFocusSpecificity; create a medium-severity issue with effort low and a Claude prompt to write a 2–3 sentence topic description for the archive.
If the site has no archive pages (single-page sites, ecommerce with no editorial section), skip this check and record "N/A — site has no archive pages."
Per-inner-page checks
For each inner page:
browser_navigate to the URL
browser_evaluate to inspect schema and meta tags (a site with a SEO plugin like Yoast / RankMath / SEOPress typically emits schema and meta on every page):
const jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]')).map(s => {
try { return JSON.parse(s.innerText); } catch(e) { return null; }
}).filter(Boolean);
const personOnPage = jsonLd.some(s => s['@type'] === 'Person' || (Array.isArray(s['@graph']) && s['@graph'].some(g => g['@type'] === 'Person')));
const articleOnPage = jsonLd.some(s => s['@type'] === 'Article' || s['@type'] === 'BlogPosting');
return { jsonLdCount: jsonLd.length, personOnPage, articleOnPage };
- For About / Team pages, run a quick named-person check:
const headings = Array.from(document.querySelectorAll('h2, h3, h4')).map(h => h.innerText.trim());
const possibleNames = headings.filter(t => /^[A-Z][a-z]+\s+[A-Z][a-z]+/.test(t));
return { headingsCount: headings.length, namedPeople: possibleNames.slice(0, 10) };
- For blog / news listings, extract dates from the most recent posts:
const timeEls = Array.from(document.querySelectorAll('time[datetime]'));
const datedItems = timeEls.map(t => ({ datetime: t.getAttribute('datetime'), text: t.innerText.trim() })).slice(0, 10);
return { datedItems };
- Canonical URL check — every inner page should have
<link rel="canonical"> pointing to itself:
const c = document.querySelector('link[rel="canonical"]');
return {
href: c ? c.getAttribute('href') : null,
selfReferential: c && new URL(c.getAttribute('href'), location.href).pathname === window.location.pathname
};
Record per-page results to feed the canonicalUrls Phase 1 signal.
-
Per-page update recency — on at least one editorial / reference page, run the contentUpdateRecency check from Section 1.6 to capture visible "Last updated" stamps and article:modified_time meta.
-
Author byline check — on at least one article/post/blog page, run the authorBylines check from Section 1.5.
-
Record all findings against the relevant signals.
CMS detection (run once on the homepage)
const generator = document.querySelector('meta[name="generator"]');
const yoastBlock = Array.from(document.querySelectorAll('script[type="application/ld+json"]')).some(s => s.innerText.includes('"yoast"') || s.innerText.includes('Yoast'));
const yoastClass = !!document.querySelector('[class*="yoast"]');
const rankMath = !!document.querySelector('meta[name="generator"][content*="Rank Math"]') || document.body.outerHTML.includes('rankmath');
const seopress = document.body.outerHTML.includes('seopress');
const wpContent = !!document.querySelector('link[href*="/wp-content/"]') || !!document.querySelector('script[src*="/wp-content/"]');
const wpJsonApi = document.body.outerHTML.includes('/wp-json/');
return {
generator: generator ? generator.getAttribute('content') : null,
yoast: yoastBlock || yoastClass,
rankMath,
seopress,
isWordPress: wpContent || wpJsonApi || (generator && /WordPress/i.test(generator.getAttribute('content') || ''))
};
Record the result in technicalNotes.cmsDetected (e.g. "WordPress + Yoast", "WordPress + RankMath", "WordPress (no SEO plugin)", "Headless / unknown"). This drives the effort ratings in Phase 5.
SECTION 3: Content AI Analysis
Extract homepage visible text:
return document.body.innerText;
Also extract:
- All heading texts:
Array.from(document.querySelectorAll('h1,h2,h3,h4')).map(h => ({ tag: h.tagName, text: h.innerText.trim() }))
- First paragraph after each H2 (for direct-answer assessment)
- Footer text
Use this content to evaluate the remaining signals.
3.1 AEO Readiness — Content (4 signals)
Direct answers after headings — directAnswers
For each H2/H3, read the paragraph immediately following it. Count what fraction of those headings are followed by a direct first-sentence answer (not a preamble or build-up).
Evaluation:
pass — 70%+ of headings answered directly.
partial — 10–69% of headings answered directly.
fail — Less than 10%, OR content not structured in heading/answer format (no H2s/H3s).
Who / what / who content — whoWhatWho
The question is site-type-agnostic but the framing differs:
| Site type | Three identifiable elements |
|---|
agency, saas | Who is the business / what do they make or do / who do they serve |
mediaBlog | Who publishes this / what topics are covered / who is the intended reader |
ecommerce | Who is the brand / what category of products / who buys them |
education | Who is the institution / what is taught / who is the student |
localBusiness | Who is the business / what services / what local market |
nonprofit | Who is the organization / what is the mission / who is served |
community | What is this community / what is the focus / who participates |