com um clique
aeo
AEO / AI Mode QA — evaluates how AI tools discover, parse, and cite a website
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Menu
AEO / AI Mode QA — evaluates how AI tools discover, parse, and cite a website
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Baseado na classificação ocupacional SOC
Accessibility-focused QA testing against WCAG 2.2 Level AA standards
Functional and design-focused QA testing across multiple pages and viewports
Performance-focused QA testing for load times, console errors, and network health
| 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.
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.
aeoRubricVersion: 1.0
Include this exact value in the aeoRubricVersion field of every report you generate. Do not modify it.
browser_navigate and browser_evaluate to inspect DOM, schema, and meta tags on each pagebrowser_navigate to fetch robots.txt, sitemap.xml, llms.txt, and llms-full.txt as separate top-level requestspass / partial / fail / na. Do not skip signals or leave status blankfail or partialeffort field on every issue object. Valid values: low, medium, high, unknown. Do not omit itreports/data/qa-report-aeo.json<title>, og:site_name, or the WordPress site title in the headerIf you cannot perform these actions, explicitly state that the Playwright MCP is not available and cannot proceed.
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.
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:
issues (see "Issue severity guide" in Phase 5)actionablePrompts with a paste-ready Claude promptThe 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 definitionsskills/aeo/references/signal-keys.md — canonical signal keys for the JSON reportUnderlying frameworks the rubric draws on:
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).
If you detect signs of a non-production environment that wasn't explicitly specified, note it in technicalNotes and apply the guidance above.
robots.txt, sitemap.xml, llms.txt, and llms-full.txt as separate top-level navigationsbrowser_evaluate for schema / meta / heading inspection on the homepagereports/screenshots/homepage-aeo-desktop.pngvisitedPages arrayIf you skip any of these steps, the test is incomplete and will not be accepted.
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:
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.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.
reports/data/qa-report-aeo.json matching schemas/qa-report-aeo-schema.jsonThe 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.
browser_navigate to the homepage, then browser_evaluate:
// JSON-LD types
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]));
// Flatten array `@type` values (e.g. Yoast's `@type: ['Person','Organization']`)
// so the resulting Set contains strings, not nested arrays — matches Phase 0.4.
const jsonLdTypes = new Set(jsonLd.map(s => s['@type']).filter(Boolean).flat());
// Microdata types (treat as equivalent format-of-the-same-truth)
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)
);
// RDFa types (rare but real — typeof attribute under vocab="http://schema.org/")
const rdfaTypes = new Set(
Array.from(document.querySelectorAll('[typeof]'))
.filter(el => {
// Only count if a vocab/prefix indicates schema.org
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)
);
// Union of all three formats — the rest of the decision tree should work off this set
const schemaTypes = [...new Set([...jsonLdTypes, ...microdataTypes, ...rdfaTypes])];
// Per-format counts for the technicalNotes payload
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, // union across JSON-LD + microdata + RDFa
schemaFormats, // per-format counts for technicalNotes
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.
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.
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.
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).
// Run on the homepage. Most relevance scans are pattern-based; ambiguous cases
// (e.g. "is this primarily editorial or primarily conference site") use
// content judgment in Phase 3 — record an initial relevance here and revisit.
const text = document.body.innerText;
const lower = text.toLowerCase();
// Person — bylined posts, named single-person bio, homepage profile pattern
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';
// Article / BlogPosting — listing of 3+ dated posts, article-like content
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';
// Organization — always relevant; the question is whether it's populated correctly
const organizationRelevance = 'high';
// LocalBusiness — visible address, hours, booking copy
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';
// Event — dated event listings with venue/registration
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';
// FAQ — Q&A patterns (also fed by existing faqSectionPresent in 1.4)
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';
// HowTo — numbered step sequence with a stated goal
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';
// Product — cart/checkout markers, product cards
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';
// Course — curriculum / syllabus / enrollment
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';
// Recipe — ingredients + instructions pattern
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';
// Review / AggregateRating — review content visible
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:
Organizationis alwayshigh, but it's evaluated under its own standalone signal (organizationSchema) — not double-counted inprimaryEntitySchemaorrelevantSchemasApplied. Similarly,FAQPageandReviewhave their own standalone signals and aren't double-counted.
browser_resize if neededbrowser_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.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 retrievalChatGPT-User — OpenAI ChatGPT user-initiated browsingClaude-User — Anthropic Claude user-initiated retrievalClaude-SearchBot — Anthropic Claude search retrievalPerplexityBot — Perplexity retrieval and citationYouBot — You.com retrievalGooglebot — Google Search and AI Overviews crawlerTraining scrapers — blocking is acceptable (and increasingly common):
GPTBot — OpenAI training scraperClaudeBot — 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 tokenCCBot — Common CrawlMeta-ExternalAgent, FacebookBot — Meta trainingEvaluation:
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 innotesas"Training scrapers blocked, retrieval bots allowed — modern 2026 posture."Environment note: on
local/development, treat any robots.txt config as neutral — recordpasswith a note about the environment. Onstaging, a blanket Disallow is expected and not a finding — recordpass.
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
localordevelopment, noindex is correct and not a finding. Record status aspasswith a note about the environment intechnicalNotes. Onstaging, noindex is expected — recordpassbut flag as a reminder if the staging URL was provided for AEO review.
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.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.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.
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.
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.
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.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:
// Use the relevance map from Section 0.4 to pick the expected primary type.
// PRIMARY_CANDIDATES is the type-defining schema list (excludes Organization/FAQPage/Review).
const PRIMARY_CANDIDATES = ['Person','Article','BlogPosting','NewsArticle','Product','Event','HowTo','LocalBusiness','Course','Recipe','SoftwareApplication'];
const relevance = /* applicableSchemas from 0.4 */;
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
highrelevance (e.g. a pure portfolio site with no Articles, Events, Products, or HowTos), Organization schema becomes the de facto primary entity for evaluation purposes. Recordpassif Organization is well-formed; otherwise evaluate against the highest-relevance candidate even if it'smedium. Record the determination innotes.Edge case — ecommerce: Product schema typically lives on PDPs, not the homepage. If
Productis the expected primary type, sample at least one product page during Phase 2 and evaluate the combined evidence — homepageStore/OfferCatalogplus PDP Product schema together can satisfypass.
relevantSchemasAppliedThe 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.
// Build the schema presence map across all three formats
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]);
// Compare against the relevance map from 0.4
const relevance = /* applicableSchemas from 0.4 */;
const inScope = Object.entries(relevance).filter(([k, v]) => v === 'high' || v === 'medium');
const matched = inScope.filter(([k, v]) => allPresentTypes.has(k) ||
// BlogPosting and NewsArticle satisfy Article relevance; Article satisfies BlogPosting relevance
(k === 'Article' && (allPresentTypes.has('BlogPosting') || allPresentTypes.has('NewsArticle'))) ||
// Specific LocalBusiness subtypes satisfy LocalBusiness relevance
(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.4found no schemas withhighormediumrelevance (rare — Organization is always at leasthigh, so this should never be empty), recordnaand note innotesas"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 inactionablePromptswith a paste-ready Claude prompt to generate the missing 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.jsonLdFormatThe 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;
// RDFa: count elements with `typeof` inside a `vocab="http://schema.org/"` or compatible prefix scope
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:
"Zero JSON-LD blocks; [N] microdata itemscope elements present — JSON-LD is preferred for reliable AI parsing.""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."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.
openGraphTagsconst 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.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.faqSectionPresent, faqSchemaApplied, questionFramedHeadings, and titleAndMetaQuestionMatch are programmatic and evaluated below. directAnswers, whoWhatWho, featuredSnippetStructure, and answerCapsules are content checks evaluated in Phase 3.
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.faqSchemaAppliedCross-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.
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.titleAndMetaQuestionMatchFor 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() : '';
// A page is "question-targeting" if its H1 ends with "?", or contains "how", "what",
// "why", "when", "where", "who" as the first word, or matches the dominant
// question pattern in the H2/H3 set
const h1IsQuestion = /\?$/.test(h1) || /^(how|what|why|when|where|who|can|does|is|are|should|do)\s+/i.test(h1);
// Or the page is question-targeting by purpose: services pages, FAQ pages,
// product detail pages, donation pages, etc.
const pathSignalsQuestion = /\/(faq|services?|pricing|how-it-works|about|donate|subscribe|join|tutorials?|guides?)/.test(window.location.pathname);
const isQuestionTargeting = h1IsQuestion || pathSignalsQuestion;
// Signal extraction — does title or meta description contain question phrasing
// or align with the H1's question/topic?
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);
// Token overlap between title/meta and H1 (a rough proxy for alignment when the
// page isn't framed as a literal question — e.g. a services page titled
// "Web Development Services for B2B SaaS" aligning with H1 "What we build")
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
naand note innotesas"N/A — no question-targeting pages observed."Record the per-page results in
notesso 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) —effortis almost alwayslow.
directAnswers, whoWhatWho, featuredSnippetStructure, and answerCapsules are evaluated in Phase 3 (content).
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).
namedTeamMembersThe 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"]'));
// Person in JSON-LD
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');
// Person in microdata
const personMicrodata = Array.from(document.querySelectorAll('[itemscope][itemtype*="schema.org/Person"]'));
// Person in RDFa
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.certificationBadgesThe 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.tenureIndicatorsThe 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.authorBylinesThis 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"]');
// Article in JSON-LD
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)));
// Article in microdata
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"]'));
// Article in RDFa
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.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.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.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.sitemapLastmodRecentCross-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.contentUpdateRecencyDistinct 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"]');
// dateModified in JSON-LD
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);
// dateModified in microdata — <meta itemprop="dateModified" content="..."> or <time itemprop="dateModified" datetime="...">
const microdataModEl = document.querySelector('[itemprop="dateModified"]');
const microdataMod = microdataModEl
? (microdataModEl.getAttribute('datetime') || microdataModEl.getAttribute('content') || microdataModEl.innerText.trim())
: null;
// dateModified in RDFa
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
naand note innotesas"N/A — site has no editorial content requiring update stamps."
recentFeaturedWork and currentDomainReferences are evaluated in Phase 3.
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.
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.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.
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.
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.
nonprofit, mediaBlog, communityVisit 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:
faqSchemaApplied to partial and creates a medium-severity issue with effort low and a paste-ready Claude prompt for the FAQ schema.faqSchemaApplied at full credit.notes as a missed AEO opportunity since these pages are high-intent.archivePageQuality check — mediaBlog onlyPer 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() : '';
// Look for descriptive copy NOT inside the post list — typically in
// <header>, <.archive-description>, <.term-description>, or the first
// .entry-content / .page-content block before the list
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(' ');
// Post list density check — how much of the page is post cards vs descriptive copy
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."
For each inner page:
browser_navigate to the URLbrowser_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 };
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)); // crude "First Last" pattern
return { headingsCount: headings.length, namedPeople: possibleNames.slice(0, 10) };
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 };
<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.
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.
Extract homepage visible text:
return document.body.innerText;
Also extract:
Array.from(document.querySelectorAll('h1,h2,h3,h4')).map(h => ({ tag: h.tagName, text: h.innerText.trim() }))Use this content to evaluate the remaining signals.
directAnswersFor 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).whoWhatWhoThe 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 |