@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 +2 -2
- package/package.json +1 -1
- package/src/client-eye.mjs +78 -28
- package/src/collect.mjs +153 -81
- package/src/fix.mjs +124 -5
- package/src/foundation.mjs +2 -1
- package/src/i18n.mjs +49 -25
- package/src/index.mjs +1 -1
- package/src/narrative.mjs +56 -25
- package/src/niche.mjs +13 -12
- package/src/pagekind.mjs +18 -1
- package/src/presentation.mjs +3 -2
- package/src/prompts.mjs +161 -40
- package/src/render.mjs +61 -30
- package/src/rendered.mjs +6 -2
- package/src/tasks-pdf.mjs +10 -5
- package/src/verify.mjs +26 -13
- package/src/visibility.mjs +137 -45
- package/src/vy.mjs +13 -0
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
|
|
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.
|
|
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",
|
package/src/client-eye.mjs
CHANGED
|
@@ -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
|
-
|
|
51
|
-
export
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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|&|&) 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
|
-
|
|
63
|
-
if (
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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))
|
|
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+) из
|
|
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/»).
|