@operstack/audit 0.37.0 → 0.38.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/bin/audit.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { collect, verifyScores, compareRivals } from '../src/collect.mjs';
28
28
  import { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from '../src/handover.mjs';
29
- import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
29
+ import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, fixPrice } from '../src/fix.mjs';
30
30
  import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
31
31
  import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
32
32
  import { renderAgentPrompts, agentPrompts } from '../src/prompts.mjs';
@@ -232,7 +232,7 @@ if (cmd === 'collect') {
232
232
  const plan = buildFixPlan(audit, {
233
233
  platform: opt('--platform', 'files'),
234
234
  lang,
235
- price: Number(opt('--price', lang === 'en' ? 249 : 21000)),
235
+ price: Number(opt('--price', fixPrice(lang))),
236
236
  currency: opt('--currency', lang === 'en' ? 'USD' : 'RUB'),
237
237
  recheckUrl: opt('--recheck-url', ''),
238
238
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.37.0",
3
+ "version": "0.38.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",
@@ -17,6 +17,8 @@
17
17
  * verifyClaims(claims, { site, fetch }) открыть живые страницы и сверить; { problems, checked }
18
18
  */
19
19
 
20
+ import { leadAnswer } from './visibility.mjs';
21
+
20
22
  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
23
 
22
24
  const ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", laquo: '«', raquo: '»', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', thinsp: ' ', middot: '·', copy: '©', times: '×', minus: '−', rub: '₽' };
@@ -47,26 +49,50 @@ export async function fetchLive(url, { fetch = globalThis.fetch, timeoutMs = 200
47
49
  // Действие, которое обещает следующий шаг: «Обсудить проект», «Заявка и смета», «Записаться», «Get a quote».
48
50
  const CTA_RU = /(обсуд|заказ|заявк|получ|запис|позвон|звонок|связат|рассчит|расч[её]т|узнать (цен|стоим)|консульт|купить|в корзин|написат|начать|попроб|оформ|подобрат|смет|бронир|забронир)/i;
49
51
  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)) {
52
+ export const MENU_CTA = /(связат|связь|заказ|позвон|перезвон|звонок|заявк|консульт|запис|обсуд|рассчит|расч[её]т|купить|в корзин|бронир|получить (?:подбор|консульт|смет|кп|предлож|расч)|подобрать|написать|whatsapp|telegram|\b(?:contact|call|book|quote|order|buy|enquir|inquir|get in touch|get (?:a|my|your|free)|shortlist)\b)/i;
53
+ export 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;
54
+ /*
55
+ * Призыв на первом экране: одно правило для отчёта (collect.mjs) и проверки глазами клиента (15.10.2026). Своих правил
56
+ * было два: отчёт мерил первые 900 знаков текста без меню и подвала, проверка глазами клиента брала всё до H1 вместе с
57
+ * меню, и они спорили («С чего начать» в меню etochay.ru, «Купить» на moregroupestate.ru/o-nas/).
58
+ *
59
+ * Первый экран это размер окна, а не доля страницы: на 1280×800 он вмещает около 900 знаков текста (medeq-mc.ru); у
60
+ * длинной страницы 15% текста. Меряем долей видимого текста, а не разметки: у Shopify первые 15% HTML съедают меню и
61
+ * иконки (brainmd.com, 12.10.2026). Скрипты, стили, svg, меню, подвал и боковая колонка в счёт не идут. Пункт меню с
62
+ * общим словом это раздел сайта; кнопка мобильного меню и выдвижной панели на компьютере не видна.
63
+ */
64
+ export function ctaScan(html) {
65
+ // Шапка видна на первом экране: кнопка «Связаться» или «Получить подборку» в ней тоже призыв (проверяющий,
66
+ // moregroupestate.ru и moregroup.estate, 15.10.2026). В шапке и меню считаем только кнопки и слова про связь и заказ.
67
+ const src = String(html || '');
68
+ const h1 = src.search(/<h1\b/i);
69
+ const head = (h1 > 0 ? src.slice(0, h1) : '').replace(/<!--[\s\S]*?-->/g, ' ').replace(/<(script|style|svg|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, ' ');
70
+ for (const m of head.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
71
+ if (/mobile|offcanvas|drawer/i.test(m[2]) || /class=["'][^"']*(?:mobile[-_]?(?:menu|nav)|menu[-_]mobile|offcanvas|drawer)/i.test(head.slice(Math.max(0, m.index - 600), m.index))) continue;
60
72
  const label = visibleText(m[3]) || decode((m[2].match(/(?:title|aria-label|value)="([^"]+)"/i) || [])[1] || '');
61
73
  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]) });
74
+ const strong = m[1].toLowerCase() === 'button' || /\bbtn\b|button/i.test(m[2]);
75
+ if (MENU_CTA.test(label) || (strong && CONTACT_INTENT.test(label))) return { found: true, label: label.slice(0, 60) };
65
76
  }
66
- // Кнопка важнее ссылки в меню: «Обсудить проект» в первом блоке, а не «Написать в MAX» в шапке.
67
- const best = found.find((x) => x.strong) || found[0];
68
- return best ? { found: true, label: best.label } : { found: false };
77
+ const screenHtml = String(html || '').replace(/<!--[\s\S]*?-->/g, ' ')
78
+ .replace(/<(script|style|svg|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, ' ')
79
+ .replace(/<(nav|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, ' ');
80
+ const textLen = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().length;
81
+ const firstScreenChars = Math.max(900, Math.round(textLen(screenHtml) * 0.15));
82
+ let seen = 0; let from = 0;
83
+ for (const m of screenHtml.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
84
+ seen += textLen(screenHtml.slice(from, m.index)); from = m.index;
85
+ if (seen > firstScreenChars) break;
86
+ const label = visibleText(m[3]) || decode((m[2].match(/(?:title|aria-label|value)="([^"]+)"/i) || [])[1] || '');
87
+ const strong = m[1].toLowerCase() === 'button' || /\bbtn\b|button/i.test(m[2]);
88
+ if (!strong && /<li\b[^>]*>\s*$/i.test(screenHtml.slice(Math.max(0, m.index - 200), m.index)) && !MENU_CTA.test(label)) continue;
89
+ if (/mobile|offcanvas|drawer/i.test(m[2]) || /class=["'][^"']*(?:mobile[-_]?(?:menu|nav)|menu[-_]mobile|offcanvas|drawer)/i.test(screenHtml.slice(Math.max(0, m.index - 600), m.index))) continue;
90
+ if (CONTACT_INTENT.test(label) || CONTACT_INTENT.test(m[0])) return { found: true, label: (label || 'кнопка').slice(0, 60) };
91
+ }
92
+ return { found: false };
69
93
  }
94
+ /** Кнопка или ссылка с действием на первом экране живой страницы: то же правило, что в отчёте (ctaScan). */
95
+ export function ctaFirstScreen(html) { return ctaScan(bodyOf(html)); }
70
96
 
71
97
  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
98
  /** Суммы с валютой в видимом тексте. Две разные суммы и больше это цены, а не случайная цифра. */
@@ -101,8 +127,29 @@ export function formsOn(html) {
101
127
  return out;
102
128
  }
103
129
 
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 }; }
130
+ /*
131
+ * Мессенджеры и чаты на сайте: один список для отчёта (collect.mjs, verify.mjs) и проверки глазами клиента. До
132
+ * 15.10.2026 списков было три, и они спорили: «verbox» без границы слова находился внутри «oldHoverBox» у Wix
133
+ * (norkisandco.com), а отчёт писал адрес загрузчика «bitrix24.ru/b19741684/crm» вместо названия чата.
134
+ */
135
+ export const MESSENGER_HREF = /^(?:(?:https?:)?\/\/(?:www\.)?(?:wa\.me|api\.whatsapp\.com|chat\.whatsapp\.com|t\.me|telegram\.me|max\.ru|vk\.me|ig\.me|m\.me|signal\.me|invite\.viber\.com)\/|(?:viber|tg|whatsapp):)/i;
136
+ export const CHAT_WIDGETS = [
137
+ [/(?<![\w-])(?:jivosite|code\.jivo|jivo\.(?:ru|chat))/i, 'Jivo'], [/(?<![\w-])gorgias/i, 'Gorgias'], [/widget\.intercom\.io|intercomSettings|js\.intercomcdn/i, 'Intercom'],
138
+ [/crisp\.chat/i, 'Crisp'], [/tawk\.to/i, 'Tawk.to'], [/livechatinc/i, 'LiveChat'], [/carrotquest/i, 'Carrot quest'], [/(?<![\w-])talk-me/i, 'Talk-Me'],
139
+ [/(?<![\w-])chatra/i, 'Chatra'], [/(?<![\w-])usedesk/i, 'UseDesk'], [/(?<![\w-])webim/i, 'Webim'], [/(?<![\w-])onicon/i, 'Onicon'], [/(?<![\w-])verbox/i, 'Verbox'],
140
+ [/b24-widget|bitrix24\.[a-z]+\/b\d+|bitrix24[^"'\s]*\/crm\/site_button/i, 'Битрикс24'], [/(?<![\w-])envybox/i, 'Envybox'], [/(?<![\w-])livetex/i, 'LiveTex'],
141
+ [/(?<![\w-])chaport/i, 'Chaport'], [/(?<![\w-])tidio/i, 'Tidio'], [/zopim|zendesk\.com\/embeddable|static\.zdassets/i, 'Zendesk'], [/js\.driftt?\.com|drift\.com\/include/i, 'Drift'],
142
+ [/js\.usemessages\.com|hubspot[^"'\s]*conversations/i, 'HubSpot'],
143
+ ];
144
+ /** Название онлайн-чата на странице или null. */
145
+ export function chatWidgetOf(html) { const s = String(html || ''); const hit = CHAT_WIDGETS.find(([re]) => re.test(s)); return hit ? hit[1] : null; }
146
+ export function messengerOn(html) {
147
+ const s = String(html || '');
148
+ const href = [...s.matchAll(/href=["']([^"']+)["']/gi)].map((m) => m[1]).find((h) => MESSENGER_HREF.test(h));
149
+ if (href) return { found: true, what: href.replace(/^(?:https?:)?\/\/(?:www\.)?/, '').slice(0, 40) };
150
+ const chat = chatWidgetOf(s);
151
+ return chat ? { found: true, what: `онлайн-чат ${chat}` } : { found: false };
152
+ }
106
153
 
107
154
  const ABOUT_TEXT = /^(о (компании|нас|студии|агентстве|бюро|клинике|враче|себе|центре|фирме)|об? (агентстве|авторе|основателе)|кто мы|наша (команда|история)|команда|про нас|about( us)?|our (story|team|company)|who we are|the team|company)$/i;
108
155
  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;
@@ -127,13 +174,11 @@ export async function aboutPage(html, base, { fetch, open = fetchLive } = {}) {
127
174
  * Цена в карточке товара не ответ (cimes.ru /solutions/aspro-*: «149 900 ₽» в div цены), счётчик «25 лет» тоже.
128
175
  */
129
176
  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) };
177
+ // То же правило, что в отчёте и бесплатной проверке (leadAnswer в visibility.mjs): своё правило здесь спорило с отчётом и
178
+ // находило «ответ» там, где отчёт честно писал, что абзаца на 20-90 слов нет (plitex-s.ru, svetak.ru, 15.10.2026).
179
+ const r = leadAnswer(html);
180
+ if (!r.hasH1) return { has: null };
181
+ return { has: r.answerFirst, quote: quote(r.answer || r.near || r.firstPara || '', 160) };
137
182
  }
138
183
 
139
184
  // Только однозначные: «compare» и «sravnenie» бывают разделом статей (oper-stack.com/compare/), их решает содержание.
@@ -203,7 +248,10 @@ export function claimsFromAudit(audit) {
203
248
  // Кнопки нет на главной по выборке (или главной в выборке нет, а кнопки нет нигде): это и сверяем.
204
249
  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
250
  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' });
251
+ // Глубину сверяем на странице, где форма правда лежит глубоко: на etochay.ru форма была на 7 внутренних страницах, а
252
+ // главную без формы проверка принимала за «форма глубоко на главной» (прогон на 20 сайтах, 15.10.2026).
253
+ const deepPage = (home && home.formDepth >= 50 ? home : null) || sample.find((p) => p.formDepth >= 50) || (sample.some((p) => 'formDepth' in p) ? null : home);
254
+ if (bad(depth) && /\d+\s?(%|процент)/.test(String(depth.value)) && !/pop-up|всплыва/i.test(String(depth.value)) && (deepPage || !sample.length)) out.push({ kind: 'form-deep', url: deepPage?.url || site, from: 'conv-form-depth' });
207
255
  if (depth && /no form|формы нет/i.test(String(depth.value))) out.push({ kind: 'form-missing', url: home?.url || site, from: 'conv-form-depth' });
208
256
  const about = by['trust-about'];
209
257
  if (bad(about)) out.push({ kind: 'about-missing', url: site, from: 'trust-about' });
@@ -213,7 +261,7 @@ export function claimsFromAudit(audit) {
213
261
  const util = by.utility;
214
262
  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
263
  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' });
264
+ if (mess && bad(mess) && /none|нет/i.test(String(mess.value))) out.push({ kind: 'messenger-missing', url: home?.url || site, from: 'conv-messenger' });
217
265
  return dedupe(out);
218
266
  }
219
267
 
@@ -260,8 +308,10 @@ export function claimsFromText(text, { lang = 'ru', site = '' } = {}) {
260
308
  if (!re.test(line)) continue;
261
309
  // «5 из 20 страниц ставят его в первых 15 процентах»: у части страниц кнопка есть, и главная могла быть среди них.
262
310
  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/);
311
+ const withCta = stat.match(/(\d+) из (?:\d+|них)[^.\n]{0,60}(?:став|вид(?:н|ен))|(\d+) of (?:\d+|them)[^.\n]{0,60}\b(?:put|is in|shows)/);
264
312
  if (kind === 'cta-missing' && (/не на всех|not on (every|all)/i.test(stat) || (withCta && Number(withCta[1] ?? withCta[2]) > 0))) continue;
313
+ // «Мессенджеров нет, но есть онлайн-чат» не утверждение «связи нет» (plitex-s.ru, perina.by, 15.10.2026).
314
+ if (kind === 'messenger-missing' && /есть онлайн-чат|carry a live chat|live chat/i.test(stat)) continue;
265
315
  // «Начало страницы» в задаче: ссылка в этой строке или двух соседних.
266
316
  const near = [lines[i - 1], line, lines[i + 1]].join(' ').match(/https?:\/\/[^\s)»"',]+/);
267
317
  // Адрес страницы в самой строке, часто без домена («1 адрес: /solutions/shop/», «начиная с /solutions/aspro-maksimum/»).