@operstack/audit 0.5.0 → 0.6.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,29 @@ 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
+ ## The Fix package: turning findings into agreed work
50
+
51
+ An audit says what is wrong. `fix-plan` says what we will do about it, before anyone pays.
52
+
53
+ ```
54
+ npx @operstack/audit fix-plan audit.json --lang en --platform files --price 249
55
+ npx @operstack/audit fix-report audit-fix-plan.json after.json
56
+ ```
57
+
58
+ Every open check is sorted into one of three buckets by whose knowledge it needs, not by how hard it is:
59
+
60
+ | Bucket | Meaning |
61
+ |---|---|
62
+ | We close it | Standard work: markup, redirects, robots, sitemap, titles, contact paths. |
63
+ | We need your facts | Text work: an answer-first paragraph, the source behind a figure, thin pages. Foundation does this, priced separately. |
64
+ | Not promised | Out of our hands (TLS, hosting response time) or behind paid data (link profile). Never charged for. |
65
+
66
+ `fix-plan` writes three files: the plan as JSON, a letter the client agrees to before paying, and a checklist for whoever does the work. The price is divided by the number of checks in the list, so one unclosed check has a known refund. `--platform cms` moves the checks that need source access out of the list instead of promising them on a hosted site builder. When fewer than five checks are left to close, the letter says plainly that the package is poor value here and points at the alternative.
67
+
68
+ `fix-report` re-runs the comparison after the work: what closed, what did not, and the exact sum to refund. A check that vanished from the later run counts as not closed.
69
+
70
+ Both read the language from the audit: a Russian letter translates the check names itself, and an English letter over a Russian audit is refused rather than sent half-translated.
71
+
49
72
  ## Example
50
73
 
51
74
  `examples/sample-audit.json` is a complete, fictional audit of "Example Villas". `npm run sample` renders it to HTML and PDF next to the JSON.
package/bin/audit.mjs CHANGED
@@ -7,6 +7,11 @@
7
7
  * audit.json with every technical row filled and the narrative fields left for the analyst
8
8
  * operstack-audit render audit.json [--out report.html] [--pdf]
9
9
  * renders the report from the JSON; --pdf also prints a PDF with headless Chrome
10
+ * operstack-audit fix-plan audit.json [--platform files|cms] [--lang ru|en] [--price 249]
11
+ * the list of checks the Fix package closes on this site, a letter for the client and a checklist
12
+ * in the buyer's language; the price defaults to 249 USD in English and 21000 RUB in Russian
13
+ * operstack-audit fix-report audit-fix-plan.json after.json
14
+ * what was closed, what was not, and how much to refund
10
15
  * operstack-audit check audit.json
11
16
  * lists narrative fields still holding placeholders and any score the checks do not support
12
17
  *
@@ -14,6 +19,7 @@
14
19
  * and an area with no checks is printed as "not measured" rather than scored low.
15
20
  */
16
21
  import { collect, verifyScores } from '../src/collect.mjs';
22
+ import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
17
23
  import { render, checkNarrative } from '../src/render.mjs';
18
24
  import { writeFileSync, readFileSync } from 'node:fs';
19
25
  import { resolve } from 'node:path';
@@ -52,4 +58,31 @@ if (cmd === 'collect') {
52
58
  if (drift.length) { console.log(`${drift.length} score(s) that no check supports:`); for (const d of drift) console.log(` ${d}`); }
53
59
  if (!missing.length && !drift.length) console.log('every narrative field is written and every score matches its checks');
54
60
  else process.exit(1);
61
+ } else if (cmd === 'fix-plan') {
62
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
63
+ // Язык покупки решает и валюту, и письмо: за доллары платят по английскому списку, за рубли по русскому.
64
+ const lang = opt('--lang', 'ru') === 'en' ? 'en' : 'ru';
65
+ const plan = buildFixPlan(audit, {
66
+ platform: opt('--platform', 'files'),
67
+ lang,
68
+ price: Number(opt('--price', lang === 'en' ? 249 : 21000)),
69
+ currency: opt('--currency', lang === 'en' ? 'USD' : 'RUB'),
70
+ });
71
+ if (plan.langMismatch) { console.error(`this audit was collected in Russian: an English letter would carry Russian check names. Re-run: operstack-audit collect <site> --lang en`); process.exit(1); }
72
+ if (plan.unknown.length) console.error(`note: ${plan.unknown.length} check(s) have no fix action yet: ${plan.unknown.join(', ')}`);
73
+ const base = opt('--out', target.replace(/\.json$/, ''));
74
+ writeFileSync(resolve(`${base}-fix-plan.json`), JSON.stringify(plan, null, 2));
75
+ writeFileSync(resolve(`${base}-fix-letter.md`), renderFixPlan(plan));
76
+ writeFileSync(resolve(`${base}-fix-checklist.md`), renderFixChecklist(plan));
77
+ console.log(`${plan.included.length} check(s) we close, ${plan.needsClient.length} need the client's facts, ${plan.notPossible.length} not promised`);
78
+ console.log(`share per check ${plan.share} ${plan.currency}`);
79
+ console.log(`wrote ${base}-fix-plan.json, ${base}-fix-letter.md, ${base}-fix-checklist.md`);
80
+ } else if (cmd === 'fix-report') {
81
+ const plan = JSON.parse(readFileSync(resolve(target), 'utf8'));
82
+ const after = JSON.parse(readFileSync(resolve(rest[0]), 'utf8'));
83
+ const report = buildFixReport(plan, after);
84
+ const out = resolve(opt('--out', target.replace(/-fix-plan\.json$/, '') + '-fix-report.md'));
85
+ writeFileSync(out, renderFixReport(report));
86
+ console.log(`closed ${report.closed.length} of ${report.total}, still open ${report.stillOpen.length}, refund ${report.refund} ${report.currency}`);
87
+ console.log(`wrote ${out}`);
55
88
  } 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.5.0",
3
+ "version": "0.6.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",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "exports": {
12
12
  ".": "./src/index.mjs",
13
- "./i18n": "./src/i18n.mjs"
13
+ "./i18n": "./src/i18n.mjs",
14
+ "./fix": "./src/fix.mjs"
14
15
  },
15
16
  "files": [
16
17
  "bin",
@@ -21,7 +22,7 @@
21
22
  "LICENSE"
22
23
  ],
23
24
  "scripts": {
24
- "test": "node src/test-scores.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",
25
+ "test": "node src/test-scores.mjs && node src/test-fix.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",
25
26
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
26
27
  },
27
28
  "keywords": [
package/src/fix.mjs ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Пакет Fix: составление списка работ по чужому сайту и отчёт о закрытии.
3
+ *
4
+ * Страница пакета обещает две вещи: список проверок согласуется письменно до оплаты, и всё, что мы
5
+ * из него не закрыли, возвращается долей. Обещание держится только если список машинный, а не
6
+ * написанный на глаз. Здесь он и рождается: берётся собранный аудит, каждая находка раскладывается
7
+ * на «закроем сами», «нужны факты клиента» и «платформа не позволяет», и на выходе получается
8
+ * письмо клиенту и чек-лист исполнителю.
9
+ *
10
+ * Классификация не про сложность, а про то, чьи знания нужны:
11
+ * always закрываем на любой платформе, ничего не спрашивая
12
+ * files нужен доступ к исходникам сайта, на готовой CMS не выйдет
13
+ * client нужны факты и тексты клиента, это уже Foundation
14
+ * never не обещаем вообще: либо не в нашей власти, либо данные платные
15
+ *
16
+ * Язык письма задаётся покупкой, а не догадкой: кто купил за рубли, читает по-русски, кто за
17
+ * доллары, по-английски. Смешивать нельзя, человек должен понимать, за что платит. Названия и
18
+ * значения проверок приходят из аудита, поэтому язык аудита здесь тоже сверяется: русское письмо
19
+ * с английскими названиями проверок это брак, который клиент видит первым.
20
+ */
21
+ import { localiseChecks } from './i18n.mjs';
22
+
23
+ /** Что мы делаем по каждой проверке. Без этого «закроем» это слово, а не работа. */
24
+ export const FIX_ACTIONS = {
25
+ https: { scope: 'never', why: { ru: 'сертификат выпускает хостинг, это не наша работа', en: 'the certificate is issued by the host, not by us' } },
26
+ www: { scope: 'always', action: { ru: 'Поставить постоянную переадресацию с www на основной адрес и проверить кодом ответа', en: 'Add a permanent redirect from www to the main address and confirm it by response code' } },
27
+ ttfb: { scope: 'never', why: { ru: 'время ответа определяется хостингом клиента, обещать его нельзя', en: 'response time is set by your hosting, so we cannot promise it' } },
28
+ robots: { scope: 'always', action: { ru: 'Переписать robots.txt: убрать лишние запреты, добавить строку с картой сайта', en: 'Rewrite robots.txt: drop the needless blocks, add the sitemap line' } },
29
+ 'robots-block': { scope: 'always', action: { ru: 'Снять Disallow: / из блока для всех роботов', en: 'Remove Disallow: / from the all-robots block' } },
30
+ 'ai-search-access': { scope: 'always', action: { ru: 'Открыть поисковые роботы ИИ в robots.txt, это они достают страницу для цитаты', en: 'Allow the AI search fetchers in robots.txt: they are what pull a page in to quote it' } },
31
+ 'robots-ai': { scope: 'always', action: { ru: 'Зафиксировать политику по обучающим краулерам письменно и привести robots.txt в соответствие', en: 'Write down your policy on training crawlers and make robots.txt match it' } },
32
+ sitemap: { scope: 'always', action: { ru: 'Починить или сгенерировать карту сайта и сослаться на неё из robots.txt', en: 'Fix or generate the sitemap and point robots.txt at it' } },
33
+ 'sitemap-foreign': { scope: 'always', action: { ru: 'Убрать из карты сайта адреса на чужих хостах', en: 'Remove URLs on other hosts from the sitemap' } },
34
+ 'sitemap-health': { scope: 'always', action: { ru: 'Вычистить из карты сайта адреса, которые отвечают редиректом или ошибкой', en: 'Clear out sitemap URLs that answer with a redirect or an error' } },
35
+ llms: { scope: 'always', action: { ru: 'Сгенерировать llms.txt из корпуса так, чтобы ссылки вели на страницы самого сайта', en: 'Generate llms.txt from the corpus so every link points at a page of this site' } },
36
+ title: { scope: 'always', action: { ru: 'Переписать заголовок главной в пределах 60 знаков', en: 'Rewrite the home page title within 60 characters' } },
37
+ description: { scope: 'always', action: { ru: 'Переписать описание главной, убрать служебный код, уложиться в выдачу', en: 'Rewrite the home page description, strip leftover markup, fit the snippet' } },
38
+ h1: { scope: 'always', action: { ru: 'Оставить на главной ровно один заголовок H1', en: 'Leave exactly one H1 on the home page' } },
39
+ canonical: { scope: 'always', action: { ru: 'Проставить канонический адрес', en: 'Set the canonical address' } },
40
+ viewport: { scope: 'always', action: { ru: 'Задать мобильный viewport и снять запрет масштабирования', en: 'Set the mobile viewport and lift the zoom lock' } },
41
+ og: { scope: 'always', action: { ru: 'Добавить карточки для соцсетей: заголовок, описание, картинка', en: 'Add social cards: title, description, image' } },
42
+ schema: { scope: 'always', action: { ru: 'Добавить разметку организации и сайта на главную', en: 'Add Organization and WebSite markup to the home page' } },
43
+ 'faq-schema': { scope: 'always', action: { ru: 'Разметить блоки вопросов и ответов схемой FAQPage', en: 'Mark up question and answer blocks with FAQPage' } },
44
+ 'org-schema': { scope: 'always', action: { ru: 'Добавить разметку организации с адресом и профилями', en: 'Add Organization markup with the address and the profiles' } },
45
+ resources: { scope: 'files', action: { ru: 'Сократить число подключаемых файлов и включить годовой кеш для статики', en: 'Cut the number of linked files and set a year-long cache for static assets' } },
46
+ hreflang: { scope: 'always', action: { ru: 'Проставить hreflang, если у сайта несколько языков', en: 'Set hreflang where the site has more than one language' } },
47
+ xmlrpc: { scope: 'always', action: { ru: 'Закрыть открытую точку xmlrpc.php', en: 'Close the open xmlrpc.php endpoint' } },
48
+ alt: { scope: 'always', action: { ru: 'Проставить подписи ко всем картинкам по их содержимому', en: 'Write alt text for every image from what it actually shows' } },
49
+ 'h1-sample': { scope: 'always', action: { ru: 'Свести заголовки к одному H1 на страницу', en: 'Reduce headings to one H1 per page' } },
50
+ 'dup-titles': { scope: 'always', action: { ru: 'Развести повторяющиеся заголовки страниц', en: 'Separate duplicated page titles' } },
51
+ utility: { scope: 'always', action: { ru: 'Исключить служебные адреса из карты сайта, оставив их доступными людям', en: 'Drop utility URLs from the sitemap while keeping them reachable for people' } },
52
+ dates: { scope: 'always', action: { ru: 'Вывести дату публикации и обновления в разметку страниц', en: 'Expose published and updated dates in the page markup' } },
53
+ 'trust-entity': { scope: 'always', action: { ru: 'Добавить город и страну в разметку организации', en: 'Add the city and country to the Organization markup' } },
54
+ 'trust-contact': { scope: 'always', action: { ru: 'Опубликовать прямые контакты и вывести их в разметку', en: 'Publish direct contacts and expose them in the markup' } },
55
+ 'trust-profiles': { scope: 'always', action: { ru: 'Заявить в разметке профили, которые у клиента действительно есть', en: 'Declare in the markup the profiles you actually have' } },
56
+ 'trust-about': { scope: 'always', action: { ru: 'Сделать страницу о компании доступной со всех страниц', en: 'Make the about page reachable from every page' } },
57
+ 'trust-policies': { scope: 'always', action: { ru: 'Добавить политику конфиденциальности и условия и сослаться на них из подвала', en: 'Add a privacy policy and terms and link them from the footer' } },
58
+ 'conv-contact-path': { scope: 'always', action: { ru: 'Дать способ связаться с любой страницы', en: 'Give a way to get in touch from any page' } },
59
+ 'conv-cta': { scope: 'always', action: { ru: 'Поставить призыв к действию в первый экран', en: 'Put a call to action in the first screen' } },
60
+ 'conv-form-depth': { scope: 'files', action: { ru: 'Поднять форму выше по странице', en: 'Move the form higher up the page' } },
61
+ 'conv-form-fields': { scope: 'files', action: { ru: 'Сократить форму до четырёх полей', en: 'Cut the form down to four fields' } },
62
+ 'conv-messenger': { scope: 'always', action: { ru: 'Добавить ссылку на мессенджер', en: 'Add a messenger link' } },
63
+ 'answer-first': { scope: 'client', why: { ru: 'первый абзац с цифрой пишется по фактам клиента', en: 'the opening paragraph with a number is written from your facts' } },
64
+ sources: { scope: 'client', why: { ru: 'источник цифры знает только клиент', en: 'only you know where each number came from' } },
65
+ thin: { scope: 'client', why: { ru: 'наполнение тонких страниц это тексты по фактам клиента', en: 'filling thin pages means writing from your facts' } },
66
+ sections: { scope: 'client', why: { ru: 'разбивка на разделы это переписывание текста', en: 'splitting into sections means rewriting the text' } },
67
+ tables: { scope: 'client', why: { ru: 'таблица собирается из данных клиента', en: 'a table is built from your data' } },
68
+ 'links-profile': { scope: 'never', why: { ru: 'данные по ссылкам платные и ни в одну оценку не входят', en: 'link data is paid, and it moves no score of ours' } },
69
+ };
70
+
71
+ const OPEN = new Set(['warn', 'bad']);
72
+
73
+ /** Слова письма и чек-листа. Цифры одни и те же, меняется только язык. */
74
+ const T = {
75
+ ru: {
76
+ planTitle: (h) => `# Что мы закроем на сайте ${h}`,
77
+ checkedOn: (d) => `Проверено ${d}. Ниже список работ, который вы согласовываете до оплаты.`,
78
+ willClose: (n) => `## Закроем: ${n} ${plural(n, 'проверка', 'проверки', 'проверок')}`,
79
+ nowIs: 'Сейчас',
80
+ willDo: 'Что сделаем',
81
+ price: (p, c, s) => `Цена: ${p} ${c}. Доля одной проверки ${s} ${c}: если какую-то из списка мы не закроем, эта доля возвращается.`,
82
+ thin: (n, s, c) => `**Скажем прямо: закрывать у вас почти нечего.** ${n} ${plural(n, 'проверка выходит', 'проверки выходят', 'проверок выходят')} по ${s} ${c} за штуку, и это дорого. Сайт в хорошем состоянии. Либо сделайте эти правки сами по списку выше, он для этого и написан, либо посмотрите на пакет Foundation: судя по проверкам, вам нужна работа с текстами, а не с техникой.`,
83
+ nothing: '**Покупать нечего.** Ни одной проверки, которую мы закрываем без ваших фактов, на сайте не открыто. Брать с вас деньги не за что.',
84
+ needsClient: (n) => `## Не входит: нужны ваши факты (${n})`,
85
+ needsClientLead: 'Это работа с текстами, её делает пакет Foundation, и цена у него считается отдельно.',
86
+ notPossible: (n) => `## Не обещаем (${n})`,
87
+ verify: 'Каждую строку вы сможете проверить сами тем же бесплатным инструментом, которым мы её нашли.',
88
+ unlisted: 'проверка не описана в наборе работ, разберём вручную',
89
+ cmsBlocked: 'нужен доступ к исходникам сайта, на готовой системе управления не выйдет',
90
+ listTitle: (h) => `# Чек-лист Fix: ${h}`,
91
+ listHead: (p, price, c, s) => `Платформа: ${p === 'files' ? 'исходники в файлах' : 'готовая система управления'}. Оплачено ${price} ${c}, доля проверки ${s}.`,
92
+ was: 'было',
93
+ todo: 'сделать',
94
+ listTail: 'После работы: повторный прогон и `fix-report`, он скажет, что закрыто и сколько возвращать.',
95
+ reportTitle: (h) => `# Отчёт о работах: ${h}`,
96
+ closedOf: (n, t) => `Закрыто ${n} из ${t}.`,
97
+ openRefund: (n, r, c) => `Не закрыто ${n}, к возврату ${r} ${c}.`,
98
+ noRefund: 'Не закрытого нет, возвращать нечего.',
99
+ closedHead: '## Закрыто',
100
+ openHead: '## Не закрыто, возвращаем долю',
101
+ before: 'Было',
102
+ after: 'Стало',
103
+ nowLabel: 'Сейчас',
104
+ gone: 'проверка исчезла из прогона',
105
+ recheck: 'Любую строку можно перепроверить бесплатным инструментом на oper-stack.ru/ai-visibility/',
106
+ },
107
+ en: {
108
+ planTitle: (h) => `# What we will fix on ${h}`,
109
+ checkedOn: (d) => `Checked ${d}. Below is the list of work you agree to before paying.`,
110
+ willClose: (n) => `## We will close ${n} check${n === 1 ? '' : 's'}`,
111
+ nowIs: 'Now',
112
+ willDo: 'What we do',
113
+ price: (p, c, s) => `Price: ${p} ${c}. One check is worth ${s} ${c}: anything on this list we fail to close comes back to you as that share.`,
114
+ thin: (n, s, c) => `**Plainly: there is little here to fix.** ${n} check${n === 1 ? '' : 's'} at ${s} ${c} each is expensive, and your site is in good shape. Either make these edits yourself from the list above, which is written to be followed, or look at Foundation: judging by the checks, what you need is work on the text, not on the plumbing.`,
115
+ nothing: '**There is nothing to buy.** Not one check we close without your facts is open on this site. There is nothing to charge you for.',
116
+ needsClient: (n) => `## Not included: we need your facts (${n})`,
117
+ needsClientLead: 'This is work on the text. The Foundation package does it, and it is priced separately.',
118
+ notPossible: (n) => `## Not promised (${n})`,
119
+ verify: 'You can verify every line yourself with the same free tool we found it with.',
120
+ unlisted: 'this check is not in our list of standard work; we will look at it by hand',
121
+ cmsBlocked: 'this needs access to the site source; a hosted site builder will not allow it',
122
+ listTitle: (h) => `# Fix checklist: ${h}`,
123
+ listHead: (p, price, c, s) => `Platform: ${p === 'files' ? 'source files' : 'hosted site builder'}. Paid ${price} ${c}, one check is worth ${s}.`,
124
+ was: 'was',
125
+ todo: 'do',
126
+ listTail: 'When the work is done: run the audit again and `fix-report`, it says what closed and what to refund.',
127
+ reportTitle: (h) => `# Work report: ${h}`,
128
+ closedOf: (n, t) => `Closed ${n} of ${t}.`,
129
+ openRefund: (n, r, c) => `${n} not closed, ${r} ${c} to refund.`,
130
+ noRefund: 'Nothing left open, nothing to refund.',
131
+ closedHead: '## Closed',
132
+ openHead: '## Not closed, share refunded',
133
+ before: 'Was',
134
+ after: 'Now',
135
+ nowLabel: 'Now',
136
+ gone: 'the check disappeared from the run',
137
+ recheck: 'Every line can be re-checked with the free tool at oper-stack.com/ai-visibility/',
138
+ },
139
+ };
140
+
141
+ const dict = (lang) => T[lang === 'en' ? 'en' : 'ru'];
142
+ const pick = (field, lang) => (field && typeof field === 'object' ? field[lang === 'en' ? 'en' : 'ru'] : field);
143
+
144
+ /**
145
+ * Список работ по собранному аудиту.
146
+ * @param {object} audit результат collect()
147
+ * @param {{platform?: 'files'|'cms', price?: number, currency?: string, lang?: 'ru'|'en'}} opts
148
+ */
149
+ export function buildFixPlan(audit, opts = {}) {
150
+ const platform = opts.platform === 'cms' ? 'cms' : 'files';
151
+ const price = Number(opts.price ?? 249);
152
+ const lang = opts.lang === 'en' ? 'en' : 'ru';
153
+ const currency = opts.currency || (lang === 'en' ? 'USD' : 'RUB');
154
+ const t = dict(lang);
155
+
156
+ // Аудит мог быть собран на другом языке. На русский названия проверок переводятся здесь же,
157
+ // обратно на английский нет: английский текст надо собирать заново командой collect --lang en.
158
+ const auditLang = audit.meta?.lang === 'ru' ? 'ru' : 'en';
159
+ const langMismatch = lang === 'en' && auditLang === 'ru';
160
+ const checks = lang === 'ru' && auditLang !== 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
161
+
162
+ const open = checks.filter((c) => OPEN.has(c.status));
163
+ const unknown = open.filter((c) => !FIX_ACTIONS[c.id]).map((c) => c.id);
164
+
165
+ const included = [];
166
+ const needsClient = [];
167
+ const notPossible = [];
168
+ for (const c of open) {
169
+ const rule = FIX_ACTIONS[c.id];
170
+ if (!rule) { needsClient.push({ check: c, why: t.unlisted }); continue; }
171
+ if (rule.scope === 'always') included.push({ check: c, action: pick(rule.action, lang) });
172
+ else if (rule.scope === 'files') {
173
+ if (platform === 'files') included.push({ check: c, action: pick(rule.action, lang) });
174
+ else notPossible.push({ check: c, why: t.cmsBlocked });
175
+ } else if (rule.scope === 'client') needsClient.push({ check: c, why: pick(rule.why, lang) });
176
+ else notPossible.push({ check: c, why: pick(rule.why, lang) });
177
+ }
178
+
179
+ const share = included.length ? Math.round((price / included.length) * 100) / 100 : 0;
180
+ // На здоровом сайте закрывать почти нечего, и продавать ему пакет по полной цене нечестно.
181
+ // Порог не выдуман: ниже него доля одной проверки превышает треть чека.
182
+ const thin = included.length > 0 && included.length < 5;
183
+ return { platform, price, currency, lang, langMismatch, included, needsClient, notPossible, share, thin, unknown, checkedAt: audit.meta?.collectedAt, host: audit.meta?.host };
184
+ }
185
+
186
+ /** Письмо клиенту: то, что он согласовывает до оплаты. */
187
+ export function renderFixPlan(plan) {
188
+ const t = dict(plan.lang);
189
+ const L = [];
190
+ L.push(t.planTitle(plan.host));
191
+ L.push('');
192
+ L.push(t.checkedOn(String(plan.checkedAt || '').slice(0, 10)));
193
+ L.push('');
194
+ L.push(t.willClose(plan.included.length));
195
+ L.push('');
196
+ plan.included.forEach((x, i) => {
197
+ L.push(`${i + 1}. **${x.check.label}.** ${t.nowIs}: ${x.check.value}`);
198
+ L.push(` ${t.willDo}: ${x.action}`);
199
+ });
200
+ L.push('');
201
+ L.push(t.price(plan.price, plan.currency, plan.share));
202
+ if (plan.thin) { L.push(''); L.push(t.thin(plan.included.length, plan.share, plan.currency)); }
203
+ if (!plan.included.length) { L.push(''); L.push(t.nothing); }
204
+ if (plan.needsClient.length) {
205
+ L.push('');
206
+ L.push(t.needsClient(plan.needsClient.length));
207
+ L.push('');
208
+ L.push(t.needsClientLead);
209
+ L.push('');
210
+ for (const x of plan.needsClient) L.push(`- **${x.check.label}.** ${x.check.value}. ${x.why}`);
211
+ }
212
+ if (plan.notPossible.length) {
213
+ L.push('');
214
+ L.push(t.notPossible(plan.notPossible.length));
215
+ L.push('');
216
+ for (const x of plan.notPossible) L.push(`- **${x.check.label}.** ${x.why}`);
217
+ }
218
+ L.push('');
219
+ L.push(t.verify);
220
+ return L.join('\n');
221
+ }
222
+
223
+ /** Чек-лист исполнителю: тот же список, но по порядку работы. */
224
+ export function renderFixChecklist(plan) {
225
+ const t = dict(plan.lang);
226
+ const L = [t.listTitle(plan.host), '', t.listHead(plan.platform, plan.price, plan.currency, plan.share), ''];
227
+ plan.included.forEach((x, i) => {
228
+ L.push(`- [ ] ${i + 1}. ${x.check.label} (\`${x.check.id}\`)`);
229
+ L.push(` ${t.was}: ${x.check.value}`);
230
+ L.push(` ${t.todo}: ${x.action}`);
231
+ });
232
+ L.push('');
233
+ L.push(t.listTail);
234
+ return L.join('\n');
235
+ }
236
+
237
+ /**
238
+ * Отчёт о закрытии: сравнение прогонов до и после по согласованному списку.
239
+ * Возвращает, что закрыто, что осталось, и сколько денег вернуть.
240
+ */
241
+ export function buildFixReport(plan, after) {
242
+ const t = dict(plan.lang);
243
+ const now = Object.fromEntries((after.checks || []).map((c) => [c.id, c]));
244
+ const closed = [];
245
+ const stillOpen = [];
246
+ for (const x of plan.included) {
247
+ const c = now[x.check.id];
248
+ if (!c) { stillOpen.push({ ...x, now: t.gone }); continue; }
249
+ if (OPEN.has(c.status)) stillOpen.push({ ...x, now: c.value });
250
+ else closed.push({ ...x, now: c.value });
251
+ }
252
+ const refund = Math.round(stillOpen.length * plan.share * 100) / 100;
253
+ return { host: plan.host, lang: plan.lang, closed, stillOpen, refund, currency: plan.currency, total: plan.included.length };
254
+ }
255
+
256
+ export function renderFixReport(report) {
257
+ const t = dict(report.lang);
258
+ const L = [t.reportTitle(report.host), ''];
259
+ L.push(t.closedOf(report.closed.length, report.total));
260
+ if (report.stillOpen.length) L.push(t.openRefund(report.stillOpen.length, report.refund, report.currency));
261
+ else L.push(t.noRefund);
262
+ L.push('');
263
+ L.push(t.closedHead);
264
+ L.push('');
265
+ for (const x of report.closed) L.push(`- **${x.check.label}.** ${t.before}: ${x.check.value}. ${t.after}: ${x.now}`);
266
+ if (report.stillOpen.length) {
267
+ L.push('');
268
+ L.push(t.openHead);
269
+ L.push('');
270
+ for (const x of report.stillOpen) L.push(`- **${x.check.label}.** ${t.before}: ${x.check.value}. ${t.nowLabel}: ${x.now}`);
271
+ }
272
+ L.push('');
273
+ L.push(t.recheck);
274
+ return L.join('\n');
275
+ }
276
+
277
+ function plural(n, one, few, many) {
278
+ const d = n % 10; const dd = n % 100;
279
+ return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
280
+ }
package/src/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
1
  export { collect, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
2
2
  export { render, checkNarrative } from './render.mjs';
3
3
  export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
4
+ export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /** Пакет Fix: раскладка проверок, доля возврата, отчёт о закрытии, два языка. */
3
+ import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
4
+ import { readFileSync } from 'node:fs';
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 audit = (checks) => ({ meta: { host: 'x.ru', collectedAt: '2026-09-11T00:00:00.000Z' }, checks });
11
+ const c = (id, status, value = 'текст') => ({ id, status, value, label: id, group: 'technical' });
12
+
13
+ const many = audit([
14
+ c('robots', 'bad'), c('sitemap', 'warn'), c('llms', 'bad'), c('og', 'warn'), c('canonical', 'warn'),
15
+ c('trust-entity', 'warn'), c('conv-messenger', 'warn'),
16
+ c('answer-first', 'warn'), c('sources', 'bad'),
17
+ c('conv-form-depth', 'bad'),
18
+ c('links-profile', 'warn'),
19
+ c('https', 'ok'), c('viewport', 'ok'),
20
+ ]);
21
+
22
+ const plan = buildFixPlan(many, { platform: 'files', price: 21000, currency: 'RUB' });
23
+ is('закрываем восемь', plan.included.length, 8);
24
+ is('фактов клиента требуют две', plan.needsClient.length, 2);
25
+ is('не обещаем одну', plan.notPossible.length, 1);
26
+ is('пройденные проверки в список не попадают', plan.included.some((x) => x.check.id === 'https'), false);
27
+ is('доля считается от числа включённых', plan.share, Math.round((21000 / 8) * 100) / 100);
28
+ ok('список не тонкий', !plan.thin);
29
+ ok('у каждой включённой проверки есть действие', plan.included.every((x) => typeof x.action === 'string' && x.action.length > 10));
30
+ ok('платные данные по ссылкам не обещаются', plan.notPossible.some((x) => x.check.id === 'links-profile'));
31
+
32
+ const cms = buildFixPlan(many, { platform: 'cms', price: 21000, currency: 'RUB' });
33
+ ok('на готовой CMS работа с исходниками не обещается', cms.notPossible.some((x) => x.check.id === 'conv-form-depth'));
34
+ is('и в список она не попадает', cms.included.some((x) => x.check.id === 'conv-form-depth'), false);
35
+ ok('доля на CMS выше, потому что работ меньше', cms.share > plan.share);
36
+
37
+ const thin = buildFixPlan(audit([c('og', 'warn'), c('canonical', 'warn')]), { price: 21000, currency: 'RUB' });
38
+ ok('тонкий список помечен', thin.thin);
39
+ ok('письмо про тонкий список говорит прямо', renderFixPlan(thin).includes('закрывать у вас почти нечего'));
40
+
41
+ const empty = buildFixPlan(audit([c('https', 'ok')]), { price: 21000 });
42
+ is('пустой список', empty.included.length, 0);
43
+ is('доля при пустом списке ноль', empty.share, 0);
44
+ ok('письмо говорит, что покупать нечего', renderFixPlan(empty).includes('Покупать нечего'));
45
+
46
+ const letter = renderFixPlan(plan);
47
+ ok('в письме есть цена и доля', letter.includes('21000 RUB') && letter.includes(String(plan.share)));
48
+ ok('в письме названо, что не входит', letter.includes('нужны ваши факты'));
49
+ ok('в чек-листе есть все включённые', plan.included.every((x) => renderFixChecklist(plan).includes(x.check.label)));
50
+
51
+ // Язык. Покупка за доллары не должна приходить письмом по-русски. Названия и значения проверок
52
+ // приходят из сборщика уже на языке аудита, поэтому и фикстура здесь английская.
53
+ const ce = (id, status, value = 'plain text') => ({ id, status, value, label: id, group: 'technical' });
54
+ const manyEn = { meta: { host: 'x.com', collectedAt: '2026-09-11T00:00:00.000Z' }, checks: many.checks.map((x) => ce(x.id, x.status)) };
55
+ const en = buildFixPlan(manyEn, { platform: 'files', price: 249, lang: 'en' });
56
+ is('валюта по умолчанию для английского', en.currency, 'USD');
57
+ is('валюта по умолчанию для русского', buildFixPlan(many, { price: 21000 }).currency, 'RUB');
58
+ is('раскладка от языка не зависит', [en.included.length, en.needsClient.length, en.notPossible.length], [8, 2, 1]);
59
+ const letterEn = renderFixPlan(en);
60
+ ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
61
+ ok('английский чек-лист без кириллицы', !/[А-Яа-яЁё]/.test(renderFixChecklist(en)));
62
+ ok('в английском письме есть цена', letterEn.includes('249 USD'));
63
+ const thinEn = buildFixPlan({ meta: { host: 'x.com' }, checks: [ce('og', 'warn')] }, { price: 249, lang: 'en' });
64
+ ok('английское предупреждение о тонком списке', renderFixPlan(thinEn).includes('little here to fix'));
65
+ ok('единственное число по-английски', renderFixPlan(thinEn).includes('1 check at'));
66
+
67
+ const after = audit([
68
+ c('robots', 'ok', 'починено'), c('sitemap', 'ok', 'починено'), c('llms', 'ok', 'починено'),
69
+ c('og', 'ok', 'починено'), c('canonical', 'ok', 'починено'), c('trust-entity', 'ok', 'починено'),
70
+ c('conv-messenger', 'warn', 'так и не сделали'), c('conv-form-depth', 'ok', 'починено'),
71
+ ]);
72
+ const rep = buildFixReport(plan, after);
73
+ is('закрыто семь', rep.closed.length, 7);
74
+ is('осталось одно', rep.stillOpen.length, 1);
75
+ is('возврат равен доле одной проверки', rep.refund, plan.share);
76
+ is('незакрытой осталась именно та проверка', rep.stillOpen[0].check.id, 'conv-messenger');
77
+ ok('в отчёте она названа', renderFixReport(rep).includes(rep.stillOpen[0].check.label));
78
+ ok('в отчёте есть сумма возврата', renderFixReport(rep).includes(String(rep.refund)));
79
+ ok('английский отчёт без кириллицы', !/[А-Яа-яЁё]/.test(renderFixReport(buildFixReport(en, { checks: [] }))));
80
+
81
+ const repAll = buildFixReport(buildFixPlan(audit([c('robots', 'bad')]), { price: 249 }), audit([c('robots', 'ok')]));
82
+ is('всё закрыто, возврата нет', repAll.refund, 0);
83
+ ok('отчёт так и говорит', renderFixReport(repAll).includes('возвращать нечего'));
84
+
85
+ // пропавшая из прогона проверка считается незакрытой, а не закрытой молча
86
+ const gone = buildFixReport(buildFixPlan(audit([c('robots', 'bad'), c('og', 'warn')]), { price: 100 }), audit([c('robots', 'ok')]));
87
+ is('пропавшая проверка не закрыта', gone.stillOpen.length, 1);
88
+ is('и за неё возвращается доля', gone.refund, 50);
89
+
90
+ // Язык аудита. Русское письмо переводит названия проверок само, английское по русскому аудиту не собрать.
91
+ const enAudit = { meta: { host: 'x.com', lang: 'en', collectedAt: '2026-09-11T00:00:00.000Z' }, checks: [{ id: 'www', status: 'warn', label: 'www canonicalisation', value: 'www.x.com answers 200 without redirecting', group: 'technical' }] };
92
+ const ruLetter = buildFixPlan(enAudit, { price: 21000 });
93
+ ok('русское письмо по английскому аудиту переводит названия', /[А-Яа-я]/.test(ruLetter.included[0].check.label));
94
+ ok('и это не считается расхождением языков', !ruLetter.langMismatch);
95
+ const ruAudit = { meta: { host: 'x.ru', lang: 'ru', collectedAt: '2026-09-11T00:00:00.000Z' }, checks: [c('www', 'warn')] };
96
+ ok('английское письмо по русскому аудиту помечено как брак', buildFixPlan(ruAudit, { lang: 'en', price: 249 }).langMismatch);
97
+
98
+ // у каждого действия есть оба языка
99
+ const noEn = Object.entries(FIX_ACTIONS).filter(([, r]) => {
100
+ const f = r.action || r.why;
101
+ return !f || typeof f !== 'object' || !f.ru || !f.en;
102
+ }).map(([id]) => id);
103
+ is('у каждого действия есть русский и английский текст', noEn, []);
104
+
105
+ // покрытие: у каждой проверки, которую умеет собирать сборщик, есть решение
106
+ const files = process.argv.slice(2);
107
+ if (files.length) {
108
+ const ids = new Set();
109
+ for (const f of files) for (const x of JSON.parse(readFileSync(f, 'utf8')).checks) ids.add(x.id);
110
+ const missing = [...ids].filter((id) => !FIX_ACTIONS[id]);
111
+ is('для каждой собираемой проверки описано решение', missing, []);
112
+ }
113
+
114
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
115
+ console.log('\nвсе тесты пакета Fix прошли');