@operstack/audit 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,9 +15,26 @@ Everything an external auditor can see without Search Console or analytics acces
15
15
  - HTTPS, www canonicalisation, time to first byte on the homepage.
16
16
  - robots.txt, the sitemap it names (or the usual paths), the URL count, foreign hosts in the sitemap, sitemap URLs that redirect or break.
17
17
  - llms.txt: present, text or HTML, and whether its links point at the site or somewhere else.
18
+ - Off-page and trust, from the site itself: the postal address in the structured data, published phone and email, `sameAs` profiles claimed on other hosts, an about page, privacy and terms.
19
+ - The conversion path: whether every page offers a way to get in touch, whether a call to action sits in the first screen, how far down the page the first form sits, how many fields it asks for, and whether a messenger link exists.
18
20
  - The homepage and a sample of pages from the sitemap: title and description lengths, shortcodes or encoded data in the description, H1 count, alt coverage, canonical, robots meta, viewport (zoom blocked), Open Graph and Twitter cards, JSON-LD types (FAQPage, Organization and business types), script and stylesheet counts, hreflang, exposed dates, word counts, duplicate titles, thin pages, utility pages in the index, and on WordPress the xmlrpc endpoint.
19
21
 
20
- Every finding becomes a row with a status (`ok`, `warn`, `bad`, `na`), a value and a comment. `bad` rows are copied into the critical-issues page automatically. Scores per group are suggested from the statuses; the analyst can overwrite them.
22
+ Every finding becomes a row with a status (`ok`, `warn`, `bad`, `na`), a value and a comment. `bad` rows are copied into the critical-issues page automatically.
23
+
24
+ **Scores are computed, never typed.** The six area scores on page 2 are rendered from the checks each time the report is built: a check that passes counts one, a check that needs attention a half, a failing check nothing, and `na` is ignored. A number written by hand into `audit.json` is ignored and named by `operstack-audit check`. An area with no checks is printed as **not measured** rather than scored, so nothing rests on an impression. Each card also shows the count it came from, so a reader can recompute it from the checklist.
25
+
26
+ ## What you need to run this
27
+
28
+ **Nothing. No account, no key, no subscription.** `collect` reads the site the way any visitor reads it, and every score in the report is computed from that. Two people auditing the same site on the same day get the same numbers.
29
+
30
+ Two things are optional, and the report always says which was used:
31
+
32
+ | Optional | Cost | What it adds |
33
+ |---|---|---|
34
+ | Google Search Console and Bing Webmaster Tools | Free, but you need your own free account and a verified site | Impressions, clicks, positions and the real queries. These go into the narrative; no score depends on them. |
35
+ | A backlink tool, passed in with `--backlinks links.json` | Paid, and never required | One row with the referring domain count and authority score, printed as a note. **It is not counted in any score**, so the report stays reproducible without it. |
36
+
37
+ The rule: **if a number costs money to obtain, it does not move a score.** Section 09 of every report prints the sources table so the reader can see exactly what was used and what it cost.
21
38
 
22
39
  ## What the analyst writes
23
40
 
@@ -35,7 +52,7 @@ Ten A4 pages: cover, executive summary with a scorecard, site overview with the
35
52
 
36
53
  ## Limits
37
54
 
38
- The external tier reads public pages only. It does not run a lab performance test, does not query keyword or backlink tools, and does not see impressions or conversions. The report says so on its limitations page.
55
+ The external tier reads public pages only. It does not run a lab performance test and does not see impressions or conversions. Conversion is scored on the contact path the pages offer, which is measurable from the HTML, not on visitor behaviour, which is not. Reviews and press are not read at all, and the report says so rather than guessing.
39
56
 
40
57
  ## License
41
58
 
package/bin/audit.mjs CHANGED
@@ -2,15 +2,18 @@
2
2
  /**
3
3
  * OperStack audit generator.
4
4
  *
5
- * operstack-audit collect https://example.com [--pages 20] [--out audit.json]
5
+ * operstack-audit collect https://example.com [--pages 20] [--out audit.json] [--backlinks links.json]
6
6
  * fetches the public signals (robots, sitemaps, llms.txt, sampled pages) and writes
7
7
  * audit.json with every technical row filled and the narrative fields left for the analyst
8
8
  * operstack-audit render audit.json [--out report.html] [--pdf]
9
9
  * renders the report from the JSON; --pdf also prints a PDF with headless Chrome
10
10
  * operstack-audit check audit.json
11
- * lists narrative fields still holding placeholders
11
+ * lists narrative fields still holding placeholders and any score the checks do not support
12
+ *
13
+ * Scores are never typed by hand: the report always prints what the collected checks give,
14
+ * and an area with no checks is printed as "not measured" rather than scored low.
12
15
  */
13
- import { collect } from '../src/collect.mjs';
16
+ import { collect, verifyScores } from '../src/collect.mjs';
14
17
  import { render, checkNarrative } from '../src/render.mjs';
15
18
  import { writeFileSync, readFileSync } from 'node:fs';
16
19
  import { resolve } from 'node:path';
@@ -20,19 +23,23 @@ const opt = (k, d) => { const i = rest.indexOf(k); return i === -1 ? d : rest[i
20
23
  const has = (k) => rest.includes(k);
21
24
 
22
25
  if (!cmd || cmd === '--help' || cmd === '-h') {
23
- console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
26
+ console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
24
27
  process.exit(cmd ? 0 : 2);
25
28
  }
26
29
  if (cmd === 'collect') {
27
30
  if (!/^https?:\/\//.test(target || '')) { console.error('collect needs an absolute URL'); process.exit(2); }
28
- const audit = await collect(target, { pages: Number(opt('--pages', 20)), log: (m) => console.error(` ${m}`) });
31
+ const backlinksFile = opt('--backlinks', '');
32
+ const backlinks = backlinksFile ? JSON.parse(readFileSync(resolve(backlinksFile), 'utf8')) : null;
33
+ const audit = await collect(target, { pages: Number(opt('--pages', 20)), backlinks, log: (m) => console.error(` ${m}`) });
29
34
  const out = resolve(opt('--out', 'audit.json'));
30
35
  writeFileSync(out, JSON.stringify(audit, null, 2));
31
- console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, suggested scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v}`).join(', ')}`);
36
+ console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
32
37
  console.log(`next: fill the narrative fields (operstack-audit check ${out} lists them), then render.`);
33
38
  } else if (cmd === 'render') {
34
39
  const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
35
40
  const out = resolve(opt('--out', target.replace(/\.json$/, '') + '.html'));
41
+ const drift = verifyScores(audit);
42
+ if (drift.length) { for (const d of drift) console.error(`score ignored, ${d}`); console.error('the report prints the score the checks give, not the one stored in the JSON.'); }
36
43
  const { html, pdf } = await render(audit, { out, pdf: has('--pdf') });
37
44
  console.log(`wrote ${out} (${html.length} bytes)${pdf ? `\nwrote ${pdf}` : ''}`);
38
45
  const missing = checkNarrative(audit);
@@ -40,6 +47,9 @@ if (cmd === 'collect') {
40
47
  } else if (cmd === 'check') {
41
48
  const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
42
49
  const missing = checkNarrative(audit);
43
- if (!missing.length) console.log('every narrative field is written');
44
- else { console.log(`${missing.length} field(s) to write:`); for (const m of missing) console.log(` ${m}`); process.exit(1); }
50
+ const drift = verifyScores(audit);
51
+ if (missing.length) { console.log(`${missing.length} field(s) to write:`); for (const m of missing) console.log(` ${m}`); }
52
+ if (drift.length) { console.log(`${drift.length} score(s) that no check supports:`); for (const d of drift) console.log(` ${d}`); }
53
+ if (!missing.length && !drift.length) console.log('every narrative field is written and every score matches its checks');
54
+ else process.exit(1);
45
55
  } else { console.error(`unknown command ${cmd}`); process.exit(2); }
@@ -1,79 +1,465 @@
1
1
  {
2
- "meta": { "site": "https://www.example-villas.example/", "host": "www.example-villas.example", "collectedAt": "2026-09-10T09:00:00.000Z", "tool": "@operstack/audit 0.1.0", "auditType": "External audit (no Search Console or analytics access)" },
3
- "client": { "name": "Example Villas", "subject": "A pool villa developer's site in a fictional resort market: technical SEO, content, answer-engine readiness and a 90 day plan.", "reportDate": "2026-09-10", "preparedBy": "OperStack" },
4
- "scores": { "SEO, technical": 3, "SEO, content and structure": 4, "AEO, answers and snippets": 5, "GEO, visibility in AI systems": 2, "Off-page and trust": 6, "Conversion and UX": 5 },
5
- "scoreNotes": { "SEO, technical": "Broken sitemap, slow first byte, template leftovers", "SEO, content and structure": "A blog exists; the homepage sells three things at once", "AEO, answers and snippets": "FAQ schema present, answers shallow", "GEO, visibility in AI systems": "llms.txt points at a demo domain", "Off-page and trust": "Portal listings exist, brand name collides", "Conversion and UX": "Form works, price is a placeholder" },
2
+ "meta": {
3
+ "site": "https://www.example-villas.example/",
4
+ "host": "www.example-villas.example",
5
+ "collectedAt": "2026-09-10T09:00:00.000Z",
6
+ "tool": "@operstack/audit 0.1.0",
7
+ "auditType": "External audit (no Search Console or analytics access)"
8
+ },
9
+ "client": {
10
+ "name": "Example Villas",
11
+ "subject": "A pool villa developer's site in a fictional resort market: technical SEO, content, answer-engine readiness and a 90 day plan.",
12
+ "reportDate": "2026-09-10",
13
+ "preparedBy": "OperStack"
14
+ },
15
+ "scores": {
16
+ "SEO, technical": 5,
17
+ "SEO, content and structure": 4,
18
+ "AEO, answers and snippets": 10,
19
+ "GEO, visibility in AI systems": 5,
20
+ "Off-page and trust": null,
21
+ "Conversion and UX": null
22
+ },
6
23
  "summary": {
7
24
  "lead": "The site is a working marketing page for a villa developer. Search engines index about 18 pages, an eight-article blog is live and FAQ markup is in place. It does not work as a search engine asset yet: the sitemap answers 404, the homepage description carries a form shortcode, and the AI index describes another business.",
8
25
  "verdict": "The site is not invisible, it is misread. Three technical defects and a homepage that sells three products at once cost it most of its organic potential. Every critical item can be fixed inside two weeks.",
9
- "priorities": ["Technical foundation: sitemap, meta description, llms.txt, utility pages out of the index.", "Demand-led structure: one page per project, one per location, one buyer guide.", "Trust content: sourced ownership answers, comparison tables and a FAQ that answer engines can quote."]
26
+ "priorities": [
27
+ "Technical foundation: sitemap, meta description, llms.txt, utility pages out of the index.",
28
+ "Demand-led structure: one page per project, one per location, one buyer guide.",
29
+ "Trust content: sourced ownership answers, comparison tables and a FAQ that answer engines can quote."
30
+ ]
10
31
  },
11
32
  "overview": {
12
- "rows": [["CMS / stack", "WordPress, page builder, commerce plugin, forms plugin"], ["Pages in sitemap", "21"], ["Language", "English, single version"], ["What is sold", "Two villa projects presented on one homepage"]],
33
+ "rows": [
34
+ [
35
+ "CMS / stack",
36
+ "WordPress, page builder, commerce plugin, forms plugin"
37
+ ],
38
+ [
39
+ "Pages in sitemap",
40
+ "21"
41
+ ],
42
+ [
43
+ "Language",
44
+ "English, single version"
45
+ ],
46
+ [
47
+ "What is sold",
48
+ "Two villa projects presented on one homepage"
49
+ ]
50
+ ],
13
51
  "note": "Three offers share the homepage: a price headline with a placeholder, a block about a seven-villa project and a block about a second phase with overlapping copy. A visitor and a crawler both leave unsure which product the site sells."
14
52
  },
15
53
  "critical": [
16
- { "title": "XML sitemap answers 404", "text": "robots.txt points at sitemap_index.xml and the server returns 404. Search engines crawl without a map: slower and less complete.", "level": "bad" },
17
- { "title": "Homepage meta description contains a form shortcode", "text": "The description field holds an encoded form tag. In results it renders as technical garbage instead of a sales sentence.", "level": "bad" },
18
- { "title": "llms.txt describes a demo site", "text": "The file exists, which is a positive signal, but its links go to a template demo domain with unrelated products. An AI system reading it may misidentify the business.", "level": "bad" },
19
- { "title": "Commerce utility pages are indexable", "text": "Shop, compare and wishlist pages carry index directives and demo products. They add noise to a 21 page index.", "level": "warn" }
54
+ {
55
+ "title": "XML sitemap answers 404",
56
+ "text": "robots.txt points at sitemap_index.xml and the server returns 404. Search engines crawl without a map: slower and less complete.",
57
+ "level": "bad"
58
+ },
59
+ {
60
+ "title": "Homepage meta description contains a form shortcode",
61
+ "text": "The description field holds an encoded form tag. In results it renders as technical garbage instead of a sales sentence.",
62
+ "level": "bad"
63
+ },
64
+ {
65
+ "title": "llms.txt describes a demo site",
66
+ "text": "The file exists, which is a positive signal, but its links go to a template demo domain with unrelated products. An AI system reading it may misidentify the business.",
67
+ "level": "bad"
68
+ },
69
+ {
70
+ "title": "Commerce utility pages are indexable",
71
+ "text": "Shop, compare and wishlist pages carry index directives and demo products. They add noise to a 21 page index.",
72
+ "level": "warn"
73
+ }
20
74
  ],
21
75
  "checks": [
22
- { "id": "https", "group": "technical", "label": "HTTPS", "status": "ok", "value": "certificate active" },
23
- { "id": "www", "group": "technical", "label": "www canonicalisation", "status": "ok", "value": "non-www redirects to www (301)" },
24
- { "id": "ttfb", "group": "technical", "label": "Time to first byte (homepage)", "status": "bad", "value": "3,300 ms", "comment": "single request from the auditor, not a lab test" },
25
- { "id": "robots", "group": "technical", "label": "robots.txt", "status": "warn", "value": "3 disallow rule(s), 1 sitemap line(s)", "comment": "the sitemap it names answers 404" },
26
- { "id": "sitemap", "group": "technical", "label": "XML sitemap", "status": "bad", "value": "sitemap_index.xml answers HTTP 404" },
27
- { "id": "canonical", "group": "technical", "label": "Canonical tag", "status": "ok", "value": "https://www.example-villas.example/" },
28
- { "id": "viewport", "group": "technical", "label": "Mobile viewport", "status": "warn", "value": "width=device-width, initial-scale=1, user-scalable=no", "comment": "blocks zoom: bad for accessibility and mobile SEO" },
29
- { "id": "og", "group": "technical", "label": "Open Graph and Twitter cards", "status": "warn", "value": "og:title, og:image, twitter:summary_large_image", "comment": "description carries the same shortcode" },
30
- { "id": "resources", "group": "technical", "label": "Page weight (homepage)", "status": "bad", "value": "39 script file(s), 14 stylesheet(s), 39 image(s)" },
31
- { "id": "title", "group": "onpage", "label": "Homepage title", "status": "warn", "value": "\"Home - Example Villas\" (21 chars)", "comment": "short: the words that earn the click are missing" },
32
- { "id": "description", "group": "onpage", "label": "Homepage meta description", "status": "bad", "value": "contains a shortcode or encoded data" },
33
- { "id": "h1", "group": "onpage", "label": "H1 on homepage", "status": "warn", "value": "3 H1 tag(s)", "comment": "more than one H1 dilutes the page topic" },
34
- { "id": "alt", "group": "onpage", "label": "Image alt text", "status": "bad", "value": "3 of 39 images carry alt text (8%) across 12 sampled page(s)" },
35
- { "id": "h1-sample", "group": "onpage", "label": "Heading structure across sample", "status": "bad", "value": "9 of 12 pages carry more than one H1" },
36
- { "id": "thin", "group": "content", "label": "Thin pages in sample", "status": "ok", "value": "median 1,600 words per page" },
37
- { "id": "dates", "group": "content", "label": "Publication dates exposed", "status": "warn", "value": "modified dates precede published dates on 3 articles" },
38
- { "id": "schema", "group": "aeo", "label": "Structured data on homepage", "status": "ok", "value": "RealEstateAgent, Organization, FAQPage" },
39
- { "id": "faq-schema", "group": "aeo", "label": "FAQPage schema", "status": "ok", "value": "present on 1 sampled page(s)" },
40
- { "id": "llms", "group": "geo", "label": "llms.txt", "status": "bad", "value": "14 link(s), 12 to other hosts", "comment": "links to a template demo domain: an AI system may misidentify the business" },
41
- { "id": "org-schema", "group": "geo", "label": "Organization or business schema", "status": "ok", "value": "present" }
76
+ {
77
+ "id": "https",
78
+ "group": "technical",
79
+ "label": "HTTPS",
80
+ "status": "ok",
81
+ "value": "certificate active"
82
+ },
83
+ {
84
+ "id": "www",
85
+ "group": "technical",
86
+ "label": "www canonicalisation",
87
+ "status": "ok",
88
+ "value": "non-www redirects to www (301)"
89
+ },
90
+ {
91
+ "id": "ttfb",
92
+ "group": "technical",
93
+ "label": "Time to first byte (homepage)",
94
+ "status": "bad",
95
+ "value": "3,300 ms",
96
+ "comment": "single request from the auditor, not a lab test"
97
+ },
98
+ {
99
+ "id": "robots",
100
+ "group": "technical",
101
+ "label": "robots.txt",
102
+ "status": "warn",
103
+ "value": "3 disallow rule(s), 1 sitemap line(s)",
104
+ "comment": "the sitemap it names answers 404"
105
+ },
106
+ {
107
+ "id": "sitemap",
108
+ "group": "technical",
109
+ "label": "XML sitemap",
110
+ "status": "bad",
111
+ "value": "sitemap_index.xml answers HTTP 404"
112
+ },
113
+ {
114
+ "id": "canonical",
115
+ "group": "technical",
116
+ "label": "Canonical tag",
117
+ "status": "ok",
118
+ "value": "https://www.example-villas.example/"
119
+ },
120
+ {
121
+ "id": "viewport",
122
+ "group": "technical",
123
+ "label": "Mobile viewport",
124
+ "status": "warn",
125
+ "value": "width=device-width, initial-scale=1, user-scalable=no",
126
+ "comment": "blocks zoom: bad for accessibility and mobile SEO"
127
+ },
128
+ {
129
+ "id": "og",
130
+ "group": "technical",
131
+ "label": "Open Graph and Twitter cards",
132
+ "status": "warn",
133
+ "value": "og:title, og:image, twitter:summary_large_image",
134
+ "comment": "description carries the same shortcode"
135
+ },
136
+ {
137
+ "id": "resources",
138
+ "group": "technical",
139
+ "label": "Page weight (homepage)",
140
+ "status": "bad",
141
+ "value": "39 script file(s), 14 stylesheet(s), 39 image(s)"
142
+ },
143
+ {
144
+ "id": "title",
145
+ "group": "onpage",
146
+ "label": "Homepage title",
147
+ "status": "warn",
148
+ "value": "\"Home - Example Villas\" (21 chars)",
149
+ "comment": "short: the words that earn the click are missing"
150
+ },
151
+ {
152
+ "id": "description",
153
+ "group": "onpage",
154
+ "label": "Homepage meta description",
155
+ "status": "bad",
156
+ "value": "contains a shortcode or encoded data"
157
+ },
158
+ {
159
+ "id": "h1",
160
+ "group": "onpage",
161
+ "label": "H1 on homepage",
162
+ "status": "warn",
163
+ "value": "3 H1 tag(s)",
164
+ "comment": "more than one H1 dilutes the page topic"
165
+ },
166
+ {
167
+ "id": "alt",
168
+ "group": "onpage",
169
+ "label": "Image alt text",
170
+ "status": "bad",
171
+ "value": "3 of 39 images carry alt text (8%) across 12 sampled page(s)"
172
+ },
173
+ {
174
+ "id": "h1-sample",
175
+ "group": "onpage",
176
+ "label": "Heading structure across sample",
177
+ "status": "bad",
178
+ "value": "9 of 12 pages carry more than one H1"
179
+ },
180
+ {
181
+ "id": "thin",
182
+ "group": "content",
183
+ "label": "Thin pages in sample",
184
+ "status": "ok",
185
+ "value": "median 1,600 words per page"
186
+ },
187
+ {
188
+ "id": "dates",
189
+ "group": "content",
190
+ "label": "Publication dates exposed",
191
+ "status": "warn",
192
+ "value": "modified dates precede published dates on 3 articles"
193
+ },
194
+ {
195
+ "id": "schema",
196
+ "group": "aeo",
197
+ "label": "Structured data on homepage",
198
+ "status": "ok",
199
+ "value": "RealEstateAgent, Organization, FAQPage"
200
+ },
201
+ {
202
+ "id": "faq-schema",
203
+ "group": "aeo",
204
+ "label": "FAQPage schema",
205
+ "status": "ok",
206
+ "value": "present on 1 sampled page(s)"
207
+ },
208
+ {
209
+ "id": "llms",
210
+ "group": "geo",
211
+ "label": "llms.txt",
212
+ "status": "bad",
213
+ "value": "14 link(s), 12 to other hosts",
214
+ "comment": "links to a template demo domain: an AI system may misidentify the business"
215
+ },
216
+ {
217
+ "id": "org-schema",
218
+ "group": "geo",
219
+ "label": "Organization or business schema",
220
+ "status": "ok",
221
+ "value": "present"
222
+ }
42
223
  ],
43
224
  "content": {
44
- "strengths": ["FAQ block on the homepage, a base for answer engines", "Enquiry form with a budget field", "Location block with real distances", "Key features with figures: 7 villas, 240 sqm plots"],
45
- "weaknesses": ["Title and description do not name the product or the place", "No single H1 with the project name and a real price", "The ownership FAQ is one sentence where buyers need a table", "Two different office addresses in the footer"],
46
- "gaps": ["/projects/<name>/: prices, floor plans, gallery, FAQ", "/location/<district>/: map, distances, infrastructure", "/foreign-buyers-guide/: ownership routes, fees, timeline", "/availability/: current prices and stock"]
225
+ "strengths": [
226
+ "FAQ block on the homepage, a base for answer engines",
227
+ "Enquiry form with a budget field",
228
+ "Location block with real distances",
229
+ "Key features with figures: 7 villas, 240 sqm plots"
230
+ ],
231
+ "weaknesses": [
232
+ "Title and description do not name the product or the place",
233
+ "No single H1 with the project name and a real price",
234
+ "The ownership FAQ is one sentence where buyers need a table",
235
+ "Two different office addresses in the footer"
236
+ ],
237
+ "gaps": [
238
+ "/projects/<name>/: prices, floor plans, gallery, FAQ",
239
+ "/location/<district>/: map, distances, infrastructure",
240
+ "/foreign-buyers-guide/: ownership routes, fees, timeline",
241
+ "/availability/: current prices and stock"
242
+ ]
47
243
  },
48
244
  "aeo": {
49
- "works": ["FAQPage schema with seven questions", "Organization and business schema", "Question and answer format in the FAQ block", "Blog articles cover informational intent"],
50
- "blocks": ["Ownership answers too short: no lease route, no risks", "No comparison tables for ownership options", "No sourced yield or price figures", "No expert bylines"],
245
+ "works": [
246
+ "FAQPage schema with seven questions",
247
+ "Organization and business schema",
248
+ "Question and answer format in the FAQ block",
249
+ "Blog articles cover informational intent"
250
+ ],
251
+ "blocks": [
252
+ "Ownership answers too short: no lease route, no risks",
253
+ "No comparison tables for ownership options",
254
+ "No sourced yield or price figures",
255
+ "No expert bylines"
256
+ ],
51
257
  "recommendation": "Publish one buyer guide with tables on ownership routes, fees and timeline, written so that a single paragraph can be quoted as the answer."
52
258
  },
53
259
  "geo": {
54
- "rows": [["Entity clarity", "△ Weak", "Schema present, but content confuses two project names"], ["Brand disambiguation", "✗ Problem", "The brand name matches a residential district in another country"], ["Third-party mentions", "✓ Present", "Listings on three property portals"], ["Reviews and PR", "✗ None", "No independent reviews found"]],
260
+ "rows": [
261
+ [
262
+ "Entity clarity",
263
+ "△ Weak",
264
+ "Schema present, but content confuses two project names"
265
+ ],
266
+ [
267
+ "Brand disambiguation",
268
+ "✗ Problem",
269
+ "The brand name matches a residential district in another country"
270
+ ],
271
+ [
272
+ "Third-party mentions",
273
+ "✓ Present",
274
+ "Listings on three property portals"
275
+ ],
276
+ [
277
+ "Reviews and PR",
278
+ "✗ None",
279
+ "No independent reviews found"
280
+ ]
281
+ ],
55
282
  "callout": "Brand collision: for the bare brand name, search engines show a district elsewhere rather than the developer. Use the brand together with the place in titles, schema and external profiles."
56
283
  },
57
284
  "offpage": {
58
- "listed": ["Two property portals carry the project with prices", "One aggregator carries a developer profile", "One agency page carries villa specifications"],
285
+ "listed": [
286
+ "Two property portals carry the project with prices",
287
+ "One aggregator carries a developer profile",
288
+ "One agency page carries villa specifications"
289
+ ],
59
290
  "note": "Portal listings drive discovery, but the authority accrues to the portals. The owned site's job is to become the primary source the portals link to."
60
291
  },
61
292
  "conversion": {
62
- "rows": [["Enquiry form", "✓ Works, budget field present"], ["Price on homepage", "✗ Placeholder instead of a figure"], ["Footer address", "✗ Two different addresses"], ["Call to action", "△ No clear next step"], ["Messengers", "△ Phone and email only"], ["Commerce plugin for 3 villas", "△ Adds weight without benefit"]]
293
+ "rows": [
294
+ [
295
+ "Enquiry form",
296
+ "✓ Works, budget field present"
297
+ ],
298
+ [
299
+ "Price on homepage",
300
+ "✗ Placeholder instead of a figure"
301
+ ],
302
+ [
303
+ "Footer address",
304
+ "✗ Two different addresses"
305
+ ],
306
+ [
307
+ "Call to action",
308
+ "△ No clear next step"
309
+ ],
310
+ [
311
+ "Messengers",
312
+ "△ Phone and email only"
313
+ ],
314
+ [
315
+ "Commerce plugin for 3 villas",
316
+ "△ Adds weight without benefit"
317
+ ]
318
+ ]
63
319
  },
64
- "limitations": ["Google Search Console: no impressions, clicks or query data", "Analytics: no traffic or behaviour data", "Keyword and backlink tools: not used in the external tier", "Lab performance report: not run; the first-byte figure is a single request"],
320
+ "limitations": [
321
+ "Google Search Console: no impressions, clicks or query data",
322
+ "Analytics: no traffic or behaviour data",
323
+ "Keyword and backlink tools: not used in the external tier",
324
+ "Lab performance report: not run; the first-byte figure is a single request"
325
+ ],
65
326
  "roadmap": [
66
- { "badge": "Week 1", "title": "Critical, quick wins", "items": ["Fix the XML sitemap and confirm the URL in robots.txt answers 200", "Rewrite the homepage title and description; remove the shortcode", "Rewrite llms.txt for the actual business; remove demo links", "Set noindex on shop, compare and wishlist", "One H1 on the homepage with the project name and a real starting price", "Remove user-scalable=no"] },
67
- { "badge": "Weeks 2 to 4", "title": "Foundation", "items": ["Project page with prices, plans and FAQ", "Buyer guide with ownership routes and fees", "Location page with map and distances", "Alt text on every image", "Search Console and Bing Webmaster connected", "One product line on the homepage, or two clearly separated"] },
68
- { "badge": "Months 2 to 3", "title": "Organic growth, AEO and GEO", "items": ["12 to 15 articles by intent cluster", "Product and place schema on villa pages", "PR and mentions in local property media", "Business profile for the office and the project location", "FAQ expanded to 15 to 20 sourced answers", "Comparison tables for ownership, fees and timeline"] }
327
+ {
328
+ "badge": "Week 1",
329
+ "title": "Critical, quick wins",
330
+ "items": [
331
+ "Fix the XML sitemap and confirm the URL in robots.txt answers 200",
332
+ "Rewrite the homepage title and description; remove the shortcode",
333
+ "Rewrite llms.txt for the actual business; remove demo links",
334
+ "Set noindex on shop, compare and wishlist",
335
+ "One H1 on the homepage with the project name and a real starting price",
336
+ "Remove user-scalable=no"
337
+ ]
338
+ },
339
+ {
340
+ "badge": "Weeks 2 to 4",
341
+ "title": "Foundation",
342
+ "items": [
343
+ "Project page with prices, plans and FAQ",
344
+ "Buyer guide with ownership routes and fees",
345
+ "Location page with map and distances",
346
+ "Alt text on every image",
347
+ "Search Console and Bing Webmaster connected",
348
+ "One product line on the homepage, or two clearly separated"
349
+ ]
350
+ },
351
+ {
352
+ "badge": "Months 2 to 3",
353
+ "title": "Organic growth, AEO and GEO",
354
+ "items": [
355
+ "12 to 15 articles by intent cluster",
356
+ "Product and place schema on villa pages",
357
+ "PR and mentions in local property media",
358
+ "Business profile for the office and the project location",
359
+ "FAQ expanded to 15 to 20 sourced answers",
360
+ "Comparison tables for ownership, fees and timeline"
361
+ ]
362
+ }
69
363
  ],
70
364
  "closing": "A beautiful site and a search asset are not the same thing. This one is pointed the right way, with a blog, a FAQ and schema, but was never cleaned up after launching on a template. The critical items take two weeks and show in Search Console within 30 to 60 days.",
71
365
  "sample": [
72
- { "url": "https://www.example-villas.example/", "title": "Home - Example Villas", "words": 940, "h1Count": 3, "images": 21, "imagesNoAlt": 19, "schemaTypes": ["RealEstateAgent", "Organization", "FAQPage"] },
73
- { "url": "https://www.example-villas.example/about/", "title": "About - Example Villas", "words": 420, "h1Count": 2, "images": 6, "imagesNoAlt": 6, "schemaTypes": [] },
74
- { "url": "https://www.example-villas.example/blog/why-invest/", "title": "Why invest in a pool villa", "words": 1610, "h1Count": 2, "images": 4, "imagesNoAlt": 4, "schemaTypes": ["Article"] },
75
- { "url": "https://www.example-villas.example/blog/location/", "title": "The location explained", "words": 1580, "h1Count": 2, "images": 5, "imagesNoAlt": 4, "schemaTypes": ["Article"] },
76
- { "url": "https://www.example-villas.example/contact/", "title": "Contact - Example Villas", "words": 180, "h1Count": 1, "images": 3, "imagesNoAlt": 3, "schemaTypes": [] }
366
+ {
367
+ "url": "https://www.example-villas.example/",
368
+ "title": "Home - Example Villas",
369
+ "words": 940,
370
+ "h1Count": 3,
371
+ "images": 21,
372
+ "imagesNoAlt": 19,
373
+ "schemaTypes": [
374
+ "RealEstateAgent",
375
+ "Organization",
376
+ "FAQPage"
377
+ ]
378
+ },
379
+ {
380
+ "url": "https://www.example-villas.example/about/",
381
+ "title": "About - Example Villas",
382
+ "words": 420,
383
+ "h1Count": 2,
384
+ "images": 6,
385
+ "imagesNoAlt": 6,
386
+ "schemaTypes": []
387
+ },
388
+ {
389
+ "url": "https://www.example-villas.example/blog/why-invest/",
390
+ "title": "Why invest in a pool villa",
391
+ "words": 1610,
392
+ "h1Count": 2,
393
+ "images": 4,
394
+ "imagesNoAlt": 4,
395
+ "schemaTypes": [
396
+ "Article"
397
+ ]
398
+ },
399
+ {
400
+ "url": "https://www.example-villas.example/blog/location/",
401
+ "title": "The location explained",
402
+ "words": 1580,
403
+ "h1Count": 2,
404
+ "images": 5,
405
+ "imagesNoAlt": 4,
406
+ "schemaTypes": [
407
+ "Article"
408
+ ]
409
+ },
410
+ {
411
+ "url": "https://www.example-villas.example/contact/",
412
+ "title": "Contact - Example Villas",
413
+ "words": 180,
414
+ "h1Count": 1,
415
+ "images": 3,
416
+ "imagesNoAlt": 3,
417
+ "schemaTypes": []
418
+ }
77
419
  ],
78
- "notes": []
420
+ "notes": [],
421
+ "scoreBasis": {
422
+ "SEO, technical": {
423
+ "counted": 9,
424
+ "ok": 3,
425
+ "warn": 3,
426
+ "bad": 3,
427
+ "note": "3 of 9 checks pass, 3 need attention, 3 fail"
428
+ },
429
+ "SEO, content and structure": {
430
+ "counted": 7,
431
+ "ok": 1,
432
+ "warn": 3,
433
+ "bad": 3,
434
+ "note": "1 of 7 checks pass, 3 need attention, 3 fail"
435
+ },
436
+ "AEO, answers and snippets": {
437
+ "counted": 2,
438
+ "ok": 2,
439
+ "warn": 0,
440
+ "bad": 0,
441
+ "note": "2 of 2 checks pass"
442
+ },
443
+ "GEO, visibility in AI systems": {
444
+ "counted": 2,
445
+ "ok": 1,
446
+ "warn": 0,
447
+ "bad": 1,
448
+ "note": "1 of 2 checks pass, 1 fail"
449
+ },
450
+ "Off-page and trust": {
451
+ "counted": 0,
452
+ "ok": 0,
453
+ "warn": 0,
454
+ "bad": 0,
455
+ "note": "not measured: no backlink or mention tool is used in this audit"
456
+ },
457
+ "Conversion and UX": {
458
+ "counted": 0,
459
+ "ok": 0,
460
+ "warn": 0,
461
+ "bad": 0,
462
+ "note": "not measured: no analytics or behaviour data was reviewed"
463
+ }
464
+ }
79
465
  }
package/package.json CHANGED
@@ -1,18 +1,41 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "OperStack",
8
- "bin": { "operstack-audit": "bin/audit.mjs" },
9
- "exports": { ".": "./src/index.mjs" },
10
- "files": ["bin", "src", "templates", "examples/sample-audit.json", "README.md", "LICENSE"],
8
+ "bin": {
9
+ "operstack-audit": "bin/audit.mjs"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.mjs"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "templates",
18
+ "examples/sample-audit.json",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
11
22
  "scripts": {
12
- "test": "node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node -e \"const s=require('fs').readFileSync('/tmp/operstack-audit-sample.html','utf8'); if(!s.includes('Executive summary')) process.exit(1); console.log('render ok', s.length, 'bytes')\"",
23
+ "test": "node src/test-scores.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
13
24
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
14
25
  },
15
- "keywords": ["seo", "aeo", "geo", "audit", "llms-txt", "site-audit", "report"],
16
- "engines": { "node": ">=20" },
17
- "dependencies": { "node-html-parser": "^9.0.4" }
26
+ "keywords": [
27
+ "seo",
28
+ "aeo",
29
+ "geo",
30
+ "audit",
31
+ "llms-txt",
32
+ "site-audit",
33
+ "report"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "dependencies": {
39
+ "node-html-parser": "^9.0.4"
40
+ }
18
41
  }
package/src/collect.mjs CHANGED
@@ -65,7 +65,49 @@ function analysePage(url, html) {
65
65
  const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
66
66
  const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
67
67
  const shortcode = /\[[a-z_-]+ [^\]]*\]/i.test(description) || /^[A-Za-z0-9+/=]{40,}$/.test(description);
68
- return { url, title, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates };
68
+ // Conversion path, read from the page itself. No analytics is needed to see whether a visitor
69
+ // who wants to talk to this business can find a way to do it, and how far down it is.
70
+ const bodyInner = body?.innerHTML || html;
71
+ const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|связ|заяв|консульт|оставить/i;
72
+ const hrefsLower = links.map((h) => h.toLowerCase());
73
+ const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
74
+ const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
75
+ const messengerLinks = hrefsLower.filter((h) => /(wa\.me|api\.whatsapp\.com|t\.me\/|telegram\.me|m\.me\/|viber:|signal\.me)/.test(h)).length;
76
+ const contactPages = hrefsLower.filter((h) => /\/(contact|contacts|contact-us|kontakt|svyaz|contacto)\/?($|[?#])/.test(h)).length;
77
+ const formEls = root.querySelectorAll('form');
78
+ const realForms = formEls.filter((f) => !/search/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''}`));
79
+ const firstForm = realForms[0] || null;
80
+ const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).length : 0;
81
+ const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
82
+ const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
83
+ const firstScreen = bodyInner.slice(0, Math.max(1200, Math.round(bodyInner.length * 0.15)));
84
+ const ctaFirstScreen = (firstScreen.match(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi) || [])
85
+ .some((el) => CONTACT_INTENT.test(el.replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(el));
86
+ // Trust signals a visitor and a rating system both look for, all of them public on the page.
87
+ const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
88
+ const trust = {
89
+ postalAddress: /"(?:PostalAddress|streetAddress|addressLocality)"/.test(ld),
90
+ telephone: /"telephone"\s*:/.test(ld) || telLinks > 0,
91
+ email: /"email"\s*:/.test(ld) || mailLinks > 0,
92
+ sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
93
+ aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
94
+ policyPages: [...new Set(hrefsLower.filter((h) => /\/(privacy|privacy-policy|terms|terms-of-use|legal|cookie|cookies|disclaimer)/.test(h)).map((h) => (h.match(/\/(privacy|terms|legal|cookie|disclaimer)/) || [])[1]).filter(Boolean))],
95
+ author: /"author"\s*:/.test(ld),
96
+ };
97
+ // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
98
+ // Site chrome is stripped, except a <header> that carries the H1: that is the article head, not chrome.
99
+ const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
100
+ .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
101
+ .replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
102
+ const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
103
+ const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
104
+ const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
105
+ const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
106
+ const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
107
+ const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
108
+ const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
109
+ const sourcePhrases = (text.match(/\b(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank)\b/gi) || []).length;
110
+ return { url, title, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
69
111
  }
70
112
 
71
113
  async function readSitemap(url, seen = new Set(), depth = 0) {
@@ -82,7 +124,8 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
82
124
  return out;
83
125
  }
84
126
 
85
- export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
127
+ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null } = {}) {
128
+ const opts = { backlinks };
86
129
  const origin = new URL(startUrl).origin;
87
130
  const host = new URL(startUrl).host;
88
131
  const checks = [];
@@ -115,7 +158,14 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
115
158
  const blockedAgents = blocks.filter((b) => !b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/')).flatMap((b) => b.agents);
116
159
  checks.push(row('robots', 'technical', 'robots.txt', robots.ok ? (sitemapUrls.length ? 'ok' : 'warn') : 'bad', robots.ok ? `${disallows.length} disallow rule(s) in ${blocks.length} agent block(s), ${sitemapUrls.length} sitemap line(s)` : `HTTP ${robots.status}`, robots.ok && !sitemapUrls.length ? 'no Sitemap: line' : ''));
117
160
  if (starBlocksAll) checks.push(row('robots-block', 'technical', 'robots.txt blocks the whole site', 'bad', 'Disallow: / under User-agent: *', 'search engines are told not to crawl anything'));
118
- if (blockedAgents.length) checks.push(row('robots-ai', 'geo', 'AI crawlers blocked in robots.txt', 'na', `${blockedAgents.length} agent(s) fully disallowed, e.g. ${blockedAgents.slice(0, 4).join(', ')}`, 'a policy choice: these systems will not read the site or cite it from a fresh crawl'));
161
+ // The two kinds of AI agent are not the same decision. A search fetcher is the one that can cite the
162
+ // site in an answer, so blocking it costs visibility and is scored. A training crawler is a policy
163
+ // choice the owner is entitled to make, so it is reported and never scored.
164
+ const AI_FETCHERS = ['oai-searchbot', 'chatgpt-user', 'perplexitybot', 'perplexity-user', 'claude-searchbot', 'claude-user', 'bingbot', 'duckassistbot', 'applebot'];
165
+ const blockedFetchers = blockedAgents.filter((a) => AI_FETCHERS.includes(a));
166
+ const blockedTrainers = blockedAgents.filter((a) => !AI_FETCHERS.includes(a));
167
+ checks.push(row('ai-search-access', 'geo', 'AI search fetchers allowed', blockedFetchers.length ? 'bad' : 'ok', blockedFetchers.length ? `${blockedFetchers.length} blocked: ${blockedFetchers.join(', ')}` : 'every AI search fetcher may read the site', blockedFetchers.length ? 'these are the agents that fetch a page to cite it in an answer' : ''));
168
+ if (blockedTrainers.length) checks.push(row('robots-ai', 'geo', 'AI training crawlers blocked (policy)', 'na', `${blockedTrainers.length} agent(s) fully disallowed: ${blockedTrainers.join(', ')}`, 'a policy choice, not a defect: these systems will not train on the site, and will not cite it from their own crawl either'));
119
169
 
120
170
  log('sitemaps');
121
171
  const candidates = sitemapUrls.length ? sitemapUrls : [`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`];
@@ -179,17 +229,69 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
179
229
  if (utility.length) checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, 'cart, wishlist, tag and author pages add noise to the index'));
180
230
  const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
181
231
  if (sitemapPages.length) checks.push(row('sitemap-health', 'technical', 'Sitemap URLs that answer 200', broken ? 'bad' : redirected ? 'warn' : 'ok', `${sample.length - redirected - broken} of ${sample.length} sampled URLs answer 200${redirected ? `, ${redirected} redirect` : ''}${broken ? `, ${broken} broken` : ''}`));
232
+ if (good.length) {
233
+ const af = good.filter((p) => p.answerFirst).length;
234
+ checks.push(row('answer-first', 'aeo', 'Answer-first opening paragraph', af === good.length ? 'ok' : af ? 'warn' : 'bad', `${af} of ${good.length} sampled page(s) open with 20 to 90 words carrying a figure, right after the H1`, af === good.length ? '' : 'that paragraph is the one an answer engine lifts'));
235
+ const structured = good.filter((p) => p.h2Count >= 3).length;
236
+ checks.push(row('sections', 'aeo', 'Section structure', structured === good.length ? 'ok' : structured ? 'warn' : 'bad', `${structured} of ${good.length} sampled page(s) carry three or more H2 sections`, structured === good.length ? '' : 'engines quote sections, not walls of text'));
237
+ const withTables = good.filter((p) => p.tables > 0).length;
238
+ checks.push(row('tables', 'aeo', 'Tables in the content', withTables ? 'ok' : 'warn', withTables ? `${withTables} of ${good.length} sampled page(s) use a table` : 'no table on the sampled pages', withTables ? '' : 'tables are the second most quoted format after the opening paragraph'));
239
+ const sourced = good.filter((p) => p.sourcePhrases > 0).length;
240
+ checks.push(row('sources', 'content', 'Sources named in the text', sourced === good.length ? 'ok' : sourced ? 'warn' : 'bad', `${sourced} of ${good.length} sampled page(s) name where a figure comes from`, sourced === good.length ? '' : 'an unsourced figure is the first thing an engine drops'));
241
+ }
182
242
  const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
183
243
  if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
184
244
  if (/wordpress/i.test(hp?.generator || '')) { const x = await get(`${origin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
185
245
 
186
- const scores = suggestScores(checks);
246
+ // ---- Off-page and trust ----------------------------------------------------------------
247
+ // Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
248
+ // reported as not measured and the area is still scored on the trust signals, which are free.
249
+ log('off-page and trust');
250
+ const pagesForTrust = hp ? [hp, ...good] : good;
251
+ const anyTrust = (f) => pagesForTrust.some(f);
252
+ const links = await semrushBacklinks(host, log, opts.backlinks || null);
253
+ if (links.ok) {
254
+ const followShare = links.total ? Math.round((links.follows / links.total) * 100) : 0;
255
+ checks.push(row('links-profile', 'offpage', 'Backlink profile (optional extra)', 'na', `${links.domains} referring domain(s), ${links.total} link(s), ${followShare}% follow, authority score ${links.ascore} of 100`, `${links.source}. A paid tool, not required for this report and not counted in any score.`));
256
+ }
257
+ const addr = anyTrust((p) => p.trust?.postalAddress);
258
+ checks.push(row('trust-entity', 'offpage', 'Who the business is, in machine-readable form', addr ? 'ok' : 'warn', addr ? 'a postal address is published in the structured data' : 'no postal address in the structured data', addr ? '' : 'rating systems and answer engines use it to tell one business from another'));
259
+ const tel = anyTrust((p) => p.trust?.telephone); const mail = anyTrust((p) => p.trust?.email);
260
+ checks.push(row('trust-contact', 'offpage', 'Direct contact details', tel && mail ? 'ok' : tel || mail ? 'warn' : 'bad', [tel ? 'phone' : '', mail ? 'email' : ''].filter(Boolean).join(' and ') || 'neither a phone nor an email is published'));
261
+ const ownHost = host.replace(/^www\./, '');
262
+ const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
263
+ checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'the cheapest off-site signal there is, and it is free'));
264
+ const about = anyTrust((p) => p.trust?.aboutPage);
265
+ checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'warn', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
266
+ const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
267
+ checks.push(row('trust-policies', 'offpage', 'Privacy and terms', policies.length >= 2 ? 'ok' : policies.length ? 'warn' : 'bad', policies.length ? policies.join(', ') : 'neither a privacy policy nor terms are linked'));
268
+
269
+ // ---- Conversion path -------------------------------------------------------------------
270
+ // Measured from the pages, not from analytics: whether a visitor who wants to talk can, and how
271
+ // far down the page the way to do it sits. Behaviour data, when the client grants it, adds to this.
272
+ const withForm = pagesForTrust.filter((p) => p.forms > 0);
273
+ const reachable = pagesForTrust.filter((p) => p.forms > 0 || p.telLinks || p.mailLinks || p.messengerLinks || p.contactPages);
274
+ checks.push(row('conv-contact-path', 'conversion', 'A way to get in touch', reachable.length === pagesForTrust.length ? 'ok' : reachable.length ? 'warn' : 'bad', `${reachable.length} of ${pagesForTrust.length} sampled page(s) offer a form, a contact link or a contact page`));
275
+ const withCta = pagesForTrust.filter((p) => p.ctaFirstScreen);
276
+ checks.push(row('conv-cta', 'conversion', 'A call to action in the first screen', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : withCta.length ? 'warn' : 'bad', `${withCta.length} of ${pagesForTrust.length} sampled page(s) put one in the first 15% of the page`, withCta.length === pagesForTrust.length ? '' : 'a reader who is convinced at the top should not have to hunt'));
277
+ const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
278
+ if (depths.length) {
279
+ const md = median(depths);
280
+ checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
281
+ } else checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length ? 'na' : 'bad', withForm.length ? 'position could not be measured' : 'no form on the sampled pages'));
282
+ const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
283
+ if (fields.length) { const mf = median(fields); checks.push(row('conv-form-fields', 'conversion', 'What the form asks for', mf <= 4 ? 'ok' : mf <= 6 ? 'warn' : 'bad', `median ${mf} field(s) before a visitor can send anything`, mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
284
+ const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
285
+ checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
286
+
287
+ const { scores, scoreBasis } = computeScores(checks);
187
288
  const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
188
289
 
189
290
  return {
190
291
  meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: 'External audit (no Search Console or analytics access)', language: hp ? (hp.og?.locale || '') : '' },
191
292
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
192
293
  scores,
294
+ scoreBasis,
193
295
  summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
194
296
  overview: { rows: [['CMS / stack', hp?.generator || '{{stack}}'], ['Pages in sitemap', String(sitemapPages.length)], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
195
297
  critical,
@@ -199,6 +301,10 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
199
301
  geo: { rows: [['Entity clarity', '{{status}}', '{{detail}}'], ['Third-party mentions', '{{status}}', '{{detail}}'], ['Reviews and PR', '{{status}}', '{{detail}}']], callout: '{{Brand disambiguation note or the single biggest GEO risk.}}' },
200
302
  offpage: { listed: ['{{where the brand is already listed}}'], note: '{{One paragraph.}}' },
201
303
  conversion: { rows: [['Contact form', '{{status}}'], ['Price visible', '{{status}}'], ['Messengers', '{{status}}']] },
304
+ sources: [
305
+ { name: 'The site itself', access: 'Free, no account needed', detail: `robots.txt, the sitemaps, llms.txt, the homepage and ${good.length} sampled page(s), read exactly as any visitor reads them` },
306
+ ...(links.ok ? [{ name: links.source, access: 'Paid tool, optional', detail: 'reported for context only, not counted in any score' }] : []),
307
+ ],
202
308
  limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
203
309
  roadmap: [
204
310
  { badge: 'Week 1', title: 'Critical, quick wins', items: critical.map((c) => `Fix: ${c.title}`).concat(['{{...}}']) },
@@ -211,18 +317,88 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
211
317
  };
212
318
  }
213
319
 
320
+
321
+ /**
322
+ * Backlink profile from the Semrush analytics API, when SEMRUSH_API_KEY is in the environment.
323
+ * Every failure returns ok:false with the reason, which the report prints instead of a score.
324
+ */
325
+ async function semrushBacklinks(host, log = () => {}, supplied = null) {
326
+ if (supplied) {
327
+ const need = ['ascore', 'total', 'domains'];
328
+ const missing = need.filter((k) => typeof supplied[k] !== 'number');
329
+ if (missing.length) return { ok: false, reason: `the supplied link data is missing ${missing.join(', ')}` };
330
+ return { ok: true, source: supplied.source || 'supplied link data', follows: supplied.follows ?? 0, nofollows: supplied.nofollows ?? 0, ...supplied };
331
+ }
332
+ const key = process.env.SEMRUSH_API_KEY;
333
+ if (!key) return { ok: false, reason: 'no link tool was used: no SEMRUSH_API_KEY and no link data supplied' };
334
+ const url = `https://api.semrush.com/analytics/v1/?key=${encodeURIComponent(key)}&type=backlinks_overview&target=${encodeURIComponent(host)}&target_type=root_domain&export_columns=ascore,total,domains_num,follows_num,nofollows_num`;
335
+ const r = await get(url, { timeout: 20000 });
336
+ if (!r.ok || /^ERROR/.test(r.text.trim())) {
337
+ const msg = (r.text || '').trim().slice(0, 120) || `HTTP ${r.status}`;
338
+ log(` link data unavailable: ${msg}`);
339
+ return { ok: false, reason: /132/.test(msg) ? 'the link tool had no API units left this month' : `the link tool answered: ${msg}` };
340
+ }
341
+ const [head, line] = r.text.trim().split(/\r?\n/);
342
+ if (!line) return { ok: false, reason: 'the link tool returned no row for this domain' };
343
+ const cols = head.split(';'); const vals = line.split(';');
344
+ const n = (name) => Number(vals[cols.indexOf(name)] || 0);
345
+ return { ok: true, source: `Semrush Backlink Analytics, ${new Date().toISOString().slice(0, 10)}`, ascore: n('ascore'), total: n('total'), domains: n('domains_num'), follows: n('follows_num'), nofollows: n('nofollows_num') };
346
+ }
347
+
214
348
  function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : 0; }
215
349
 
216
- /** 0 to 10 per group from the statuses: ok 1, warn 0.5, bad 0; na ignored. An analyst may override. */
217
- export function suggestScores(checks) {
218
- const groups = { technical: 'SEO, technical', onpage: 'SEO, content and structure', content: 'SEO, content and structure', aeo: 'AEO, answers and snippets', geo: 'GEO, visibility in AI systems' };
219
- const acc = {};
220
- for (const c of checks) {
221
- const g = groups[c.group]; if (!g || c.status === 'na') continue;
222
- acc[g] ??= { n: 0, s: 0 }; acc[g].n++; acc[g].s += c.status === 'ok' ? 1 : c.status === 'warn' ? 0.5 : 0;
350
+ /**
351
+ * The six areas of the report. `groups` lists the check groups that feed the score.
352
+ * An area with no groups is an area this audit does not test: it is reported as
353
+ * "not measured", never as a low score. Nothing in a report may be scored by hand.
354
+ */
355
+ export const SCORE_AREAS = [
356
+ { label: 'SEO, technical', groups: ['technical'] },
357
+ { label: 'SEO, content and structure', groups: ['onpage', 'content'] },
358
+ { label: 'AEO, answers and snippets', groups: ['aeo'] },
359
+ { label: 'GEO, visibility in AI systems', groups: ['geo'] },
360
+ { label: 'Off-page and trust', groups: ['offpage'] },
361
+ { label: 'Conversion and UX', groups: ['conversion'] },
362
+ ];
363
+
364
+ /**
365
+ * Score every area from the collected checks: ok 1, warn 0.5, bad 0, na ignored, rounded to 0-10.
366
+ * Returns the scores and the count each one came from, so the report can print its own evidence.
367
+ * An area with no counted check scores null and carries the reason it was not measured.
368
+ */
369
+ export function computeScores(checks) {
370
+ const scores = {}; const scoreBasis = {};
371
+ for (const area of SCORE_AREAS) {
372
+ const rows = (checks || []).filter((c) => area.groups.includes(c.group) && c.status !== 'na');
373
+ if (!rows.length) {
374
+ scores[area.label] = null;
375
+ scoreBasis[area.label] = { counted: 0, ok: 0, warn: 0, bad: 0, note: `not measured: ${area.reason || 'this audit collected no check in this area'}` };
376
+ continue;
377
+ }
378
+ const ok = rows.filter((c) => c.status === 'ok').length;
379
+ const warn = rows.filter((c) => c.status === 'warn').length;
380
+ const bad = rows.filter((c) => c.status === 'bad').length;
381
+ scores[area.label] = Math.round(((ok + warn * 0.5) / rows.length) * 10);
382
+ scoreBasis[area.label] = { counted: rows.length, ok, warn, bad, note: `${ok} of ${rows.length} checks pass${warn ? `, ${warn} ${warn === 1 ? 'needs' : 'need'} attention` : ''}${bad ? `, ${bad} ${bad === 1 ? 'fails' : 'fail'}` : ''}` };
383
+ }
384
+ return { scores, scoreBasis };
385
+ }
386
+
387
+ /** Every way the scores stored in an audit disagree with its own checks. Empty means reproducible. */
388
+ export function verifyScores(audit) {
389
+ const { scores } = computeScores(audit.checks || []);
390
+ const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/10`);
391
+ const problems = [];
392
+ for (const [label, expected] of Object.entries(scores)) {
393
+ const stored = (audit.scores || {})[label];
394
+ const actual = stored === undefined ? null : stored;
395
+ if (actual !== expected) problems.push(`${label}: the report says ${show(actual)}, the checks give ${show(expected)}`);
223
396
  }
224
- const out = {};
225
- for (const [g, { n, s }] of Object.entries(acc)) out[g] = Math.round((s / n) * 10);
226
- for (const g of ['Off-page and trust', 'Conversion and UX']) out[g] = out[g] ?? null;
227
- return out;
397
+ for (const label of Object.keys(audit.scores || {})) {
398
+ if (!(label in scores)) problems.push(`${label}: not one of the six audit areas, so nothing measures it`);
399
+ }
400
+ return problems;
228
401
  }
402
+
403
+ /** @deprecated kept for 0.1.x callers: use computeScores, which also returns the evidence. */
404
+ export function suggestScores(checks) { return computeScores(checks).scores; }
package/src/render.mjs CHANGED
@@ -7,11 +7,12 @@ import { writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
+ import { computeScores } from './collect.mjs';
10
11
 
11
12
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
12
13
  const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
13
14
  const t = (s) => (isPlaceholder(s) ? `<mark class="todo">${esc(s)}</mark>` : esc(s));
14
- const STATUS = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['·', 'status-na'] };
15
+ const STATUS = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['· Note', 'status-na'] };
15
16
  const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
16
17
 
17
18
  export function checkNarrative(audit) {
@@ -22,6 +23,7 @@ export function checkNarrative(audit) {
22
23
  else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k);
23
24
  };
24
25
  for (const k of ['client', 'summary', 'overview', 'critical', 'content', 'aeo', 'geo', 'offpage', 'conversion', 'roadmap', 'closing']) walk(audit[k], k);
26
+ (audit.critical || []).forEach((c, i) => { if (!c.text && !c.cost && !c.fix) out.push(`critical[${i}].text (the issue "${c.title || '?'}" would print as a bare headline)`); });
25
27
  return out;
26
28
  }
27
29
 
@@ -59,6 +61,8 @@ function css() {
59
61
  .score-num { font-family: "Fraunces", Georgia, serif; font-size: 22pt; font-weight: 600; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; }
60
62
  .score-num.low { color: var(--danger); } .score-num.mid { color: var(--warn); } .score-num.ok { color: var(--ok); }
61
63
  .score-note { font-size: 8.5pt; color: var(--muted); flex: 1; text-align: right; }
64
+ .score-num.na { font-family: inherit; font-size: 10pt; font-weight: 600; color: var(--muted); letter-spacing: .02em; }
65
+ .scorecard-foot { font-size: 8.5pt; color: var(--muted); margin: -3mm 0 6mm; }
62
66
  .verdict { background: var(--ink); color: #e8ecef; border-radius: 4px; padding: 5mm 6mm; margin: 5mm 0; }
63
67
  .verdict p { color: #e8ecef; margin: 0; }
64
68
  table { width: 100%; border-collapse: collapse; margin: 3mm 0 5mm; font-size: 9.2pt; }
@@ -68,7 +72,9 @@ function css() {
68
72
  .cards { display: grid; gap: 3mm; margin: 4mm 0; }
69
73
  .card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
70
74
  .card.warn { border-left-color: var(--warn); background: #fdf8ee; }
71
- .card h4 { font-size: 10pt; margin-bottom: 1.5mm; } .card p { font-size: 9.3pt; margin: 0; }
75
+ .card h4 { font-size: 10pt; margin-bottom: 1.5mm; } .card p { font-size: 9.3pt; margin: 0 0 1.5mm; } .card p:last-child { margin-bottom: 0; }
76
+ .card-tag { display: inline-block; font-size: 7.5pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-right: 2mm; }
77
+ .card-empty { color: var(--danger); font-style: italic; }
72
78
  .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
73
79
  .callout { background: var(--gold-soft); border-radius: 4px; padding: 4mm 5mm; margin: 4mm 0; border: 1px solid #e8dcc8; }
74
80
  .callout p { color: var(--ink); margin: 0; font-size: 9.5pt; }
@@ -89,25 +95,37 @@ const statusCell = (s) => { const [label, cls] = STATUS[s] || STATUS.na; return
89
95
 
90
96
  export function toHtml(audit) {
91
97
  const a = audit;
92
- const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['overview', 'Overview']];
98
+ const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
93
99
  const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
94
- const scoreCards = Object.entries(a.scores || {}).map(([label, n]) => `<div class="score"><div class="label">${esc(label)}</div><div class="score-row"><span class="score-num ${scoreClass(n)}">${n === null || n === undefined ? 'n/a' : `${n}/10`}</span><span class="score-note">${esc((a.scoreNotes || {})[label] || '')}</span></div></div>`).join('');
100
+ const { scores: measured, scoreBasis } = computeScores(a.checks || []);
101
+ const scoreCards = Object.entries(measured).map(([label, n]) => {
102
+ const basis = scoreBasis[label] || {};
103
+ const num = n === null ? '<span class="score-num na">not measured</span>' : `<span class="score-num ${scoreClass(n)}">${n}/10</span>`;
104
+ const note = n === null ? String(basis.note || '').replace(/^not measured:\s*/, '') : basis.note || '';
105
+ return `<div class="score"><div class="label">${esc(label)}</div><div class="score-row">${num}<span class="score-note">${esc(note)}</span></div></div>`;
106
+ }).join('');
95
107
  let n = 1;
96
108
  const pages = [];
97
109
  pages.push(`<div class="page"><div class="cover"><div><div class="cover-top"><div class="eyebrow">Digital marketing audit</div><h1>SEO, AEO and GEO<br>audit report</h1><p class="cover-sub">${t(a.client.subject)}</p><span class="cover-url">${esc(a.meta.host)}</span></div>
98
110
  <dl class="cover-meta"><div><dt>Audit subject</dt><dd>${t(a.client.name)}</dd></div><div><dt>Report date</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>Audit type</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>Prepared by</dt><dd>${esc(a.client.preparedBy || 'OperStack')}</dd></div></dl></div>
99
111
  <div class="cover-foot">Public signals were collected by ${esc(a.meta.tool)} on ${esc((a.meta.collectedAt || '').slice(0, 10))}. Every status in this report can be reproduced from the site as it stood on that day.</div></div></div>`);
100
112
  n++;
101
- pages.push(`<div class="page">${header(a, 'Executive summary')}<div class="eyebrow">01 · Summary</div><h2>Executive summary</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><div class="verdict"><p><strong>Key takeaway:</strong> ${t(a.summary.verdict)}</p></div><h3>Three priorities</h3>${olist(a.summary.priorities)}${footer(a, n++)}</div>`);
113
+ pages.push(`<div class="page">${header(a, 'Executive summary')}<div class="eyebrow">01 · Summary</div><h2>Executive summary</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from section 04 onwards.</p><div class="verdict"><p><strong>Key takeaway:</strong> ${t(a.summary.verdict)}</p></div><h3>Three priorities</h3>${olist(a.summary.priorities)}${footer(a, n++)}</div>`);
102
114
  pages.push(`<div class="page">${header(a, 'Site overview')}<div class="eyebrow">02 · Overview</div><h2>What the site is</h2><table><tr><th>Parameter</th><th>Value</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>Pages sampled</h3><table><tr><th>URL</th><th>Title</th><th>Words</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++)}</div>`);
103
- const cards = (a.critical || []).map((c, i) => `<div class="card ${c.level === 'warn' ? 'warn' : ''}"><h4>${i + 1}. ${t(c.title)}</h4><p>${t(c.text)}</p></div>`).join('') || '<p>No critical defects were found in the public signals.</p>';
115
+ const cardBody = (c) => [
116
+ c.text ? `<p>${t(c.text)}</p>` : '',
117
+ c.cost ? `<p><span class="card-tag">What it costs</span>${t(c.cost)}</p>` : '',
118
+ c.fix ? `<p><span class="card-tag">Fix</span>${t(c.fix)}</p>` : '',
119
+ ].join('') || '<p class="card-empty">This issue has no description in the audit file.</p>';
120
+ const cards = (a.critical || []).map((c, i) => `<div class="card ${c.level === 'warn' ? 'warn' : ''}"><h4>${i + 1}. ${t(c.title)}</h4>${cardBody(c)}</div>`).join('') || '<p>No critical defects were found in the public signals.</p>';
104
121
  pages.push(`<div class="page">${header(a, 'Critical issues')}<div class="eyebrow">03 · P0</div><h2>Critical issues, fix first</h2><div class="cards">${cards}</div>${footer(a, n++)}</div>`);
105
122
  const techRows = [...checksBy('technical'), ...checksBy('onpage')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('');
106
123
  pages.push(`<div class="page">${header(a, 'Technical SEO')}<div class="eyebrow">04 · Technical</div><h2>Technical and on-page checklist</h2><table><tr><th>Check</th><th>Status</th><th>Finding</th></tr>${techRows}</table>${footer(a, n++)}</div>`);
107
124
  pages.push(`<div class="page">${header(a, 'Content and on-page')}<div class="eyebrow">05 · Content</div><h2>Content and on-page SEO</h2><div class="two-col"><div><h3>Strengths</h3>${list(a.content.strengths)}</div><div><h3>Weaknesses</h3>${list(a.content.weaknesses)}</div></div><h3>Structural gaps: pages that do not exist yet</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>Check</th><th>Status</th><th>Finding</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++)}</div>`);
108
- const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment)}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
125
+ const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment.charAt(0).toUpperCase() + c.comment.slice(1))}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
109
126
  pages.push(`<div class="page">${header(a, 'AEO and GEO')}<div class="eyebrow">06 · AEO / GEO</div><h2>Answer engines: AEO</h2><p class="lead">Whether the site's answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.</p><div class="two-col"><div><h3>What already works</h3>${list(a.aeo.works)}</div><div><h3>What blocks the wins</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>Recommendation:</strong> ${t(a.aeo.recommendation)}</p></div><h2>Generative engines: GEO</h2><table><tr><th>Signal</th><th>Status</th><th>Detail</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++)}</div>`);
110
- pages.push(`<div class="page">${header(a, 'Off-page, conversion, limitations')}<div class="eyebrow">07 · Off-page</div><h2>Off-page and external presence</h2>${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">08 · Conversion</div><h2>Conversion and UX</h2><table><tr><th>Element</th><th>Status</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><div class="eyebrow" style="margin-top:8mm">09 · Limitations</div><h2>Audit limitations</h2><p>This report is based on public data only:</p>${list(a.limitations)}${footer(a, n++)}</div>`);
127
+ const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>Check</th><th>Status</th><th>Finding</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('')}</table>` : '');
128
+ pages.push(`<div class="page">${header(a, 'Off-page, conversion, limitations')}<div class="eyebrow">07 · Off-page</div><h2>Off-page and trust</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">08 · Conversion</div><h2>Conversion and UX</h2>${checkTable('conversion')}<table><tr><th>Element</th><th>Status</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><div class="eyebrow" style="margin-top:8mm">09 · Limitations</div><h2>Where the data comes from</h2>${(a.sources || []).length ? `<table><tr><th>Source</th><th>Access</th><th>What was read</th></tr>${a.sources.map((s) => `<tr><td>${t(s.name)}</td><td>${t(s.access)}</td><td>${t(s.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same numbers.</p>` : ''}<h3>Limitations</h3>${list(a.limitations)}${footer(a, n++)}</div>`);
111
129
  const phases = (a.roadmap || []).map((p) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(p.badge)}</span><span class="phase-title">${t(p.title)}</span></div>${olist(p.items)}</div>`).join('');
112
130
  pages.push(`<div class="page">${header(a, 'Roadmap')}<div class="eyebrow">10 · Roadmap</div><h2>Work plan: priorities and timeline</h2>${phases}<div class="verdict"><p><strong>Key message:</strong> ${t(a.closing)}</p></div>${footer(a, n++)}</div>`);
113
131
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: SEO, AEO and GEO audit</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}</style></head><body>${pages.join('\n')}</body></html>`;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The rule this file exists to defend: no number in a report is typed by a human.
3
+ * Every score is computed from the collected checks, and an area with no checks is
4
+ * printed as "not measured" rather than scored on an impression.
5
+ */
6
+ import { computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
7
+ import { toHtml, checkNarrative } from './render.mjs';
8
+
9
+ let failed = 0;
10
+ const is = (name, actual, expected) => {
11
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
12
+ if (!ok) { failed++; console.error(`FAIL ${name}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
13
+ else console.log(`ok ${name}`);
14
+ };
15
+ const ok = (name, cond) => is(name, Boolean(cond), true);
16
+
17
+ const checks = [
18
+ { group: 'technical', status: 'ok' }, { group: 'technical', status: 'ok' },
19
+ { group: 'technical', status: 'warn' }, { group: 'technical', status: 'na' },
20
+ { group: 'aeo', status: 'bad' }, { group: 'aeo', status: 'ok' },
21
+ ];
22
+ const { scores, scoreBasis } = computeScores(checks);
23
+
24
+ is('ok 1, warn 0.5, bad 0, na ignored', scores['SEO, technical'], 8);
25
+ is('an area with half its checks failing scores 5', scores['AEO, answers and snippets'], 5);
26
+ is('na is not counted', scoreBasis['SEO, technical'].counted, 3);
27
+ is('an area with no checks is not measured', scores['Off-page and trust'], null);
28
+ is('and says why', scoreBasis['Off-page and trust'].note, 'not measured: this audit collected no check in this area');
29
+ is('conversion is not measured either', scores['Conversion and UX'], null);
30
+ is('every area is accounted for', Object.keys(scores).length, SCORE_AREAS.length);
31
+ is('the basis is printable evidence', scoreBasis['AEO, answers and snippets'].note, '1 of 2 checks pass, 1 fails');
32
+
33
+ // an area is scored as soon as its checks are collected, and only then
34
+ const withOffpage = computeScores([
35
+ ...checks,
36
+ { group: 'offpage', status: 'ok' }, { group: 'offpage', status: 'warn' }, { group: 'offpage', status: 'na' },
37
+ { group: 'conversion', status: 'bad' }, { group: 'conversion', status: 'ok' },
38
+ ]);
39
+ is('off-page is scored from its own checks', withOffpage.scores['Off-page and trust'], 8);
40
+ is('and the na row is not counted', withOffpage.scoreBasis['Off-page and trust'].counted, 2);
41
+ is('conversion is scored from its own checks', withOffpage.scores['Conversion and UX'], 5);
42
+ is('an area with checks is never "not measured"', withOffpage.scoreBasis['Conversion and UX'].note, '1 of 2 checks pass, 1 fails');
43
+
44
+ const typed = { checks, scores: { 'SEO, technical': 2, 'Off-page and trust': 6, 'Made-up area': 9 } };
45
+ const problems = verifyScores(typed);
46
+ ok('a typed score is caught', problems.some((p) => p.startsWith('SEO, technical:')));
47
+ ok('a score for an unmeasured area is caught', problems.some((p) => p.startsWith('Off-page and trust:')));
48
+ ok('an invented area is caught', problems.some((p) => p.includes('not one of the six audit areas')));
49
+ is('a report that matches its checks has no problems', verifyScores({ checks, scores }), []);
50
+
51
+ const audit = {
52
+ meta: { host: 'example.com', collectedAt: '2026-09-11T00:00:00.000Z', tool: 'test', auditType: 'test' },
53
+ client: { name: 'Example', subject: 'test', reportDate: '2026-09-11' },
54
+ scores: { 'SEO, technical': 2, 'Off-page and trust': 2 },
55
+ checks,
56
+ summary: { lead: 'x', verdict: 'x', priorities: ['x'] },
57
+ overview: { rows: [['a', 'b']], note: 'x' },
58
+ critical: [{ title: 'An issue with no text', cost: 'what it costs', fix: 'how to fix it' }, { title: 'A bare headline' }],
59
+ content: { strengths: ['x'], weaknesses: ['x'], gaps: ['x'] },
60
+ aeo: { works: ['x'], blocks: ['x'], recommendation: 'x' },
61
+ geo: { rows: [['a', 'b', 'c']], callout: 'x' },
62
+ offpage: { listed: ['x'], note: 'x' },
63
+ conversion: { rows: [['a', 'b']] },
64
+ limitations: ['x'], roadmap: [{ badge: 'w', title: 't', items: ['x'] }], closing: 'x', sample: [],
65
+ };
66
+ const html = toHtml(audit);
67
+ ok('the report prints the computed score, not the typed one', html.includes('8/10'));
68
+ ok('the typed 2/10 never reaches the page', !html.includes('>2/10<'));
69
+ ok('an unmeasured area says so', html.includes('not measured'));
70
+ ok('the scorecard shows the count it came from', html.includes('2 of 3 checks pass'));
71
+ ok('a critical issue prints what it costs and how to fix it', html.includes('What it costs') && html.includes('how to fix it'));
72
+ ok('a critical issue with no body is reported', checkNarrative(audit).some((m) => m.startsWith('critical[1].text')));
73
+
74
+ if (failed) { console.error(`\n${failed} test(s) failed`); process.exit(1); }
75
+ console.log('\nall scoring tests pass');