@operstack/audit 0.7.1 → 0.7.2

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
@@ -79,7 +79,7 @@ npx @operstack/audit foundation-scope audit.json --lang en --new-pages 3
79
79
 
80
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
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.
82
+ The letter states the scope and the shares; `--price` adds the money, because a short page is sometimes short on purpose and an automatic invoice for rewriting it is an invoice for work that should not happen. 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
83
 
84
84
  ## Example
85
85
 
package/bin/audit.mjs CHANGED
@@ -45,8 +45,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
45
45
  ' what the Fix package closes on this site: plan, client letter, executor checklist',
46
46
  ' operstack-audit fix-report <audit-fix-plan.json> <after.json> [--out report.md]',
47
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',
48
+ ' operstack-audit foundation-scope <audit.json> [--lang ru|en] [--new-pages 3] [--gsc] [--price] [--out base]',
49
+ ' the text work the Foundation package would do, page by page; --price also states the money',
50
50
  ].join('\n'));
51
51
  process.exit(cmd ? 0 : 2);
52
52
  }
@@ -114,6 +114,7 @@ if (cmd === 'collect') {
114
114
  lang,
115
115
  newPages: Number(opt('--new-pages', 0)),
116
116
  hasSearchConsole: has("--gsc"),
117
+ withPrice: has('--price'),
117
118
  unit: opt('--unit') ? Number(opt('--unit')) : undefined,
118
119
  minimum: opt('--minimum') ? Number(opt('--minimum')) : undefined,
119
120
  currency: opt('--currency'),
@@ -123,6 +124,7 @@ if (cmd === 'collect') {
123
124
  writeFileSync(resolve(`${base}-foundation-letter.md`), renderFoundationScope(scope));
124
125
  writeFileSync(resolve(`${base}-foundation-checklist.md`), renderFoundationChecklist(scope));
125
126
  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
+ if (scope.withPrice) console.log(`price ${scope.price} ${scope.currency}${scope.atMinimum ? ` (raw ${scope.raw}, lifted to the package minimum)` : ''}`);
128
+ else console.log('no price in the letter: add --price once the list is agreed and you have decided what to charge');
127
129
  console.log(`wrote ${base}-foundation-scope.json, ${base}-foundation-letter.md, ${base}-foundation-checklist.md`);
128
130
  } 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.7.1",
3
+ "version": "0.7.2",
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",
package/src/collect.mjs CHANGED
@@ -59,9 +59,22 @@ function analysePage(url, html) {
59
59
  walk(j);
60
60
  } catch { schemaTypes.push('(invalid JSON-LD)'); }
61
61
  }
62
+ // Служебная обвязка вырезается, кроме <header>, в котором стоит H1: это шапка статьи, а не сайта.
63
+ const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
64
+ .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
65
+ .replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
66
+ const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
67
+ const countWords = (x) => (x.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
62
68
  const body = root.querySelector('body');
63
- const text = (body?.text ?? '').replace(/\s+/g, ' ').trim();
64
- const words = (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
69
+ // Парсер иногда не находит body в большом или неаккуратном HTML и молча возвращает пустой текст,
70
+ // а пустой текст это приговор «страница пустая». Проверено на css-tricks.com: 178 КБ, body не
71
+ // найден, слов ноль при живых 978. Поэтому берём тот текст, которого больше: пустоту показывать
72
+ // клиенту нельзя, это ложное обвинение, за которое он ещё и заплатит в смете.
73
+ const parsedText = (body?.text ?? '').replace(/\s+/g, ' ').trim();
74
+ const text = countWords(parsedText) >= countWords(plain(bodyHtml)) ? parsedText : plain((html.match(/<body[\s\S]*<\/body>/i) || [html])[0].replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' '));
75
+ // Слова считаем по содержимому без меню и подвала: иначе страница из одного абзаца в большом
76
+ // шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
77
+ const words = countWords(plain(bodyHtml)) || countWords(text);
65
78
  const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
66
79
  const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
67
80
  const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
@@ -108,11 +121,6 @@ function analysePage(url, html) {
108
121
  author: /"author"\s*:/.test(ld),
109
122
  };
110
123
  // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
111
- // Site chrome is stripped, except a <header> that carries the H1: that is the article head, not chrome.
112
- const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
113
- .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
114
- .replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
115
- const plain = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
116
124
  const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
117
125
  const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
118
126
  const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
@@ -125,13 +133,18 @@ function analysePage(url, html) {
125
133
  const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
126
134
  // Абзацы, в которых вообще есть цифра. Без этого счётчика «цифры без источника» вменяется
127
135
  // странице, где цифр нет ни одной, например политике конфиденциальности.
128
- const figureParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => /\d/.test(plain(para))).length;
129
- const citedParagraphs = (bodyHtml.match(/<p[^>]*>[\s\S]*?<\/p>/gi) || []).filter((para) => {
130
- if (!/\d/.test(plain(para))) return false;
131
- return [...para.matchAll(/<a[^>]+href=["'](https?:\/\/[^"']+)["']/gi)].some((m) => {
132
- try { return new URL(m[1]).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
133
- });
134
- }).length;
136
+ // Абзацы берём разбором дерева, а не регуляркой: на странице с незакрытым <p> регулярка
137
+ // склеивает переключатель валют, заголовок и текст в один «абзац с цифрой». Порог в двенадцать
138
+ // слов отсекает служебные обрывки: цена в карточке это не утверждение, которому нужен источник.
139
+ const PROSE_WORDS = 12;
140
+ const paras = (body ? body.querySelectorAll('p') : []).map((el) => ({
141
+ text: (el.text || '').replace(/\s+/g, ' ').trim(),
142
+ hrefs: el.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || ''),
143
+ })).filter((x) => countWords(x.text) >= PROSE_WORDS && /\d/.test(x.text));
144
+ const figureParagraphs = paras.length;
145
+ const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
146
+ try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
147
+ })).length;
135
148
  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 };
136
149
  }
137
150
 
@@ -13,6 +13,10 @@
13
13
  * Чего этот расчёт не знает: какие страницы приносят показы. Это видно только в Search Console,
14
14
  * доступ к которой даёт клиент, и это бесплатно. Без доступа мы считаем по выборке и говорим
15
15
  * об этом прямо, а не делаем вид, что выбрали важные страницы.
16
+ *
17
+ * Деньги в письмо по умолчанию не попадают. Объём работы машина считает честно, а цену называет
18
+ * человек: короткая страница бывает короткой намеренно, и счёт, выставленный автоматом, окажется
19
+ * счётом за работу, которой не нужно было делать. Цена включается флагом, осознанно.
16
20
  */
17
21
 
18
22
  /**
@@ -84,13 +88,17 @@ export function buildFoundationScope(audit, opts = {}) {
84
88
  const unit = Number(opts.unit ?? (lang === 'en' ? 50 : 4200));
85
89
  const minimum = Number(opts.minimum ?? (lang === 'en' ? 500 : 42000));
86
90
  const newPages = Math.max(0, Number(opts.newPages ?? 0));
91
+ const withPrice = opts.withPrice === true;
87
92
 
88
93
  const sampled = (audit.sample || []).filter((p) => p.title !== undefined && typeof p.words === 'number');
89
94
  const utility = sampled.filter((p) => isUtilityPage(p.url));
90
95
  const content = sampled.filter((p) => !isUtilityPage(p.url));
91
96
  const pages = [];
92
97
  for (const p of content) {
93
- const issues = PAGE_ISSUES.filter((i) => i.test(p));
98
+ let issues = PAGE_ISSUES.filter((i) => i.test(p));
99
+ // Странице, которую пишем заново, незачем отдельной строкой советовать переписать первый
100
+ // абзац: это та же работа, названная дважды, и в списке она выглядит как две.
101
+ if (issues.some((i) => i.heavy)) issues = issues.filter((i) => i.heavy);
94
102
  if (!issues.length) continue;
95
103
  const tier = tierOf(issues);
96
104
  pages.push({
@@ -112,7 +120,7 @@ export function buildFoundationScope(audit, opts = {}) {
112
120
  const price = Math.max(minimum, raw);
113
121
 
114
122
  return {
115
- lang, currency, unit, minimum, newPages,
123
+ lang, currency, unit, minimum, newPages, withPrice,
116
124
  host: audit.meta?.host,
117
125
  checkedAt: audit.meta?.collectedAt,
118
126
  sampledPages: sampled.length,
@@ -134,6 +142,9 @@ const T = {
134
142
  tier: { rewrite: 'написать заново', rework: 'переработать', touch: 'одна правка' },
135
143
  words: (n) => `${n} ${plural(n, 'слово', 'слова', 'слов')}`,
136
144
  newPages: (n, s) => `## Новые страницы: ${n}\n\nСтраниц, которых у вас нет, а рынок их спрашивает: ${n}. Каждая считается как страница с нуля, это ${s} ${plural(s, 'доля', 'доли', 'долей')}.`,
145
+ workHead: '## Объём работы',
146
+ workBody: (s) => `Всего ${s} ${plural(s, 'доля', 'доли', 'долей')} работы. Доля это работа над одной страницей: одна правка доля, две или три две доли, четыре и больше или пустая страница три. Цену мы назовём отдельно, когда вы согласуете сам список.`,
147
+ thinWarn: 'Среди них есть страницы короче трёхсот слов. Если такие страницы у вас задуманы короткими, это анонсы, новости или ссылки на чужой материал, скажите, и мы уберём их из списка: писать их заново незачем.',
137
148
  priceHead: '## Цена',
138
149
  priceBody: (s, u, c, raw) => `Доля работы над одной страницей стоит ${u} ${c}. Долей набралось ${s}, это ${raw} ${c}.`,
139
150
  atMin: (min, c) => `Минимум пакета ${min} ${c}, поэтому в счёте будет ${min} ${c}: меньше этой суммы браться нет смысла ни нам, ни вам.`,
@@ -157,6 +168,9 @@ const T = {
157
168
  tier: { rewrite: 'write from scratch', rework: 'rework', touch: 'one edit' },
158
169
  words: (n) => `${n} word${n === 1 ? '' : 's'}`,
159
170
  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'}.`,
171
+ workHead: '## Scope of work',
172
+ workBody: (s) => `${s} share${s === 1 ? '' : 's'} of work in total. A share is the work on one page: one edit is a share, two or three defects are two, four or more (or an empty page) three. We name the price separately, once you have agreed the list itself.`,
173
+ thinWarn: 'Some of these are under 300 words. If those pages are meant to be short, link posts, news items, announcements, say so and we will drop them from the list: there is nothing to rewrite.',
160
174
  priceHead: '## Price',
161
175
  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
176
  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.`,
@@ -194,11 +208,18 @@ export function renderFoundationScope(scope) {
194
208
  }
195
209
  if (scope.newPages) { L.push(t.newPages(scope.newPages, scope.newShares)); L.push(''); }
196
210
 
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));
211
+ if (scope.withPrice) {
212
+ L.push(t.priceHead);
213
+ L.push('');
214
+ L.push(t.priceBody(scope.shares, scope.unit, scope.currency, scope.raw));
215
+ if (scope.atMinimum) L.push(t.atMin(scope.minimum, scope.currency));
216
+ L.push(t.total(scope.price, scope.currency));
217
+ } else {
218
+ L.push(t.workHead);
219
+ L.push('');
220
+ L.push(t.workBody(scope.shares));
221
+ }
222
+ if (scope.pages.some((p) => p.issues.some((i) => i.id === 'thin'))) { L.push(''); L.push(t.thinWarn); }
202
223
  L.push('');
203
224
  L.push(t.needHead);
204
225
  L.push('');
@@ -216,7 +237,7 @@ export function renderFoundationScope(scope) {
216
237
  export function renderFoundationChecklist(scope) {
217
238
  const t = dict(scope.lang);
218
239
  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}.`, ''];
240
+ `${scope.lang === 'en' ? 'Shares' : 'Долей'}: ${scope.shares} (${scope.pageShares} + ${scope.newShares} ${scope.lang === 'en' ? 'new' : 'новых'}).${scope.withPrice ? ` ${scope.lang === 'en' ? 'Price' : 'Цена'}: ${scope.price} ${scope.currency}.` : ''}`, ''];
220
241
  for (const p of scope.pages) {
221
242
  L.push(`- [ ] ${p.title} (${t.tier[p.tier]}, ${p.shares})`);
222
243
  L.push(` ${p.url}`);
@@ -17,6 +17,7 @@ is('цена без работы ноль долей', clean.shares, 0);
17
17
 
18
18
  const thin = buildFoundationScope(audit([page({ words: 120 })]));
19
19
  is('пустая страница это три доли', thin.pages[0].shares, 3);
20
+ is('и одна работа, а не список повторов', buildFoundationScope(audit([page({ words: 90, answerFirst: false, h2Count: 0, tables: 0 })])).pages[0].issues.map((i) => i.id), ['thin']);
20
21
  is('и помечена как «написать заново»', thin.pages[0].tier, 'rewrite');
21
22
 
22
23
  const one = buildFoundationScope(audit([page({ answerFirst: false })]));
@@ -66,7 +67,20 @@ is('цена доли по умолчанию', en.unit, 50);
66
67
  is('минимум пакета', en.minimum, 500);
67
68
  const letterEn = renderFoundationScope({ ...en, host: 'x.com', pages: en.pages.map((p) => ({ ...p, title: 'A page' })) });
68
69
  ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
69
- ok('в английском письме есть цена', letterEn.includes('500 USD'));
70
+
71
+ // Деньги в письме только по флагу: цену называет человек, а не машина
72
+ ok('по умолчанию цены в письме нет', !/USD|RUB/.test(letterEn));
73
+ ok('вместо неё объём работы', letterEn.includes('share') && letterEn.includes('name the price separately'));
74
+ const priced = buildFoundationScope(audit([page({ url: 'https://x.ru/a/', words: 100 })]), { lang: 'en', withPrice: true });
75
+ ok('с флагом цена появляется', renderFoundationScope(priced).includes('500 USD'));
76
+ ok('и в чек-листе тоже', renderFoundationChecklist(priced).includes('500 USD'));
77
+ ok('без флага чек-лист без денег', !/USD|RUB/.test(renderFoundationChecklist(en)));
78
+ is('доли считаются в обоих случаях', [en.shares > 0, priced.shares > 0], [true, true]);
79
+
80
+ // Короткие страницы бывают короткими намеренно, поэтому их просим подтвердить
81
+ const thinLetter = renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/n/', words: 90 })])));
82
+ ok('письмо просит подтвердить короткие страницы', thinLetter.includes('задуманы короткими'));
83
+ ok('без коротких страниц такой оговорки нет', !renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/a/', answerFirst: false })]))).includes('задуманы короткими'));
70
84
 
71
85
  // Чек-лист
72
86
  const list = renderFoundationChecklist(many);