@operstack/audit 0.35.1 → 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 +10 -0
- package/package.json +5 -3
- package/src/client-eye.mjs +339 -0
- package/src/collect.mjs +94 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +37 -23
- package/src/index.mjs +2 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +58 -17
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/verify.mjs +19 -12
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -125
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
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.
|
|
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",
|
|
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(/ /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
|
+
}
|