@operstack/audit 0.18.0 → 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.18.0",
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/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;
@@ -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все тесты слепоты к скриптам прошли');