@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 CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.12.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
- const UA = 'Mozilla/5.0 (compatible; OperStackAudit/0.1; +https://oper-stack.com)';
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
- checks.push(row('https', 'technical', 'HTTPS', home.final.startsWith('https://') ? 'ok' : 'bad', home.final.startsWith('https://') ? 'certificate active' : 'site served over http'));
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: '@operstack/audit 0.1.0', auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
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
- const nums = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => x.replace(',', '.'));
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
- const top = [...bad, ...warn].slice(0, 3);
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
- const TECH = /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/gi;
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
- const digits = (s) => (String(s).match(/\d+/g) || []).join(',');
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(',').some((n) => !after.split(',').includes(n));
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