@operstack/audit 0.5.0 → 0.7.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,41 @@ 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
+
72
+ ## The Foundation package: pricing the writing
73
+
74
+ Fix closes what needs no knowledge of the business. Everything else is text, and text is priced per page.
75
+
76
+ ```
77
+ npx @operstack/audit foundation-scope audit.json --lang en --new-pages 3
78
+ ```
79
+
80
+ Each sampled page is read for five things: does it open with an answer carrying a figure, are the figures sourced, is it broken into sections, does it carry a table worth quoting, and is there enough of it at all. A page needing one edit is one share of work, two or three defects is two, four or more (or an empty page) is three, and a page that does not exist yet is three. Shares times the unit price, lifted to the package minimum.
81
+
82
+ Utility pages are excluded and counted separately: nobody rewrites a privacy policy to open with a figure. Sources are asked for only where the page actually carries figures. Without Search Console access the scope is built on a sitemap sample, and the letter says so rather than implying we picked the pages that matter.
83
+
49
84
  ## Example
50
85
 
51
86
  `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,13 @@
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
15
+ * operstack-audit foundation-scope audit.json [--lang ru|en] [--new-pages 3] [--gsc]
16
+ * the text work the Foundation package would do, page by page, with the price it adds up to
10
17
  * operstack-audit check audit.json
11
18
  * lists narrative fields still holding placeholders and any score the checks do not support
12
19
  *
@@ -14,6 +21,8 @@
14
21
  * and an area with no checks is printed as "not measured" rather than scored low.
15
22
  */
16
23
  import { collect, verifyScores } from '../src/collect.mjs';
24
+ import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
25
+ import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
17
26
  import { render, checkNarrative } from '../src/render.mjs';
18
27
  import { writeFileSync, readFileSync } from 'node:fs';
19
28
  import { resolve } from 'node:path';
@@ -52,4 +61,53 @@ if (cmd === 'collect') {
52
61
  if (drift.length) { console.log(`${drift.length} score(s) that no check supports:`); for (const d of drift) console.log(` ${d}`); }
53
62
  if (!missing.length && !drift.length) console.log('every narrative field is written and every score matches its checks');
54
63
  else process.exit(1);
64
+ } else if (cmd === 'fix-plan') {
65
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
66
+ // Язык покупки решает и валюту, и письмо: за доллары платят по английскому списку, за рубли по русскому.
67
+ const lang = opt('--lang', 'ru') === 'en' ? 'en' : 'ru';
68
+ const plan = buildFixPlan(audit, {
69
+ platform: opt('--platform', 'files'),
70
+ lang,
71
+ price: Number(opt('--price', lang === 'en' ? 249 : 21000)),
72
+ currency: opt('--currency', lang === 'en' ? 'USD' : 'RUB'),
73
+ });
74
+ 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); }
75
+ if (plan.unknown.length) console.error(`note: ${plan.unknown.length} check(s) have no fix action yet: ${plan.unknown.join(', ')}`);
76
+ const base = opt('--out', target.replace(/\.json$/, ''));
77
+ writeFileSync(resolve(`${base}-fix-plan.json`), JSON.stringify(plan, null, 2));
78
+ writeFileSync(resolve(`${base}-fix-letter.md`), renderFixPlan(plan));
79
+ writeFileSync(resolve(`${base}-fix-checklist.md`), renderFixChecklist(plan));
80
+ console.log(`${plan.included.length} check(s) we close, ${plan.needsClient.length} need the client's facts, ${plan.notPossible.length} not promised`);
81
+ console.log(`share per check ${plan.share} ${plan.currency}`);
82
+ console.log(`wrote ${base}-fix-plan.json, ${base}-fix-letter.md, ${base}-fix-checklist.md`);
83
+ } else if (cmd === 'fix-report') {
84
+ const plan = JSON.parse(readFileSync(resolve(target), 'utf8'));
85
+ const after = JSON.parse(readFileSync(resolve(rest[0]), 'utf8'));
86
+ const report = buildFixReport(plan, after);
87
+ const out = resolve(opt('--out', target.replace(/-fix-plan\.json$/, '') + '-fix-report.md'));
88
+ writeFileSync(out, renderFixReport(report));
89
+ console.log(`closed ${report.closed.length} of ${report.total}, still open ${report.stillOpen.length}, refund ${report.refund} ${report.currency}`);
90
+ console.log(`wrote ${out}`);
91
+ } else if (cmd === 'foundation-scope') {
92
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
93
+ const lang = opt('--lang', 'ru') === 'en' ? 'en' : 'ru';
94
+ if (!(audit.sample || []).some((p) => p.answerFirst !== undefined)) {
95
+ console.error('this audit was collected before the text fields were kept in the sample: re-run collect to scope Foundation');
96
+ process.exit(1);
97
+ }
98
+ const scope = buildFoundationScope(audit, {
99
+ lang,
100
+ newPages: Number(opt('--new-pages', 0)),
101
+ hasSearchConsole: has("--gsc"),
102
+ unit: opt('--unit') ? Number(opt('--unit')) : undefined,
103
+ minimum: opt('--minimum') ? Number(opt('--minimum')) : undefined,
104
+ currency: opt('--currency'),
105
+ });
106
+ const base = opt('--out', target.replace(/\.json$/, ''));
107
+ writeFileSync(resolve(`${base}-foundation-scope.json`), JSON.stringify(scope, null, 2));
108
+ writeFileSync(resolve(`${base}-foundation-letter.md`), renderFoundationScope(scope));
109
+ writeFileSync(resolve(`${base}-foundation-checklist.md`), renderFoundationChecklist(scope));
110
+ console.log(`${scope.pages.length} page(s) need text work of ${scope.sampledPages} sampled, ${scope.shares} share(s)`);
111
+ console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
112
+ console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
55
113
  } 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.7.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,9 @@
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",
15
+ "./foundation": "./src/foundation.mjs"
14
16
  },
15
17
  "files": [
16
18
  "bin",
@@ -21,7 +23,7 @@
21
23
  "LICENSE"
22
24
  ],
23
25
  "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",
26
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
25
27
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
26
28
  },
27
29
  "keywords": [
package/src/collect.mjs CHANGED
@@ -123,13 +123,16 @@ function analysePage(url, html) {
123
123
  // somebody else's site sitting in the same paragraph as the figure it backs.
124
124
  const sourcePhrases = (text.match(/\b(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank)\b/gi) || []).length;
125
125
  const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
126
+ // Абзацы, в которых вообще есть цифра. Без этого счётчика «цифры без источника» вменяется
127
+ // странице, где цифр нет ни одной, например политике конфиденциальности.
128
+ const figureParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => /\d/.test(plain(para))).length;
126
129
  const citedParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => {
127
130
  if (!/\d/.test(plain(para))) return false;
128
131
  return [...para.matchAll(/<a[^>]+href=["'](https?:\/\/[^"']+)["']/gi)].some((m) => {
129
132
  try { return new URL(m[1]).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
130
133
  });
131
134
  }).length;
132
- return { url, title, citedParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
135
+ return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
133
136
  }
134
137
 
135
138
  async function readSitemap(url, seen = new Set(), depth = 0) {
@@ -347,7 +350,9 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
347
350
  { badge: 'Months 2 to 3', title: 'Organic growth, AEO and GEO', items: ['{{content clusters, schema, PR, reviews}}'] },
348
351
  ],
349
352
  closing: '{{Key message for the client in three sentences.}}',
350
- sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, words: p.words, h1Count: p.h1Count, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes } : p),
353
+ // В выборке остаётся и то, из чего считается работа с текстами: без этих полей пакет
354
+ // Foundation пришлось бы оценивать на глаз, как раньше оценивался Fix.
355
+ sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, words: p.words, h1Count: p.h1Count, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates } : p),
351
356
  notes,
352
357
  };
353
358
  }
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
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Пакет Foundation: работа с текстами, посчитанная по страницам, а не на глаз.
3
+ *
4
+ * Fix закрывает то, для чего не нужно знать бизнес клиента. Всё остальное, начала страниц,
5
+ * источники цифр, разделы, таблицы и недостающие страницы, это Foundation. Цена такой работы
6
+ * зависит от того, сколько страниц придётся тронуть и насколько глубоко, поэтому считать её
7
+ * надо по выборке страниц, а не называть числом из головы.
8
+ *
9
+ * Единица счёта здесь одна: доля работы над одной страницей. Страница, которую надо написать
10
+ * заново, стоит три доли, переработка двух и более дефектов две, одна правка одну. Итог это
11
+ * сумма долей, умноженная на цену доли, но не ниже минимума пакета.
12
+ *
13
+ * Чего этот расчёт не знает: какие страницы приносят показы. Это видно только в Search Console,
14
+ * доступ к которой даёт клиент, и это бесплатно. Без доступа мы считаем по выборке и говорим
15
+ * об этом прямо, а не делаем вид, что выбрали важные страницы.
16
+ */
17
+
18
+ /**
19
+ * Служебные страницы. Политику конфиденциальности не переписывают под ответ с цифрой, и таблица
20
+ * в контактах никому не нужна. Счёт за такую работу это счёт за работу, которой не будет.
21
+ */
22
+ // Слоги в двух алфавитах: русские сайты пишут те же страницы своими словами.
23
+ const UTILITY = /\/(privacy|privacy-policy|terms|terms-of-use|terms-and-conditions|cookie|cookies|refund|refunds|legal|disclaimer|imprint|contact|contacts|sitemap|search|404|thanks|thank-you|cart|checkout|wishlist|compare|my-account|tag|tags|author|category|feed|politika-konfidencialnosti|politika|konfidencialnost|usloviya|oferta|publichnaya-oferta|dogovor|vozvrat|vozvraty|kontakty|kontakt|spasibo|karta-sajta|poisk|pravila)(\/|$)/i;
24
+
25
+ export const isUtilityPage = (url) => { try { return UTILITY.test(new URL(url).pathname); } catch { return false; } };
26
+
27
+ /** Что не так с текстом страницы. Каждая строка это работа, а не наблюдение. */
28
+ const PAGE_ISSUES = [
29
+ {
30
+ id: 'thin',
31
+ test: (p) => p.words < 300,
32
+ ru: { what: 'страница пустая', action: 'написать заново по вашим фактам' },
33
+ en: { what: 'the page is empty', action: 'write it from your facts' },
34
+ heavy: true,
35
+ },
36
+ {
37
+ id: 'answer-first',
38
+ test: (p) => p.answerFirst === false,
39
+ ru: { what: 'нет ответа в первом абзаце', action: 'переписать начало так, чтобы в первых 20 - 90 словах стоял ответ с цифрой' },
40
+ en: { what: 'no answer in the opening paragraph', action: 'rewrite the opening so the first 20 to 90 words carry an answer with a figure' },
41
+ },
42
+ {
43
+ id: 'sources',
44
+ // Спрашиваем источники только там, где цифры вообще есть.
45
+ test: (p) => (p.figureParagraphs || 0) > 0 && (p.sourcePhrases || 0) === 0 && (p.citedParagraphs || 0) === 0 && p.words >= 300,
46
+ ru: { what: 'цифры без источников', action: 'подписать к каждой цифре, откуда она, словами или ссылкой рядом' },
47
+ en: { what: 'figures with no sources', action: 'name where each figure comes from, in words or as a link beside it' },
48
+ },
49
+ {
50
+ id: 'sections',
51
+ test: (p) => (p.h2Count || 0) < 3 && p.words >= 300,
52
+ ru: { what: 'нет разделов', action: 'разбить на разделы с заголовками, которые повторяют вопросы читателя' },
53
+ en: { what: 'no sections', action: 'break it into sections whose headings repeat the reader\'s questions' },
54
+ },
55
+ {
56
+ id: 'tables',
57
+ test: (p) => (p.tables || 0) === 0 && p.words >= 600,
58
+ ru: { what: 'нечего процитировать таблицей', action: 'собрать таблицу из ваших данных: её цитируют охотнее абзаца' },
59
+ en: { what: 'nothing a table could quote', action: 'build a table from your data: tables get quoted more readily than prose' },
60
+ },
61
+ ];
62
+
63
+ /**
64
+ * Доли работы. Пустую страницу пишем заново, это всегда три доли. Дальше по числу дефектов:
65
+ * одна правка одна доля, две или три две, четыре и больше это по сути та же работа заново.
66
+ */
67
+ const SHARES = { rewrite: 3, rework: 2, touch: 1 };
68
+ function tierOf(issues) {
69
+ if (issues.some((i) => i.heavy)) return 'rewrite';
70
+ if (issues.length >= 4) return 'rewrite';
71
+ return issues.length > 1 ? 'rework' : 'touch';
72
+ }
73
+
74
+ const pick = (o, lang) => o[lang === 'en' ? 'en' : 'ru'];
75
+
76
+ /**
77
+ * Смета по собранному аудиту.
78
+ * @param {object} audit результат collect()
79
+ * @param {{lang?: 'ru'|'en', unit?: number, minimum?: number, currency?: string, newPages?: number, hasSearchConsole?: boolean}} opts
80
+ */
81
+ export function buildFoundationScope(audit, opts = {}) {
82
+ const lang = opts.lang === 'en' ? 'en' : 'ru';
83
+ const currency = opts.currency || (lang === 'en' ? 'USD' : 'RUB');
84
+ const unit = Number(opts.unit ?? (lang === 'en' ? 50 : 4200));
85
+ const minimum = Number(opts.minimum ?? (lang === 'en' ? 500 : 42000));
86
+ const newPages = Math.max(0, Number(opts.newPages ?? 0));
87
+
88
+ const sampled = (audit.sample || []).filter((p) => p.title !== undefined && typeof p.words === 'number');
89
+ const utility = sampled.filter((p) => isUtilityPage(p.url));
90
+ const content = sampled.filter((p) => !isUtilityPage(p.url));
91
+ const pages = [];
92
+ for (const p of content) {
93
+ const issues = PAGE_ISSUES.filter((i) => i.test(p));
94
+ if (!issues.length) continue;
95
+ const tier = tierOf(issues);
96
+ pages.push({
97
+ url: p.url,
98
+ title: p.title,
99
+ words: p.words,
100
+ tier,
101
+ shares: SHARES[tier],
102
+ issues: issues.map((i) => ({ id: i.id, what: pick(i, lang).what, action: pick(i, lang).action })),
103
+ });
104
+ }
105
+ pages.sort((a, b) => b.shares - a.shares || a.words - b.words);
106
+
107
+ // Новая страница это всегда работа с нуля, то есть столько же, сколько переписывание пустой.
108
+ const pageShares = pages.reduce((n, p) => n + p.shares, 0);
109
+ const newShares = newPages * SHARES.rewrite;
110
+ const shares = pageShares + newShares;
111
+ const raw = shares * unit;
112
+ const price = Math.max(minimum, raw);
113
+
114
+ return {
115
+ lang, currency, unit, minimum, newPages,
116
+ host: audit.meta?.host,
117
+ checkedAt: audit.meta?.collectedAt,
118
+ sampledPages: sampled.length,
119
+ contentPages: content.length,
120
+ utilityPages: utility.length,
121
+ cleanPages: content.length - pages.length,
122
+ pages, shares, pageShares, newShares, raw, price,
123
+ atMinimum: price > raw,
124
+ hasSearchConsole: Boolean(opts.hasSearchConsole),
125
+ };
126
+ }
127
+
128
+ const T = {
129
+ ru: {
130
+ title: (h) => `# Работа с текстами на сайте ${h}`,
131
+ checked: (d, n, u) => `Проверено ${d}. Смотрели ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} из карты сайта${u ? `, не считая ${u} ${plural(u, 'служебной', 'служебных', 'служебных')}: политику, условия, контакты и подобные мы в работу не берём и в счёт не ставим` : ''}.`,
132
+ none: '**Работы нет.** Ни на одной из просмотренных страниц мы не нашли того, что чинит Foundation. Предлагать вам этот пакет не за что.',
133
+ found: (n, c) => `## Что нашли: ${n} ${plural(n, 'страница требует', 'страницы требуют', 'страниц требуют')} работы${c ? `, ещё ${c} в порядке` : ''}`,
134
+ tier: { rewrite: 'написать заново', rework: 'переработать', touch: 'одна правка' },
135
+ words: (n) => `${n} ${plural(n, 'слово', 'слова', 'слов')}`,
136
+ newPages: (n, s) => `## Новые страницы: ${n}\n\nСтраниц, которых у вас нет, а рынок их спрашивает: ${n}. Каждая считается как страница с нуля, это ${s} ${plural(s, 'доля', 'доли', 'долей')}.`,
137
+ priceHead: '## Цена',
138
+ priceBody: (s, u, c, raw) => `Доля работы над одной страницей стоит ${u} ${c}. Долей набралось ${s}, это ${raw} ${c}.`,
139
+ atMin: (min, c) => `Минимум пакета ${min} ${c}, поэтому в счёте будет ${min} ${c}: меньше этой суммы браться нет смысла ни нам, ни вам.`,
140
+ total: (p, c) => `Итого ${p} ${c}.`,
141
+ needHead: '## Что нужно от вас',
142
+ needs: [
143
+ 'Факты: цифры, сроки, условия, которые мы поставим в начала страниц. Мы не выдумываем цифры за клиента.',
144
+ 'Источники этих цифр: ссылка, документ или ваш внутренний отчёт, на который можно сослаться словами.',
145
+ 'Доступ к сайту: репозиторий или редактор в вашей системе управления.',
146
+ ],
147
+ gscHead: 'Про Search Console',
148
+ gscNo: 'Доступа к Search Console у нас нет, поэтому мы считали по выборке страниц из карты сайта, а не по тем, которые уже приносят показы. Если дадите доступ на чтение, это бесплатно и делается в два клика, мы пересчитаем смету по страницам, которые уже что-то собирают: обычно так работы становится меньше, а толку больше.',
149
+ gscYes: 'Доступ к Search Console есть, поэтому в первую очередь мы берём страницы, которые уже собирают показы: правка такой страницы окупается быстрее, чем новая.',
150
+ tail: 'Каждую строку этой сметы видно на самой странице, её можно проверить, открыв страницу глазами.',
151
+ },
152
+ en: {
153
+ title: (h) => `# Text work on ${h}`,
154
+ checked: (d, n, u) => `Checked ${d}. We looked at ${n} page${n === 1 ? '' : 's'} from the sitemap${u ? `, not counting ${u} utility page${u === 1 ? '' : 's'}: policies, terms, contact and the like are not work we take on or charge for` : ''}.`,
155
+ none: '**There is no work here.** On none of the pages we looked at did we find what Foundation fixes. There is nothing to sell you.',
156
+ found: (n, c) => `## What we found: ${n} page${n === 1 ? '' : 's'} ${n === 1 ? 'needs' : 'need'} work${c ? `, ${c} more ${c === 1 ? 'is' : 'are'} fine` : ''}`,
157
+ tier: { rewrite: 'write from scratch', rework: 'rework', touch: 'one edit' },
158
+ words: (n) => `${n} word${n === 1 ? '' : 's'}`,
159
+ newPages: (n, s) => `## New pages: ${n}\n\nPages you do not have and the market asks for: ${n}. Each counts as a page written from scratch, ${s} share${s === 1 ? '' : 's'}.`,
160
+ priceHead: '## Price',
161
+ priceBody: (s, u, c, raw) => `One share of work on a page costs ${u} ${c}. The scope came to ${s} shares, which is ${raw} ${c}.`,
162
+ atMin: (min, c) => `The package minimum is ${min} ${c}, so the invoice will say ${min} ${c}: below that it is not worth either side's time.`,
163
+ total: (p, c) => `Total ${p} ${c}.`,
164
+ needHead: '## What we need from you',
165
+ needs: [
166
+ 'Facts: the figures, timings and terms we will put at the top of each page. We do not invent a client\'s numbers.',
167
+ 'Where those figures come from: a link, a document, or an internal report we can name in words.',
168
+ 'Access to the site: the repository, or an editor account in your CMS.',
169
+ ],
170
+ gscHead: 'About Search Console',
171
+ gscNo: 'We have no Search Console access, so this is scoped on a sample of pages from the sitemap rather than on the pages that already earn impressions. Grant us read-only access, which is free and takes two clicks, and we will re-scope on the pages that already collect something: that usually means less work and more return.',
172
+ gscYes: 'With Search Console access we start from the pages that already earn impressions: editing one of those pays back faster than a new page does.',
173
+ tail: 'Every line of this scope is visible on the page itself; you can check it by opening the page and reading it.',
174
+ },
175
+ };
176
+
177
+ const dict = (lang) => T[lang === 'en' ? 'en' : 'ru'];
178
+
179
+ /** Письмо клиенту: смета, которую он согласовывает до оплаты. */
180
+ export function renderFoundationScope(scope) {
181
+ const t = dict(scope.lang);
182
+ const L = [t.title(scope.host), '', t.checked(String(scope.checkedAt || '').slice(0, 10), scope.contentPages ?? scope.sampledPages, scope.utilityPages || 0), ''];
183
+ if (!scope.pages.length && !scope.newPages) { L.push(t.none); return L.join('\n'); }
184
+
185
+ if (scope.pages.length) {
186
+ L.push(t.found(scope.pages.length, scope.cleanPages));
187
+ L.push('');
188
+ scope.pages.forEach((p, i) => {
189
+ L.push(`${i + 1}. **${p.title}** (${t.words(p.words)}) — ${t.tier[p.tier]}, ${p.shares}`);
190
+ L.push(` ${p.url}`);
191
+ for (const is of p.issues) L.push(` - ${is.what}: ${is.action}`);
192
+ });
193
+ L.push('');
194
+ }
195
+ if (scope.newPages) { L.push(t.newPages(scope.newPages, scope.newShares)); L.push(''); }
196
+
197
+ L.push(t.priceHead);
198
+ L.push('');
199
+ L.push(t.priceBody(scope.shares, scope.unit, scope.currency, scope.raw));
200
+ if (scope.atMinimum) L.push(t.atMin(scope.minimum, scope.currency));
201
+ L.push(t.total(scope.price, scope.currency));
202
+ L.push('');
203
+ L.push(t.needHead);
204
+ L.push('');
205
+ for (const n of t.needs) L.push(`- ${n}`);
206
+ L.push('');
207
+ L.push(`### ${t.gscHead}`);
208
+ L.push('');
209
+ L.push(scope.hasSearchConsole ? t.gscYes : t.gscNo);
210
+ L.push('');
211
+ L.push(t.tail);
212
+ return L.join('\n');
213
+ }
214
+
215
+ /** Чек-лист исполнителю: страницы по убыванию работы. */
216
+ export function renderFoundationChecklist(scope) {
217
+ const t = dict(scope.lang);
218
+ const L = [`# ${scope.lang === 'en' ? 'Foundation checklist' : 'Чек-лист Foundation'}: ${scope.host}`, '',
219
+ `${scope.lang === 'en' ? 'Shares' : 'Долей'}: ${scope.shares} (${scope.pageShares} + ${scope.newShares} ${scope.lang === 'en' ? 'new' : 'новых'}). ${scope.lang === 'en' ? 'Price' : 'Цена'}: ${scope.price} ${scope.currency}.`, ''];
220
+ for (const p of scope.pages) {
221
+ L.push(`- [ ] ${p.title} (${t.tier[p.tier]}, ${p.shares})`);
222
+ L.push(` ${p.url}`);
223
+ for (const is of p.issues) L.push(` - [ ] ${is.id}: ${is.action}`);
224
+ }
225
+ if (scope.newPages) L.push(`- [ ] ${scope.lang === 'en' ? 'New pages' : 'Новые страницы'}: ${scope.newPages} × ${SHARES.rewrite}`);
226
+ return L.join('\n');
227
+ }
228
+
229
+ function plural(n, one, few, many) {
230
+ const d = n % 10; const dd = n % 100;
231
+ return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
232
+ }
package/src/index.mjs CHANGED
@@ -1,3 +1,5 @@
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';
5
+ export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.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 прошли');
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /** Пакет Foundation: раскладка страниц, доли работы, цена, два языка. */
3
+ import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
4
+
5
+ let bad = 0;
6
+ const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
7
+ const ok = (n, c) => is(n, Boolean(c), true);
8
+
9
+ const page = (over) => ({ url: 'https://x.ru/p', title: 'Страница', words: 900, answerFirst: true, sourcePhrases: 2, citedParagraphs: 1, figureParagraphs: 4, h2Count: 5, tables: 1, ...over });
10
+ const audit = (sample) => ({ meta: { host: 'x.ru', collectedAt: '2026-09-11T00:00:00.000Z' }, sample });
11
+
12
+ const clean = buildFoundationScope(audit([page(), page({ url: 'https://x.ru/b' })]));
13
+ is('чистые страницы в смету не попадают', clean.pages.length, 0);
14
+ is('и считаются как в порядке', clean.cleanPages, 2);
15
+ ok('письмо говорит, что работы нет', renderFoundationScope(clean).includes('Работы нет'));
16
+ is('цена без работы ноль долей', clean.shares, 0);
17
+
18
+ const thin = buildFoundationScope(audit([page({ words: 120 })]));
19
+ is('пустая страница это три доли', thin.pages[0].shares, 3);
20
+ is('и помечена как «написать заново»', thin.pages[0].tier, 'rewrite');
21
+
22
+ const one = buildFoundationScope(audit([page({ answerFirst: false })]));
23
+ is('одна беда это одна доля', one.pages[0].shares, 1);
24
+ is('и это «одна правка»', one.pages[0].tier, 'touch');
25
+
26
+ const two = buildFoundationScope(audit([page({ answerFirst: false, h2Count: 1 })]));
27
+ is('две беды это две доли', two.pages[0].shares, 2);
28
+ is('и обе названы', two.pages[0].issues.map((i) => i.id), ['answer-first', 'sections']);
29
+
30
+ // Пороги: короткой странице не вменяем отсутствие таблицы, пустой не вменяем всё подряд дважды
31
+ const short = buildFoundationScope(audit([page({ words: 400, tables: 0 })]));
32
+ is('таблицу спрашиваем только с длинных страниц', short.pages.length, 0);
33
+ const noSrc = buildFoundationScope(audit([page({ words: 250, sourcePhrases: 0, citedParagraphs: 0, h2Count: 0 })]));
34
+ is('с пустой страницы не спрашиваем источники и разделы отдельно', noSrc.pages[0].issues.map((i) => i.id), ['thin']);
35
+
36
+ // Цена
37
+ const many = buildFoundationScope(audit([page({ words: 100 }), page({ url: 'https://x.ru/b', answerFirst: false, h2Count: 1 }), page({ url: 'https://x.ru/c', answerFirst: false })]));
38
+ is('доли складываются', many.shares, 6);
39
+ is('цена это доли на цену доли', many.raw, 6 * 4200);
40
+ is('но не ниже минимума', many.price, Math.max(42000, 6 * 4200));
41
+ ok('минимум сработал', many.atMinimum === (many.price > many.raw));
42
+ const big = buildFoundationScope(audit(Array.from({ length: 12 }, (_, i) => page({ url: `https://x.ru/${i}`, words: 100 }))));
43
+ is('на большом объёме цена выше минимума', big.price, 36 * 4200);
44
+ ok('и это уже не минимум', !big.atMinimum);
45
+
46
+ const withNew = buildFoundationScope(audit([page({ answerFirst: false })]), { newPages: 2 });
47
+ is('новая страница считается как написанная с нуля', withNew.newShares, 6);
48
+ is('и входит в общую сумму долей', withNew.shares, 7);
49
+ ok('в письме названы новые страницы', renderFoundationScope(withNew).includes('Новые страницы: 2'));
50
+
51
+ // Порядок: тяжёлое сверху
52
+ const order = buildFoundationScope(audit([page({ url: 'https://x.ru/a', answerFirst: false }), page({ url: 'https://x.ru/b', words: 100 })]));
53
+ is('самая тяжёлая страница идёт первой', order.pages[0].url, 'https://x.ru/b');
54
+
55
+ // Search Console: без доступа говорим об этом прямо
56
+ const noGsc = renderFoundationScope(many);
57
+ ok('без доступа к Search Console письмо это признаёт', noGsc.includes('Доступа к Search Console у нас нет'));
58
+ ok('и говорит, что доступ бесплатный', noGsc.includes('бесплатно'));
59
+ const yesGsc = renderFoundationScope(buildFoundationScope(audit([page({ answerFirst: false })]), { hasSearchConsole: true }));
60
+ ok('с доступом письмо берёт страницы с показами', yesGsc.includes('уже собирают показы'));
61
+
62
+ // Язык
63
+ const en = buildFoundationScope(audit([page({ words: 100 }), page({ url: 'https://x.ru/b', answerFirst: false })]), { lang: 'en' });
64
+ is('валюта английской сметы', en.currency, 'USD');
65
+ is('цена доли по умолчанию', en.unit, 50);
66
+ is('минимум пакета', en.minimum, 500);
67
+ const letterEn = renderFoundationScope({ ...en, host: 'x.com', pages: en.pages.map((p) => ({ ...p, title: 'A page' })) });
68
+ ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
69
+ ok('в английском письме есть цена', letterEn.includes('500 USD'));
70
+
71
+ // Чек-лист
72
+ const list = renderFoundationChecklist(many);
73
+ ok('в чек-листе все страницы сметы', many.pages.every((p) => list.includes(p.url)));
74
+ ok('и все работы по ним', many.pages.every((p) => p.issues.every((i) => list.includes(i.id))));
75
+
76
+ // Страницы без разбора (редиректы, ошибки) в смету не лезут
77
+ const junk = buildFoundationScope(audit([{ url: 'https://x.ru/r', redirect: 'https://x.ru/', status: 301 }, { url: 'https://x.ru/e', status: 404 }, page({ answerFirst: false })]));
78
+ is('нечитаемые страницы пропускаются', junk.sampledPages, 1);
79
+ is('и не превращаются в работу', junk.pages.length, 1);
80
+
81
+ // Служебные страницы: политику и контакты в работу не берём и в счёт не ставим
82
+ const util = buildFoundationScope(audit([
83
+ page({ url: 'https://x.ru/privacy/', words: 1100, answerFirst: false, sourcePhrases: 0, citedParagraphs: 0, tables: 0 }),
84
+ page({ url: 'https://x.ru/terms/', words: 1100, answerFirst: false, sourcePhrases: 0, citedParagraphs: 0, tables: 0 }),
85
+ page({ url: 'https://x.ru/contact/', words: 1100, answerFirst: false, h2Count: 1 }),
86
+ page({ url: 'https://x.ru/guides/kak-vybrat/', answerFirst: false }),
87
+ ]));
88
+ is('служебные страницы в счёт не идут', util.pages.length, 1);
89
+ is('и посчитаны отдельно', util.utilityPages, 3);
90
+ is('содержательных страниц одна', util.contentPages, 1);
91
+ ok('письмо объясняет, почему служебные не в счёте', renderFoundationScope(util).includes('в работу не берём и в счёт не ставим'));
92
+ ok('в счёте осталась только содержательная', util.pages[0].url.includes('/guides/'));
93
+
94
+ // Источники спрашиваем только там, где цифры есть
95
+ const noFigures = buildFoundationScope(audit([page({ url: 'https://x.ru/o-nas/', figureParagraphs: 0, sourcePhrases: 0, citedParagraphs: 0 })]));
96
+ is('страница без цифр не должна источников', noFigures.pages.length, 0);
97
+ const withFigures = buildFoundationScope(audit([page({ url: 'https://x.ru/o-nas/', figureParagraphs: 3, sourcePhrases: 0, citedParagraphs: 0 })]));
98
+ is('страница с цифрами без источников это работа', withFigures.pages[0].issues.map((i) => i.id), ['sources']);
99
+
100
+ // Четыре беды это уже работа заново, а не «переработка» по цене двух долей
101
+ const four = buildFoundationScope(audit([page({ url: 'https://x.ru/g/', words: 700, answerFirst: false, sourcePhrases: 0, citedParagraphs: 0, h2Count: 1, tables: 0 })]));
102
+ is('четыре беды дают четыре работы', four.pages[0].issues.length, 4);
103
+ is('и три доли, а не две', four.pages[0].shares, 3);
104
+ is('с честной пометкой', four.pages[0].tier, 'rewrite');
105
+
106
+ // Английское множественное число
107
+ const en1 = buildFoundationScope(audit([page({ url: 'https://x.ru/a/', answerFirst: false }), page({ url: 'https://x.ru/b/' })]), { lang: 'en' });
108
+ const txt1 = renderFoundationScope(en1);
109
+ ok('одна страница: needs work, ещё одна is fine', txt1.includes('1 page needs work, 1 more is fine'));
110
+
111
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
112
+ console.log('\nвсе тесты пакета Foundation прошли');