@operstack/audit 0.15.4 → 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.15.4",
3
+ "version": "0.16.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",
@@ -28,7 +28,7 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "scripts": {
31
- "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-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",
31
+ "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-rendered.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
32
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
33
33
  },
34
34
  "keywords": [
package/src/collect.mjs CHANGED
@@ -19,7 +19,11 @@ const VERSION = (() => {
19
19
 
20
20
  const UA = `Mozilla/5.0 (compatible; OperStackAudit/${VERSION}; +https://oper-stack.com)`;
21
21
 
22
- async function get(url, { method = 'GET', timeout = 15000 } = {}) {
22
+ /** Таймаут запроса раньше был зашит числом и наружу не выводился, поэтому поднять его со стороны
23
+ * очереди писем было нельзя. Теперь это значение по умолчанию, которое collect() выставляет из опций. */
24
+ let REQUEST_TIMEOUT = 15000;
25
+
26
+ async function get(url, { method = 'GET', timeout = REQUEST_TIMEOUT } = {}) {
23
27
  const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
24
28
  const started = Date.now();
25
29
  try {
@@ -161,21 +165,49 @@ function analysePage(url, html) {
161
165
  return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
162
166
  }
163
167
 
164
- async function readSitemap(url, seen = new Set(), depth = 0) {
165
- if (seen.has(url) || depth > 3) return [];
166
- seen.add(url);
167
- const r = await get(url);
168
- if (!r.ok) return [];
169
- const locs = [...r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
170
- const out = [];
171
- for (const l of locs) {
172
- if (/\.xml(\.gz)?$/i.test(l) || /sitemap/i.test(l) && !/\.(html?|php)$/i.test(l) && r.text.includes('<sitemapindex')) out.push(...(await readSitemap(l, seen, depth + 1)));
173
- else out.push(l);
168
+ /** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
169
+ * агентства недвижимости в sitemap.xml лежит 79 вложенных карт. Раньше мы заходили в каждую без
170
+ * ограничений, по 15 секунд на запрос, и бесплатная проверка упиралась в таймаут ещё до того, как
171
+ * прочитает хоть одну страницу: отчёт приходил пустым или не приходил вовсе.
172
+ *
173
+ * Поэтому три ограничителя: сколько файлов читаем, сколько всего на это тратим и сколько адресов
174
+ * набираем. Для выборки в двадцать страниц первых файлов хватает с запасом.
175
+ *
176
+ * Если остановились раньше, это возвращается наружу и попадает в отчёт словами. Выдать часть за
177
+ * целое нельзя: «1 200 адресов в карте сайта» на сайте с сорока тысячами адресов это неверное
178
+ * измерение, а не округление. */
179
+ async function readSitemap(startUrl, { maxFiles = 6, budgetMs = 45000, maxUrls = 5000, timeout } = {}) {
180
+ const deadline = Date.now() + budgetMs;
181
+ const seen = new Set();
182
+ const queue = [{ url: startUrl, depth: 0 }];
183
+ const urls = [];
184
+ let filesRead = 0; let nestedFound = 0; let stopped = '';
185
+ while (queue.length) {
186
+ if (filesRead >= maxFiles) { stopped = 'files'; break; }
187
+ if (Date.now() > deadline) { stopped = 'time'; break; }
188
+ if (urls.length >= maxUrls) { stopped = 'urls'; break; }
189
+ const { url, depth } = queue.shift();
190
+ if (seen.has(url) || depth > 3) continue;
191
+ seen.add(url);
192
+ const r = await get(url, timeout ? { timeout } : {});
193
+ filesRead++;
194
+ if (!r.ok) continue;
195
+ // Спускаться вглубь можно только из индекса карт. Прежнее условие из-за приоритета операторов
196
+ // читало `A || (B && C && D)` и уходило в рекурсию по любой ссылке на .xml, даже из обычного urlset.
197
+ const isIndex = /<sitemapindex/i.test(r.text);
198
+ for (const m of r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
199
+ const l = m[1];
200
+ if (isIndex && (/\.xml(\.gz)?$/i.test(l) || (/sitemap/i.test(l) && !/\.(html?|php)$/i.test(l)))) { nestedFound += 1; queue.push({ url: l, depth: depth + 1 }); }
201
+ else urls.push(l);
202
+ }
174
203
  }
175
- return out;
204
+ return { urls, filesRead, nestedFound, pending: queue.length, stopped };
176
205
  }
177
206
 
178
- export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '' } = {}) {
207
+ export const __readSitemap = readSitemap;
208
+
209
+ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 6 } = {}) {
210
+ REQUEST_TIMEOUT = requestTimeout;
179
211
  const opts = { backlinks };
180
212
  const origin = new URL(startUrl).origin;
181
213
  const host = new URL(startUrl).host;
@@ -226,12 +258,22 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
226
258
  log('sitemaps');
227
259
  const candidates = sitemapUrls.length ? sitemapUrls : [`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`];
228
260
  let sitemapPages = []; let sitemapStatus = 'missing'; let sitemapSource = '';
261
+ let sitemapRead = { filesRead: 0, nestedFound: 0, pending: 0, stopped: '' };
229
262
  for (const u of candidates) {
230
263
  const r = await get(u);
231
- if (r.ok && /<(urlset|sitemapindex)/.test(r.text)) { sitemapPages = await readSitemap(u); sitemapStatus = 'ok'; sitemapSource = u; break; }
264
+ if (r.ok && /<(urlset|sitemapindex)/.test(r.text)) {
265
+ sitemapRead = await readSitemap(u, { budgetMs: sitemapBudgetMs, maxFiles: sitemapMaxFiles });
266
+ sitemapPages = sitemapRead.urls; sitemapStatus = 'ok'; sitemapSource = u; break;
267
+ }
232
268
  if (sitemapUrls.includes(u)) { sitemapStatus = `HTTP ${r.status}`; sitemapSource = u; }
233
269
  }
234
- checks.push(row('sitemap', 'technical', 'XML sitemap', sitemapStatus === 'ok' ? 'ok' : 'bad', sitemapStatus === 'ok' ? `${sitemapPages.length} URL(s) in ${sitemapSource}` : sitemapSource ? `${sitemapSource} answers ${sitemapStatus}` : 'no sitemap found at the usual paths', sitemapStatus !== 'ok' && sitemapUrls.length ? 'robots.txt points at a sitemap that does not answer' : ''));
270
+ // Когда обход остановлен ограничителем, в отчёте стоит число прочитанного, а не выдуманное целое.
271
+ const sitemapValue = sitemapStatus !== 'ok'
272
+ ? (sitemapSource ? `${sitemapSource} answers ${sitemapStatus}` : 'no sitemap found at the usual paths')
273
+ : sitemapRead.stopped
274
+ ? `${sitemapPages.length} URL(s) read from ${sitemapRead.filesRead} of ${sitemapRead.filesRead + sitemapRead.pending} sitemap file(s) in ${sitemapSource}`
275
+ : `${sitemapPages.length} URL(s) in ${sitemapSource}`;
276
+ checks.push(row('sitemap', 'technical', 'XML sitemap', sitemapStatus === 'ok' ? 'ok' : 'bad', sitemapValue, sitemapStatus !== 'ok' && sitemapUrls.length ? 'robots.txt points at a sitemap that does not answer' : sitemapRead.stopped ? 'a large nested sitemap: the pages below were sampled from the files that were read, not from every file' : ''));
235
277
  const foreign = sitemapPages.filter((u) => { try { return new URL(u).host !== host && new URL(u).host !== altHost; } catch { return true; } });
236
278
  if (foreign.length) checks.push(row('sitemap-foreign', 'technical', 'Sitemap lists other hosts', 'bad', `${foreign.length} URL(s) on other hosts, e.g. ${foreign[0]}`));
237
279
 
@@ -407,6 +449,10 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
407
449
  checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
408
450
 
409
451
  const { scores, scoreBasis } = computeScores(checks);
452
+
453
+ // Один балл на весь отчёт: его же берут страница проверки и письмо, чтобы цифры не спорили.
454
+
455
+ const overall = computeOverall(checks);
410
456
  // Оценки считаются до перевода: язык на цифры не влияет.
411
457
  // Сколько текста не видно без выполнения скриптов. Меряется настоящим браузером и только по
412
458
  // просьбе: запуск браузера это десятки секунд на страницу, и еженедельному мониторингу это ни к
@@ -433,6 +479,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
433
479
  return {
434
480
  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 || '') : '' },
435
481
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
482
+ overall,
436
483
  scores,
437
484
  scoreBasis,
438
485
  summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
@@ -498,14 +545,45 @@ function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? s
498
545
  * "not measured", never as a low score. Nothing in a report may be scored by hand.
499
546
  */
500
547
  export const SCORE_AREAS = [
501
- { label: 'SEO, technical', groups: ['technical'] },
502
- { label: 'SEO, content and structure', groups: ['onpage', 'content'] },
503
- { label: 'AEO, answers and snippets', groups: ['aeo'] },
504
- { label: 'GEO, visibility in AI systems', groups: ['geo'] },
505
- { label: 'Off-page and trust', groups: ['offpage'] },
506
- { label: 'Conversion and UX', groups: ['conversion'] },
548
+ { label: 'SEO, technical', groups: ['technical'], weight: 20 },
549
+ { label: 'SEO, content and structure', groups: ['onpage', 'content'], weight: 20 },
550
+ { label: 'AEO, answers and snippets', groups: ['aeo'], weight: 15 },
551
+ { label: 'GEO, visibility in AI systems', groups: ['geo'], weight: 20 },
552
+ { label: 'Off-page and trust', groups: ['offpage'], weight: 15 },
553
+ { label: 'Conversion and UX', groups: ['conversion'], weight: 10 },
507
554
  ];
508
555
 
556
+ /**
557
+ * Один балл из ста на весь продукт.
558
+ *
559
+ * 14.09.2026 Максим получил письмо, где в теле стояло «45 из 100», а во вложенном PDF шесть
560
+ * областей давали 44 из 60, то есть 73 процента. Человек читает в одном документе «ужас» и «почти
561
+ * всё хорошо» и делает единственный возможный вывод: цифры выдуманы. Считали их два разных движка
562
+ * с разными рамками, и это обесценивало оба продукта сразу.
563
+ *
564
+ * Поэтому балл считается здесь и только здесь, а страница проверки, письмо и отчёт берут его
565
+ * отсюда. Веса ниже это решение, а не измерение, и поэтому они напечатаны в самом отчёте: покупатель
566
+ * должен уметь пересчитать цифру руками, а не верить ей на слово.
567
+ *
568
+ * Область, которую этот прогон не измерял, не штрафуется и не считается за ноль: её вес выкидывается
569
+ * из знаменателя, и отчёт говорит, из чего именно сложился балл. Ноль за неизмеренное это враньё,
570
+ * ровно как «0 знаков» вместо «описания нет».
571
+ */
572
+ export function computeOverall(checks) {
573
+ const { scores, scoreBasis } = computeScores(checks);
574
+ const parts = SCORE_AREAS
575
+ .map((a) => ({ label: a.label, weight: a.weight, score: scores[a.label], basis: scoreBasis[a.label] }))
576
+ .filter((p) => p.score !== null && p.score !== undefined);
577
+ const weighed = parts.reduce((sum, p) => sum + p.weight, 0);
578
+ if (!weighed) return { score: null, grade: 'not measured', weighed: 0, parts: [], note: 'nothing was measured, so there is no score' };
579
+ const score = Math.round(parts.reduce((sum, p) => sum + (p.score / 10) * p.weight, 0) / weighed * 100);
580
+ const grade = score >= 80 ? 'strong' : score >= 60 ? 'workable' : score >= 40 ? 'weak' : 'critical';
581
+ const note = weighed === 100
582
+ ? `weighted from all ${parts.length} areas`
583
+ : `weighted from ${parts.length} of ${SCORE_AREAS.length} areas: ${weighed} of 100 points were measurable on this site`;
584
+ return { score, grade, weighed, parts, note };
585
+ }
586
+
509
587
  /**
510
588
  * Score every area from the collected checks: ok 1, warn 0.5, bad 0, na ignored, rounded to 0-10.
511
589
  * Returns the scores and the count each one came from, so the report can print its own evidence.
@@ -534,6 +612,18 @@ export function verifyScores(audit) {
534
612
  const { scores } = computeScores(audit.checks || []);
535
613
  const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/10`);
536
614
  const problems = [];
615
+ // Общий балл проверяется первым: именно он стоит в заголовке отчёта, в письме и на странице,
616
+ // и именно он обесценивает всё остальное, если разойдётся со своими же проверками.
617
+ //
618
+ // Сверяем только когда поле есть. Отчёт, собранный версией до 0.16.0, его не содержит, и это не
619
+ // ложь, а старый формат: в вёрстке заголовок всё равно считается из проверок этого же файла,
620
+ // поэтому отсутствие поля не может напечатать неверное число.
621
+ if (audit.overall && audit.overall.score !== undefined) {
622
+ const overall = computeOverall(audit.checks || []);
623
+ const stored = audit.overall.score;
624
+ const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/100`);
625
+ if (stored !== overall.score) problems.push(`overall: the report says ${show(stored)}, the checks give ${show(overall.score)}`);
626
+ }
537
627
  for (const [label, expected] of Object.entries(scores)) {
538
628
  const stored = (audit.scores || {})[label];
539
629
  const actual = stored === undefined ? null : stored;
package/src/i18n.mjs CHANGED
@@ -69,7 +69,15 @@ const RU = {
69
69
  },
70
70
  sitemap: {
71
71
  label: 'Карта сайта XML',
72
- value: (v) => (/URL\(s\) in/.test(v) ? `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} в карте сайта` : /not found|missing/i.test(v) ? 'не найдена' : `не отдаётся, код ${nums(v)[0]}`),
72
+ // Обход больших вложенных карт останавливается ограничителем, и по-русски это надо сказать
73
+ // словами, иначе прочитанная часть выглядит как полное измерение.
74
+ value: (v) => {
75
+ const n = nums(v);
76
+ if (/read from/.test(v)) return `${plural(Number(n[0]), 'адрес', 'адреса', 'адресов')} прочитано из ${n[1]} файлов карты, всего файлов ${n[2]}`;
77
+ if (/URL\(s\) in/.test(v)) return `${plural(Number(n[0]), 'адрес', 'адреса', 'адресов')} в карте сайта`;
78
+ if (/not found|missing/i.test(v)) return 'не найдена';
79
+ return `не отдаётся, код ${n[0]}`;
80
+ },
73
81
  },
74
82
  'sitemap-foreign': { label: 'В карте сайта чужие хосты', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} на других хостах` },
75
83
  'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} проверенных адресов отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
package/src/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export { collect, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
1
+ export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } from './collect.mjs';
2
2
  export { render, checkNarrative } from './render.mjs';
3
3
  export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
4
4
  export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
package/src/render.mjs CHANGED
@@ -7,7 +7,7 @@ import { writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
- import { computeScores } from './collect.mjs';
10
+ import { computeScores, computeOverall } from './collect.mjs';
11
11
  import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
12
12
  import { softenHex } from './agency.mjs';
13
13
 
@@ -25,6 +25,13 @@ const LABELS = {
25
25
  secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
26
26
  scorecardFoot: 'Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from section 04 onwards.',
27
27
  notMeasured: 'not measured',
28
+ overallLabel: 'Overall score',
29
+ grades: { strong: 'Strong', workable: 'Workable', weak: 'Weak', critical: 'Critical' },
30
+ // Веса это решение, а не измерение, поэтому они напечатаны здесь: балл должен пересчитываться руками.
31
+ overallNote: (o) => `Weighted from the areas below: ${o.parts.map((x) => `${x.label} ${x.weight}`).join(', ')}. `
32
+ + (o.weighed === 100
33
+ ? 'Every area was measurable on this site.'
34
+ : `Only ${o.weighed} of 100 points were measurable here, so the areas this audit could not read are left out of the total rather than scored zero.`),
28
35
  secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
29
36
  secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
30
37
  secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
@@ -48,6 +55,12 @@ const LABELS = {
48
55
  secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
49
56
  scorecardFoot: 'Каждая оценка считает проверки из этого же отчёта: пройденная проверка это балл, спорная половина балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по разделам начиная с четвёртого.',
50
57
  notMeasured: 'не измерялось',
58
+ overallLabel: 'Общий балл',
59
+ grades: { strong: 'Сильно', workable: 'Рабочее состояние', weak: 'Слабо', critical: 'Критично' },
60
+ overallNote: (o) => `Взвешен по областям ниже: ${o.parts.map((x) => `${AREAS_RU[x.label] || x.label} ${x.weight}`).join(', ')}. `
61
+ + (o.weighed === 100
62
+ ? 'Все области на этом сайте удалось измерить.'
63
+ : `Измеримыми здесь оказались ${o.weighed} ${ruPoints(o.weighed)} из 100, поэтому области, которые аудит прочитать не смог, не попали в знаменатель, а не получили ноль.`),
51
64
  secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
52
65
  secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
53
66
  secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
@@ -68,6 +81,9 @@ const LABELS = {
68
81
  const STATUS_KEYS = { ok: 'statusOk', warn: 'statusWarn', bad: 'statusBad', na: 'statusNa' };
69
82
  const STATUS_CLASS = { ok: 'status-ok', warn: 'status-warn', bad: 'status-bad', na: 'status-na' };
70
83
  const STATUS_UNUSED = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['· Note', 'status-na'] };
84
+ /** Склонение «балл» для русской подписи под общим баллом: 21 балл, 72 балла, 75 баллов. */
85
+ const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return 'баллов'; if (b === 1) return 'балл'; if (b >= 2 && b <= 4) return 'балла'; return 'баллов'; };
86
+
71
87
  const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
72
88
 
73
89
  export function checkNarrative(audit) {
@@ -110,6 +126,13 @@ function css() {
110
126
  .page-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 3mm; margin-bottom: 6mm; border-bottom: 1px solid var(--rule); font-size: 8pt; color: var(--muted); }
111
127
  .page-header strong { color: var(--accent); }
112
128
  .page-footer { position: absolute; bottom: 9mm; left: var(--pad); right: var(--pad); display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: 1px solid var(--rule); padding-top: 2.5mm; }
129
+ .overall { display: flex; align-items: center; gap: 6mm; border: 1px solid var(--rule); border-radius: 5px; padding: 5mm 6mm; background: #f4f8f8; margin: 5mm 0 4mm; }
130
+ .overall-num { font-family: "Fraunces", Georgia, serif; font-size: 44pt; font-weight: 600; line-height: 1; color: var(--accent); font-variant-numeric: tabular-nums; }
131
+ .overall-num span { font-size: 18pt; color: var(--muted); }
132
+ .overall-num.low { color: var(--danger); } .overall-num.mid { color: var(--warn); } .overall-num.ok { color: var(--ok); }
133
+ .overall-label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
134
+ .overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 15pt; font-weight: 600; margin: 1mm 0 1.5mm; }
135
+ .overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; }
113
136
  .scorecard { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin: 5mm 0 6mm; }
114
137
  .score { border: 1px solid var(--rule); border-radius: 4px; padding: 3.5mm 4mm; background: #fafbfb; }
115
138
  .score .label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 1.5mm; }
@@ -157,6 +180,15 @@ export function toHtml(audit, brand = null) {
157
180
  const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
158
181
  const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
159
182
  const { scores: measured, scoreBasis } = computeScores(a.checks || []);
183
+ // Один балл на весь отчёт стоит заголовком, шесть областей под ним расшифровкой. До 14.09.2026
184
+ // в письме и в PDF стояли числа из двух разных движков, и читатель делал единственный возможный
185
+ // вывод: цифры выдуманы. Веса печатаются рядом, чтобы балл можно было пересчитать руками.
186
+ const overall = computeOverall(a.checks || []);
187
+ const overallBlock = overall.score === null ? '' : `<div class="overall">
188
+ <div class="overall-num ${scoreClass(Math.round(overall.score / 10))}">${overall.score}<span>/100</span></div>
189
+ <div class="overall-side"><div class="overall-label">${esc(L.overallLabel)}</div>
190
+ <div class="overall-grade">${esc(L.grades[overall.grade] || overall.grade)}</div>
191
+ <div class="overall-note">${esc(L.overallNote(overall))}</div></div></div>`;
160
192
  const scoreCards = Object.entries(measured).map(([label, n]) => {
161
193
  const basis = scoreBasis[label] || {};
162
194
  const shown = (L.areas && L.areas[label]) || label;
@@ -177,7 +209,7 @@ export function toHtml(audit, brand = null) {
177
209
  <dl class="cover-meta"><div><dt>${L.auditSubject}</dt><dd>${t(a.client.name)}</dd></div><div><dt>${L.reportDate}</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>${L.auditType}</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>${L.preparedBy}</dt><dd>${esc(preparedBy)}</dd></div></dl></div>
178
210
  ${toolLine}</div></div>`);
179
211
  n++;
180
- pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
212
+ pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}<div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
181
213
  pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.value}</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
182
214
  const cardBody = (c) => [
183
215
  c.text ? `<p>${t(c.text)}</p>` : '',
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /** Один балл из ста: и в объекте аудита, и в отчёте, и на обоих языках.
3
+ *
4
+ * 14.09.2026 человек получил письмо с «45 из 100» и приложенный PDF, где шесть областей давали
5
+ * 44 из 60, то есть 73 процента. Два разных движка в одном документе читаются как выдуманные
6
+ * цифры. Эти тесты держат единственный источник балла и запрет считать неизмеренное нулём. */
7
+ import { readFileSync } from 'node:fs';
8
+ import { computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
9
+ import { toHtml } from './render.mjs';
10
+
11
+ let bad = 0;
12
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
13
+ const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
14
+
15
+ const c = (group, status, i = 0) => ({ id: `${group}-${status}-${i}`, group, status, label: group, value: 'x' });
16
+
17
+ // Веса это решение, а не измерение, но сумма обязана быть сотней, иначе балл не из ста.
18
+ is('веса дают ровно сто', SCORE_AREAS.reduce((s, a) => s + a.weight, 0), 100);
19
+
20
+ // Всё пройдено это сто, всё провалено это ноль.
21
+ const every = (status) => SCORE_AREAS.flatMap((a) => a.groups.map((g) => c(g, status)));
22
+ is('все проверки пройдены: сто', computeOverall(every('ok')).score, 100);
23
+ is('все проверки провалены: ноль', computeOverall(every('bad')).score, 0);
24
+ is('все спорные: половина', computeOverall(every('warn')).score, 50);
25
+
26
+ // Неизмеренная область выкидывается из знаменателя, а не получает ноль. Ноль за то, чего не
27
+ // мерили, это неверное измерение, ровно как «0 знаков» вместо «описания у страницы нет».
28
+ {
29
+ const partial = [c('technical', 'ok'), c('geo', 'ok')];
30
+ const o = computeOverall(partial);
31
+ is('измеренное на отлично даёт сто даже при неполном покрытии', o.score, 100);
32
+ is('в знаменатель попали только измеренные веса', o.weighed, 40);
33
+ ok('и отчёт об этом говорит', /40 of 100 points were measurable/.test(o.note));
34
+ ok('неизмеренные области в разбивку не попали', o.parts.length === 2);
35
+ }
36
+
37
+ // Пустой аудит не выдаёт ноль: нечего мерить это не то же самое, что всё плохо.
38
+ {
39
+ const o = computeOverall([]);
40
+ is('пустой набор проверок не даёт нуля', o.score, null);
41
+ is('и называется неизмеренным', o.grade, 'not measured');
42
+ }
43
+
44
+ // Проверки со статусом na не считаются ни в плюс, ни в минус.
45
+ {
46
+ const withNa = [c('technical', 'ok'), { ...c('technical', 'na'), status: 'na' }];
47
+ is('na не портит и не улучшает балл', computeOverall(withNa).score, 100);
48
+ }
49
+
50
+ // Балл в отчёте обязан сходиться с проверками этого же отчёта.
51
+ {
52
+ const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
53
+ const o = computeOverall(audit.checks || []);
54
+ audit.overall = o;
55
+ const problems = verifyScores(audit);
56
+ ok('честный отчёт проходит проверку', !problems.some((p) => p.startsWith('overall')));
57
+ audit.overall = { ...o, score: (o.score + 7) % 101 };
58
+ ok('подделанный балл ловится', verifyScores(audit).some((p) => p.startsWith('overall')));
59
+ }
60
+
61
+ // Оба языка. Сегодня дважды чинили русскую ветку и оставляли английскую сломанной.
62
+ {
63
+ const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
64
+ const expected = computeOverall(audit.checks || []).score;
65
+ for (const [lang, grade, note] of [['en', /Strong|Workable|Weak|Critical/, /Weighted from the areas below/], ['ru', /Сильно|Рабочее состояние|Слабо|Критично/, /Взвешен по областям ниже/]]) {
66
+ audit.meta.lang = lang;
67
+ const html = toHtml(audit, null);
68
+ const num = (html.match(/overall-num[^>]*>(\d+)<span>/) || [])[1];
69
+ is(`${lang}: в отчёте стоит тот же балл, что считают проверки`, Number(num), expected);
70
+ ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
71
+ ok(`${lang}: веса напечатаны, балл можно пересчитать руками`, note.test(html));
72
+ ok(`${lang}: в подписи нет чужого языка`, lang === 'en'
73
+ ? !/[А-Яа-яЁё]/.test((html.match(/overall-note">([^<]*)</) || [])[1] || '')
74
+ : !/Weighted|measurable/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
75
+ }
76
+ // Русское склонение: 75 баллов, а не 75 балла.
77
+ audit.meta.lang = 'ru';
78
+ ok('русское склонение «баллов» верное', !/\d+ балла из 100/.test(toHtml(audit, null)));
79
+ }
80
+
81
+ // Шесть областей никуда не делись: общий балл их дополняет, а не заменяет.
82
+ {
83
+ const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
84
+ const { scores } = computeScores(audit.checks || []);
85
+ const html = toHtml(audit, null);
86
+ ok('разбивка по областям осталась под общим баллом', Object.keys(scores).every((k) => html.includes('/10') || scores[k] === null));
87
+ }
88
+
89
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
90
+ console.log('\nвсе тесты общего балла прошли');
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /** Обход карт сайта: ограничители и честность отчёта.
3
+ *
4
+ * 14.09.2026 бесплатная проверка не собрала отчёт по whitewill.ru. В его sitemap.xml 79 вложенных
5
+ * карт, обход заходил в каждую по 15 секунд, и прогон умирал по таймауту, не дочитав до страниц.
6
+ * Тесты поднимают такой же сайт локально, чтобы это не вернулось. */
7
+ import { createServer } from 'node:http';
8
+ import { __readSitemap } from './collect.mjs';
9
+ import { localiseChecks } from './i18n.mjs';
10
+
11
+ let bad = 0;
12
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
13
+ const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
14
+
15
+ const index = (hrefs) => `<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${hrefs.map((h) => `<sitemap><loc>${h}</loc></sitemap>`).join('')}</sitemapindex>`;
16
+ const urlset = (hrefs) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${hrefs.map((h) => `<url><loc>${h}</loc></url>`).join('')}</urlset>`;
17
+
18
+ /** Сервер отвечает медленно, как настоящий сайт на другом континенте. */
19
+ const start = (delayMs, nested) => new Promise((resolve) => {
20
+ const server = createServer(async (req, res) => {
21
+ if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
22
+ const base = `http://127.0.0.1:${server.address().port}`;
23
+ res.setHeader('content-type', 'application/xml');
24
+ if (req.url === '/sitemap.xml') return res.end(index(Array.from({ length: nested }, (_, i) => `${base}/map-${i}.xml`)));
25
+ if (req.url === '/plain.xml') return res.end(urlset([`${base}/page`, `${base}/feed.xml`]));
26
+ const i = (req.url.match(/map-(\d+)/) || [])[1] ?? '0';
27
+ return res.end(urlset([`${base}/p-${i}-a`, `${base}/p-${i}-b`]));
28
+ });
29
+ server.listen(0, '127.0.0.1', () => resolve({ server, base: `http://127.0.0.1:${server.address().port}` }));
30
+ });
31
+
32
+ // 1. Семьдесят девять вложенных карт: читаем несколько файлов, а не все, и говорим об этом.
33
+ {
34
+ const { server, base } = await start(30, 79);
35
+ const began = Date.now();
36
+ const r = await __readSitemap(`${base}/sitemap.xml`, { maxFiles: 6, budgetMs: 45000 });
37
+ const took = Date.now() - began;
38
+ is('прочитано ровно столько файлов, сколько разрешено', r.filesRead, 6);
39
+ is('остановились по числу файлов', r.stopped, 'files');
40
+ is('нашли все вложенные карты', r.nestedFound, 79);
41
+ ok('непрочитанные файлы посчитаны', r.pending === 74);
42
+ ok('адресов хватает на выборку страниц', r.urls.length >= 10);
43
+ ok(`уложились в разумное время (${took} мс)`, took < 5000);
44
+ server.close();
45
+ }
46
+
47
+ // 2. Маленький сайт читается целиком, и тогда про ограничители в отчёте ни слова.
48
+ {
49
+ const { server, base } = await start(0, 2);
50
+ const r = await __readSitemap(`${base}/sitemap.xml`);
51
+ is('маленькая карта прочитана целиком', r.stopped, '');
52
+ is('файлов прочитано: индекс и две карты', r.filesRead, 3);
53
+ is('собраны все адреса', r.urls.length, 4);
54
+ server.close();
55
+ }
56
+
57
+ // 3. Из обычного urlset вглубь не спускаемся, даже если адрес заканчивается на .xml.
58
+ // Прежнее условие из-за приоритета операторов читало `A || (B && C && D)` и уходило в рекурсию.
59
+ {
60
+ const { server, base } = await start(0, 2);
61
+ const r = await __readSitemap(`${base}/plain.xml`);
62
+ is('из urlset рекурсии нет', r.filesRead, 1);
63
+ is('ссылка на .xml осталась адресом страницы', r.urls.length, 2);
64
+ server.close();
65
+ }
66
+
67
+ // 4. Общий бюджет времени соблюдается, даже если файлов мало, а сайт медленный.
68
+ {
69
+ const { server, base } = await start(200, 40);
70
+ const began = Date.now();
71
+ const r = await __readSitemap(`${base}/sitemap.xml`, { maxFiles: 100, budgetMs: 700 });
72
+ const took = Date.now() - began;
73
+ is('остановились по времени', r.stopped, 'time');
74
+ ok(`бюджет времени соблюдён (${took} мс)`, took < 2500);
75
+ server.close();
76
+ }
77
+
78
+ // 5. По-русски усечённый обход называется словами, а не «не отдаётся, код 1200».
79
+ // Локализатор подбирает строку регулярным выражением, поэтому новая формулировка без своей
80
+ // ветки молча проваливается в числовую. Ровно это уже случалось с нулями в отчёте.
81
+ {
82
+ const en = [{ id: 'sitemap', group: 'technical', label: 'XML sitemap', status: 'ok', value: '1200 URL(s) read from 6 of 79 sitemap file(s) in https://example.ru/sitemap.xml', comment: '' }];
83
+ const ru = localiseChecks(en, 'ru')[0].value;
84
+ ok(`усечение названо словами: "${ru}"`, /прочитано из 6 файлов карты, всего файлов 79/.test(ru));
85
+ ok('и это не выдано за код ответа', !/не отдаётся/.test(ru));
86
+ const whole = localiseChecks([{ ...en[0], value: '1200 URL(s) in https://example.ru/sitemap.xml' }], 'ru')[0].value;
87
+ ok(`целая карта по-прежнему читается нормально: "${whole}"`, /в карте сайта/.test(whole));
88
+ }
89
+
90
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
91
+ console.log('\nвсе тесты обхода карт прошли');