@operstack/audit 0.6.0 → 0.7.1

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
@@ -69,6 +69,18 @@ Every open check is sorted into one of three buckets by whose knowledge it needs
69
69
 
70
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
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
+
72
84
  ## Example
73
85
 
74
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
@@ -12,6 +12,8 @@
12
12
  * in the buyer's language; the price defaults to 249 USD in English and 21000 RUB in Russian
13
13
  * operstack-audit fix-report audit-fix-plan.json after.json
14
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
15
17
  * operstack-audit check audit.json
16
18
  * lists narrative fields still holding placeholders and any score the checks do not support
17
19
  *
@@ -20,6 +22,7 @@
20
22
  */
21
23
  import { collect, verifyScores } from '../src/collect.mjs';
22
24
  import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
25
+ import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
23
26
  import { render, checkNarrative } from '../src/render.mjs';
24
27
  import { writeFileSync, readFileSync } from 'node:fs';
25
28
  import { resolve } from 'node:path';
@@ -29,7 +32,22 @@ const opt = (k, d) => { const i = rest.indexOf(k); return i === -1 ? d : rest[i
29
32
  const has = (k) => rest.includes(k);
30
33
 
31
34
  if (!cmd || cmd === '--help' || cmd === '-h') {
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>');
35
+ console.log([
36
+ 'Usage:',
37
+ ' operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json] [--lang ru]',
38
+ ' collect the public signals of a site into audit.json',
39
+ ' operstack-audit render <audit.json> [--out report.html] [--pdf]',
40
+ ' render the report; --pdf also prints a PDF with headless Chrome',
41
+ ' operstack-audit check <audit.json>',
42
+ ' list narrative fields still holding placeholders and any score the checks do not support',
43
+ '',
44
+ ' operstack-audit fix-plan <audit.json> [--platform files|cms] [--lang ru|en] [--price 249] [--out base]',
45
+ ' what the Fix package closes on this site: plan, client letter, executor checklist',
46
+ ' operstack-audit fix-report <audit-fix-plan.json> <after.json> [--out report.md]',
47
+ ' what was closed, what was not, and how much to refund',
48
+ ' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--unit 50] [--minimum 500] [--out base]',
49
+ ' the text work the Foundation package would do, page by page, and the price it adds up to',
50
+ ].join('\n'));
33
51
  process.exit(cmd ? 0 : 2);
34
52
  }
35
53
  if (cmd === 'collect') {
@@ -85,4 +103,26 @@ if (cmd === 'collect') {
85
103
  writeFileSync(out, renderFixReport(report));
86
104
  console.log(`closed ${report.closed.length} of ${report.total}, still open ${report.stillOpen.length}, refund ${report.refund} ${report.currency}`);
87
105
  console.log(`wrote ${out}`);
106
+ } else if (cmd === 'foundation-scope') {
107
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
108
+ const lang = opt('--lang', 'ru') === 'en' ? 'en' : 'ru';
109
+ if (!(audit.sample || []).some((p) => p.answerFirst !== undefined)) {
110
+ console.error('this audit was collected before the text fields were kept in the sample: re-run collect to scope Foundation');
111
+ process.exit(1);
112
+ }
113
+ const scope = buildFoundationScope(audit, {
114
+ lang,
115
+ newPages: Number(opt('--new-pages', 0)),
116
+ hasSearchConsole: has("--gsc"),
117
+ unit: opt('--unit') ? Number(opt('--unit')) : undefined,
118
+ minimum: opt('--minimum') ? Number(opt('--minimum')) : undefined,
119
+ currency: opt('--currency'),
120
+ });
121
+ const base = opt('--out', target.replace(/\.json$/, ''));
122
+ writeFileSync(resolve(`${base}-foundation-scope.json`), JSON.stringify(scope, null, 2));
123
+ writeFileSync(resolve(`${base}-foundation-letter.md`), renderFoundationScope(scope));
124
+ writeFileSync(resolve(`${base}-foundation-checklist.md`), renderFoundationChecklist(scope));
125
+ console.log(`${scope.pages.length} page(s) need text work of ${scope.sampledPages} sampled, ${scope.shares} share(s)`);
126
+ console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
127
+ console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
88
128
  } 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.6.0",
3
+ "version": "0.7.1",
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,8 +10,10 @@
10
10
  },
11
11
  "exports": {
12
12
  ".": "./src/index.mjs",
13
+ "./package.json": "./package.json",
13
14
  "./i18n": "./src/i18n.mjs",
14
- "./fix": "./src/fix.mjs"
15
+ "./fix": "./src/fix.mjs",
16
+ "./foundation": "./src/foundation.mjs"
15
17
  },
16
18
  "files": [
17
19
  "bin",
@@ -22,7 +24,7 @@
22
24
  "LICENSE"
23
25
  ],
24
26
  "scripts": {
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",
27
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
26
28
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
27
29
  },
28
30
  "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
  }
@@ -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
@@ -2,3 +2,4 @@ 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
4
  export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
5
+ export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
@@ -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 прошли');