@operstack/audit 0.33.3 → 0.34.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 +8 -3
- package/src/collect.mjs +222 -46
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +26 -20
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +57 -21
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +12 -2
- package/src/review.mjs +271 -0
- package/src/tasks-pdf.mjs +67 -0
- package/src/test-agent-surface.mjs +1 -1
- package/src/test-etalon.mjs +42 -0
- package/src/test-narrative.mjs +11 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +11 -2
- package/src/test-shopify-store.mjs +159 -0
- package/src/verify.mjs +347 -0
- package/src/visibility.mjs +40 -10
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Магазин на Shopify, как brainmd.com 12.10.2026. Прогон 0.33.3 дал пять неправд о живом сайте:
|
|
4
|
+
* 1. «язык не указан», хотя <html> разбит на строки и lang="en" в нём стоит;
|
|
5
|
+
* 2. «в карте 349 страниц» при 1 357: из десяти файлов карты прочитаны шесть, статьи пропущены;
|
|
6
|
+
* 3. «CMS не измерено», хотя в коде Shopify.shop и cdn.shopify.com;
|
|
7
|
+
* 4. в выборку попали книги и диски вместо основных товаров, и FAQPage «не найдена»;
|
|
8
|
+
* 5. балл видимости не посчитан: в выборку попали agents.md и вход в аккаунт.
|
|
9
|
+
* Второй прогон того же дня нашёл ещё пять:
|
|
10
|
+
* 6. «нет sameAs», хотя в JSON-LD семь профилей, только слеши записаны как \/;
|
|
11
|
+
* 7. «мёртвая ссылка» в llms.txt с хвостовой обратной кавычкой из markdown;
|
|
12
|
+
* 8. «цены нет» при ценах на каждом товаре; пустая корзина $0.00 при этом не цена;
|
|
13
|
+
* 9. «на первом экране нечего нажать»: Add to cart не считалось, а меню съедало 15% разметки;
|
|
14
|
+
* 10. свой домен с точкой на конце в списке чужих хостов llms.txt.
|
|
15
|
+
* Здесь такой же магазин поднимается локально, и каждая из пяти неправд проверяется отдельно.
|
|
16
|
+
*/
|
|
17
|
+
import { createServer } from 'node:http';
|
|
18
|
+
import { lookup } from 'node:dns/promises';
|
|
19
|
+
import { collect, detectStack, homeLinkRank } from './collect.mjs';
|
|
20
|
+
import { checkVisibility, linksInText } from './visibility.mjs';
|
|
21
|
+
import { localiseChecks } from './i18n.mjs';
|
|
22
|
+
import { draftNarrative } from './narrative.mjs';
|
|
23
|
+
|
|
24
|
+
let bad = 0;
|
|
25
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
26
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
27
|
+
|
|
28
|
+
// ---- движок по следам в коде
|
|
29
|
+
is('Shopify по Shopify.shop', detectStack('<script>Shopify.shop = "x.myshopify.com";</script>'), 'Shopify');
|
|
30
|
+
is('Shopify по cdn.shopify.com', detectStack('<link href="//cdn.shopify.com/s/files/1/a.css">'), 'Shopify');
|
|
31
|
+
is('WordPress по wp-content', detectStack('<img src="/wp-content/uploads/a.jpg">'), 'WordPress');
|
|
32
|
+
is('без следов не угадываем', detectStack('<p>Shopify is a platform we like</p>'), '');
|
|
33
|
+
|
|
34
|
+
// ---- 7, 10. Адреса из markdown без хвостов
|
|
35
|
+
is('хвосты markdown отрезаны', linksInText('See `https://x.com/.well-known/ucp`. Home: https://x.com. Also (https://x.com/a), "https://x.com/b"'),
|
|
36
|
+
['https://x.com/.well-known/ucp', 'https://x.com', 'https://x.com/a', 'https://x.com/b']);
|
|
37
|
+
|
|
38
|
+
// ---- порядок по ссылкам главной
|
|
39
|
+
{
|
|
40
|
+
const r = homeLinkRank('<a href="/products/a">1</a><a href="/products/a">2</a><a href="https://www.x.com/products/b/">3</a><a href="https://other.com/products/c">4</a>', 'https://x.com/', new Set(['x.com', 'www.x.com']));
|
|
41
|
+
is('ссылки главной посчитаны', [r.get('https://x.com/products/a/'), r.get('https://www.x.com/products/b/'), r.has('https://other.com/products/c/')], [2, 1, false]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const BOOKS = Array.from({ length: 12 }, (_, i) => `/products/book-${i + 1}`);
|
|
45
|
+
const MAIN = ['/products/omega-power', '/products/memory-boost'];
|
|
46
|
+
const ARTICLES = Array.from({ length: 980 }, (_, i) => `/blogs/news/post-${i + 1}`);
|
|
47
|
+
const PAGES = ['/pages/contact', '/pages/about'];
|
|
48
|
+
const CHILDREN = ['agentic_discovery', 'products_1?from=1&to=2', 'pages_1', 'collections_1', 'blogs_1', 'articles_1', 'metaobject_pages_1', 'metaobject_pages_2', 'metaobject_pages_3'];
|
|
49
|
+
|
|
50
|
+
const shopifyHead = (title, schema = '') => `<!doctype html>
|
|
51
|
+
<html
|
|
52
|
+
class="no-js market-us "
|
|
53
|
+
lang="en"
|
|
54
|
+
|
|
55
|
+
>
|
|
56
|
+
<head><title>${title} | Brain Store supplements</title>
|
|
57
|
+
<meta name="description" content="A description of the page that is long enough to count as a real one for the check.">
|
|
58
|
+
<link rel="stylesheet" href="//cdn.shopify.com/s/files/1/theme.css">
|
|
59
|
+
<script>var Shopify = Shopify || {}; Shopify.shop = "x09u0a-0i.myshopify.com";</script>${schema}</head>`;
|
|
60
|
+
const sameAs = '<script type="application/ld+json">{"@context":"https:\\/\\/schema.org","@type":"Organization","name":"Brain Store","sameAs":["https:\\/\\/www.instagram.com\\/brainstore\\/","https:\\/\\/www.youtube.com\\/brainstore","https:\\/\\/www.facebook.com\\/brainstore"]}</script>';
|
|
61
|
+
const cartDrawer = '<div class="drawer">Cart 0. Spend $0.00 more to reach free shipping!</div>';
|
|
62
|
+
const bigMenu = `<header><div class="menu">${'<div class="item"><img src="/i.png" alt=""><span class="x"></span></div>'.repeat(400)}</div></header>`;
|
|
63
|
+
const buyBox = `<div class="gallery">${'<img src="/g.jpg" alt="">'.repeat(60)}</div><div class="buy"><p>$39.95</p><button type="submit" name="add">Add To Cart</button></div>`;
|
|
64
|
+
const faq = '<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}</script><script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"X"}</script>';
|
|
65
|
+
const body = (h1, links = []) => `<body><main><h1>${h1}</h1>${'<p>Plain paragraph about the product, what it does and the price.</p>'.repeat(8)}
|
|
66
|
+
<nav>${links.map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
|
|
67
|
+
const urlset = (base, list) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${list.map((p) => `<url><loc>${base}${p}</loc></url>`).join('')}</urlset>`;
|
|
68
|
+
|
|
69
|
+
const hits = [];
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
hits.push(req.url);
|
|
72
|
+
const base = `http://${req.headers.host}`;
|
|
73
|
+
const xml = (s) => { res.writeHead(200, { 'content-type': 'application/xml' }); res.end(s); };
|
|
74
|
+
const html = (s) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(s); };
|
|
75
|
+
const u = req.url;
|
|
76
|
+
if (u === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`User-agent: *\nDisallow: /cart\nSitemap: ${base}/sitemap.xml\n`); }
|
|
77
|
+
if (u === '/sitemap.xml') return xml(`<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${CHILDREN.map((c) => `<sitemap><loc>${base}/sitemap_${c.replace(/\?.*/, '')}.xml${c.includes('?') ? `?${c.split('?')[1]}` : ''}</loc></sitemap>`).join('')}</sitemapindex>`);
|
|
78
|
+
if (u === '/sitemap_agentic_discovery.xml') return xml(urlset(base, ['/agents.md']));
|
|
79
|
+
// Shopify отдаёт товары только с обоими параметрами: & в адресе значит пустую карту.
|
|
80
|
+
if (u.startsWith('/sitemap_products_1.xml')) return xml(urlset(base, u === '/sitemap_products_1.xml?from=1&to=2' ? [...BOOKS, ...MAIN] : []));
|
|
81
|
+
if (u === '/sitemap_pages_1.xml') return xml(urlset(base, PAGES));
|
|
82
|
+
if (u === '/sitemap_articles_1.xml') return xml(urlset(base, ARTICLES));
|
|
83
|
+
if (u.startsWith('/sitemap_')) return xml(urlset(base, []));
|
|
84
|
+
if (u === '/agents.md') { res.writeHead(200, { 'content-type': 'text/markdown' }); return res.end('# Agents\n'); }
|
|
85
|
+
if (u.startsWith('/customer_authentication/')) return; // висит, как вход в аккаунт Shopify
|
|
86
|
+
if (u === '/llms.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`# Brain Store\n\nShop: ${base}.\nAgents: \`${base}/.well-known/ucp\`.\n- [Omega](${base}${MAIN[0]})\n- [Memory](${base}${MAIN[1]})\n- [Contact](${base}/pages/contact)\n- [Shop app](https://shop.app/brainstore)\n`); }
|
|
87
|
+
if (u === '/.well-known/ucp') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{}'); }
|
|
88
|
+
if (u === '/') return html(shopifyHead('Home', sameAs) + body('Brain Store', [MAIN[0], MAIN[0], MAIN[0], MAIN[1], MAIN[1], '/pages/contact', '/customer_authentication/redirect']));
|
|
89
|
+
if (MAIN.includes(u)) return html(shopifyHead(u, faq) + `<body>${cartDrawer}${bigMenu}<main><h1>${u}</h1>${buyBox}${'<p>Plain paragraph about the product, what it does and how to take it.</p>'.repeat(40)}</main></body></html>`);
|
|
90
|
+
if (PAGES.includes(u)) return html(shopifyHead(u) + `<body>${cartDrawer}` + body(u).slice('<body>'.length));
|
|
91
|
+
if (BOOKS.includes(u) || u.startsWith('/blogs/')) return html(shopifyHead(u) + body(u));
|
|
92
|
+
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
|
|
93
|
+
});
|
|
94
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
95
|
+
// Бесплатная проверка не берёт 127.0.0.1 (это не публичный адрес), поэтому живая часть идёт через lvh.me.
|
|
96
|
+
let dnsOk = true;
|
|
97
|
+
try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
98
|
+
if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); server.close(); process.exit(bad ? 1 : 0); }
|
|
99
|
+
const base = `http://lvh.me:${server.address().port}`;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const a = await collect(`${base}/`, { pages: 6, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
103
|
+
const check = (id) => a.checks.find((c) => c.id === id) || {};
|
|
104
|
+
const row = (label) => (a.overview.rows.find((r) => r[0] === label) || [])[1];
|
|
105
|
+
|
|
106
|
+
// 1. Язык
|
|
107
|
+
is('1. язык прочитан из многострочного <html>', a.meta.language, 'en');
|
|
108
|
+
|
|
109
|
+
// 2. Карта сайта
|
|
110
|
+
const total = BOOKS.length + MAIN.length + PAGES.length + ARTICLES.length + 1; // + agents.md
|
|
111
|
+
is('2. прочитаны все десять файлов карты', check('sitemap').value, `${total} URL(s) in ${base}/sitemap.xml`);
|
|
112
|
+
is('2. в обзоре полное число', row('Pages in sitemap'), String(total));
|
|
113
|
+
ok('2. товары прочитаны по адресу с &, а не с &', hits.includes('/sitemap_products_1.xml?from=1&to=2'));
|
|
114
|
+
|
|
115
|
+
// 2. Если карта прочитана не вся, в обзоре это сказано, а не выдано за целое.
|
|
116
|
+
const part = await collect(`${base}/`, { pages: 2, pageDelayMs: 0, sitemapMaxFiles: 3, log: () => {}, visibility: { ok: false, error: 'skipped in test' }, rendered: false });
|
|
117
|
+
const partRow = (part.overview.rows.find((r) => r[0] === 'Pages in sitemap') || [])[1] || '';
|
|
118
|
+
ok(`2. частичная цифра названа частичной (${partRow})`, /^at least \d+: 3 of 10 sitemap files were read/.test(partRow));
|
|
119
|
+
|
|
120
|
+
// 3. Движок
|
|
121
|
+
is('3. Shopify в обзоре', row('CMS / stack'), 'Shopify');
|
|
122
|
+
is('3. Shopify в проверке', check('cms').value, 'Shopify (detected from the page code)');
|
|
123
|
+
is('3. по-русски', localiseChecks([check('cms')], 'ru')[0].value, 'Shopify (определён по коду страницы)');
|
|
124
|
+
|
|
125
|
+
// 4. Выборка
|
|
126
|
+
const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
|
|
127
|
+
ok(`4. основные товары с главной в выборке (${read.join(', ')})`, MAIN.every((p) => read.includes(p)));
|
|
128
|
+
ok('4. чаще упомянутый на главной идёт раньше', read.indexOf(MAIN[0]) < read.indexOf(MAIN[1]));
|
|
129
|
+
ok('4. книги не вытеснили товары', read.filter((p) => p.startsWith('/products/book-')).length <= 1);
|
|
130
|
+
ok('4. agents.md не читается как страница', !read.includes('/agents.md'));
|
|
131
|
+
is('4. FAQPage найдена', check('faq-schema').status, 'ok');
|
|
132
|
+
|
|
133
|
+
// 6. Профили из sameAs со слешами \/
|
|
134
|
+
is('6. профили прочитаны', check('trust-profiles').value, '3: instagram.com, youtube.com, facebook.com');
|
|
135
|
+
|
|
136
|
+
// 7, 10. llms.txt
|
|
137
|
+
ok(`7. адрес с обратной кавычкой не мёртвый (${check('llms-health').value})`, check('llms-health').status === 'ok');
|
|
138
|
+
// Точку после своего домена с портом URL не разберёт, поэтому сам случай brainmd.com. проверен выше на linksInText.
|
|
139
|
+
is('10. свой домен с точкой не чужой хост', check('llms').value, '6 link(s), 1 to other hosts');
|
|
140
|
+
|
|
141
|
+
// 8. Цена: из выборки, и пустая корзина ценой не считается
|
|
142
|
+
const priced = a.sample.filter((p) => p.priceShown).map((p) => new URL(p.url).pathname);
|
|
143
|
+
ok(`8. цена найдена на товарах и только на них (${priced.join(', ')})`, MAIN.every((p) => priced.includes(p)) && !priced.includes('/pages/contact'));
|
|
144
|
+
const drafted = draftNarrative(a, { lang: 'en' });
|
|
145
|
+
ok(`8. строка «цена видна» не пишет «цены нет» (${drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1]})`, /^yes: a price is shown on 2 of \d+ pages/.test(drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1] || ''));
|
|
146
|
+
|
|
147
|
+
// 9. Призыв на первом экране: Add To Cart после огромного меню и галереи
|
|
148
|
+
ok(`9. Add to cart на товаре засчитан (${check('conv-cta').value})`, /^2 of /.test(check('conv-cta').value) // два товара; ссылки главной стоят в <nav>, это не первый экран
|
|
149
|
+
&& a.sample.filter((p) => MAIN.some((m) => p.url.endsWith(m))).every((p) => p.ctaFirstScreen === true));
|
|
150
|
+
|
|
151
|
+
// 5. Балл видимости
|
|
152
|
+
const v = await checkVisibility(`${base}/`, { lang: 'en' });
|
|
153
|
+
ok(`5. балл посчитан (${v.ok ? v.score : v.error})`, v.ok && Number.isFinite(v.score));
|
|
154
|
+
const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
|
|
155
|
+
ok(`5. в выборке нет agents.md и входа в аккаунт (${vRead.join(', ')})`, vRead.length >= 3 && !vRead.some((p) => p === '/agents.md' || p.startsWith('/customer_authentication')));
|
|
156
|
+
} finally { server.close(); server.closeAllConnections?.(); }
|
|
157
|
+
|
|
158
|
+
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
159
|
+
console.log('\nвсё сходится');
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Второй взгляд (14.10.2026): проверка утверждений отчёта другим способом до того, как он уйдёт.
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Сборщик смотрит на выборку из 20-30 страниц и пишет «на сайте нет политики»,
|
|
5
|
+
* «цены нет», «мессенджеров нет». За неделю это трижды оказалось неправдой: политика и цены лежали
|
|
6
|
+
* на страницах вне выборки или внутри текста, соцсети стояли в подвале, а скорость сайта мерилась
|
|
7
|
+
* одним запросом. Здесь каждое такое «нет» перепроверяется по ВСЕМ страницам карты сайта (до
|
|
8
|
+
* предела), а время ответа берётся серединой из трёх замеров. Если второй взгляд нашёл то, чего
|
|
9
|
+
* «нет», строка переписывается по факту, а в audit.verification записывается, что и почему
|
|
10
|
+
* поменялось. Отчёт без этой записи считается непроверенным (см. assertVerified).
|
|
11
|
+
*/
|
|
12
|
+
import { __readSitemap as readSitemap, policyKinds, computeScores, computeOverall } from './collect.mjs';
|
|
13
|
+
import { localiseChecks } from './i18n.mjs';
|
|
14
|
+
import { detectNiche } from './niche.mjs';
|
|
15
|
+
import { MECHANICAL_IDS } from './narrative.mjs';
|
|
16
|
+
import { request as httpsRequest } from 'node:https';
|
|
17
|
+
import { request as httpRequest } from 'node:http';
|
|
18
|
+
|
|
19
|
+
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36';
|
|
20
|
+
const MESSENGER = /(?:wa\.me\/|api\.whatsapp\.com|whatsapp:\/\/|\bt\.me\/|telegram\.me\/|viber:\/\/|invite\.viber\.com|\bmax\.ru\/)/i;
|
|
21
|
+
const SOCIAL = /\/\/(?:www\.)?(vk\.com|ok\.ru|t\.me|instagram\.com|facebook\.com|youtube\.com|rutube\.ru|dzen\.ru|linkedin\.com|tiktok\.com|x\.com|twitter\.com|pinterest\.com)\/[^"'\s?#]+/gi;
|
|
22
|
+
const ABOUT_HREF = /\/(about|about-us|about-me|our-story|team|company|o-nas|o-kompanii|o-czentre|o-centre|o-klinike|kompaniya|obo-mne|o-sebe|ob-avtore|o-proekte|our-practice|o-vrache|o-klinike)\/?($|[?#])/i;
|
|
23
|
+
const ABOUT_TITLE = /^(о нас|о компании|о клинике|о центре|обо мне|о себе|об авторе|о проекте|о враче|о докторе|о специалисте|о практике|about|about us|about me|about the doctor|our practice|our story|who we are)(?![\p{L}])/iu;
|
|
24
|
+
const POLICY_TEXT = { privacy: /политик[аи] (?:конфиденциальности|обработки персональных данных)|privacy policy/i, terms: /пользовательское соглашение|публичная оферта|договор(?:-| )оферт|правила (?:и порядок )?оказания (?:платных )?услуг|terms (?:of (?:service|use)|and conditions)/i };
|
|
25
|
+
const PRICE = /(?:\d[\d\s ]{0,9}(?:[.,]\d{1,2})?\s?(?:₽|руб(?:\.|лей|ля|ль)?(?![а-яё])|р\.(?=\s))|(?:\$|€|£)\s?\d[\d,]*(?:\.\d{2})?|"price"\s*:\s*"?\d)/i;
|
|
26
|
+
|
|
27
|
+
const strip = (html) => String(html || '').replace(/<(script|style|noscript|template)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ | /g, ' ').replace(/\s+/g, ' ');
|
|
28
|
+
const hrefs = (html) => [...String(html || '').matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
|
29
|
+
const titleOf = (html) => strip((String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '').trim();
|
|
30
|
+
// H1 внутри <template> и с атрибутами Vue (v-, :, @) человек не видит: как в сборщике (collect.mjs).
|
|
31
|
+
const h1sOf = (html) => [...String(html).replace(/<template[\s\S]*?<\/template>/gi, ' ').matchAll(/<h1\b([^>]*)>[\s\S]*?<\/h1>/gi)].filter((m) => !/\s(?:v-|:|@|x-)[\w-]+=/.test(m[1] || '')).map((m) => strip(m[0]).trim()).filter((t) => t && !/\{\{|\}\}/.test(t));
|
|
32
|
+
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); return s.length ? s[Math.floor((s.length - 1) / 2)] : null; };
|
|
33
|
+
|
|
34
|
+
async function fetchPage(url, fetchImpl, timeout = 20000) {
|
|
35
|
+
const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
|
|
36
|
+
const started = Date.now();
|
|
37
|
+
try {
|
|
38
|
+
const r = await fetchImpl(url, { redirect: 'follow', signal: c.signal, headers: { 'user-agent': UA, accept: 'text/html,*/*' } });
|
|
39
|
+
const ttfb = Date.now() - started;
|
|
40
|
+
const text = await r.text();
|
|
41
|
+
return { url, ok: r.ok, status: r.status, html: text, ttfb };
|
|
42
|
+
} catch (e) { return { url, ok: false, status: 0, html: '', ttfb: Date.now() - started, error: e.message }; }
|
|
43
|
+
finally { clearTimeout(t); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function pool(items, n, fn) {
|
|
47
|
+
const out = new Array(items.length); let i = 0;
|
|
48
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (i < items.length) { const k = i++; out[k] = await fn(items[k]); } }));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
/** Время до первого байта на новом соединении, как у посетителя: без пула соединений. */
|
|
54
|
+
export function coldTtfb(url, { timeout = 20000, ua = UA } = {}) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let u; try { u = new URL(url); } catch { resolve(null); return; }
|
|
57
|
+
const started = Date.now();
|
|
58
|
+
const req = (u.protocol === 'http:' ? httpRequest : httpsRequest)(u, { method: 'GET', agent: false, headers: { 'user-agent': ua, accept: 'text/html,*/*' }, timeout }, (res) => {
|
|
59
|
+
const ms = Date.now() - started; res.resume(); res.on('end', () => {}); resolve({ ms, status: res.statusCode });
|
|
60
|
+
});
|
|
61
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
62
|
+
req.on('error', () => resolve({ ms: null, status: 0 }));
|
|
63
|
+
req.end();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Пускает ли сервер робота с таким user-agent: ответ на robots.txt, обрыв соединения это «не пускает». */
|
|
68
|
+
const BOT_UA = (name) => `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ${name}; +https://example.com/bot)`;
|
|
69
|
+
const TRAINING = ['GPTBot/1.1', 'ClaudeBot/1.0', 'Amazonbot/0.1', 'meta-externalagent/1.1', 'CCBot/2.0', 'Bytespider'];
|
|
70
|
+
const SEARCH = ['OAI-SearchBot/1.0', 'ChatGPT-User/1.0', 'PerplexityBot/1.0', 'Perplexity-User/1.0', 'Claude-SearchBot/1.0', 'Claude-User/1.0'];
|
|
71
|
+
|
|
72
|
+
const cap = (x) => { const t = String(x || '').trim(); return t ? t[0].toUpperCase() + t.slice(1) : t; };
|
|
73
|
+
/** «Значение. Пояснение» без повтора и со строчной после точки (14.10.2026: «описания нет. описания нет»). */
|
|
74
|
+
export function joinValueComment(value, comment) {
|
|
75
|
+
const v = cap(value); const c = String(comment || '').trim();
|
|
76
|
+
if (!c) return v;
|
|
77
|
+
const words = (x) => x.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
|
|
78
|
+
const vw = new Set(words(v));
|
|
79
|
+
// Пояснение, все слова которого уже есть в значении, это повтор: «описания у страницы нет. описания нет».
|
|
80
|
+
if (words(c).every((w) => vw.has(w))) return v;
|
|
81
|
+
return `${v}. ${cap(c)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Список «что чинить первым» и первая неделя плана собираются из строк как они есть сейчас. */
|
|
85
|
+
export function rebuildCritical(audit) {
|
|
86
|
+
const ru = audit.meta?.lang === 'ru';
|
|
87
|
+
const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
88
|
+
const bad = shown.filter((c) => c.status === 'bad');
|
|
89
|
+
audit.critical = bad.map((c) => ({ title: c.label, text: joinValueComment(c.value, c.comment), level: 'bad' }));
|
|
90
|
+
const fix = ru ? 'Исправить' : 'Fix';
|
|
91
|
+
const week1 = (audit.roadmap || [])[0]; const week2 = (audit.roadmap || [])[1];
|
|
92
|
+
if (week1 && Array.isArray(week1.items)) {
|
|
93
|
+
// В «быстрые победы» только техническое: то, что требует фактов владельца или юриста, уходит во вторую очередь (14.10.2026).
|
|
94
|
+
const quick = bad.filter((c) => MECHANICAL_IDS.has(c.id) && c.id !== 'trust-policies');
|
|
95
|
+
const slow = bad.filter((c) => !quick.includes(c));
|
|
96
|
+
const other = week1.items.filter((i) => !String(i).startsWith(`${fix}:`) && !String(i).includes('{{'));
|
|
97
|
+
week1.items = [...quick.map((c) => `${fix}: ${c.label}`), ...other];
|
|
98
|
+
if (week2 && Array.isArray(week2.items)) {
|
|
99
|
+
const keep = week2.items.filter((i) => !String(i).includes('{{') && !week1.items.some((w) => String(w).toLowerCase().includes(String(i).toLowerCase()) || String(i).toLowerCase().includes(String(w).replace(/^[^:]+:\s*/, '').toLowerCase())));
|
|
100
|
+
week2.items = [...slow.map((c) => `${fix}: ${c.label}`), ...keep, ru ? 'Остальные задачи по порядку из файла задач' : 'The rest of the tasks, in order, from the tasks file'];
|
|
101
|
+
week2.items = [...new Set(week2.items)];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return audit;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Все страницы сайта, которые стоит прочитать второй раз: карта сайта плюс выборка, без файлов. */
|
|
108
|
+
async function siteUrls(audit, { maxPages, fetchImpl }) {
|
|
109
|
+
const origin = new URL(audit.meta.site).origin;
|
|
110
|
+
const fromSample = (audit.sample || []).map((p) => p.url).filter(Boolean);
|
|
111
|
+
let fromMap = [];
|
|
112
|
+
try {
|
|
113
|
+
const robots = await fetchPage(`${origin}/robots.txt`, fetchImpl, 10000);
|
|
114
|
+
const lines = [...String(robots.html).matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]);
|
|
115
|
+
for (const u of [...lines, `${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`, `${origin}/wp-sitemap.xml`]) {
|
|
116
|
+
const r = await readSitemap(u, { maxFiles: 50, budgetMs: 40000 }).catch(() => null);
|
|
117
|
+
if (r && r.urls && r.urls.length) { fromMap = r.urls; break; }
|
|
118
|
+
}
|
|
119
|
+
} catch { /* без карты читаем то, что есть */ }
|
|
120
|
+
const pages = fromMap.filter((u) => !/\.(xml|pdf|jpe?g|png|webp|gif|svg|zip|docx?|xlsx?)(\?|$)/i.test(u));
|
|
121
|
+
// Сначала короткие адреса (разделы, служебные страницы), потом длинные (статьи): политика и «о нас» живут сверху.
|
|
122
|
+
pages.sort((a, b) => a.length - b.length);
|
|
123
|
+
// Один адрес со слешем и без считался двумя страницами: выходило «105 прочитанных» при 104 в карте (14.10.2026).
|
|
124
|
+
const norm = (u) => String(u).replace(/[?#].*$/, '').replace(/\/+$/, '');
|
|
125
|
+
const seen = new Set(); const out = [];
|
|
126
|
+
for (const u of [audit.meta.site, ...fromSample, ...pages]) { const k = norm(u); if (!seen.has(k)) { seen.add(k); out.push(u); } }
|
|
127
|
+
return out.slice(0, maxPages);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Второй взгляд по сайту. Меняет audit.checks по факту и пишет audit.verification:
|
|
132
|
+
* { at, pagesRead, changes: [{ id, before, after, evidence }] }.
|
|
133
|
+
*/
|
|
134
|
+
export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetchImpl = fetch, log = () => {}, lang = audit.meta?.lang || 'en', probes = true } = {}) {
|
|
135
|
+
// probes: false выключает замеры по сырому соединению (время ответа, роботы ИИ): так эталонный тест идёт без сети.
|
|
136
|
+
const ru = lang === 'ru';
|
|
137
|
+
const urls = await siteUrls(audit, { maxPages, fetchImpl });
|
|
138
|
+
log(`second look: reading ${urls.length} page(s) of ${audit.meta.host}`);
|
|
139
|
+
const pages = (await pool(urls, concurrency, (u) => fetchPage(u, fetchImpl))).filter((p) => p.ok && /<html|<body/i.test(p.html));
|
|
140
|
+
const changes = [];
|
|
141
|
+
const byId = (id) => (audit.checks || []).find((c) => c.id === id);
|
|
142
|
+
const change = (id, patch, evidence) => {
|
|
143
|
+
const c = byId(id); if (!c) return;
|
|
144
|
+
const before = { status: c.status, value: c.value };
|
|
145
|
+
Object.assign(c, patch, ('value' in patch || 'comment' in patch) && ru ? { localised: true } : {});
|
|
146
|
+
changes.push({ id, before, after: { status: c.status, value: c.value }, evidence });
|
|
147
|
+
};
|
|
148
|
+
const path = (u) => { try { return new URL(u).pathname; } catch { return u; } };
|
|
149
|
+
|
|
150
|
+
// 1. Политика и условия: ссылки на любых страницах, затем текст на страницах.
|
|
151
|
+
const pol = byId('trust-policies');
|
|
152
|
+
if (pol && pol.status !== 'ok') {
|
|
153
|
+
const linked = new Set(); const inText = {};
|
|
154
|
+
for (const p of pages) {
|
|
155
|
+
for (const h of hrefs(p.html)) for (const k of policyKinds(h)) linked.add(k);
|
|
156
|
+
const t = strip(p.html);
|
|
157
|
+
for (const [k, re] of Object.entries(POLICY_TEXT)) if (re.test(t) && !inText[k]) inText[k] = p.url;
|
|
158
|
+
}
|
|
159
|
+
const kinds = [...linked];
|
|
160
|
+
const textOnly = Object.entries(inText).filter(([k]) => !kinds.some((x) => (k === 'privacy' ? /privacy|konfid|personal|polit/.test(x) : !/privacy|konfid|personal|polit/.test(x))));
|
|
161
|
+
if (kinds.length || textOnly.length) {
|
|
162
|
+
const parts = [];
|
|
163
|
+
if (kinds.length) parts.push(ru ? `ссылки есть: ${kinds.join(', ')}` : `linked: ${kinds.join(', ')}`);
|
|
164
|
+
for (const [k, u] of textOnly) parts.push(ru ? `${k === 'privacy' ? 'политика конфиденциальности' : 'правила или условия оказания услуг'} есть только в тексте страницы ${path(u)}, отдельной страницы и ссылки нет` : `${k === 'privacy' ? 'privacy policy' : 'terms'} only inside the text of ${path(u)}, no page of its own and no link`);
|
|
165
|
+
const missingPrivacy = !kinds.some((x) => /privacy|konfid|personal|polit/.test(x)) && !inText.privacy;
|
|
166
|
+
if (missingPrivacy) parts.push(ru ? 'политики конфиденциальности нет нигде на сайте' : 'no privacy policy anywhere on the site');
|
|
167
|
+
const withForms = pages.filter((p) => /<form[\s\S]*?(?:type=["']?tel|name=["'][^"']*(?:phone|tel|name|email)|placeholder=["'][^"']*(?:телефон|номер|имя|почт))/i.test(p.html)).length;
|
|
168
|
+
if (missingPrivacy && withForms && ru) parts.push(`при этом формы, которые собирают контакты, стоят на ${withForms} из ${pages.length} страниц; по 152-ФЗ сайт, который собирает персональные данные, обязан опубликовать политику их обработки и получать согласие`);
|
|
169
|
+
change('trust-policies', { status: missingPrivacy ? 'bad' : kinds.length >= 2 ? 'ok' : 'warn', value: parts.join('; ') }, { pagesRead: pages.length, linked: kinds, inText, withForms });
|
|
170
|
+
} else {
|
|
171
|
+
change('trust-policies', { value: ru ? `ни политики конфиденциальности, ни условий нет ни на одной из ${pages.length} прочитанных страниц` : `neither a privacy policy nor terms on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 2. Страница «о компании».
|
|
176
|
+
const about = byId('trust-about');
|
|
177
|
+
if (about && about.status !== 'ok') {
|
|
178
|
+
const hit = pages.find((p) => hrefs(p.html).some((h) => ABOUT_HREF.test(h)) || ABOUT_HREF.test(new URL(p.url).pathname) || /"@type"\s*:\s*"AboutPage"/.test(p.html) || ABOUT_TITLE.test(titleOf(p.html)) || h1sOf(p.html).some((h) => ABOUT_TITLE.test(h)));
|
|
179
|
+
if (hit) change('trust-about', { status: 'ok', value: ru ? `есть: найдено на ${path(hit.url)}` : `present: found via ${path(hit.url)}` }, { url: hit.url });
|
|
180
|
+
else change('trust-about', { value: ru ? `страницы о компании нет ни на одной из ${pages.length} прочитанных страниц` : `no about or team page on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Мессенджеры.
|
|
184
|
+
const msg = byId('conv-messenger');
|
|
185
|
+
// Онлайн-чат на сайте это тоже способ написать (14.10.2026, brainmd.com: Gorgias на каждой странице).
|
|
186
|
+
const CHAT = /(gorgias|jivosite|jivo\.chat|code\.jivo|intercom|tawk\.to|crisp\.chat|livechatinc|zopim|zendesk\.com\/embeddable|talk-me|carrotquest|bitrix24.*crm|chatra|tidio|drift\.com|hubspot.*conversations)/i;
|
|
187
|
+
const chatPages = pages.filter((p) => CHAT.test(p.html));
|
|
188
|
+
if (msg && msg.status !== 'ok' && chatPages.length) {
|
|
189
|
+
const name = (chatPages[0].html.match(CHAT) || [])[1] || 'chat';
|
|
190
|
+
change('conv-messenger', { status: 'ok', value: ru ? `мессенджеров нет, но на ${chatPages.length} из ${pages.length} страниц есть онлайн-чат (${name})` : `no messenger links, but ${chatPages.length} of ${pages.length} pages carry a live chat (${name})`, comment: '' }, { chat: name });
|
|
191
|
+
}
|
|
192
|
+
if (msg && msg.status !== 'ok') {
|
|
193
|
+
const hit = pages.filter((p) => hrefs(p.html).some((h) => MESSENGER.test(h)));
|
|
194
|
+
if (hit.length) change('conv-messenger', { status: 'ok', value: ru ? `есть на ${hit.length} из ${pages.length} прочитанных страниц` : `on ${hit.length} of ${pages.length} pages read` }, { urls: hit.slice(0, 5).map((p) => p.url) });
|
|
195
|
+
else change('conv-messenger', { value: ru ? `нет ни на одной из ${pages.length} прочитанных страниц` : `none on any of the ${pages.length} pages read`, comment: ru ? 'человеку с телефона одна ссылка на мессенджер часто удобнее формы' : 'for a visitor on a phone, one messenger link is often easier than a form' }, { pagesRead: pages.length });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 4. Профили: sameAs с разэкранированными слешами, плюс ссылки на соцсети в самих страницах.
|
|
199
|
+
const prof = byId('trust-profiles');
|
|
200
|
+
if (prof && prof.status !== 'ok') {
|
|
201
|
+
const own = audit.meta.host.replace(/^www\./, '');
|
|
202
|
+
const inSchema = new Set(); const inLinks = new Set();
|
|
203
|
+
for (const p of pages) {
|
|
204
|
+
const ld = String(p.html).replace(/\\\//g, '/');
|
|
205
|
+
for (const m of ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) for (const u of m[1].match(/https?:\/\/[^"\s,\]]+/g) || []) { try { const h = new URL(u).host.replace(/^www\./, ''); if (h !== own) inSchema.add(h); } catch { /* мусор */ } }
|
|
206
|
+
for (const m of String(p.html).matchAll(SOCIAL)) inLinks.add(m[1].toLowerCase());
|
|
207
|
+
}
|
|
208
|
+
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value: `${inSchema.size}: ${[...inSchema].slice(0, 5).join(', ')}` }, { inSchema: [...inSchema] });
|
|
209
|
+
else if (inLinks.size) change('trust-profiles', { status: 'warn', value: ru ? `в разметке не заявлены, но ссылки на сайте есть: ${[...inLinks].join(', ')}` : `not declared in the markup, though the pages link to: ${[...inLinks].join(', ')}` }, { inLinks: [...inLinks] });
|
|
210
|
+
else change('trust-profiles', { value: ru ? `ни в разметке, ни ссылками ни на одной из ${pages.length} прочитанных страниц профилей нет` : `no profile in the markup or as a link on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 5. Цена: по всем страницам, с «рублей» прописью и в разметке.
|
|
214
|
+
const priced = pages.filter((p) => PRICE.test(strip(p.html)) || /"price"\s*:\s*"?\d/.test(p.html));
|
|
215
|
+
audit.verification = audit.verification || {};
|
|
216
|
+
audit.verification.price = { pagesRead: pages.length, withPrice: priced.map((p) => path(p.url)).slice(0, 10), count: priced.length };
|
|
217
|
+
|
|
218
|
+
// 6. Главная: описание, H1, llms.txt и время ответа, тремя новыми запросами.
|
|
219
|
+
const homeTries = await pool([0, 1, 2], 1, () => fetchPage(audit.meta.site, fetchImpl));
|
|
220
|
+
const homeOk = homeTries.filter((r) => r.ok);
|
|
221
|
+
if (homeOk.length) {
|
|
222
|
+
const html = homeOk[0].html;
|
|
223
|
+
const desc = (html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || html.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i) || [])[1];
|
|
224
|
+
const d = byId('description');
|
|
225
|
+
if (d && d.status === 'bad' && desc && desc.trim()) change('description', { status: 'ok', value: ru ? `${desc.trim().length} знаков` : `${desc.trim().length} chars` }, { desc });
|
|
226
|
+
const h1 = byId('h1');
|
|
227
|
+
if (h1 && h1.status === 'bad' && h1sOf(html).length === 1) change('h1', { status: 'ok', value: h1sOf(html)[0].slice(0, 80) }, { h1: h1sOf(html) });
|
|
228
|
+
const t = byId('ttfb');
|
|
229
|
+
if (t && probes) {
|
|
230
|
+
// Пять замеров на новом соединении, середина (14.10.2026: один запрос давал то 1,1 с, то 4,0 с).
|
|
231
|
+
const runs = [];
|
|
232
|
+
for (let i = 0; i < 5; i++) { const r = await coldTtfb(audit.meta.site); if (r && r.ms) runs.push(r.ms); }
|
|
233
|
+
if (runs.length >= 3) {
|
|
234
|
+
const mid = median(runs); const lo = Math.min(...runs); const hi = Math.max(...runs);
|
|
235
|
+
const fmt = (x) => (ru ? `${(x / 1000).toFixed(1).replace('.', ',')} с` : `${(x / 1000).toFixed(1)} s`);
|
|
236
|
+
// Пороги web.dev: до 0,8 с хорошо, до 1,8 с терпимо, дальше плохо.
|
|
237
|
+
const status = mid <= 800 ? 'ok' : mid <= 1800 ? 'warn' : 'bad';
|
|
238
|
+
change('ttfb', { status, value: ru ? `середина ${fmt(mid)} по ${runs.length} замерам (от ${fmt(lo)} до ${fmt(hi)}), замер с сервера в Германии` : `median ${fmt(mid)} over ${runs.length} requests (${fmt(lo)} to ${fmt(hi)}), measured from a server in Germany`, comment: ru ? 'хорошим Google считает ответ до 0,8 секунды; посетители из другой страны могут видеть другое время' : 'Google calls 0.8 seconds or less good; visitors elsewhere may see a different time' }, { runs });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const llms = byId('llms');
|
|
243
|
+
if (llms && llms.status !== 'ok') {
|
|
244
|
+
const r = await fetchPage(`${new URL(audit.meta.site).origin}/llms.txt`, fetchImpl, 15000);
|
|
245
|
+
if (r.ok && r.html.trim() && !/<html/i.test(r.html)) change('llms', { status: 'ok', value: ru ? 'есть' : 'present' }, { status: r.status });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Числа по всему сайту рядом с числами по выборке (14.10.2026, Максим: «H1 нет на 11 из 20» без «выборки» и без
|
|
249
|
+
// «по всему сайту на 84 из 104» преуменьшает проблему). Только там, где второй взгляд прочитал больше выборки.
|
|
250
|
+
if (pages.length > (audit.sample || []).length) {
|
|
251
|
+
const shownValue = (c) => (ru ? localiseChecks([c], 'ru')[0].value : c.value);
|
|
252
|
+
const h1c = byId('h1-sample');
|
|
253
|
+
const noH1All = pages.filter((p) => !h1sOf(p.html).length).length;
|
|
254
|
+
if (h1c && noH1All && /no H1|H1 нет/.test(String(shownValue(h1c)))) change('h1-sample', { value: `${shownValue(h1c)}${ru ? `, по всему сайту на ${noH1All} из ${pages.length}` : `; across the whole site on ${noH1All} of ${pages.length}`}` }, { noH1All, pagesRead: pages.length });
|
|
255
|
+
const dc = byId('description-all');
|
|
256
|
+
const noDescAll = pages.filter((p) => !((String(p.html).match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || String(p.html).match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i) || [])[1] || '').trim()).length;
|
|
257
|
+
if (dc && dc.status !== 'ok' && noDescAll) change('description-all', { value: `${shownValue(dc)}${ru ? `; по всему сайту описания нет на ${noDescAll} из ${pages.length}` : `; across the whole site ${noDescAll} of ${pages.length} pages have none`}` }, { noDescAll, pagesRead: pages.length });
|
|
258
|
+
}
|
|
259
|
+
// Ниша и необязательные для неё строки: карточка агента, каталог API и markdown нужны только тем, у кого
|
|
260
|
+
// есть свой ИИ-сервис или API. Остальным это не недостаток, а «не нужно» (14.10.2026).
|
|
261
|
+
audit.niche = audit.niche || detectNiche(audit);
|
|
262
|
+
if (audit.niche.niche !== 'b2b') for (const id of ['agent-card', 'api-catalog', 'agent-markdown']) {
|
|
263
|
+
const c = byId(id);
|
|
264
|
+
if (c && c.status === 'warn') change(id, { status: 'na', comment: ru ? 'необязательно для этого сайта: нужно, только если у компании есть свой ИИ-сервис или API' : 'optional for this site: needed only when the business runs its own AI service or API' }, { niche: audit.niche.niche });
|
|
265
|
+
}
|
|
266
|
+
// Роботы ИИ по-настоящему: robots.txt может их пускать, а сервер обрывать соединение (14.10.2026, medeq-mc.ru).
|
|
267
|
+
const robotsUrl = `${new URL(audit.meta.site).origin}/robots.txt`;
|
|
268
|
+
const robotsText = (await fetchPage(robotsUrl, fetchImpl, 10000)).html || '';
|
|
269
|
+
const probe = async (name) => { const r = await coldTtfb(robotsUrl, { ua: BOT_UA(name), timeout: 15000 }); return { name: name.split('/')[0], ok: Boolean(r && r.status && r.status < 500) }; };
|
|
270
|
+
const train = probes ? await pool(TRAINING, 3, probe) : []; const search = probes ? await pool(SEARCH, 3, probe) : [];
|
|
271
|
+
const cut = [...train, ...search].filter((x) => !x.ok);
|
|
272
|
+
if (cut.length) {
|
|
273
|
+
const cutSearch = search.filter((x) => !x.ok).map((x) => x.name); const cutTrain = train.filter((x) => !x.ok).map((x) => x.name); const okTrain = train.filter((x) => x.ok).map((x) => x.name);
|
|
274
|
+
const value = ru
|
|
275
|
+
? `${cutSearch.length ? `сервер не пускает поисковых роботов ИИ ${cutSearch.join(', ')}; ` : ''}${cutTrain.length ? `сервер не пускает обучающих роботов ${cutTrain.join(', ')}: соединение обрывается без ответа, даже на robots.txt` : ''}${okTrain.length && cutTrain.length ? `, а ${okTrain.join(', ')} пускает` : ''}; в robots.txt этого не видно, запрет стоит на уровне сервера или хостинга`
|
|
276
|
+
: `${cutSearch.length ? `the server refuses AI search fetchers ${cutSearch.join(', ')}; ` : ''}${cutTrain.length ? `it drops the training crawlers ${cutTrain.join(', ')} without an answer, even on robots.txt` : ''}${okTrain.length && cutTrain.length ? `, while letting ${okTrain.join(', ')} in` : ''}; robots.txt does not show this, the block sits in the server or host`;
|
|
277
|
+
if (!(audit.checks || []).some((c) => c.id === 'train-bots')) {
|
|
278
|
+
const at = (audit.checks || []).findIndex((c) => c.id === 'ai-search-access');
|
|
279
|
+
const row = { id: 'train-bots', group: 'geo', label: ru ? 'Роботы ИИ на уровне сервера' : 'AI robots at the server', status: cutSearch.length ? 'bad' : 'warn', value, comment: ru ? 'если это не решение владельца, стоит спросить хостинг' : 'if the owner did not decide this, ask the host' };
|
|
280
|
+
audit.checks.splice(at >= 0 ? at + 1 : audit.checks.length, 0, row);
|
|
281
|
+
changes.push({ id: 'train-bots', before: null, after: { status: row.status, value }, evidence: { train, search } });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
audit.verification = { ...audit.verification, at: new Date().toISOString(), pagesRead: pages.length, changes };
|
|
285
|
+
const src = (audit.sources || [])[0];
|
|
286
|
+
if (src && pages.length > (audit.sample || []).length) src.detail = ru ? `robots.txt и все ${pages.length} ${pages.length % 10 >= 2 && pages.length % 10 <= 4 && (pages.length % 100 < 10 || pages.length % 100 >= 20) ? 'страницы' : pages.length % 10 === 1 && pages.length % 100 !== 11 ? 'страницу' : 'страниц'} из карты сайта: на них проверено всё, что отчёт называет отсутствующим; ${(audit.sample || []).length} страниц выборки (включая главную) разобраны подробно` : `robots.txt and all ${pages.length} pages in the sitemap, checked for everything the report calls absent; ${(audit.sample || []).length} sampled pages (home included) read in detail`;
|
|
287
|
+
rescore(audit);
|
|
288
|
+
// Сжатые копии прочитанных страниц для проверяющего: в audit не пишутся, отдаются вызывающему.
|
|
289
|
+
Object.defineProperty(audit.verification, 'digest', { value: siteDigest(pages, robotsText), enumerable: false });
|
|
290
|
+
log(`second look: ${pages.length} page(s) read, ${changes.filter((c) => c.before?.status !== c.after.status).length} status(es) corrected`);
|
|
291
|
+
return audit.verification;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Отчёт без второго взгляда не печатается для клиента. */
|
|
295
|
+
export function assertVerified(audit) {
|
|
296
|
+
if (!audit.verification || !audit.verification.at) throw new Error('fact gate: this audit was not verified (run secondLook before render)');
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Оценки областей и балл отчёта пересчитываются после любой правки строк: иначе на первой странице стоит старое число. */
|
|
301
|
+
export function rescore(audit) {
|
|
302
|
+
// Пояснение, все слова которого уже есть в значении, убираем: «описания нет» под «описания у страницы нет» (14.10.2026).
|
|
303
|
+
for (const c of audit.checks || []) if (c.comment && joinValueComment(c.value, c.comment) === joinValueComment(c.value, '')) c.comment = '';
|
|
304
|
+
const { scores, scoreBasis } = computeScores(audit.checks);
|
|
305
|
+
audit.scores = scores; audit.scoreBasis = scoreBasis;
|
|
306
|
+
audit.reportScore = computeOverall(audit.checks);
|
|
307
|
+
rebuildCritical(audit);
|
|
308
|
+
return audit;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Короткая выжимка по каждой прочитанной странице: то, на что опираются утверждения отчёта.
|
|
313
|
+
* Её получает проверяющий ИИ, чтобы сверять фразы не с памятью, а с сайтом.
|
|
314
|
+
*/
|
|
315
|
+
export function siteDigest(pages, robotsText = '') {
|
|
316
|
+
const one = (p) => {
|
|
317
|
+
const h = String(p.html || '');
|
|
318
|
+
const attr = (re) => (h.match(re) || [])[1] || '';
|
|
319
|
+
const ld = h.replace(/\\\//g, '/');
|
|
320
|
+
const imgs = h.match(/<img\b[^>]*>/gi) || [];
|
|
321
|
+
const text = strip(h.replace(/<(header|nav|footer)[\s\S]*?<\/\1>/gi, ' ')).trim();
|
|
322
|
+
return {
|
|
323
|
+
url: p.url,
|
|
324
|
+
title: titleOf(h),
|
|
325
|
+
description: attr(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)/i),
|
|
326
|
+
h1: h1sOf(h),
|
|
327
|
+
h2: [...h.matchAll(/<h2\b[\s\S]*?<\/h2>/gi)].map((m) => strip(m[0]).trim()).filter(Boolean).slice(0, 12),
|
|
328
|
+
schema: [...new Set([...ld.matchAll(/"@type"\s*:\s*"([^"]+)"/g)].map((m) => m[1]))],
|
|
329
|
+
sameAs: [...new Set([...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []))],
|
|
330
|
+
images: imgs.length, imagesAltEmpty: imgs.filter((i) => /alt=["']\s*["']/i.test(i)).length, imagesNoAltAttr: imgs.filter((i) => !/\balt=/i.test(i)).length,
|
|
331
|
+
forms: (h.match(/<form\b/gi) || []).length,
|
|
332
|
+
social: [...new Set([...h.matchAll(SOCIAL)].map((m) => m[0].replace(/^\/\//, '')))].slice(0, 8),
|
|
333
|
+
messengers: hrefs(h).filter((x) => MESSENGER.test(x)).slice(0, 5),
|
|
334
|
+
words: text.split(/\s+/).filter(Boolean).length,
|
|
335
|
+
visibleDates: [...new Set((text.match(/(?<!\d)\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2}(?!\d)/g) || []))].slice(0, 6),
|
|
336
|
+
prices: [...new Set((text.match(/\d[\d\s]{0,9}(?:₽|руб(?:\.|лей|ля|ль)?(?![а-яё])|USD|\$)/gi) || []).map((x) => x.trim()))].slice(0, 6),
|
|
337
|
+
privacyText: /политик[аи] (?:конфиденциальности|обработки персональных данных)|согласи[ея] на обработку|privacy policy/i.test(text),
|
|
338
|
+
termsText: /правила (?:и порядок )?оказания|публичная оферта|пользовательское соглашение|terms (?:of|and)/i.test(text),
|
|
339
|
+
accordions: (h.match(/class=["'][^"']*(?:faq|accordion|spoiler|toggle)[^"']*["']/gi) || []).length,
|
|
340
|
+
tableTags: (h.match(/<table\b/gi) || []).length,
|
|
341
|
+
tableLike: (h.match(/class=["'][^"']*(?:table|tr\b|td\b)[^"']*["']/gi) || []).length,
|
|
342
|
+
formFields: [...new Set([...h.matchAll(/<input[^>]*(?:type=["'](tel|email|text)["']|placeholder=["']([^"']+)["'])/gi)].map((m) => m[1] || m[2]).filter(Boolean))].slice(0, 6),
|
|
343
|
+
text: text.slice(0, 1800),
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
return { robots: String(robotsText).slice(0, 2000), pages: pages.map(one) };
|
|
347
|
+
}
|