@operstack/audit 0.13.0 → 0.14.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 +25 -0
- package/bin/audit.mjs +45 -1
- package/licensing/public.pem +3 -0
- package/package.json +3 -2
- package/src/agency.mjs +108 -0
- package/src/batch.mjs +82 -0
- package/src/collect.mjs +9 -4
- package/src/fix.mjs +16 -6
- package/src/i18n.mjs +1 -1
- package/src/render.mjs +27 -16
- package/src/test-agency.mjs +86 -0
package/README.md
CHANGED
|
@@ -141,3 +141,28 @@ 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/
|
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, summarise } from '../src/batch.mjs';
|
|
33
35
|
import { writeFileSync, readFileSync } from 'node:fs';
|
|
34
36
|
import { resolve } from 'node:path';
|
|
35
37
|
|
|
@@ -58,6 +60,21 @@ 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 batch <sites.txt> [--out reports] [--pages 12] [--lang en|ru] [--pdf] [brand flags]',
|
|
65
|
+
' one report per line of the list; a line is "url" or "url, Client Name"',
|
|
66
|
+
'',
|
|
67
|
+
' White label:',
|
|
68
|
+
' --by "Agency Name" who the report says prepared it; also OPERSTACK_PREPARED_BY',
|
|
69
|
+
' works on collect and on render, so an existing audit can be re-signed',
|
|
70
|
+
' --recheck-url <url> where the fix report sends the client to re-check',
|
|
71
|
+
' with --by set to someone other than us and no url, the line is omitted',
|
|
72
|
+
'',
|
|
73
|
+
' Agency plan (needs a licence key, https://oper-stack.com/products/agency/):',
|
|
74
|
+
' --logo <file> svg, png, jpg or webp on the cover, embedded in the PDF',
|
|
75
|
+
' --color <hex> replaces the report accent colour, six hex digits',
|
|
76
|
+
' --no-tool-line drops the line naming the tool that collected the signals',
|
|
77
|
+
' --licence <key> OSK1 key; also OPERSTACK_LICENCE or a .operstack-licence file',
|
|
61
78
|
].join('\n'));
|
|
62
79
|
process.exit(cmd ? 0 : 2);
|
|
63
80
|
}
|
|
@@ -71,18 +88,32 @@ if (cmd === 'collect') {
|
|
|
71
88
|
const audit = await collect(target, {
|
|
72
89
|
pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'),
|
|
73
90
|
rendered: !has('--no-rendered'), renderedPages: Number(opt('--rendered-pages', 3)),
|
|
91
|
+
preparedBy: opt('--by', ''),
|
|
74
92
|
log: (m) => console.error(` ${m}`),
|
|
75
93
|
});
|
|
76
94
|
const out = resolve(opt('--out', 'audit.json'));
|
|
77
95
|
writeFileSync(out, JSON.stringify(audit, null, 2));
|
|
78
96
|
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
97
|
console.log(`next: fill the narrative fields (operstack-audit check ${out} lists them), then render.`);
|
|
98
|
+
} else if (cmd === 'batch') {
|
|
99
|
+
const brand = brandFrom(opt, has);
|
|
100
|
+
const items = readList(target);
|
|
101
|
+
if (!items.length) { console.error('the list is empty'); process.exit(2); }
|
|
102
|
+
console.error(`${items.length} site(s) to audit`);
|
|
103
|
+
const results = await runBatch(items, {
|
|
104
|
+
outDir: opt('--out', 'reports'), pages: Number(opt('--pages', 12)), lang: opt('--lang', 'en'),
|
|
105
|
+
brand, pdf: has('--pdf'), log: (m) => console.error(` ${m}`),
|
|
106
|
+
});
|
|
107
|
+
console.log(summarise(results));
|
|
108
|
+
if (!results.some((r) => r.ok)) process.exit(1);
|
|
80
109
|
} else if (cmd === 'render') {
|
|
81
110
|
const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
|
|
111
|
+
const by = opt('--by', '');
|
|
112
|
+
if (by) audit.client = { ...(audit.client || {}), preparedBy: by };
|
|
82
113
|
const out = resolve(opt('--out', target.replace(/\.json$/, '') + '.html'));
|
|
83
114
|
const drift = verifyScores(audit);
|
|
84
115
|
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') });
|
|
116
|
+
const { html, pdf } = await render(audit, { out, pdf: has('--pdf'), brand: brandFrom(opt, has) });
|
|
86
117
|
console.log(`wrote ${out} (${html.length} bytes)${pdf ? `\nwrote ${pdf}` : ''}`);
|
|
87
118
|
const missing = checkNarrative(audit);
|
|
88
119
|
if (missing.length) console.log(`note: ${missing.length} narrative field(s) still carry placeholders: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? ', ...' : ''}`);
|
|
@@ -123,6 +154,7 @@ if (cmd === 'collect') {
|
|
|
123
154
|
lang,
|
|
124
155
|
price: Number(opt('--price', lang === 'en' ? 249 : 21000)),
|
|
125
156
|
currency: opt('--currency', lang === 'en' ? 'USD' : 'RUB'),
|
|
157
|
+
recheckUrl: opt('--recheck-url', ''),
|
|
126
158
|
});
|
|
127
159
|
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
160
|
if (plan.unknown.length) console.error(`note: ${plan.unknown.length} check(s) have no fix action yet: ${plan.unknown.join(', ')}`);
|
|
@@ -166,3 +198,15 @@ if (cmd === 'collect') {
|
|
|
166
198
|
else console.log('no price in the letter: add --price once the list is agreed and you have decided what to charge');
|
|
167
199
|
console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
|
|
168
200
|
} else { console.error(`unknown command ${cmd}`); process.exit(2); }
|
|
201
|
+
|
|
202
|
+
/** Оформление из флагов. Причины, по которым что-то не применилось, печатаются сразу: молча
|
|
203
|
+
* проигнорированный логотип выглядит как поломка продукта, а не как отсутствие лицензии. */
|
|
204
|
+
function brandFrom(opt, has) {
|
|
205
|
+
const brand = resolveBranding({
|
|
206
|
+
by: opt('--by', ''), logo: opt('--logo', ''), color: opt('--color', ''),
|
|
207
|
+
licence: opt('--licence', ''), toolLine: !has('--no-tool-line'),
|
|
208
|
+
});
|
|
209
|
+
for (const n of brand.notes) console.error(` ${n}`);
|
|
210
|
+
if (brand.licensed) console.error(` agency plan active${brand.licensee ? ` (${brand.licensee})` : ''}`);
|
|
211
|
+
return brand;
|
|
212
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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,82 @@
|
|
|
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
|
+
|
|
15
|
+
/** Строки списка: адрес и, через запятую, имя клиента для обложки. Пустые строки и # игнорируются. */
|
|
16
|
+
export function parseList(text) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const raw of String(text).split('\n')) {
|
|
19
|
+
const line = raw.trim();
|
|
20
|
+
if (!line || line.startsWith('#')) continue;
|
|
21
|
+
const [url, ...rest] = line.split(',');
|
|
22
|
+
const name = rest.join(',').trim();
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(url.trim().startsWith('http') ? url.trim() : `https://${url.trim()}`);
|
|
25
|
+
out.push({ url: u.toString(), name: name || u.host });
|
|
26
|
+
} catch {
|
|
27
|
+
out.push({ url: url.trim(), name, invalid: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Имя файла из адреса: понятное человеку, без косых черт и без совпадений между сайтами. */
|
|
34
|
+
const slug = (u) => {
|
|
35
|
+
try { return new URL(u).host.replace(/^www\./, '').replace(/[^a-z0-9.-]/gi, '-'); }
|
|
36
|
+
catch { return String(u).replace(/[^a-z0-9.-]/gi, '-').slice(0, 60) || 'site'; }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function runBatch(items, {
|
|
40
|
+
outDir = 'reports', pages = 12, lang = 'en', brand = null, pdf = false,
|
|
41
|
+
log = () => {}, onDone = () => {},
|
|
42
|
+
} = {}) {
|
|
43
|
+
mkdirSync(resolve(outDir), { recursive: true });
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const [i, item] of items.entries()) {
|
|
46
|
+
const label = `${i + 1}/${items.length} ${item.url}`;
|
|
47
|
+
if (item.invalid) {
|
|
48
|
+
results.push({ ...item, ok: false, reason: 'not a valid address' });
|
|
49
|
+
log(`${label}: пропущен, адрес не разобран`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
log(`${label}: собираю`);
|
|
54
|
+
const audit = await collect(item.url, { pages, lang, rendered: false, preparedBy: brand?.preparedBy || '' });
|
|
55
|
+
audit.client.name = item.name;
|
|
56
|
+
draft(audit, { lang });
|
|
57
|
+
const base = join(resolve(outDir), slug(item.url));
|
|
58
|
+
writeFileSync(`${base}.json`, JSON.stringify(audit, null, 2));
|
|
59
|
+
const r = await render(audit, { out: `${base}.html`, pdf, brand });
|
|
60
|
+
results.push({ ...item, ok: true, json: `${base}.json`, html: `${base}.html`, pdf: r.pdf, scores: audit.scores });
|
|
61
|
+
log(`${label}: готово`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
results.push({ ...item, ok: false, reason: err.message });
|
|
64
|
+
log(`${label}: не вышло, ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
onDone(results[results.length - 1]);
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Короткая сводка для человека: что получилось, что нет и почему. */
|
|
72
|
+
export function summarise(results) {
|
|
73
|
+
const ok = results.filter((r) => r.ok);
|
|
74
|
+
const bad = results.filter((r) => !r.ok);
|
|
75
|
+
const lines = [`готово ${ok.length} из ${results.length}`];
|
|
76
|
+
for (const r of bad) lines.push(` не вышло ${r.url}: ${r.reason}`);
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readList(path) {
|
|
81
|
+
return parseList(readFileSync(resolve(path), 'utf8'));
|
|
82
|
+
}
|
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;
|
|
@@ -185,7 +185,12 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
185
185
|
log(`homepage ${startUrl}`);
|
|
186
186
|
const home = await follow(startUrl);
|
|
187
187
|
const homePage = home.response.ok ? analysePage(home.final, home.response.text) : null;
|
|
188
|
-
|
|
188
|
+
// Сайт, который не ответил, не имеет ни сертификата, ни чего-либо ещё. Пока эта строка смотрела
|
|
189
|
+
// только на схему в адресе, отчёт по несуществующему домену уверенно сообщал «certificate active».
|
|
190
|
+
const homeAnswered = Boolean(home.response.ok);
|
|
191
|
+
checks.push(homeAnswered
|
|
192
|
+
? row('https', 'technical', 'HTTPS', home.final.startsWith('https://') ? 'ok' : 'bad', home.final.startsWith('https://') ? 'certificate active' : 'site served over http')
|
|
193
|
+
: row('https', 'technical', 'HTTPS', 'bad', `the site did not answer${home.response.status ? `: HTTP ${home.response.status}` : ''}`, 'nothing below could be measured on a site that does not answer'));
|
|
189
194
|
const altHost = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
|
|
190
195
|
const alt = await follow(`${new URL(home.final).protocol}//${altHost}/`);
|
|
191
196
|
const altOk = alt.final.replace(/\/$/, '') === new URL(home.final).origin.replace(/\/$/, '') || alt.hops.some((h) => h.status >= 300 && h.status < 400);
|
|
@@ -423,8 +428,8 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
423
428
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
424
429
|
|
|
425
430
|
return {
|
|
426
|
-
meta: { site: home.final, host, 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 || '') : '' },
|
|
427
|
-
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
|
|
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: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
|
|
428
433
|
scores,
|
|
429
434
|
scoreBasis,
|
|
430
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/i18n.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export function localiseBasisNote(note) {
|
|
|
43
43
|
* пересобирая его из тех же чисел. Там, где текст постоянный, возвращается константа.
|
|
44
44
|
*/
|
|
45
45
|
const RU = {
|
|
46
|
-
https: { label: 'HTTPS', value: (v) => (/certificate active/.test(v) ? 'сертификат активен' : 'сайт отдаётся по http, без шифрования') },
|
|
46
|
+
https: { label: 'HTTPS', value: (v) => (/did not answer/.test(v) ? `сайт не ответил${/HTTP (\d+)/.exec(v) ? `: HTTP ${/HTTP (\d+)/.exec(v)[1]}` : ''}` : /certificate active/.test(v) ? 'сертификат активен' : 'сайт отдаётся по http, без шифрования'), comment: (c) => (c ? 'на сайте, который не отвечает, измерить нельзя ничего' : '') },
|
|
47
47
|
www: {
|
|
48
48
|
label: 'Склейка адреса с www',
|
|
49
49
|
value: (v) => (/redirects to/.test(v) ? 'адрес с www перенаправляется на основной'
|
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,86 @@
|
|
|
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
|
+
console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
|
|
86
|
+
process.exit(failed ? 1 : 0);
|