@operstack/audit 0.26.0 → 0.28.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/visibility.mjs +102 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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 && node src/test-sample-fairness.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 && node test-engines.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/visibility.mjs
CHANGED
|
@@ -40,6 +40,39 @@ const AI_AGENTS = [
|
|
|
40
40
|
{ agent: 'duckassistbot', label: 'DuckDuckGo AI (DuckAssistBot)', labelRu: 'DuckDuckGo AI (DuckAssistBot)', kind: 'search' },
|
|
41
41
|
];
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Готовность по каждому движку отдельно.
|
|
45
|
+
*
|
|
46
|
+
* Простым языком. «Цитируемость 20 из 25» человеку ничего не говорит, а «Gemini 61» говорит сразу.
|
|
47
|
+
* Поэтому те же самые измерения пересобираются в одно число на движок.
|
|
48
|
+
*
|
|
49
|
+
* Здесь не придумано ни одного нового замера. Число собирается ТОЛЬКО из того, что мы уже
|
|
50
|
+
* измерили, и под каждым движком видно, из чего оно сложилось. Это принципиально: конкурент
|
|
51
|
+
* показывает такие числа без объяснения, откуда они, и мы за это его же и критикуем.
|
|
52
|
+
*
|
|
53
|
+
* Формула здесь ТА ЖЕ, что у общего балла, и это принципиально. В первой версии я дал двум
|
|
54
|
+
* группам движков разные веса: у тех, кто достаёт страницу живьём, тяжелее доступ, у тех, кто
|
|
55
|
+
* отвечает из индекса, тяжелее разметка. Обоснование звучало складно, но замером не было: это
|
|
56
|
+
* было моё допущение, то есть ровно то, за что мы критикуем конкурента, который показывает такие
|
|
57
|
+
* числа без объяснения, откуда они.
|
|
58
|
+
*
|
|
59
|
+
* Поэтому веса взяты один в один из общего балла, а отличаются движки друг от друга только одним
|
|
60
|
+
* и настоящим: что robots.txt отвечает ИХ собственным роботам. У сайта, который никого не закрыл,
|
|
61
|
+
* все пять чисел совпадут с общим баллом, и это правда, а не скучная правда вместо красивой лжи.
|
|
62
|
+
*
|
|
63
|
+
* Неизмеренная область в расчёт не идёт вовсе, и оставшиеся веса делятся заново. Правило то же,
|
|
64
|
+
* что и у общего балла: не измерили, значит не считаем ни в плюс, ни в минус.
|
|
65
|
+
*/
|
|
66
|
+
const SCORE_MIX = Object.freeze({ access: 0.25, index: 0.15, entity: 0.20, content: 0.25, trust: 0.15 });
|
|
67
|
+
|
|
68
|
+
const ENGINES = [
|
|
69
|
+
{ id: 'chatgpt', label: 'ChatGPT', agents: ['oai-searchbot', 'chatgpt-user'] },
|
|
70
|
+
{ id: 'perplexity', label: 'Perplexity', agents: ['perplexitybot', 'perplexity-user'] },
|
|
71
|
+
{ id: 'claude', label: 'Claude', agents: ['claude-searchbot', 'claude-user'] },
|
|
72
|
+
{ id: 'gemini', label: 'Gemini', agents: ['google-extended'] },
|
|
73
|
+
{ id: 'copilot', label: 'Copilot', agents: ['bingbot'] },
|
|
74
|
+
];
|
|
75
|
+
|
|
43
76
|
/**
|
|
44
77
|
* Russian plural form: pl(1, ...) is "страница", pl(2, ...) "страницы", pl(5, ...) "страниц".
|
|
45
78
|
* Two shapes are needed and they differ: a bare count takes the counted form, while "из N"
|
|
@@ -316,7 +349,57 @@ function analysePage(html, url) {
|
|
|
316
349
|
const named = Boolean(blockText.match(SOURCE_RE)) || Boolean(blockText.match(ILLUSTRATIVE_RE)) || OUTBOUND_LINK_RE.test(block);
|
|
317
350
|
if (!named) unsourcedFigures += found;
|
|
318
351
|
}
|
|
319
|
-
|
|
352
|
+
// Ссылки и картинки считаются по тому же телу, из которого выброшена обвязка сайта: меню и
|
|
353
|
+
// подвал есть на каждой странице одинаково, и в свойствах страницы им делать нечего.
|
|
354
|
+
const links = (bodyHtml.match(/<a[^>]+href=/gi) || []).length;
|
|
355
|
+
const images = (bodyHtml.match(/<img[\s>]/gi) || []).length;
|
|
356
|
+
return { url, title, description, robotsMeta, canonical, ogTitle, schemaTypes: [...types], datePublished, dateModified, words, h1, h2Count, tables, links, images, firstPara: firstPara.slice(0, 220), answerFirst, claimFigures, unsourcedFigures, noai: /noai|noimageai/.test(robotsMeta) };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Сводит измеренное в одно число на движок. Возвращает и само число, и подписи под ним: какие
|
|
362
|
+
* роботы этого движка пущены, и какая из областей тянет сильнее всего вниз. Без подписей число
|
|
363
|
+
* было бы гаданием, а мы обещаем обратное.
|
|
364
|
+
*/
|
|
365
|
+
export function engineReadiness(verdicts, parts, T, label = (v) => v.label) {
|
|
366
|
+
const byAgent = new Map(verdicts.map((v) => [v.agent, v]));
|
|
367
|
+
return ENGINES.map((e) => {
|
|
368
|
+
const own = e.agents.map((a) => byAgent.get(a)).filter(Boolean);
|
|
369
|
+
const known = own.filter((v) => v.verdict !== 'unknown');
|
|
370
|
+
const share = {
|
|
371
|
+
// Доступ считается по роботам ЭТОГО движка, а не по всем четырнадцати: закрытый Applebot
|
|
372
|
+
// не мешает ChatGPT процитировать страницу. Это единственное, чем движки тут отличаются.
|
|
373
|
+
access: known.length ? known.filter((v) => v.verdict !== 'blocked').length / known.length : null,
|
|
374
|
+
index: parts.index,
|
|
375
|
+
entity: parts.entity,
|
|
376
|
+
content: parts.content,
|
|
377
|
+
trust: parts.trust,
|
|
378
|
+
};
|
|
379
|
+
let sum = 0;
|
|
380
|
+
let weight = 0;
|
|
381
|
+
for (const [key, w] of Object.entries(SCORE_MIX)) {
|
|
382
|
+
if (share[key] === null || share[key] === undefined) continue;
|
|
383
|
+
sum += share[key] * w;
|
|
384
|
+
weight += w;
|
|
385
|
+
}
|
|
386
|
+
const score = weight ? Math.round((sum / weight) * 100) : null;
|
|
387
|
+
const blocked = own.filter((v) => v.verdict === 'blocked').map(label);
|
|
388
|
+
// Что тянет вниз: самая слабая из измеренных областей, кроме доступа, о нём сказано отдельно.
|
|
389
|
+
const areasByWeak = [['content', share.content], ['trust', share.trust], ['entity', share.entity], ['index', share.index]]
|
|
390
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
391
|
+
.sort((a, b) => a[1] - b[1]);
|
|
392
|
+
const weakest = areasByWeak.length && areasByWeak[0][1] < 0.999 ? T.area[areasByWeak[0][0]] : null;
|
|
393
|
+
return {
|
|
394
|
+
id: e.id,
|
|
395
|
+
label: e.label,
|
|
396
|
+
score,
|
|
397
|
+
// Вместо рассуждений о природе движка говорим факт: чьи роботы спрошены.
|
|
398
|
+
how: own.map(label).join(', '),
|
|
399
|
+
blocked,
|
|
400
|
+
weakest,
|
|
401
|
+
};
|
|
402
|
+
});
|
|
320
403
|
}
|
|
321
404
|
|
|
322
405
|
async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
|
|
@@ -720,8 +803,25 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
720
803
|
return {
|
|
721
804
|
ok: true, url: home.url, host, checkedAt: new Date().toISOString(), ms: Date.now() - started,
|
|
722
805
|
score: total, grade, areas, fixes,
|
|
723
|
-
|
|
806
|
+
/*
|
|
807
|
+
* Свойства каждой прочитанной страницы. Заголовок, H1, подзаголовки, таблицы, ссылки и
|
|
808
|
+
* картинки считались и раньше, но наружу не отдавались, и человек видел «19 из 25», не видя,
|
|
809
|
+
* из чего это сложилось. Показать то, что уже измерено, дешевле, чем объяснять словами.
|
|
810
|
+
*/
|
|
811
|
+
sample: pages.map((p) => ({
|
|
812
|
+
url: p.url, title: p.title, h1: p.h1, words: p.words, h2: p.h2Count, tables: p.tables,
|
|
813
|
+
links: p.links, images: p.images,
|
|
814
|
+
answerFirst: p.answerFirst, dated: Boolean(p.datePublished || p.dateModified),
|
|
815
|
+
schema: p.schemaTypes.slice(0, 4),
|
|
816
|
+
})),
|
|
724
817
|
sitemap: { found: sitemap.found, count: sitemap.count, lastmod: sitemap.lastmod, unchecked: Boolean(!sitemap.found && (sitemap.timedOut || sitemap.skipped)) },
|
|
725
818
|
crawlers: verdicts.map((v) => ({ label: agentLabel(v), kind: v.kind, verdict: v.verdict })),
|
|
819
|
+
engines: engineReadiness(verdicts, {
|
|
820
|
+
access: null, // считается внутри по роботам каждого движка
|
|
821
|
+
index: llmsRead ? index / 15 : null,
|
|
822
|
+
entity: entity / 20,
|
|
823
|
+
content: sampleOk ? content / 25 : null,
|
|
824
|
+
trust: sampleOk ? trust / 15 : null,
|
|
825
|
+
}, T, agentLabel),
|
|
726
826
|
};
|
|
727
827
|
}
|