@operstack/audit 0.12.0 → 0.13.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/bin/audit.mjs +0 -0
- package/package.json +2 -2
- package/src/collect.mjs +18 -3
- package/src/i18n.mjs +9 -7
- package/src/narrative.mjs +11 -1
- package/src/test-i18n.mjs +8 -3
package/bin/audit.mjs
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -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);
|
|
@@ -175,7 +185,12 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
175
185
|
log(`homepage ${startUrl}`);
|
|
176
186
|
const home = await follow(startUrl);
|
|
177
187
|
const homePage = home.response.ok ? analysePage(home.final, home.response.text) : null;
|
|
178
|
-
|
|
188
|
+
// Сайт, который не ответил, не имеет ни сертификата, ни чего-либо ещё. Пока эта строка смотрела
|
|
189
|
+
// только на схему в адресе, отчёт по несуществующему домену уверенно сообщал «certificate active».
|
|
190
|
+
const homeAnswered = Boolean(home.response.ok);
|
|
191
|
+
checks.push(homeAnswered
|
|
192
|
+
? row('https', 'technical', 'HTTPS', home.final.startsWith('https://') ? 'ok' : 'bad', home.final.startsWith('https://') ? 'certificate active' : 'site served over http')
|
|
193
|
+
: row('https', 'technical', 'HTTPS', 'bad', `the site did not answer${home.response.status ? `: HTTP ${home.response.status}` : ''}`, 'nothing below could be measured on a site that does not answer'));
|
|
179
194
|
const altHost = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
|
|
180
195
|
const alt = await follow(`${new URL(home.final).protocol}//${altHost}/`);
|
|
181
196
|
const altOk = alt.final.replace(/\/$/, '') === new URL(home.final).origin.replace(/\/$/, '') || alt.hops.some((h) => h.status >= 300 && h.status < 400);
|
|
@@ -413,7 +428,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
413
428
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
414
429
|
|
|
415
430
|
return {
|
|
416
|
-
meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool:
|
|
431
|
+
meta: { site: home.final, host, reachable: homeAnswered, 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
432
|
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
|
|
418
433
|
scores,
|
|
419
434
|
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;
|
|
@@ -41,7 +43,7 @@ export function localiseBasisNote(note) {
|
|
|
41
43
|
* пересобирая его из тех же чисел. Там, где текст постоянный, возвращается константа.
|
|
42
44
|
*/
|
|
43
45
|
const RU = {
|
|
44
|
-
https: { label: 'HTTPS', value: (v) => (/certificate active/.test(v) ? 'сертификат активен' : 'сайт отдаётся по http, без шифрования') },
|
|
46
|
+
https: { label: 'HTTPS', value: (v) => (/did not answer/.test(v) ? `сайт не ответил${/HTTP (\d+)/.exec(v) ? `: HTTP ${/HTTP (\d+)/.exec(v)[1]}` : ''}` : /certificate active/.test(v) ? 'сертификат активен' : 'сайт отдаётся по http, без шифрования'), comment: (c) => (c ? 'на сайте, который не отвечает, измерить нельзя ничего' : '') },
|
|
45
47
|
www: {
|
|
46
48
|
label: 'Склейка адреса с www',
|
|
47
49
|
value: (v) => (/redirects to/.test(v) ? 'адрес с www перенаправляется на основной'
|
|
@@ -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
|
|