@operstack/audit 0.4.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
@@ -2,11 +2,16 @@
2
2
  /**
3
3
  * OperStack audit generator.
4
4
  *
5
- * operstack-audit collect https://example.com [--pages 20] [--out audit.json] [--backlinks links.json]
5
+ * operstack-audit collect https://example.com [--pages 20] [--out audit.json] [--backlinks links.json] [--lang ru]
6
6
  * fetches the public signals (robots, sitemaps, llms.txt, sampled pages) and writes
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';
@@ -23,14 +29,14 @@ const opt = (k, d) => { const i = rest.indexOf(k); return i === -1 ? d : rest[i
23
29
  const has = (k) => rest.includes(k);
24
30
 
25
31
  if (!cmd || cmd === '--help' || cmd === '-h') {
26
- console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
32
+ console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json] [--lang ru]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
27
33
  process.exit(cmd ? 0 : 2);
28
34
  }
29
35
  if (cmd === 'collect') {
30
36
  if (!/^https?:\/\//.test(target || '')) { console.error('collect needs an absolute URL'); process.exit(2); }
31
37
  const backlinksFile = opt('--backlinks', '');
32
38
  const backlinks = backlinksFile ? JSON.parse(readFileSync(resolve(backlinksFile), 'utf8')) : null;
33
- const audit = await collect(target, { pages: Number(opt('--pages', 20)), backlinks, log: (m) => console.error(` ${m}`) });
39
+ const audit = await collect(target, { pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'), log: (m) => console.error(` ${m}`) });
34
40
  const out = resolve(opt('--out', 'audit.json'));
35
41
  writeFileSync(out, JSON.stringify(audit, null, 2));
36
42
  console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
@@ -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.4.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",
@@ -9,7 +9,9 @@
9
9
  "operstack-audit": "bin/audit.mjs"
10
10
  },
11
11
  "exports": {
12
- ".": "./src/index.mjs"
12
+ ".": "./src/index.mjs",
13
+ "./i18n": "./src/i18n.mjs",
14
+ "./fix": "./src/fix.mjs"
13
15
  },
14
16
  "files": [
15
17
  "bin",
@@ -20,7 +22,7 @@
20
22
  "LICENSE"
21
23
  ],
22
24
  "scripts": {
23
- "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",
24
26
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
25
27
  },
26
28
  "keywords": [
package/src/collect.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  * No Search Console, no analytics. The narrative fields are left for the analyst.
5
5
  */
6
6
  import { parse } from 'node-html-parser';
7
+ import { localiseChecks } from './i18n.mjs';
7
8
 
8
9
  const UA = 'Mozilla/5.0 (compatible; OperStackAudit/0.1; +https://oper-stack.com)';
9
10
 
@@ -145,7 +146,7 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
145
146
  return out;
146
147
  }
147
148
 
148
- export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null } = {}) {
149
+ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en' } = {}) {
149
150
  const opts = { backlinks };
150
151
  const origin = new URL(startUrl).origin;
151
152
  const host = new URL(startUrl).host;
@@ -317,17 +318,19 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
317
318
  checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
318
319
 
319
320
  const { scores, scoreBasis } = computeScores(checks);
321
+ // Оценки считаются до перевода: язык на цифры не влияет.
322
+ const localised = localiseChecks(checks, lang);
320
323
  const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
321
324
 
322
325
  return {
323
- meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: 'External audit (no Search Console or analytics access)', language: hp ? (hp.og?.locale || '') : '' },
326
+ meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
324
327
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
325
328
  scores,
326
329
  scoreBasis,
327
330
  summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
328
331
  overview: { rows: [['CMS / stack', hp?.generator || '{{stack}}'], ['Pages in sitemap', String(sitemapPages.length)], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
329
- critical,
330
- checks,
332
+ critical: lang === 'ru' ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical,
333
+ checks: localised,
331
334
  content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
332
335
  aeo: { works: ['{{what already works}}'], blocks: ['{{what blocks answer-engine wins}}'], recommendation: '{{One recommendation with a page name.}}' },
333
336
  geo: { rows: [['Entity clarity', '{{status}}', '{{detail}}'], ['Third-party mentions', '{{status}}', '{{detail}}'], ['Reviews and PR', '{{status}}', '{{detail}}']], callout: '{{Brand disambiguation note or the single biggest GEO risk.}}' },
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/i18n.mjs ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Перевод собранных проверок на русский.
3
+ *
4
+ * Отдельный слой поверх сборщика: логика проверок не трогается вообще, поэтому перевод не может
5
+ * сломать ни одну оценку. Каждая запись знает идентификатор проверки и умеет пересобрать её текст
6
+ * по числам, которые уже посчитаны. Если для проверки записи нет, она остаётся как есть, и тест
7
+ * это показывает, а не прячет.
8
+ *
9
+ * import { localiseChecks } from '@operstack/audit/i18n'
10
+ * audit.checks = localiseChecks(audit.checks, 'ru')
11
+ */
12
+
13
+ /** Достаёт все целые и дробные числа из строки по порядку. */
14
+ const nums = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => x.replace(',', '.'));
15
+ /** Склонение: 1 страница, 2 страницы, 5 страниц. */
16
+ const plural = (n, one, few, many) => {
17
+ const d = Math.abs(n) % 10; const dd = Math.abs(n) % 100;
18
+ return `${n} ${d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many}`;
19
+ };
20
+ const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
21
+ const pagesOf = (a, b) => `${a} из ${pages(b)}`;
22
+
23
+ /** Перевод «сколько проверок пройдено» из подписи под оценкой. */
24
+ export function localiseBasisNote(note) {
25
+ const m = String(note || '').match(/^(\d+) of (\d+) checks pass(?:, (\d+) needs? attention)?(?:, (\d+) fails?)?$/);
26
+ if (!m) {
27
+ if (/^not measured/.test(String(note || ''))) return String(note).replace(/^not measured:\s*/, 'не измерялось: ');
28
+ return note;
29
+ }
30
+ const [, ok, total, warn, bad] = m;
31
+ const parts = [`${ok} из ${plural(Number(total), 'проверки', 'проверок', 'проверок')} пройдено`];
32
+ if (warn) parts.push(plural(Number(warn), 'спорная', 'спорные', 'спорных'));
33
+ if (bad) parts.push(plural(Number(bad), 'провалена', 'провалены', 'провалено'));
34
+ return parts.join(', ');
35
+ }
36
+
37
+ /**
38
+ * Записи перевода. value и comment получают исходный английский текст и возвращают русский,
39
+ * пересобирая его из тех же чисел. Там, где текст постоянный, возвращается константа.
40
+ */
41
+ const RU = {
42
+ https: { label: 'HTTPS', value: (v) => (/certificate active/.test(v) ? 'сертификат активен' : 'сайт отдаётся по http, без шифрования') },
43
+ www: {
44
+ label: 'Склейка адреса с www',
45
+ value: (v) => (/redirects to/.test(v) ? 'адрес с www перенаправляется на основной'
46
+ : /does not resolve/.test(v) ? 'адрес с www не существует'
47
+ : `адрес с www отвечает ${nums(v)[0] || ''} и не перенаправляет`),
48
+ },
49
+ ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
50
+ robots: {
51
+ label: 'robots.txt',
52
+ value: (v) => { const n = nums(v); return /HTTP/.test(v) ? `не отдаётся, код ${n[0]}` : `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')} в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов, ${plural(Number(n[2]), 'строка', 'строки', 'строк')} с картой сайта`; },
53
+ comment: (c) => (/no Sitemap/.test(c) ? 'нет строки Sitemap' : ''),
54
+ },
55
+ 'robots-block': { label: 'robots.txt закрывает весь сайт', value: () => 'Disallow: / под User-agent: *', comment: () => 'поисковым системам сказано не обходить ничего' },
56
+ 'ai-search-access': {
57
+ label: 'Доступ поисковых роботов ИИ',
58
+ value: (v) => (/every AI search fetcher/.test(v) ? 'все поисковые роботы ИИ могут читать сайт' : `закрыто: ${v.replace(/^\d+ blocked: /, '')}`),
59
+ comment: (c) => (c ? 'именно эти роботы достают страницу, чтобы процитировать её в ответе' : ''),
60
+ },
61
+ 'robots-ai': {
62
+ label: 'Обучающие краулеры закрыты (решение владельца)',
63
+ value: (v) => `${plural(Number(nums(v)[0]), 'агент закрыт', 'агента закрыты', 'агентов закрыто')} полностью: ${v.split(': ').slice(1).join(': ')}`,
64
+ comment: () => 'это выбор владельца, а не дефект: эти системы не будут учиться на сайте и не процитируют его из собственного обхода',
65
+ },
66
+ sitemap: {
67
+ label: 'Карта сайта XML',
68
+ value: (v) => (/URL\(s\) in/.test(v) ? `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} в карте сайта` : /not found|missing/i.test(v) ? 'не найдена' : `не отдаётся, код ${nums(v)[0]}`),
69
+ },
70
+ 'sitemap-foreign': { label: 'В карте сайта чужие хосты', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} на других хостах` },
71
+ 'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} проверенных адресов отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
72
+ llms: {
73
+ label: 'llms.txt',
74
+ value: (v) => (/not present/.test(v) ? 'нет'
75
+ : /HTML page/.test(v) ? 'отдаёт HTML-страницу вместо текстового индекса'
76
+ : `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
77
+ comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
78
+ },
79
+ title: { label: 'Заголовок главной', value: (v) => { const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; } },
80
+ description: { label: 'Описание главной', value: (v) => (/missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`) },
81
+ h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1` },
82
+ canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
83
+ viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
84
+ og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v) },
85
+ schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
86
+ 'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`) },
87
+ 'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
88
+ resources: { label: 'Вес главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
89
+ hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
90
+ cms: { label: 'Движок сайта', value: (v) => v },
91
+ 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])} выборки`; } },
93
+ 'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному H1 на страницу' : `${nums(v)[0]} из ${pages(nums(v)[1])} имеют больше одного H1`) },
94
+ 'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
95
+ thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${pages(nums(v)[1])} короче 300 слов`) },
96
+ utility: { label: 'Служебные страницы в карте сайта', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`, comment: () => 'корзина, избранное, теги и страницы авторов засоряют индекс' },
97
+ 'answer-first': {
98
+ label: 'Ответ в первом абзаце',
99
+ value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки начинаются с 20-90 слов, где есть цифра, сразу после H1`; },
100
+ comment: (c) => (c ? 'именно этот абзац ответная система забирает в цитату' : ''),
101
+ },
102
+ sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки имеют три и более подзаголовка H2`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
103
+ tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
104
+ sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
105
+ 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]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
107
+ 'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
108
+ 'trust-contact': {
109
+ label: 'Прямые контакты',
110
+ value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
111
+ : v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/ and /g, ' и ')),
112
+ },
113
+ 'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'самый дешёвый внешний сигнал, и он бесплатный' : '') },
114
+ 'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
115
+ '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
+ 'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
117
+ '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 ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
119
+ 'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
120
+ 'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
121
+ };
122
+
123
+ /** Проверки, для которых перевода нет. Пусто означает, что покрыто всё. */
124
+ export function untranslated(checks) {
125
+ return [...new Set((checks || []).map((c) => c.id).filter((id) => !RU[id]))];
126
+ }
127
+
128
+ /**
129
+ * Возвращает копию списка проверок с русскими названиями и текстами.
130
+ * Проверка без записи возвращается как есть: молча английский текст не прячем.
131
+ */
132
+ export function localiseChecks(checks, lang = 'ru') {
133
+ if (lang !== 'ru') return checks;
134
+ return (checks || []).map((c) => {
135
+ const r = RU[c.id];
136
+ if (!r) return c;
137
+ const out = { ...c, label: r.label || c.label };
138
+ try { if (r.value) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
139
+ try { if (r.comment) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
140
+ return out;
141
+ });
142
+ }
143
+
144
+ /** Русские названия шести областей. */
145
+ export const AREAS_RU = {
146
+ 'SEO, technical': 'SEO, техническая часть',
147
+ 'SEO, content and structure': 'SEO, контент и структура',
148
+ 'AEO, answers and snippets': 'AEO, ответы и сниппеты',
149
+ 'GEO, visibility in AI systems': 'GEO, видимость в системах ИИ',
150
+ 'Off-page and trust': 'Ссылки и доверие',
151
+ 'Conversion and UX': 'Конверсия и удобство',
152
+ };
package/src/index.mjs CHANGED
@@ -1,2 +1,4 @@
1
- export { collect } from './collect.mjs';
1
+ export { collect, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
2
2
  export { render, checkNarrative } from './render.mjs';
3
+ export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
4
+ export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
package/src/render.mjs CHANGED
@@ -8,6 +8,7 @@ import { spawnSync } from 'node:child_process';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { computeScores } from './collect.mjs';
11
+ import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
11
12
 
12
13
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
13
14
  const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
@@ -55,21 +56,8 @@ const LABELS = {
55
56
  secRoadmap: 'План работ', roadmapEyebrow: '10 · План', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
56
57
  runningHead: 'Аудит SEO, AEO и GEO',
57
58
  statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
58
- areas: {
59
- 'SEO, technical': 'SEO, техническая часть',
60
- 'SEO, content and structure': 'SEO, контент и структура',
61
- 'AEO, answers and snippets': 'AEO, ответы и сниппеты',
62
- 'GEO, visibility in AI systems': 'GEO, видимость в системах ИИ',
63
- 'Off-page and trust': 'Ссылки и доверие',
64
- 'Conversion and UX': 'Конверсия и удобство',
65
- },
66
- basisNote: (b) => {
67
- const w = (n, one, few, many) => { const d = n % 10, dd = n % 100; return n + ' ' + (d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many); };
68
- const parts = [`${b.ok} из ${w(b.counted, 'проверки', 'проверок', 'проверок')} пройдено`];
69
- if (b.warn) parts.push(`${w(b.warn, 'спорная', 'спорные', 'спорных')}`);
70
- if (b.bad) parts.push(`${w(b.bad, 'провалена', 'провалены', 'провалено')}`);
71
- return parts.join(', ');
72
- },
59
+ areas: AREAS_RU,
60
+ basisNote: (b) => localiseBasisNote(`${b.ok} of ${b.counted} checks pass${b.warn ? `, ${b.warn} ${b.warn === 1 ? 'needs' : 'need'} attention` : ''}${b.bad ? `, ${b.bad} ${b.bad === 1 ? 'fails' : 'fail'}` : ''}`),
73
61
  notMeasuredReason: { 'Off-page and trust': 'данные по ссылкам и упоминаниям не собирались', 'Conversion and UX': 'сигналы конверсии не собирались' },
74
62
  },
75
63
  };
@@ -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 прошли');
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /** Перевод проверок: покрытие, отсутствие английских хвостов, целость чисел. */
3
+ import { readFileSync } from 'node:fs';
4
+ import { localiseChecks, untranslated, localiseBasisNote } from './i18n.mjs';
5
+
6
+ let bad = 0;
7
+ const ok = (n, c) => { if (!c) { bad++; console.error(`FAIL ${n}`); } else console.log(`ok ${n}`); };
8
+ const is = (n, a, b) => { const g = JSON.stringify(a) === JSON.stringify(b); if (!g) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
9
+
10
+ const files = process.argv.slice(2);
11
+ if (!files.length) { console.error('нужен хотя бы один audit.json для проверки покрытия'); process.exit(2); }
12
+ const all = [];
13
+ for (const f of files) all.push(...JSON.parse(readFileSync(f, 'utf8')).checks);
14
+
15
+ is('перевод есть для всех собранных проверок', untranslated(all), []);
16
+
17
+ const ru = localiseChecks(all, 'ru');
18
+ is('количество проверок не изменилось', ru.length, all.length);
19
+ ok('статусы не тронуты', ru.every((c, i) => c.status === all[i].status));
20
+ ok('идентификаторы не тронуты', ru.every((c, i) => c.id === all[i].id));
21
+
22
+ // в русском тексте не должно остаться английских слов, кроме технических имён
23
+ const TECH = /https?|robots\.txt|llms\.txt|sitemap|xmlrpc|HTTP|HTTPS|H1|H2|og:|twitter:|JSON-LD|FAQPage|Organization|WebSite|Article|LocalBusiness|viewport|width|initial-scale|device-width|user-scalable|maximum-scale|Disallow|User-agent|gptbot|google-extended|ccbot|anthropic-ai|claudebot|applebot-extended|facebookbot|meta-externalagent|bytespider|oai-searchbot|chatgpt-user|perplexitybot|claude-searchbot|duckassistbot|applebot|sameAs|com|ru|org|io|me|net|dev|github|npmjs|apify|producthunt|wikidata|moregroup|estate|Content|Type|Cache|Control|OperStack|SEO|AEO|GEO|AI|XML|CMS|PDF|URL/gi;
24
+ const leftovers = [];
25
+ for (const c of ru) {
26
+ for (const field of ['label', 'value', 'comment']) {
27
+ const t = String(c[field] || '')
28
+ .replace(/\/[A-Za-z0-9_\-/.]+\/?/g, ' ') // пути страниц клиента
29
+ .replace(/\b[a-z-]+:[a-z_:]+/gi, ' ') // og:title, twitter:summary_large_image
30
+ .replace(/\bhreflang\b/gi, ' ') // имя атрибута
31
+ .replace(TECH, '').replace(/[^A-Za-z]/g, ' ');
32
+ const words = t.split(/\s+/).filter((w) => w.length > 3);
33
+ if (words.length) leftovers.push(`${c.id}.${field}: ${words.join(' ')}`);
34
+ }
35
+ }
36
+ is('английских слов в русском тексте не осталось', leftovers, []);
37
+
38
+ // числа не потерялись
39
+ const digits = (s) => (String(s).match(/\d+/g) || []).join(',');
40
+ const lostNumbers = ru.filter((c, i) => {
41
+ const before = digits(all[i].value);
42
+ const after = digits(c.value);
43
+ return before && after && before.split(',').some((n) => !after.split(',').includes(n));
44
+ }).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
45
+ is('числа в тексте сохранились', lostNumbers, []);
46
+
47
+ is('подпись под оценкой переведена', localiseBasisNote('10 of 11 checks pass, 1 needs attention'), '10 из 11 проверок пройдено, 1 спорная');
48
+ is('склонение на пяти', localiseBasisNote('3 of 5 checks pass, 2 need attention'), '3 из 5 проверок пройдено, 2 спорные');
49
+ is('провал тоже склоняется', localiseBasisNote('1 of 3 checks pass, 1 needs attention, 1 fails'), '1 из 3 проверок пройдено, 1 спорная, 1 провалена');
50
+ is('неизмеренное переводится', localiseBasisNote('not measured: no link data was collected'), 'не измерялось: no link data was collected');
51
+
52
+ is('английский язык ничего не меняет', localiseChecks(all, 'en'), all);
53
+
54
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
55
+ console.log(`\nперевод проверен на ${all.length} проверках, всё чисто`);