@operstack/audit 0.9.0 → 0.10.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/README.md +14 -0
- package/bin/audit.mjs +11 -3
- package/package.json +4 -3
- package/src/collect.mjs +21 -1
- package/src/fix.mjs +1 -0
- package/src/prompts.mjs +4 -0
- package/src/rendered.mjs +103 -0
- package/src/test-rendered.mjs +61 -0
package/README.md
CHANGED
|
@@ -46,6 +46,20 @@ An agent can fill these fields from the JSON: the Claude Code plugin `operstack-
|
|
|
46
46
|
|
|
47
47
|
Ten A4 pages: cover, executive summary with a scorecard, site overview with the sampled pages, critical issues, technical and on-page checklist, content and structure, AEO and GEO, off-page and conversion and limitations, roadmap in three phases, closing message. HTML always; PDF with `--pdf` when Chrome or Chromium is installed (`CHROME_PATH` overrides the lookup).
|
|
48
48
|
|
|
49
|
+
## What an AI does not see: the JavaScript check
|
|
50
|
+
|
|
51
|
+
**In plain words.** Some sites draw themselves with scripts. A person opens the page and sees the
|
|
52
|
+
full text; a robot that does not run scripts sees an empty frame. The robots that pull a page into
|
|
53
|
+
an AI answer are exactly that kind of robot. So the owner reads his own page, sees plenty of text,
|
|
54
|
+
and cannot understand why nobody quotes it. This check reads the page twice, as a plain request and
|
|
55
|
+
in a real browser, and tells you how much of the text only exists in the second reading.
|
|
56
|
+
|
|
57
|
+
**What it does.** `collect` runs Chrome or Chromium over the homepage and two sampled pages, strips
|
|
58
|
+
the navigation and footer from both readings, and compares the word counts. Forty percent hidden, or
|
|
59
|
+
a page that is nearly empty without scripts, fails. Ten to forty warns. Under ten passes. With no
|
|
60
|
+
browser installed the check says it was not measured and why. `--no-rendered` skips it,
|
|
61
|
+
`--rendered-pages N` changes how many pages are opened; each page costs roughly half a minute.
|
|
62
|
+
|
|
49
63
|
## Writing the report: `draft`
|
|
50
64
|
|
|
51
65
|
**If you have never done SEO, start here.** An audit tells you what a search engine and an AI see when they open your site. This tool writes that report for you. It does not guess anything about your business: every sentence in it comes from something that was measured on your pages, and where nothing could be measured it says so instead of filling the gap with words. You read it, correct what only you know, and send it.
|
package/bin/audit.mjs
CHANGED
|
@@ -40,8 +40,9 @@ const has = (k) => rest.includes(k);
|
|
|
40
40
|
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
41
41
|
console.log([
|
|
42
42
|
'Usage:',
|
|
43
|
-
' operstack-audit collect <url> [--pages 20] [--out audit.json] [--
|
|
44
|
-
' collect the public signals of a site into audit.json',
|
|
43
|
+
' operstack-audit collect <url> [--pages 20] [--out audit.json] [--lang ru] [--no-rendered]',
|
|
44
|
+
' collect the public signals of a site into audit.json; a real browser also measures how',
|
|
45
|
+
' much of the text is invisible without JavaScript (--no-rendered skips that)',
|
|
45
46
|
' operstack-audit render <audit.json> [--out report.html] [--pdf]',
|
|
46
47
|
' render the report; --pdf also prints a PDF with headless Chrome',
|
|
47
48
|
' operstack-audit draft <audit.json> [--lang ru|en] [--out audit.json]',
|
|
@@ -64,7 +65,14 @@ if (cmd === 'collect') {
|
|
|
64
65
|
if (!/^https?:\/\//.test(target || '')) { console.error('collect needs an absolute URL'); process.exit(2); }
|
|
65
66
|
const backlinksFile = opt('--backlinks', '');
|
|
66
67
|
const backlinks = backlinksFile ? JSON.parse(readFileSync(resolve(backlinksFile), 'utf8')) : null;
|
|
67
|
-
|
|
68
|
+
// Проверка «виден ли текст без скриптов» требует браузера и добавляет полминуты на страницу.
|
|
69
|
+
// В платном аудите она нужна всегда: именно здесь мы можем соврать про чужой сайт. Выключается
|
|
70
|
+
// флагом --no-rendered, например когда браузера нет или прогон идёт по расписанию.
|
|
71
|
+
const audit = await collect(target, {
|
|
72
|
+
pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'),
|
|
73
|
+
rendered: !has('--no-rendered'), renderedPages: Number(opt('--rendered-pages', 3)),
|
|
74
|
+
log: (m) => console.error(` ${m}`),
|
|
75
|
+
});
|
|
68
76
|
const out = resolve(opt('--out', 'audit.json'));
|
|
69
77
|
writeFileSync(out, JSON.stringify(audit, null, 2));
|
|
70
78
|
console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"./fix": "./src/fix.mjs",
|
|
16
16
|
"./foundation": "./src/foundation.mjs",
|
|
17
17
|
"./narrative": "./src/narrative.mjs",
|
|
18
|
-
"./prompts": "./src/prompts.mjs"
|
|
18
|
+
"./prompts": "./src/prompts.mjs",
|
|
19
|
+
"./rendered": "./src/rendered.mjs"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"bin",
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
|
-
"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 bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
|
|
30
|
+
"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-rendered.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
|
|
30
31
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
|
|
31
32
|
},
|
|
32
33
|
"keywords": [
|
package/src/collect.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* robots.txt, sitemaps, llms.txt, the homepage and a sample of pages from the sitemap.
|
|
4
4
|
* No Search Console, no analytics. The narrative fields are left for the analyst.
|
|
5
5
|
*/
|
|
6
|
+
import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
|
|
6
7
|
import { parse } from 'node-html-parser';
|
|
7
8
|
import { localiseChecks } from './i18n.mjs';
|
|
8
9
|
|
|
@@ -162,7 +163,7 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
|
|
|
162
163
|
return out;
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en' } = {}) {
|
|
166
|
+
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3 } = {}) {
|
|
166
167
|
const opts = { backlinks };
|
|
167
168
|
const origin = new URL(startUrl).origin;
|
|
168
169
|
const host = new URL(startUrl).host;
|
|
@@ -335,6 +336,25 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
335
336
|
|
|
336
337
|
const { scores, scoreBasis } = computeScores(checks);
|
|
337
338
|
// Оценки считаются до перевода: язык на цифры не влияет.
|
|
339
|
+
// Сколько текста не видно без выполнения скриптов. Меряется настоящим браузером и только по
|
|
340
|
+
// просьбе: запуск браузера это десятки секунд на страницу, и еженедельному мониторингу это ни к
|
|
341
|
+
// чему. Для платного аудита включено, потому что именно здесь мы можем соврать про чужой сайт.
|
|
342
|
+
if (rendered) {
|
|
343
|
+
const targets = [{ url: home.final, raw: home.response.ok ? home.response.text : '' },
|
|
344
|
+
...good.filter((p) => p.url !== home.final).slice(0, Math.max(0, renderedPages - 1)).map((p) => ({ url: p.url, raw: '' }))];
|
|
345
|
+
const measured = [];
|
|
346
|
+
for (const t of targets) {
|
|
347
|
+
if (!t.url) continue;
|
|
348
|
+
log(`rendering ${t.url} in a real browser`);
|
|
349
|
+
const dom = renderedDom(t.url);
|
|
350
|
+
if (!dom) { log('no browser here, the JavaScript check will say it was not measured'); break; }
|
|
351
|
+
const rawHtml = t.raw || (await get(t.url)).text;
|
|
352
|
+
if (!rawHtml) continue;
|
|
353
|
+
measured.push({ url: t.url, ...compareReadings(rawHtml, dom) });
|
|
354
|
+
}
|
|
355
|
+
checks.push(jsBlindnessCheck(measured, lang));
|
|
356
|
+
}
|
|
357
|
+
|
|
338
358
|
const localised = localiseChecks(checks, lang);
|
|
339
359
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
340
360
|
|
package/src/fix.mjs
CHANGED
|
@@ -66,6 +66,7 @@ export const FIX_ACTIONS = {
|
|
|
66
66
|
thin: { scope: 'client', why: { ru: 'наполнение тонких страниц это тексты по фактам клиента', en: 'filling thin pages means writing from your facts' } },
|
|
67
67
|
sections: { scope: 'client', why: { ru: 'разбивка на разделы это переписывание текста', en: 'splitting into sections means rewriting the text' } },
|
|
68
68
|
tables: { scope: 'client', why: { ru: 'таблица собирается из данных клиента', en: 'a table is built from your data' } },
|
|
69
|
+
'js-content': { scope: 'files', action: { ru: 'Отдавать текст страницы сразу в HTML, а не дорисовывать его скриптами: предрендер или серверная отрисовка тех страниц, где текст сейчас появляется только в браузере', en: 'Serve the page text in the HTML itself instead of drawing it with scripts: prerender or server-render the pages whose text currently appears only in a browser' } },
|
|
69
70
|
'links-profile': { scope: 'never', why: { ru: 'данные по ссылкам платные и ни в одну оценку не входят', en: 'link data is paid, and it moves no score of ours' } },
|
|
70
71
|
};
|
|
71
72
|
|
package/src/prompts.mjs
CHANGED
|
@@ -94,6 +94,10 @@ const P = {
|
|
|
94
94
|
ru: { task: 'Пройди по абзацам, где есть цифры, и подпиши к каждой, откуда она: ссылкой на первоисточник или словами («по данным реестра за 2026 год»). Если источника нет ни у тебя, ни у владельца, цифру надо убрать, а не оставлять голой. Не ставь ссылку наугад: неверный источник хуже отсутствующего.', verify: 'В каждом абзаце с цифрой есть либо ссылка на чужой сайт, либо названный словами источник.' },
|
|
95
95
|
en: { task: 'Go through the paragraphs carrying figures and name where each came from: a link to the primary source, or words ("per the 2026 registry"). If neither you nor the owner has a source, the figure comes out rather than standing bare. Do not guess a link: a wrong source is worse than none.', verify: 'Every paragraph with a figure carries either an outbound link or a source named in words.' },
|
|
96
96
|
},
|
|
97
|
+
'js-content': {
|
|
98
|
+
ru: { task: 'Найди страницы, где основной текст появляется только после выполнения скриптов, и сделай так, чтобы он отдавался сразу в HTML: предрендер при сборке или серверная отрисовка. Проверить просто: открой адрес страницы командой curl и посмотри, есть ли в ответе текст статьи. Не прячь текст в атрибуты и не дублируй его скрытым блоком ради роботов: это считается обманом и наказывается.', verify: 'В ответе на обычный запрос без браузера виден весь основной текст страницы, а не только каркас.' },
|
|
99
|
+
en: { task: 'Find the pages whose main text appears only after scripts run, and make that text arrive in the HTML itself: prerender at build time or render on the server. Checking is simple: fetch the URL with curl and see whether the article text is in the response. Do not hide the text in attributes and do not duplicate it in a hidden block for robots: that counts as cloaking and is punished.', verify: 'A plain request without a browser returns the whole main text of the page, not just the shell.' },
|
|
100
|
+
},
|
|
97
101
|
thin: {
|
|
98
102
|
ru: { task: 'Возьми страницы короче трёхсот слов и допиши их фактами, которые знает владелец: условия, цены, сроки, ограничения, частые вопросы покупателей с ответами. Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов и оставь страницу как есть.', verify: 'Страница стала длиннее трёхсот слов, и каждый добавленный абзац содержит факт, а не общие слова.' },
|
|
99
103
|
en: { task: 'Take the pages under three hundred words and fill them with facts the owner has: terms, prices, timings, limits, the questions buyers actually ask with the answers. Do not pad for length. Where the facts are missing, write the owner a list of questions and leave the page alone.', verify: 'The page is over three hundred words and every added paragraph carries a fact rather than filler.' },
|
package/src/rendered.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сколько текста страницы не видит тот, кто не выполняет JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* Человеческими словами: если сайт рисует себя скриптами, то поисковый робот и особенно робот,
|
|
5
|
+
* который достаёт страницу для ответа ИИ, могут увидеть пустую страницу. Владелец при этом видит
|
|
6
|
+
* полный текст и не понимает, почему его не цитируют. Здесь это измеряется прямо: страница
|
|
7
|
+
* читается дважды, как обычный запрос без скриптов и как настоящий браузер, и считается, какая
|
|
8
|
+
* доля текста появляется только во втором случае.
|
|
9
|
+
*
|
|
10
|
+
* Браузер берётся тот же, которым печатается PDF отчёта: свой Chrome или Chromium. Если его нет,
|
|
11
|
+
* проверка не выдумывает результат, а говорит, что не измеряла, и почему.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Тот же поиск браузера, что у печати PDF: одна привычка на весь пакет. */
|
|
19
|
+
export function findChrome() {
|
|
20
|
+
const candidates = [
|
|
21
|
+
process.env.CHROME_PATH,
|
|
22
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
23
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
24
|
+
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
25
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
26
|
+
].filter(Boolean);
|
|
27
|
+
for (const c of candidates) {
|
|
28
|
+
const r = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 8000 });
|
|
29
|
+
if (r.status === 0) return c;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Текст страницы после выполнения скриптов. null, если браузера нет или он не справился. */
|
|
35
|
+
export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 8000 } = {}) {
|
|
36
|
+
const bin = chrome || findChrome();
|
|
37
|
+
if (!bin) return null;
|
|
38
|
+
const profile = mkdtempSync(join(tmpdir(), 'operstack-render-'));
|
|
39
|
+
try {
|
|
40
|
+
const r = spawnSync(bin, [
|
|
41
|
+
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
|
42
|
+
'--disable-extensions', '--mute-audio', '--hide-scrollbars',
|
|
43
|
+
`--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 }); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
|
|
51
|
+
const plain = (html) => String(html)
|
|
52
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
53
|
+
.replace(CHROME_STRIP, ' ')
|
|
54
|
+
.replace(/<[^>]+>/g, ' ')
|
|
55
|
+
.replace(/ /g, ' ')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
58
|
+
const countWords = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Сравнение двух чтений одной страницы.
|
|
62
|
+
* @returns {{raw:number, rendered:number, hiddenShare:number, verdict:'ok'|'warn'|'bad'}}
|
|
63
|
+
*/
|
|
64
|
+
export function compareReadings(rawHtml, renderedHtml) {
|
|
65
|
+
const raw = countWords(plain(rawHtml));
|
|
66
|
+
const rendered = countWords(plain(renderedHtml));
|
|
67
|
+
// Отрицательной разницы не бывает: если браузер увидел меньше, значит он не досчитал, а не
|
|
68
|
+
// страница уменьшилась. Такой случай считаем нулевой разницей, а не отрицательной долей.
|
|
69
|
+
const extra = Math.max(0, rendered - raw);
|
|
70
|
+
const hiddenShare = rendered > 0 ? Math.round((extra / rendered) * 100) : 0;
|
|
71
|
+
const verdict = hiddenShare >= 40 || (raw < 50 && rendered >= 200) ? 'bad' : hiddenShare >= 10 ? 'warn' : 'ok';
|
|
72
|
+
return { raw, rendered, hiddenShare, verdict };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const WORD = { ru: (n) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'слово' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'слова' : 'слов'}` };
|
|
76
|
+
|
|
77
|
+
/** Строка проверки для аудита. Текст пишется так, чтобы владелец понял, чем ему это грозит. */
|
|
78
|
+
export function jsBlindnessCheck(pages, lang = 'en') {
|
|
79
|
+
const measured = pages.filter((p) => p && p.rendered > 0);
|
|
80
|
+
if (!measured.length) {
|
|
81
|
+
return {
|
|
82
|
+
id: 'js-content', group: 'technical',
|
|
83
|
+
label: lang === 'ru' ? 'Виден ли текст без выполнения скриптов' : 'Text visible without running JavaScript',
|
|
84
|
+
status: 'na',
|
|
85
|
+
value: lang === 'ru' ? 'не измеряли: на этой машине нет браузера Chrome или Chromium' : 'not measured: no Chrome or Chromium on this machine',
|
|
86
|
+
comment: lang === 'ru' ? 'проверка сравнивает страницу, прочитанную без скриптов, с ней же в настоящем браузере' : 'the check compares the page read without scripts against the same page in a real browser',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const worst = measured.slice().sort((a, b) => b.hiddenShare - a.hiddenShare)[0];
|
|
90
|
+
const median = measured.map((p) => p.hiddenShare).sort((a, b) => a - b)[Math.floor(measured.length / 2)];
|
|
91
|
+
const status = measured.some((p) => p.verdict === 'bad') ? 'bad' : measured.some((p) => p.verdict === 'warn') ? 'warn' : 'ok';
|
|
92
|
+
const value = lang === 'ru'
|
|
93
|
+
? `без скриптов видно всё, кроме ${median} процентов текста (медиана по ${measured.length} ${measured.length === 1 ? 'странице' : 'страницам'}), хуже всего ${worst.url}: там скрыто ${worst.hiddenShare} процентов, ${WORD.ru(worst.raw)} без скриптов против ${WORD.ru(worst.rendered)} в браузере`
|
|
94
|
+
: `${median}% of the text is invisible without JavaScript (median over ${measured.length} page${measured.length === 1 ? '' : 's'}); worst is ${worst.url} at ${worst.hiddenShare}%, ${worst.raw} words without scripts against ${worst.rendered} in a browser`;
|
|
95
|
+
// Слова по размеру беды: на четырнадцати процентах писать «страница почти пустая» это враньё
|
|
96
|
+
// в ту же сторону, от которого мы уходим.
|
|
97
|
+
const comment = status === 'ok'
|
|
98
|
+
? (lang === 'ru' ? 'страницы читаются целиком и без браузера, это то, что нужно поиску и ИИ' : 'the pages read whole without a browser, which is what search and AI need')
|
|
99
|
+
: status === 'bad'
|
|
100
|
+
? (lang === 'ru' ? 'для простых роботов поиска и для роботов, которые достают страницу в ответ ИИ, эта страница почти пустая: они не выполняют скрипты' : 'to simple search crawlers and to the fetchers that pull a page into an AI answer this page is nearly empty: they do not run scripts')
|
|
101
|
+
: (lang === 'ru' ? 'эта часть текста не доходит до роботов, которые не выполняют скрипты, а именно они приносят страницу в ответ ИИ' : 'this part of the text does not reach the robots that do not run scripts, and those are the ones that bring a page into an AI answer');
|
|
102
|
+
return { id: 'js-content', group: 'technical', label: lang === 'ru' ? 'Виден ли текст без выполнения скриптов' : 'Text visible without running JavaScript', status, value, comment };
|
|
103
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Слепота к скриптам: считаем честно, не измерив, так и говорим. */
|
|
3
|
+
import { compareReadings, jsBlindnessCheck } from './rendered.mjs';
|
|
4
|
+
|
|
5
|
+
let bad = 0;
|
|
6
|
+
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}`); };
|
|
7
|
+
const ok = (n, c) => is(n, Boolean(c), true);
|
|
8
|
+
|
|
9
|
+
const text = (n) => '<p>' + 'слово '.repeat(n) + '</p>';
|
|
10
|
+
const body = (inner) => `<html><body>${inner}</body></html>`;
|
|
11
|
+
|
|
12
|
+
is('одинаковые чтения дают ноль скрытого', compareReadings(body(text(300)), body(text(300))).hiddenShare, 0);
|
|
13
|
+
is('и вердикт «в порядке»', compareReadings(body(text(300)), body(text(300))).verdict, 'ok');
|
|
14
|
+
|
|
15
|
+
const empty = compareReadings(body('<div id="root"></div>'), body(text(300)));
|
|
16
|
+
is('пустой каркас против полной страницы это сто процентов', empty.hiddenShare, 100);
|
|
17
|
+
is('и это приговор', empty.verdict, 'bad');
|
|
18
|
+
|
|
19
|
+
const half = compareReadings(body(text(100)), body(text(200)));
|
|
20
|
+
is('половина текста за скриптами считается верно', half.hiddenShare, 50);
|
|
21
|
+
is('и это тоже приговор', half.verdict, 'bad');
|
|
22
|
+
|
|
23
|
+
is('пять процентов за скриптами это ещё в порядке', compareReadings(body(text(285)), body(text(300))).verdict, 'ok');
|
|
24
|
+
const tenth = compareReadings(body(text(270)), body(text(300)));
|
|
25
|
+
is('десятая часть текста за скриптами это уже предупреждение', tenth.verdict, 'warn');
|
|
26
|
+
is('и доля посчитана', tenth.hiddenShare, 10);
|
|
27
|
+
|
|
28
|
+
// Меню и подвал не считаются: их и так никто не цитирует
|
|
29
|
+
const chrome = compareReadings(body('<nav>меню меню меню</nav>' + text(100)), body('<nav>меню меню меню другое</nav>' + text(100)));
|
|
30
|
+
is('разница только в меню не считается скрытым текстом', chrome.hiddenShare, 0);
|
|
31
|
+
|
|
32
|
+
// Браузер увидел меньше: это его беда, а не отрицательная доля
|
|
33
|
+
is('отрицательной доли не бывает', compareReadings(body(text(300)), body(text(100))).hiddenShare, 0);
|
|
34
|
+
|
|
35
|
+
// Строка проверки
|
|
36
|
+
const good = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' }], 'ru');
|
|
37
|
+
is('чистый сайт получает «ok»', good.status, 'ok');
|
|
38
|
+
ok('и понятный текст', /читаются целиком/.test(good.comment));
|
|
39
|
+
|
|
40
|
+
const worst = jsBlindnessCheck([
|
|
41
|
+
{ url: 'https://x.ru/a', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' },
|
|
42
|
+
{ url: 'https://x.ru/b', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' },
|
|
43
|
+
], 'ru');
|
|
44
|
+
is('одна плохая страница делает проверку проваленной', worst.status, 'bad');
|
|
45
|
+
ok('в тексте названа худшая страница', worst.value.includes('https://x.ru/b'));
|
|
46
|
+
ok('и сказано, сколько слов видно без скриптов', /10 слов без скриптов против 400/.test(worst.value));
|
|
47
|
+
ok('приговор говорит про роботов ИИ', /достают страницу в ответ ИИ/.test(worst.comment));
|
|
48
|
+
|
|
49
|
+
const mild = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 86, rendered: 100, hiddenShare: 14, verdict: 'warn' }], 'ru');
|
|
50
|
+
ok('на четырнадцати процентах не пишем «страница почти пустая»', !/почти пустая/.test(mild.comment));
|
|
51
|
+
ok('а пишем, что часть текста не доходит', /не доходит/.test(mild.comment));
|
|
52
|
+
|
|
53
|
+
const none = jsBlindnessCheck([], 'ru');
|
|
54
|
+
is('без браузера статус «не измеряли»', none.status, 'na');
|
|
55
|
+
ok('и сказано почему', /нет браузера/.test(none.value));
|
|
56
|
+
|
|
57
|
+
const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
|
|
58
|
+
ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
|
|
59
|
+
|
|
60
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
61
|
+
console.log('\nвсе тесты слепоты к скриптам прошли');
|