@operstack/audit 0.7.1 → 0.8.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
@@ -46,6 +46,18 @@ An agent can fill these fields from the JSON: the Claude Code plugin `operstack-
46
46
 
47
47
  Ten A4 pages: cover, executive summary with a scorecard, site overview with the sampled pages, critical issues, technical and on-page checklist, content and structure, AEO and GEO, off-page and conversion and limitations, roadmap in three phases, closing message. HTML always; PDF with `--pdf` when Chrome or Chromium is installed (`CHROME_PATH` overrides the lookup).
48
48
 
49
+ ## Writing the report: `draft`
50
+
51
+ **If you have never done SEO, start here.** An audit tells you what a search engine and an AI see when they open your site. This tool writes that report for you. It does not guess anything about your business: every sentence in it comes from something that was measured on your pages, and where nothing could be measured it says so instead of filling the gap with words. You read it, correct what only you know, and send it.
52
+
53
+ ```
54
+ npx @operstack/audit collect https://example.com --out audit.json
55
+ npx @operstack/audit draft audit.json --lang en
56
+ npx @operstack/audit render audit.json --pdf
57
+ ```
58
+
59
+ Technically: `draft` fills the narrative fields (`summary`, `client.subject`, `overview.note`, `content`, `aeo`, `geo`, `offpage`, `conversion`, `roadmap`, `closing`) from the collected checks. Priorities and roadmap items are written as actions, taken from the same table the Fix checklist uses, so a finding and the work that closes it read the same. Fields an analyst has already written are left alone; anything still empty is listed at the end. For a Russian report over an English audit, check and area names are translated first.
60
+
49
61
  ## The Fix package: turning findings into agreed work
50
62
 
51
63
  An audit says what is wrong. `fix-plan` says what we will do about it, before anyone pays.
@@ -79,7 +91,7 @@ npx @operstack/audit foundation-scope audit.json --lang en --new-pages 3
79
91
 
80
92
  Each sampled page is read for five things: does it open with an answer carrying a figure, are the figures sourced, is it broken into sections, does it carry a table worth quoting, and is there enough of it at all. A page needing one edit is one share of work, two or three defects is two, four or more (or an empty page) is three, and a page that does not exist yet is three. Shares times the unit price, lifted to the package minimum.
81
93
 
82
- Utility pages are excluded and counted separately: nobody rewrites a privacy policy to open with a figure. Sources are asked for only where the page actually carries figures. Without Search Console access the scope is built on a sitemap sample, and the letter says so rather than implying we picked the pages that matter.
94
+ The letter states the scope and the shares; `--price` adds the money, because a short page is sometimes short on purpose and an automatic invoice for rewriting it is an invoice for work that should not happen. Utility pages are excluded and counted separately: nobody rewrites a privacy policy to open with a figure. Sources are asked for only where the page actually carries figures. Without Search Console access the scope is built on a sitemap sample, and the letter says so rather than implying we picked the pages that matter.
83
95
 
84
96
  ## Example
85
97
 
package/bin/audit.mjs CHANGED
@@ -14,6 +14,8 @@
14
14
  * what was closed, what was not, and how much to refund
15
15
  * operstack-audit foundation-scope audit.json [--lang ru|en] [--new-pages 3] [--gsc]
16
16
  * the text work the Foundation package would do, page by page, with the price it adds up to
17
+ * operstack-audit draft audit.json [--lang ru|en] [--out audit.json]
18
+ * fills the narrative fields from the checks so the analyst edits instead of writing
17
19
  * operstack-audit check audit.json
18
20
  * lists narrative fields still holding placeholders and any score the checks do not support
19
21
  *
@@ -23,6 +25,7 @@
23
25
  import { collect, verifyScores } from '../src/collect.mjs';
24
26
  import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
25
27
  import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
28
+ import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
26
29
  import { render, checkNarrative } from '../src/render.mjs';
27
30
  import { writeFileSync, readFileSync } from 'node:fs';
28
31
  import { resolve } from 'node:path';
@@ -38,6 +41,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
38
41
  ' collect the public signals of a site into audit.json',
39
42
  ' operstack-audit render <audit.json> [--out report.html] [--pdf]',
40
43
  ' render the report; --pdf also prints a PDF with headless Chrome',
44
+ ' operstack-audit draft <audit.json> [--lang ru|en] [--out audit.json]',
45
+ ' fill the narrative from the checks: the analyst edits a draft instead of writing one',
41
46
  ' operstack-audit check <audit.json>',
42
47
  ' list narrative fields still holding placeholders and any score the checks do not support',
43
48
  '',
@@ -45,8 +50,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
45
50
  ' what the Fix package closes on this site: plan, client letter, executor checklist',
46
51
  ' operstack-audit fix-report <audit-fix-plan.json> <after.json> [--out report.md]',
47
52
  ' what was closed, what was not, and how much to refund',
48
- ' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--unit 50] [--minimum 500] [--out base]',
49
- ' the text work the Foundation package would do, page by page, and the price it adds up to',
53
+ ' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--price] [--out base]',
54
+ ' the text work the Foundation package would do, page by page; --price also states the money',
50
55
  ].join('\n'));
51
56
  process.exit(cmd ? 0 : 2);
52
57
  }
@@ -76,6 +81,17 @@ if (cmd === 'collect') {
76
81
  if (drift.length) { console.log(`${drift.length} score(s) that no check supports:`); for (const d of drift) console.log(` ${d}`); }
77
82
  if (!missing.length && !drift.length) console.log('every narrative field is written and every score matches its checks');
78
83
  else process.exit(1);
84
+ } else if (cmd === 'draft') {
85
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
86
+ const before = stillEmpty(audit).length;
87
+ const drafted = draftNarrative(audit, { lang: opt('--lang', audit.meta?.lang || 'en') });
88
+ const after = stillEmpty(drafted);
89
+ const out = resolve(opt('--out', target));
90
+ writeFileSync(out, JSON.stringify(drafted, null, 2));
91
+ console.log(`${before} field(s) were empty, ${after.length} left`);
92
+ if (after.length) for (const f of after) console.log(` still yours to write: ${f}`);
93
+ console.log(`wrote ${out}`);
94
+ console.log('read it before sending: the draft states only what was measured, and says so where nothing was.');
79
95
  } else if (cmd === 'fix-plan') {
80
96
  const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
81
97
  // Язык покупки решает и валюту, и письмо: за доллары платят по английскому списку, за рубли по русскому.
@@ -114,6 +130,7 @@ if (cmd === 'collect') {
114
130
  lang,
115
131
  newPages: Number(opt('--new-pages', 0)),
116
132
  hasSearchConsole: has("--gsc"),
133
+ withPrice: has('--price'),
117
134
  unit: opt('--unit') ? Number(opt('--unit')) : undefined,
118
135
  minimum: opt('--minimum') ? Number(opt('--minimum')) : undefined,
119
136
  currency: opt('--currency'),
@@ -123,6 +140,7 @@ if (cmd === 'collect') {
123
140
  writeFileSync(resolve(`${base}-foundation-letter.md`), renderFoundationScope(scope));
124
141
  writeFileSync(resolve(`${base}-foundation-checklist.md`), renderFoundationChecklist(scope));
125
142
  console.log(`${scope.pages.length} page(s) need text work of ${scope.sampledPages} sampled, ${scope.shares} share(s)`);
126
- console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
143
+ if (scope.withPrice) console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
144
+ else console.log('no price in the letter: add --price once the list is agreed and you have decided what to charge');
127
145
  console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
128
146
  } else { console.error(`unknown command ${cmd}`); process.exit(2); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
@@ -13,7 +13,8 @@
13
13
  "./package.json": "./package.json",
14
14
  "./i18n": "./src/i18n.mjs",
15
15
  "./fix": "./src/fix.mjs",
16
- "./foundation": "./src/foundation.mjs"
16
+ "./foundation": "./src/foundation.mjs",
17
+ "./narrative": "./src/narrative.mjs"
17
18
  },
18
19
  "files": [
19
20
  "bin",
@@ -24,7 +25,7 @@
24
25
  "LICENSE"
25
26
  ],
26
27
  "scripts": {
27
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
28
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
28
29
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
29
30
  },
30
31
  "keywords": [
package/src/collect.mjs CHANGED
@@ -59,9 +59,22 @@ function analysePage(url, html) {
59
59
  walk(j);
60
60
  } catch { schemaTypes.push('(invalid JSON-LD)'); }
61
61
  }
62
+ // Служебная обвязка вырезается, кроме <header>, в котором стоит H1: это шапка статьи, а не сайта.
63
+ const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
64
+ .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
65
+ .replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
66
+ const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
67
+ const countWords = (x) => (x.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
62
68
  const body = root.querySelector('body');
63
- const text = (body?.text ?? '').replace(/\s+/g, ' ').trim();
64
- const words = (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
69
+ // Парсер иногда не находит body в большом или неаккуратном HTML и молча возвращает пустой текст,
70
+ // а пустой текст это приговор «страница пустая». Проверено на css-tricks.com: 178 КБ, body не
71
+ // найден, слов ноль при живых 978. Поэтому берём тот текст, которого больше: пустоту показывать
72
+ // клиенту нельзя, это ложное обвинение, за которое он ещё и заплатит в смете.
73
+ const parsedText = (body?.text ?? '').replace(/\s+/g, ' ').trim();
74
+ const text = countWords(parsedText) >= countWords(plain(bodyHtml)) ? parsedText : plain((html.match(/<body[\s\S]*<\/body>/i) || [html])[0].replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' '));
75
+ // Слова считаем по содержимому без меню и подвала: иначе страница из одного абзаца в большом
76
+ // шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
77
+ const words = countWords(plain(bodyHtml)) || countWords(text);
65
78
  const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
66
79
  const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
67
80
  const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
@@ -108,11 +121,6 @@ function analysePage(url, html) {
108
121
  author: /"author"\s*:/.test(ld),
109
122
  };
110
123
  // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
111
- // Site chrome is stripped, except a <header> that carries the H1: that is the article head, not chrome.
112
- const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
113
- .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
114
- .replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
115
- const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
116
124
  const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
117
125
  const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
118
126
  const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
@@ -125,13 +133,18 @@ function analysePage(url, html) {
125
133
  const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
126
134
  // Абзацы, в которых вообще есть цифра. Без этого счётчика «цифры без источника» вменяется
127
135
  // странице, где цифр нет ни одной, например политике конфиденциальности.
128
- const figureParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => /\d/.test(plain(para))).length;
129
- const citedParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => {
130
- if (!/\d/.test(plain(para))) return false;
131
- return [...para.matchAll(/<a[^>]+href=["'](https?:\/\/[^"']+)["']/gi)].some((m) => {
132
- try { return new URL(m[1]).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
133
- });
134
- }).length;
136
+ // Абзацы берём разбором дерева, а не регуляркой: на странице с незакрытым <p> регулярка
137
+ // склеивает переключатель валют, заголовок и текст в один «абзац с цифрой». Порог в двенадцать
138
+ // слов отсекает служебные обрывки: цена в карточке это не утверждение, которому нужен источник.
139
+ const PROSE_WORDS = 12;
140
+ const paras = (body ? body.querySelectorAll('p') : []).map((el) => ({
141
+ text: (el.text || '').replace(/\s+/g, ' ').trim(),
142
+ hrefs: el.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || ''),
143
+ })).filter((x) => countWords(x.text) >= PROSE_WORDS && /\d/.test(x.text));
144
+ const figureParagraphs = paras.length;
145
+ const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
146
+ try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
147
+ })).length;
135
148
  return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
136
149
  }
137
150
 
@@ -13,6 +13,10 @@
13
13
  * Чего этот расчёт не знает: какие страницы приносят показы. Это видно только в Search Console,
14
14
  * доступ к которой даёт клиент, и это бесплатно. Без доступа мы считаем по выборке и говорим
15
15
  * об этом прямо, а не делаем вид, что выбрали важные страницы.
16
+ *
17
+ * Деньги в письмо по умолчанию не попадают. Объём работы машина считает честно, а цену называет
18
+ * человек: короткая страница бывает короткой намеренно, и счёт, выставленный автоматом, окажется
19
+ * счётом за работу, которой не нужно было делать. Цена включается флагом, осознанно.
16
20
  */
17
21
 
18
22
  /**
@@ -84,13 +88,17 @@ export function buildFoundationScope(audit, opts = {}) {
84
88
  const unit = Number(opts.unit ?? (lang === 'en' ? 50 : 4200));
85
89
  const minimum = Number(opts.minimum ?? (lang === 'en' ? 500 : 42000));
86
90
  const newPages = Math.max(0, Number(opts.newPages ?? 0));
91
+ const withPrice = opts.withPrice === true;
87
92
 
88
93
  const sampled = (audit.sample || []).filter((p) => p.title !== undefined && typeof p.words === 'number');
89
94
  const utility = sampled.filter((p) => isUtilityPage(p.url));
90
95
  const content = sampled.filter((p) => !isUtilityPage(p.url));
91
96
  const pages = [];
92
97
  for (const p of content) {
93
- const issues = PAGE_ISSUES.filter((i) => i.test(p));
98
+ let issues = PAGE_ISSUES.filter((i) => i.test(p));
99
+ // Странице, которую пишем заново, незачем отдельной строкой советовать переписать первый
100
+ // абзац: это та же работа, названная дважды, и в списке она выглядит как две.
101
+ if (issues.some((i) => i.heavy)) issues = issues.filter((i) => i.heavy);
94
102
  if (!issues.length) continue;
95
103
  const tier = tierOf(issues);
96
104
  pages.push({
@@ -112,7 +120,7 @@ export function buildFoundationScope(audit, opts = {}) {
112
120
  const price = Math.max(minimum, raw);
113
121
 
114
122
  return {
115
- lang, currency, unit, minimum, newPages,
123
+ lang, currency, unit, minimum, newPages, withPrice,
116
124
  host: audit.meta?.host,
117
125
  checkedAt: audit.meta?.collectedAt,
118
126
  sampledPages: sampled.length,
@@ -134,6 +142,9 @@ const T = {
134
142
  tier: { rewrite: 'написать заново', rework: 'переработать', touch: 'одна правка' },
135
143
  words: (n) => `${n} ${plural(n, 'слово', 'слова', 'слов')}`,
136
144
  newPages: (n, s) => `## Новые страницы: ${n}\n\nСтраниц, которых у вас нет, а рынок их спрашивает: ${n}. Каждая считается как страница с нуля, это ${s} ${plural(s, 'доля', 'доли', 'долей')}.`,
145
+ workHead: '## Объём работы',
146
+ workBody: (s) => `Всего ${s} ${plural(s, 'доля', 'доли', 'долей')} работы. Доля это работа над одной страницей: одна правка доля, две или три две доли, четыре и больше или пустая страница три. Цену мы назовём отдельно, когда вы согласуете сам список.`,
147
+ thinWarn: 'Среди них есть страницы короче трёхсот слов. Если такие страницы у вас задуманы короткими, это анонсы, новости или ссылки на чужой материал, скажите, и мы уберём их из списка: писать их заново незачем.',
137
148
  priceHead: '## Цена',
138
149
  priceBody: (s, u, c, raw) => `Доля работы над одной страницей стоит ${u} ${c}. Долей набралось ${s}, это ${raw} ${c}.`,
139
150
  atMin: (min, c) => `Минимум пакета ${min} ${c}, поэтому в счёте будет ${min} ${c}: меньше этой суммы браться нет смысла ни нам, ни вам.`,
@@ -157,6 +168,9 @@ const T = {
157
168
  tier: { rewrite: 'write from scratch', rework: 'rework', touch: 'one edit' },
158
169
  words: (n) => `${n} word${n === 1 ? '' : 's'}`,
159
170
  newPages: (n, s) => `## New pages: ${n}\n\nPages you do not have and the market asks for: ${n}. Each counts as a page written from scratch, ${s} share${s === 1 ? '' : 's'}.`,
171
+ workHead: '## Scope of work',
172
+ workBody: (s) => `${s} share${s === 1 ? '' : 's'} of work in total. A share is the work on one page: one edit is a share, two or three defects are two, four or more (or an empty page) three. We name the price separately, once you have agreed the list itself.`,
173
+ thinWarn: 'Some of these are under 300 words. If those pages are meant to be short, link posts, news items, announcements, say so and we will drop them from the list: there is nothing to rewrite.',
160
174
  priceHead: '## Price',
161
175
  priceBody: (s, u, c, raw) => `One share of work on a page costs ${u} ${c}. The scope came to ${s} shares, which is ${raw} ${c}.`,
162
176
  atMin: (min, c) => `The package minimum is ${min} ${c}, so the invoice will say ${min} ${c}: below that it is not worth either side's time.`,
@@ -194,11 +208,18 @@ export function renderFoundationScope(scope) {
194
208
  }
195
209
  if (scope.newPages) { L.push(t.newPages(scope.newPages, scope.newShares)); L.push(''); }
196
210
 
197
- L.push(t.priceHead);
198
- L.push('');
199
- L.push(t.priceBody(scope.shares, scope.unit, scope.currency, scope.raw));
200
- if (scope.atMinimum) L.push(t.atMin(scope.minimum, scope.currency));
201
- L.push(t.total(scope.price, scope.currency));
211
+ if (scope.withPrice) {
212
+ L.push(t.priceHead);
213
+ L.push('');
214
+ L.push(t.priceBody(scope.shares, scope.unit, scope.currency, scope.raw));
215
+ if (scope.atMinimum) L.push(t.atMin(scope.minimum, scope.currency));
216
+ L.push(t.total(scope.price, scope.currency));
217
+ } else {
218
+ L.push(t.workHead);
219
+ L.push('');
220
+ L.push(t.workBody(scope.shares));
221
+ }
222
+ if (scope.pages.some((p) => p.issues.some((i) => i.id === 'thin'))) { L.push(''); L.push(t.thinWarn); }
202
223
  L.push('');
203
224
  L.push(t.needHead);
204
225
  L.push('');
@@ -216,7 +237,7 @@ export function renderFoundationScope(scope) {
216
237
  export function renderFoundationChecklist(scope) {
217
238
  const t = dict(scope.lang);
218
239
  const L = [`# ${scope.lang === 'en' ? 'Foundation checklist' : 'Чек-лист Foundation'}: ${scope.host}`, '',
219
- `${scope.lang === 'en' ? 'Shares' : 'Долей'}: ${scope.shares} (${scope.pageShares} + ${scope.newShares} ${scope.lang === 'en' ? 'new' : 'новых'}). ${scope.lang === 'en' ? 'Price' : 'Цена'}: ${scope.price} ${scope.currency}.`, ''];
240
+ `${scope.lang === 'en' ? 'Shares' : 'Долей'}: ${scope.shares} (${scope.pageShares} + ${scope.newShares} ${scope.lang === 'en' ? 'new' : 'новых'}).${scope.withPrice ? ` ${scope.lang === 'en' ? 'Price' : 'Цена'}: ${scope.price} ${scope.currency}.` : ''}`, ''];
220
241
  for (const p of scope.pages) {
221
242
  L.push(`- [ ] ${p.title} (${t.tier[p.tier]}, ${p.shares})`);
222
243
  L.push(` ${p.url}`);
package/src/i18n.mjs CHANGED
@@ -17,6 +17,8 @@ const plural = (n, one, few, many) => {
17
17
  const d = Math.abs(n) % 10; const dd = Math.abs(n) % 100;
18
18
  return `${n} ${d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many}`;
19
19
  };
20
+ /** Проценты по-русски склоняются: 31 процент, 32 процента, 35 процентов. */
21
+ const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
20
22
  const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
21
23
  const pagesOf = (a, b) => `${a} из ${pages(b)}`;
22
24
 
@@ -89,7 +91,7 @@ const RU = {
89
91
  hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
90
92
  cms: { label: 'Движок сайта', value: (v) => v },
91
93
  xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
92
- alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${n[2]} процентов, на ${pages(n[3])} выборки`; } },
94
+ alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${pages(n[3])} выборки`; } },
93
95
  'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному H1 на страницу' : `${nums(v)[0]} из ${pages(nums(v)[1])} имеют больше одного H1`) },
94
96
  'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
95
97
  thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${pages(nums(v)[1])} короче 300 слов`) },
@@ -103,7 +105,7 @@ const RU = {
103
105
  tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
104
106
  sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
105
107
  dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
106
- 'links-profile': { label: 'Профиль внешних ссылок (необязательное дополнение)', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'ссылающийся домен', 'ссылающихся домена', 'ссылающихся доменов')}, ${plural(Number(n[1]), 'ссылка', 'ссылки', 'ссылок')}, ${n[2]} процентов передают вес, показатель авторитета ${n[3]} из 100`; }, comment: (c) => `${c.split('.')[0]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
108
+ 'links-profile': { label: 'Профиль внешних ссылок (необязательное дополнение)', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'ссылающийся домен', 'ссылающихся домена', 'ссылающихся доменов')}, ${plural(Number(n[1]), 'ссылка', 'ссылки', 'ссылок')}, ${pct(n[2])} передают вес, показатель авторитета ${n[3]} из 100`; }, comment: (c) => `${c.split('.')[0]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
107
109
  'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
108
110
  'trust-contact': {
109
111
  label: 'Прямые контакты',
@@ -115,7 +117,7 @@ const RU = {
115
117
  'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка')}`) },
116
118
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
117
119
  'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
118
- 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${nums(v)[0]} процентов вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
120
+ 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
119
121
  'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
120
122
  'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
121
123
  };
package/src/index.mjs CHANGED
@@ -3,3 +3,4 @@ export { render, checkNarrative } from './render.mjs';
3
3
  export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
4
4
  export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
5
5
  export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
6
+ export { draftNarrative, stillEmpty } from './narrative.mjs';
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Черновик текста аудита, собранный из проверок.
3
+ *
4
+ * Раньше десятиcтраничный отчёт выходил из сборщика с тридцатью двумя дырами: резюме, приоритеты,
5
+ * разбор контента, вывод. Их писал человек, то есть каждая продажа стоила часа работы, и продать
6
+ * десять аудитов в неделю было физически нельзя. Здесь эти дыры закрываются словами, собранными
7
+ * из тех же цифр, что уже измерены. Аналитику остаётся править, а не писать с нуля.
8
+ *
9
+ * Два правила, которые тут важнее всего.
10
+ *
11
+ * Первое: ни одного факта о бизнесе клиента мы не выдумываем. Всё, что пишется, либо посчитано
12
+ * проверками, либо прочитано с самой страницы, и тогда помечено как прочитанное, чтобы аналитик
13
+ * подтвердил. Там, где знания нет, стоит честная строка «не измеряли», а не догадка.
14
+ *
15
+ * Второе: отчёт читает не сеошник. Его читает владелец, который платил агентству, результата не
16
+ * увидел и теперь разбирается сам. Поэтому каждая мысль сначала объясняется человеческими словами
17
+ * (что это значит для него и его денег), и только потом называется технически.
18
+ */
19
+
20
+ const T = {
21
+ ru: {
22
+ subjectFrom: (d) => `По описанию на главной: ${d}. Подтвердите, что это верно.`,
23
+ subjectNone: 'На главной не сказано, чем занимается компания. Это и есть первая проблема: если это непонятно человеку, это непонятно и поисковику, и ИИ.',
24
+ unknownLang: 'не указан в коде страниц',
25
+ sells: (d) => d ? `${d} (взято из описания главной, подтвердите)` : 'из описания на главной не понять, подтвердите',
26
+ leadWhat: (h, n) => `${h} это сайт из ${n} ${plural(n, 'страницы', 'страниц', 'страниц')}, и он читается машинами: мы открыли его так же, как их открывают поисковик и ИИ.`,
27
+ leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
28
+ leadBad: (area, n) => n ? `Держит сайт назад другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
29
+ verdict: (openN, mech) => openN
30
+ ? `Главное: ${openN} ${plural(openN, 'вещь мешает', 'вещи мешают', 'вещей мешают')} сайту показываться и попадать в ответы ИИ. Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы покупателей.`
31
+ : 'Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы покупателей, цифрами и источниками, по которым вас можно процитировать.',
32
+ noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
33
+ ctaYes: 'на первом экране есть понятный призыв к действию',
34
+ ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
35
+ contactYes: 'связаться можно почти с любой страницы',
36
+ contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
37
+ strengthNone: 'Сильных сторон в текстах проверки не нашли.',
38
+ weakNone: 'Слабых мест в текстах проверки не нашли.',
39
+ gapsNone: 'Пробелов по страницам из выборки не видно.',
40
+ aeoWorksNone: 'Пока ничего из того, что помогает попасть в ответы, на сайте не работает.',
41
+ aeoBlocksNone: 'Прямых помех для попадания в ответы не нашли.',
42
+ aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой и назовите источник этой цифры рядом. Так страницу можно процитировать.` : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
43
+ geoEntity: ['Кто вы, понятно машине', (ok) => ok ? 'да' : 'нет'],
44
+ notMeasured: 'не измеряли',
45
+ geoMentions: 'Упоминания на чужих сайтах',
46
+ geoReviews: 'Отзывы и публикации',
47
+ geoMentionsNote: 'Не измеряли: данные о ссылках и упоминаниях продаются за деньги, а мы обещали отчёт только по бесплатным источникам.',
48
+ geoCallout: (ok) => ok
49
+ ? 'Роботы, которые достают страницу, чтобы процитировать её в ответе, на сайт пущены. Это половина дела: дальше вопрос в том, есть ли что цитировать.'
50
+ : 'Роботы, которые достают страницу для цитаты в ответе ИИ, на сайт не пущены. Пока это так, попасть в ответы невозможно физически, чего бы вы ни написали.',
51
+ offpageListed: (profiles) => profiles.length ? `Заявлены профили: ${profiles.join(', ')}.` : 'Ни одного профиля компании на других площадках в коде сайта не заявлено.',
52
+ offpageNote: 'Мы не покупаем данные о ссылках, поэтому не пишем ни цифр про их количество, ни оценок за них. Что проверяемо бесплатно: сказал ли сайт сам, где ещё вас искать, и совпадает ли это с реальностью.',
53
+ convForm: (v) => v,
54
+ priceRow: 'Цена видна',
55
+ priceNo: 'на проверенных страницах цены нет',
56
+ closing: (host, bad) => bad
57
+ ? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается то, что чинится технически: это быстро и это видно в цифрах. Потом берутся тексты, и вот там понадобитесь вы, потому что цифры и факты про ваш бизнес знаете только вы.`
58
+ : `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
59
+ weekFill: 'Больше ничего срочного не нашли',
60
+ monthFill: 'Тексты по вашим фактам: ответы в начале страниц, источники к цифрам, недостающие страницы',
61
+ quarterFill: 'Повторная проверка и сравнение с этим отчётом',
62
+ },
63
+ en: {
64
+ subjectFrom: (d) => `From the homepage description: ${d}. Please confirm this is right.`,
65
+ subjectNone: 'The homepage does not say what the business does. That is the first problem: if a person cannot tell, neither can a search engine or an AI.',
66
+ unknownLang: 'not declared in the page code',
67
+ sells: (d) => d ? `${d} (taken from the homepage description, please confirm)` : 'the homepage description does not say, please confirm',
68
+ leadWhat: (h, n) => `${h} is a site of ${n} page${n === 1 ? '' : 's'}, and it was read the way machines read it: the way a search engine and an AI open it.`,
69
+ leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
70
+ leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed, and the hardest area is "${area}".` : `No check is failing, but the last points are missing, mostly in "${area}".`,
71
+ verdict: (openN, mech) => openN
72
+ ? `The point: ${openN} thing${openN === 1 ? '' : 's'} stop this site from being shown and from being quoted by AI. ${mech} of them are mechanical, fixable without rewriting a word, which means quickly; the other ${openN - mech} need your facts: figures, sources, answers to what buyers ask.`
73
+ : 'The point: nothing is broken. From here the site grows through text, not repair: answers to what buyers ask, figures, and the sources behind them.',
74
+ noteOffer: (cta, contact) => `What a visitor meets: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
75
+ ctaYes: 'the first screen has a clear thing to do',
76
+ ctaNo: 'the first screen has nothing to click, which means arrivals leave',
77
+ contactYes: 'getting in touch is possible from almost any page',
78
+ contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
79
+ strengthNone: 'The checks found no strength in the text.',
80
+ weakNone: 'The checks found no weakness in the text.',
81
+ gapsNone: 'No gaps are visible across the sampled pages.',
82
+ aeoWorksNone: 'Nothing that helps a page get quoted is working yet.',
83
+ aeoBlocksNone: 'Nothing directly blocks a page from being quoted.',
84
+ aeoRec: (p) => p ? `Start with one page: ${p}. Put a direct answer carrying a figure at the top and name where that figure comes from, right beside it. That is what makes a page quotable.` : 'Put a direct answer carrying a figure at the top of every page that matters, and name the source beside it. What gets quoted is what can be lifted in one paragraph.',
85
+ notMeasured: 'not measured',
86
+ geoMentions: 'Mentions elsewhere',
87
+ geoReviews: 'Reviews and press',
88
+ geoMentionsNote: 'Not measured: link and mention data is sold for money, and this report was promised on free sources only.',
89
+ geoCallout: (ok) => ok
90
+ ? 'The fetchers that pull a page in order to quote it in an answer are allowed in. That is half the job: the rest is whether there is anything worth quoting.'
91
+ : 'The fetchers that pull a page to quote it in an AI answer are blocked. While that is true, being quoted is physically impossible, whatever you write.',
92
+ offpageListed: (profiles) => profiles.length ? `Profiles declared: ${profiles.join(', ')}.` : 'The site declares no company profile anywhere else.',
93
+ offpageNote: 'We do not buy link data, so we print no number of links and no score built on one. What is checkable for free: whether the site says where else to find you, and whether that matches reality.',
94
+ priceRow: 'Price visible',
95
+ priceNo: 'no price on the pages we read',
96
+ closing: (host, bad) => bad
97
+ ? `We looked at ${host} the way a machine looks at it and wrote down what we saw, without generalities. What gets fixed first is what is mechanical: it is quick and it shows in the numbers. Then comes the text, and there we need you, because only you know the figures and facts of your business.`
98
+ : `We looked at ${host} the way a machine looks at it. Technically it is in order and there is nothing to repair, and that is a finding, not a compliment. Growth from here comes through text: answers to buyers' questions, with figures and sources.`,
99
+ weekFill: 'Nothing else is urgent',
100
+ monthFill: 'Text from your facts: answers at the top of pages, sources beside figures, the pages you are missing',
101
+ quarterFill: 'A second run, compared against this report',
102
+ },
103
+ };
104
+
105
+ function plural(n, one, few, many) {
106
+ const d = n % 10; const dd = n % 100;
107
+ return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
108
+ }
109
+ const listRu = (a) => a.length > 1 ? `${a.slice(0, -1).join(', ')} и ${a[a.length - 1]}` : a[0] || '';
110
+ const listEn = (a) => a.length > 1 ? `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}` : a[0] || '';
111
+
112
+ /** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
113
+ const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-profiles', 'trust-about', 'trust-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
114
+
115
+ import { localiseChecks, AREAS_RU } from './i18n.mjs';
116
+ import { FIX_ACTIONS } from './fix.mjs';
117
+
118
+ const byId = (checks, id) => (checks || []).find((c) => c.id === id);
119
+ const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
120
+
121
+ /**
122
+ * Заполняет пустые поля отчёта словами, собранными из проверок.
123
+ * @param {object} audit результат collect()
124
+ * @param {{lang?: 'ru'|'en'}} opts
125
+ */
126
+ export function draftNarrative(audit, opts = {}) {
127
+ const lang = opts.lang || audit.meta?.lang || 'en';
128
+ const t = T[lang === 'ru' ? 'ru' : 'en'];
129
+ const a = JSON.parse(JSON.stringify(audit));
130
+ // Отчёт по-русски по английскому аудиту: названия проверок и областей переводим здесь же,
131
+ // иначе владелец читает русский текст с английскими вставками и перестаёт нам верить.
132
+ const needsRu = lang === 'ru' && a.meta?.lang !== 'ru';
133
+ const checks = needsRu ? localiseChecks(a.checks || [], 'ru') : (a.checks || []);
134
+ const areaName = (k) => (needsRu ? (AREAS_RU[k] || k) : k);
135
+ const host = a.meta?.host || '';
136
+ const sample = (a.sample || []).filter((p) => p.title !== undefined);
137
+ const home = sample[0] || {};
138
+
139
+ const bad = checks.filter((c) => c.status === 'bad');
140
+ const warn = checks.filter((c) => c.status === 'warn');
141
+ const mechanical = [...bad, ...warn].filter((c) => MECHANICAL.has(c.id)).length;
142
+ const scores = Object.entries(a.scores || {}).filter(([, v]) => typeof v === 'number');
143
+ const strong = scores.filter(([, v]) => v >= 8).map(([k]) => areaName(k));
144
+ const weakest = areaName(scores.slice().sort((x, y) => x[1] - y[1])[0]?.[0] || '');
145
+
146
+ // Кто клиент. Имя это адрес сайта, предмет прочитан с главной и помечен как прочитанный.
147
+ const desc = String(home.description || '').trim().slice(0, 180);
148
+ a.client.name = a.client.name?.includes('{{') ? host : a.client.name;
149
+ if (String(a.client.subject).includes('{{')) a.client.subject = desc ? t.subjectFrom(desc) : t.subjectNone;
150
+
151
+ // Резюме.
152
+ if (String(a.summary.lead).includes('{{')) {
153
+ a.summary.lead = [t.leadWhat(host, sample.length), t.leadGood(strong), t.leadBad(weakest, bad.length)].join(' ');
154
+ }
155
+ if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical);
156
+ // Приоритет это не название проверки, а действие: владелец должен прочитать и понять, что делать.
157
+ const actionText = (c) => {
158
+ const rule = FIX_ACTIONS[c.id];
159
+ const L = lang === 'ru' ? 'ru' : 'en';
160
+ if (rule && rule.action) return rule.action[L];
161
+ // Проверки, которые закрываются только фактами клиента: называем работу, а не оправдание.
162
+ const TEXT_WORK = {
163
+ ru: { thin: 'Наполнить короткие страницы фактами: цифрами, условиями, сроками', 'answer-first': 'Поставить в начало страницы прямой ответ с цифрой', sources: 'Подписать к каждой цифре, откуда она взята', sections: 'Разбить страницу на разделы по вопросам читателя', tables: 'Собрать таблицу из ваших данных: таблицы цитируют охотнее абзацев' },
164
+ en: { thin: 'Fill the short pages with facts: figures, terms, timings', 'answer-first': 'Put a direct answer carrying a figure at the top of the page', sources: 'Name where each figure comes from', sections: 'Break the page into sections that follow the reader\'s questions', tables: 'Build a table from your data: tables get quoted more readily than prose' },
165
+ };
166
+ const own = TEXT_WORK[L][c.id];
167
+ if (own) return own;
168
+ if (rule && rule.why) return L === 'ru' ? `Это работа с текстами: ${rule.why.ru}` : `This is text work: ${rule.why.en}`;
169
+ return c.label;
170
+ };
171
+ const action = (c) => `${actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
172
+ const top = [...bad, ...warn].slice(0, 3);
173
+ a.summary.priorities = (a.summary.priorities || []).map((p, i) => {
174
+ if (!String(p).includes('{{')) return p;
175
+ const c = top[i];
176
+ return c ? action(c) : (lang === 'ru' ? 'Ничего срочного больше не нашли' : 'Nothing else is urgent');
177
+ });
178
+
179
+ // Обзор.
180
+ a.overview.rows = (a.overview.rows || []).map(([k, v]) => {
181
+ if (!String(v).includes('{{')) return [k, v];
182
+ if (/language|язык/i.test(k)) return [k, a.meta?.language || t.unknownLang];
183
+ if (/sold|продаётся|продается/i.test(k)) return [k, t.sells(desc)];
184
+ return [k, t.notMeasured];
185
+ });
186
+ if (String(a.overview.note).includes('{{')) {
187
+ const cta = byId(checks, 'conv-cta');
188
+ const path = byId(checks, 'conv-contact-path');
189
+ a.overview.note = t.noteOffer(open(cta) ? t.ctaNo : t.ctaYes, open(path) ? t.contactNo : t.contactYes);
190
+ }
191
+
192
+ // Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
193
+ const group = (g) => checks.filter((c) => c.group === g);
194
+ const fillList = (arr, source, empty) => (arr || []).map((x, i) => {
195
+ if (!String(x).includes('{{')) return x;
196
+ const c = source[i];
197
+ return c ? `${c.label}: ${c.value}` : empty;
198
+ });
199
+ a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
200
+ a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), t.weakNone);
201
+ a.content.gaps = fillList(a.content.gaps, group('content').filter((c) => c.status === 'bad'), t.gapsNone);
202
+ a.aeo.works = fillList(a.aeo.works, group('aeo').filter((c) => c.status === 'ok'), t.aeoWorksNone);
203
+ a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
204
+ if (String(a.aeo.recommendation).includes('{{')) {
205
+ const thinnest = sample.filter((p) => p.answerFirst === false).sort((x, y) => (y.words || 0) - (x.words || 0))[0];
206
+ a.aeo.recommendation = t.aeoRec(thinnest ? thinnest.url : '');
207
+ }
208
+
209
+ const entity = byId(checks, 'trust-entity') || byId(checks, 'org-schema');
210
+ a.geo.rows = (a.geo.rows || []).map((row, i) => {
211
+ const [label, status, detail] = row;
212
+ if (!String(status).includes('{{') && !String(detail).includes('{{')) return row;
213
+ if (i === 0) return [label, entity && entity.status === 'ok' ? (lang === 'ru' ? 'да' : 'yes') : (lang === 'ru' ? 'нет' : 'no'), entity ? entity.value : t.notMeasured];
214
+ if (i === 1) return [t.geoMentions, t.notMeasured, t.geoMentionsNote];
215
+ return [t.geoReviews, t.notMeasured, t.geoMentionsNote];
216
+ });
217
+ if (String(a.geo.callout).includes('{{')) {
218
+ const fetchers = byId(checks, 'ai-search-access');
219
+ a.geo.callout = t.geoCallout(!fetchers || fetchers.status === 'ok');
220
+ }
221
+
222
+ const profilesCheck = byId(checks, 'trust-profiles');
223
+ const profiles = profilesCheck && profilesCheck.status === 'ok' ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
224
+ a.offpage.listed = (a.offpage.listed || []).map((x) => String(x).includes('{{') ? t.offpageListed(profiles) : x);
225
+ if (String(a.offpage.note).includes('{{')) a.offpage.note = t.offpageNote;
226
+
227
+ a.conversion.rows = (a.conversion.rows || []).map(([label, status]) => {
228
+ if (!String(status).includes('{{')) return [label, status];
229
+ if (/form|форм/i.test(label)) { const c = byId(checks, 'conv-form-fields') || byId(checks, 'conv-form-depth'); return [label, c ? c.value : t.notMeasured]; }
230
+ if (/messenger|мессендж/i.test(label)) { const c = byId(checks, 'conv-messenger'); return [label, c ? c.value : t.notMeasured]; }
231
+ if (/price|цена/i.test(label)) return [label, t.priceNo];
232
+ return [label, t.notMeasured];
233
+ });
234
+
235
+ // Дорожная карта: недостающие строки берутся из открытых проверок по их же смыслу.
236
+ const mech = [...bad, ...warn].filter((c) => MECHANICAL.has(c.id));
237
+ const textWork = [...bad, ...warn].filter((c) => !MECHANICAL.has(c.id));
238
+ // Строки, которые сборщик уже написал по-английски, в русском отчёте переводим по той же проверке.
239
+ const labelToCheck = new Map(checks.map((c) => [c.label, c]));
240
+ const englishLabel = new Map((a.checks || []).map((c, i) => [c.label, checks[i]]));
241
+ const retitle = (item) => {
242
+ const m = /^Fix:\s*(.+)$/.exec(String(item));
243
+ if (!m) return item;
244
+ const c = labelToCheck.get(m[1]) || englishLabel.get(m[1]);
245
+ return c ? actionText(c) : item;
246
+ };
247
+ a.roadmap = (a.roadmap || []).map((phase, pi) => ({
248
+ ...phase,
249
+ items: (phase.items || []).map((item, ii) => {
250
+ if (!String(item).includes('{{')) return needsRu ? retitle(item) : item;
251
+ const pool = pi === 0 ? mech : pi === 1 ? textWork : [];
252
+ const c = pool[ii];
253
+ if (c) return actionText(c);
254
+ return pi === 0 ? t.weekFill : pi === 1 ? t.monthFill : t.quarterFill;
255
+ }),
256
+ }));
257
+
258
+ if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length);
259
+ return a;
260
+ }
261
+
262
+ /** Что осталось пустым после черновика: аналитик правит только это. */
263
+ export function stillEmpty(audit) {
264
+ const out = [];
265
+ const walk = (o, path = '') => {
266
+ if (typeof o === 'string') { if (o.includes('{{')) out.push(path); return; }
267
+ if (Array.isArray(o)) { o.forEach((x, i) => walk(x, `${path}[${i}]`)); return; }
268
+ if (o && typeof o === 'object') for (const k of Object.keys(o)) walk(o[k], path ? `${path}.${k}` : k);
269
+ };
270
+ walk(audit);
271
+ return out;
272
+ }
@@ -17,6 +17,7 @@ is('цена без работы ноль долей', clean.shares, 0);
17
17
 
18
18
  const thin = buildFoundationScope(audit([page({ words: 120 })]));
19
19
  is('пустая страница это три доли', thin.pages[0].shares, 3);
20
+ is('и одна работа, а не список повторов', buildFoundationScope(audit([page({ words: 90, answerFirst: false, h2Count: 0, tables: 0 })])).pages[0].issues.map((i) => i.id), ['thin']);
20
21
  is('и помечена как «написать заново»', thin.pages[0].tier, 'rewrite');
21
22
 
22
23
  const one = buildFoundationScope(audit([page({ answerFirst: false })]));
@@ -66,7 +67,20 @@ is('цена доли по умолчанию', en.unit, 50);
66
67
  is('минимум пакета', en.minimum, 500);
67
68
  const letterEn = renderFoundationScope({ ...en, host: 'x.com', pages: en.pages.map((p) => ({ ...p, title: 'A page' })) });
68
69
  ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
69
- ok('в английском письме есть цена', letterEn.includes('500 USD'));
70
+
71
+ // Деньги в письме только по флагу: цену называет человек, а не машина
72
+ ok('по умолчанию цены в письме нет', !/USD|RUB/.test(letterEn));
73
+ ok('вместо неё объём работы', letterEn.includes('share') && letterEn.includes('name the price separately'));
74
+ const priced = buildFoundationScope(audit([page({ url: 'https://x.ru/a/', words: 100 })]), { lang: 'en', withPrice: true });
75
+ ok('с флагом цена появляется', renderFoundationScope(priced).includes('500 USD'));
76
+ ok('и в чек-листе тоже', renderFoundationChecklist(priced).includes('500 USD'));
77
+ ok('без флага чек-лист без денег', !/USD|RUB/.test(renderFoundationChecklist(en)));
78
+ is('доли считаются в обоих случаях', [en.shares > 0, priced.shares > 0], [true, true]);
79
+
80
+ // Короткие страницы бывают короткими намеренно, поэтому их просим подтвердить
81
+ const thinLetter = renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/n/', words: 90 })])));
82
+ ok('письмо просит подтвердить короткие страницы', thinLetter.includes('задуманы короткими'));
83
+ ok('без коротких страниц такой оговорки нет', !renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/a/', answerFirst: false })]))).includes('задуманы короткими'));
70
84
 
71
85
  // Чек-лист
72
86
  const list = renderFoundationChecklist(many);
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /** Черновик текста аудита: заполняет всё, ничего не выдумывает, читается человеком. */
3
+ import { draftNarrative, stillEmpty } from './narrative.mjs';
4
+
5
+ let bad = 0;
6
+ const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
7
+ const ok = (n, c) => is(n, Boolean(c), true);
8
+
9
+ const audit = (over = {}) => ({
10
+ meta: { host: 'x.ru', site: 'https://x.ru', collectedAt: '2026-09-11T00:00:00.000Z', lang: 'en', language: '' },
11
+ client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: '2026-09-11', preparedBy: 'OperStack' },
12
+ scores: { 'SEO, technical': 9, 'SEO, content and structure': 4, 'AEO, answers and snippets': 6, 'GEO, visibility in AI systems': 8, 'Off-page and trust': null, 'Conversion and UX': 5 },
13
+ summary: { lead: '{{a}}', verdict: '{{b}}', priorities: ['{{1}}', '{{2}}', '{{3}}'] },
14
+ overview: { rows: [['CMS / stack', 'Astro'], ['Pages in sitemap', '40'], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{note}}' },
15
+ content: { strengths: ['{{s}}'], weaknesses: ['{{w}}'], gaps: ['{{g}}'] },
16
+ aeo: { works: ['{{x}}'], blocks: ['{{y}}'], recommendation: '{{r}}' },
17
+ geo: { rows: [['Entity clarity', '{{s}}', '{{d}}'], ['Third-party mentions', '{{s}}', '{{d}}'], ['Reviews and PR', '{{s}}', '{{d}}']], callout: '{{c}}' },
18
+ offpage: { listed: ['{{l}}'], note: '{{n}}' },
19
+ conversion: { rows: [['Contact form', '{{s}}'], ['Price visible', '{{s}}'], ['Messengers', '{{s}}']] },
20
+ roadmap: [
21
+ { badge: 'Week 1', title: 'Critical', items: ['Fix: Image alt text', '{{...}}'] },
22
+ { badge: 'Weeks 2 to 4', title: 'Foundation', items: ['{{...}}'] },
23
+ { badge: 'Months 2 to 3', title: 'Growth', items: ['{{...}}'] },
24
+ ],
25
+ closing: '{{close}}',
26
+ sample: [{ url: 'https://x.ru/', title: 'Дом', description: 'Мы продаём станки', words: 900, answerFirst: false }],
27
+ checks: [
28
+ { id: 'alt', group: 'onpage', label: 'Image alt text', status: 'bad', value: '3 of 10 images carry alt text (30%) across 5 sampled page(s)' },
29
+ { id: 'thin', group: 'content', label: 'Thin pages in sample', status: 'warn', value: '4 of 20 pages under 300 words' },
30
+ { id: 'answer-first', group: 'aeo', label: 'Answer-first opening paragraph', status: 'bad', value: '2 of 20 sampled page(s) open with a figure' },
31
+ { id: 'sources', group: 'aeo', label: 'Sources named in the text', status: 'ok', value: '18 of 20 name a source' },
32
+ { id: 'dates', group: 'content', label: 'Publication dates exposed', status: 'ok', value: '19 of 20 expose dates' },
33
+ { id: 'conv-cta', group: 'conversion', label: 'A call to action in the first screen', status: 'bad', value: '1 of 20 put it in the first 30%' },
34
+ { id: 'conv-messenger', group: 'conversion', label: 'Messenger link', status: 'ok', value: 'telegram found' },
35
+ { id: 'trust-entity', group: 'trust', label: 'Who the business is', status: 'ok', value: 'city and country in the structured data' },
36
+ { id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'ok', value: 'every AI search fetcher may read the site' },
37
+ ],
38
+ ...over,
39
+ });
40
+
41
+ const d = draftNarrative(audit(), { lang: 'ru' });
42
+ is('не осталось ни одной дыры', stillEmpty(d), []);
43
+ ok('имя клиента это адрес сайта, а не выдумка', d.client.name === 'x.ru');
44
+ ok('предмет взят с главной и помечен как прочитанный', d.client.subject.includes('Мы продаём станки') && /подтвердите/i.test(d.client.subject));
45
+
46
+ // Ничего не выдумываем: там, где не мерили, так и написано
47
+ ok('упоминания на чужих сайтах помечены как неизмеренные', d.geo.rows[1][1] === 'не измеряли');
48
+ ok('и объяснено почему', /за деньги/.test(d.geo.rows[1][2]));
49
+ ok('в разделе ссылок сказано, что данные платные', /не покупаем данные/.test(d.offpage.note));
50
+ ok('язык не выдуман, если его нет в коде', /не указан/.test(d.overview.rows[2][1]));
51
+
52
+ // Числа сходятся между собой
53
+ const openN = 4; const mech = 2; // alt, conv-cta механические; thin и answer-first это тексты
54
+ ok('в выводе число открытых проверок верное', d.summary.verdict.includes(`${openN} вещи`) || d.summary.verdict.includes(`${openN} вещей`));
55
+ ok('и механических не больше, чем всех', d.summary.verdict.includes(`${mech} правятся`) || d.summary.verdict.includes(`${mech} правится`));
56
+
57
+ // Приоритеты это действие, а не название проверки
58
+ ok('приоритет говорит, что делать', /Проставить подписи/.test(d.summary.priorities[0]));
59
+ ok('и показывает, как сейчас', /Сейчас:/.test(d.summary.priorities[0]));
60
+ ok('работа с текстами названа работой, а не отговоркой', d.summary.priorities.some((p) => /Наполнить короткие страницы|прямой ответ/.test(p)));
61
+
62
+ // Русский отчёт по английскому аудиту не должен содержать английских названий проверок
63
+ const ruText = JSON.stringify([d.summary, d.content, d.aeo, d.roadmap, d.closing]);
64
+ ok('в русском черновике нет английских названий проверок', !/Image alt text|Thin pages|call to action/.test(ruText));
65
+ ok('области названы по-русски', /техническая часть|Конверсия/.test(d.summary.lead));
66
+
67
+ // Дорожная карта: уже написанные строки переведены, пустые заполнены
68
+ ok('готовая строка карты переведена', d.roadmap[0].items[0].includes('подписи'));
69
+ ok('пустая строка карты заполнена делом', !d.roadmap[1].items[0].includes('{{'));
70
+
71
+ // Английский отчёт остаётся английским
72
+ const e = draftNarrative(audit(), { lang: 'en' });
73
+ is('по-английски тоже без дыр', stillEmpty(e), []);
74
+ ok('английский черновик без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify([e.summary, e.aeo, e.closing])));
75
+ ok('и приоритет тоже действие', /Write alt text/.test(e.summary.priorities[0]));
76
+
77
+ // Здоровый сайт: вывод не пугает, а говорит правду
78
+ const clean = draftNarrative(audit({ checks: [
79
+ { id: 'alt', group: 'onpage', label: 'Image alt text', status: 'ok', value: 'all images carry alt text' },
80
+ { id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'ok', value: 'allowed' },
81
+ ] }), { lang: 'ru' });
82
+ ok('на чистом сайте вывод говорит, что чинить нечего', /критичных поломок нет/.test(clean.summary.verdict));
83
+ ok('и заключение не выдумывает проблем', /нечего/.test(clean.closing));
84
+
85
+ // Ничего уже написанного человеком не затирается
86
+ const edited = audit();
87
+ edited.summary.lead = 'Текст, который написал аналитик.';
88
+ const keep = draftNarrative(edited, { lang: 'ru' });
89
+ is('правку аналитика черновик не трогает', keep.summary.lead, 'Текст, который написал аналитик.');
90
+
91
+ // Заблокированные фетчеры это прямой приговор, а не намёк
92
+ const blocked = draftNarrative(audit({ checks: [{ id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'bad', value: '3 blocked' }] }), { lang: 'ru' });
93
+ ok('если роботы ИИ закрыты, об этом сказано прямо', /невозможно физически/.test(blocked.geo.callout));
94
+
95
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
96
+ console.log('\nвсе тесты черновика прошли');