@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 +2 -2
- package/src/collect.mjs +14 -1
- package/src/test-ru-sources.mjs +41 -0
- package/src/test-sample-fairness.mjs +50 -0
- package/src/visibility.mjs +57 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
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);
|
package/src/visibility.mjs
CHANGED
|
@@ -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}
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|