@operstack/audit 0.1.0 → 0.3.1
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 +19 -2
- package/bin/audit.mjs +18 -8
- package/examples/sample-audit.json +434 -48
- package/package.json +31 -8
- package/src/collect.mjs +203 -15
- package/src/render.mjs +26 -8
- package/src/test-scores.mjs +75 -0
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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
44
|
-
|
|
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": {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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": [
|
|
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": [
|
|
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
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
{
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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": [
|
|
45
|
-
|
|
46
|
-
|
|
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": [
|
|
50
|
-
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
3
|
+
"version": "0.3.1",
|
|
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": {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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": [
|
|
16
|
-
|
|
17
|
-
|
|
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,61 @@ 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
|
-
|
|
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
|
+
// A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
|
|
81
|
+
const visibleToVisitor = (el) => {
|
|
82
|
+
if (el.getAttribute('tabindex') === '-1') return false;
|
|
83
|
+
if (/honeypot|\bhp\b/i.test(`${el.getAttribute('class') || ''} ${el.getAttribute('name') || ''}`)) return false;
|
|
84
|
+
for (let n = el.parentNode; n; n = n.parentNode) {
|
|
85
|
+
if (typeof n.getAttribute !== 'function') break;
|
|
86
|
+
if (n.getAttribute('aria-hidden') === 'true') return false;
|
|
87
|
+
if (/honeypot|\bhp\b/i.test(n.getAttribute('class') || '')) return false;
|
|
88
|
+
if (/display:\s*none|visibility:\s*hidden/i.test(n.getAttribute('style') || '')) return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
|
|
93
|
+
const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
|
|
94
|
+
const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
|
|
95
|
+
const firstScreen = bodyInner.slice(0, Math.max(1200, Math.round(bodyInner.length * 0.15)));
|
|
96
|
+
const ctaFirstScreen = (firstScreen.match(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi) || [])
|
|
97
|
+
.some((el) => CONTACT_INTENT.test(el.replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(el));
|
|
98
|
+
// Trust signals a visitor and a rating system both look for, all of them public on the page.
|
|
99
|
+
const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
|
|
100
|
+
const trust = {
|
|
101
|
+
postalAddress: /"(?:PostalAddress|streetAddress|addressLocality)"/.test(ld),
|
|
102
|
+
telephone: /"telephone"\s*:/.test(ld) || telLinks > 0,
|
|
103
|
+
email: /"email"\s*:/.test(ld) || mailLinks > 0,
|
|
104
|
+
sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
|
|
105
|
+
aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
|
|
106
|
+
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))],
|
|
107
|
+
author: /"author"\s*:/.test(ld),
|
|
108
|
+
};
|
|
109
|
+
// Answer-first family, the same measures the free AI visibility check uses, so the two agree.
|
|
110
|
+
// Site chrome is stripped, except a <header> that carries the H1: that is the article head, not chrome.
|
|
111
|
+
const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
|
|
112
|
+
.replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
113
|
+
.replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
|
|
114
|
+
const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
|
115
|
+
const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
|
|
116
|
+
const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
|
|
117
|
+
const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
118
|
+
const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
|
|
119
|
+
const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
|
|
120
|
+
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
121
|
+
const sourcePhrases = (text.match(/\b(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank)\b/gi) || []).length;
|
|
122
|
+
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
123
|
}
|
|
70
124
|
|
|
71
125
|
async function readSitemap(url, seen = new Set(), depth = 0) {
|
|
@@ -82,7 +136,8 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
|
|
|
82
136
|
return out;
|
|
83
137
|
}
|
|
84
138
|
|
|
85
|
-
export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
|
|
139
|
+
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null } = {}) {
|
|
140
|
+
const opts = { backlinks };
|
|
86
141
|
const origin = new URL(startUrl).origin;
|
|
87
142
|
const host = new URL(startUrl).host;
|
|
88
143
|
const checks = [];
|
|
@@ -115,7 +170,14 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
|
|
|
115
170
|
const blockedAgents = blocks.filter((b) => !b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/')).flatMap((b) => b.agents);
|
|
116
171
|
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
172
|
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
|
-
|
|
173
|
+
// The two kinds of AI agent are not the same decision. A search fetcher is the one that can cite the
|
|
174
|
+
// site in an answer, so blocking it costs visibility and is scored. A training crawler is a policy
|
|
175
|
+
// choice the owner is entitled to make, so it is reported and never scored.
|
|
176
|
+
const AI_FETCHERS = ['oai-searchbot', 'chatgpt-user', 'perplexitybot', 'perplexity-user', 'claude-searchbot', 'claude-user', 'bingbot', 'duckassistbot', 'applebot'];
|
|
177
|
+
const blockedFetchers = blockedAgents.filter((a) => AI_FETCHERS.includes(a));
|
|
178
|
+
const blockedTrainers = blockedAgents.filter((a) => !AI_FETCHERS.includes(a));
|
|
179
|
+
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' : ''));
|
|
180
|
+
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
181
|
|
|
120
182
|
log('sitemaps');
|
|
121
183
|
const candidates = sitemapUrls.length ? sitemapUrls : [`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`];
|
|
@@ -179,17 +241,69 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
|
|
|
179
241
|
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
242
|
const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
|
|
181
243
|
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` : ''}`));
|
|
244
|
+
if (good.length) {
|
|
245
|
+
const af = good.filter((p) => p.answerFirst).length;
|
|
246
|
+
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'));
|
|
247
|
+
const structured = good.filter((p) => p.h2Count >= 3).length;
|
|
248
|
+
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'));
|
|
249
|
+
const withTables = good.filter((p) => p.tables > 0).length;
|
|
250
|
+
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'));
|
|
251
|
+
const sourced = good.filter((p) => p.sourcePhrases > 0).length;
|
|
252
|
+
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'));
|
|
253
|
+
}
|
|
182
254
|
const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
|
|
183
255
|
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
256
|
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
257
|
|
|
186
|
-
|
|
258
|
+
// ---- Off-page and trust ----------------------------------------------------------------
|
|
259
|
+
// Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
|
|
260
|
+
// reported as not measured and the area is still scored on the trust signals, which are free.
|
|
261
|
+
log('off-page and trust');
|
|
262
|
+
const pagesForTrust = hp ? [hp, ...good] : good;
|
|
263
|
+
const anyTrust = (f) => pagesForTrust.some(f);
|
|
264
|
+
const links = await semrushBacklinks(host, log, opts.backlinks || null);
|
|
265
|
+
if (links.ok) {
|
|
266
|
+
const followShare = links.total ? Math.round((links.follows / links.total) * 100) : 0;
|
|
267
|
+
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.`));
|
|
268
|
+
}
|
|
269
|
+
const addr = anyTrust((p) => p.trust?.postalAddress);
|
|
270
|
+
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'));
|
|
271
|
+
const tel = anyTrust((p) => p.trust?.telephone); const mail = anyTrust((p) => p.trust?.email);
|
|
272
|
+
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'));
|
|
273
|
+
const ownHost = host.replace(/^www\./, '');
|
|
274
|
+
const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
|
|
275
|
+
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'));
|
|
276
|
+
const about = anyTrust((p) => p.trust?.aboutPage);
|
|
277
|
+
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'));
|
|
278
|
+
const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
|
|
279
|
+
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'));
|
|
280
|
+
|
|
281
|
+
// ---- Conversion path -------------------------------------------------------------------
|
|
282
|
+
// Measured from the pages, not from analytics: whether a visitor who wants to talk can, and how
|
|
283
|
+
// far down the page the way to do it sits. Behaviour data, when the client grants it, adds to this.
|
|
284
|
+
const withForm = pagesForTrust.filter((p) => p.forms > 0);
|
|
285
|
+
const reachable = pagesForTrust.filter((p) => p.forms > 0 || p.telLinks || p.mailLinks || p.messengerLinks || p.contactPages);
|
|
286
|
+
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`));
|
|
287
|
+
const withCta = pagesForTrust.filter((p) => p.ctaFirstScreen);
|
|
288
|
+
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'));
|
|
289
|
+
const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
|
|
290
|
+
if (depths.length) {
|
|
291
|
+
const md = median(depths);
|
|
292
|
+
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' : ''));
|
|
293
|
+
} 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'));
|
|
294
|
+
const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
|
|
295
|
+
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' : '')); }
|
|
296
|
+
const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
|
|
297
|
+
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'));
|
|
298
|
+
|
|
299
|
+
const { scores, scoreBasis } = computeScores(checks);
|
|
187
300
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
188
301
|
|
|
189
302
|
return {
|
|
190
303
|
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
304
|
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
|
|
192
305
|
scores,
|
|
306
|
+
scoreBasis,
|
|
193
307
|
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
308
|
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
309
|
critical,
|
|
@@ -199,6 +313,10 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
|
|
|
199
313
|
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
314
|
offpage: { listed: ['{{where the brand is already listed}}'], note: '{{One paragraph.}}' },
|
|
201
315
|
conversion: { rows: [['Contact form', '{{status}}'], ['Price visible', '{{status}}'], ['Messengers', '{{status}}']] },
|
|
316
|
+
sources: [
|
|
317
|
+
{ 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` },
|
|
318
|
+
...(links.ok ? [{ name: links.source, access: 'Paid tool, optional', detail: 'reported for context only, not counted in any score' }] : []),
|
|
319
|
+
],
|
|
202
320
|
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
321
|
roadmap: [
|
|
204
322
|
{ badge: 'Week 1', title: 'Critical, quick wins', items: critical.map((c) => `Fix: ${c.title}`).concat(['{{...}}']) },
|
|
@@ -211,18 +329,88 @@ export async function collect(startUrl, { pages = 20, log = () => {} } = {}) {
|
|
|
211
329
|
};
|
|
212
330
|
}
|
|
213
331
|
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Backlink profile from the Semrush analytics API, when SEMRUSH_API_KEY is in the environment.
|
|
335
|
+
* Every failure returns ok:false with the reason, which the report prints instead of a score.
|
|
336
|
+
*/
|
|
337
|
+
async function semrushBacklinks(host, log = () => {}, supplied = null) {
|
|
338
|
+
if (supplied) {
|
|
339
|
+
const need = ['ascore', 'total', 'domains'];
|
|
340
|
+
const missing = need.filter((k) => typeof supplied[k] !== 'number');
|
|
341
|
+
if (missing.length) return { ok: false, reason: `the supplied link data is missing ${missing.join(', ')}` };
|
|
342
|
+
return { ok: true, source: supplied.source || 'supplied link data', follows: supplied.follows ?? 0, nofollows: supplied.nofollows ?? 0, ...supplied };
|
|
343
|
+
}
|
|
344
|
+
const key = process.env.SEMRUSH_API_KEY;
|
|
345
|
+
if (!key) return { ok: false, reason: 'no link tool was used: no SEMRUSH_API_KEY and no link data supplied' };
|
|
346
|
+
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`;
|
|
347
|
+
const r = await get(url, { timeout: 20000 });
|
|
348
|
+
if (!r.ok || /^ERROR/.test(r.text.trim())) {
|
|
349
|
+
const msg = (r.text || '').trim().slice(0, 120) || `HTTP ${r.status}`;
|
|
350
|
+
log(` link data unavailable: ${msg}`);
|
|
351
|
+
return { ok: false, reason: /132/.test(msg) ? 'the link tool had no API units left this month' : `the link tool answered: ${msg}` };
|
|
352
|
+
}
|
|
353
|
+
const [head, line] = r.text.trim().split(/\r?\n/);
|
|
354
|
+
if (!line) return { ok: false, reason: 'the link tool returned no row for this domain' };
|
|
355
|
+
const cols = head.split(';'); const vals = line.split(';');
|
|
356
|
+
const n = (name) => Number(vals[cols.indexOf(name)] || 0);
|
|
357
|
+
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') };
|
|
358
|
+
}
|
|
359
|
+
|
|
214
360
|
function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : 0; }
|
|
215
361
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
362
|
+
/**
|
|
363
|
+
* The six areas of the report. `groups` lists the check groups that feed the score.
|
|
364
|
+
* An area with no groups is an area this audit does not test: it is reported as
|
|
365
|
+
* "not measured", never as a low score. Nothing in a report may be scored by hand.
|
|
366
|
+
*/
|
|
367
|
+
export const SCORE_AREAS = [
|
|
368
|
+
{ label: 'SEO, technical', groups: ['technical'] },
|
|
369
|
+
{ label: 'SEO, content and structure', groups: ['onpage', 'content'] },
|
|
370
|
+
{ label: 'AEO, answers and snippets', groups: ['aeo'] },
|
|
371
|
+
{ label: 'GEO, visibility in AI systems', groups: ['geo'] },
|
|
372
|
+
{ label: 'Off-page and trust', groups: ['offpage'] },
|
|
373
|
+
{ label: 'Conversion and UX', groups: ['conversion'] },
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Score every area from the collected checks: ok 1, warn 0.5, bad 0, na ignored, rounded to 0-10.
|
|
378
|
+
* Returns the scores and the count each one came from, so the report can print its own evidence.
|
|
379
|
+
* An area with no counted check scores null and carries the reason it was not measured.
|
|
380
|
+
*/
|
|
381
|
+
export function computeScores(checks) {
|
|
382
|
+
const scores = {}; const scoreBasis = {};
|
|
383
|
+
for (const area of SCORE_AREAS) {
|
|
384
|
+
const rows = (checks || []).filter((c) => area.groups.includes(c.group) && c.status !== 'na');
|
|
385
|
+
if (!rows.length) {
|
|
386
|
+
scores[area.label] = null;
|
|
387
|
+
scoreBasis[area.label] = { counted: 0, ok: 0, warn: 0, bad: 0, note: `not measured: ${area.reason || 'this audit collected no check in this area'}` };
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const ok = rows.filter((c) => c.status === 'ok').length;
|
|
391
|
+
const warn = rows.filter((c) => c.status === 'warn').length;
|
|
392
|
+
const bad = rows.filter((c) => c.status === 'bad').length;
|
|
393
|
+
scores[area.label] = Math.round(((ok + warn * 0.5) / rows.length) * 10);
|
|
394
|
+
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'}` : ''}` };
|
|
395
|
+
}
|
|
396
|
+
return { scores, scoreBasis };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Every way the scores stored in an audit disagree with its own checks. Empty means reproducible. */
|
|
400
|
+
export function verifyScores(audit) {
|
|
401
|
+
const { scores } = computeScores(audit.checks || []);
|
|
402
|
+
const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/10`);
|
|
403
|
+
const problems = [];
|
|
404
|
+
for (const [label, expected] of Object.entries(scores)) {
|
|
405
|
+
const stored = (audit.scores || {})[label];
|
|
406
|
+
const actual = stored === undefined ? null : stored;
|
|
407
|
+
if (actual !== expected) problems.push(`${label}: the report says ${show(actual)}, the checks give ${show(expected)}`);
|
|
223
408
|
}
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return
|
|
409
|
+
for (const label of Object.keys(audit.scores || {})) {
|
|
410
|
+
if (!(label in scores)) problems.push(`${label}: not one of the six audit areas, so nothing measures it`);
|
|
411
|
+
}
|
|
412
|
+
return problems;
|
|
228
413
|
}
|
|
414
|
+
|
|
415
|
+
/** @deprecated kept for 0.1.x callers: use computeScores, which also returns the evidence. */
|
|
416
|
+
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
|
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
|
|
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
|
-
|
|
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');
|