@operstack/audit 0.7.2 → 0.9.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,28 @@ 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
+
61
+ ## Handing the work to an AI agent: `prompts`
62
+
63
+ **If you do not edit the site yourself.** Most people who read an audit now fix their site by telling an AI assistant what to do. This turns every finding into a task written for that assistant: copy it, paste it into Cursor or Claude Code, and it does the work. Each task says what the state is today, what to change, and how you will know it is finished, so the assistant cannot wander off and cannot quietly invent facts about your business.
64
+
65
+ ```
66
+ npx @operstack/audit prompts audit.json --lang en --limit 3
67
+ ```
68
+
69
+ Technically: every open check is rendered as a Markdown block with `Now` (the measured value), `Task` (the action and its boundaries) and `How to check` (the acceptance test), plus a standing rule against inventing business facts. Twenty checks carry text written for that specific finding; the rest fall back to the Fix action with a generic acceptance test. `--limit 3` is what the free tier hands out; the full list ships with the paid audit. `renderFixChecklist(plan, { withPrompts: true })` embeds the same tasks in the executor checklist.
70
+
49
71
  ## The Fix package: turning findings into agreed work
50
72
 
51
73
  An audit says what is wrong. `fix-plan` says what we will do about it, before anyone pays.
package/bin/audit.mjs CHANGED
@@ -14,6 +14,10 @@
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
19
+ * operstack-audit prompts audit.json [--lang ru|en] [--limit 3] [--out prompts.md]
20
+ * every finding rewritten as a task an AI agent can execute
17
21
  * operstack-audit check audit.json
18
22
  * lists narrative fields still holding placeholders and any score the checks do not support
19
23
  *
@@ -23,6 +27,8 @@
23
27
  import { collect, verifyScores } from '../src/collect.mjs';
24
28
  import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
25
29
  import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
30
+ import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
31
+ import { renderAgentPrompts, agentPrompts } from '../src/prompts.mjs';
26
32
  import { render, checkNarrative } from '../src/render.mjs';
27
33
  import { writeFileSync, readFileSync } from 'node:fs';
28
34
  import { resolve } from 'node:path';
@@ -38,6 +44,10 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
38
44
  ' collect the public signals of a site into audit.json',
39
45
  ' operstack-audit render <audit.json> [--out report.html] [--pdf]',
40
46
  ' render the report; --pdf also prints a PDF with headless Chrome',
47
+ ' operstack-audit draft <audit.json> [--lang ru|en] [--out audit.json]',
48
+ ' fill the narrative from the checks: the analyst edits a draft instead of writing one',
49
+ ' operstack-audit prompts <audit.json> [--lang ru|en] [--limit 3] [--out prompts.md]',
50
+ ' every finding rewritten as a task you can paste into Cursor or Claude Code',
41
51
  ' operstack-audit check <audit.json>',
42
52
  ' list narrative fields still holding placeholders and any score the checks do not support',
43
53
  '',
@@ -76,6 +86,26 @@ if (cmd === 'collect') {
76
86
  if (drift.length) { console.log(`${drift.length} score(s) that no check supports:`); for (const d of drift) console.log(` ${d}`); }
77
87
  if (!missing.length && !drift.length) console.log('every narrative field is written and every score matches its checks');
78
88
  else process.exit(1);
89
+ } else if (cmd === 'draft') {
90
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
91
+ const before = stillEmpty(audit).length;
92
+ const drafted = draftNarrative(audit, { lang: opt('--lang', audit.meta?.lang || 'en') });
93
+ const after = stillEmpty(drafted);
94
+ const out = resolve(opt('--out', target));
95
+ writeFileSync(out, JSON.stringify(drafted, null, 2));
96
+ console.log(`${before} field(s) were empty, ${after.length} left`);
97
+ if (after.length) for (const f of after) console.log(` still yours to write: ${f}`);
98
+ console.log(`wrote ${out}`);
99
+ console.log('read it before sending: the draft states only what was measured, and says so where nothing was.');
100
+ } else if (cmd === 'prompts') {
101
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
102
+ const lang = opt('--lang', audit.meta?.lang || 'en');
103
+ const limit = Number(opt('--limit', 0));
104
+ const list = agentPrompts(audit, { lang, limit });
105
+ const out = resolve(opt('--out', target.replace(/\.json$/, '') + '-prompts.md'));
106
+ writeFileSync(out, renderAgentPrompts(audit, { lang, limit }));
107
+ console.log(`${list.length} task(s): ${list.filter((x) => x.hasOwnText).length} written for that finding, ${list.filter((x) => !x.hasOwnText).length} generic`);
108
+ console.log(`wrote ${out}`);
79
109
  } else if (cmd === 'fix-plan') {
80
110
  const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
81
111
  // Язык покупки решает и валюту, и письмо: за доллары платят по английскому списку, за рубли по русскому.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.7.2",
3
+ "version": "0.9.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,9 @@
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",
18
+ "./prompts": "./src/prompts.mjs"
17
19
  },
18
20
  "files": [
19
21
  "bin",
@@ -24,7 +26,7 @@
24
26
  "LICENSE"
25
27
  ],
26
28
  "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",
29
+ "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 bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
28
30
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
29
31
  },
30
32
  "keywords": [
package/src/fix.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  * с английскими названиями проверок это брак, который клиент видит первым.
20
20
  */
21
21
  import { localiseChecks } from './i18n.mjs';
22
+ import { agentPrompt } from './prompts.mjs';
22
23
 
23
24
  /** Что мы делаем по каждой проверке. Без этого «закроем» это слово, а не работа. */
24
25
  export const FIX_ACTIONS = {
@@ -220,14 +221,24 @@ export function renderFixPlan(plan) {
220
221
  return L.join('\n');
221
222
  }
222
223
 
223
- /** Чек-лист исполнителю: тот же список, но по порядку работы. */
224
- export function renderFixChecklist(plan) {
224
+ /**
225
+ * Чек-лист исполнителю: тот же список, но по порядку работы.
226
+ * @param {object} plan результат buildFixPlan
227
+ * @param {{withPrompts?: boolean}} opts withPrompts добавляет задания для ИИ-агента: работа по ним
228
+ * идёт быстрее, потому что исполнитель не переписывает задачу своими словами.
229
+ */
230
+ export function renderFixChecklist(plan, opts = {}) {
225
231
  const t = dict(plan.lang);
226
232
  const L = [t.listTitle(plan.host), '', t.listHead(plan.platform, plan.price, plan.currency, plan.share), ''];
227
233
  plan.included.forEach((x, i) => {
228
234
  L.push(`- [ ] ${i + 1}. ${x.check.label} (\`${x.check.id}\`)`);
229
235
  L.push(` ${t.was}: ${x.check.value}`);
230
236
  L.push(` ${t.todo}: ${x.action}`);
237
+ if (opts.withPrompts) {
238
+ L.push('');
239
+ L.push(agentPrompt(x.check, { lang: plan.lang }).split('\n').map((line) => ` ${line}`).join('\n'));
240
+ L.push('');
241
+ }
231
242
  });
232
243
  L.push('');
233
244
  L.push(t.listTail);
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,5 @@ 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';
7
+ export { agentPrompt, agentPrompts, renderAgentPrompts } from './prompts.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
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Находка превращается в задание для ИИ-агента.
3
+ *
4
+ * Наш покупатель правит сайт не руками: он открывает Cursor или Claude Code и говорит агенту, что
5
+ * сделать. До сих пор мы отдавали ему список «что не так», а формулировку задания он придумывал
6
+ * сам. Здесь это делается за него: каждая найденная проблема выходит текстом, который вставляется
7
+ * в агента и исполняется без правок.
8
+ *
9
+ * У каждого задания три обязательные части, иначе агент делает не то:
10
+ * 1. Что сейчас: измеренное число, чтобы агент знал точку отсчёта и не чинил уже целое.
11
+ * 2. Что сделать: одно действие, границы, и прямой запрет выдумывать факты о бизнесе.
12
+ * 3. Как проверить: признак, по которому видно, что работа сделана, а не «кажется сделана».
13
+ *
14
+ * Заданий с собственным текстом столько, сколько проверок мы умеем чинить осмысленно. Для
15
+ * остальных собирается общее задание из описания работы: оно короче, но не врёт.
16
+ */
17
+ import { FIX_ACTIONS } from './fix.mjs';
18
+ import { localiseChecks } from './i18n.mjs';
19
+
20
+ const P = {
21
+ alt: {
22
+ ru: { task: 'Пройди по шаблонам и контенту сайта и проставь атрибут alt каждой картинке, у которой его нет или он пустой. Подпись описывает своими словами, что изображено, от пяти до пятнадцати слов, без перечисления ключевых слов. Если картинка чисто декоративная, поставь пустой alt="", это правильно и намеренно. Если по имени файла и окружающему тексту непонятно, что на картинке, не выдумывай: собери такие файлы в список в конце ответа и не трогай их.', verify: 'В собранном HTML не осталось картинок без атрибута alt.' },
23
+ en: { task: 'Go through the site templates and content and give every image an alt attribute where it is missing or empty. The text describes in plain words what the image shows, five to fifteen words, no keyword lists. Purely decorative images take an empty alt="", deliberately. Where the file name and the surrounding text do not tell you what the image shows, do not invent it: list those files at the end and leave them alone.', verify: 'No image in the built HTML is left without an alt attribute.' },
24
+ },
25
+ 'conv-cta': {
26
+ ru: { task: 'Поставь на первый экран каждой важной страницы одно понятное действие: кнопку или ссылку, которая говорит, что произойдёт после нажатия («Получить расчёт», «Посмотреть цены», «Написать в Telegram»). Одно действие на экран, не три. Текст кнопки бери из того, что реально происходит дальше, не выдумывай новых обещаний и сроков.', verify: 'На каждой такой странице в верхних тридцати процентах высоты есть кликабельное действие.' },
27
+ en: { task: 'Put one clear thing to do in the first screen of every page that matters: a button or link that says what happens next ("Get a quote", "See prices", "Message us"). One action per screen, not three. Take the wording from what actually happens next; do not invent new promises or timings.', verify: 'Every such page has a clickable action within the top thirty percent of its height.' },
28
+ },
29
+ 'conv-messenger': {
30
+ ru: { task: 'Добавь в подвал и на страницу контактов прямую ссылку на мессенджер, которым компания действительно пользуется. Ссылка должна открывать диалог, а не страницу профиля. Если ты не знаешь, какой мессенджер используется, не выбирай его сам: оставь место и спроси владельца.', verify: 'Ссылка открывается и ведёт в диалог, а не на общую страницу.' },
31
+ en: { task: 'Add a direct messenger link to the footer and the contact page, for a messenger the business actually uses. The link opens a conversation, not a profile page. If you do not know which messenger they use, do not choose one: leave the slot and ask the owner.', verify: 'The link opens and starts a conversation rather than a general page.' },
32
+ },
33
+ robots: {
34
+ ru: { task: 'Перепиши robots.txt: убери запреты, которые закрывают обычные страницы сайта, оставь закрытыми только служебные адреса (корзина, личный кабинет, поиск по сайту, страницы благодарности). В конце добавь строку Sitemap с полным адресом карты сайта. Ничего не закрывай «на всякий случай»: каждый запрет должен иметь причину.', verify: 'Главная и разделы сайта открыты для всех роботов, в файле есть строка Sitemap, и она отдаёт карту по указанному адресу.' },
35
+ en: { task: 'Rewrite robots.txt: drop the rules that block ordinary pages, keep blocked only the utility addresses (cart, account, site search, thank-you pages). Add a Sitemap line with the full address at the end. Block nothing "just in case": every rule needs a reason.', verify: 'The homepage and sections are open to all robots, the file has a Sitemap line, and that address serves the sitemap.' },
36
+ },
37
+ 'ai-search-access': {
38
+ ru: { task: 'Открой в robots.txt роботов, которые достают страницу, чтобы процитировать её в ответе ИИ: OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User, DuckAssistBot, Applebot. Это не те роботы, что обучают модели, это те, что приносят вас в ответ на вопрос покупателя. Обучающие краулеры (GPTBot, CCBot, ClaudeBot, Google-Extended) оставь как есть: закрывать их или нет, решает владелец.', verify: 'В robots.txt ни один из перечисленных восьми агентов не запрещён.' },
39
+ en: { task: 'In robots.txt, allow the fetchers that pull a page to quote it in an AI answer: OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User, DuckAssistBot, Applebot. These are not the training crawlers; these are the ones that bring you into an answer to a buyer\'s question. Leave the training crawlers (GPTBot, CCBot, ClaudeBot, Google-Extended) as they are: blocking them is the owner\'s call.', verify: 'None of those eight agents is disallowed in robots.txt.' },
40
+ },
41
+ llms: {
42
+ ru: { task: 'Сгенерируй файл llms.txt в корне сайта: заголовок с названием компании и одной строкой о том, чем она занимается, затем разделы по типам страниц, в каждом ссылки вида «- [Заголовок](полный адрес): одно предложение о чём страница». Бери только страницы, открытые для индексации. Не включай страницы с noindex, служебные адреса и то, что отвечает перенаправлением.', verify: 'Файл отдаётся по адресу /llms.txt, все ссылки в нём ведут на страницы этого же сайта и отвечают кодом 200.' },
43
+ en: { task: 'Generate an llms.txt at the site root: a heading with the company name and one line on what it does, then sections by page type, each holding links shaped "- [Title](full URL): one sentence on what the page is". Include only pages open to indexing. Leave out noindex pages, utility addresses, and anything that answers with a redirect.', verify: 'The file is served at /llms.txt, and every link in it points at this site and answers 200.' },
44
+ },
45
+ sitemap: {
46
+ ru: { task: 'Почини карту сайта: она должна содержать все страницы, открытые для индексации, и только их. Убери из неё служебные адреса, страницы с noindex и адреса, отвечающие перенаправлением. Проставь честную дату последнего изменения для каждой страницы, не сегодняшнюю дату для всех.', verify: 'Карта отдаётся по адресу из robots.txt, все адреса в ней отвечают кодом 200, дат «сегодня» у всех страниц нет.' },
47
+ en: { task: 'Fix the sitemap: it must list every page open to indexing and nothing else. Remove utility addresses, noindex pages and anything that answers with a redirect. Give each page an honest last-modified date, not today\'s date for all of them.', verify: 'The sitemap is served at the address in robots.txt, every URL in it answers 200, and the dates are not all today.' },
48
+ },
49
+ title: {
50
+ ru: { task: 'Перепиши заголовок страницы (тег title) так, чтобы он укладывался в 60 знаков и говорил, что на странице, словами, которыми это ищет человек. Название компании в конце только если остаётся место. Не повторяй одно и то же слово дважды и не пиши список ключевых слов через запятую.', verify: 'Длина title не больше 60 знаков, и он отличается от заголовков других страниц.' },
51
+ en: { task: 'Rewrite the page title so it fits 60 characters and says what is on the page, in the words a person would search with. Put the company name at the end only if there is room. Do not repeat a word twice and do not write a comma-separated keyword list.', verify: 'The title is 60 characters or fewer and differs from other pages\' titles.' },
52
+ },
53
+ description: {
54
+ ru: { task: 'Перепиши описание страницы (meta description) в 120-160 знаков: что человек найдёт на странице и почему ему стоит зайти. Одно предложение, без рекламных восклицаний. Убери из него любые остатки кода и шорткодов. Не обещай того, чего на странице нет.', verify: 'Описание есть, его длина от 120 до 160 знаков, и оно описывает именно эту страницу.' },
55
+ en: { task: 'Rewrite the meta description in 120 to 160 characters: what the reader will find on the page and why it is worth opening. One sentence, no exclamation marks. Strip any leftover code or shortcodes. Do not promise anything the page does not have.', verify: 'The description exists, is 120 to 160 characters, and describes this page in particular.' },
56
+ },
57
+ h1: {
58
+ ru: { task: 'Оставь на странице ровно один заголовок первого уровня (H1), он должен совпадать по смыслу с тем, что на странице. Остальные крупные заголовки переведи в H2. Не делай H1 из логотипа и из названия сайта в шапке.', verify: 'На странице ровно один H1, и он про содержание страницы, а не про название сайта.' },
59
+ en: { task: 'Leave exactly one first-level heading (H1) on the page, matching what the page is about. Demote the other large headings to H2. Do not make an H1 out of the logo or the site name in the header.', verify: 'The page has exactly one H1, and it is about the page rather than the site name.' },
60
+ },
61
+ canonical: {
62
+ ru: { task: 'Проставь на каждой странице канонический адрес (link rel="canonical") с полным адресом этой же страницы, включая протокол и домен, без параметров отслеживания. На страницах с постраничной навигацией канонический адрес указывает на саму страницу, а не на первую.', verify: 'На каждой странице есть канонический адрес, он абсолютный и указывает на неё саму.' },
63
+ en: { task: 'Give every page a canonical link with the full address of that same page, protocol and domain included, without tracking parameters. On paginated pages the canonical points at the page itself, not at page one.', verify: 'Every page has a canonical link, absolute, pointing at itself.' },
64
+ },
65
+ og: {
66
+ ru: { task: 'Добавь на страницы карточки для соцсетей и мессенджеров: og:title, og:description, og:image с картинкой не меньше 1200 на 630 точек, og:url. Заголовок и описание бери со страницы, не выдумывай новых. Проверь, что картинка отдаётся по прямой ссылке без авторизации.', verify: 'При вставке ссылки в мессенджер появляется карточка с заголовком, описанием и картинкой.' },
67
+ en: { task: 'Add social cards to the pages: og:title, og:description, og:image at 1200 by 630 or larger, og:url. Take the title and description from the page itself; do not write new ones. Check the image is served over a plain link without authentication.', verify: 'Pasting the link into a messenger shows a card with title, description and image.' },
68
+ },
69
+ schema: {
70
+ ru: { task: 'Добавь на главную страницу разметку организации (schema.org Organization) в формате JSON-LD: название, адрес сайта, логотип, город и страна, почта или телефон, и ссылки на профили компании в других местах. Заполняй только тем, что действительно есть: выдуманный адрес или несуществующий профиль хуже, чем его отсутствие.', verify: 'Разметка проходит проверку в Rich Results Test, и каждое поле в ней соответствует правде.' },
71
+ en: { task: 'Add Organization markup (schema.org, JSON-LD) to the homepage: name, site address, logo, city and country, email or phone, and links to the company\'s profiles elsewhere. Fill it only with what actually exists: an invented address or a profile that is not yours is worse than nothing.', verify: 'The markup passes the Rich Results Test and every field in it is true.' },
72
+ },
73
+ 'faq-schema': {
74
+ ru: { task: 'Найди страницы, где уже есть блок вопросов и ответов, и размечай их схемой FAQPage в JSON-LD. Текст в разметке должен слово в слово совпадать с тем, что видит человек на странице. Не добавляй в разметку вопросы, которых на странице нет.', verify: 'Разметка проходит проверку, и каждый вопрос из неё виден на самой странице.' },
75
+ en: { task: 'Find the pages that already carry a questions-and-answers block and mark them up as FAQPage in JSON-LD. The text in the markup must match word for word what a person sees on the page. Do not add questions that are not on the page.', verify: 'The markup validates and every question in it is visible on the page itself.' },
76
+ },
77
+ 'trust-entity': {
78
+ ru: { task: 'Допиши в разметку организации город и страну (PostalAddress с addressLocality и addressCountry). Это то, по чему ИИ понимает, кто вы и откуда. Бери реальный адрес компании: если публиковать его полностью не хочется, достаточно города и страны.', verify: 'В разметке организации есть город и страна, и они совпадают с действительностью.' },
79
+ en: { task: 'Add the city and country to the Organization markup (PostalAddress with addressLocality and addressCountry). This is how an AI knows who you are and where. Use the real address: if you would rather not publish it in full, the city and country are enough.', verify: 'The Organization markup carries a city and country, and they are true.' },
80
+ },
81
+ 'trust-contact': {
82
+ ru: { task: 'Опубликуй прямые контакты на странице контактов и в подвале: почта, мессенджер, форма. Те же контакты продублируй в разметке организации (contactPoint). Публикуй только те адреса, которые действительно читают: неотвечающий ящик хуже, чем его отсутствие.', verify: 'Контакты видны на странице и присутствуют в разметке, письмо на указанный адрес доходит.' },
83
+ en: { task: 'Publish direct contacts on the contact page and in the footer: email, messenger, form. Mirror the same contacts in the Organization markup (contactPoint). Publish only addresses somebody actually reads: a mailbox nobody opens is worse than none.', verify: 'The contacts are visible on the page and present in the markup, and mail to that address arrives.' },
84
+ },
85
+ dates: {
86
+ ru: { task: 'Выведи на страницах дату публикации и дату последнего изменения: видимой строкой для человека и полями datePublished и dateModified в разметке. Дата изменения должна меняться только тогда, когда текст действительно правился, а не при каждой сборке сайта.', verify: 'На странице видна дата, в разметке есть оба поля, и дата изменения не равна дате сборки.' },
87
+ en: { task: 'Expose the published and last-modified dates on the pages: as a visible line for the reader and as datePublished and dateModified in the markup. The modified date changes only when the text actually changed, not on every build.', verify: 'The page shows a date, the markup carries both fields, and the modified date is not simply the build date.' },
88
+ },
89
+ 'answer-first': {
90
+ ru: { task: 'Перепиши начало страницы так, чтобы первый абзац сразу отвечал на вопрос, ради которого страницу открыли: от двадцати до девяноста слов, и в нём должна быть конкретная цифра (цена, срок, доля, количество). Цифру бери у владельца сайта и рядом назови, откуда она. Если цифры нет, не придумывай её: спроси владельца и оставь пометку.', verify: 'Первый абзац после заголовка укладывается в 20-90 слов и содержит цифру с названным источником.' },
91
+ en: { task: 'Rewrite the opening so the first paragraph answers the question the page was opened for: twenty to ninety words, carrying a concrete figure (a price, a timing, a share, a count). Get the figure from the site owner and name its source beside it. If there is no figure, do not invent one: ask the owner and leave a marker.', verify: 'The first paragraph after the heading is 20 to 90 words and carries a figure with a named source.' },
92
+ },
93
+ sources: {
94
+ ru: { task: 'Пройди по абзацам, где есть цифры, и подпиши к каждой, откуда она: ссылкой на первоисточник или словами («по данным реестра за 2026 год»). Если источника нет ни у тебя, ни у владельца, цифру надо убрать, а не оставлять голой. Не ставь ссылку наугад: неверный источник хуже отсутствующего.', verify: 'В каждом абзаце с цифрой есть либо ссылка на чужой сайт, либо названный словами источник.' },
95
+ en: { task: 'Go through the paragraphs carrying figures and name where each came from: a link to the primary source, or words ("per the 2026 registry"). If neither you nor the owner has a source, the figure comes out rather than standing bare. Do not guess a link: a wrong source is worse than none.', verify: 'Every paragraph with a figure carries either an outbound link or a source named in words.' },
96
+ },
97
+ thin: {
98
+ ru: { task: 'Возьми страницы короче трёхсот слов и допиши их фактами, которые знает владелец: условия, цены, сроки, ограничения, частые вопросы покупателей с ответами. Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов и оставь страницу как есть.', verify: 'Страница стала длиннее трёхсот слов, и каждый добавленный абзац содержит факт, а не общие слова.' },
99
+ en: { task: 'Take the pages under three hundred words and fill them with facts the owner has: terms, prices, timings, limits, the questions buyers actually ask with the answers. Do not pad for length. Where the facts are missing, write the owner a list of questions and leave the page alone.', verify: 'The page is over three hundred words and every added paragraph carries a fact rather than filler.' },
100
+ },
101
+ };
102
+
103
+ const withLang = (v, lang) => v[lang === 'ru' ? 'ru' : 'en'];
104
+
105
+ const HEAD = {
106
+ ru: { now: 'Сейчас', task: 'Задача', verify: 'Как проверить', pages: 'Страницы, где это видно', genericVerify: 'Прогони проверку сайта заново: эта строка должна перестать быть проблемой, а остальные не должны испортиться.', rule: 'Правило: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца и оставляй пометку.' },
107
+ en: { now: 'Now', task: 'Task', verify: 'How to check', pages: 'Pages where this shows', genericVerify: 'Run the site check again: this line must stop being a finding, and nothing else may get worse.', rule: 'Rule: invent no facts about the business. Anything not on the site and not told to you, ask the owner and leave a marker.' },
108
+ };
109
+
110
+ /**
111
+ * Задание для ИИ-агента по одной находке.
112
+ * @param {object} check строка проверки из аудита
113
+ * @param {{lang?: 'ru'|'en', urls?: string[]}} opts
114
+ */
115
+ export function agentPrompt(check, opts = {}) {
116
+ const lang = opts.lang === 'ru' ? 'ru' : 'en';
117
+ const h = HEAD[lang];
118
+ const own = P[check.id] ? withLang(P[check.id], lang) : null;
119
+ const rule = FIX_ACTIONS[check.id];
120
+ const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
121
+
122
+ const lines = [`### ${check.label}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
123
+ // Проверка обязательна у каждого задания: без неё агент не знает, когда остановиться.
124
+ lines.push('', `**${h.verify}:** ${own ? own.verify : h.genericVerify}`);
125
+ if (opts.urls && opts.urls.length) { lines.push('', `**${h.pages}:** ${opts.urls.slice(0, 8).join(', ')}`); }
126
+ lines.push('', h.rule);
127
+ return lines.join('\n');
128
+ }
129
+
130
+ /** Все задания по открытым находкам аудита, в порядке важности. */
131
+ export function agentPrompts(audit, opts = {}) {
132
+ const lang = opts.lang === 'ru' ? 'ru' : 'en';
133
+ const limit = Number(opts.limit ?? 0);
134
+ // Русский файл по английскому аудиту: названия и значения проверок переводим, иначе владелец
135
+ // читает русское задание с английскими вставками и не понимает, про что оно.
136
+ const source = lang === 'ru' && audit.meta?.lang !== 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
137
+ const checks = source.filter((c) => c.status === 'bad' || c.status === 'warn');
138
+ const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
139
+ const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
140
+ return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[c.id]), text: agentPrompt(c, { lang }) }));
141
+ }
142
+
143
+ /** Файл, который отдаётся клиенту вместе с отчётом. */
144
+ export function renderAgentPrompts(audit, opts = {}) {
145
+ const lang = opts.lang === 'ru' ? 'ru' : 'en';
146
+ const host = audit.meta?.host || '';
147
+ const list = agentPrompts(audit, opts);
148
+ const intro = lang === 'ru'
149
+ ? [`# Задания для ИИ-агента: ${host}`, '',
150
+ 'Это тот же список находок, что в отчёте, но переписанный так, чтобы его можно было отдать ИИ-агенту (Cursor, Claude Code, любому другому) и он сделал работу. Копируй задание целиком, вместе со строками «Сейчас» и «Как проверить»: без них агент не поймёт, откуда начинать и чем закончить.', '',
151
+ 'Делайте по одному заданию за раз и проверяйте результат, прежде чем брать следующее. Агент, которому дали десять задач сразу, делает первую и половину остальных.', '']
152
+ : [`# Prompts for an AI agent: ${host}`, '',
153
+ 'This is the same list of findings as in the report, rewritten so you can hand it to an AI agent (Cursor, Claude Code, any other) and it does the work. Copy a task whole, including the "Now" and "How to check" lines: without them the agent does not know where it starts or when it is done.', '',
154
+ 'Do one task at a time and check the result before taking the next. An agent given ten tasks at once does the first and half of the rest.', ''];
155
+ return [intro.join('\n').trimEnd(), ...list.map((x) => x.text)].join('\n\n');
156
+ }
@@ -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все тесты черновика прошли');
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /** Задания для ИИ-агента: полнота, язык, запрет на выдумки. */
3
+ import { agentPrompt, agentPrompts, renderAgentPrompts } from './prompts.mjs';
4
+ import { FIX_ACTIONS } from './fix.mjs';
5
+
6
+ let bad = 0;
7
+ 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}`); };
8
+ const ok = (n, c) => is(n, Boolean(c), true);
9
+
10
+ const c = (id, status, label, value) => ({ id, status, label, value, group: 'technical' });
11
+ const audit = (checks, lang = 'en') => ({ meta: { host: 'x.ru', lang }, checks });
12
+
13
+ const one = agentPrompt(c('alt', 'bad', 'Подписи к картинкам', '3 из 10 картинок с подписью'), { lang: 'ru' });
14
+ ok('в задании есть точка отсчёта', one.includes('**Сейчас:** 3 из 10'));
15
+ ok('в задании есть само действие', one.includes('**Задача:**'));
16
+ ok('в задании есть признак готовности', one.includes('**Как проверить:**'));
17
+ ok('в задании есть запрет выдумывать', one.includes('не выдумывай факты о бизнесе'));
18
+
19
+ // Даже там, где своего текста нет, все три части на месте
20
+ const generic = agentPrompt(c('xmlrpc', 'warn', 'xmlrpc.php', 'открыт'), { lang: 'ru' });
21
+ ok('общее задание тоже с проверкой', generic.includes('**Как проверить:**'));
22
+ ok('и с действием из набора работ', generic.includes('Закрыть открытую точку'));
23
+
24
+ // Порядок: сначала провалы, потом предупреждения
25
+ const list = agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
26
+ is('первым идёт провал, а не предупреждение', list[0].id, 'robots');
27
+ is('заданий столько же, сколько открытых проверок', list.length, 2);
28
+ is('пройденные проверки заданий не порождают', agentPrompts(audit([c('og', 'ok', 'OG', 'есть')])).length, 0);
29
+ is('лимит режет список', agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'r', 'з')]), { limit: 1 }).length, 1);
30
+
31
+ // Язык: русский файл по английскому аудиту не должен содержать английских проверок
32
+ const ruFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images carry alt text (30%) across 5 sampled page(s)')], 'en'), { lang: 'ru' });
33
+ ok('название проверки переведено', ruFile.includes('Подписи к картинкам'));
34
+ ok('и значение тоже', !ruFile.includes('images carry alt text'));
35
+ const enFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images')], 'en'), { lang: 'en' });
36
+ ok('английский файл без кириллицы', !/[А-Яа-яЁё]/.test(enFile));
37
+
38
+ // Задания не обещают того, чего мы не знаем
39
+ const contact = agentPrompt(c('conv-messenger', 'bad', 'Мессенджер', 'нет'), { lang: 'ru' });
40
+ ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси владельца/.test(contact));
41
+ const src = agentPrompt(c('sources', 'bad', 'Источники', '2 из 20'), { lang: 'ru' });
42
+ ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник хуже/.test(src));
43
+ const af = agentPrompt(c('answer-first', 'bad', 'Ответ в начале', '2 из 20'), { lang: 'ru' });
44
+ ok('запрещает придумывать цифру', /не придумывай её|спроси владельца/.test(af));
45
+
46
+ // Покрытие: у каждой проверки, которую мы беремся чинить, задание осмысленное
47
+ const mechanical = Object.entries(FIX_ACTIONS).filter(([, r]) => r.scope === 'always' || r.scope === 'files');
48
+ const empty = mechanical.filter(([id]) => {
49
+ const p = agentPrompt(c(id, 'bad', id, 'значение'), { lang: 'ru' });
50
+ return !p.includes('**Задача:**') || p.split('**Задача:**')[1].trim().length < 20;
51
+ }).map(([id]) => id);
52
+ is('у каждой чинимой проверки есть внятное задание', empty, []);
53
+
54
+ // Шапка файла объясняет, как этим пользоваться
55
+ const head = renderAgentPrompts(audit([c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
56
+ ok('шапка объясняет, что копировать', /Копируй задание целиком/.test(head));
57
+ ok('и предупреждает делать по одному', /по одному заданию за раз/.test(head));
58
+ ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
59
+
60
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
61
+ console.log('\nвсе тесты заданий прошли');