@operstack/audit 0.36.2 → 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.
Files changed (50) hide show
  1. package/README.md +10 -0
  2. package/bin/audit.mjs +2 -2
  3. package/package.json +5 -3
  4. package/src/client-eye.mjs +389 -0
  5. package/src/collect.mjs +171 -85
  6. package/src/fix.mjs +124 -5
  7. package/src/foundation.mjs +2 -1
  8. package/src/i18n.mjs +50 -25
  9. package/src/index.mjs +2 -1
  10. package/src/narrative.mjs +56 -25
  11. package/src/niche.mjs +13 -12
  12. package/src/pagekind.mjs +18 -1
  13. package/src/presentation.mjs +3 -2
  14. package/src/prompts.mjs +163 -42
  15. package/src/render.mjs +61 -30
  16. package/src/rendered.mjs +6 -2
  17. package/src/tasks-pdf.mjs +10 -5
  18. package/src/verify.mjs +26 -13
  19. package/src/visibility.mjs +137 -45
  20. package/src/vy.mjs +13 -0
  21. package/src/test-agency-ids.mjs +0 -48
  22. package/src/test-agency.mjs +0 -158
  23. package/src/test-agent-surface.mjs +0 -46
  24. package/src/test-answer-first.mjs +0 -27
  25. package/src/test-blocked.mjs +0 -100
  26. package/src/test-crawl-links.mjs +0 -87
  27. package/src/test-demand.mjs +0 -243
  28. package/src/test-etalon.mjs +0 -42
  29. package/src/test-fix.mjs +0 -115
  30. package/src/test-flaky-files.mjs +0 -81
  31. package/src/test-foundation.mjs +0 -126
  32. package/src/test-handover.mjs +0 -148
  33. package/src/test-i18n.mjs +0 -68
  34. package/src/test-narrative.mjs +0 -117
  35. package/src/test-overall.mjs +0 -236
  36. package/src/test-planted.mjs +0 -114
  37. package/src/test-presentation.mjs +0 -67
  38. package/src/test-prompts.mjs +0 -142
  39. package/src/test-prospect-count.mjs +0 -12
  40. package/src/test-redirect-host.mjs +0 -63
  41. package/src/test-rendered.mjs +0 -136
  42. package/src/test-report-wording.mjs +0 -35
  43. package/src/test-ru-report.mjs +0 -95
  44. package/src/test-ru-sources.mjs +0 -41
  45. package/src/test-sample-fairness.mjs +0 -50
  46. package/src/test-sample.mjs +0 -91
  47. package/src/test-scores.mjs +0 -75
  48. package/src/test-shopify-store.mjs +0 -159
  49. package/src/test-sitemap.mjs +0 -91
  50. 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/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.36.2",
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",
@@ -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,389 @@
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
+ import { leadAnswer } from './visibility.mjs';
21
+
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';
23
+
24
+ const ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", laquo: '«', raquo: '»', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', thinsp: ' ', middot: '·', copy: '©', times: '×', minus: '−', rub: '₽' };
25
+ export function decode(s) {
26
+ 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);
27
+ }
28
+ /** Видимый текст страницы: без скриптов, стилей, картинок-SVG и шаблонов. */
29
+ export function visibleText(html) {
30
+ 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();
31
+ }
32
+ const bodyOf = (html) => { const s = String(html || ''); const i = s.search(/<body\b/i); return i >= 0 ? s.slice(i) : s; };
33
+ const words = (t) => (String(t).match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
34
+ const hostOf = (u) => { try { return new URL(u).host.replace(/^www\./, '').toLowerCase(); } catch { return ''; } };
35
+ const abs = (href, base) => { try { return new URL(decode(href), base).href; } catch { return ''; } };
36
+ 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; };
37
+
38
+ /** Свежая страница с браузерным User-Agent. Не открылась, значит null: проверить нечем, находкой это не считается. */
39
+ export async function fetchLive(url, { fetch = globalThis.fetch, timeoutMs = 20000 } = {}) {
40
+ try {
41
+ 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) });
42
+ const html = await r.text();
43
+ return { url: r.url || url, status: r.status, html, text: visibleText(html) };
44
+ } catch { return null; }
45
+ }
46
+
47
+ /* ---------- что есть на живой странице ---------- */
48
+
49
+ // Действие, которое обещает следующий шаг: «Обсудить проект», «Заявка и смета», «Записаться», «Get a quote».
50
+ const CTA_RU = /(обсуд|заказ|заявк|получ|запис|позвон|звонок|связат|рассчит|расч[её]т|узнать (цен|стоим)|консульт|купить|в корзин|написат|начать|попроб|оформ|подобрат|смет|бронир|забронир)/i;
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;
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;
72
+ const label = visibleText(m[3]) || decode((m[2].match(/(?:title|aria-label|value)="([^"]+)"/i) || [])[1] || '');
73
+ if (!label || label.length > 60) continue;
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) };
76
+ }
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 };
93
+ }
94
+ /** Кнопка или ссылка с действием на первом экране живой страницы: то же правило, что в отчёте (ctaScan). */
95
+ export function ctaFirstScreen(html) { return ctaScan(bodyOf(html)); }
96
+
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;
98
+ /** Суммы с валютой в видимом тексте. Две разные суммы и больше это цены, а не случайная цифра. */
99
+ export function pricesOn(text) {
100
+ return [...new Set((String(text).match(PRICE_RE) || []).map((x) => x.replace(/[\s  ]+/g, ' ').trim()))];
101
+ }
102
+
103
+ /** Формы и поля на странице по сути: заявка (имя, телефон, почта), окно cookie, поиск. */
104
+ export function formsOn(html) {
105
+ const body = bodyOf(html);
106
+ const out = [];
107
+ for (const m of body.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/gi)) {
108
+ const inner = m[2]; const t = visibleText(inner);
109
+ 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);
110
+ 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';
111
+ out.push({ kind, text: quote(t, 70) });
112
+ }
113
+ // Поля заявки без тега form: виджет записи в теле страницы (neronov.pro, секция #online) это форма на своём месте,
114
+ // а окно помощника или чата (cimes.ru, ai-lead) висит поверх страницы, и глубины у него нет.
115
+ const outside = body.replace(/<form\b[\s\S]*?<\/form>/gi, ' ');
116
+ for (const m of outside.matchAll(/<input\b[^>]*>/gi)) {
117
+ if (!/type=["']?(tel|email)\b|(name|placeholder|data-name|autocomplete)=["'][^"']*(phone|tel|телефон|e-?mail|почт|имя|name)/i.test(m[0])) continue;
118
+ const around = outside.slice(Math.max(0, m.index - 1500), m.index) + m[0];
119
+ const widget = /class=["'][^"']*\b(ai-|chat|widget|assistant|popup|modal|callback|fancybox|jivo|bot)[\w-]*/i.test(around.slice(-700));
120
+ out.push({ kind: widget ? 'widget' : 'lead', text: widget ? 'окно помощника или чата с полями заявки' : 'поля заявки без тега form' });
121
+ break;
122
+ }
123
+ // Форма во всплывающем окне по кнопке: кнопка с действием и признаком окна. Якорь #order ведёт к блоку на той же
124
+ // странице (у babynanny.ru к ссылкам мессенджеров), это не окно с формой.
125
+ 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]))));
126
+ if (popup) out.push({ kind: 'popup', text: visibleText(popup[3]) });
127
+ return out;
128
+ }
129
+
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
+ }
153
+
154
+ const ABOUT_TEXT = /^(о (компании|нас|студии|агентстве|бюро|клинике|враче|себе|центре|фирме)|об? (агентстве|авторе|основателе)|кто мы|наша (команда|история)|команда|про нас|about( us)?|our (story|team|company)|who we are|the team|company)$/i;
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;
156
+ /** Страница о компании: ссылка своего сайта с понятным текстом или адресом, которая открывается и где есть текст. */
157
+ export async function aboutPage(html, base, { fetch, open = fetchLive } = {}) {
158
+ const host = hostOf(base);
159
+ const links = [...String(html || '').matchAll(/<a\b[^>]*href=["']([^"'#]+)["'][^>]*>([\s\S]{0,200}?)<\/a>/gi)]
160
+ .map((m) => ({ url: abs(m[1], base), label: visibleText(m[2]) }))
161
+ .filter((l) => l.url && hostOf(l.url) === host && (ABOUT_TEXT.test(l.label) || ABOUT_HREF.test(new URL(l.url).pathname)));
162
+ const tried = new Set();
163
+ for (const l of [...links, ...['/about/', '/o-kompanii/', '/o-nas/', '/company/', '/about-us/'].map((p) => ({ url: abs(p, base), label: '' }))]) {
164
+ if (tried.has(l.url) || tried.size >= 6) continue;
165
+ tried.add(l.url);
166
+ const p = await open(l.url, { fetch });
167
+ 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 };
168
+ }
169
+ return { exists: false, tried: [...tried].map((u) => new URL(u).pathname) };
170
+ }
171
+
172
+ /**
173
+ * Ответ под H1: абзац прозы в первом блоке после заголовка (до второго заголовка или формы), в котором есть число.
174
+ * Цена в карточке товара не ответ (cimes.ru /solutions/aspro-*: «149 900 ₽» в div цены), счётчик «25 лет» тоже.
175
+ */
176
+ export function answerUnderH1(html) {
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) };
182
+ }
183
+
184
+ // Только однозначные: «compare» и «sravnenie» бывают разделом статей (oper-stack.com/compare/), их решает содержание.
185
+ 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;
186
+ /** Служебная страница по сути: корзина, вход, поиск, теги. Страница с заголовком и текстом не служебная. */
187
+ export function realContentPage(p) {
188
+ if (!p || p.status !== 200) return null;
189
+ const path = (() => { try { return new URL(p.url).pathname; } catch { return ''; } })();
190
+ const noindex = /<meta[^>]+name=["']robots["'][^>]+noindex/i.test(p.html);
191
+ 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;
192
+ }
193
+
194
+ /**
195
+ * Суть абзаца уже на странице (15.10.2026, правка недели cimes): все его числа и большая часть слов стоят в одном
196
+ * месте страницы, или почти все слова. Окно, а не вся страница: те же слова, разбросанные по большой главной, не
197
+ * значат, что этот абзац там есть. Для одного предложения пороги ниже (SENTENCE): «Цены и сроки рассчитываем до
198
+ * начала работ и закрепляем в договоре» при «Цены и сроки считаем до начала работ и закрепляем договором» на сайте.
199
+ */
200
+ export const SENTENCE = { share: 0.75, numShare: 0.5, minNums: 1, stem: 4 };
201
+ export function alreadyOnPage(x, pageText, { share: shareMin = 0.85, numShare = 0.6, minNums = 2, stem = 5 } = {}) {
202
+ const raw = pageText && typeof pageText === 'object' ? [pageText.title, pageText.h1, pageText.text].filter(Boolean).join(' ') : pageText;
203
+ const s = String(x || '').trim(); const page = String(raw || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').toLowerCase();
204
+ if (!s || !page) return false;
205
+ const digits = (t) => [...String(t).matchAll(/\d[\d\s ]*\d|\d/g)].map((m) => m[0].replace(/[\s ]/g, ''));
206
+ const nums = digits(s).filter((n) => n.length >= 3);
207
+ const stems = [...new Set((s.toLowerCase().match(/[\p{L}]{5,}/gu) || []).map((w) => w.slice(0, stem)))];
208
+ if (!stems.length) return false;
209
+ const span = Math.max(400, Math.round(s.length * 2)); const step = Math.max(80, Math.round(span / 4));
210
+ for (let i = 0; i < Math.max(1, page.length - span + step); i += step) {
211
+ const w = page.slice(i, i + span);
212
+ const share = stems.filter((t) => w.includes(t)).length / stems.length;
213
+ const wd = new Set(digits(w));
214
+ const allNums = nums.length > 0 && nums.every((n) => wd.has(n));
215
+ if ((nums.length >= minNums && allNums && share >= numShare) || share >= shareMin) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ /**
221
+ * Абзац-ответ уже на странице: целиком (alreadyOnPage), или его первое предложение (сам ответ на вопрос), или больше
222
+ * половины его предложений стоят на странице почти теми же словами. Длинный абзац, где героический блок главной
223
+ * пересказан и дополнен двумя фразами из других мест, иначе проходил целиком (cimes.ru, 15.10.2026, вторая пересборка).
224
+ */
225
+ export function paragraphOnPage(x, pageText) {
226
+ if (alreadyOnPage(x, pageText)) return true;
227
+ const sentences = String(x || '').split(/(?<=[.!?])\s+/).map((t) => t.trim()).filter((t) => t.split(/\s+/).length >= 5);
228
+ if (!sentences.length) return false;
229
+ // Начало абзаца (два предложения и больше) одним блоком на странице: ядро ответа уже стоит, хвост добавлен из других мест.
230
+ for (let k = 2; k < sentences.length; k++) if (alreadyOnPage(sentences.slice(0, k).join(' '), pageText)) return true;
231
+ const on = sentences.map((t) => alreadyOnPage(t, pageText, SENTENCE));
232
+ return on[0] || on.filter(Boolean).length * 2 > sentences.length;
233
+ }
234
+
235
+ /* ---------- утверждения ---------- */
236
+
237
+ /**
238
+ * Утверждения платного отчёта по строкам проверок и выборке страниц. Каждое: что сказано (kind),
239
+ * о какой странице (url), откуда (from), чтобы находка называла строку отчёта.
240
+ */
241
+ export function claimsFromAudit(audit) {
242
+ const site = audit?.meta?.site || audit?.meta?.url || (audit?.meta?.host ? `https://${audit.meta.host}/` : '');
243
+ const by = Object.fromEntries((audit?.checks || []).map((c) => [c.id, c]));
244
+ const sample = (audit?.sample || []).filter((p) => p && p.url && p.title !== undefined);
245
+ const home = sample.find((p) => { try { return new URL(p.url).pathname.replace(/\/$/, '') === ''; } catch { return false; } });
246
+ const out = [];
247
+ const bad = (c) => c && (c.status === 'bad' || c.status === 'warn');
248
+ // Кнопки нет на главной по выборке (или главной в выборке нет, а кнопки нет нигде): это и сверяем.
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' });
250
+ const depth = by['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' });
255
+ if (depth && /no form|формы нет/i.test(String(depth.value))) out.push({ kind: 'form-missing', url: home?.url || site, from: 'conv-form-depth' });
256
+ const about = by['trust-about'];
257
+ if (bad(about)) out.push({ kind: 'about-missing', url: site, from: 'trust-about' });
258
+ else if (about && about.status === 'ok') out.push({ kind: 'about-present', url: site, from: 'trust-about' });
259
+ if (sample.length && sample.every((p) => p.priceShown === false)) out.push({ kind: 'prices-missing', url: home?.url || site, from: 'price' });
260
+ if (home && home.answerFirst === false) out.push({ kind: 'answer-missing', url: home.url, from: 'answer-first' });
261
+ const util = by.utility;
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' });
263
+ const mess = by['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' });
265
+ return dedupe(out);
266
+ }
267
+
268
+ const CLAIM_TEXT = {
269
+ ru: [
270
+ ['cta-missing', /(призыв\S* к действию|кнопк\S*|действие)[^.\n]{0,60}(в|на) перв\S* экран|на первом экране нечего нажать/i],
271
+ ['prices-missing', /(на сайте|на странице|на главной|на проверенных страницах)[^.\n]{0,30}(нет|не видно|не указан\S*)[^.\n]{0,25}цен|цен\S* (нет|не указан\S*|не видн\S*)|(добавьте|укажите|опубликуйте) цен/i],
272
+ ['form-deep', /(поднять|поднимите|поднимите) форму|форм\S*[^.\n]{0,40}\d+ ?(%|процент\S*) вниз/i],
273
+ ['form-missing', /формы (заявки )?нет|нет (ни одной )?формы|(добавьте|поставьте) форму/i],
274
+ ['about-missing', /страниц\S* о компании нет|нет страниц\S* о компании|(добавьте|сделайте|создайте) страниц\S* «?о компании/i],
275
+ ['answer-missing', /(нет|без) прям\S* ответ\S*[^.\n]{0,40}(цифр|числ)|постав\S* в (её|его|начало)[^.\n]{0,20}прям\S* ответ с цифр/i],
276
+ ['messenger-missing', /(мессенджер\S*|чат\S*)[^.\n]{0,30}(нет|не найден)|нет (ни )?(мессенджер|чат)/i],
277
+ ['address-missing', /на сайте нет (почтового )?адреса|адрес\S* (на сайте )?(нет|не указан)/i],
278
+ ['about-present', /страниц\S* о компании\s+✓|на неё ведут ссылки с проверенных страниц/i],
279
+ ['utility', /служебн\S* страниц\S*[^\n]{0,80}?(?:https?:\/\/\S+|\s\/[\w\-./%]+\/?)/i],
280
+ ],
281
+ en: [
282
+ ['cta-missing', /(call to action|button)[^.\n]{0,60}(first screen|above the fold)|nothing to click (in|on) the first screen/i],
283
+ ['prices-missing', /no price|prices? (are|is) not (shown|visible|listed)|(add|publish|show) (your )?prices/i],
284
+ ['form-deep', /(move|bring) the form (up|higher)|form[^.\n]{0,40}\d+ ?% down/i],
285
+ ['form-missing', /no (contact )?form\b|add a (contact )?form/i],
286
+ ['about-missing', /no about page|(add|create) an about page/i],
287
+ ['answer-missing', /no direct answer[^.\n]{0,40}(figure|number)|open[^.\n]{0,20}with a direct answer/i],
288
+ ['messenger-missing', /no messenger|no (live )?chat\b/i],
289
+ ['address-missing', /no (postal |street )?address (on|is published)/i],
290
+ ['about-present', /linked from the sampled pages/i],
291
+ ['utility', /utility pages?[^\n]{0,80}?(?:https?:\/\/\S+|\s\/[\w\-./%]+\/?)/i],
292
+ ],
293
+ };
294
+ /**
295
+ * Утверждения из готового текста (задачи, письмо). Страница утверждения: ссылка в той же строке или главная.
296
+ * Абзац «добавьте этот абзац» (правка недели) берётся целиком: его проверяют по странице, куда он назначен.
297
+ */
298
+ export function claimsFromText(text, { lang = 'ru', site = '' } = {}) {
299
+ const t = String(text || '');
300
+ const out = [];
301
+ const lines = t.split('\n');
302
+ for (const [i, line] of lines.entries()) {
303
+ // Строка, где старое утверждение приведено, чтобы его опровергнуть («Неверно: кнопка есть»), не утверждение.
304
+ if (/неверно|был[аио]? ошибк|ошибочн|исправил|\bwrong\b|was a mistake|incorrect/i.test(line)) continue;
305
+ // «Как проверить: кнопка видна на первом экране» это мерка для исполнителя, а не утверждение о главной (moregroupestate.ru).
306
+ if (/как проверить|how to check|^\W*(?:проверка|verify)\b/i.test(line)) continue;
307
+ for (const [kind, re] of CLAIM_TEXT[lang === 'en' ? 'en' : 'ru']) {
308
+ if (!re.test(line)) continue;
309
+ // «5 из 20 страниц ставят его в первых 15 процентах»: у части страниц кнопка есть, и главная могла быть среди них.
310
+ const stat = [line, lines[i + 1], lines[i + 2], lines[i + 3]].join(' ');
311
+ const withCta = stat.match(/(\d+) из (?:\d+|них)[^.\n]{0,60}(?:став|вид(?:н|ен))|(\d+) of (?:\d+|them)[^.\n]{0,60}\b(?:put|is in|shows)/);
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;
315
+ // «Начало страницы» в задаче: ссылка в этой строке или двух соседних.
316
+ const near = [lines[i - 1], line, lines[i + 1]].join(' ').match(/https?:\/\/[^\s)»"',]+/);
317
+ // Адрес страницы в самой строке, часто без домена («1 адрес: /solutions/shop/», «начиная с /solutions/aspro-maksimum/»).
318
+ const path = (line.match(/(?:https?:\/\/[^\s,;)»]+|(?<=\s)\/[\w\-./%]+\/?)/) || [])[0] || null;
319
+ const named = path ? abs(path, site) : near && (!site || hostOf(near[0]) === hostOf(site)) ? near[0].replace(/[.;:]$/, '') : null;
320
+ // «Нет на 11 из 20 страниц» говорит о части выборки, а не о главной: без названной страницы такое не сверяем.
321
+ const agg = line.match(/(?:на|в)\s+(\d+)\s+из\s+(\d+)|\b(\d+) of (\d+)/);
322
+ if (!named && kind === 'answer-missing' && !/главн|home ?page/i.test(line) && agg && Number(agg[1] ?? agg[3]) < Number(agg[2] ?? agg[4])) continue;
323
+ if (kind === 'utility' && !path) continue;
324
+ out.push({ kind, url: named || site, from: quote(line, 120) });
325
+ }
326
+ }
327
+ // Правка недели: абзац между «Добавьте этот абзац…» и следующей пустой строкой после него.
328
+ const add = t.match(/(Добавьте этот абзац|Add this paragraph)[^\n]*\n+([\s\S]+?)(\n\s*\n|$)/i);
329
+ if (add && add[2].trim().length > 60) out.push({ kind: 'paragraph-new', url: site, text: add[2].trim(), from: quote(add[2], 120) });
330
+ return dedupe(out);
331
+ }
332
+ 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; }); }
333
+
334
+ /* ---------- сверка с живой страницей ---------- */
335
+
336
+ const SAY = {
337
+ 'cta-missing': (r) => `кнопка «${r.label}» уже стоит в первом экране ${r.page}`,
338
+ 'prices-missing': (r) => `цены на ${r.page} есть: ${r.list.slice(0, 3).join(', ')}`,
339
+ 'form-deep': (r) => `на ${r.page} нет формы заявки, которую можно мерить по глубине: ${r.what}`,
340
+ 'form-missing': (r) => `форма на ${r.page} есть: ${r.what}`,
341
+ 'about-missing': (r) => `страница о компании есть: ${r.url}${r.label ? ` («${r.label}»)` : ''}`,
342
+ 'about-present': (r) => `страницы о компании нет: ссылок на неё нет, ${r.tried.join(', ')} не открываются`,
343
+ 'answer-missing': (r) => `сразу под H1 на ${r.page} уже стоит ответ с цифрой: «${r.quote}»`,
344
+ utility: (r) => `${r.page} это страница с содержанием (H1 «${quote(r.h1, 50)}», ${r.words} слов), а не служебная`,
345
+ 'messenger-missing': (r) => `на ${r.page} есть связь в мессенджере или чате: ${r.what}`,
346
+ 'address-missing': (r) => `адрес на ${r.page} есть: «${r.quote}»`,
347
+ 'paragraph-new': (r) => `этот абзац уже стоит на ${r.page} почти слово в слово`,
348
+ };
349
+ const pageName = (u) => { try { const p = new URL(u).pathname; return p === '/' || p === '' ? 'главной' : p; } catch { return String(u); } };
350
+
351
+ /**
352
+ * Открыть страницы утверждений заново и сверить. problems: живая страница говорит обратное (текст одной строкой
353
+ * простым русским, для Telegram). checked: что проверено и что вышло, для отчёта о проверке.
354
+ */
355
+ export async function verifyClaims(claims, { site, fetch = globalThis.fetch, open = fetchLive } = {}) {
356
+ const cache = new Map();
357
+ const get = async (u) => { if (!cache.has(u)) cache.set(u, await open(u, { fetch })); return cache.get(u); };
358
+ const problems = []; const checked = [];
359
+ for (const c of claims) {
360
+ const url = c.url || site;
361
+ const p = await get(url);
362
+ if (!p || p.status !== 200) { checked.push({ ...c, result: 'не открылась' }); continue; }
363
+ const page = pageName(p.url || url);
364
+ let hit = null;
365
+ if (c.kind === 'cta-missing') { const r = ctaFirstScreen(p.html); if (r.found) hit = { label: r.label, page }; }
366
+ else if (c.kind === 'prices-missing') { const list = pricesOn(p.text); if (list.length >= 2) hit = { list, page }; }
367
+ 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(', ') : 'форм нет совсем' }; }
368
+ 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(', ') }; }
369
+ 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; }
370
+ else if (c.kind === 'answer-missing') { const r = answerUnderH1(p.html); if (r.has) hit = { quote: r.quote, page }; }
371
+ else if (c.kind === 'utility') { const r = realContentPage(p); if (r) hit = { ...r, page }; }
372
+ else if (c.kind === 'messenger-missing') { const r = messengerOn(p.html); if (r.found) hit = { what: r.what, page }; }
373
+ 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 }; }
374
+ else if (c.kind === 'paragraph-new') { if (paragraphOnPage(c.text, p.text)) hit = { page }; }
375
+ checked.push({ ...c, result: hit ? 'живая страница говорит обратное' : 'подтверждено или нечем опровергнуть' });
376
+ if (hit) problems.push({ kind: c.kind, url, from: c.from, line: SAY[c.kind](hit) });
377
+ }
378
+ return { problems, checked };
379
+ }
380
+
381
+ /**
382
+ * Платный отчёт перед выдачей: утверждения строк проверок и выборки плюс текст задач, всё против живого сайта.
383
+ * Сеть недоступна или страница не открылась: находкой это не считается (проверить нечем, а не «неправда»).
384
+ */
385
+ export async function checkReport(audit, { tasks = '', lang = audit?.meta?.lang || 'en', fetch = globalThis.fetch, open = fetchLive } = {}) {
386
+ const site = audit?.meta?.site || audit?.meta?.url || (audit?.meta?.host ? `https://${audit.meta.host}/` : '');
387
+ const claims = dedupe([...claimsFromAudit(audit), ...claimsFromText(tasks, { lang, site })]);
388
+ return { ...(await verifyClaims(claims, { site, fetch, open })), claims };
389
+ }