@operstack/audit 0.17.2 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.17.2",
3
+ "version": "0.18.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",
package/src/collect.mjs CHANGED
@@ -460,7 +460,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
460
460
  for (const t of targets) {
461
461
  if (!t.url) continue;
462
462
  log(`rendering ${t.url} in a real browser`);
463
- const dom = renderedDom(t.url);
463
+ const dom = await renderedDom(t.url);
464
464
  if (!dom) { log('no browser here, the JavaScript check will say it was not measured'); break; }
465
465
  const rawHtml = t.raw || (await get(t.url)).text;
466
466
  if (!rawHtml) continue;
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } from './collect.mjs';
2
2
  // Единственная копия движка видимости. Сайты и актор зовут его отсюда, своих копий больше нет.
3
3
  export { checkVisibility, normaliseInput, parseRobots, agentVerdict, MESSAGES as VISIBILITY_MESSAGES, VISIBILITY_DEFAULTS } from './visibility.mjs';
4
- export { render, checkNarrative } from './render.mjs';
4
+ export { render, checkNarrative, overallSummary } from './render.mjs';
5
5
  export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
6
6
  export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
7
7
  export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
package/src/render.mjs CHANGED
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { computeScores, computeOverall } from './collect.mjs';
11
11
  import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
12
+ import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
12
13
  import { softenHex } from './agency.mjs';
13
14
 
14
15
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -102,6 +103,38 @@ const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a >
102
103
 
103
104
  const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
104
105
 
106
+ /**
107
+ * Балл видимости и всё, что про него надо сказать словами, одним объектом.
108
+ *
109
+ * Простым языком: это тот самый блок, который стоит на первой странице отчёта. Письмо печатает
110
+ * его же. Раньше письмо собирало свою формулировку и свою цифру, и человек получал на странице
111
+ * одно, в письме другое, а в PDF третье. Теперь текст ровно один и живёт здесь.
112
+ *
113
+ * Названия областей берутся по `id`, а не по готовой подписи из замера: замер мог пройти на
114
+ * английской странице, а отчёт уходить русскому покупателю, и тогда подпись пришла бы на чужом
115
+ * языке. Язык отчёта главнее языка замера.
116
+ *
117
+ * Возвращает null, если балла нет: движок отказывается мерить закрытые и приватные адреса, и в
118
+ * этом случае честнее не называть цифру вовсе, чем подставить ноль.
119
+ */
120
+ export function overallSummary(overall, { lang = 'en' } = {}) {
121
+ if (!overall || overall.score === null || overall.score === undefined) return null;
122
+ const key = lang === 'ru' ? 'ru' : 'en';
123
+ const L = LABELS[key];
124
+ const names = VISIBILITY_MESSAGES[key].area;
125
+ return {
126
+ label: L.overallLabel,
127
+ score: overall.score,
128
+ grade: (L.grades && L.grades[overall.grade]) || overall.grade,
129
+ note: L.overallNote(overall),
130
+ areas: (Array.isArray(overall.areas) ? overall.areas : []).map((x) => ({
131
+ id: x.id, label: (x.id && names[x.id]) || x.label, score: x.score, max: x.max,
132
+ })),
133
+ secondMeasure: L.secondMeasure,
134
+ secondMeasureFoot: L.scorecardFoot.replace(/<\/?strong>/g, ''),
135
+ };
136
+ }
137
+
105
138
  export function checkNarrative(audit) {
106
139
  const out = [];
107
140
  const walk = (v, path) => {
@@ -211,14 +244,13 @@ export function toHtml(audit, brand = null) {
211
244
  * независимое измерение по проверкам самого отчёта, а не разбивка заголовка. Сводить их с
212
245
  * заголовком не нужно и не получится, и об этом сказано словами.
213
246
  */
214
- const overall = a.overall && a.overall.score !== undefined ? a.overall : null;
215
- const overallAreas = overall && Array.isArray(overall.areas) ? overall.areas : [];
216
- const overallBlock = !overall || overall.score === null ? '' : `<div class="overall">
217
- <div class="overall-num ${scoreClass(Math.round(overall.score / 10))}">${overall.score}<span>/100</span></div>
218
- <div class="overall-side"><div class="overall-label">${esc(L.overallLabel)}</div>
219
- <div class="overall-grade">${esc((L.grades && L.grades[overall.grade]) || overall.grade)}</div>
220
- <div class="overall-note">${esc(L.overallNote(overall))}</div></div></div>`
221
- + (overallAreas.length ? `<table class="areas"><tr>${overallAreas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${overallAreas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
247
+ const head = overallSummary(a.overall, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' });
248
+ const overallBlock = !head ? '' : `<div class="overall">
249
+ <div class="overall-num ${scoreClass(Math.round(head.score / 10))}">${head.score}<span>/100</span></div>
250
+ <div class="overall-side"><div class="overall-label">${esc(head.label)}</div>
251
+ <div class="overall-grade">${esc(head.grade)}</div>
252
+ <div class="overall-note">${esc(head.note)}</div></div></div>`
253
+ + (head.areas.length ? `<table class="areas"><tr>${head.areas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${head.areas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
222
254
  const scoreCards = Object.entries(measured).map(([label, n]) => {
223
255
  const basis = scoreBasis[label] || {};
224
256
  const shown = (L.areas && L.areas[label]) || label;
package/src/rendered.mjs CHANGED
@@ -10,11 +10,31 @@
10
10
  * Браузер берётся тот же, которым печатается PDF отчёта: свой Chrome или Chromium. Если его нет,
11
11
  * проверка не выдумывает результат, а говорит, что не измеряла, и почему.
12
12
  */
13
- import { spawnSync } from 'node:child_process';
13
+ import { spawn, spawnSync } from 'node:child_process';
14
14
  import { mkdtempSync, rmSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
 
18
+ /**
19
+ * Это страница сайта или собственная страница ошибки браузера.
20
+ *
21
+ * Простым языком: браузер не смог открыть сайт и вместо него напечатал свою страницу «не удалось
22
+ * подключиться». В ней сотни слов интерфейса самого браузера. Если принять её за «как сайт
23
+ * выглядит со скриптами», получится, что у покупателя почти весь текст появляется только в
24
+ * браузере, и мы напишем ему в отчёте, что его сайт не читается без скриптов. Это была бы
25
+ * выдуманная находка по чужому сайту, худший вид ошибки в платном отчёте.
26
+ *
27
+ * Поэтому такую страницу мы не меряем вовсе и честно говорим, что не мерили.
28
+ */
29
+ export function isBrowserErrorPage(html) {
30
+ const s = String(html || '');
31
+ if (!s) return false;
32
+ // Своя страница ошибки Chrome собирается из этих кусков и ни на одном настоящем сайте их нет
33
+ // всех сразу: контейнер ошибки, скрипт neterror и копирайт Chromium в собственных стилях.
34
+ const marks = [/id="main-frame-error"/i, /neterror/i, /chrome-error:/i, /Chromium Authors/].filter((re) => re.test(s)).length;
35
+ return marks >= 2;
36
+ }
37
+
18
38
  /** Тот же поиск браузера, что у печати PDF: одна привычка на весь пакет. */
19
39
  export function findChrome() {
20
40
  const candidates = [
@@ -34,17 +54,74 @@ export function findChrome() {
34
54
  /** Текст страницы после выполнения скриптов. null, если браузера нет или он не справился. */
35
55
  export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 8000 } = {}) {
36
56
  const bin = chrome || findChrome();
37
- if (!bin) return null;
57
+ if (!bin) return Promise.resolve(null);
38
58
  const profile = mkdtempSync(join(tmpdir(), 'operstack-render-'));
39
- try {
40
- const r = spawnSync(bin, [
59
+
60
+ /*
61
+ * Почему не spawnSync с таймаутом, хотя так было и так короче.
62
+ *
63
+ * 14.09.2026 отчёт по habr.com встал на этой стадии и не двигался сорок минут при нулевой
64
+ * загрузке процессора. Таймаут spawnSync отправляет сигнал только самому Chrome, а тот
65
+ * запускает десяток вспомогательных процессов, и они наследуют тот же канал вывода. Главный
66
+ * умирает, помощники живут, канал не закрывается, и ожидание чтения не кончается никогда.
67
+ * Таймаут при этом честно сработал: висел не Chrome, висело чтение его вывода.
68
+ *
69
+ * Поэтому запускаем своей группой процессов (`detached`) и по истечении срока убиваем всю
70
+ * группу разом, отрицательным номером. Помощники уходят вместе с главным, канал закрывается,
71
+ * и стадия заканчивается в срок при любом поведении чужого сайта.
72
+ */
73
+ /** Похоже ли прочитанное на целую страницу, а не на обрывок. */
74
+ const complete = (html) => /<\/html\s*>\s*$/i.test(String(html).trimEnd());
75
+
76
+ return new Promise((resolve) => {
77
+ let out = ''; let size = 0; let done = false;
78
+ const child = spawn(bin, [
41
79
  '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
42
80
  '--disable-extensions', '--mute-audio', '--hide-scrollbars',
43
81
  `--user-data-dir=${profile}`, `--virtual-time-budget=${waitMs}`, '--dump-dom', url,
44
- ], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
45
- if (r.status !== 0 || !r.stdout) return null;
46
- return r.stdout;
47
- } catch { return null; } finally { rmSync(profile, { recursive: true, force: true }); }
82
+ ], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
83
+
84
+ const finish = (value) => {
85
+ if (done) return;
86
+ done = true;
87
+ // Страница ошибки браузера это не сайт покупателя: меряли бы интерфейс Chrome.
88
+ if (value && isBrowserErrorPage(value)) value = null;
89
+ clearTimeout(timer);
90
+ stopGroup();
91
+ rmSync(profile, { recursive: true, force: true });
92
+ resolve(value);
93
+ };
94
+ const stopGroup = () => {
95
+ // Отрицательный номер это вся группа: сам Chrome и все его помощники.
96
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* уже ушли */ }
97
+ };
98
+
99
+ // По сроку отдаём то, что успели прочитать: браузер мог допечатать страницу целиком и не
100
+ // суметь завершиться. Выбрасывать готовый DOM только потому, что Chrome не ушёл сам, значит
101
+ // сказать покупателю «не измеряли» там, где измерили.
102
+ const timer = setTimeout(() => finish(complete(out) ? out : null), timeoutMs);
103
+ child.stdout.on('data', (chunk) => {
104
+ size += chunk.length;
105
+ // Страница может оказаться огромной. Читаем до потолка и прекращаем: DOM на шестьдесят
106
+ // мегабайт всё равно не нужен, а память нужна.
107
+ if (size > 64 * 1024 * 1024) { finish(complete(out) ? out : null); return; }
108
+ out += chunk;
109
+ // Как только страница допечатана до закрывающего тега, ждать больше нечего: `--dump-dom`
110
+ // печатает разметку целиком и одним куском. Chrome после этого может не завершиться сам,
111
+ // и раньше каждая страница стоила полного срока ожидания вместо секунды.
112
+ if (complete(out)) finish(out);
113
+ });
114
+ child.on('error', () => finish(null));
115
+ /*
116
+ * Ждём именно `exit`, а не `close`.
117
+ *
118
+ * `close` ждёт, пока закроются все потоки, а канал вывода держат вспомогательные процессы
119
+ * Chrome, которые живут дольше главного. Из-за этого стадия не заканчивалась даже на
120
+ * example.com: DOM был давно прочитан, а событие не приходило. `exit` приходит, когда ушёл
121
+ * сам браузер, и этого достаточно: всё, что он собирался напечатать, уже у нас.
122
+ */
123
+ child.on('exit', (code) => finish(code === 0 && out ? out : (complete(out) ? out : null)));
124
+ });
48
125
  }
49
126
 
50
127
  const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
12
12
  import { createServer } from 'node:http';
13
13
  import { collect, computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
14
14
  import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
15
- import { toHtml } from './render.mjs';
15
+ import { toHtml, overallSummary } from './render.mjs';
16
16
 
17
17
  let bad = 0;
18
18
  const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
@@ -186,6 +186,42 @@ ok('параметры нельзя поменять на ходу', Object.isFr
186
186
  }
187
187
  }
188
188
 
189
+ // ---- один блок «балл и области» на отчёт и на письмо
190
+ //
191
+ // Письмо собирало свою формулировку и свою цифру, отчёт свою. Разошлись. Теперь обе стороны
192
+ // зовут overallSummary, и разойтись нечему: текст ровно один и живёт в пакете.
193
+ {
194
+ const areas = [{ id: 'access', label: 'Can AI crawlers read it', score: 20, max: 25 },
195
+ { id: 'index', label: 'Is there a map for agents (llms.txt)', score: 5, max: 15 },
196
+ { id: 'entity', label: 'Is the entity clear (schema)', score: 12, max: 20 },
197
+ { id: 'content', label: 'Is there something to quote', score: 18, max: 25 },
198
+ { id: 'trust', label: 'Can it be dated and trusted', score: 8, max: 15 }];
199
+ const overall = { score: 63, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
200
+
201
+ const ru = overallSummary(overall, { lang: 'ru' });
202
+ const en = overallSummary(overall, { lang: 'en' });
203
+ is('заголовок письма это заголовок отчёта', ru.score, 63);
204
+ is('сумма пяти областей даёт заголовок', ru.areas.reduce((a, x) => a + x.score, 0), ru.score);
205
+ ok('оценка словом переведена', ru.grade === 'Рабочее состояние' && en.grade === 'Workable');
206
+ const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
207
+ fixture.meta.lang = 'ru'; fixture.overall = overall;
208
+ ok('подпись та же, что печатает отчёт', ru.note === (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1]);
209
+
210
+ // Замер мог пройти на английской странице, а отчёт уходит русскому покупателю: язык отчёта главнее.
211
+ ok('названия областей на языке отчёта, а не замера', ru.areas[0].label === 'Могут ли роботы ИИ прочитать сайт');
212
+ ok('и в английском они английские', en.areas[0].label === 'Can AI crawlers read it');
213
+ ok('в русском блоке нет английских названий', !ru.areas.some((x) => /[A-Za-z]{4}/.test(x.label.replace('llms.txt', ''))));
214
+
215
+ // Шесть областей отчёта это второе измерение, и подпись обязана это говорить в обоих языках.
216
+ ok('подзаголовок второго измерения есть', /Второе, отдельное измерение/.test(ru.secondMeasure) && /second, separate measurement/.test(en.secondMeasure));
217
+ ok('и сказано, что складывать их с заголовком не надо', /в сумме его не дают/.test(ru.secondMeasureFoot) && /will not add up to it/.test(en.secondMeasureFoot));
218
+ ok('в подписи второго измерения нет разметки', !/</.test(ru.secondMeasureFoot) && !/</.test(en.secondMeasureFoot));
219
+
220
+ // Балла может не быть: движок не мерит закрытые адреса. Тогда письмо не называет цифру вовсе.
221
+ is('без балла блока нет', overallSummary({ score: null, grade: 'not measured', areas: [] }, { lang: 'ru' }), null);
222
+ is('и на пустом объекте тоже', overallSummary(null, { lang: 'en' }), null);
223
+ }
224
+
189
225
  // ---- движок в пакете это та же программа, что стоит на сайтах
190
226
  {
191
227
  const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /** Слепота к скриптам: считаем честно, не измерив, так и говорим. */
3
- import { compareReadings, jsBlindnessCheck } from './rendered.mjs';
3
+ import { chmodSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom } from './rendered.mjs';
4
7
 
5
8
  let bad = 0;
6
9
  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}`); };
@@ -57,5 +60,57 @@ ok('и сказано почему', /нет браузера/.test(none.value))
57
60
  const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
58
61
  ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
59
62
 
63
+ // ---- страница ошибки браузера это не сайт покупателя
64
+ //
65
+ // 14.09.2026 браузер не смог открыть сайт и напечатал свою страницу «не удалось подключиться»:
66
+ // сто восемьдесят килобайт интерфейса Chrome. Приняв её за «как выглядит со скриптами», мы бы
67
+ // написали покупателю в платном отчёте, что его сайт не читается без скриптов. Выдуманная
68
+ // находка по чужому сайту это худшее, что может быть в отчёте за деньги.
69
+ {
70
+ const errorPage = '<!DOCTYPE html><html dir="ltr" lang="ru"><head><style>/* Copyright 2017 The Chromium Authors */</style>'
71
+ + '<script>function neterror(){}</script></head><body><div id="main-frame-error"><span>Не удалось открыть страницу</span></div></body></html>';
72
+ ok('страница ошибки браузера распознана', isBrowserErrorPage(errorPage));
73
+ ok('настоящая страница не принята за ошибку', !isBrowserErrorPage(body(text(300))));
74
+ ok('пустой ответ не принят за ошибку', !isBrowserErrorPage(''));
75
+ // Одного совпадения мало: слово neterror может встретиться в статье про ошибки браузера.
76
+ ok('одного слова недостаточно', !isBrowserErrorPage('<html><body><p>Статья про neterror и как его читать</p></body></html>'));
77
+ }
78
+
79
+ // ---- браузер обязан отпускать, даже если сам не уходит
80
+ //
81
+ // Chrome печатает разметку и может не завершиться: его вспомогательные процессы держат канал
82
+ // вывода открытым. Отчёт по habr.com из-за этого стоял сорок минут при нулевой загрузке.
83
+ {
84
+ const dir = mkdtempSync(join(tmpdir(), 'operstack-fake-chrome-'));
85
+ const fake = (script) => {
86
+ const f = join(dir, `chrome-${Math.random().toString(36).slice(2)}.sh`);
87
+ writeFileSync(f, `#!/bin/sh\n${script}\n`);
88
+ chmodSync(f, 0o755);
89
+ return f;
90
+ };
91
+
92
+ // Напечатал страницу целиком и завис. Ждать его незачем: всё уже у нас.
93
+ const printsThenHangs = fake(`printf '%s' '<html><body><p>текст страницы</p></body></html>'\nsleep 60`);
94
+ const t0 = Date.now();
95
+ const dom = await renderedDom('https://example.com/', { chrome: printsThenHangs, timeoutMs: 10000 });
96
+ const took = Date.now() - t0;
97
+ ok('разметка получена, хотя браузер не вышел', typeof dom === 'string' && dom.includes('текст страницы'));
98
+ ok(`и получена сразу, а не по сроку (${(took / 1000).toFixed(1)} с)`, took < 5000);
99
+
100
+ // Ничего не печатает и не уходит. Здесь обязан сработать срок, и вернуться «не измеряли».
101
+ const silentHang = fake('sleep 60');
102
+ const t1 = Date.now();
103
+ const none = await renderedDom('https://example.com/', { chrome: silentHang, timeoutMs: 2500 });
104
+ const waited = Date.now() - t1;
105
+ is('молчащий браузер это «не измеряли», а не выдумка', none, null);
106
+ ok(`и ожидание кончилось в срок (${(waited / 1000).toFixed(1)} с)`, waited >= 2000 && waited < 9000);
107
+
108
+ // Напечатал свою страницу ошибки: не мерим.
109
+ const printsError = fake(`printf '%s' '<html><head><style>/* Copyright The Chromium Authors */</style></head><body><div id="main-frame-error">нет связи</div></body></html>'`);
110
+ is('страница ошибки не идёт в измерение', await renderedDom('https://example.com/', { chrome: printsError, timeoutMs: 8000 }), null);
111
+
112
+ rmSync(dir, { recursive: true, force: true });
113
+ }
114
+
60
115
  if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
61
116
  console.log('\nвсе тесты слепоты к скриптам прошли');