@operstack/audit 0.22.0 → 0.24.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.22.0",
3
+ "version": "0.24.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 && node src/test-ru-sources.mjs",
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
  },
@@ -33,7 +33,9 @@ is('пустой набор не даёт нуля', computeOverall([]).score, n
33
33
 
34
34
  // ---- параметры измерения зафиксированы: их расхождение и есть расхождение чисел
35
35
  is('бюджет времени один на всех', VISIBILITY_DEFAULTS.budgetMs, 8500);
36
- is('глубина выборки одна на всех', VISIBILITY_DEFAULTS.samplePages, 3);
36
+ // Девять плюс главная это десять. Число стоит на витрине словами «другие проверяют одну
37
+ // страницу, мы проверяем десять», поэтому оно обязано совпадать с тем, что движок правда читает.
38
+ is('глубина выборки одна на всех', VISIBILITY_DEFAULTS.samplePages, 9);
37
39
  ok('параметры нельзя поменять на ходу', Object.isFrozen(VISIBILITY_DEFAULTS));
38
40
 
39
41
  // ---- балл видимости сходится сам с собой
@@ -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);
@@ -1,16 +1,22 @@
1
1
  /**
2
2
  * AI visibility check: can ChatGPT, Perplexity, Copilot, Claude and Gemini read, understand
3
3
  * and cite this site? Public signals only, one homepage fetch plus robots, llms.txt, the
4
- * sitemap head and up to three sampled pages, all in parallel, under a hard time budget so it
4
+ * sitemap head and up to nine sampled pages, all in parallel, under a hard time budget so it
5
5
  * runs inside a serverless function. Plain ESM so the same module runs in Node for tests.
6
6
  *
7
+ * Почему девять, а не три. До 17.09.2026 проверка читала главную и три страницы, а на витрине мы
8
+ * собирались написать «другие проверяют одну страницу, мы проверяем десять». Это было бы неправдой:
9
+ * платный аудит читал десять, бесплатная проверка четыре. Замерено: десять страниц укладываются в
10
+ * 598 мс при бюджете 8500, потому что страницы берутся параллельно. Сайту с пятью страницами это не
11
+ * вредит: берётся столько, сколько есть, и отчёт называет настоящее число прочитанного.
12
+ *
7
13
  * The measurement is language-neutral; only the words a person reads come from the MESSAGES
8
14
  * table below, picked by the `lang` option ('en' by default, 'ru' on oper-stack.ru). Finding ids
9
15
  * and area ids never change with the language, so task lists and reports can key on them.
10
16
  *
11
17
  * This file is also vendored by the Apify actor (OperStack/apify-ai-visibility/src). The two
12
18
  * copies must stay byte for byte identical, and `npm test` in the actor fails if they drift.
13
- * That is why `samplePages` lives here although both sites leave it at three: a copy that has
19
+ * That is why `samplePages` lives here and every caller spreads VISIBILITY_DEFAULTS: a copy that has
14
20
  * to differ is a copy nobody keeps in sync, and this one had drifted by 304 lines.
15
21
  */
16
22
 
@@ -84,10 +90,11 @@ export const MESSAGES = {
84
90
  ogMissing: 'No Open Graph tags on the homepage: shared links and previews render without a title or image.',
85
91
  thinPages: (thin, total) => `${thin} of ${total} sampled page(s) hold under 300 words. Engines rarely cite thin pages.`,
86
92
  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.`,
93
+ 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
94
  answerFirstOk: 'Every sampled page opens with an answer-first paragraph carrying a figure.',
89
95
  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.',
96
+ noTables: 'No tables on the measured pages. Tables are the second most quoted format after the first paragraph.',
97
+ 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
98
  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
99
  datesMissing: (n) => `${n} sampled page(s) expose no publication or modified date. Engines prefer sources they can date.`,
93
100
  datesOk: 'Sampled pages expose publication dates.',
@@ -136,14 +143,15 @@ export const MESSAGES = {
136
143
  ? 'Единственная проверенная страница короче 300 слов. Короткие страницы цитируют редко.'
137
144
  : `Из ${total} ${ofPages(total)} ${thin} короче 300 слов. Короткие страницы цитируют редко.`),
138
145
  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}`}. Именно этот абзац попадает в цитату.`),
146
+ answerFirstMissing: (n, total, where) => (total === 1
147
+ ? `Единственная проверенная страница не начинается с абзаца-ответа (20-90 слов с цифрой сразу после H1). Именно этот абзац попадает в цитату. Чинить здесь: ${where}.`
148
+ : `Из ${total} ${ofPages(total)} с абзаца-ответа (20-90 слов с цифрой сразу после H1) ${n === 0 ? 'не начинается ни одна' : n === 1 ? 'начинается одна' : `начинаются ${n}`}. Именно этот абзац попадает в цитату. Чинить здесь: ${where}.`),
142
149
  answerFirstOk: 'Каждая проверенная страница начинается с абзаца-ответа с цифрой.',
143
150
  fewH2: (n, total) => (total === 1
144
151
  ? 'У единственной проверенной страницы меньше трёх подзаголовков H2.'
145
152
  : `У ${n} из ${total} ${ofPages(total)} меньше трёх подзаголовков H2.`),
146
153
  noTables: 'На проверенных страницах нет таблиц. Таблица второй по цитируемости формат после первого абзаца.',
154
+ measured: (list) => `Мерили по этим страницам: ${list}. Каждая находка по содержанию считается долей от них, поэтому правка на одной странице двигает балл на свою долю.`,
147
155
  homeOnly: 'Прочитать удалось только главную, поэтому эта область измерена по одной странице, а не по нескольким. Отвечающая карта сайта дала бы полную картину.',
148
156
  datesMissing: (n, total) => (total === 1
149
157
  ? 'Единственная проверенная страница не показывает дату публикации или изменения. Системы предпочитают источники, которые можно датировать.'
@@ -344,7 +352,7 @@ async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
344
352
  * в аудите и в акторе. Если где-то дать больше страниц или времени, число про один и тот же сайт
345
353
  * разойдётся, и мы вернёмся к тому, ради чего всё это затевалось.
346
354
  */
347
- export const VISIBILITY_DEFAULTS = Object.freeze({ budgetMs: 8500, samplePages: 3 });
355
+ export const VISIBILITY_DEFAULTS = Object.freeze({ budgetMs: 8500, samplePages: 9 });
348
356
 
349
357
  /**
350
358
  * Что писать владельцу сайта, который нас не пустил.
@@ -373,7 +381,7 @@ export function blockedNote({ status, challenged, blocked = true, robotsText = n
373
381
  return { text: head + tail + T.howToCheckBots, botsBlocked: closed.length > 0 };
374
382
  }
375
383
 
376
- export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', samplePages = 3 } = {}) {
384
+ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', samplePages = 9 } = {}) {
377
385
  const T = MESSAGES[lang] || MESSAGES.en;
378
386
  const agentLabel = (a) => (lang === 'ru' ? a.labelRu : a.label);
379
387
  const started = Date.now();
@@ -455,7 +463,38 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
455
463
  let wantedPages = 0;
456
464
  if (left() > 2000 && sitemap.pages.length) {
457
465
  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);
466
+ /*
467
+ * Какие страницы берём в выборку.
468
+ *
469
+ * До 16.09.2026 здесь стояла сортировка по длине адреса по убыванию, то есть наверх
470
+ * поднимались самые глубокие страницы: расшифровки, архивы, служебные разделы. Живой
471
+ * пользователь увидел это на своём сайте: в выборку попали расшифровки на семь тысяч слов.
472
+ * Он чинил витрину, а балл почти не двигался, потому что каждая находка по содержанию
473
+ * считается долей от всех измеренных страниц, и одна поправленная главная из четырёх даёт
474
+ * четверть балла.
475
+ *
476
+ * Теперь берём страницы поближе к корню и по одной из каждого раздела: это то, ради чего
477
+ * сайт сделан, и то, что человек станет чинить. Порядок строго определён, чтобы два прогона
478
+ * по одному сайту брали одни и те же страницы и балл не гулял.
479
+ */
480
+ const depth = (u) => { try { return new URL(u).pathname.replace(/\/$/, '').split('/').filter(Boolean).length; } catch { return 99; } };
481
+ const section = (u) => { try { return new URL(u).pathname.split('/').filter(Boolean)[0] || ''; } catch { return ''; } };
482
+ const candidates = sitemap.pages.filter((p) => norm(p) !== norm(home.url) && p.startsWith(origin));
483
+ const byDepth = [...candidates].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
484
+ const picks = [];
485
+ const usedSections = new Set();
486
+ for (const p of byDepth) {
487
+ if (picks.length >= samplePages) break;
488
+ const sec = section(p);
489
+ if (usedSections.has(sec)) continue;
490
+ usedSections.add(sec);
491
+ picks.push(p);
492
+ }
493
+ // Разделов может оказаться меньше, чем нужно страниц: добираем ближайшими к корню.
494
+ for (const p of byDepth) {
495
+ if (picks.length >= samplePages) break;
496
+ if (!picks.includes(p)) picks.push(p);
497
+ }
459
498
  const read = async (list) => Promise.all(list.map((p) => get(p, { timeout: Math.min(4000, left() - 300) })));
460
499
  let rs = await read(picks);
461
500
  // Одна повторная попытка по тем, что не ответили: страницы маленькие, а разница в балле
@@ -574,7 +613,16 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
574
613
  if (homePage.ogTitle) entity += 2; else entityFindings.push({ id: 'og-missing', level: 'warn', text: T.ogMissing });
575
614
 
576
615
  // Area 4: answer-first content (25)
577
- const contentPages = sampled.length ? sampled : [homePage];
616
+ /*
617
+ * Главная считается наравне с остальными.
618
+ *
619
+ * До 16.09.2026 здесь стояло `sampled.length ? sampled : [homePage]`, то есть главную мы
620
+ * смотрели только когда больше ничего не нашлось. На любом сайте с картой сайта самая важная
621
+ * страница выпадала из оценки содержания целиком: ни таблицы, ни абзац-ответ, ни объём.
622
+ * Живой пользователь добавил таблицу на главную, балл не сдвинулся, и он не понял почему.
623
+ * Понять было нельзя: мы туда не смотрели.
624
+ */
625
+ const contentPages = [homePage, ...sampled];
578
626
  /* Say so. A score built from one page is not the same measurement as one built from four. */
579
627
  const homeOnly = !sampled.length;
580
628
  const thin = contentPages.filter((p) => p.words < 300).length;
@@ -585,11 +633,18 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
585
633
  content += Math.round(10 * (1 - thin / contentPages.length));
586
634
  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
635
  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 });
636
+ // Адрес страницы в находке это разница между «полдня искали» и «поправил за минуту»: живой
637
+ // пользователь 16.09.2026 потратил полдня, чтобы понять, какая именно страница виновата.
638
+ const shortPath = (u) => { try { const x = new URL(u); return (x.pathname === '/' ? '/' : x.pathname.replace(/\/$/, '')); } catch { return u; } };
639
+ const pageList = (list) => list.map(shortPath).join(', ');
640
+ 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
641
  content += Math.round(4 * (structured / contentPages.length)) + Math.round(3 * (withTables / contentPages.length));
590
642
  if (structured < contentPages.length) contentFindings.push({ id: 'few-h2', level: 'warn', text: T.fewH2(contentPages.length - structured, contentPages.length) });
591
643
  if (!withTables) contentFindings.push({ id: 'no-tables', level: 'warn', text: T.noTables });
592
644
  if (homeOnly) contentFindings.push({ level: 'warn', text: T.homeOnly });
645
+ // Какие именно страницы мы мерили. Без этой строки человек чинит витрину, видит движение балла
646
+ // на один пункт и не понимает, почему: находки считаются долей от всех измеренных страниц.
647
+ contentFindings.push({ level: 'pass', text: T.measured(pageList(contentPages.map((p) => p.url))) });
593
648
 
594
649
  // Area 5: freshness and sources (15)
595
650
  const dated = contentPages.filter((p) => p.datePublished || p.dateModified).length;