@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 +2 -2
- package/src/collect.mjs +57 -15
- package/src/i18n.mjs +9 -1
- 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": [
|
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;
|
|
@@ -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
|
|
package/src/i18n.mjs
CHANGED
|
@@ -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) ? ', есть битые' : ''}`; } },
|
|
@@ -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все тесты обхода карт прошли');
|