@operstack/audit 0.15.4 → 0.15.5

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.15.5",
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-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
 
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) ? ', есть битые' : ''}`; } },
@@ -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все тесты обхода карт прошли');