@operstack/audit 0.15.3 → 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 +3 -3
- package/src/collect.mjs +64 -19
- package/src/i18n.mjs +15 -5
- package/src/test-prompts.mjs +20 -0
- package/src/test-sitemap.mjs +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.15.
|
|
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": [
|
|
@@ -46,4 +46,4 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"node-html-parser": "^9.0.4"
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
}
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
204
|
+
return { urls, filesRead, nestedFound, pending: queue.length, stopped };
|
|
176
205
|
}
|
|
177
206
|
|
|
178
|
-
export
|
|
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;
|
|
@@ -212,7 +244,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
212
244
|
const disallows = blocks.flatMap((b) => b.rules.filter((r) => r.key === 'disallow' && r.value).map((r) => r.value));
|
|
213
245
|
const starBlocksAll = blocks.some((b) => b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/'));
|
|
214
246
|
const blockedAgents = blocks.filter((b) => !b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/')).flatMap((b) => b.agents);
|
|
215
|
-
checks.push(row('robots', 'technical', 'robots.txt', robots.ok ? (sitemapUrls.length ? 'ok' : 'warn') : 'bad', robots.ok ? `${disallows.length} disallow rule(s) in ${blocks.length} agent block(s), ${sitemapUrls.length} sitemap line(s)` : `HTTP ${robots.status}`, robots.ok && !sitemapUrls.length ? 'no Sitemap: line' : ''));
|
|
247
|
+
checks.push(row('robots', 'technical', 'robots.txt', robots.ok ? (sitemapUrls.length ? 'ok' : 'warn') : 'bad', robots.ok ? `${disallows.length} disallow rule(s) in ${blocks.length} agent block(s), ${sitemapUrls.length ? `${sitemapUrls.length} sitemap line(s)` : 'no sitemap line'}` : `HTTP ${robots.status}`, robots.ok && !sitemapUrls.length ? 'no Sitemap: line' : ''));
|
|
216
248
|
if (starBlocksAll) checks.push(row('robots-block', 'technical', 'robots.txt blocks the whole site', 'bad', 'Disallow: / under User-agent: *', 'search engines are told not to crawl anything'));
|
|
217
249
|
// The two kinds of AI agent are not the same decision. A search fetcher is the one that can cite the
|
|
218
250
|
// site in an answer, so blocking it costs visibility and is scored. A training crawler is a policy
|
|
@@ -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)) {
|
|
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
|
-
|
|
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
|
|
|
@@ -317,9 +359,12 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
317
359
|
const hp = homePage || good[0];
|
|
318
360
|
|
|
319
361
|
if (hp) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
362
|
+
// Ноль в этих трёх строках означает «элемента нет», а не «маленькое значение». Строка
|
|
363
|
+
// «Сейчас: 0 знаков» в задании для исполнителя читается как обрывок: человеку непонятно,
|
|
364
|
+
// что описания у страницы просто не существует. Где ноль это отсутствие, пишем словами.
|
|
365
|
+
checks.push(row('title', 'onpage', 'Homepage title', hp.titleLength >= 40 && hp.titleLength <= 60 ? 'ok' : hp.titleLength ? 'warn' : 'bad', hp.titleLength ? `"${hp.title}" (${hp.titleLength} chars)` : 'no title on the page', hp.titleLength < 40 ? 'short: the words that earn the click are missing' : hp.titleLength > 60 ? 'cut in results' : ''));
|
|
366
|
+
checks.push(row('description', 'onpage', 'Homepage meta description', hp.descriptionGarbage ? 'bad' : hp.descriptionLength >= 70 && hp.descriptionLength <= 160 ? 'ok' : hp.descriptionLength ? 'warn' : 'bad', hp.descriptionGarbage ? 'contains a shortcode or encoded data' : hp.descriptionLength ? `${hp.descriptionLength} chars` : 'no meta description on the page', hp.descriptionGarbage ? 'renders as technical garbage in the snippet' : !hp.descriptionLength ? 'missing' : ''));
|
|
367
|
+
checks.push(row('h1', 'onpage', 'H1 on homepage', hp.h1Count === 1 ? 'ok' : hp.h1Count === 0 ? 'bad' : 'warn', hp.h1Count ? `${hp.h1Count} H1 tag(s)` : 'no H1 on the page', hp.h1Count > 1 ? 'more than one H1 dilutes the page topic' : hp.h1Count === 0 ? 'no H1' : ''));
|
|
323
368
|
checks.push(row('canonical', 'technical', 'Canonical tag', hp.canonical ? 'ok' : 'warn', hp.canonical || 'missing'));
|
|
324
369
|
checks.push(row('viewport', 'technical', 'Mobile viewport', !hp.viewport ? 'bad' : /user-scalable\s*=\s*no|maximum-scale\s*=\s*1(\.0)?\b/i.test(hp.viewport) ? 'warn' : 'ok', hp.viewport || 'missing', /user-scalable\s*=\s*no/i.test(hp.viewport) ? 'blocks zoom: bad for accessibility and mobile SEO' : ''));
|
|
325
370
|
checks.push(row('og', 'technical', 'Open Graph and Twitter cards', hp.og.title && hp.og.image ? (hp.twitter ? 'ok' : 'warn') : 'warn', [hp.og.title ? 'og:title' : '', hp.og.image ? 'og:image' : '', hp.twitter ? `twitter:${hp.twitter}` : ''].filter(Boolean).join(', ') || 'none'));
|
package/src/i18n.mjs
CHANGED
|
@@ -53,7 +53,7 @@ const RU = {
|
|
|
53
53
|
ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
|
|
54
54
|
robots: {
|
|
55
55
|
label: 'robots.txt',
|
|
56
|
-
value: (v) => { const n = nums(v);
|
|
56
|
+
value: (v) => { const n = nums(v); if (/HTTP/.test(v)) return `не отдаётся, код ${n[0]}`; const map = /no sitemap line/i.test(v) ? 'строки с картой сайта нет' : `${plural(Number(n[2]), 'строка', 'строки', 'строк')} с картой сайта`; return `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')} в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов, ${map}`; },
|
|
57
57
|
comment: (c) => (/no Sitemap/.test(c) ? 'нет строки Sitemap' : ''),
|
|
58
58
|
},
|
|
59
59
|
'robots-block': { label: 'robots.txt закрывает весь сайт', value: () => 'Disallow: / под User-agent: *', comment: () => 'поисковым системам сказано не обходить ничего' },
|
|
@@ -69,7 +69,15 @@ const RU = {
|
|
|
69
69
|
},
|
|
70
70
|
sitemap: {
|
|
71
71
|
label: 'Карта сайта XML',
|
|
72
|
-
|
|
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) ? ', есть битые' : ''}`; } },
|
|
@@ -80,9 +88,11 @@ const RU = {
|
|
|
80
88
|
: `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
|
|
81
89
|
comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
|
|
82
90
|
},
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
// Где ноль означает отсутствие элемента, по-русски это тоже пишется словами, а не цифрой:
|
|
92
|
+
// «0 знаков» в задании для исполнителя читается как обрывок.
|
|
93
|
+
title: { label: 'Заголовок главной', value: (v) => { if (/^no title/i.test(v)) return 'заголовка у страницы нет'; 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) ? 'в выдаче обрежется' : '') },
|
|
94
|
+
description: { label: 'Описание главной', value: (v) => (/^no meta description/i.test(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) ? 'описания нет' : '') },
|
|
95
|
+
h1: { label: 'Заголовок H1 на главной', value: (v) => (/^no H1/i.test(v) ? 'заголовка H1 на странице нет' : `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1`), comment: (c) => (/dilutes/.test(c) ? 'больше одного H1 размывает тему страницы' : /no H1/.test(c) ? 'H1 на странице нет' : '') },
|
|
86
96
|
canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
|
|
87
97
|
viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
|
|
88
98
|
og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
|
package/src/test-prompts.mjs
CHANGED
|
@@ -51,6 +51,26 @@ const empty = mechanical.filter(([id]) => {
|
|
|
51
51
|
}).map(([id]) => id);
|
|
52
52
|
is('у каждой чинимой проверки есть внятное задание', empty, []);
|
|
53
53
|
|
|
54
|
+
// Ноль, который означает отсутствие, пишется словами, а не цифрой. 14.09.2026 в доставленном
|
|
55
|
+
// письме стояло «Сейчас: 0 знаков», и человек не понимал, что описания у страницы просто нет.
|
|
56
|
+
const zeros = [
|
|
57
|
+
['description', 'no meta description on the page', /описания у страницы нет/],
|
|
58
|
+
['title', 'no title on the page', /заголовка у страницы нет/],
|
|
59
|
+
['h1', 'no H1 on the page', /заголовка H1 на странице нет/],
|
|
60
|
+
// У robots ноль правил это настоящее измерение, а не отсутствие: словами пишется только
|
|
61
|
+
// пропавшая строка Sitemap, поэтому в образце правил три.
|
|
62
|
+
['robots', '3 disallow rule(s) in 2 agent block(s), no sitemap line', /строки с картой сайта нет/],
|
|
63
|
+
];
|
|
64
|
+
const nowLine = (text) => (text.split('\n').find((l) => l.startsWith('**Сейчас:**')) || '');
|
|
65
|
+
for (const [id, value, expect] of zeros) {
|
|
66
|
+
const localised = renderAgentPrompts(audit([c(id, 'bad', id, value)], 'en'), { lang: 'ru' });
|
|
67
|
+
const now = nowLine(localised);
|
|
68
|
+
ok(`${id}: по-английски ноль не выводится цифрой`, !/(^|\s)0 (chars|H1|sitemap)/.test(value));
|
|
69
|
+
ok(`${id}: по-русски отсутствие названо словами`, expect.test(now));
|
|
70
|
+
// Проверяем именно строку «Сейчас»: в тексте задачи числа законны («в 120-160 знаков»).
|
|
71
|
+
ok(`${id}: в строке «Сейчас» нет обрывка с нулём`, !/(^|\s)0\s/.test(now));
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
// Шапка файла объясняет, как этим пользоваться
|
|
55
75
|
const head = renderAgentPrompts(audit([c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
|
|
56
76
|
// Проверяем смысл, а не точную формулировку: шапку переписывали под покупателя без терминала.
|
|
@@ -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все тесты обхода карт прошли');
|