@operstack/audit 0.13.1 → 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 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
+ }
@@ -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.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;
@@ -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
 
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,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 &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
+ console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
86
+ process.exit(failed ? 1 : 0);