@operstack/audit 0.13.1 → 0.15.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 +42 -0
- package/bin/audit.mjs +59 -1
- package/licensing/public.pem +3 -0
- package/package.json +3 -2
- package/src/agency.mjs +108 -0
- package/src/batch.mjs +107 -0
- package/src/collect.mjs +2 -2
- package/src/fix.mjs +16 -6
- package/src/prospect.mjs +282 -0
- package/src/render.mjs +27 -16
- package/src/test-agency-ids.mjs +48 -0
- package/src/test-agency.mjs +132 -0
package/README.md
CHANGED
|
@@ -141,3 +141,45 @@ The external tier reads public pages only. It does not run a lab performance tes
|
|
|
141
141
|
## License
|
|
142
142
|
|
|
143
143
|
MIT. Built by OperStack.
|
|
144
|
+
|
|
145
|
+
## Agency plan
|
|
146
|
+
|
|
147
|
+
Signing a report with someone else's name is free:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
operstack-audit render audit.json --by "Harper & Vale Digital"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The cover and every page footer then carry that name. The cover keeps one line saying which tool
|
|
154
|
+
collected the signals, so the report still shows its method.
|
|
155
|
+
|
|
156
|
+
The agency plan replaces that with the buyer's own identity and adds bulk runs:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
operstack-audit batch clients.txt --by "Harper & Vale Digital" \
|
|
160
|
+
--logo brand/logo.svg --color "#7b3fa0" --no-tool-line --pdf
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`clients.txt` holds one site per line, either `example.com` or `example.com, Client Name`. Every
|
|
164
|
+
report lands in `reports/`, and a site that fails does not stop the rest.
|
|
165
|
+
|
|
166
|
+
The key goes in `--licence`, in `OPERSTACK_LICENCE`, or in a `.operstack-licence` file beside the
|
|
167
|
+
work. This package is MIT and its source is public, so the key states the terms of commercial use
|
|
168
|
+
and records who bought them; it is not a technical lock. Plan: https://oper-stack.com/products/agency/
|
|
169
|
+
|
|
170
|
+
## Who to write to first
|
|
171
|
+
|
|
172
|
+
`prospect` answers the question a batch of reports does not: of twenty sites, which one has the
|
|
173
|
+
worst problem and what do you say in the first line of the email.
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
operstack-audit prospect clients.txt --out prospects
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Out comes `prospects.csv` and `prospects.md`: one row per site, weakest first, with the two or
|
|
180
|
+
three open findings and one sentence written for a business owner rather than for an engineer.
|
|
181
|
+
Every sentence is a translation of a check that measured something; where there is no translation,
|
|
182
|
+
the raw finding is printed instead of an invention.
|
|
183
|
+
|
|
184
|
+
It runs the same checks as the report but skips the writing and the layout, so twenty sites take
|
|
185
|
+
minutes. Free, no licence needed.
|
package/bin/audit.mjs
CHANGED
|
@@ -30,6 +30,8 @@ import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist
|
|
|
30
30
|
import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
|
|
31
31
|
import { renderAgentPrompts, agentPrompts } from '../src/prompts.mjs';
|
|
32
32
|
import { render, checkNarrative } from '../src/render.mjs';
|
|
33
|
+
import { resolveBranding } from '../src/agency.mjs';
|
|
34
|
+
import { readList, runBatch, runProspect, summarise, toCsv, toMarkdown } from '../src/batch.mjs';
|
|
33
35
|
import { writeFileSync, readFileSync } from 'node:fs';
|
|
34
36
|
import { resolve } from 'node:path';
|
|
35
37
|
|
|
@@ -58,6 +60,23 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
|
58
60
|
' what was closed, what was not, and how much to refund',
|
|
59
61
|
' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--price] [--out base]',
|
|
60
62
|
' the text work the Foundation package would do, page by page; --price also states the money',
|
|
63
|
+
'',
|
|
64
|
+
' operstack-audit prospect <sites.txt> [--out prospects] [--pages 8] [--lang en|ru]',
|
|
65
|
+
' who to write to first: one table, weakest site at the top, with a line to open the email',
|
|
66
|
+
' operstack-audit batch <sites.txt> [--out reports] [--pages 12] [--lang en|ru] [--pdf] [brand flags]',
|
|
67
|
+
' one report per line of the list; a line is "url" or "url, Client Name"',
|
|
68
|
+
'',
|
|
69
|
+
' White label:',
|
|
70
|
+
' --by "Agency Name" who the report says prepared it; also OPERSTACK_PREPARED_BY',
|
|
71
|
+
' works on collect and on render, so an existing audit can be re-signed',
|
|
72
|
+
' --recheck-url <url> where the fix report sends the client to re-check',
|
|
73
|
+
' with --by set to someone other than us and no url, the line is omitted',
|
|
74
|
+
'',
|
|
75
|
+
' Agency plan (needs a licence key, https://oper-stack.com/products/agency/):',
|
|
76
|
+
' --logo <file> svg, png, jpg or webp on the cover, embedded in the PDF',
|
|
77
|
+
' --color <hex> replaces the report accent colour, six hex digits',
|
|
78
|
+
' --no-tool-line drops the line naming the tool that collected the signals',
|
|
79
|
+
' --licence <key> OSK1 key; also OPERSTACK_LICENCE or a .operstack-licence file',
|
|
61
80
|
].join('\n'));
|
|
62
81
|
process.exit(cmd ? 0 : 2);
|
|
63
82
|
}
|
|
@@ -71,18 +90,44 @@ if (cmd === 'collect') {
|
|
|
71
90
|
const audit = await collect(target, {
|
|
72
91
|
pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'),
|
|
73
92
|
rendered: !has('--no-rendered'), renderedPages: Number(opt('--rendered-pages', 3)),
|
|
93
|
+
preparedBy: opt('--by', ''),
|
|
74
94
|
log: (m) => console.error(` ${m}`),
|
|
75
95
|
});
|
|
76
96
|
const out = resolve(opt('--out', 'audit.json'));
|
|
77
97
|
writeFileSync(out, JSON.stringify(audit, null, 2));
|
|
78
98
|
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(', ')}`);
|
|
79
99
|
console.log(`next: fill the narrative fields (operstack-audit check ${out} lists them), then render.`);
|
|
100
|
+
} else if (cmd === 'prospect') {
|
|
101
|
+
const items = readList(target);
|
|
102
|
+
if (!items.length) { console.error('the list is empty'); process.exit(2); }
|
|
103
|
+
const lang = opt('--lang', 'en');
|
|
104
|
+
console.error(`${items.length} site(s) to check`);
|
|
105
|
+
const { rows, failed } = await runProspect(items, { pages: Number(opt('--pages', 8)), lang, log: (m) => console.error(` ${m}`) });
|
|
106
|
+
if (!rows.length) { console.error('nothing could be checked'); process.exit(1); }
|
|
107
|
+
const base = resolve(opt('--out', 'prospects'));
|
|
108
|
+
writeFileSync(`${base}.csv`, toCsv(rows));
|
|
109
|
+
writeFileSync(`${base}.md`, toMarkdown(rows));
|
|
110
|
+
for (const f of failed) console.error(` skipped ${f.url}: ${f.reason}`);
|
|
111
|
+
console.log(`wrote ${base}.csv and ${base}.md: ${rows.length} site(s), weakest first`);
|
|
112
|
+
} else if (cmd === 'batch') {
|
|
113
|
+
const brand = brandFrom(opt, has);
|
|
114
|
+
const items = readList(target);
|
|
115
|
+
if (!items.length) { console.error('the list is empty'); process.exit(2); }
|
|
116
|
+
console.error(`${items.length} site(s) to audit`);
|
|
117
|
+
const results = await runBatch(items, {
|
|
118
|
+
outDir: opt('--out', 'reports'), pages: Number(opt('--pages', 12)), lang: opt('--lang', 'en'),
|
|
119
|
+
brand, pdf: has('--pdf'), log: (m) => console.error(` ${m}`),
|
|
120
|
+
});
|
|
121
|
+
console.log(summarise(results));
|
|
122
|
+
if (!results.some((r) => r.ok)) process.exit(1);
|
|
80
123
|
} else if (cmd === 'render') {
|
|
81
124
|
const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
|
|
125
|
+
const by = opt('--by', '');
|
|
126
|
+
if (by) audit.client = { ...(audit.client || {}), preparedBy: by };
|
|
82
127
|
const out = resolve(opt('--out', target.replace(/\.json$/, '') + '.html'));
|
|
83
128
|
const drift = verifyScores(audit);
|
|
84
129
|
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.'); }
|
|
85
|
-
const { html, pdf } = await render(audit, { out, pdf: has('--pdf') });
|
|
130
|
+
const { html, pdf } = await render(audit, { out, pdf: has('--pdf'), brand: brandFrom(opt, has) });
|
|
86
131
|
console.log(`wrote ${out} (${html.length} bytes)${pdf ? `\nwrote ${pdf}` : ''}`);
|
|
87
132
|
const missing = checkNarrative(audit);
|
|
88
133
|
if (missing.length) console.log(`note: ${missing.length} narrative field(s) still carry placeholders: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? ', ...' : ''}`);
|
|
@@ -123,6 +168,7 @@ if (cmd === 'collect') {
|
|
|
123
168
|
lang,
|
|
124
169
|
price: Number(opt('--price', lang === 'en' ? 249 : 21000)),
|
|
125
170
|
currency: opt('--currency', lang === 'en' ? 'USD' : 'RUB'),
|
|
171
|
+
recheckUrl: opt('--recheck-url', ''),
|
|
126
172
|
});
|
|
127
173
|
if (plan.langMismatch) { console.error(`this audit was collected in Russian: an English letter would carry Russian check names. Re-run: operstack-audit collect <site> --lang en`); process.exit(1); }
|
|
128
174
|
if (plan.unknown.length) console.error(`note: ${plan.unknown.length} check(s) have no fix action yet: ${plan.unknown.join(', ')}`);
|
|
@@ -166,3 +212,15 @@ if (cmd === 'collect') {
|
|
|
166
212
|
else console.log('no price in the letter: add --price once the list is agreed and you have decided what to charge');
|
|
167
213
|
console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
|
|
168
214
|
} else { console.error(`unknown command ${cmd}`); process.exit(2); }
|
|
215
|
+
|
|
216
|
+
/** Оформление из флагов. Причины, по которым что-то не применилось, печатаются сразу: молча
|
|
217
|
+
* проигнорированный логотип выглядит как поломка продукта, а не как отсутствие лицензии. */
|
|
218
|
+
function brandFrom(opt, has) {
|
|
219
|
+
const brand = resolveBranding({
|
|
220
|
+
by: opt('--by', ''), logo: opt('--logo', ''), color: opt('--color', ''),
|
|
221
|
+
licence: opt('--licence', ''), toolLine: !has('--no-tool-line'),
|
|
222
|
+
});
|
|
223
|
+
for (const n of brand.notes) console.error(` ${n}`);
|
|
224
|
+
if (brand.licensed) console.error(` agency plan active${brand.licensee ? ` (${brand.licensee})` : ''}`);
|
|
225
|
+
return brand;
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -22,12 +22,13 @@
|
|
|
22
22
|
"bin",
|
|
23
23
|
"src",
|
|
24
24
|
"templates",
|
|
25
|
+
"licensing",
|
|
25
26
|
"examples/sample-audit.json",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE"
|
|
28
29
|
],
|
|
29
30
|
"scripts": {
|
|
30
|
-
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
|
|
31
|
+
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
|
|
31
32
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
package/src/agency.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agency plan: the report comes out under the buyer's brand instead of ours.
|
|
3
|
+
*
|
|
4
|
+
* What is free and what is paid, and why the line is drawn here. Signing a report with
|
|
5
|
+
* `--by "Some Agency"` is free: that is honest attribution, and the cover still carries the line
|
|
6
|
+
* saying which tool collected the signals, so our name travels with every free report. The agency
|
|
7
|
+
* plan buys the visual identity, a logo and a colour, and the right to drop that tool line, which
|
|
8
|
+
* is what turns the PDF into the agency's own product.
|
|
9
|
+
*
|
|
10
|
+
* The package is MIT and its source is public, so this is a licence, not a lock: anyone able to
|
|
11
|
+
* edit node_modules can walk past it. It exists to state the terms and to record who bought them,
|
|
12
|
+
* the same way every open-core tool does.
|
|
13
|
+
*/
|
|
14
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { resolve, dirname, extname } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
/** Ed25519, тот же формат ключа, что и у Site Kit: OSK1.<payload>.<signature>, base64url. */
|
|
22
|
+
export function verifyKey(key, publicPem) {
|
|
23
|
+
const m = String(key || '').trim().match(/^OSK1\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/);
|
|
24
|
+
if (!m) return { ok: false, reason: 'malformed key' };
|
|
25
|
+
const payload = Buffer.from(m[1], 'base64url');
|
|
26
|
+
const sig = Buffer.from(m[2], 'base64url');
|
|
27
|
+
let data;
|
|
28
|
+
try { data = JSON.parse(payload.toString('utf8')); } catch { return { ok: false, reason: 'unreadable payload' }; }
|
|
29
|
+
if (!verify(null, payload, createPublicKey(publicPem), sig)) return { ok: false, reason: 'signature does not match' };
|
|
30
|
+
if (data.expires && new Date(data.expires) < new Date()) return { ok: false, reason: `the plan lapsed on ${data.expires}`, data };
|
|
31
|
+
if (data.plan && data.plan !== 'agency') return { ok: false, reason: `this key is for the ${data.plan} plan, not agency`, data };
|
|
32
|
+
return { ok: true, data };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Ключ берётся из флага, из переменной окружения или из файла рядом с работой. */
|
|
36
|
+
export function findKey(explicit = '') {
|
|
37
|
+
if (explicit) return explicit.trim();
|
|
38
|
+
if (process.env.OPERSTACK_LICENCE) return process.env.OPERSTACK_LICENCE.trim();
|
|
39
|
+
for (const p of ['.operstack-licence', '.operstack-licence.json']) {
|
|
40
|
+
const f = resolve(process.cwd(), p);
|
|
41
|
+
if (!existsSync(f)) continue;
|
|
42
|
+
const raw = readFileSync(f, 'utf8').trim();
|
|
43
|
+
if (raw.startsWith('{')) { try { return String(JSON.parse(raw).key || '').trim(); } catch { return ''; } }
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MIME = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
50
|
+
const HEX = /^#?[0-9a-f]{6}$/i;
|
|
51
|
+
/** Логотип уезжает в PDF вместе с файлом, поэтому встраивается, а не подключается ссылкой. */
|
|
52
|
+
const MAX_LOGO = 512 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Собирает оформление отчёта. Возвращает ещё и `notes`: причины, по которым что-то не применилось,
|
|
56
|
+
* чтобы человек увидел их в терминале, а не гадал, почему логотипа нет.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveBranding({ by = '', logo = '', color = '', licence = '', toolLine = true } = {}) {
|
|
59
|
+
const notes = [];
|
|
60
|
+
const out = { preparedBy: by.trim(), logo: null, accent: '', showToolLine: true, licensed: false, licensee: '' };
|
|
61
|
+
|
|
62
|
+
const wantsPaid = Boolean(logo || color || toolLine === false);
|
|
63
|
+
if (!wantsPaid) return { ...out, notes };
|
|
64
|
+
|
|
65
|
+
const pubPath = resolve(root, 'licensing/public.pem');
|
|
66
|
+
const key = findKey(licence);
|
|
67
|
+
if (!key) {
|
|
68
|
+
notes.push('logo, colour and hiding the tool line need the agency plan: https://oper-stack.com/products/agency/');
|
|
69
|
+
return { ...out, notes };
|
|
70
|
+
}
|
|
71
|
+
if (!existsSync(pubPath)) {
|
|
72
|
+
notes.push('licensing/public.pem is missing from this copy, so the key cannot be checked');
|
|
73
|
+
return { ...out, notes };
|
|
74
|
+
}
|
|
75
|
+
const r = verifyKey(key, readFileSync(pubPath, 'utf8'));
|
|
76
|
+
if (!r.ok) {
|
|
77
|
+
notes.push(`licence rejected: ${r.reason}`);
|
|
78
|
+
return { ...out, notes };
|
|
79
|
+
}
|
|
80
|
+
out.licensed = true;
|
|
81
|
+
out.licensee = r.data.email || r.data.name || '';
|
|
82
|
+
|
|
83
|
+
if (logo) {
|
|
84
|
+
const f = resolve(process.cwd(), logo);
|
|
85
|
+
const type = MIME[extname(f).toLowerCase()];
|
|
86
|
+
if (!existsSync(f)) notes.push(`logo not found: ${logo}`);
|
|
87
|
+
else if (!type) notes.push(`logo must be svg, png, jpg or webp, got ${extname(f) || 'no extension'}`);
|
|
88
|
+
else {
|
|
89
|
+
const buf = readFileSync(f);
|
|
90
|
+
if (buf.length > MAX_LOGO) notes.push(`logo is ${Math.round(buf.length / 1024)} KB, over the 512 KB the PDF can carry`);
|
|
91
|
+
else out.logo = `data:${type};base64,${buf.toString('base64')}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (color) {
|
|
95
|
+
if (!HEX.test(color)) notes.push(`colour must be six hex digits, got "${color}"`);
|
|
96
|
+
else out.accent = color.startsWith('#') ? color : `#${color}`;
|
|
97
|
+
}
|
|
98
|
+
if (toolLine === false) out.showToolLine = false;
|
|
99
|
+
return { ...out, notes };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Светлая версия цвета для подложек: тот же тон, разбавленный белым. */
|
|
103
|
+
export function softenHex(hex, mix = 0.86) {
|
|
104
|
+
const h = hex.replace('#', '');
|
|
105
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
106
|
+
const up = (c) => Math.round(c + (255 - c) * mix);
|
|
107
|
+
return `#${[up(r), up(g), up(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
|
108
|
+
}
|
package/src/batch.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Пакетный прогон: список сайтов на входе, готовые отчёты на выходе.
|
|
3
|
+
*
|
|
4
|
+
* Агентство живёт не одним сайтом, а двадцатью, и запускать их по одному вручную никто не станет.
|
|
5
|
+
* Здесь нет ничего, чего не делает обычный прогон: тот же сбор, тот же черновик, та же вёрстка,
|
|
6
|
+
* просто по очереди и с общим оформлением. Упавший сайт не останавливает остальные: он попадает
|
|
7
|
+
* в отчёт о прогоне со своей причиной, потому что девятнадцать готовых отчётов лучше, чем ноль.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { resolve, join } from 'node:path';
|
|
11
|
+
import { collect } from './collect.mjs';
|
|
12
|
+
import { render } from './render.mjs';
|
|
13
|
+
import { draftNarrative as draft } from './narrative.mjs';
|
|
14
|
+
import { prospectRow, toCsv, toMarkdown } from './prospect.mjs';
|
|
15
|
+
|
|
16
|
+
/** Строки списка: адрес и, через запятую, имя клиента для обложки. Пустые строки и # игнорируются. */
|
|
17
|
+
export function parseList(text) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const raw of String(text).split('\n')) {
|
|
20
|
+
const line = raw.trim();
|
|
21
|
+
if (!line || line.startsWith('#')) continue;
|
|
22
|
+
const [url, ...rest] = line.split(',');
|
|
23
|
+
const name = rest.join(',').trim();
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(url.trim().startsWith('http') ? url.trim() : `https://${url.trim()}`);
|
|
26
|
+
out.push({ url: u.toString(), name: name || u.host });
|
|
27
|
+
} catch {
|
|
28
|
+
out.push({ url: url.trim(), name, invalid: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Имя файла из адреса: понятное человеку, без косых черт и без совпадений между сайтами. */
|
|
35
|
+
const slug = (u) => {
|
|
36
|
+
try { return new URL(u).host.replace(/^www\./, '').replace(/[^a-z0-9.-]/gi, '-'); }
|
|
37
|
+
catch { return String(u).replace(/[^a-z0-9.-]/gi, '-').slice(0, 60) || 'site'; }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function runBatch(items, {
|
|
41
|
+
outDir = 'reports', pages = 12, lang = 'en', brand = null, pdf = false,
|
|
42
|
+
log = () => {}, onDone = () => {},
|
|
43
|
+
} = {}) {
|
|
44
|
+
mkdirSync(resolve(outDir), { recursive: true });
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const [i, item] of items.entries()) {
|
|
47
|
+
const label = `${i + 1}/${items.length} ${item.url}`;
|
|
48
|
+
if (item.invalid) {
|
|
49
|
+
results.push({ ...item, ok: false, reason: 'not a valid address' });
|
|
50
|
+
log(`${label}: пропущен, адрес не разобран`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
log(`${label}: собираю`);
|
|
55
|
+
const audit = await collect(item.url, { pages, lang, rendered: false, preparedBy: brand?.preparedBy || '' });
|
|
56
|
+
audit.client.name = item.name;
|
|
57
|
+
draft(audit, { lang });
|
|
58
|
+
const base = join(resolve(outDir), slug(item.url));
|
|
59
|
+
writeFileSync(`${base}.json`, JSON.stringify(audit, null, 2));
|
|
60
|
+
const r = await render(audit, { out: `${base}.html`, pdf, brand });
|
|
61
|
+
results.push({ ...item, ok: true, json: `${base}.json`, html: `${base}.html`, pdf: r.pdf, scores: audit.scores });
|
|
62
|
+
log(`${label}: готово`);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
results.push({ ...item, ok: false, reason: err.message });
|
|
65
|
+
log(`${label}: не вышло, ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
onDone(results[results.length - 1]);
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Короткая сводка для человека: что получилось, что нет и почему. */
|
|
73
|
+
export function summarise(results) {
|
|
74
|
+
const ok = results.filter((r) => r.ok);
|
|
75
|
+
const bad = results.filter((r) => !r.ok);
|
|
76
|
+
const lines = [`готово ${ok.length} из ${results.length}`];
|
|
77
|
+
for (const r of bad) lines.push(` не вышло ${r.url}: ${r.reason}`);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readList(path) {
|
|
82
|
+
return parseList(readFileSync(resolve(path), 'utf8'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Разведка: те же проверки, но без отчётов. Нужен ответ «кому писать первым», а не двадцать PDF,
|
|
87
|
+
* поэтому здесь не собирается ни черновик, ни вёрстка: только замер и таблица.
|
|
88
|
+
*/
|
|
89
|
+
export async function runProspect(items, { pages = 8, lang = 'en', log = () => {} } = {}) {
|
|
90
|
+
const rows = [];
|
|
91
|
+
const failed = [];
|
|
92
|
+
for (const [i, item] of items.entries()) {
|
|
93
|
+
const label = `${i + 1}/${items.length} ${item.url}`;
|
|
94
|
+
if (item.invalid) { failed.push({ ...item, reason: 'not a valid address' }); log(`${label}: пропущен`); continue; }
|
|
95
|
+
try {
|
|
96
|
+
log(`${label}: проверяю`);
|
|
97
|
+
const audit = await collect(item.url, { pages, lang, rendered: false });
|
|
98
|
+
rows.push(prospectRow(audit, { name: item.name, lang }));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
failed.push({ ...item, reason: err.message });
|
|
101
|
+
log(`${label}: не вышло, ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { rows, failed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { toCsv, toMarkdown };
|
package/src/collect.mjs
CHANGED
|
@@ -175,7 +175,7 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
|
|
|
175
175
|
return out;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3 } = {}) {
|
|
178
|
+
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '' } = {}) {
|
|
179
179
|
const opts = { backlinks };
|
|
180
180
|
const origin = new URL(startUrl).origin;
|
|
181
181
|
const host = new URL(startUrl).host;
|
|
@@ -429,7 +429,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
429
429
|
|
|
430
430
|
return {
|
|
431
431
|
meta: { site: home.final, host, reachable: homeAnswered, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
|
|
432
|
-
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
|
|
432
|
+
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
|
|
433
433
|
scores,
|
|
434
434
|
scoreBasis,
|
|
435
435
|
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}}'] },
|
package/src/fix.mjs
CHANGED
|
@@ -108,7 +108,7 @@ const T = {
|
|
|
108
108
|
after: 'Стало',
|
|
109
109
|
nowLabel: 'Сейчас',
|
|
110
110
|
gone: 'проверка исчезла из прогона',
|
|
111
|
-
recheck:
|
|
111
|
+
recheck: (u) => `Любую строку можно перепроверить бесплатным инструментом на ${u}`,
|
|
112
112
|
},
|
|
113
113
|
en: {
|
|
114
114
|
planTitle: (h) => `# What we will fix on ${h}`,
|
|
@@ -140,10 +140,15 @@ const T = {
|
|
|
140
140
|
after: 'Now',
|
|
141
141
|
nowLabel: 'Now',
|
|
142
142
|
gone: 'the check disappeared from the run',
|
|
143
|
-
recheck:
|
|
143
|
+
recheck: (u) => `Every line can be re-checked with the free tool at ${u}`,
|
|
144
144
|
},
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
+
// Ссылка на бесплатную перепроверку. В отчёте под чужим брендом её нет: рекламировать нас
|
|
148
|
+
// внутри документа, который подрядчик отдаёт своему клиенту, нельзя.
|
|
149
|
+
const DEFAULT_RECHECK = { ru: 'oper-stack.ru/ai-visibility/', en: 'oper-stack.com/ai-visibility/' };
|
|
150
|
+
const isOurBrand = (by) => !by || /operstack|oper-stack/i.test(by);
|
|
151
|
+
|
|
147
152
|
const dict = (lang) => T[lang === 'en' ? 'en' : 'ru'];
|
|
148
153
|
const pick = (field, lang) => (field && typeof field === 'object' ? field[lang === 'en' ? 'en' : 'ru'] : field);
|
|
149
154
|
|
|
@@ -186,7 +191,10 @@ export function buildFixPlan(audit, opts = {}) {
|
|
|
186
191
|
// На здоровом сайте закрывать почти нечего, и продавать ему пакет по полной цене нечестно.
|
|
187
192
|
// Порог не выдуман: ниже него доля одной проверки превышает треть чека.
|
|
188
193
|
const thin = included.length > 0 && included.length < 5;
|
|
189
|
-
|
|
194
|
+
const preparedBy = audit.client?.preparedBy || '';
|
|
195
|
+
const recheckUrl = opts.recheckUrl || (isOurBrand(preparedBy) ? DEFAULT_RECHECK[lang] : null);
|
|
196
|
+
const brand = { preparedBy, recheckUrl };
|
|
197
|
+
return { platform, price, currency, lang, langMismatch, included, needsClient, notPossible, share, thin, unknown, brand, checkedAt: audit.meta?.collectedAt, host: audit.meta?.host };
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
/** Письмо клиенту: то, что он согласовывает до оплаты. */
|
|
@@ -266,7 +274,7 @@ export function buildFixReport(plan, after) {
|
|
|
266
274
|
else closed.push({ ...x, now: c.value });
|
|
267
275
|
}
|
|
268
276
|
const refund = Math.round(stillOpen.length * plan.share * 100) / 100;
|
|
269
|
-
return { host: plan.host, lang: plan.lang, closed, stillOpen, refund, currency: plan.currency, total: plan.included.length };
|
|
277
|
+
return { host: plan.host, lang: plan.lang, closed, stillOpen, refund, currency: plan.currency, total: plan.included.length, brand: plan.brand };
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
export function renderFixReport(report) {
|
|
@@ -285,8 +293,10 @@ export function renderFixReport(report) {
|
|
|
285
293
|
L.push('');
|
|
286
294
|
for (const x of report.stillOpen) L.push(`- **${x.check.label}.** ${t.before}: ${x.check.value}. ${t.nowLabel}: ${x.now}`);
|
|
287
295
|
}
|
|
288
|
-
|
|
289
|
-
|
|
296
|
+
if (report.brand?.recheckUrl) {
|
|
297
|
+
L.push('');
|
|
298
|
+
L.push(t.recheck(report.brand.recheckUrl));
|
|
299
|
+
}
|
|
290
300
|
return L.join('\n');
|
|
291
301
|
}
|
|
292
302
|
|
package/src/prospect.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Кому писать первым и что сказать.
|
|
3
|
+
*
|
|
4
|
+
* Пакетный прогон отдаёт двадцать отчётов по десять страниц каждый, и дальше человек три часа
|
|
5
|
+
* читает их подряд, чтобы понять, у кого дыра, а у кого мелочь. Здесь эти три часа сводятся к
|
|
6
|
+
* одной таблице: сайты по возрастанию оценки, две-три худшие находки и одна фраза, которую можно
|
|
7
|
+
* поставить в письмо.
|
|
8
|
+
*
|
|
9
|
+
* Фраза не сочиняется. Она берётся из проверки, которая уже что-то измерила, и переводится на
|
|
10
|
+
* язык владельца бизнеса. Там, где перевода нет, печатается сама находка: пусть сухо, но правда.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Что сказать владельцу про каждую находку. Здесь только те проверки, по которым владельцу
|
|
15
|
+
* действительно есть что услышать: остальные важны исполнителю, а не в первом письме.
|
|
16
|
+
*
|
|
17
|
+
* Правило для каждой строки: это последствие для его бизнеса, а не название проверки.
|
|
18
|
+
*/
|
|
19
|
+
export const OWNER_LINES = {
|
|
20
|
+
'robots': {
|
|
21
|
+
en: 'Your site tells robots to skip part of it and never shows them the sitemap.',
|
|
22
|
+
ru: 'Служебный файл сайта запрещает роботам часть страниц и не показывает им карту сайта.',
|
|
23
|
+
},
|
|
24
|
+
'sitemap': {
|
|
25
|
+
en: 'There is no working sitemap, so a search engine simply never finds some of your pages.',
|
|
26
|
+
ru: 'У сайта нет рабочей карты, поэтому часть страниц поисковик просто не находит.',
|
|
27
|
+
},
|
|
28
|
+
'sitemap-health': {
|
|
29
|
+
en: 'Your sitemap points at pages that have moved or no longer exist.',
|
|
30
|
+
ru: 'Карта сайта ведёт на страницы, которых уже нет или которые переехали.',
|
|
31
|
+
},
|
|
32
|
+
'ai-search-access': {
|
|
33
|
+
en: 'ChatGPT and Perplexity cannot read your site: one line in a config file shuts them out.',
|
|
34
|
+
ru: 'ChatGPT и Perplexity не могут прочитать ваш сайт: доступ закрыт в одной строке служебного файла.',
|
|
35
|
+
},
|
|
36
|
+
'llms': {
|
|
37
|
+
en: 'An assistant asked about you has no way to know which of your pages to read, so it reads whichever it hits first.',
|
|
38
|
+
ru: 'Ассистенту, которого спросили про вас, нечем понять, какие ваши страницы читать, и он берёт первую попавшуюся.',
|
|
39
|
+
},
|
|
40
|
+
'llms-health': {
|
|
41
|
+
en: 'You do have the file assistants look for, but its links lead nowhere.',
|
|
42
|
+
ru: 'Файл для ассистентов у вас есть, но ссылки в нём ведут в никуда.',
|
|
43
|
+
},
|
|
44
|
+
'agent-card': {
|
|
45
|
+
en: 'There is nothing on the site telling an AI agent who you are and how to reach you.',
|
|
46
|
+
ru: 'На сайте нет карточки, по которой программа-агент понимает, кто вы и как с вами связаться.',
|
|
47
|
+
},
|
|
48
|
+
'agent-markdown': {
|
|
49
|
+
en: 'Assistants have to parse your layout to read you, because no clean text copy of the pages exists.',
|
|
50
|
+
ru: 'Ассистенту приходится разбирать вёрстку, потому что чистой текстовой копии страниц нет.',
|
|
51
|
+
},
|
|
52
|
+
'org-schema': {
|
|
53
|
+
en: 'Your markup does not say who you are: no address, no profiles to check you against.',
|
|
54
|
+
ru: 'В разметке не указано, кто вы: ни адреса, ни профилей, по которым вас можно сверить.',
|
|
55
|
+
},
|
|
56
|
+
'schema': {
|
|
57
|
+
en: 'Nothing in your markup tells a search engine what this organisation is or what it does.',
|
|
58
|
+
ru: 'Поисковик не понимает по разметке, что это за организация и чем она занимается.',
|
|
59
|
+
},
|
|
60
|
+
'faq-schema': {
|
|
61
|
+
en: 'Your questions and answers are not marked up, so they never appear as an answer in search.',
|
|
62
|
+
ru: 'Вопросы и ответы не размечены, поэтому в выдачу как ответ они не попадают.',
|
|
63
|
+
},
|
|
64
|
+
'title': {
|
|
65
|
+
en: 'The title of your home page does not say what you do, and that is the first thing both a person and a machine read.',
|
|
66
|
+
ru: 'Заголовок главной страницы не говорит, чем вы занимаетесь, а это первое, что видят и человек, и машина.',
|
|
67
|
+
},
|
|
68
|
+
'description': {
|
|
69
|
+
en: 'The description shown in search results is not written, so the engine assembles one from scraps of your text.',
|
|
70
|
+
ru: 'Описание страницы в выдаче не написано, поэтому поисковик собирает его сам из обрывков текста.',
|
|
71
|
+
},
|
|
72
|
+
'h1': {
|
|
73
|
+
en: 'The home page has several top-level headings at once, so it is unclear which one is the subject.',
|
|
74
|
+
ru: 'На главной несколько главных заголовков сразу, и непонятно, какой из них про что.',
|
|
75
|
+
},
|
|
76
|
+
'dup-titles': {
|
|
77
|
+
en: 'Several of your pages carry the same title, so a search engine cannot tell them apart.',
|
|
78
|
+
ru: 'У нескольких страниц одинаковые заголовки, и поисковик не отличает их друг от друга.',
|
|
79
|
+
},
|
|
80
|
+
'alt': {
|
|
81
|
+
en: 'Your images carry no captions, so neither image search nor a blind visitor can make sense of them.',
|
|
82
|
+
ru: 'У картинок нет подписей, поэтому ни поиск по картинкам, ни незрячий посетитель их не понимают.',
|
|
83
|
+
},
|
|
84
|
+
'viewport': {
|
|
85
|
+
en: 'On a phone your page cannot be pinched to zoom: the code forbids it.',
|
|
86
|
+
ru: 'С телефона страницу нельзя увеличить пальцами, масштабирование запрещено в коде.',
|
|
87
|
+
},
|
|
88
|
+
'answer-first': {
|
|
89
|
+
en: 'Not one page opens with a short direct answer, and that opening is exactly what gets quoted.',
|
|
90
|
+
ru: 'Ни на одной странице нет короткого прямого ответа в первом абзаце, а цитируют именно его.',
|
|
91
|
+
},
|
|
92
|
+
'tables': {
|
|
93
|
+
en: 'Your pages have no tables, and a table is the easiest thing for an assistant to lift and quote.',
|
|
94
|
+
ru: 'На страницах нет таблиц, а таблицу ассистенту проще всего взять и процитировать.',
|
|
95
|
+
},
|
|
96
|
+
'sections': {
|
|
97
|
+
en: 'Your pages run as long unbroken text, so an assistant cannot pick out the part that answers the question.',
|
|
98
|
+
ru: 'Страницы идут сплошным текстом, и ассистент не может выделить кусок, который отвечает на вопрос.',
|
|
99
|
+
},
|
|
100
|
+
'sources': {
|
|
101
|
+
en: 'Your pages carry figures but never say where they came from, so nobody can cite them.',
|
|
102
|
+
ru: 'Цифры на страницах есть, но не сказано, откуда они взяты, и сослаться на них нельзя.',
|
|
103
|
+
},
|
|
104
|
+
'dates': {
|
|
105
|
+
en: 'Your pages do not show when they were updated, so they read as stale even when they are not.',
|
|
106
|
+
ru: 'На страницах не видно, когда их обновляли, и выглядят они устаревшими, даже если это не так.',
|
|
107
|
+
},
|
|
108
|
+
'thin': {
|
|
109
|
+
en: 'Several pages are too short to answer anything, and a short page is not quoted.',
|
|
110
|
+
ru: 'Несколько страниц слишком короткие, чтобы на чём-то отвечать, а короткую страницу не цитируют.',
|
|
111
|
+
},
|
|
112
|
+
'trust-entity': {
|
|
113
|
+
en: 'Your structured data has no postal address, so there is nothing to verify the business against.',
|
|
114
|
+
ru: 'В данных о компании нет почтового адреса, и сверить бизнес не с чем.',
|
|
115
|
+
},
|
|
116
|
+
'trust-profiles': {
|
|
117
|
+
en: 'There is nothing to check you against from the outside: no profile on any other platform was found.',
|
|
118
|
+
ru: 'Вас не на что сверить со стороны: ни одного профиля на других площадках не нашлось.',
|
|
119
|
+
},
|
|
120
|
+
'trust-contact': {
|
|
121
|
+
en: 'The site does not give a plain way to reach a human, which both buyers and search engines look for.',
|
|
122
|
+
ru: 'На сайте нет понятного способа связаться с человеком, а его ищут и покупатели, и поисковик.',
|
|
123
|
+
},
|
|
124
|
+
'trust-about': {
|
|
125
|
+
en: 'There is no page saying who is behind the business, and that is one of the first things checked.',
|
|
126
|
+
ru: 'Нет страницы о том, кто стоит за бизнесом, а это проверяют одним из первых.',
|
|
127
|
+
},
|
|
128
|
+
'trust-policies': {
|
|
129
|
+
en: 'The site has no terms or privacy page, which costs trust with both buyers and platforms.',
|
|
130
|
+
ru: 'На сайте нет условий и политики конфиденциальности, и это стоит доверия и покупателей, и площадок.',
|
|
131
|
+
},
|
|
132
|
+
'conv-cta': {
|
|
133
|
+
en: 'Most pages hide the call to action far down, so a visitor who is ready to act has to hunt for it.',
|
|
134
|
+
ru: 'На большинстве страниц призыв к действию спрятан далеко вниз, и готовый покупатель его ищет.',
|
|
135
|
+
},
|
|
136
|
+
'conv-form-depth': {
|
|
137
|
+
en: 'Your enquiry form sits past the middle of the page, and most phone visitors never scroll that far.',
|
|
138
|
+
ru: 'Форма заявки стоит за серединой страницы, а большинство посетителей с телефона так далеко не листают.',
|
|
139
|
+
},
|
|
140
|
+
'conv-form-fields': {
|
|
141
|
+
en: 'The enquiry form asks for too much at once, and every extra field costs replies.',
|
|
142
|
+
ru: 'Форма заявки спрашивает слишком много сразу, и каждое лишнее поле стоит ответов.',
|
|
143
|
+
},
|
|
144
|
+
'conv-contact-path': {
|
|
145
|
+
en: 'Getting from a page to a way of contacting you takes more steps than it should.',
|
|
146
|
+
ru: 'Путь от страницы до способа связаться длиннее, чем нужно.',
|
|
147
|
+
},
|
|
148
|
+
'utility': {
|
|
149
|
+
en: 'Some pages exist that nobody should land on from search, and they dilute the rest.',
|
|
150
|
+
ru: 'Есть страницы, на которые из поиска попадать не должны, и они размывают остальные.',
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Чем открывать письмо. Порядок здесь не по тяжести проверки, а по силе для владельца: закрытый
|
|
156
|
+
* доступ роботов ИИ он поймёт мгновенно, а «есть лишние страницы» не значит для него ничего, хотя
|
|
157
|
+
* формально обе находки одного веса. Первая же строка решает, дочитают письмо или нет.
|
|
158
|
+
*/
|
|
159
|
+
const LEAD_ORDER = [
|
|
160
|
+
'ai-search-access', 'llms', 'llms-health', 'answer-first', 'sources', 'schema', 'org-schema',
|
|
161
|
+
'agent-card', 'conv-form-depth', 'conv-cta', 'title', 'description', 'tables', 'sections',
|
|
162
|
+
'trust-profiles', 'trust-entity', 'trust-contact', 'faq-schema', 'sitemap', 'sitemap-health',
|
|
163
|
+
'robots', 'thin', 'dup-titles', 'alt', 'viewport', 'trust-about', 'trust-policies',
|
|
164
|
+
'conv-form-fields', 'conv-contact-path', 'agent-markdown', 'h1', 'dates', 'utility',
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/** Фраза владельцу на нужном языке. Нет перевода, значит нет фразы, и печатается сама находка. */
|
|
168
|
+
const line = (id, lang = 'en') => (OWNER_LINES[id] || {})[lang === 'ru' ? 'ru' : 'en'] || '';
|
|
169
|
+
|
|
170
|
+
/** Вес области при подсчёте одной общей оценки. Считаем только то, что правда измерено. */
|
|
171
|
+
const WEIGHT = {
|
|
172
|
+
'SEO, technical': 1,
|
|
173
|
+
'SEO, content and structure': 1.2,
|
|
174
|
+
'AEO, answers and snippets': 1.3,
|
|
175
|
+
'GEO, visibility in AI systems': 1.3,
|
|
176
|
+
'Off-page and trust': 0.8,
|
|
177
|
+
'Conversion and UX': 0.8,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Одна оценка на сайт из шести областей. Неизмеренная область не занижает и не завышает:
|
|
182
|
+
* она просто не участвует, потому что оценивать неизмеренное значит выдумывать.
|
|
183
|
+
*/
|
|
184
|
+
export function overallScore(scores = {}) {
|
|
185
|
+
let sum = 0;
|
|
186
|
+
let weight = 0;
|
|
187
|
+
for (const [area, value] of Object.entries(scores)) {
|
|
188
|
+
if (typeof value !== 'number') continue;
|
|
189
|
+
const w = WEIGHT[area] ?? 1;
|
|
190
|
+
sum += value * w;
|
|
191
|
+
weight += w;
|
|
192
|
+
}
|
|
193
|
+
if (!weight) return null;
|
|
194
|
+
return Math.round((sum / weight) * 10);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SEVERITY = { fail: 0, warn: 1 };
|
|
198
|
+
|
|
199
|
+
/** Худшие находки по одному сайту: сначала провалы, потом предупреждения, и только те, что слышит владелец. */
|
|
200
|
+
export function worstFindings(audit, limit = 3, lang = 'en') {
|
|
201
|
+
const open = (audit.checks || [])
|
|
202
|
+
.filter((c) => c.status === 'fail' || c.status === 'warn')
|
|
203
|
+
.sort((a, b) => {
|
|
204
|
+
const s = (SEVERITY[a.status] ?? 2) - (SEVERITY[b.status] ?? 2);
|
|
205
|
+
if (s) return s;
|
|
206
|
+
// При равной тяжести вперёд идёт то, для чего есть фраза владельцу: она и попадёт в письмо.
|
|
207
|
+
return (line(b.id, lang) ? 1 : 0) - (line(a.id, lang) ? 1 : 0);
|
|
208
|
+
});
|
|
209
|
+
return open.slice(0, limit).map((c) => ({
|
|
210
|
+
id: c.id,
|
|
211
|
+
status: c.status,
|
|
212
|
+
label: c.label,
|
|
213
|
+
value: c.value,
|
|
214
|
+
say: line(c.id, lang) || `${c.label}: ${c.value}`,
|
|
215
|
+
translated: Boolean(line(c.id, lang)),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Одна строка таблицы: сайт, оценка, находки и фраза для письма. */
|
|
220
|
+
export function prospectRow(audit, { name = '', lang = 'en' } = {}) {
|
|
221
|
+
const findings = worstFindings(audit, 3, lang);
|
|
222
|
+
// Фразу для письма ищем по всем открытым находкам, а не только по трём верхним: иначе в письмо
|
|
223
|
+
// попадёт сырая техническая строка вроде «API catalogue (RFC 9727): not present», которую
|
|
224
|
+
// владелец бизнеса не поймёт, хотя ниже лежала находка с человеческим объяснением.
|
|
225
|
+
const all = worstFindings(audit, 99, lang).filter((f) => f.translated);
|
|
226
|
+
const lead = [...all].sort((a, b) => {
|
|
227
|
+
const ia = LEAD_ORDER.indexOf(a.id);
|
|
228
|
+
const ib = LEAD_ORDER.indexOf(b.id);
|
|
229
|
+
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
|
|
230
|
+
})[0] || findings[0] || null;
|
|
231
|
+
return {
|
|
232
|
+
host: audit.meta?.host || '',
|
|
233
|
+
name: name || audit.client?.name || audit.meta?.host || '',
|
|
234
|
+
score: overallScore(audit.scores),
|
|
235
|
+
failing: (audit.checks || []).filter((c) => c.status === 'fail').length,
|
|
236
|
+
warning: (audit.checks || []).filter((c) => c.status === 'warn').length,
|
|
237
|
+
findings,
|
|
238
|
+
say: lead ? lead.say : (lang === 'ru'
|
|
239
|
+
? 'Ничего заметного не нашлось: этот сайт в порядке, писать не о чем.'
|
|
240
|
+
: 'Nothing worth writing about: this site is in order.'),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Сортировка: сначала те, у кого хуже. Сайт без оценки уходит в конец, а не наверх. */
|
|
245
|
+
export function rank(rows) {
|
|
246
|
+
return [...rows].sort((a, b) => {
|
|
247
|
+
if (a.score === null) return 1;
|
|
248
|
+
if (b.score === null) return -1;
|
|
249
|
+
if (a.score !== b.score) return a.score - b.score;
|
|
250
|
+
return b.failing - a.failing;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const csvCell = (v) => {
|
|
255
|
+
const s = String(v ?? '');
|
|
256
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export function toCsv(rows) {
|
|
260
|
+
const head = ['site', 'name', 'score', 'failing', 'warnings', 'what to say'];
|
|
261
|
+
return [head, ...rank(rows).map((r) => [r.host, r.name, r.score ?? '', r.failing, r.warning, r.say])]
|
|
262
|
+
.map((line) => line.map(csvCell).join(','))
|
|
263
|
+
.join('\n');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function toMarkdown(rows, { title = 'Prospects, worst first' } = {}) {
|
|
267
|
+
const ranked = rank(rows);
|
|
268
|
+
const out = [`# ${title}`, '', `${ranked.length} site(s), the weakest at the top.`, '',
|
|
269
|
+
'| # | Site | Score | Fails | What to say in the first line |', '|---:|---|---:|---:|---|'];
|
|
270
|
+
ranked.forEach((r, i) => {
|
|
271
|
+
out.push(`| ${i + 1} | ${r.host} | ${r.score ?? 'n/a'} | ${r.failing} | ${r.say.replace(/\|/g, '/')} |`);
|
|
272
|
+
});
|
|
273
|
+
out.push('', '## What each one has open', '');
|
|
274
|
+
for (const r of ranked) {
|
|
275
|
+
out.push(`### ${r.name} (${r.host})`);
|
|
276
|
+
if (!r.findings.length) out.push('Nothing open that an owner would act on.');
|
|
277
|
+
for (const f of r.findings) out.push(`- **${f.label}** (${f.status}): ${f.value}`);
|
|
278
|
+
out.push('');
|
|
279
|
+
}
|
|
280
|
+
out.push('Every line above is a measured check, not an opinion. Open the site and confirm before you send anything.');
|
|
281
|
+
return out.join('\n');
|
|
282
|
+
}
|
package/src/render.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { computeScores } from './collect.mjs';
|
|
11
11
|
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
12
|
+
import { softenHex } from './agency.mjs';
|
|
12
13
|
|
|
13
14
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
14
15
|
const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
|
|
@@ -19,6 +20,7 @@ const LABELS = {
|
|
|
19
20
|
en: {
|
|
20
21
|
eyebrowAudit: 'Digital marketing audit', coverTitle: 'SEO, AEO and GEO<br>audit report',
|
|
21
22
|
auditSubject: 'Audit subject', reportDate: 'Report date', auditType: 'Audit type', preparedBy: 'Prepared by',
|
|
23
|
+
reproducible: 'Every status in this report was read from the public site and can be reproduced from it as it stood on the report date.',
|
|
22
24
|
collectedBy: (tool, date) => `Public signals were collected by ${tool} on ${date}. Every status in this report can be reproduced from the site as it stood on that day.`,
|
|
23
25
|
secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
|
|
24
26
|
scorecardFoot: '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.',
|
|
@@ -41,6 +43,7 @@ const LABELS = {
|
|
|
41
43
|
ru: {
|
|
42
44
|
eyebrowAudit: 'Аудит цифрового маркетинга', coverTitle: 'Аудит сайта:<br>SEO, AEO и GEO',
|
|
43
45
|
auditSubject: 'Что проверяли', reportDate: 'Дата отчёта', auditType: 'Тип аудита', preparedBy: 'Кто готовил',
|
|
46
|
+
reproducible: 'Каждый статус в этом отчёте прочитан с публичного сайта, и его можно перепроверить по сайту на дату отчёта.',
|
|
44
47
|
collectedBy: (tool, date) => `Публичные сигналы собраны инструментом ${tool} ${date}. Любой статус в этом отчёте можно перепроверить по сайту в том виде, в каком он был в этот день.`,
|
|
45
48
|
secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
|
|
46
49
|
scorecardFoot: 'Каждая оценка считает проверки из этого же отчёта: пройденная проверка это балл, спорная половина балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по разделам начиная с четвёртого.',
|
|
@@ -96,6 +99,7 @@ function css() {
|
|
|
96
99
|
.eyebrow { font-size: 8pt; letter-spacing: .12em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin-bottom: 2mm; }
|
|
97
100
|
.cover { min-height: calc(297mm - 38mm); display: flex; flex-direction: column; justify-content: space-between; }
|
|
98
101
|
.cover-top { border-top: 3px solid var(--accent); padding-top: 8mm; }
|
|
102
|
+
.cover-logo { display: block; max-height: 16mm; max-width: 70mm; margin-bottom: 7mm; }
|
|
99
103
|
.cover h1 { font-size: 34pt; margin: 2mm 0 4mm; }
|
|
100
104
|
.cover-sub { font-size: 13pt; color: var(--ink-2); max-width: 85%; margin-bottom: 10mm; }
|
|
101
105
|
.cover-url { display: inline-block; background: var(--accent-soft); color: var(--accent); padding: 2.5mm 4.5mm; border-radius: 4px; font-weight: 600; font-size: 11pt; }
|
|
@@ -140,12 +144,14 @@ function css() {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
const header = (audit, section, L) => `<div class="page-header"><strong>${esc(audit.client.name)}</strong><span>${esc(L.runningHead)} · ${esc(section)}</span></div>`;
|
|
143
|
-
const footer = (audit, n) => `<div class="page-footer"><span>${esc(audit.client.preparedBy || 'OperStack')} · ${esc(audit.client.reportDate)}</span><span>${n}</span></div>`;
|
|
147
|
+
const footer = (audit, n, by) => `<div class="page-footer"><span>${esc(by || audit.client.preparedBy || 'OperStack')} · ${esc(audit.client.reportDate)}</span><span>${n}</span></div>`;
|
|
144
148
|
const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
145
149
|
const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
|
|
146
150
|
const statusCell = (s, L) => `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${L[STATUS_KEYS[s] || 'statusNa']}</td>`;
|
|
147
151
|
|
|
148
|
-
export function toHtml(audit) {
|
|
152
|
+
export function toHtml(audit, brand = null) {
|
|
153
|
+
// Цвет агентства подменяет только акцент: остальная палитра отчёта остаётся выверенной.
|
|
154
|
+
const accentCss = brand && brand.accent ? `:root{--accent:${brand.accent};--accent-soft:${softenHex(brand.accent)};}` : '';
|
|
149
155
|
const a = audit;
|
|
150
156
|
const L = LABELS[a.meta?.lang === 'ru' ? 'ru' : 'en'];
|
|
151
157
|
const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
|
|
@@ -162,29 +168,34 @@ export function toHtml(audit) {
|
|
|
162
168
|
}).join('');
|
|
163
169
|
let n = 1;
|
|
164
170
|
const pages = [];
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
const logo = brand && brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : '';
|
|
172
|
+
const preparedBy = (brand && brand.preparedBy) || a.client.preparedBy || 'OperStack';
|
|
173
|
+
const toolLine = !brand || brand.showToolLine !== false
|
|
174
|
+
? `<div class="cover-foot">${esc(L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</div>`
|
|
175
|
+
: `<div class="cover-foot">${esc(L.reproducible)}</div>`;
|
|
176
|
+
pages.push(`<div class="page"><div class="cover"><div><div class="cover-top">${logo}<div class="eyebrow">${L.eyebrowAudit}</div><h1>${L.coverTitle}</h1><p class="cover-sub">${t(a.client.subject)}</p><span class="cover-url">${esc(a.meta.host)}</span></div>
|
|
177
|
+
<dl class="cover-meta"><div><dt>${L.auditSubject}</dt><dd>${t(a.client.name)}</dd></div><div><dt>${L.reportDate}</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>${L.auditType}</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>${L.preparedBy}</dt><dd>${esc(preparedBy)}</dd></div></dl></div>
|
|
178
|
+
${toolLine}</div></div>`);
|
|
168
179
|
n++;
|
|
169
|
-
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n
|
|
170
|
-
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.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>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</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
|
|
180
|
+
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
|
|
181
|
+
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.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>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</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++, preparedBy)}</div>`);
|
|
171
182
|
const cardBody = (c) => [
|
|
172
183
|
c.text ? `<p>${t(c.text)}</p>` : '',
|
|
173
184
|
c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
|
|
174
185
|
c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
|
|
175
186
|
].join('') || '<p class="card-empty">${L.noBody}</p>';
|
|
176
187
|
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>${L.noCritical}</p>`;
|
|
177
|
-
pages.push(`<div class="page">${header(a, L.secCritical, L)}<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>${footer(a, n
|
|
188
|
+
pages.push(`<div class="page">${header(a, L.secCritical, L)}<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>${footer(a, n++, preparedBy)}</div>`);
|
|
178
189
|
const techRows = [...checksBy('technical'), ...checksBy('onpage')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('');
|
|
179
|
-
pages.push(`<div class="page">${header(a, L.secTechnical, L)}<div class="eyebrow">${L.technicalEyebrow}</div><h2>${L.technicalTitle}</h2><table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${techRows}</table>${footer(a, n
|
|
180
|
-
pages.push(`<div class="page">${header(a, L.secContent, L)}<div class="eyebrow">${L.contentEyebrow}</div><h2>${L.contentTitle}</h2><div class="two-col"><div><h3>${L.strengths}</h3>${list(a.content.strengths)}</div><div><h3>${L.weaknesses}</h3>${list(a.content.weaknesses)}</div></div><h3>${L.gaps}</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n
|
|
190
|
+
pages.push(`<div class="page">${header(a, L.secTechnical, L)}<div class="eyebrow">${L.technicalEyebrow}</div><h2>${L.technicalTitle}</h2><table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${techRows}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
191
|
+
pages.push(`<div class="page">${header(a, L.secContent, L)}<div class="eyebrow">${L.contentEyebrow}</div><h2>${L.contentTitle}</h2><div class="two-col"><div><h3>${L.strengths}</h3>${list(a.content.strengths)}</div><div><h3>${L.weaknesses}</h3>${list(a.content.weaknesses)}</div></div><h3>${L.gaps}</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
181
192
|
const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<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('');
|
|
182
|
-
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</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>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div><h2>${L.geoTitle}</h2><table><tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n
|
|
193
|
+
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</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>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div><h2>${L.geoTitle}</h2><table><tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++, preparedBy)}</div>`);
|
|
183
194
|
const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('')}</table>` : '');
|
|
184
|
-
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</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">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${(a.sources || []).length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</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">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}${footer(a, n
|
|
195
|
+
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</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">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${(a.sources || []).length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</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">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}${footer(a, n++, preparedBy)}</div>`);
|
|
185
196
|
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('');
|
|
186
|
-
pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div>${footer(a, n
|
|
187
|
-
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>`;
|
|
197
|
+
pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div>${footer(a, n++, preparedBy)}</div>`);
|
|
198
|
+
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()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
function findChrome() {
|
|
@@ -192,8 +203,8 @@ function findChrome() {
|
|
|
192
203
|
return candidates.find((p) => existsSync(p));
|
|
193
204
|
}
|
|
194
205
|
|
|
195
|
-
export async function render(audit, { out, pdf = false } = {}) {
|
|
196
|
-
const html = toHtml(audit);
|
|
206
|
+
export async function render(audit, { out, pdf = false, brand = null } = {}) {
|
|
207
|
+
const html = toHtml(audit, brand);
|
|
197
208
|
writeFileSync(out, html);
|
|
198
209
|
let pdfPath = null;
|
|
199
210
|
if (pdf) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Идентификаторы проверок, снятые с настоящего прогона: тест сверяет с ними фразы владельцу,
|
|
2
|
+
* чтобы переименованная проверка не оставила строку, которая никогда не сработает. */
|
|
3
|
+
export const CHECK_IDS = [
|
|
4
|
+
"agent-card",
|
|
5
|
+
"agent-markdown",
|
|
6
|
+
"ai-search-access",
|
|
7
|
+
"alt",
|
|
8
|
+
"answer-first",
|
|
9
|
+
"api-catalog",
|
|
10
|
+
"canonical",
|
|
11
|
+
"conv-contact-path",
|
|
12
|
+
"conv-cta",
|
|
13
|
+
"conv-form-depth",
|
|
14
|
+
"conv-form-fields",
|
|
15
|
+
"conv-messenger",
|
|
16
|
+
"dates",
|
|
17
|
+
"description",
|
|
18
|
+
"dup-titles",
|
|
19
|
+
"faq-schema",
|
|
20
|
+
"h1",
|
|
21
|
+
"h1-sample",
|
|
22
|
+
"hreflang",
|
|
23
|
+
"https",
|
|
24
|
+
"llms",
|
|
25
|
+
"llms-health",
|
|
26
|
+
"og",
|
|
27
|
+
"org-schema",
|
|
28
|
+
"resources",
|
|
29
|
+
"robots",
|
|
30
|
+
"robots-ai",
|
|
31
|
+
"schema",
|
|
32
|
+
"sections",
|
|
33
|
+
"sitemap",
|
|
34
|
+
"sitemap-health",
|
|
35
|
+
"sources",
|
|
36
|
+
"tables",
|
|
37
|
+
"thin",
|
|
38
|
+
"title",
|
|
39
|
+
"trust-about",
|
|
40
|
+
"trust-contact",
|
|
41
|
+
"trust-entity",
|
|
42
|
+
"trust-policies",
|
|
43
|
+
"trust-profiles",
|
|
44
|
+
"ttfb",
|
|
45
|
+
"utility",
|
|
46
|
+
"viewport",
|
|
47
|
+
"www"
|
|
48
|
+
];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Правило, которое защищает этот файл: платное оформление не включается без лицензии, а
|
|
3
|
+
* бесплатная подпись работает всегда. Если сломать первое, агентский план перестанет продаваться,
|
|
4
|
+
* если второе, отчёт перестанет подписываться именем покупателя.
|
|
5
|
+
*
|
|
6
|
+
* Ключ здесь генерируется прямо в тесте одноразовой парой Ed25519: настоящий приватный ключ в
|
|
7
|
+
* репозитории не лежит и лежать не должен.
|
|
8
|
+
*/
|
|
9
|
+
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
10
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { resolveBranding, verifyKey, softenHex } from './agency.mjs';
|
|
14
|
+
import { toHtml } from './render.mjs';
|
|
15
|
+
|
|
16
|
+
let failed = 0;
|
|
17
|
+
const ok = (name, cond) => { if (cond) console.log(`ok ${name}`); else { failed++; console.error(`FAIL ${name}`); } };
|
|
18
|
+
|
|
19
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
20
|
+
const pub = publicKey.export({ type: 'spki', format: 'pem' });
|
|
21
|
+
const makeKey = (data) => {
|
|
22
|
+
const payload = Buffer.from(JSON.stringify(data));
|
|
23
|
+
return `OSK1.${payload.toString('base64url')}.${sign(null, payload, privateKey).toString('base64url')}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Проверка подписи
|
|
27
|
+
ok('правильный ключ принимается', verifyKey(makeKey({ email: 'a@b.co', plan: 'agency' }), pub).ok);
|
|
28
|
+
ok('мусор отвергается', !verifyKey('OSK1.aaa.bbb', pub).ok);
|
|
29
|
+
ok('чужая подпись отвергается', !verifyKey(makeKey({ plan: 'agency' }).replace(/.$/, 'A'), pub).ok);
|
|
30
|
+
ok('просроченный план отвергается', !verifyKey(makeKey({ plan: 'agency', expires: '2020-01-01' }), pub).ok);
|
|
31
|
+
ok('ключ другого продукта отвергается', !verifyKey(makeKey({ plan: 'site-kit' }), pub).ok);
|
|
32
|
+
ok('срок в будущем проходит', verifyKey(makeKey({ plan: 'agency', expires: '2999-01-01' }), pub).ok);
|
|
33
|
+
|
|
34
|
+
// Что бесплатно, а что нет
|
|
35
|
+
const free = resolveBranding({ by: 'Harper & Vale' });
|
|
36
|
+
ok('подпись именем работает без лицензии', free.preparedBy === 'Harper & Vale' && free.notes.length === 0);
|
|
37
|
+
ok('без платных опций строка об инструменте остаётся', free.showToolLine === true);
|
|
38
|
+
|
|
39
|
+
const blocked = resolveBranding({ by: 'X', color: '#123456', toolLine: false });
|
|
40
|
+
ok('без лицензии цвет не применяется', !blocked.accent && blocked.showToolLine === true);
|
|
41
|
+
ok('и человеку сказано почему', blocked.notes.some((n) => /agency plan/i.test(n)));
|
|
42
|
+
|
|
43
|
+
// Цвет
|
|
44
|
+
ok('светлая версия цвета светлее исходной', parseInt(softenHex('#0b7a75').slice(1, 3), 16) > 0x0b);
|
|
45
|
+
ok('кривой цвет не проходит', resolveBranding({ color: 'нет', licence: makeKey({ plan: 'agency' }) }).accent === '');
|
|
46
|
+
|
|
47
|
+
// Вёрстка
|
|
48
|
+
const audit = {
|
|
49
|
+
meta: { host: 'example.com', tool: '@operstack/audit 0.0.0', collectedAt: '2026-01-01T00:00:00Z', auditType: 'External', lang: 'en' },
|
|
50
|
+
client: { name: 'Client', subject: 'Sells things', reportDate: '2026-01-01', preparedBy: 'OperStack' },
|
|
51
|
+
scores: {}, scoreBasis: {}, summary: { lead: 'a', verdict: 'b', priorities: ['c'] },
|
|
52
|
+
overview: { rows: [], note: '' }, sample: [], checks: [], critical: [],
|
|
53
|
+
content: { strengths: [], weaknesses: [], gaps: [] },
|
|
54
|
+
aeo: { works: [], blocks: [], recommendation: '' }, geo: { rows: [], callout: '' },
|
|
55
|
+
offpage: { listed: [], note: '' }, conversion: { rows: [] }, sources: [], limitations: [],
|
|
56
|
+
roadmap: [], closing: 'done',
|
|
57
|
+
};
|
|
58
|
+
const plain = toHtml(audit);
|
|
59
|
+
ok('без оформления на обложке стоит наше имя', plain.includes('OperStack') && !plain.includes('<img class="cover-logo"'));
|
|
60
|
+
|
|
61
|
+
const branded = toHtml(audit, { preparedBy: 'Harper & Vale Digital', logo: 'data:image/svg+xml;base64,AAA', accent: '#7b3fa0', showToolLine: false });
|
|
62
|
+
ok('имя агентства попало на обложку', branded.includes('Harper & Vale Digital'));
|
|
63
|
+
ok('логотип попал на обложку', branded.includes('<img class="cover-logo"'));
|
|
64
|
+
ok('цвет агентства подменил акцент', branded.includes('--accent:#7b3fa0'));
|
|
65
|
+
ok('строка про инструмент убрана', !branded.includes('@operstack/audit 0.0.0'));
|
|
66
|
+
ok('но воспроизводимость обещана по-прежнему', /reproduced from it/i.test(branded));
|
|
67
|
+
ok('нашего имени в фирменном отчёте не осталось', !/OperStack/.test(branded));
|
|
68
|
+
|
|
69
|
+
// Список сайтов
|
|
70
|
+
const { parseList } = await import('./batch.mjs');
|
|
71
|
+
const list = parseList('# комментарий\n\nexample.com, Northline Dental\nhttps://two.example\nне адрес\n');
|
|
72
|
+
ok('список разбирается', list.length === 3 && list[0].name === 'Northline Dental');
|
|
73
|
+
ok('адрес без схемы достраивается', list[0].url.startsWith('https://example.com'));
|
|
74
|
+
ok('строка-мусор помечена, а не роняет прогон', list[2].invalid === true);
|
|
75
|
+
|
|
76
|
+
// Ключ из файла рядом с работой
|
|
77
|
+
const dir = mkdtempSync(join(tmpdir(), 'operstack-agency-'));
|
|
78
|
+
writeFileSync(join(dir, '.operstack-licence'), makeKey({ plan: 'agency', email: 'x@y.z' }));
|
|
79
|
+
const cwd = process.cwd();
|
|
80
|
+
process.chdir(dir);
|
|
81
|
+
const { findKey } = await import('./agency.mjs');
|
|
82
|
+
ok('ключ читается из файла .operstack-licence', findKey().startsWith('OSK1.'));
|
|
83
|
+
process.chdir(cwd);
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// ── Разведка кандидатов ───────────────────────────────────────────────────────
|
|
87
|
+
// Правило: в письмо никогда не попадает строка, которую владелец бизнеса не поймёт, и
|
|
88
|
+
// ни одна фраза не ссылается на проверку, которой в сборщике нет.
|
|
89
|
+
const { OWNER_LINES, overallScore, prospectRow, rank, toCsv, toMarkdown } = await import('./prospect.mjs');
|
|
90
|
+
const { CHECK_IDS } = await import('./test-agency-ids.mjs');
|
|
91
|
+
|
|
92
|
+
const unknown = Object.keys(OWNER_LINES).filter((id) => !CHECK_IDS.includes(id));
|
|
93
|
+
ok('каждая фраза ссылается на существующую проверку', unknown.length === 0);
|
|
94
|
+
ok('у каждой фразы есть оба языка', Object.values(OWNER_LINES).every((v) => v.en && v.ru));
|
|
95
|
+
|
|
96
|
+
ok('неизмеренная область не тянет оценку вниз', overallScore({ a: 10, b: null }) === 100);
|
|
97
|
+
ok('пустые оценки дают не ноль, а «не измеряли»', overallScore({}) === null);
|
|
98
|
+
|
|
99
|
+
const mk = (id, status, scores) => ({
|
|
100
|
+
meta: { host: `${id}.example` }, client: { name: id }, scores,
|
|
101
|
+
checks: [{ id, group: 'x', label: 'L', status, value: 'v' }],
|
|
102
|
+
});
|
|
103
|
+
const weak = prospectRow(mk('ai-search-access', 'fail', { a: 2 }));
|
|
104
|
+
ok('перекрытый доступ роботов ИИ попадает в письмо', /ChatGPT/.test(weak.say));
|
|
105
|
+
ok('провал посчитан', weak.failing === 1 && weak.warning === 0);
|
|
106
|
+
|
|
107
|
+
const dull = prospectRow(mk('utility', 'warn', { a: 9 }));
|
|
108
|
+
const sharp = prospectRow({
|
|
109
|
+
meta: { host: 'x.example' }, client: { name: 'x' }, scores: { a: 5 },
|
|
110
|
+
checks: [
|
|
111
|
+
{ id: 'utility', group: 'x', label: 'L', status: 'warn', value: 'v' },
|
|
112
|
+
{ id: 'ai-search-access', group: 'x', label: 'L', status: 'warn', value: 'v' },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
ok('при равной тяжести в письмо идёт сильная находка, а не первая', /ChatGPT/.test(sharp.say));
|
|
116
|
+
ok('слабая находка всё равно объяснена по-человечески', !/^utility/.test(dull.say));
|
|
117
|
+
|
|
118
|
+
const ranked = rank([prospectRow(mk('alt', 'warn', { a: 9 })), weak, { ...dull, score: null }]);
|
|
119
|
+
ok('сначала идёт тот, у кого хуже', ranked[0].score < ranked[1].score);
|
|
120
|
+
ok('сайт без оценки уходит в конец, а не наверх', ranked[ranked.length - 1].score === null);
|
|
121
|
+
|
|
122
|
+
const csv = toCsv([weak]);
|
|
123
|
+
ok('в CSV есть заголовок и строка', csv.split('\n').length === 2 && csv.startsWith('site,name,score'));
|
|
124
|
+
ok('запятая внутри фразы не ломает CSV', toCsv([{ ...weak, say: 'a, b' }]).includes('"a, b"'));
|
|
125
|
+
ok('в таблице сказано, что строки измерены, а не придуманы', /measured check, not an opinion/.test(toMarkdown([weak])));
|
|
126
|
+
|
|
127
|
+
const russian = prospectRow(mk('ai-search-access', 'fail', { a: 2 }), { lang: 'ru' });
|
|
128
|
+
ok('русская фраза приходит по-русски', /Perplexity не могут/.test(russian.say));
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
|
|
132
|
+
process.exit(failed ? 1 : 0);
|