@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 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
+ }
@@ -0,0 +1,3 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEA6kj0h3XVWk3F524Me43hYC7EoqY2URR6cVFczVCMLd4=
3
+ -----END PUBLIC KEY-----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.13.1",
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: 'Любую строку можно перепроверить бесплатным инструментом на oper-stack.ru/ai-visibility/',
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: 'Every line can be re-checked with the free tool at oper-stack.com/ai-visibility/',
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
- return { platform, price, currency, lang, langMismatch, included, needsClient, notPossible, share, thin, unknown, checkedAt: audit.meta?.collectedAt, host: audit.meta?.host };
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
- L.push('');
289
- L.push(t.recheck);
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
 
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
- pages.push(`<div class="page"><div class="cover"><div><div class="cover-top"><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>
166
- <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(a.client.preparedBy || 'OperStack')}</dd></div></dl></div>
167
- <div class="cover-foot">${esc(L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</div></div></div>`);
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++)}</div>`);
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++)}</div>`);
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++)}</div>`);
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++)}</div>`);
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++)}</div>`);
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++)}</div>`);
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++)}</div>`);
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++)}</div>`);
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 &amp; 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);