@operstack/audit 0.12.0 → 0.13.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 +12 -2
- package/src/i18n.mjs +8 -6
- package/src/narrative.mjs +11 -1
- package/src/test-i18n.mjs +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"LICENSE"
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
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 src/test-agent-surface.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 src/test-agent-surface.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",
|
|
31
31
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
|
|
32
32
|
},
|
|
33
33
|
"keywords": [
|
package/src/collect.mjs
CHANGED
|
@@ -3,11 +3,21 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
6
9
|
import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
|
|
7
10
|
import { parse } from 'node-html-parser';
|
|
8
11
|
import { localiseChecks } from './i18n.mjs';
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
/** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
|
|
14
|
+
* в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
|
|
15
|
+
const VERSION = (() => {
|
|
16
|
+
try { return JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version; }
|
|
17
|
+
catch { return '0'; }
|
|
18
|
+
})();
|
|
19
|
+
|
|
20
|
+
const UA = `Mozilla/5.0 (compatible; OperStackAudit/${VERSION}; +https://oper-stack.com)`;
|
|
11
21
|
|
|
12
22
|
async function get(url, { method = 'GET', timeout = 15000 } = {}) {
|
|
13
23
|
const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
|
|
@@ -413,7 +423,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
413
423
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
414
424
|
|
|
415
425
|
return {
|
|
416
|
-
meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool:
|
|
426
|
+
meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
|
|
417
427
|
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
|
|
418
428
|
scores,
|
|
419
429
|
scoreBasis,
|
package/src/i18n.mjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* audit.checks = localiseChecks(audit.checks, 'ru')
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/** Достаёт все целые и дробные числа из строки по порядку.
|
|
14
|
-
|
|
13
|
+
/** Достаёт все целые и дробные числа из строки по порядку.
|
|
14
|
+
* Сборщик пишет по-английски, и там запятая это разделитель тысяч: «median 1,600 words».
|
|
15
|
+
* Пока её принимали за дробную часть, 1600 слов превращались в 1,6 и уезжали в отчёт покупателю. */
|
|
16
|
+
const nums = (s) => (String(s).match(/\d+(?:[.,]\d{3})*(?:\.\d+)?/g) || []).map((x) => x.replace(/,(?=\d{3}\b)/g, ''));
|
|
15
17
|
/** Склонение: 1 страница, 2 страницы, 5 страниц. */
|
|
16
18
|
const plural = (n, one, few, many) => {
|
|
17
19
|
const d = Math.abs(n) % 10; const dd = Math.abs(n) % 100;
|
|
@@ -78,12 +80,12 @@ const RU = {
|
|
|
78
80
|
: `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
|
|
79
81
|
comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
|
|
80
82
|
},
|
|
81
|
-
title: { label: 'Заголовок главной', value: (v) => { const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; } },
|
|
82
|
-
description: { label: 'Описание главной', value: (v) => (/missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`) },
|
|
83
|
-
h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1
|
|
83
|
+
title: { label: 'Заголовок главной', value: (v) => { const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; }, comment: (c) => (/^short/.test(c) ? 'коротко: в заголовке нет слов, ради которых по нему кликают' : /cut in results/.test(c) ? 'в выдаче обрежется' : '') },
|
|
84
|
+
description: { label: 'Описание главной', value: (v) => (/missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`), comment: (c) => (/technical garbage/.test(c) ? 'в сниппете покажется технический мусор вместо текста' : /missing/.test(c) ? 'описания нет' : '') },
|
|
85
|
+
h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1`, comment: (c) => (/dilutes/.test(c) ? 'больше одного H1 размывает тему страницы' : /no H1/.test(c) ? 'H1 на странице нет' : '') },
|
|
84
86
|
canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
|
|
85
87
|
viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
|
|
86
|
-
og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v) },
|
|
88
|
+
og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
|
|
87
89
|
schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
|
|
88
90
|
'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`) },
|
|
89
91
|
'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
|
package/src/narrative.mjs
CHANGED
|
@@ -123,6 +123,9 @@ const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
|
|
|
123
123
|
* @param {object} audit результат collect()
|
|
124
124
|
* @param {{lang?: 'ru'|'en'}} opts
|
|
125
125
|
*/
|
|
126
|
+
/** Проверки, которые закрывает только текст клиента: у них есть своя формулировка работы. */
|
|
127
|
+
const TEXT_WORK_IDS = new Set(['thin', 'answer-first', 'sources', 'sections', 'tables']);
|
|
128
|
+
|
|
126
129
|
export function draftNarrative(audit, opts = {}) {
|
|
127
130
|
const lang = opts.lang || audit.meta?.lang || 'en';
|
|
128
131
|
const t = T[lang === 'ru' ? 'ru' : 'en'];
|
|
@@ -169,7 +172,14 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
169
172
|
return c.label;
|
|
170
173
|
};
|
|
171
174
|
const action = (c) => `${actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
|
|
172
|
-
|
|
175
|
+
// В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
|
|
176
|
+
// вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
|
|
177
|
+
// такая строка первым пунктом плана читается как отговорка, а не как задача.
|
|
178
|
+
const actionable = (c) => {
|
|
179
|
+
const rule = FIX_ACTIONS[c.id];
|
|
180
|
+
return Boolean((rule && rule.action) || TEXT_WORK_IDS.has(c.id));
|
|
181
|
+
};
|
|
182
|
+
const top = [...bad.filter(actionable), ...warn.filter(actionable), ...bad.filter((c) => !actionable(c)), ...warn.filter((c) => !actionable(c))].slice(0, 3);
|
|
173
183
|
a.summary.priorities = (a.summary.priorities || []).map((p, i) => {
|
|
174
184
|
if (!String(p).includes('{{')) return p;
|
|
175
185
|
const c = top[i];
|
package/src/test-i18n.mjs
CHANGED
|
@@ -20,7 +20,11 @@ ok('статусы не тронуты', ru.every((c, i) => c.status === all[i].
|
|
|
20
20
|
ok('идентификаторы не тронуты', ru.every((c, i) => c.id === all[i].id));
|
|
21
21
|
|
|
22
22
|
// в русском тексте не должно остаться английских слов, кроме технических имён
|
|
23
|
-
|
|
23
|
+
// Технические имена, которые обязаны остаться латиницей. Границы слова здесь не украшение:
|
|
24
|
+
// без них \bio\b съедало «io» внутри description, а Estate внутри RealEstateAgent, и тест
|
|
25
|
+
// докладывал о несуществующих английских хвостах вроде «descriptn» и «RealAgent».
|
|
26
|
+
const SCHEMA_TYPES = 'RealEstateAgent|Corporation|BreadcrumbList|WebPage|Product|Service|Place|Person|ItemList|HowTo|QAPage|NewsArticle|BlogPosting|CollectionPage|AboutPage|ContactPage|SoftwareApplication';
|
|
27
|
+
const TECH = new RegExp(`\\b(?:https?|robots\.txt|llms\.txt|sitemap|xmlrpc|HTTP|HTTPS|H1|H2|og:|twitter:|JSON-LD|FAQPage|Organization|WebSite|Article|LocalBusiness|viewport|width|initial-scale|device-width|user-scalable|maximum-scale|Disallow|User-agent|gptbot|google-extended|ccbot|anthropic-ai|claudebot|applebot-extended|facebookbot|meta-externalagent|bytespider|oai-searchbot|chatgpt-user|perplexitybot|claude-searchbot|duckassistbot|applebot|sameAs|com|ru|org|io|me|net|dev|github|npmjs|apify|producthunt|wikidata|moregroup|estate|Content|Type|Cache|Control|OperStack|SEO|AEO|GEO|AI|XML|CMS|PDF|URL|${SCHEMA_TYPES})\\b`, 'gi');
|
|
24
28
|
const leftovers = [];
|
|
25
29
|
for (const c of ru) {
|
|
26
30
|
for (const field of ['label', 'value', 'comment']) {
|
|
@@ -36,11 +40,12 @@ for (const c of ru) {
|
|
|
36
40
|
is('английских слов в русском тексте не осталось', leftovers, []);
|
|
37
41
|
|
|
38
42
|
// числа не потерялись
|
|
39
|
-
|
|
43
|
+
// Английские тысячи пишутся через запятую, русские слитно: 1,600 и 1600 это одно число.
|
|
44
|
+
const digits = (s) => (String(s).replace(/,(?=\d{3}\b)/g, '').match(/\d+/g) || []).join('|');
|
|
40
45
|
const lostNumbers = ru.filter((c, i) => {
|
|
41
46
|
const before = digits(all[i].value);
|
|
42
47
|
const after = digits(c.value);
|
|
43
|
-
return before && after && before.split('
|
|
48
|
+
return before && after && before.split('|').some((n) => !after.split('|').includes(n));
|
|
44
49
|
}).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
|
|
45
50
|
is('числа в тексте сохранились', lostNumbers, []);
|
|
46
51
|
|