@operstack/audit 0.36.2 → 0.37.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/README.md CHANGED
@@ -145,6 +145,16 @@ Three sources, each named in the output. The queries come from free Yandex and G
145
145
 
146
146
  The map is an appendix, page 11 of the report, and the sources table names Topvisor as paid and counted towards no score. The same text tells the buyer where to check the figures for free: Yandex Wordstat and the Google Ads Keyword Planner with their own account. A query without a volume says "no data", never 0. `--seeds "a; b"` overrides the automatic seeds when the analyst knows the niche better than the headings do; `--no-volumes` builds the map without numbers.
147
147
 
148
+ ## Past mistakes are an exam: `exam/errors.json`
149
+
150
+ Every wrong statement that reached a client, or nearly did, is written down in `exam/errors.json` (in the repository only, not in the npm package: it names client sites): the site, what the report or letter said, what is actually true, which rule catches it, and which test proves it stays caught. `npm test` runs the whole file against recorded pages in `test/etalon` (no network): the engine must not say the old wrong thing again, and the client-eye check (`src/client-eye.mjs`) must refute it against the page the client would open. An entry with no test fails the run.
151
+
152
+ **Rule: any mistake found by Maxim, Hermes or a reviewer is first added to `exam/errors.json` with a test, and only then fixed.** If the fix comes first, nothing stops the mistake from coming back.
153
+
154
+ Правило по-русски: любая ошибка, найденная Максимом, Hermes или проверяющим, сначала добавляется в `exam/errors.json` и тестом, потом чинится. Найденное, но ещё не исправленное лежит в `exam/open.json` и переезжает в `errors.json` вместе с тестом в день исправления.
155
+
156
+ The client-eye check reads the finished report (check rows and sampled pages), the task file or a letter, takes every "you have no X", "add X" or "X is there", opens that page again with an ordinary browser User-Agent and looks for X a different way than `collect` did: a button with an action before the end of the first block, sums with a currency, a lead form rather than a cookie window or search, an about page that actually opens, a paragraph with a figure right under the H1. When the live page says the opposite, the report or letter is not sent.
157
+
148
158
  ## Example
149
159
 
150
160
  `examples/sample-audit.json` is a complete, fictional audit of "Example Villas". `npm run sample` renders it to HTML and PDF next to the JSON.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
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",
@@ -22,11 +22,13 @@
22
22
  "./verify": "./src/verify.mjs",
23
23
  "./review": "./src/review.mjs",
24
24
  "./consistency": "./src/consistency.mjs",
25
- "./niche": "./src/niche.mjs"
25
+ "./niche": "./src/niche.mjs",
26
+ "./client-eye": "./src/client-eye.mjs"
26
27
  },
27
28
  "files": [
28
29
  "bin",
29
30
  "src",
31
+ "!src/test-*.mjs",
30
32
  "templates",
31
33
  "licensing",
32
34
  "examples/sample-audit.json",
@@ -34,7 +36,7 @@
34
36
  "LICENSE"
35
37
  ],
36
38
  "scripts": {
37
- "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-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.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 && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs && node src/test-planted.mjs && node src/test-presentation.mjs && node src/test-answer-first.mjs",
39
+ "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-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.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 && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs && node src/test-planted.mjs && node src/test-presentation.mjs && node src/test-answer-first.mjs && node src/test-client-eye.mjs && node src/test-exam.mjs",
38
40
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
39
41
  "verify:live": "node src/verify-live.mjs"
40
42
  },
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Проверка «глазами клиента» по живому сайту (15.10.2026, задание «проверка глазами клиента»).
3
+ *
4
+ * Простым языком. Все ошибки, что доходили или почти дошли до клиентов, одного вида: мы советуем то, что у
5
+ * клиента уже есть. cimes.ru: «поставьте кнопку на первый экран» (кнопка «Обсудить проект» стоит в шапке),
6
+ * «форма на 86%» (это окно cookie), «цен нет» (цены под H1), «/solutions/shop/ служебная» (это каталог),
7
+ * «нет прямого ответа с цифрой» (он сразу под H1), в письме «добавьте абзац» (он уже на главной).
8
+ * Сверка фактов сверяла отчёт с собранными данными, то есть с тем же, в чём ошибся сбор.
9
+ *
10
+ * Здесь каждое утверждение «нет X», «добавьте X», «X есть» сверяется со страницей, открытой заново, с обычным
11
+ * браузерным User-Agent, и другим способом, чем у сбора: не процент высоты страницы, а «есть ли кнопка до конца
12
+ * первого блока»; не одна регулярка цены, а сами суммы с валютой; не ссылка со словом about, а открытая страница.
13
+ * Совпало со словами отчёта, молчим. Живая страница говорит обратное, это находка: отчёт или письмо не уходят.
14
+ *
15
+ * claimsFromAudit(audit) утверждения отчёта по строкам проверок и выборке
16
+ * claimsFromText(text, { lang }) утверждения из готового текста: задачи, письмо Аналитика
17
+ * verifyClaims(claims, { site, fetch }) открыть живые страницы и сверить; { problems, checked }
18
+ */
19
+
20
+ export const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
21
+
22
+ const ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", laquo: '«', raquo: '»', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', thinsp: ' ', middot: '·', copy: '©', times: '×', minus: '−', rub: '₽' };
23
+ export function decode(s) {
24
+ return String(s || '').replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&([a-z]+);/gi, (m, n) => ENTITIES[n.toLowerCase()] ?? m);
25
+ }
26
+ /** Видимый текст страницы: без скриптов, стилей, картинок-SVG и шаблонов. */
27
+ export function visibleText(html) {
28
+ return decode(String(html || '').replace(/<!--[\s\S]*?-->/g, ' ').replace(/<(script|style|noscript|svg|template|head)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ')).replace(/[\s  ]+/g, ' ').trim();
29
+ }
30
+ const bodyOf = (html) => { const s = String(html || ''); const i = s.search(/<body\b/i); return i >= 0 ? s.slice(i) : s; };
31
+ const words = (t) => (String(t).match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
32
+ const hostOf = (u) => { try { return new URL(u).host.replace(/^www\./, '').toLowerCase(); } catch { return ''; } };
33
+ const abs = (href, base) => { try { return new URL(decode(href), base).href; } catch { return ''; } };
34
+ const quote = (t, n = 90) => { const s = String(t).replace(/\s+/g, ' ').trim(); return s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')}…` : s; };
35
+
36
+ /** Свежая страница с браузерным User-Agent. Не открылась, значит null: проверить нечем, находкой это не считается. */
37
+ export async function fetchLive(url, { fetch = globalThis.fetch, timeoutMs = 20000 } = {}) {
38
+ try {
39
+ const r = await fetch(url, { headers: { 'user-agent': BROWSER_UA, 'accept-language': 'ru-RU,ru;q=0.9,en;q=0.8', accept: 'text/html,application/xhtml+xml' }, redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
40
+ const html = await r.text();
41
+ return { url: r.url || url, status: r.status, html, text: visibleText(html) };
42
+ } catch { return null; }
43
+ }
44
+
45
+ /* ---------- что есть на живой странице ---------- */
46
+
47
+ // Действие, которое обещает следующий шаг: «Обсудить проект», «Заявка и смета», «Записаться», «Get a quote».
48
+ const CTA_RU = /(обсуд|заказ|заявк|получ|запис|позвон|звонок|связат|рассчит|расч[её]т|узнать (цен|стоим)|консульт|купить|в корзин|написат|начать|попроб|оформ|подобрат|смет|бронир|забронир)/i;
49
+ const CTA_EN = /\b(contact|get (a|your|started|in touch)|book|request|start|try|buy|order|call|schedule|quote|demo|sign up|subscribe|shop now|talk to|let'?s talk|enquire|inquire|add to cart)\b/i;
50
+ /** Кнопка или ссылка с действием в шапке или в первом блоке (до конца первого блока после H1). */
51
+ export function ctaFirstScreen(html) {
52
+ const body = bodyOf(html);
53
+ const h1 = body.search(/<h1\b/i);
54
+ const h1End = h1 >= 0 ? body.indexOf('</h1>', h1) : -1;
55
+ // Первый экран: всё до H1 и первый блок после него (до второго заголовка или 6000 знаков разметки).
56
+ const after = h1End >= 0 ? body.slice(h1End, h1End + 6000).split(/<h[2-6]\b/i)[0] : '';
57
+ const zone = h1End >= 0 ? body.slice(0, h1End) + after : body.slice(0, Math.round(body.length * 0.25));
58
+ const found = [];
59
+ for (const m of zone.matchAll(/<(a|button)\b([^>]*)>([\s\S]{0,300}?)<\/\1>/gi)) {
60
+ const label = visibleText(m[3]) || decode((m[2].match(/(?:title|aria-label|value)="([^"]+)"/i) || [])[1] || '');
61
+ if (!label || label.length > 60) continue;
62
+ // Мобильное меню и выдвижная панель на компьютере не видны: их кнопка не первый экран (moregroupestate.ru, 15.10.2026).
63
+ if (/mobile|offcanvas|drawer/i.test(m[2]) || /class=["'][^"']*(?:mobile[-_]?(?:menu|nav)|menu[-_]mobile|offcanvas|drawer)/i.test(zone.slice(Math.max(0, m.index - 600), m.index))) continue;
64
+ if (CTA_RU.test(label) || CTA_EN.test(label)) found.push({ label, strong: m[1].toLowerCase() === 'button' || /\bbtn\b|button/i.test(m[2]) });
65
+ }
66
+ // Кнопка важнее ссылки в меню: «Обсудить проект» в первом блоке, а не «Написать в MAX» в шапке.
67
+ const best = found.find((x) => x.strong) || found[0];
68
+ return best ? { found: true, label: best.label } : { found: false };
69
+ }
70
+
71
+ const PRICE_RE = /(?:от\s+)?(?<![\d.,])[1-9]\d{0,2}(?:[   ]\d{3})+(?:[.,]\d+)?\s?(?:₽|руб(?:\.|лей|ля)?|р\.)(?![\p{L}])|(?<![\d.,])[1-9]\d*(?:[.,]\d+)?\s?(?:₽|руб(?:\.|лей|ля)?)(?![\p{L}])|[$€£]\s?[1-9][\d,]*(?:\.\d{1,2})?|(?<![\d.,])[1-9][\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP)\b/gu;
72
+ /** Суммы с валютой в видимом тексте. Две разные суммы и больше это цены, а не случайная цифра. */
73
+ export function pricesOn(text) {
74
+ return [...new Set((String(text).match(PRICE_RE) || []).map((x) => x.replace(/[\s  ]+/g, ' ').trim()))];
75
+ }
76
+
77
+ /** Формы и поля на странице по сути: заявка (имя, телефон, почта), окно cookie, поиск. */
78
+ export function formsOn(html) {
79
+ const body = bodyOf(html);
80
+ const out = [];
81
+ for (const m of body.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/gi)) {
82
+ const inner = m[2]; const t = visibleText(inner);
83
+ const lead = /<input\b[^>]*type=["']?(tel|email)\b|<input\b[^>]*(name|placeholder|id)=["'][^"']*(phone|tel|телефон|e-?mail|почт|name|имя|fio|фио)/i.test(inner) || /<textarea\b/i.test(inner);
84
+ const kind = lead ? 'lead' : /cookie|куки/i.test(t) ? 'cookie' : /type=["']?search|name=["']?(q|s|search|query)\b/i.test(inner) || /^(найти|поиск|search)\b/i.test(t) ? 'search' : 'other';
85
+ out.push({ kind, text: quote(t, 70) });
86
+ }
87
+ // Поля заявки без тега form: виджет записи в теле страницы (neronov.pro, секция #online) это форма на своём месте,
88
+ // а окно помощника или чата (cimes.ru, ai-lead) висит поверх страницы, и глубины у него нет.
89
+ const outside = body.replace(/<form\b[\s\S]*?<\/form>/gi, ' ');
90
+ for (const m of outside.matchAll(/<input\b[^>]*>/gi)) {
91
+ if (!/type=["']?(tel|email)\b|(name|placeholder|data-name|autocomplete)=["'][^"']*(phone|tel|телефон|e-?mail|почт|имя|name)/i.test(m[0])) continue;
92
+ const around = outside.slice(Math.max(0, m.index - 1500), m.index) + m[0];
93
+ const widget = /class=["'][^"']*\b(ai-|chat|widget|assistant|popup|modal|callback|fancybox|jivo|bot)[\w-]*/i.test(around.slice(-700));
94
+ out.push({ kind: widget ? 'widget' : 'lead', text: widget ? 'окно помощника или чата с полями заявки' : 'поля заявки без тега form' });
95
+ break;
96
+ }
97
+ // Форма во всплывающем окне по кнопке: кнопка с действием и признаком окна. Якорь #order ведёт к блоку на той же
98
+ // странице (у babynanny.ru к ссылкам мессенджеров), это не окно с формой.
99
+ const popup = [...body.matchAll(/<(a|button)\b([^>]*)>([\s\S]{0,200}?)<\/\1>/gi)].find((m) => /popup|modal|callback|fancybox|data-(?:bs-)?toggle=["']modal|data-fancybox/i.test(m[2]) && (CTA_RU.test(visibleText(m[3])) || CTA_EN.test(visibleText(m[3]))));
100
+ if (popup) out.push({ kind: 'popup', text: visibleText(popup[3]) });
101
+ return out;
102
+ }
103
+
104
+ const MESSENGER_RE = /href=["'](?:https?:)?\/\/(?:wa\.me|api\.whatsapp\.com|t\.me|telegram\.me|max\.ru|vk\.me|ig\.me|m\.me)\/|href=["'](?:viber|tg|whatsapp):|(jivosite|jivo\.ru|code\.jivo|gorgias|intercom|crisp\.chat|tawk\.to|livechatinc|carrotquest|talk-me|chatra|usedesk|webim|onicon|verbox|b24-widget|bitrix24\.[a-z]+\/b\d+|envybox|livetex|chaport|tidio|zendesk|drift\.com|hubspot.*conversations)/i;
105
+ export function messengerOn(html) { const m = String(html || '').match(MESSENGER_RE); return m ? { found: true, what: m[0].replace(/^href=["']/, '').slice(0, 40) } : { found: false }; }
106
+
107
+ const ABOUT_TEXT = /^(о (компании|нас|студии|агентстве|бюро|клинике|враче|себе|центре|фирме)|об? (агентстве|авторе|основателе)|кто мы|наша (команда|история)|команда|про нас|about( us)?|our (story|team|company)|who we are|the team|company)$/i;
108
+ const ABOUT_HREF = /\/(about|about-us|about-company|our-story|team|company|o-nas|o-kompanii|kompaniya|o-studii|o-klinike|o-vrache|obo-mne|o-sebe|our-practice)\/?(?:$|[?#])/i;
109
+ /** Страница о компании: ссылка своего сайта с понятным текстом или адресом, которая открывается и где есть текст. */
110
+ export async function aboutPage(html, base, { fetch, open = fetchLive } = {}) {
111
+ const host = hostOf(base);
112
+ const links = [...String(html || '').matchAll(/<a\b[^>]*href=["']([^"'#]+)["'][^>]*>([\s\S]{0,200}?)<\/a>/gi)]
113
+ .map((m) => ({ url: abs(m[1], base), label: visibleText(m[2]) }))
114
+ .filter((l) => l.url && hostOf(l.url) === host && (ABOUT_TEXT.test(l.label) || ABOUT_HREF.test(new URL(l.url).pathname)));
115
+ const tried = new Set();
116
+ for (const l of [...links, ...['/about/', '/o-kompanii/', '/o-nas/', '/company/', '/about-us/'].map((p) => ({ url: abs(p, base), label: '' }))]) {
117
+ if (tried.has(l.url) || tried.size >= 6) continue;
118
+ tried.add(l.url);
119
+ const p = await open(l.url, { fetch });
120
+ if (p && p.status === 200 && words(p.text) >= 80 && hostOf(p.url) === host && new URL(p.url).pathname.replace(/\/$/, '') !== '') return { exists: true, url: p.url, label: l.label };
121
+ }
122
+ return { exists: false, tried: [...tried].map((u) => new URL(u).pathname) };
123
+ }
124
+
125
+ /**
126
+ * Ответ под H1: абзац прозы в первом блоке после заголовка (до второго заголовка или формы), в котором есть число.
127
+ * Цена в карточке товара не ответ (cimes.ru /solutions/aspro-*: «149 900 ₽» в div цены), счётчик «25 лет» тоже.
128
+ */
129
+ export function answerUnderH1(html) {
130
+ const body = bodyOf(html);
131
+ const end = body.search(/<\/h1>/i);
132
+ if (end < 0) return { has: null };
133
+ const zone = body.slice(end + 5, end + 8000).split(/<h[2-6]\b|<form\b/i)[0];
134
+ const paras = [...zone.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => visibleText(m[1])).filter(Boolean).slice(0, 3);
135
+ const hit = paras.find((t) => words(t) >= 10 && /(?<![\p{L}\d])\d+(?:[\s.,\u00a0]\d+)*(?![\p{L}\d])/u.test(t));
136
+ return { has: Boolean(hit), quote: quote(hit || paras[0] || '', 160) };
137
+ }
138
+
139
+ // Только однозначные: «compare» и «sravnenie» бывают разделом статей (oper-stack.com/compare/), их решает содержание.
140
+ const UTILITY_PATH = /\/(cart|basket|korzina|checkout|order|login|signin|sign-in|register|account|my-account|lk|personal|auth|search|poisk|wishlist|izbrannoe|tag|tags|author|feed|rss|thank-you|spasibo)(\/|$)/i;
141
+ /** Служебная страница по сути: корзина, вход, поиск, теги. Страница с заголовком и текстом не служебная. */
142
+ export function realContentPage(p) {
143
+ if (!p || p.status !== 200) return null;
144
+ const path = (() => { try { return new URL(p.url).pathname; } catch { return ''; } })();
145
+ const noindex = /<meta[^>]+name=["']robots["'][^>]+noindex/i.test(p.html);
146
+ return !UTILITY_PATH.test(path) && !noindex && /<h1\b/i.test(p.html) && words(p.text) >= 250 ? { words: words(p.text), h1: visibleText((p.html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i) || ['', ''])[1]) } : null;
147
+ }
148
+
149
+ /**
150
+ * Суть абзаца уже на странице (15.10.2026, правка недели cimes): все его числа и большая часть слов стоят в одном
151
+ * месте страницы, или почти все слова. Окно, а не вся страница: те же слова, разбросанные по большой главной, не
152
+ * значат, что этот абзац там есть. Для одного предложения пороги ниже (SENTENCE): «Цены и сроки рассчитываем до
153
+ * начала работ и закрепляем в договоре» при «Цены и сроки считаем до начала работ и закрепляем договором» на сайте.
154
+ */
155
+ export const SENTENCE = { share: 0.75, numShare: 0.5, minNums: 1, stem: 4 };
156
+ export function alreadyOnPage(x, pageText, { share: shareMin = 0.85, numShare = 0.6, minNums = 2, stem = 5 } = {}) {
157
+ const raw = pageText && typeof pageText === 'object' ? [pageText.title, pageText.h1, pageText.text].filter(Boolean).join(' ') : pageText;
158
+ const s = String(x || '').trim(); const page = String(raw || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').toLowerCase();
159
+ if (!s || !page) return false;
160
+ const digits = (t) => [...String(t).matchAll(/\d[\d\s ]*\d|\d/g)].map((m) => m[0].replace(/[\s ]/g, ''));
161
+ const nums = digits(s).filter((n) => n.length >= 3);
162
+ const stems = [...new Set((s.toLowerCase().match(/[\p{L}]{5,}/gu) || []).map((w) => w.slice(0, stem)))];
163
+ if (!stems.length) return false;
164
+ const span = Math.max(400, Math.round(s.length * 2)); const step = Math.max(80, Math.round(span / 4));
165
+ for (let i = 0; i < Math.max(1, page.length - span + step); i += step) {
166
+ const w = page.slice(i, i + span);
167
+ const share = stems.filter((t) => w.includes(t)).length / stems.length;
168
+ const wd = new Set(digits(w));
169
+ const allNums = nums.length > 0 && nums.every((n) => wd.has(n));
170
+ if ((nums.length >= minNums && allNums && share >= numShare) || share >= shareMin) return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Абзац-ответ уже на странице: целиком (alreadyOnPage), или его первое предложение (сам ответ на вопрос), или больше
177
+ * половины его предложений стоят на странице почти теми же словами. Длинный абзац, где героический блок главной
178
+ * пересказан и дополнен двумя фразами из других мест, иначе проходил целиком (cimes.ru, 15.10.2026, вторая пересборка).
179
+ */
180
+ export function paragraphOnPage(x, pageText) {
181
+ if (alreadyOnPage(x, pageText)) return true;
182
+ const sentences = String(x || '').split(/(?<=[.!?])\s+/).map((t) => t.trim()).filter((t) => t.split(/\s+/).length >= 5);
183
+ if (!sentences.length) return false;
184
+ // Начало абзаца (два предложения и больше) одним блоком на странице: ядро ответа уже стоит, хвост добавлен из других мест.
185
+ for (let k = 2; k < sentences.length; k++) if (alreadyOnPage(sentences.slice(0, k).join(' '), pageText)) return true;
186
+ const on = sentences.map((t) => alreadyOnPage(t, pageText, SENTENCE));
187
+ return on[0] || on.filter(Boolean).length * 2 > sentences.length;
188
+ }
189
+
190
+ /* ---------- утверждения ---------- */
191
+
192
+ /**
193
+ * Утверждения платного отчёта по строкам проверок и выборке страниц. Каждое: что сказано (kind),
194
+ * о какой странице (url), откуда (from), чтобы находка называла строку отчёта.
195
+ */
196
+ export function claimsFromAudit(audit) {
197
+ const site = audit?.meta?.site || audit?.meta?.url || (audit?.meta?.host ? `https://${audit.meta.host}/` : '');
198
+ const by = Object.fromEntries((audit?.checks || []).map((c) => [c.id, c]));
199
+ const sample = (audit?.sample || []).filter((p) => p && p.url && p.title !== undefined);
200
+ const home = sample.find((p) => { try { return new URL(p.url).pathname.replace(/\/$/, '') === ''; } catch { return false; } });
201
+ const out = [];
202
+ const bad = (c) => c && (c.status === 'bad' || c.status === 'warn');
203
+ // Кнопки нет на главной по выборке (или главной в выборке нет, а кнопки нет нигде): это и сверяем.
204
+ if (bad(by['conv-cta']) && (home ? home.ctaFirstScreen === false : sample.length && sample.every((p) => p.ctaFirstScreen === false))) out.push({ kind: 'cta-missing', url: home?.url || site, from: 'conv-cta' });
205
+ const depth = by['conv-form-depth'];
206
+ if (bad(depth) && /\d+\s?(%|процент)/.test(String(depth.value)) && !/pop-up|всплыва/i.test(String(depth.value))) out.push({ kind: 'form-deep', url: home?.url || site, from: 'conv-form-depth' });
207
+ if (depth && /no form|формы нет/i.test(String(depth.value))) out.push({ kind: 'form-missing', url: home?.url || site, from: 'conv-form-depth' });
208
+ const about = by['trust-about'];
209
+ if (bad(about)) out.push({ kind: 'about-missing', url: site, from: 'trust-about' });
210
+ else if (about && about.status === 'ok') out.push({ kind: 'about-present', url: site, from: 'trust-about' });
211
+ if (sample.length && sample.every((p) => p.priceShown === false)) out.push({ kind: 'prices-missing', url: home?.url || site, from: 'price' });
212
+ if (home && home.answerFirst === false) out.push({ kind: 'answer-missing', url: home.url, from: 'answer-first' });
213
+ const util = by.utility;
214
+ if (bad(util)) for (const m of String(util.value).matchAll(/(?:https?:\/\/[^\s,;]+|(?<![\w])\/[\w\-./%]+\/?)/g)) out.push({ kind: 'utility', url: abs(m[0], site), from: 'utility' });
215
+ const mess = by['conv-messenger'];
216
+ if (mess && /none|нет/i.test(String(mess.value)) && mess.status !== 'na') out.push({ kind: 'messenger-missing', url: home?.url || site, from: 'conv-messenger' });
217
+ return dedupe(out);
218
+ }
219
+
220
+ const CLAIM_TEXT = {
221
+ ru: [
222
+ ['cta-missing', /(призыв\S* к действию|кнопк\S*|действие)[^.\n]{0,60}(в|на) перв\S* экран|на первом экране нечего нажать/i],
223
+ ['prices-missing', /(на сайте|на странице|на главной|на проверенных страницах)[^.\n]{0,30}(нет|не видно|не указан\S*)[^.\n]{0,25}цен|цен\S* (нет|не указан\S*|не видн\S*)|(добавьте|укажите|опубликуйте) цен/i],
224
+ ['form-deep', /(поднять|поднимите|поднимите) форму|форм\S*[^.\n]{0,40}\d+ ?(%|процент\S*) вниз/i],
225
+ ['form-missing', /формы (заявки )?нет|нет (ни одной )?формы|(добавьте|поставьте) форму/i],
226
+ ['about-missing', /страниц\S* о компании нет|нет страниц\S* о компании|(добавьте|сделайте|создайте) страниц\S* «?о компании/i],
227
+ ['answer-missing', /(нет|без) прям\S* ответ\S*[^.\n]{0,40}(цифр|числ)|постав\S* в (её|его|начало)[^.\n]{0,20}прям\S* ответ с цифр/i],
228
+ ['messenger-missing', /(мессенджер\S*|чат\S*)[^.\n]{0,30}(нет|не найден)|нет (ни )?(мессенджер|чат)/i],
229
+ ['address-missing', /на сайте нет (почтового )?адреса|адрес\S* (на сайте )?(нет|не указан)/i],
230
+ ['about-present', /страниц\S* о компании\s+✓|на неё ведут ссылки с проверенных страниц/i],
231
+ ['utility', /служебн\S* страниц\S*[^\n]{0,80}?(?:https?:\/\/\S+|\s\/[\w\-./%]+\/?)/i],
232
+ ],
233
+ en: [
234
+ ['cta-missing', /(call to action|button)[^.\n]{0,60}(first screen|above the fold)|nothing to click (in|on) the first screen/i],
235
+ ['prices-missing', /no price|prices? (are|is) not (shown|visible|listed)|(add|publish|show) (your )?prices/i],
236
+ ['form-deep', /(move|bring) the form (up|higher)|form[^.\n]{0,40}\d+ ?% down/i],
237
+ ['form-missing', /no (contact )?form\b|add a (contact )?form/i],
238
+ ['about-missing', /no about page|(add|create) an about page/i],
239
+ ['answer-missing', /no direct answer[^.\n]{0,40}(figure|number)|open[^.\n]{0,20}with a direct answer/i],
240
+ ['messenger-missing', /no messenger|no (live )?chat\b/i],
241
+ ['address-missing', /no (postal |street )?address (on|is published)/i],
242
+ ['about-present', /linked from the sampled pages/i],
243
+ ['utility', /utility pages?[^\n]{0,80}?(?:https?:\/\/\S+|\s\/[\w\-./%]+\/?)/i],
244
+ ],
245
+ };
246
+ /**
247
+ * Утверждения из готового текста (задачи, письмо). Страница утверждения: ссылка в той же строке или главная.
248
+ * Абзац «добавьте этот абзац» (правка недели) берётся целиком: его проверяют по странице, куда он назначен.
249
+ */
250
+ export function claimsFromText(text, { lang = 'ru', site = '' } = {}) {
251
+ const t = String(text || '');
252
+ const out = [];
253
+ const lines = t.split('\n');
254
+ for (const [i, line] of lines.entries()) {
255
+ // Строка, где старое утверждение приведено, чтобы его опровергнуть («Неверно: кнопка есть»), не утверждение.
256
+ if (/неверно|был[аио]? ошибк|ошибочн|исправил|\bwrong\b|was a mistake|incorrect/i.test(line)) continue;
257
+ // «Как проверить: кнопка видна на первом экране» это мерка для исполнителя, а не утверждение о главной (moregroupestate.ru).
258
+ if (/как проверить|how to check|^\W*(?:проверка|verify)\b/i.test(line)) continue;
259
+ for (const [kind, re] of CLAIM_TEXT[lang === 'en' ? 'en' : 'ru']) {
260
+ if (!re.test(line)) continue;
261
+ // «5 из 20 страниц ставят его в первых 15 процентах»: у части страниц кнопка есть, и главная могла быть среди них.
262
+ const stat = [line, lines[i + 1], lines[i + 2], lines[i + 3]].join(' ');
263
+ const withCta = stat.match(/(\d+) из \d+[^.\n]{0,60}став|(\d+) of \d+[^.\n]{0,60}\bput/);
264
+ if (kind === 'cta-missing' && (/не на всех|not on (every|all)/i.test(stat) || (withCta && Number(withCta[1] ?? withCta[2]) > 0))) continue;
265
+ // «Начало страницы» в задаче: ссылка в этой строке или двух соседних.
266
+ const near = [lines[i - 1], line, lines[i + 1]].join(' ').match(/https?:\/\/[^\s)»"',]+/);
267
+ // Адрес страницы в самой строке, часто без домена («1 адрес: /solutions/shop/», «начиная с /solutions/aspro-maksimum/»).
268
+ const path = (line.match(/(?:https?:\/\/[^\s,;)»]+|(?<=\s)\/[\w\-./%]+\/?)/) || [])[0] || null;
269
+ const named = path ? abs(path, site) : near && (!site || hostOf(near[0]) === hostOf(site)) ? near[0].replace(/[.;:]$/, '') : null;
270
+ // «Нет на 11 из 20 страниц» говорит о части выборки, а не о главной: без названной страницы такое не сверяем.
271
+ const agg = line.match(/(?:на|в)\s+(\d+)\s+из\s+(\d+)|\b(\d+) of (\d+)/);
272
+ if (!named && kind === 'answer-missing' && !/главн|home ?page/i.test(line) && agg && Number(agg[1] ?? agg[3]) < Number(agg[2] ?? agg[4])) continue;
273
+ if (kind === 'utility' && !path) continue;
274
+ out.push({ kind, url: named || site, from: quote(line, 120) });
275
+ }
276
+ }
277
+ // Правка недели: абзац между «Добавьте этот абзац…» и следующей пустой строкой после него.
278
+ const add = t.match(/(Добавьте этот абзац|Add this paragraph)[^\n]*\n+([\s\S]+?)(\n\s*\n|$)/i);
279
+ if (add && add[2].trim().length > 60) out.push({ kind: 'paragraph-new', url: site, text: add[2].trim(), from: quote(add[2], 120) });
280
+ return dedupe(out);
281
+ }
282
+ function dedupe(list) { const seen = new Set(); return list.filter((c) => { const k = `${c.kind} ${c.url} ${c.text || ''}`; if (seen.has(k)) return false; seen.add(k); return true; }); }
283
+
284
+ /* ---------- сверка с живой страницей ---------- */
285
+
286
+ const SAY = {
287
+ 'cta-missing': (r) => `кнопка «${r.label}» уже стоит в первом экране ${r.page}`,
288
+ 'prices-missing': (r) => `цены на ${r.page} есть: ${r.list.slice(0, 3).join(', ')}`,
289
+ 'form-deep': (r) => `на ${r.page} нет формы заявки, которую можно мерить по глубине: ${r.what}`,
290
+ 'form-missing': (r) => `форма на ${r.page} есть: ${r.what}`,
291
+ 'about-missing': (r) => `страница о компании есть: ${r.url}${r.label ? ` («${r.label}»)` : ''}`,
292
+ 'about-present': (r) => `страницы о компании нет: ссылок на неё нет, ${r.tried.join(', ')} не открываются`,
293
+ 'answer-missing': (r) => `сразу под H1 на ${r.page} уже стоит ответ с цифрой: «${r.quote}»`,
294
+ utility: (r) => `${r.page} это страница с содержанием (H1 «${quote(r.h1, 50)}», ${r.words} слов), а не служебная`,
295
+ 'messenger-missing': (r) => `на ${r.page} есть связь в мессенджере или чате: ${r.what}`,
296
+ 'address-missing': (r) => `адрес на ${r.page} есть: «${r.quote}»`,
297
+ 'paragraph-new': (r) => `этот абзац уже стоит на ${r.page} почти слово в слово`,
298
+ };
299
+ const pageName = (u) => { try { const p = new URL(u).pathname; return p === '/' || p === '' ? 'главной' : p; } catch { return String(u); } };
300
+
301
+ /**
302
+ * Открыть страницы утверждений заново и сверить. problems: живая страница говорит обратное (текст одной строкой
303
+ * простым русским, для Telegram). checked: что проверено и что вышло, для отчёта о проверке.
304
+ */
305
+ export async function verifyClaims(claims, { site, fetch = globalThis.fetch, open = fetchLive } = {}) {
306
+ const cache = new Map();
307
+ const get = async (u) => { if (!cache.has(u)) cache.set(u, await open(u, { fetch })); return cache.get(u); };
308
+ const problems = []; const checked = [];
309
+ for (const c of claims) {
310
+ const url = c.url || site;
311
+ const p = await get(url);
312
+ if (!p || p.status !== 200) { checked.push({ ...c, result: 'не открылась' }); continue; }
313
+ const page = pageName(p.url || url);
314
+ let hit = null;
315
+ if (c.kind === 'cta-missing') { const r = ctaFirstScreen(p.html); if (r.found) hit = { label: r.label, page }; }
316
+ else if (c.kind === 'prices-missing') { const list = pricesOn(p.text); if (list.length >= 2) hit = { list, page }; }
317
+ else if (c.kind === 'form-deep') { const f = formsOn(p.html); if (!f.some((x) => x.kind === 'lead')) hit = { page, what: f.length ? f.map((x) => (x.kind === 'cookie' ? `окно cookie («${x.text}»)` : x.kind === 'search' ? 'поиск' : x.kind === 'popup' ? `заявка во всплывающем окне по кнопке «${x.text}»` : x.kind === 'widget' ? x.text : `«${x.text}»`)).join(', ') : 'форм нет совсем' }; }
318
+ else if (c.kind === 'form-missing') { const f = formsOn(p.html).filter((x) => ['lead', 'popup', 'widget'].includes(x.kind)); if (f.length) hit = { page, what: f.map((x) => (x.kind === 'popup' ? `заявка во всплывающем окне по кнопке «${x.text}»` : x.text)).join(', ') }; }
319
+ else if (c.kind === 'about-missing' || c.kind === 'about-present') { const r = await aboutPage(p.html, p.url || url, { fetch, open: (u) => get(u) }); if ((c.kind === 'about-missing') === r.exists) hit = r; }
320
+ else if (c.kind === 'answer-missing') { const r = answerUnderH1(p.html); if (r.has) hit = { quote: r.quote, page }; }
321
+ else if (c.kind === 'utility') { const r = realContentPage(p); if (r) hit = { ...r, page }; }
322
+ else if (c.kind === 'messenger-missing') { const r = messengerOn(p.html); if (r.found) hit = { what: r.what, page }; }
323
+ else if (c.kind === 'address-missing') { const m = p.text.match(/(?:ул\.|улица|пр-т|проспект|пер\.|переулок|шоссе|наб\.|бульвар)\s?[\p{L}\d .-]{2,40},?\s*(?:д\.|дом)?\s?\d+[\p{L}]?/iu) || p.text.match(/\d{2,5} [A-Z][a-z]+ (Street|St\.|Avenue|Ave\.|Road|Rd\.|Boulevard|Blvd\.)/); if (m) hit = { quote: quote(m[0], 60), page }; }
324
+ else if (c.kind === 'paragraph-new') { if (paragraphOnPage(c.text, p.text)) hit = { page }; }
325
+ checked.push({ ...c, result: hit ? 'живая страница говорит обратное' : 'подтверждено или нечем опровергнуть' });
326
+ if (hit) problems.push({ kind: c.kind, url, from: c.from, line: SAY[c.kind](hit) });
327
+ }
328
+ return { problems, checked };
329
+ }
330
+
331
+ /**
332
+ * Платный отчёт перед выдачей: утверждения строк проверок и выборки плюс текст задач, всё против живого сайта.
333
+ * Сеть недоступна или страница не открылась: находкой это не считается (проверить нечем, а не «неправда»).
334
+ */
335
+ export async function checkReport(audit, { tasks = '', lang = audit?.meta?.lang || 'en', fetch = globalThis.fetch, open = fetchLive } = {}) {
336
+ const site = audit?.meta?.site || audit?.meta?.url || (audit?.meta?.host ? `https://${audit.meta.host}/` : '');
337
+ const claims = dedupe([...claimsFromAudit(audit), ...claimsFromText(tasks, { lang, site })]);
338
+ return { ...(await verifyClaims(claims, { site, fetch, open })), claims };
339
+ }
package/src/collect.mjs CHANGED
@@ -194,7 +194,9 @@ export function analysePage(url, html) {
194
194
  // H1 из шаблонов Vue и подобных (v-if, :title, {{ }} или внутри <template>) человек не видит: не считаем (14.10.2026, brainmd.com).
195
195
  const inTemplate = (el) => { for (let n = el.parentNode; n; n = n.parentNode) { const tag = String(n.rawTagName || n.tagName || '').toLowerCase(); if (tag === 'template' || tag === 'script') return true; } return false; };
196
196
  const framework = (el) => Object.keys(el.attributes || {}).some((k) => /^(v-|:|@|x-)/.test(k)) || /\{\{|\}\}/.test(el.text);
197
- const h1s = root.querySelectorAll('h1').filter((h) => !inTemplate(h) && !framework(h)).map((h) => h.text.trim()).filter(Boolean);
197
+ // Тот же H1 дважды это копия блока для окна быстрого просмотра или мобильной вёрстки, видимый один (brainmd.com,
198
+ // товары Shopify: «Omega-3 Power» в карточке и в quick-view; отчёт писал «больше одного H1 на 7 из 20», 14.10.2026).
199
+ const h1s = [...new Set(root.querySelectorAll('h1').filter((h) => !inTemplate(h) && !framework(h)).map((h) => h.text.trim().replace(/\s+/g, ' ')).filter(Boolean))];
198
200
  const imgs = root.querySelectorAll('img');
199
201
  // Значки оформления (svg с пустым alt) и счётчики-пиксели не картинки содержания: пустой alt у них правильный.
200
202
  // Фото, выведенные блоком с role="img" и aria-label (Tilda), считаем подписанными (14.10.2026).
@@ -265,7 +267,8 @@ export function analysePage(url, html) {
265
267
  // Conversion path, read from the page itself. No analytics is needed to see whether a visitor
266
268
  // who wants to talk to this business can find a way to do it, and how far down it is.
267
269
  const bodyInner = body?.innerHTML || html;
268
- const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |buy now|add to (cart|bag|basket)|shop (now|all|the)|take the quiz|start the quiz|subscribe (and|&|&amp;) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|оставьте|закаж|закажите|заказать|купить|в корзину|добавить в корзину|узнать цен|узнать стоимост|проверить сайт|начать|обсудить|обсудим|позвонить|перезвон|рассчитать|расч[её]т|записаться|запишитесь|получить (?:предложение|кп|консультац|расч)|callback-|order-btn|request-btn/i;
270
+ // «Получить подборку» тоже призыв (moregroupestate.ru, прогон на своих сайтах 15.10.2026: отчёт писал «на главной призыва нет»).
271
+ const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |buy now|add to (cart|bag|basket)|shop (now|all|the)|take the quiz|start the quiz|subscribe (and|&|&amp;) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|оставьте|закаж|закажите|заказать|купить|в корзину|добавить в корзину|узнать цен|узнать стоимост|проверить сайт|начать|обсудить|обсудим|позвонить|перезвон|рассчитать|расч[её]т|записаться|запишитесь|получить (?:предложение|кп|консультац|расч|подбор|смет|прайс|цен|демо|доступ|бесплатн)|callback-|order-btn|request-btn/i;
269
272
  // «Обсудить проект», «Заказать», «Рассчитать стоимость» тоже призыв: cimes.ru 14.10.2026 отчёт писал «на первом экране нечего нажать».
270
273
  const hrefsLower = links.map((h) => h.toLowerCase());
271
274
  const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
@@ -321,7 +324,10 @@ export function analysePage(url, html) {
321
324
  .replace(/<(nav|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, ' ');
322
325
  const textLen = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().length;
323
326
  const screenText = textLen(screenHtml);
324
- const firstScreenChars = Math.max(300, Math.round(screenText * 0.15));
327
+ // Первый экран это размер окна, а не доля страницы: на 1280×800 он вмещает около 900 знаков текста. У medeq-mc.ru вводный
328
+ // блок главной из четырёх абзацев, и форма телефона с кнопкой «Связаться» видна без прокрутки, а 15% текста страницы
329
+ // кончались раньше неё: отчёт писал «на главной призыва нет» (экзамен по эталонам, 15.10.2026). Длинной странице 15%.
330
+ const firstScreenChars = Math.max(900, Math.round(screenText * 0.15));
325
331
  let ctaFirstScreen = false;
326
332
  {
327
333
  let seen = 0; let from = 0;
@@ -837,12 +843,16 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
837
843
  // Список статей, отзывы с виджетом, рубрики короткие по устройству: их не считаем тонкими (14.10.2026, neronov.pro).
838
844
  const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
839
845
  const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
840
- if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
846
+ // Подпись нейтральная: при «ok» строка уходит в «сильные стороны», и «Thin pages in sample: median 1370 words» там
847
+ // читалась как беда (oper-stack.com, прогон на своих сайтах 15.10.2026).
848
+ if (good.length > 1) checks.push(row('thin', 'content', 'Page length in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
841
849
  // «shop» это витрина, а не служебная страница: cimes.ru/solutions/shop/ продающий каталог (14.10.2026). Только целый сегмент пути.
842
- const utility = sitemapPages.filter((u) => /\/(cart|checkout|wishlist|compare|my-account|tag|author|feed)(\/|$)/i.test(new URL(u).pathname));
850
+ // «compare» не берём: у oper-stack.com /compare/ это раздел сравнений на тысячу слов, а не сравнение товаров магазина
851
+ // (прогон на своих сайтах 15.10.2026). По адресу их не различить, а служебная страница сравнения в карте сайта редкость.
852
+ const utility = sitemapPages.filter((u) => /\/(cart|checkout|wishlist|my-account|tag|author|feed)(\/|$)/i.test(new URL(u).pathname));
843
853
  if (utility.length) {
844
854
  // Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
845
- const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist|\/compare/i, 'wishlist pages'], [/\/my-account/i, 'account pages'], [/\/feed\b/i, 'feeds']];
855
+ const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist/i, 'wishlist pages'], [/\/my-account/i, 'account pages'], [/\/feed\b/i, 'feeds']];
846
856
  const kinds = [...new Set(utility.flatMap((u) => KIND.filter(([re]) => re.test(new URL(u).pathname)).map(([, k]) => k)))];
847
857
  checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', utility.length === 1 ? `1 URL: ${new URL(utility[0]).pathname}` : `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, `${kinds.join(', ') || 'utility pages'} add noise to the index`));
848
858
  }
@@ -892,13 +902,17 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
892
902
  // Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
893
903
  const seen = good.filter((p) => p.dates.visibleMatch).length;
894
904
  // Даты нужны статьям, новостям и кейсам. Нет таких страниц в выборке: оценивать нечего, а не «Частично» (cimes.ru, 14.10.2026).
895
- const ARTICLE = /\/(blog|news|novosti|stati|statya|article|articles|post|posts|journal|zhurnal|media|press|kejsy?|cases?|portfolio|insights|guides?)\/[^/]+/i;
905
+ // Статья Shopify это /blogs/<блог>/<статья>; /blogs/wellness сам по себе список статей.
906
+ const ARTICLE = /\/(blog|news|novosti|stati|statya|article|articles|post|posts|journal|zhurnal|media|press|kejsy?|cases?|portfolio|insights|guides?)\/[^/]+|\/blogs\/[^/]+\/[^/]+/i;
896
907
  const NOT_ARTICLE = /\/(contact|contacts|kontakty?|about|o-nas|o-kompanii|uslugi|services?|price|ceny|prajs|policy|politika|soglasie|cart|checkout)(\/|$)/i;
897
- const articleLike = good.filter((p) => { const path = new URL(p.url).pathname; return ARTICLE.test(path) || (p.schemaTypes || []).some((t) => /^(Article|BlogPosting|NewsArticle|Report|MedicalWebPage)$/.test(t)) || (p.dates?.published && !NOT_ARTICLE.test(path) && path !== '/'); });
908
+ // Товар, каталог и страница магазина не статья, даже если движок магазина ставит им datePublished: у brainmd.com Shopify
909
+ // ставит дату каждому товару, и отчёт писал «дата в разметке на 18 из 20, видна на 0», хотя статьи блога даты показывают (14.10.2026).
910
+ const SHOP = /\/(products?|collections?|catalog|katalog|shop|pages|product-category|tovary?)(\/|$)/i;
911
+ const articleLike = good.filter((p) => { const path = new URL(p.url).pathname; return ARTICLE.test(path) || (p.schemaTypes || []).some((t) => /^(Article|BlogPosting|NewsArticle|Report|MedicalWebPage)$/.test(t)) || (p.dates?.published && !NOT_ARTICLE.test(path) && !SHOP.test(path) && !(p.schemaTypes || []).includes('Product') && path !== '/'); });
898
912
  // Виды считаем отдельно и называем только те, что есть: «статьи блога и новости, 271» было 231 статьёй и 40 работами
899
913
  // портфолио, а новостей у cimes.ru нет вовсе (15.10.2026).
900
914
  const kindCount = (re) => sitemapPages.filter((u) => { try { const p = new URL(u).pathname; return re.test(p) && ARTICLE.test(p); } catch { return false; } }).length;
901
- const notSampled = { blog: kindCount(/\/(blog|stati|statya|article|articles|post|posts|journal|zhurnal|insights|guides?)\//i), news: kindCount(/\/(news|novosti|press|media)\//i), cases: kindCount(/\/(kejsy?|cases?|portfolio)\//i) };
915
+ const notSampled = { blog: kindCount(/\/(blogs?|stati|statya|article|articles|post|posts|journal|zhurnal|insights|guides?)\//i), news: kindCount(/\/(news|novosti|press|media)\//i), cases: kindCount(/\/(kejsy?|cases?|portfolio)\//i) };
902
916
  const notSampledLine = Object.entries(notSampled).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');
903
917
  if (good.length > 2 && !articleLike.length) checks.push(row('dates', 'content', 'Publication dates exposed', 'na', notSampledLine ? `no articles, news or cases in the sample (not sampled from the sitemap: ${notSampledLine}): nothing to date here` : 'no articles, news or cases in the sample: nothing to date'));
904
918
  else if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', (dated >= DATES_OK_MIN || dated >= good.length / 2) && seen >= dated / 2 ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} of them the same date is also visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
@@ -973,7 +987,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
973
987
  const reachable = pagesForTrust.filter((p) => p.forms > 0 || p.telLinks || p.mailLinks || p.messengerLinks || p.contactPages);
974
988
  checks.push(row('conv-contact-path', 'conversion', 'A way to get in touch', reachable.length === pagesForTrust.length ? 'ok' : reachable.length ? 'warn' : 'bad', `${reachable.length} of ${pagesForTrust.length} sampled page(s) offer a form, a contact link or a contact page`));
975
989
  const withCta = pagesForTrust.filter((p) => p.ctaFirstScreen);
976
- checks.push(row('conv-cta', 'conversion', 'A call to action in the first screen', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : withCta.length ? 'warn' : 'bad', `${withCta.length} of ${pagesForTrust.length} sampled page(s) put one in the first 15% of the page`, withCta.length === pagesForTrust.length ? '' : 'a reader who is convinced at the top should not have to hunt'));
990
+ checks.push(row('conv-cta', 'conversion', 'A call to action in the first screen', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : withCta.length ? 'warn' : 'bad', `${withCta.length} of ${pagesForTrust.length} sampled page(s) put one in the first screen`, withCta.length === pagesForTrust.length ? '' : 'a reader who is convinced at the top should not have to hunt'));
977
991
  const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
978
992
  if (depths.length) {
979
993
  const md = median(depths);
package/src/i18n.mjs CHANGED
@@ -167,7 +167,8 @@ const RU = {
167
167
  'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
168
168
  'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
169
169
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'даёт', 'дают')} форму, контактную ссылку или страницу контактов`; } },
170
- 'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
170
+ // С 0.37.0 «на первом экране» без процента (первый экран не меньше 900 знаков текста); старые audit.json несут процент.
171
+ 'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return n[2] ? `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его в первых ${n[2]} процентах страницы` : `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его на первый экран`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
171
172
  'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : (Number(nums(v)[1]) === 1 ? `форма заявки есть на одной странице выборки, на ${pct(nums(v)[0])} вниз по коду страницы` : `медиана ${pct(nums(v)[0])} вниз по коду страницы, измерено на ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'странице' : 'страницах'}`)), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
172
173
  'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => (/only (enquiry )?form/.test(v) ? `в единственной форме заявки в выборке ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')}` : `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`), comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
173
174
  'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
package/src/index.mjs CHANGED
@@ -16,3 +16,4 @@ export { detectNiche, vocabFor } from './niche.mjs';
16
16
  export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
17
17
  export { printFitted, pdfPageCount } from './render.mjs';
18
18
  export { presentationCheck, STATUS_OK_SHARE, STATUS_OK_MIN } from './presentation.mjs';
19
+ export { checkReport as clientEyeReport, claimsFromAudit, claimsFromText, verifyClaims, alreadyOnPage, paragraphOnPage, SENTENCE as SENTENCE_ON_PAGE, fetchLive, BROWSER_UA } from './client-eye.mjs';
package/src/prompts.mjs CHANGED
@@ -373,8 +373,8 @@ const PF = {
373
373
  : { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure.${why}`, verify: `The pages ${list} carry at least three H2s that follow the text.` };
374
374
  },
375
375
  'conv-cta': ({ ru, v }) => (ru
376
- ? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице в верхних 15 процентах высоты, в основном блоке страницы, а не в шапке, есть кнопка или ссылка с этим действием.' }
377
- : { task: `Put one clear action the site already offers into the first screen of every main page: ${v.cta}. One action per screen. Invent no new promises or timings.`, verify: 'Each such page has that button or link within the top 15 percent of its height, in the main content block rather than the header.' }),
376
+ ? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице кнопка или ссылка с этим действием видна на первом экране без прокрутки, в основном блоке страницы, а не в шапке.' }
377
+ : { task: `Put one clear action the site already offers into the first screen of every main page: ${v.cta}. One action per screen. Invent no new promises or timings.`, verify: 'Each such page shows that button or link in the first screen without scrolling, in the main content block rather than the header.' }),
378
378
  'conv-messenger': ({ ru }) => (ru
379
379
  ? { task: 'Спроси владельца, есть ли у компании мессенджер для связи (WhatsApp, Telegram, MAX). Если есть, поставь одну ссылку рядом с телефоном, чтобы она открывала диалог. Если нет, задачу закрой.', verify: 'Либо рядом с телефоном есть рабочая ссылка на мессенджер, либо владелец подтвердил, что его нет.' }
380
380
  : { task: 'Ask the owner whether the business uses a messenger (WhatsApp, Telegram and the like). If yes, put one link next to the phone that opens a conversation. If no, close the task.', verify: 'Either a working messenger link sits next to the phone, or the owner confirmed there is none.' }),
@@ -1,48 +0,0 @@
1
- /** Идентификаторы проверок, снятые с настоящего прогона: тест сверяет с ними фразы владельцу,
2
- * чтобы переименованная проверка не оставила строку, которая никогда не сработает. */
3
- export const CHECK_IDS = [
4
- "agent-card",
5
- "agent-markdown",
6
- "ai-search-access",
7
- "alt",
8
- "answer-first",
9
- "api-catalog",
10
- "canonical",
11
- "conv-contact-path",
12
- "conv-cta",
13
- "conv-form-depth",
14
- "conv-form-fields",
15
- "conv-messenger",
16
- "dates",
17
- "description",
18
- "dup-titles",
19
- "faq-schema",
20
- "h1",
21
- "h1-sample",
22
- "hreflang",
23
- "https",
24
- "llms",
25
- "llms-health",
26
- "og",
27
- "org-schema",
28
- "resources",
29
- "robots",
30
- "robots-ai",
31
- "schema",
32
- "sections",
33
- "sitemap",
34
- "sitemap-health",
35
- "sources",
36
- "tables",
37
- "thin",
38
- "title",
39
- "trust-about",
40
- "trust-contact",
41
- "trust-entity",
42
- "trust-policies",
43
- "trust-profiles",
44
- "ttfb",
45
- "utility",
46
- "viewport",
47
- "www"
48
- ];