SEO Audit
Runs a structured SEO audit combining Ahrefs data with a direct on-page crawl, synthesizes a diagnosis and prioritized recommendations, then always builds the client-ready report and implementation sheet as the final step.
The audit answers one core question: what is the binding constraint on this site's organic growth — authority, on-page, content, technical, or greenfield — and what's the highest-leverage fix?
This file is self-contained. Ahrefs schemas, AI-visibility rulings, connector setup, the report design spec, the full report-builder source, and the brand logomarks are all in the appendices. There are no sibling assets/ or references/ files, and no dependency on any other skill. The only external requirement is the Ahrefs connector, which Phase 0 checks.
It is also client-agnostic. Site size, market, business model, property boundaries and delivering agency are all variables — Phase 0b sets them, and getting them wrong is the most common way this skill produces a confident but useless deliverable.
Claim discipline — read before writing a single finding
This becomes a client-facing document that a developer or in-house SEO will read adversarially. Every rule below exists because it was violated on a real engagement and produced a wrong deliverable.
1. Empty is not absent. A failed, timed-out, blocked or empty tool response is never evidence that something does not exist. Every negative finding — "there is no robots.txt", "no structured data", "no canonical" — must trace to a successful fetch that affirmatively shows the absence. On a real audit a sandboxed shell command returned nothing because the sandbox had no network route to the domain, and that silence was written up as "no robots.txt exists." The file existed and was well configured.
2. Name the tool behind every number. Before writing a headline figure, be able to say which call produced it. If it was inferred from an adjacent dataset, either measure it or hedge the wording. "Only two pages rank" survived a full draft as an inference before site-explorer-top-pages confirmed it — it happened to be right, which is luck, not method.
3. One series per delta. Never compute a change by comparing two endpoints. A year-over-year figure comes from a single history series, same month to same month. Mixing a current snapshot against a historical month produced a "37% decline" that was really 40%.
4. Verify the rule, not just the data. Before asserting a search-engine behaviour as the reason for a recommendation, check it against primary documentation. "Google treats cross-domain sitemaps as untrusted" was asserted as justification for a Critical action item; Google documents robots.txt cross-submission as the sanctioned mechanism. The recommendation survived, the reasoning didn't, and a client's developer would have found it in a minute.
5. State n. Any claim about a set of N items carries its sample size, and a recommendation acting on all N needs a sample sized to the risk. One post is not a basis for characterising 337 — and a proper sample reversed the recommendation from "triage before indexing or you'll hurt yourself" to "safe to index, consolidate the older cohort."
6. Name the mechanism when the effort estimate depends on it. "Server-side render every URL — effort: Days" is not credible on a React SPA and will be rejected by whoever builds it. Say prerender layer, and say a framework migration is a different order of project. Effort is engineering effort, not calendar time — in an enterprise with change control, "Days" of work is a month elapsed.
7. Mark provenance when a deliverable mixes verified and inferred rows. Drafting title tags for 30 pages from URL slugs is useful; presenting them indistinguishably alongside 7 written after reading the page is not. Add a column saying which is which, and say so in the report.
8. Suspect the tool before the site. If a browser tool returns an empty document then succeeds on retry, that is evidence about the tool's timing until proven otherwise. Poll for a stable DOM before recording any measurement, and when reporting slow hydration mark it suggestive and point the client at Search Console's URL Inspection.
9. Re-derive counts, never recall them. Sitemap totals, page counts and word counts get restated a dozen times across a report and its sheet. Count once from source, then grep the finished data file for stale variants.
10. Don't fit this client to the last client's shape. The most seductive failure is producing a report that looks like the previous good one. If the parseability check passes, there is no rendering story. If the links are fine, say so. A healthy site gets an incremental-gains report, not a manufactured crisis.
11. Test in the renderer that matters. A layout claim verified in LibreOffice says nothing about Word or Google Docs. When you cannot test the target renderer, say the claim is unverified rather than shipping a fix twice. Two cover-page "fixes" shipped on the strength of a LibreOffice page count that was identical before and after — the client saw the same broken output both times.
12. Say who can't see it. Every claim that something is "invisible", "unread" or "unseen" must name the reader. "337 blog posts unread" is ambiguous and arguably false — a human can read them, a crawler cannot. Prefer the measured ranking figure, which cannot be argued with.
Phase 0 — Preflight
1. Ahrefs connector — hard blocker. If Ahrefs tools are missing entirely, walk the user through Appendix C. If they exist, confirm the connection is live with one cheap call: site-explorer-backlinks-stats (date = today, mode = subdomains) — ~50 units, seconds when healthy. Ahrefs calls fail slowly (4-minute timeouts), so never skip this and never batch it with expensive calls.
On timeout: do NOT retry — each failure burns 4 minutes. Work through, in order: (1) confirm they're signed in to Ahrefs — an expired session produces exactly this pattern, including working for a few calls then stalling; (2) fully quit and reopen the desktop app; (3) check API unit balance. Offer to proceed with web-research-only portions meanwhile.
2. Build toolchain — affects the deliverable, not the audit. One bash call: node -v, soffice --version, pdftoppm -v, convert -version. Check now, not in Phase 7 — a failure otherwise surfaces after the Ahrefs units are spent. Degrade: no Node/registry → skip the report, deliver chat findings plus the sheet, say so up front; no soffice → no PDF; hand over the .docx and say the visual check couldn't run; no pdftoppm → build but state you could not visually verify; no ImageMagick → the cover loses its full-bleed background and the logomark falls back to a text wordmark. Mention once.
Never ask the user to fix the toolchain — it's a sandbox property. The connector is the only thing worth prompting about.
Phase 0b — Size and shape the audit
Establish four variables before pulling data. All four change the method.
Site size. Fetch the sitemap (via robots.txt if it points elsewhere) and cross-check against org_keywords. A <sitemapindex> lists child sitemaps, not pages — counting its <loc> entries reports the number of sitemaps as the number of URLs. Follow one or two children to learn the pattern, then estimate above a few thousand rather than counting exhaustively.
| Bucket | URLs | What changes |
|---|---|---|
| Micro | under 50 | Everything here applies as written; inspect most pages individually. |
| Small | 50–500 | As written, but sample per template rather than per page. |
| Mid | 500–5,000 | Raise organic-keywords to 100+; group keywords by section; 2–3 pages per template; prefer Site Audit data if a project exists. |
| Large | 5,000+ | No page-level lists. Work at template and section level. top-pages and site-audit-* replace manual crawling. Budget triples or worse. Tell the user the audit is section-level before starting — a large-site audit promising per-URL detail will either lie or never finish. |
Market. Default country=us only when the client is US-focused. Confirm the target market early: it drives the country parameter on every keyword call, the volume figures, and the report's column header. Set market in the report data (e.g. "UK", "DE") or the keyword table reads "US Vol" for a client who doesn't sell there.
Property boundary — what is in scope. Establish this before writing a single recommendation, because conflating a marketing site with an application produces advice a developer will reject outright. Check the rendered homepage for links to other domains and subdomains ([...document.querySelectorAll('a')].map(x=>x.href) — the raw HTML won't have them on a client-rendered site). Then decide explicitly which properties the audit covers, and say so in the report.
- A marketing site and an authenticated app are different properties with opposite requirements. "Prerender every URL" is right for the marketing site and wrong for the app — nobody needs an authenticated dashboard in the index. Scope every technical recommendation by name.
- Watch for the product on its own domain. A separate product domain competing for the same terms is a strategic finding in its own right, not a footnote: compare DR and refdomains on each, check whether the link between them is reciprocal, and note which is technically healthier. On one audit the company site held DR 30 and 568 refdomains while the product domain held DR 21 and zero keywords, linked one-way, both marketing the same product — a decision the client had to make before any retitling work was worth doing. Frame it as a decision, not a directive: which domain should own the product terms depends on whether the product is being positioned to stand apart from the parent brand, and that is a business question.
Audit type. Name it, because it determines which phase carries the audit:
- Broken — something technical suppresses an otherwise healthy site. Phase 2 is the audit.
- Greenfield — no search-facing content exists. Phase 3 is the audit.
- Authority-constrained — good content, correct technical, no links. Backlink and earned-media work is the audit.
- Plateaued — ranks well, growth stalled. Striking-distance terms, cannibalisation and refresh are the audit. Do not invent a technical crisis (rule 10).
- Mostly out of scope — the growth problem is the local pack, marketplaces or paid. Say so early rather than delivering a tangential document.
Scope boundaries — state what this audit does not cover
This skill audits organic search for a website. It does not cover the following, and the report should say so explicitly whenever they are material — two sentences that prevent the client assuming these were checked and found healthy:
- Local / multi-location: Google Business Profile, NAP consistency, review velocity, map-pack rankings, location-page architecture. For a local-services or multi-location client this is frequently most of the opportunity.
- Ecommerce architecture: faceted navigation and URL parameters, collection-versus-product cannibalisation, out-of-stock and discontinued URL policy, pagination, Product/Offer schema at scale. Word-count analysis is the wrong instrument for templated product pages — assess the template once, then measure how much genuinely unique content varies between instances and whether near-identical pages compete.
- Authenticated applications, including any product app on its own domain or subdomain.
- Migrations: redirect mapping and post-migration recovery.
- Paid search overlap, CRO, and anything downstream of the click.
Phase 1 — Core Ahrefs data pull
Use mode=subdomains on every site-explorer call (domain mode excludes www). Use today's date. Scale limits to the Phase 0b bucket.
site-explorer-metrics— org keywords, traffic, traffic value, paid presence. Frames everything.site-explorer-domain-rating— DR and Ahrefs rank.site-explorer-backlinks-stats— live vs all-time backlinks and refdomains (done in Phase 0).site-explorer-organic-keywords— 25 rows on a small site, 100+ on mid/large. Note the branded/non-branded split, which URLs rank, and the country breakdown (Appendix A — the same keyword returns one row per country, and the domestic share is often a finding in itself).site-explorer-top-pages— how many pages actually rank, and which. Never infer this; measure it. Far fewer than the sitemap declares means an indexing problem — go to the parseability check.site-explorer-referring-domains— top 15 bydomain_rating:desc, history=live, withis_spamanddofollow_links. On a profile with thousands of refdomains the top 15 says nothing about the long tail — raise the limit or sample by DR band, and say which you did.site-explorer-metrics-history— 12–13 months monthly. All trend claims come from this series alone.site-explorer-organic-competitors— top 8. Expect noise on small sites (Phase 5).
Run metrics and domain-rating on any second property found in the property-boundary check too (~100 units) — you cannot write the two-domain finding without them.
Re-verify before you build. Immediately before writing the report data file, re-pull site-explorer-metrics, backlinks-stats and top-pages (~150 units). Live figures drift across a long audit, and this pass also catches an inferred number that was never measured.
Phase 1b — Prefer first-party data wherever it exists
Ahrefs traffic figures are third-party estimates and can be off by an order of magnitude (one client audit found GA4 organic traffic ~19x Ahrefs' estimate).
Check for an Ahrefs project first. management-projects is free (0 units) and tells you what else is available. When a project exists, these beat anything you can infer from sampling:
site-audit-issues— real crawl data at whole-site scale: duplicate titles, broken links, redirect chains, missing meta. On a mid or large site this replaces page-by-page inspection entirely, and it is the difference between "we sampled 15 pages" and "the crawl found 1,400 pages with duplicate titles." Never hand-sample an issue Site Audit already counted.site-audit-page-explorer— per-URL crawl attributes; the fastest way to build a page-actions list without inspecting each page.gsc-keywords,gsc-pages,gsc-performance-history— first-party impressions, clicks and positions. Measured data; it outranks every Ahrefs estimate in the report. When it exists, stop labelling traffic "Ahrefs estimate" and quote GSC.rank-tracker-overview— tracked-keyword movement over time.
If an analytics connector is present (Windsor.ai → GA4/GSC, or direct), pull 12 months of organic sessions and compare. Analytics is authoritative for volume; Ahrefs for keywords, positions and backlinks. State material divergence. Treat broken analytics as a finding — tracking gaps and missing months are billable discoveries.
If nothing is connected, label every traffic figure "Ahrefs estimate" in chat and report, and make "verify Search Console and establish the true baseline" an early action item. Setting up an Ahrefs project is itself a recommendation.
Phase 2 — On-page crawl
Ahrefs tells you what ranks; the crawl tells you why. Fetch the homepage and 1–2 key money pages.
Fetch tooling — non-negotiable. Use only web_fetch and the Claude-in-Chrome tools. Never use curl, wget, or any HTTP library via bash to fetch a URL, regardless of whether web_fetch succeeded, timed out, or returned nothing. If web_fetch reports a domain cannot be fetched, tell the user and stop. And remember rule 1: a shell command returning nothing has told you about your sandbox, not the site.
Check: titles and meta descriptions (targeting, length, and entity encoding — generated titles containing raw & or ’ render literally in results and are a real defect), canonicals, H1s, structured data, hreflang where multilingual, internal linking to money pages, legacy URL patterns.
The parseability check — run this on every audit
Compare raw HTML against the rendered DOM on at least two URLs, one representative of the site's bulk (if 90% of URLs are blog posts, test a blog post, not just the homepage):
web_fetchthe URL — raw HTML, no JavaScript: what a crawler receives.- Chrome
navigate+get_page_text— JavaScript executed: what a human sees. - Diff them.
If the raw fetch returns only meta tags and an empty body while the browser returns full content, the site is client-side rendered and blank to crawlers — a diagnosis-level finding. The most persuasive artifact in the audit is one page quoted twice: what the crawler got, and what the browser got.
If the check passes, say so in one line and move on. A server-rendered site has no rendering story, and hunting for one produces exactly the padded technical section rule 10 warns about. Run the check on any second property too — they often differ, and "the product domain is technically healthier than the company site" changes the recommendation.
When it fails, three follow-ups sharpen it:
- Are titles shared across URLs? Compare
titleandmeta-descriptionverbatim across paths. Byte-identical values everywhere means one template is served to crawlers — devastating and trivially fixable. - Do per-page titles appear even after rendering? Some SPAs inject unique titles on some routes but not others. Inconsistency means the fix is needed in two places, and a developer who does only server rendering will think the job is done.
- Is the canonical client-injected too? If so, crawlers never see canonicals, so duplicate URLs cannot be de-duplicated even when every canonical is correct — connecting two findings that otherwise look separate.
Also verify money pages and any content index (/blog, /resources) render their listings, and confirm on a second attempt before calling an index broken (rule 8).
Index management: review sitemap and robots.txt, both fetched properly. Count URLs per the sitemap-index caution in Phase 0b, and compare against top-pages; "N declared vs. M ranking" is one of the most persuasive lines available, so get N right. Check whether robots.txt points at a different sitemap than /sitemap.xml — two sitemaps that disagree is a real finding, and the reason to fix it is contradiction plus third-party dependency, not distrust (rule 4). Look for topic-diluting indexed content, near-duplicate pairs where one subject lives at two paths, and numeric-suffixed slugs (-2, -3) indicating duplicate publication — while checking whether a part-1/part-2 pattern is a legitimate series before recommending a redirect.
Content sampling — method by rendering, sample size by decision
You sample to answer one question, usually "is this library an asset or a liability?" Sample until the answer stops changing, not until the library is covered.
- Server-rendered:
web_fetchreturns body text directly. Sampling is cheap — 20–30 URLs is comfortable. - Client-rendered: each sample costs a navigation plus a hydration wait. Cap at 12–20, poll for a stable DOM (rule 8), and state the sample size in the finding.
- Large libraries (500+ items): do not scale the sample with the library. 15–20 chosen across strata answers the cohort question as well as 200 would. If the client needs a per-URL prune list, that is a separate scripted crawl and a separate line item — say so rather than half-doing it.
- Templated pages (products, locations, programmatic): word count is the wrong measure. Assess the template once, then measure unique-content variance between instances and whether near-identical pages compete.
Stratify by date and title pattern, never at random. Cohorts almost always split on when and how content was produced, and finding a cohort split changes the advice from "prune" to "consolidate the older batch" — materially different guidance. Report the sample size; treat cohort boundaries as the finding and exact averages as indicative.
Core Web Vitals: collectible only where the network allows Google's PageSpeed API. Expect 429s — try once or twice, don't loop. When not captured, say so and make it an action item; never silently omit or guess.
Phase 3 — Keyword opportunity research
Two different jobs depending on what already ranks. Choose deliberately:
A. The site has meaningful non-branded rankings (plateaued or healthy). Hand-building a 20-keyword list is the wrong exercise; the opportunity is in what already almost works.
- Striking distance: filter the organic-keywords pull to positions 4–15. These need a nudge, not a new page, and they are the fastest wins on any established site.
- Competitor gap: run
site-explorer-organic-competitorsagainst real comparables, then pull their organic keywords for terms they hold and the client doesn't. - Cannibalisation: the same keyword ranking via two URLs means two pages split one signal.
B. Little or no non-branded ranking (greenfield or broken). Build the target list from the site's actual products and services, taken from the rendered DOM, then pull keywords-explorer-overview for the whole list in ONE call (comma-separated, country = the Phase 0b market, select keyword,volume,difficulty,cpc,traffic_potential).
Three rules for the built list:
- Include spelling and spacing variants. Sites often target the brand's internal spelling while the market searches differently. Zero volume for the site's spelling next to real volume for the common one is a title-tag fix worth flagging loudly.
- Include commercial modifiers relevant to the business model and the category head term.
- Test the terms the site already built pages for. A page built on a zero-volume term is a finding: retire it as an SEO target, keep it for sales enablement. Conversely, mark every keyword that already has a page — "page exists" turns a content recommendation into a retitling task, changing both effort and timeline.
Assign each term to a property, not just a page. When a second domain exists, "which page should own this term" is preceded by "which domain should own it" — and the keyword map is worthless if that is unresolved. Gate the retitling work behind the decision.
Winnability check: for the lead commercial term, pull the live SERP (serp-overview). One table of who holds page one, with what DR and how many backlinks. A low-DR or low-backlink page holding page one is the most persuasive artifact in the keyword section. Note the type of page ranking — if page one is definitional explainer content, that tells the client what to write. Assign verdicts sized against the client's actual DR.
Phase 4 — AI visibility sub-audit
Appendix B has the verified bot taxonomy, rulings, citations and caveats. Four layers:
Layer 1 — Measurement. Pull site-explorer-ai-responses-count (~105 units). Zero-to-low citations is a normal small-site finding — a gap, not a crisis. Then check Bing: ChatGPT Search retrieves from Bing's index, so compare Bing vs. Google indexed pages and, if Bing Webmaster Tools is verified, pull its AI Performance report for free first-party citation data. If BWT isn't verified, that's a finding and a Days-effort action item. Brand Radar tools typically fail without a configured report — don't burn retries.
Interpreting the count. Ask where citations come from. If the site failed the parseability check, its own pages cannot be the source — the citations came from third-party press, and the referring-domains list shows which outlets. This reframes AI visibility from "our content isn't cited" to "our content has never been readable," which points at a different fix. Zero AI Overviews follows mechanically from few indexed pages — connect the two rather than reporting them separately.
Layer 2 — Access. Three checks in order: (1) CDN/WAF — Cloudflare blocks AI bots by default on new domains and its toggle overrides robots.txt, so confirm or list as a verification item; (2) robots.txt against the Appendix B taxonomy — and if it is already correct, say so plainly; naming the one thing a client got right buys credibility for everything else, and a wildcard Allow means unlisted retrieval bots are permitted, not blocked; (3) parseability — the Phase 2 check. Client-side rendering is an AI-visibility problem as much as an SEO one; when both apply, note one fix solves both.
Layer 3 — Content readiness. Apply the Appendix B citability checklist, judging the rendered content. When existing content already has TL;DRs, structured subheads and named attributed quotes, say so — the citability work is done and only delivery is broken.
Layer 4 — Authority & mentions. The buyer-prompt protocol (Appendix B) is manual and human-run — Claude cannot query other engines. Pair with a third-party vouching inventory on industry-appropriate platforms. Then connect to earned media: AI assistants assemble answers from Reddit, YouTube, trade press and roundups, so search the category's live discourse for angles where the client's differentiation matches what the market already argues about. For persona-level channel work, hand off to watering-hole-analysis if installed.
Phase 5 — Analysis framework
Synthesize a diagnosis, not a data dump. Prefer the simplest diagnosis the evidence supports — if one root cause explains the symptoms, say one cause. Claiming "two faults compound" when the second isn't causing anything is weaker, not more thorough.
Technical-invisibility check (first). If parseability failed, the constraint is technical and everything else is downstream. Do not diagnose "thin content" or "weak authority" on a site whose content and links are unreadable. Frame it "a rendering fix, not a rebuild" — good news, because the expensive inputs are already paid for. This changes engagement scope and price, so make it the diagnosis when it applies.
Greenfield check. A site ranking only for its brand name with no traffic history never entered the game: "a build, not a tune." Distinguish from technical invisibility — greenfield means the content doesn't exist; invisibility means it exists and can't be read.
Authority vs. on-page. Strong on-page but absent rankings on winnable terms means authority. Adequate DR but absent rankings means on-page, content or technical. State the DR-to-difficulty comparison as evidence: "DR 30 against a target cluster of KD 6–19 means authority is not the constraint."
No-constraint case. If parseability passes, links are decent, content is solid and the site ranks — the honest diagnosis is investment level or competitive intensity, and the deliverable is an incremental-gains report built on striking-distance terms and refresh priorities. "No single binding constraint; here is where the marginal hour goes" is legitimate and sometimes correct.
Split-property check. When the same product is marketed on two domains, ask whether effort is being divided. Compare DR, refdomains and organic keywords on each; check link reciprocity; check which is technically healthier. A one-way link from the strong domain to the weak one means the earned authority isn't transferring.
Refdomain-to-DR ratio. Many refdomains at low DR means a diluted profile; check is_spam. Distinguish from "no links at all" and from a legitimate PR-earned profile. When healthy, say so and use it as evidence rather than manufacturing work; "no remediation required" is a finding.
Link destinations. Note whether strong links hit the homepage or money pages. Authority concentrated on the homepage with orphaned money pages is common and fixable with internal linking plus targeted outreach.
Geography. Check the country split on ranking keywords. A single-market B2B site drawing a third of its organic traffic from unrelated markets on a generic-phrase brand name has less addressable traffic than the headline implies — quote the domestic figure as the real baseline and compare in-market keyword targets against it, not a global total.
Trend. Flat for 12+ months is its own finding: not declining, stuck. A single-month step change is different — name the month and make investigating it in GSC an action item rather than speculating. When all traffic is branded, a decline reflects brand-search volume and competition on the name, not content decay.
Competitor noise filter. On small sites the competitors report surfaces giants sharing one keyword; ignore anything with 0–1 common keywords and DR > 85. Watch for the stronger failure mode: when the brand name is a common phrase, the entire competitor set can be a naming collision rather than a market. Say the report contains no real competitors and the site has no measurable presence in its actual category; don't analyze the noise. A contested generic brand name is worth flagging on its own — a company paying KD 68 to hold its own name has a positioning problem, not just an SEO one.
Proof-of-concept pages. If one page ranks while structurally identical siblings don't, that page proves the template works and the gap is authority or competition elsewhere.
Phase 6 — Deliver findings in chat
Present with the Ahrefs render widgets, interpretation between them, never stacked. Lead with the one-sentence diagnosis before the first widget.
render-scorecard— DR, live refdomains, live backlinks, org keywords, org traffic, traffic value (monetary values arrive in cents; divide by 100). Add a coverage group (URLs declared vs. ranking) when there's a gap worth showing.- The evidence for the diagnosis — for technical invisibility, the raw-fetch vs. rendered-DOM comparison as two short quoted blocks. More persuasive than any table, four lines.
render-data-table— keyword opportunities with "current ranking" and "page exists" merged in.render-data-table— the winnability SERP, low-DR page called out in prose.render-scorecard— AI citations per platform (grouped), when Phase 4 ran.render-time-series-chart— only if the trend has a shape worth seeing.render-data-table— top referring domains. Place where it supports the diagnosis; when authority is adequate this table is the proof and belongs late, framed as such.
Close with the diagnosis restated, the priority list (quick technical fixes first), a ceiling estimate grounded in traffic-potential numbers and compared against the right baseline, and the API units consumed.
Phase 7 — Build the report and implementation sheet (always, without being asked)
Do not ask whether the user wants a report. Build it. Announce and proceed.
- Work in a scratch directory, never the outputs folder — the outputs mount often disallows deletes, which breaks the rebuild loop. Use
/tmp/<scratch>, copy only the final files out. - Write the findings JSON (Appendix E). Absent keys omit their section, enforcing no-orphan-sections.
preparedByis required — set it to the delivering agency's name, and setmarketwhen the client isn't US-focused. The builder exits rather than guessing an attribution, andmarketdefaults to US, so a report labelled "US Vol" for a UK client is an obvious tell. - Write Appendix D verbatim to
report_builder.js, and copy the agency's own logo files alongside it aslogo-white.pngandlogo-dark.png(Appendix F) if they exist — without them the report sets thepreparedByname as a text wordmark instead. Thennpm install docxandnode report_builder.js <data.json> <output.docx>. - Convert to PDF with soffice, count the pages, rasterize with
pdftoppm -png -r 60, and read every page image. Check logo proportions, table fit, orphan headings, near-empty final page, and any blank page. Trim copy and rebuild to fix orphans. - Always produce both formats. Both are now full-fidelity — the cover's full-bleed background works in the .docx as well as the PDF (see the design spec). The PDF is the client-facing deliverable; the .docx ships alongside it because copy almost always needs a late tweak. Keep the findings JSON too: for anything more than a word change, edit the JSON and rebuild both rather than hand-editing the .docx, or the two files drift apart.
- Build the implementation sheet (below), then present the PDF, the sheet, and the .docx, with two or three sentences.
The implementation sheet — what earns a tab
The second file exists for exactly one reason: to carry per-row work the report cannot hold legibly. Not a companion volume, not a second copy of the findings. Call it an implementation sheet — the name sets the expectation.
Test every candidate tab against three questions; it needs all three:
- Does this appear nowhere in the report? If it restates a report table, cut it. The action list in particular does not belong — it is already in the report, priority-ordered, and a client's team tracks work in Jira or Teamwork, not a spreadsheet a consultant emailed them.
- Is it a list someone works through row by row? Reference data nobody actions is inventory for its own sake.
- Would putting it in the report cost pages? Under roughly ten rows, it belongs in the report body.
Tabs that reliably pass: page-level actions (each URL with the change it needs and a draft title tag — usually the single tab that justifies the file, because it is copy-paste source material); prune/redirect lists; keyword-to-page mapping (the one column the report's keyword table can't carry).
On a large site, page-level tabs stop being viable — 5,000 rows is not a work queue. Switch to template-level actions (one row per template with the count of URLs affected) and say in the README that per-URL export is a separate deliverable.
Mechanics: every tab gets a leading empty Done column and auto_filter. Where rows mix verified and inferred content, add a provenance column (rule 7). The README states the diagnosis, how to use the file, and what is deliberately not itemised and why — if one fix resolves N items at once, do not enumerate the N. A 337-row inventory of posts that all become crawlable the moment one config ships is thoroughness theatre, and saying so is more useful than the rows.
Report design spec — the builder in Appendix D implements this
Section order: Cover · Executive Summary · Action Items · Impact/Effort Matrix · Domain Health Snapshot · Findings · Keyword Opportunity Map · Winnability Check · 90-Day Sequence · Appendix & Next Steps. Omit any section whose data wasn't gathered.
Voice: written for a business owner, not an SEO. Define DR on first use. Translate difficulty into verdicts. Every recommendation ties to revenue logic and traces to a finding. The diagnosis appears twice — italic on the cover, opening the Executive Summary.
Layout and language rules that are easy to get wrong:
- The market eyebrow must use the client's own category language. Never file a client under a category they position against. One audit labelled a client "B2B SaaS" whose entire pitch is that SaaS is the problem their product replaces — it read as if the report hadn't understood the business. Take the framing from their own money pages, not from an analyst taxonomy.
- Attribute every "invisible" claim to a reader (rule 12). Prefer the measured ranking figure: "337 → 0 blog posts ranking" parallels "374 → 2 URLs vs. pages ranking" and cannot be argued with. Reserve "not indexed" for when you actually have index-coverage data, which means Search Console.
- Never open a section with a wall of text. Every Findings entry leads with a single sentence stating the verdict, then supporting paragraphs follow. This is structural in the builder:
notesis the one-sentence lead,detailis an array of paragraphs after it. The Executive Summary lede is one sentence too, and the paragraphs after the stats band are short. If a reader has to hunt inside a paragraph for the point, the paragraph is wrong. - Scope every technical recommendation to a named property. "Prerender every URL" invites a developer to ask whether you mean the app. Say which domain, and say explicitly what is excluded.
- Cover eyebrow carries the domain, not a category label — an eyebrow reading "SEARCH ENGINE OPTIMIZATION" above a headline reading "SEO AUDIT & OPPORTUNITY ASSESSMENT" says the same thing twice. Domain uppercase and letterspaced in the bright accent colour; no separate domain line under "Prepared for {Client}".
- Interior header separates by whitespace, not a rule. Logo left, "SEO AUDIT · {CLIENT}" right, no accent rule beneath, generous trailing gap. The accent rule is for section headings only.
- Footer is two-up: attribution left, "Page N" right. No "Confidential".
- Full-bleed cover: use a page-size image anchored behind the text. This is the one construction that works in a .docx, and it is worth knowing generally — it applies to any full-bleed page in any Word document, not just this cover. Generate a solid page-size PNG at build time (ImageMagick:
convert -size 1632x2112 xc:#<hex> out.png, i.e. 2x for print crispness), then place it as anImageRunwithfloating: { horizontalPosition: {relative: PAGE, offset: 0}, verticalPosition: {relative: PAGE, offset: 0}, behindDocument: true, wrap: {type: NONE} }andtransformation: { width: 816, height: 1056 }(8.5x11in at 96px/in). Attach it to the run list of the section's first paragraph, keep normal page margins so the text stays inset, and set the text colours for a dark ground. Verified: bleeds on all four edges and all four corners, one page, no blanks, body pages unaffected. The three approaches that do not work, so nobody re-litigates them: a full-page table bleeds correctly but Google Docs inserts empty paragraphs before and after any table on import, which in a zero-margin section become blank pages either side of the cover — and Word separately needs a trailing paragraph the full-page table leaves no room for. Paragraph shading follows the paragraph's indents, so indenting the text to inset it also insets the shading and leaves white side strips.w:background(Design → Page Color) renders a perfect bleed with no table at all, but OOXML scopes it to the whole document, so it tints every page. Degrade gracefully: if ImageMagick is unavailable, omit the background rather than falling back to a table. - The Executive Summary must be scannable and fit one page. Order: market eyebrow → one-sentence lede in larger PRIMARY-coloured type → a stats band of 3–4 headline figures → at most two or three short paragraphs → "The bottom line" arrow bullets with bold lead-ins. Stat labels about three words. A paragraph over five lines belongs in Findings.
- Choose stats band figures by diagnosis type. Broken: declared-vs-ranking URLs, the suppressed asset count, authority adequacy, the prize. Greenfield: category volume, competitor DR range, current keyword count, time to first ranking. Plateaued: striking-distance count, top-3 share, YoY change, refresh candidates. Authority-constrained: DR gap to page one, refdomain gap, content pages ready, target term volume. Reusing the broken-site stats on a healthy site produces meaningless numbers.
- Action Items is the deliverable for many readers: one row per recommendation, self-contained, priority-ordered, Effort / Impact / Time / Owner (DEV, SEO, Client). Where a recommendation is gated by a client decision, put the decision above it as its own row.
- Tables: PRIMARY header row w
…(truncated)