@operstack/audit 0.33.4 → 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 +70 -18
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +25 -19
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +42 -17
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +4 -1
- 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 +2 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +1 -1
- package/src/verify.mjs +347 -0
package/src/test-narrative.mjs
CHANGED
|
@@ -72,7 +72,7 @@ ok('резюме говорит, сколько прочитано, а не ск
|
|
|
72
72
|
|
|
73
73
|
// Числа сходятся между собой
|
|
74
74
|
const openN = 4; const mech = 2; // alt, conv-cta механические; thin и answer-first это тексты
|
|
75
|
-
ok('в выводе число открытых проверок верное', d.summary.verdict.
|
|
75
|
+
ok('в выводе число открытых проверок верное', d.summary.verdict.startsWith(`${openN} `) && /не полностью/.test(d.summary.verdict));
|
|
76
76
|
ok('и механических не больше, чем всех', d.summary.verdict.includes(`${mech} правятся`) || d.summary.verdict.includes(`${mech} правится`));
|
|
77
77
|
|
|
78
78
|
// Приоритеты это действие, а не название проверки
|
|
@@ -83,7 +83,7 @@ ok('работа с текстами названа работой, а не от
|
|
|
83
83
|
// Русский отчёт по английскому аудиту не должен содержать английских названий проверок
|
|
84
84
|
const ruText = JSON.stringify([d.summary, d.content, d.aeo, d.roadmap, d.closing]);
|
|
85
85
|
ok('в русском черновике нет английских названий проверок', !/Image alt text|Thin pages|call to action/.test(ruText));
|
|
86
|
-
ok('области названы по-русски',
|
|
86
|
+
ok('области названы по-русски', /доступ и адреса|техническая часть|конверсия/i.test(d.summary.lead));
|
|
87
87
|
|
|
88
88
|
// Дорожная карта: уже написанные строки переведены, пустые заполнены
|
|
89
89
|
ok('готовая строка карты переведена', d.roadmap[0].items[0].includes('подписи'));
|
package/src/test-prompts.mjs
CHANGED
|
@@ -39,9 +39,9 @@ ok('английский файл без кириллицы', !/[А-Яа-яЁё]
|
|
|
39
39
|
|
|
40
40
|
// Задания не обещают того, чего мы не знаем
|
|
41
41
|
const contact = agentPrompt(c('conv-messenger', 'bad', 'Мессенджер', 'нет'), { lang: 'ru' });
|
|
42
|
-
ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси
|
|
42
|
+
ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси владельца/i.test(contact));
|
|
43
43
|
const src = agentPrompt(c('sources', 'bad', 'Источники', '2 из 20'), { lang: 'ru' });
|
|
44
|
-
ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник
|
|
44
|
+
ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник хуже/i.test(src));
|
|
45
45
|
const af = agentPrompt(c('answer-first', 'bad', 'Ответ в начале', '2 из 20'), { lang: 'ru' });
|
|
46
46
|
ok('запрещает придумывать цифру', /не придумывай её|спроси владельца/.test(af));
|
|
47
47
|
|
package/src/test-rendered.mjs
CHANGED
|
@@ -64,7 +64,7 @@ ok('а пишем, что часть текста не доходит', /не д
|
|
|
64
64
|
|
|
65
65
|
const none = jsBlindnessCheck([], 'ru');
|
|
66
66
|
is('без браузера статус «не измеряли»', none.status, 'na');
|
|
67
|
-
ok('и сказано
|
|
67
|
+
ok('и сказано почему, без слов про «машину»', /не измеряли/.test(none.value) && !/машин/.test(none.value));
|
|
68
68
|
|
|
69
69
|
const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
|
|
70
70
|
ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
|
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
|
+
}
|