@operstack/audit 0.21.4 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.21.4",
3
+ "version": "0.23.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",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
32
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs",
33
33
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
34
  "verify:live": "node src/verify-live.mjs"
35
35
  },
package/src/collect.mjs CHANGED
@@ -52,6 +52,13 @@ async function follow(url, max = 5) {
52
52
 
53
53
  const row = (id, group, label, status, value, comment = '') => ({ id, group, label, status, value, comment });
54
54
 
55
+ /**
56
+ * Чем в тексте называют источник цифры, по-английски и по-русски. Список слово в слово совпадает
57
+ * с SOURCE_RE в visibility.mjs: два движка должны считать одно и то же, иначе балл в бесплатной
58
+ * проверке и в платном отчёте разойдётся на ровном месте.
59
+ */
60
+ const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank|по данным|источник:|источники:|согласно|по информации|по сведениям|росстат|центробанк|банк россии|росреестр|минфин|минэкономразвития)(?!\p{L})/giu;
61
+
55
62
  function analysePage(url, html) {
56
63
  const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
57
64
  const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
@@ -147,7 +154,13 @@ function analysePage(url, html) {
147
154
  const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
148
155
  // A source is named either in words, or the way careful writing actually does it: a link to
149
156
  // somebody else's site sitting in the same paragraph as the figure it backs.
150
- const sourcePhrases = (text.match(/\b(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank)\b/gi) || []).length;
157
+ // Слова, которыми называют источник цифры. Список и границы слова те же, что в движке
158
+ // бесплатной проверки (visibility.mjs, SOURCE_RE): до 16.09.2026 здесь были только английские
159
+ // выражения, и русский сайт, честно написавший «по данным Росстата», получал ноль. Живой
160
+ // пользователь сказал об этом прямо: на русском сайте эти баллы недостижимы.
161
+ // Обычная граница слова \b для кириллицы не работает, она построена на латинице, поэтому
162
+ // границы заданы через отсутствие буквы рядом.
163
+ const sourcePhrases = (text.match(SOURCE_RE) || []).length;
151
164
  const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
152
165
  // Абзацы, в которых вообще есть цифра. Без этого счётчика «цифры без источника» вменяется
153
166
  // странице, где цифр нет ни одной, например политике конфиденциальности.
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Русский сайт, который честно называет источник цифры, получает за это балл.
4
+ *
5
+ * 16.09.2026 живой пользователь сказал прямо: «поиск источника цифр англоязычный, на русском
6
+ * сайте эти пять баллов недостижимы честно». Так и было: в движке платного аудита список слов
7
+ * состоял только из английских выражений, хотя в движке бесплатной проверки русские уже были.
8
+ * Два движка считали одно и то же по-разному.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { resolve, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const src = readFileSync(resolve(here, 'collect.mjs'), 'utf8');
16
+ const vis = readFileSync(resolve(here, 'visibility.mjs'), 'utf8');
17
+
18
+ let bad = 0;
19
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
20
+
21
+ const line = (text) => (text.match(/^const SOURCE_RE = .+$/m) || [''])[0];
22
+ ok('списки слов в обоих движках совпадают слово в слово', line(src) === line(vis) && line(src).length > 60);
23
+
24
+ const RE = new RegExp(line(src).replace(/^const SOURCE_RE = \//, '').replace(/\/giu;$/, ''), 'giu');
25
+ const count = (t) => (t.match(RE) || []).length;
26
+
27
+ for (const t of ['По данным Росстата, рынок вырос на 12%.',
28
+ 'Источник: отчёт Банка России за 2025 год.',
29
+ 'Согласно исследованию, доля выросла до 40%.',
30
+ 'По информации Минфина, ставка снижена.',
31
+ 'По сведениям Росреестра, сделок стало больше.'])
32
+ ok(`русский источник виден: ${t.slice(0, 34)}…`, count(t) > 0);
33
+
34
+ for (const t of ['According to Rosstat, the market grew 12%.', 'Source: Bank of Russia report.'])
35
+ ok(`английский по-прежнему виден: ${t.slice(0, 30)}…`, count(t) > 0);
36
+
37
+ for (const t of ['Мы выросли на 12% за год.', 'Наша команда работает с 2019 года.', 'Согласнее всех был директор.'])
38
+ ok(`пустая похвальба источником не считается: ${t.slice(0, 28)}…`, count(t) === 0);
39
+
40
+ console.log(bad ? `\nпровалов: ${bad}` : '\nвсё сходится');
41
+ process.exit(bad ? 1 : 0);
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Мы меряем то, что человек станет чинить, и говорим ему, что именно измерили.
4
+ *
5
+ * 16.09.2026 живой пользователь прошёл путь с 63 до 95 и назвал два узких места подряд:
6
+ * в выборку попали расшифровки на семь тысяч слов, а таблицу, добавленную на главную, проверка
7
+ * не увидела. Оба оказались правдой. Отбор шёл сортировкой по длине адреса по убыванию, то есть
8
+ * наверх поднимались самые глубокие страницы, а главная в оценку содержания не входила вовсе.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { resolve, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const src = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), 'visibility.mjs'), 'utf8');
15
+
16
+ let bad = 0;
17
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
18
+
19
+ ok('главная входит в оценку содержания наравне с остальными',
20
+ /const contentPages = \[homePage, \.\.\.sampled\]/.test(src));
21
+ ok('сортировки по длине адреса по убыванию больше нет',
22
+ !/sort\(\(a, b\) => b\.length - a\.length\)/.test(src));
23
+ ok('страницы отбираются по близости к корню', /depth\(a\) - depth\(b\)/.test(src));
24
+ ok('и по одной из каждого раздела', /usedSections/.test(src));
25
+ ok('отчёт называет измеренные страницы', /measured: \(list\)/.test(src) && /T\.measured\(/.test(src));
26
+ ok('находка про абзац-ответ называет страницы', /answerFirstMissing: \(n, total, where\)/.test(src));
27
+
28
+ // Отбор должен быть строго определённым: два прогона по одному сайту берут одни и те же страницы,
29
+ // иначе балл гуляет и человек не понимает, что изменилось от его правки.
30
+ const depth = (u) => new URL(u).pathname.replace(/\/$/, '').split('/').filter(Boolean).length;
31
+ const section = (u) => new URL(u).pathname.split('/').filter(Boolean)[0] || '';
32
+ const pick = (pages, want) => {
33
+ const byDepth = [...pages].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
34
+ const out = []; const used = new Set();
35
+ for (const p of byDepth) { if (out.length >= want) break; const s = section(p); if (used.has(s)) continue; used.add(s); out.push(p); }
36
+ for (const p of byDepth) { if (out.length >= want) break; if (!out.includes(p)) out.push(p); }
37
+ return out;
38
+ };
39
+ const site = [
40
+ 'https://x.ru/blog/rasshifrovka-vypuska-nomer-sorok-dva-pro-vsyo-na-svete/',
41
+ 'https://x.ru/blog/rasshifrovka-vypuska-nomer-sorok-tri-pro-vsyo-na-svete/',
42
+ 'https://x.ru/uslugi/', 'https://x.ru/ceny/', 'https://x.ru/o-nas/', 'https://x.ru/blog/',
43
+ ];
44
+ const got = pick(site, 3);
45
+ ok('расшифровки больше не вытесняют витрину', !got.some((u) => u.includes('rasshifrovka')));
46
+ ok('взяты разные разделы', new Set(got.map(section)).size === got.length);
47
+ ok('отбор повторяем', JSON.stringify(pick(site, 3)) === JSON.stringify(got));
48
+
49
+ console.log(bad ? `\nпровалов: ${bad}` : '\nвсё сходится');
50
+ process.exit(bad ? 1 : 0);
@@ -84,10 +84,11 @@ export const MESSAGES = {
84
84
  ogMissing: 'No Open Graph tags on the homepage: shared links and previews render without a title or image.',
85
85
  thinPages: (thin, total) => `${thin} of ${total} sampled page(s) hold under 300 words. Engines rarely cite thin pages.`,
86
86
  avgWords: (avg) => `Sampled pages carry ${avg} words on average.`,
87
- answerFirstMissing: (n, total) => `${n} of ${total} sampled page(s) open with an answer-first paragraph (20 to 90 words with a figure right after the H1). That paragraph is what gets quoted.`,
87
+ answerFirstMissing: (n, total, where) => `${n} of ${total} measured page(s) open with an answer-first paragraph (20 to 90 words with a figure right after the H1). That paragraph is what gets quoted. Missing on: ${where}.`,
88
88
  answerFirstOk: 'Every sampled page opens with an answer-first paragraph carrying a figure.',
89
89
  fewH2: (n) => `${n} sampled page(s) have fewer than three H2 sections.`,
90
- noTables: 'No tables on the sampled pages. Tables are the second most quoted format after the first paragraph.',
90
+ noTables: 'No tables on the measured pages. Tables are the second most quoted format after the first paragraph.',
91
+ measured: (list) => `Measured on these pages: ${list}. Every content finding is a share of them, so a fix on one page moves the score by its share.`,
91
92
  homeOnly: 'Only the homepage could be read, so this area is measured on one page rather than several. A sitemap that answers would give a fuller picture.',
92
93
  datesMissing: (n) => `${n} sampled page(s) expose no publication or modified date. Engines prefer sources they can date.`,
93
94
  datesOk: 'Sampled pages expose publication dates.',
@@ -136,14 +137,15 @@ export const MESSAGES = {
136
137
  ? 'Единственная проверенная страница короче 300 слов. Короткие страницы цитируют редко.'
137
138
  : `Из ${total} ${ofPages(total)} ${thin} короче 300 слов. Короткие страницы цитируют редко.`),
138
139
  avgWords: (avg) => `На проверенных страницах в среднем ${avg} ${pl(avg, 'слово', 'слова', 'слов')}.`,
139
- answerFirstMissing: (n, total) => (total === 1
140
- ? 'Единственная проверенная страница не начинается с абзаца-ответа (20-90 слов с цифрой сразу после H1). Именно этот абзац попадает в цитату.'
141
- : `Из ${total} ${ofPages(total)} с абзаца-ответа (20-90 слов с цифрой сразу после H1) ${n === 0 ? 'не начинается ни одна' : n === 1 ? 'начинается одна' : `начинаются ${n}`}. Именно этот абзац попадает в цитату.`),
140
+ answerFirstMissing: (n, total, where) => (total === 1
141
+ ? `Единственная проверенная страница не начинается с абзаца-ответа (20-90 слов с цифрой сразу после H1). Именно этот абзац попадает в цитату. Чинить здесь: ${where}.`
142
+ : `Из ${total} ${ofPages(total)} с абзаца-ответа (20-90 слов с цифрой сразу после H1) ${n === 0 ? 'не начинается ни одна' : n === 1 ? 'начинается одна' : `начинаются ${n}`}. Именно этот абзац попадает в цитату. Чинить здесь: ${where}.`),
142
143
  answerFirstOk: 'Каждая проверенная страница начинается с абзаца-ответа с цифрой.',
143
144
  fewH2: (n, total) => (total === 1
144
145
  ? 'У единственной проверенной страницы меньше трёх подзаголовков H2.'
145
146
  : `У ${n} из ${total} ${ofPages(total)} меньше трёх подзаголовков H2.`),
146
147
  noTables: 'На проверенных страницах нет таблиц. Таблица второй по цитируемости формат после первого абзаца.',
148
+ measured: (list) => `Мерили по этим страницам: ${list}. Каждая находка по содержанию считается долей от них, поэтому правка на одной странице двигает балл на свою долю.`,
147
149
  homeOnly: 'Прочитать удалось только главную, поэтому эта область измерена по одной странице, а не по нескольким. Отвечающая карта сайта дала бы полную картину.',
148
150
  datesMissing: (n, total) => (total === 1
149
151
  ? 'Единственная проверенная страница не показывает дату публикации или изменения. Системы предпочитают источники, которые можно датировать.'
@@ -455,7 +457,38 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
455
457
  let wantedPages = 0;
456
458
  if (left() > 2000 && sitemap.pages.length) {
457
459
  const norm = (u) => u.replace(/\/$/, '').toLowerCase();
458
- const picks = sitemap.pages.filter((p) => norm(p) !== norm(home.url) && p.startsWith(origin)).sort((a, b) => b.length - a.length).slice(0, Math.max(12, samplePages * 4)).filter((_, i) => i % 4 === 0).slice(0, samplePages);
460
+ /*
461
+ * Какие страницы берём в выборку.
462
+ *
463
+ * До 16.09.2026 здесь стояла сортировка по длине адреса по убыванию, то есть наверх
464
+ * поднимались самые глубокие страницы: расшифровки, архивы, служебные разделы. Живой
465
+ * пользователь увидел это на своём сайте: в выборку попали расшифровки на семь тысяч слов.
466
+ * Он чинил витрину, а балл почти не двигался, потому что каждая находка по содержанию
467
+ * считается долей от всех измеренных страниц, и одна поправленная главная из четырёх даёт
468
+ * четверть балла.
469
+ *
470
+ * Теперь берём страницы поближе к корню и по одной из каждого раздела: это то, ради чего
471
+ * сайт сделан, и то, что человек станет чинить. Порядок строго определён, чтобы два прогона
472
+ * по одному сайту брали одни и те же страницы и балл не гулял.
473
+ */
474
+ const depth = (u) => { try { return new URL(u).pathname.replace(/\/$/, '').split('/').filter(Boolean).length; } catch { return 99; } };
475
+ const section = (u) => { try { return new URL(u).pathname.split('/').filter(Boolean)[0] || ''; } catch { return ''; } };
476
+ const candidates = sitemap.pages.filter((p) => norm(p) !== norm(home.url) && p.startsWith(origin));
477
+ const byDepth = [...candidates].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
478
+ const picks = [];
479
+ const usedSections = new Set();
480
+ for (const p of byDepth) {
481
+ if (picks.length >= samplePages) break;
482
+ const sec = section(p);
483
+ if (usedSections.has(sec)) continue;
484
+ usedSections.add(sec);
485
+ picks.push(p);
486
+ }
487
+ // Разделов может оказаться меньше, чем нужно страниц: добираем ближайшими к корню.
488
+ for (const p of byDepth) {
489
+ if (picks.length >= samplePages) break;
490
+ if (!picks.includes(p)) picks.push(p);
491
+ }
459
492
  const read = async (list) => Promise.all(list.map((p) => get(p, { timeout: Math.min(4000, left() - 300) })));
460
493
  let rs = await read(picks);
461
494
  // Одна повторная попытка по тем, что не ответили: страницы маленькие, а разница в балле
@@ -574,7 +607,16 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
574
607
  if (homePage.ogTitle) entity += 2; else entityFindings.push({ id: 'og-missing', level: 'warn', text: T.ogMissing });
575
608
 
576
609
  // Area 4: answer-first content (25)
577
- const contentPages = sampled.length ? sampled : [homePage];
610
+ /*
611
+ * Главная считается наравне с остальными.
612
+ *
613
+ * До 16.09.2026 здесь стояло `sampled.length ? sampled : [homePage]`, то есть главную мы
614
+ * смотрели только когда больше ничего не нашлось. На любом сайте с картой сайта самая важная
615
+ * страница выпадала из оценки содержания целиком: ни таблицы, ни абзац-ответ, ни объём.
616
+ * Живой пользователь добавил таблицу на главную, балл не сдвинулся, и он не понял почему.
617
+ * Понять было нельзя: мы туда не смотрели.
618
+ */
619
+ const contentPages = [homePage, ...sampled];
578
620
  /* Say so. A score built from one page is not the same measurement as one built from four. */
579
621
  const homeOnly = !sampled.length;
580
622
  const thin = contentPages.filter((p) => p.words < 300).length;
@@ -585,11 +627,18 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
585
627
  content += Math.round(10 * (1 - thin / contentPages.length));
586
628
  if (thin) contentFindings.push({ id: 'thin-pages', level: thin === contentPages.length ? 'fail' : 'warn', text: T.thinPages(thin, contentPages.length) }); else contentFindings.push({ level: 'pass', text: T.avgWords(Math.round(contentPages.reduce((a, p) => a + p.words, 0) / contentPages.length)) });
587
629
  content += Math.round(8 * (answerFirst / contentPages.length));
588
- if (answerFirst < contentPages.length) contentFindings.push({ id: 'answer-first-missing', level: answerFirst ? 'warn' : 'fail', text: T.answerFirstMissing(answerFirst, contentPages.length) }); else contentFindings.push({ level: 'pass', text: T.answerFirstOk });
630
+ // Адрес страницы в находке это разница между «полдня искали» и «поправил за минуту»: живой
631
+ // пользователь 16.09.2026 потратил полдня, чтобы понять, какая именно страница виновата.
632
+ const shortPath = (u) => { try { const x = new URL(u); return (x.pathname === '/' ? '/' : x.pathname.replace(/\/$/, '')); } catch { return u; } };
633
+ const pageList = (list) => list.map(shortPath).join(', ');
634
+ if (answerFirst < contentPages.length) contentFindings.push({ id: 'answer-first-missing', level: answerFirst ? 'warn' : 'fail', text: T.answerFirstMissing(answerFirst, contentPages.length, pageList(contentPages.filter((p) => !p.answerFirst).map((p) => p.url))) }); else contentFindings.push({ level: 'pass', text: T.answerFirstOk });
589
635
  content += Math.round(4 * (structured / contentPages.length)) + Math.round(3 * (withTables / contentPages.length));
590
636
  if (structured < contentPages.length) contentFindings.push({ id: 'few-h2', level: 'warn', text: T.fewH2(contentPages.length - structured, contentPages.length) });
591
637
  if (!withTables) contentFindings.push({ id: 'no-tables', level: 'warn', text: T.noTables });
592
638
  if (homeOnly) contentFindings.push({ level: 'warn', text: T.homeOnly });
639
+ // Какие именно страницы мы мерили. Без этой строки человек чинит витрину, видит движение балла
640
+ // на один пункт и не понимает, почему: находки считаются долей от всех измеренных страниц.
641
+ contentFindings.push({ level: 'pass', text: T.measured(pageList(contentPages.map((p) => p.url))) });
593
642
 
594
643
  // Area 5: freshness and sources (15)
595
644
  const dated = contentPages.filter((p) => p.datePublished || p.dateModified).length;