Check A7 compares the visible text in your raw HTML with the visible text in the rendered page and scores the ratio. The two fetches explains why that gap matters and gives a one-minute version with curl and a browser console. This post is the exact version: a short Node script that reproduces the scanner's number, so you can run it against every route that matters rather than the homepage alone, and read the result per route before deciding what, if anything, to change.
The scanner measures A7 on the homepage only, because that is the page the free crawl renders. An agent does not stop at the homepage. The pricing page, the product page, the docs and the contact page are where it goes next, and they are often rendered differently from the homepage, because they were built at different times by different people. Measuring them yourself is the only way to find out.
What the engine does, exactly
Both sides of the ratio are defined in code, and the definitions are short enough to reproduce.
The raw side is the body of a plain GET to the URL, parsed with an HTML parser rather than a regex. The parser removes every script, style, noscript, template and svg element, takes the text of what remains, and collapses each run of whitespace to a single space. The length of that string is rawTextLength.
The rendered side comes from a headless Chromium session driven by Playwright. The page is loaded with waitUntil: "networkidle", which means the browser waits until no network request has been in flight for a short interval, with a 30 second ceiling. The engine then reads innerText of body, collapses whitespace the same way, and the length is renderedTextLength.
The ratio is raw divided by rendered, capped at 1. If the rendered text is empty the ratio is 1, so a blank page is not punished twice. At or above 0.8 the check scores its full five points; at or above 0.5 it scores three; below that, zero. It is rated High effort. The report shows all three values as rawTextLength, renderedTextLength and ratio, the last rounded to three decimal places.
One asymmetry is worth understanding before you run anything. The raw side is a parser reading text nodes; it knows nothing about CSS, so text inside an element styled display: none counts. The rendered side is innerText, which is defined in terms of rendering and omits hidden elements, as MDN describes. A page can therefore have more raw text than rendered text, which is why the ratio is capped rather than allowed to exceed 1.
The script
Two dependencies, the same two the engine uses.
mkdir parity && cd parity && npm init -y >/dev/null
npm install node-html-parser playwright
npx playwright install chromium
Save the following as parity.mjs.
import { parse } from "node-html-parser";
import { chromium } from "playwright";
// The user agent our crawler sends for the raw fetch. Swap in a crawler
// string such as GPTBot's if your edge treats user agents differently.
const UA = "AgentFriendlyRankBot/1.0 (+https://agentfriendlyrank.com/bot)";
const STRIP = ["script", "style", "noscript", "template", "svg"];
function rawVisibleText(html) {
const doc = parse(html, {
comment: false,
blockTextElements: { script: true, style: true, noscript: true },
});
for (const tag of STRIP) {
for (const el of doc.querySelectorAll(tag)) el.remove();
}
return doc.structuredText.replace(/\s+/g, " ").trim();
}
async function rawLength(url) {
const res = await fetch(url, { headers: { "User-Agent": UA }, redirect: "follow" });
return { status: res.status, length: rawVisibleText(await res.text()).length };
}
async function renderedLength(browser, url) {
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
return (await page.innerText("body")).replace(/\s+/g, " ").trim().length;
} finally {
await page.close();
}
}
const urls = process.argv.slice(2);
const browser = await chromium.launch();
try {
console.log("route\tstatus\traw\trendered\tratio\tpoints");
for (const url of urls) {
const raw = await rawLength(url);
const rendered = await renderedLength(browser, url);
const ratio = rendered === 0 ? 1 : Math.min(raw.length / rendered, 1);
const points = ratio >= 0.8 ? 5 : ratio >= 0.5 ? 3 : 0;
console.log(
[new URL(url).pathname, raw.status, raw.length, rendered, ratio.toFixed(3), points].join("\t"),
);
}
} finally {
await browser.close();
}
The stripping rules, the whitespace collapse, the networkidle wait, the cap and the thresholds are the engine's own. Run it against your homepage and the result should match the ratio on your scan report, give or take the noise of a page that changes between requests.
Run it against the routes that matter
Pass several URLs. Choose the ones an agent will actually fetch, which are also the ones our crawler prefers when it picks pages beyond the homepage: pricing or plans, product or collection pages, documentation, about, contact, and for a shop the cart.
node parity.mjs \
https://example.com/ \
https://example.com/pricing \
https://example.com/products/example-widget \
https://example.com/docs \
https://example.com/contact
Output is tab-separated, one row per route, so it pastes into a spreadsheet. An illustrative run against a site with a server-rendered homepage and a client-rendered product catalogue would look like this:
route status raw rendered ratio points
/ 200 4812 5104 0.943 5
/pricing 200 2210 2388 0.925 5
/products/example-widget 200 318 3960 0.080 0
/docs 200 6720 6810 0.987 5
/contact 200 940 1102 0.853 5
The scanner would give that site full marks on A7. An agent asked to compare its products would find nothing to compare.
Two practical notes. If a route never reaches networkidle, because a widget polls or an analytics beacon fires on a timer, the render times out after 30 seconds and the script throws; replace networkidle with load for that route and accept that a late-loading component may be missed. And read the status column: a 200 that carries a challenge page rather than your content produces a high ratio for the wrong reason, because the challenge itself is server-rendered. CAPTCHA challenges and agents covers how to recognise one.
Reading the ratio per route
The ratio describes a rendering mode, and the same number on two routes usually means the same architecture underneath. What follows are mechanisms, not statistics.
Near 1.0. The route is server-rendered or built statically, and whatever JavaScript runs afterwards attaches behaviour rather than content. A raw column larger than the rendered column points to text the browser hides: a mobile menu and a desktop menu both present in the markup, collapsed accordion panels, or a consent banner that a cookie has already dismissed.
Roughly 0.8 to 0.95. Server-rendered with client-only islands. The page is there, but a component fetches its own data after hydration: a reviews block, a stock indicator, a personalised recommendation strip, a chat widget, a cookie banner injected by a tag manager. The check passes. The missing content is still worth checking against what an agent would need, because a price that arrives from a client-side call is invisible to a plain fetch however good the score.
Roughly 0.5 to 0.8. A server-rendered shell with the main content fetched client-side. The header, footer and navigation are in the HTML; the product grid, article body or search results are not. This is common when a static layout wraps a data-driven component, and it scores three points while hiding the part of the page the route exists for.
Below 0.5, often below 0.1. A client-rendered application. The raw HTML is a root element, script tags and a few dozen characters of loading text. Frameworks that render on the client by default produce this on every route, and so do server-rendering frameworks on any route where rendering has been switched off for that page. Everything the page carries, structured data included, is on the far side of the gap.
A raw column that varies between runs while the rendered column does not. The server is deciding per request what to send. Usually it is a cache: the first fetch is rendered in full, later ones are served as a shell with data loaded on the client, or the reverse. Run the script twice with a pause between and compare.
One further pattern is streaming server rendering, where the HTML arrives in chunks with placeholders that later chunks and a small inline script replace. A plain fetch reads the whole stream, so both the placeholder text and the real content end up on the raw side, and the ratio is unaffected or slightly inflated. Google's JavaScript SEO documentation describes the same rendering categories from the search side.
What to do with the table
Sort by points, then by how commercially important the route is. A zero on a product, pricing or docs route is the first thing to fix, whatever the homepage scores. The fix is the one the two fetches post describes, server rendering or prerendering for that route, and the table tells you how many routes and which templates are involved, which is the number any estimate depends on. Our implementation service scopes this work from exactly that table.
Then re-run after every deploy. A rendering mode is a per-route setting in most frameworks, and a route that was server-rendered in one release can quietly stop being so in the next when someone wraps the page in a client-only component. The script takes a few seconds per route; it belongs alongside your other post-deploy checks.
Check your own site
The free scan reports rawTextLength, renderedTextLength and ratio for your homepage under A7, and the crawl procedure, including the user agent and the render step, is published at /bot. A7 is defined in full under discovery and access on the methodology page.