The decision this protects
The decision is whether a page’s visibility problem belongs to the content team, the developer who owns rendering, or the developer who owns animation, because each fix costs about a month and the wrong one costs the month and keeps the problem. A writer asked for more copy on a page that already carries 1,400 words behind opacity:0 wastes the month. A developer asked to add server-side rendering when the raw HTML already contains every word wastes a quarter. The checklist forces three separate counts before anyone proposes a fix, and the verdict names the pattern responsible, so the ticket goes to the person who owns it.
Before you start
- One URL that matters, ideally a service or category page with real copy below the fold. Audit one page before you audit fifty.
- A terminal with curl, Python 3 and either headless Chrome or Playwright (
pip install playwrightthenplaywright install chromium). - Access to the Search Console property, so URL Inspection and its rendered HTML view are available to you.
- A folder for the run. You will save raw.html, rendered.html, two text files and the verdict, and you will want them when the developer asks what you measured.
Stated limits
- It measures words, not meaning. A page can pass every check and still say nothing a buyer needs.
- It measures what Google could have received, not what Google chose to index. Canonicals, crawl budget and quality signals are outside it, as are images, video and structured data.
- Headless Chrome on a laptop stands in for Google’s rendering service. Where the two disagree, the URL Inspection rendered HTML wins.
1. Pass A, raw HTML (checks 1 to 4)
Set the user agent and the URL once, then fetch the page as Googlebot smartphone receives it before any script runs.
UA='Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
URL='https://example.com/service-page'
curl -sL -A "$UA" -o raw.html "$URL"
Save the counter below as words.py. It strips script, style, noscript and template blocks, then tags, then counts words, so every pass is counted the same way.
import html, re, sys
s = open(sys.argv[1], encoding="utf-8", errors="replace").read()
s = re.sub(r"<(script|style|noscript|template)[^>]*>.*?</\1>", " ", s, flags=re.S | re.I)
s = html.unescape(re.sub(r"<[^>]+>", " ", s))
s = re.sub(r"\s+", " ", s).strip()
if "--text" in sys.argv:
print(s)
else:
print(len(re.findall(r"[A-Za-z][A-Za-z'\-]*", s)))
python3 words.py raw.html # check 2
grep -oE '<div id="(root|app|__next|__nuxt)"[^>]*>\s*</div>' raw.html # check 3
grep -c '<noscript' raw.html; grep -c '<template' raw.html; grep -oE '\{\{[^}]{1,40}\}\}' raw.html | sort | uniq -c | sort -rn | head # check 4
| Field | What to record | Why it matters |
|---|---|---|
| 1. Fetch | HTTP status, final URL after redirects, response size in bytes | A redirect or a 2 KB body means the crawler is being served something different from the browser |
| 2. Raw word count | Output of words.py raw.html |
The baseline every later count is compared with |
| 3. App shell | Whether the body is an empty mount point such as <div id="root"></div>, yes or no |
An empty shell means every word depends on JavaScript running to completion |
| 4. Noscript and template tags | Count of noscript blocks, template tags and unrendered {{ }} placeholders, and how many words sit inside noscript |
Copy that lives only inside noscript is a fallback no browser with JavaScript shows, and an unfilled placeholder is copy that never existed for anyone |
Judgment prompt: If check 2 returns under 100 words on a page that shows 1,500 in a browser, every one of those words depends on Pass B, so log the raw count and treat the rendered count as a number Google may or may not receive on a given crawl.
2. Pass B, rendered HTML (checks 5 to 8)
Take the rendered HTML from two places. In Search Console, run URL Inspection on the URL, choose Test live URL, then View tested page, and copy the HTML tab into rendered-gsc.html. Then render it yourself, so the run can be repeated whenever the developer ships a change.
google-chrome --headless=new --disable-gpu --user-agent="$UA" --window-size=412,915 \
--virtual-time-budget=10000 --dump-dom "$URL" > rendered.html
python3 words.py rendered.html # check 6
python3 words.py raw.html --text > raw.txt
python3 words.py rendered.html --text > rendered.txt
diff <(tr ' ' '\n' < raw.txt) <(tr ' ' '\n' < rendered.txt) | grep -c '^>' # check 7, words added by rendering
For check 8, URL Inspection lists page resources that could not be loaded and JavaScript console messages under More info. Record every script that was blocked, timed out or threw, then grep the raw HTML and the site’s bundles for the events that gate the loader.
grep -oE "addEventListener\(\s*['\"](mousemove|touchstart|touchmove|keydown|scroll|wheel|click)['\"]" raw.html *.js | sort | uniq -c | sort -rn
| Field | What to record | Why it matters |
|---|---|---|
| 5. Rendered source | URL Inspection or headless Chrome, with the date and time | The two can disagree, and the client needs to know which one your number came from |
| 6. Rendered word count | Output of words.py rendered.html, and the same for rendered-gsc.html |
Words that appear only after rendering depend on scripts that may not run for Google |
| 7. Raw to rendered delta | Words added and removed by rendering, from the diff | A negative delta means a script is replacing content the raw HTML already carried |
| 8. Scripts that did not run | Script URL, reason (blocked, timed out, error, waiting for an event), and the event | A loader that waits for mousemove or touchstart never fires for a crawler that produces no input |
Judgment prompt: If the rendered count is high and the raw count is low, ask whether the client can afford a crawl on which Google skips the render, because rendering is queued rather than guaranteed.
3. Pass C, viewport-visible at first paint (checks 9 to 12)
This pass counts only the words a user with no hands would see, which is the crawler’s situation. Save the script below as firstpaint.py. It walks every text node, climbs its ancestors, and charges the node’s words to the first hiding rule it finds.
import json, sys
from playwright.sync_api import sync_playwright
UA = ("Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36 "
"(compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
JS = r"""
() => {
const wc = s => (s.match(/[A-Za-z][A-Za-z'\-]*/g) || []).length;
const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let total = 0, hidden = 0; const why = {};
for (let n; (n = walker.nextNode());) {
const words = wc(n.nodeValue);
if (!words || skip.has(n.parentElement.tagName)) continue;
total += words;
let reason = null;
for (let e = n.parentElement; e && e !== document.body && !reason; e = e.parentElement) {
const cs = getComputedStyle(e);
const r = e.getBoundingClientRect();
if (cs.display === 'none') reason = 'display none';
else if (parseFloat(cs.opacity) === 0) reason = 'opacity zero';
else if (cs.visibility === 'hidden') reason = 'visibility hidden';
else if (r.width && (r.right <= 0 || r.left >= innerWidth || r.bottom < 0)) reason = 'transformed off-screen';
}
if (reason) { hidden += words; why[reason] = (why[reason] || 0) + words; }
}
return { total, visible: total - hidden, hidden, why };
}
"""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(user_agent=UA, viewport={"width": 412, "height": 915})
page.goto(sys.argv[1], wait_until="networkidle")
page.wait_for_timeout(3000)
print(json.dumps(page.evaluate(JS), indent=2))
browser.close()
python3 firstpaint.py "$URL" # check 9
grep -oE 'data-aos|class="[^"]*\b(wow|animate__[a-z]+|is-visible|reveal|fade-up|fade-in)\b' rendered.html | sort | uniq -c | sort -rn # check 10
The script never scrolls, never moves the mouse and never presses a key, so anything gated on those stays hidden, exactly as it would for Googlebot. Elements further down the page count as visible, because position in the document is not a hiding rule. Only elements pushed off the sides, above the top, or hidden by a style rule count as hidden.
| Field | What to record | Why it matters |
|---|---|---|
| 9. Hidden words at first paint | The total, visible and hidden figures and the why breakdown from firstpaint.py |
The number the verdict is built on |
| 10. Reveal-library signatures | Count of data-aos, wow, animate__, is-visible and similar hooks, and the sections they wrap |
A library that sets opacity zero in CSS and removes it on scroll hides the page from anything that does not scroll |
| 11. Interaction-gated loaders | Each script from check 8 that waits for mousemove, touchstart, keydown or scroll, and the words that depend on it | Usually added to improve a PageSpeed score, trading a lab metric for the page’s index copy |
| 12. Scroll-loaded content | Sections inserted by an IntersectionObserver or scroll listener with no server-rendered fallback, and their word count | Google documents that its crawler does not scroll, so content that waits for a scroll event is never requested |
Judgment prompt: If the why breakdown puts most hidden words under one reason, resist adding a second cause to the verdict, because the client needs one fix to approve and the second cause can have its own ticket later.
4. The counts and the delta
| Field | What to record | Why it matters |
|---|---|---|
| Raw words | Check 2 | Baseline |
| Rendered words | Check 6 | What Google gets if the render succeeds |
| Visible words at first paint | Check 9, visible |
What Google gets with no input |
| Rendered minus raw | Check 6 minus check 2 | Dependency on JavaScript running |
| Rendered minus visible | Check 6 minus check 9 | Dependency on user input, which Google never gives |
| Share visible | Visible divided by rendered, as a percentage | The figure in the verdict |
Judgment prompt: If share visible is above 90 percent and the page still does not rank, close this audit and open a content or links audit, because rendering is not the reason.
5. The pattern responsible
| Field | What to record | Why it matters |
|---|---|---|
| Pattern | Empty app shell, interaction-gated loader, opacity reveal, visibility reveal, off-screen transform, scroll-loaded section, noscript-only copy or unfilled template | The verdict names one, and the pattern decides who owns the fix |
| Sections affected | Count, and the headings of the first three | Gives the developer a place to start and the client a sense of scale |
| Owner | Person or team that shipped the pattern | The ticket goes to them and nobody else |
| Fix | One sentence in the developer’s terms, such as render visible and animate with transform only, or fire the loader on DOMContentLoaded | The fix must keep the effect the designer wanted, or it will be reverted in the next redesign |
| Re-test date | When Pass C will be run again | A render fix without a re-test is a promise, and this template records measurements |
Judgment prompt: If the owner argues that the page passes PageSpeed, agree, then show the visible figure from check 9, because the two measure different things and both are true.
The defensible output
One line the client can read aloud in a meeting, followed by the six figures from section 4 as a small table. The line always has three parts, the share, the cause and the fix.
Googlebot receives 18 percent of the page's words at first paint; cause: opacity-based reveal on 9 sections; fix: render visible, animate with transform only.
Attach raw.html, rendered.html, the two text files, the firstpaint.py output and the date, so that when the fix ships the same commands produce a second line to sit beside the first.
Where I could be wrong
Google’s rendering service is a version of Chrome with its own timeouts, and a headless run on a laptop is a stand-in for it. Google documents that its crawler does not scroll or click, and every case I have measured agrees, but the service changes without announcement, which is why the URL Inspection rendered HTML is recorded alongside the local render and wins any disagreement. If Google’s renderer uses a viewport shorter than the page and never extends it, the share I report will be higher than what Google received.
The counter looks for four hiding rules. Sites also hide content with height zero and overflow hidden, with clip-path, with transparent text colour, or behind another element. Those pages score better on check 9 than they should, and the rendered screenshot in URL Inspection is the only check I have for them.
The word regex counts Latin words. On a page written in Hindi or another Indic script it returns close to zero at every pass. Swap the regex for one that matches the page’s script before you run it.
Sources
- Google Search Central, JavaScript SEO basics
- Google Search Central, Fix lazy-loaded content
- Google Search Central, Overview of Google crawlers and fetchers
- Google Search Console Help, URL Inspection Tool
- Chrome for Developers, Chrome Headless mode
- Playwright for Python documentation