@operstack/audit 0.34.1 → 0.35.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.34.1",
3
+ "version": "0.35.1",
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",
@@ -34,7 +34,7 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "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",
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",
38
38
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
39
39
  "verify:live": "node src/verify-live.mjs"
40
40
  },
package/src/collect.mjs CHANGED
@@ -225,6 +225,9 @@ function analysePage(url, html) {
225
225
  walk(j);
226
226
  } catch { schemaTypes.push('(invalid JSON-LD)'); }
227
227
  }
228
+ // Микроразметка schema.org (itemtype) это та же разметка, что JSON-LD: Битрикс и Аспро ставят хлебные крошки и
229
+ // товары только так. До 14.10.2026 движок её не видел и писал cimes.ru «хлебных крошек нет» при BreadcrumbList на всех страницах.
230
+ for (const el of root.querySelectorAll('[itemtype]')) for (const t of String(el.getAttribute('itemtype') || '').split(/\s+/)) { const m = /schema\.org\/([A-Za-z]+)/.exec(t); if (m) schemaTypes.push(m[1]); }
228
231
  // Служебная обвязка вырезается, кроме <header>, в котором стоит H1: это шапка статьи, а не сайта.
229
232
  const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
230
233
  .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
@@ -258,20 +261,34 @@ function analysePage(url, html) {
258
261
  // Conversion path, read from the page itself. No analytics is needed to see whether a visitor
259
262
  // who wants to talk to this business can find a way to do it, and how far down it is.
260
263
  const bodyInner = body?.innerHTML || html;
261
- 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|связ|заяв|консульт|оставить|закаж|закажите|купить|в корзину|добавить в корзину|узнать цен|проверить сайт|начать/i;
264
+ 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;
265
+ // «Обсудить проект», «Заказать», «Рассчитать стоимость» тоже призыв: cimes.ru 14.10.2026 отчёт писал «на первом экране нечего нажать».
262
266
  const hrefsLower = links.map((h) => h.toLowerCase());
263
267
  const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
264
268
  const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
265
269
  const messengerLinks = hrefsLower.filter((h) => /(wa\.me|api\.whatsapp\.com|t\.me\/|telegram\.me|m\.me\/|viber:|signal\.me)/.test(h)).length;
266
270
  const contactPages = hrefsLower.filter((h) => /\/(contact|contacts|contact-us|kontakt|svyaz|contacto)\/?($|[?#])/.test(h)).length;
267
271
  const formEls = root.querySelectorAll('form');
268
- const realForms = formEls.filter((f) => !/search/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''}`));
272
+ // Не форма заявки: поиск, окно настроек cookie и форма без полей для текста, телефона или почты (cimes.ru 14.10.2026:
273
+ // «форма на 86 процентах страницы» оказалась окном cookie, а заявка открывается кнопкой в шапке).
274
+ const realForms = formEls.filter((f) => !/search|cookie|consent|gdpr/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''} ${f.getAttribute('name') || ''}`)
275
+ && !f.querySelectorAll('input').some((i) => /^search$/i.test(i.getAttribute('type') || '') || /^(q|s|search|query)$/i.test(i.getAttribute('name') || ''))
276
+ && (f.querySelectorAll('textarea').length || f.querySelectorAll('input').some((i) => /^(text|tel|email|number)?$/i.test(i.getAttribute('type') || ''))));
269
277
  // Виджет записи без тега <form> (поля телефона и имени в обычном блоке) это тоже форма (14.10.2026, neronov.pro).
270
278
  if (!realForms.length) {
271
279
  const tel = root.querySelectorAll('input').find((i) => /tel/i.test(`${i.getAttribute('type') || ''} ${i.getAttribute('inputmode') || ''}`) || /phone|tel|телефон|номер|\+7|\+1/i.test(`${i.getAttribute('name') || ''} ${i.getAttribute('placeholder') || ''} ${Object.keys(i.attributes || {}).join(' ')}`));
272
280
  if (tel) { let box = tel.parentNode; for (let k = 0; k < 4 && box && box.parentNode && box.querySelectorAll('input').length < 2; k++) box = box.parentNode; if (box) realForms.push(box); }
273
281
  }
274
- const firstForm = realForms[0] || null;
282
+ // Форма во всплывающем окне (заявка по кнопке) стоит в разметке в конце страницы, но посетитель её не листает:
283
+ // её глубину не мерим, а отмечаем отдельно (cimes.ru 14.10.2026: «форма на 96 процентах» была окном «Обсудить проект»).
284
+ const attrs = (n) => `${n.getAttribute('class') || ''} ${n.getAttribute('id') || ''}`;
285
+ // Скрытый блок (hidden, display:none, aria-hidden) посетитель не видит: это не форма. На cimes.ru поле телефона
286
+ // стояло в скрытом блоке виджета ИИ-чата. Класс оформления «popup-content» у видимой формы контактов окном не считаем.
287
+ const hiddenBox = (el) => { for (let n = el; n; n = n.parentNode) { if (typeof n.getAttribute !== 'function') break; const h = n.getAttribute('hidden'); if ((h !== undefined && h !== null) || n.getAttribute('aria-hidden') === 'true' || /display:\s*none/i.test(n.getAttribute('style') || '') || /(^|\s)(hidden|d-none|mfp-hide|is-hidden)(\s|$)/i.test(n.getAttribute('class') || '')) return true; } return false; };
288
+ const inPopup = (el) => { for (let n = el; n; n = n.parentNode) { if (typeof n.getAttribute !== 'function') break; if (/^dialog$/i.test(n.tagName || '') || n.getAttribute('role') === 'dialog' || /(^|\s)(modal|popup|popup-window|modal-window|fancybox|lightbox|callback-popup|js-popup)(\s|$)/i.test(attrs(n))) return true; } return false; };
289
+ for (let i = realForms.length - 1; i >= 0; i--) if (hiddenBox(realForms[i])) realForms.splice(i, 1);
290
+ const popupForms = realForms.filter(inPopup);
291
+ const firstForm = realForms.find((f) => !inPopup(f)) || null;
275
292
  // A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
276
293
  const visibleToVisitor = (el) => {
277
294
  if (el.getAttribute('tabindex') === '-1') return false;
@@ -327,7 +344,8 @@ function analysePage(url, html) {
327
344
  email: /"email"\s*:/.test(ld) || mailLinks > 0,
328
345
  sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
329
346
  sameAsUrls: [...new Set(sameAs)],
330
- aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
347
+ // Только ссылки внутри своего сайта: «yandex.ru/support/.../about» засчитывался cimes.ru как страница о компании (14.10.2026).
348
+ aboutPage: hrefsLower.some((h) => { let own = false; try { own = !/^[a-z]+:/.test(h) && !h.startsWith('//') ? true : new URL(h, url).host.replace(/^www\./, '') === new URL(url).host.replace(/^www\./, ''); } catch { own = false; } return own && /\/(about|about-us|about-company|our-story|team|company|o-nas|o-kompanii|kompaniya)\/?($|[?#])/.test(h); }),
331
349
  policyPages: [...new Set(hrefsLower.flatMap(policyKinds))],
332
350
  author: /"author"\s*:/.test(ld),
333
351
  };
@@ -368,7 +386,7 @@ function analysePage(url, html) {
368
386
  const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
369
387
  try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
370
388
  })).length;
371
- return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, trust };
389
+ return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length - popupForms.length, formPopup: popupForms.length > 0, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, trust };
372
390
  }
373
391
 
374
392
  /** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
@@ -511,7 +529,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
511
529
 
512
530
  const alt = await follow(`${new URL(home.final).protocol}//${altHost}/`);
513
531
  const altOk = alt.final.replace(/\/$/, '') === new URL(home.final).origin.replace(/\/$/, '') || alt.hops.some((h) => h.status >= 300 && h.status < 400);
514
- checks.push(row('www', 'technical', 'www canonicalisation', alt.response.status === 0 ? 'warn' : altOk ? 'ok' : 'warn', alt.response.status === 0 ? `${altHost} does not resolve` : altOk ? `${altHost} redirects to ${new URL(home.final).host}` : `${altHost} answers ${alt.response.status} without redirecting`));
532
+ // Нет кода ответа (обрыв, долгое ожидание) это «не измерено», а не «не перенаправляет»: cimes.ru 14.10.2026 отвечал 301, отчёт писал обратное.
533
+ const altCode = Number(alt.response.status);
534
+ checks.push(row('www', 'technical', 'www canonicalisation', alt.response.status === 0 ? 'warn' : altOk ? 'ok' : !altCode ? 'na' : 'warn', alt.response.status === 0 ? `${altHost} does not resolve` : altOk ? `${altHost} redirects to ${new URL(home.final).host}` : !altCode ? `${altHost} did not answer in time, not measured` : `${altHost} answers ${altCode} without redirecting`));
515
535
  checks.push(row('ttfb', 'technical', 'Time to first byte (homepage)', home.response.ttfb > 2500 ? 'bad' : home.response.ttfb > 1200 ? 'warn' : 'ok', `${home.response.ttfb} ms`, 'single request from the auditor, not a lab test'));
516
536
 
517
537
  log('robots.txt');
@@ -794,7 +814,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
794
814
  const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
795
815
  const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
796
816
  if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
797
- const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
817
+ // «shop» это витрина, а не служебная страница: cimes.ru/solutions/shop/ продающий каталог (14.10.2026). Только целый сегмент пути.
818
+ const utility = sitemapPages.filter((u) => /\/(cart|checkout|wishlist|compare|my-account|tag|author|feed)(\/|$)/i.test(new URL(u).pathname));
798
819
  if (utility.length) {
799
820
  // Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
800
821
  const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist|\/compare/i, 'wishlist pages'], [/\/my-account/i, 'account pages'], [/\/feed\b/i, 'feeds']];
@@ -873,7 +894,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
873
894
  const channels = [tel ? 'phone' : '', mail ? 'email' : '', msgr ? 'a messenger' : ''].filter(Boolean);
874
895
  checks.push(row('trust-contact', 'offpage', 'Direct contact details', channels.length >= 2 ? 'ok' : channels.length ? 'warn' : 'bad', channels.length ? channels.join(' and ') : 'no phone, email or messenger is published'));
875
896
  const ownHost = host.replace(/^www\./, '');
876
- const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
897
+ // Ссылка на чат WhatsApp или Viber это способ написать, а не профиль на площадке (cimes.ru, 14.10.2026).
898
+ const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`) && !/^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i.test(h));
877
899
  /*
878
900
  * Для готовой разметки нужен адрес профиля, а не домен: «https://x.com» это не страница компании,
879
901
  * а сам сайт. И список сужается до площадок, где профиль вообще бывает: сайт нередко перечисляет
@@ -904,6 +926,10 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
904
926
  // Если кнопка действия стоит на первом экране почти везде, форма внизу не беда: к ней ведёт кнопка (14.10.2026, neronov.pro).
905
927
  const ctaShare = pagesForTrust.length ? pagesForTrust.filter((p) => p.ctaFirstScreen).length / pagesForTrust.length : 0;
906
928
  checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 || ctaShare >= 0.8 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, ctaShare >= 0.8 && md >= 50 ? 'the first screen already has a button that leads to it' : md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
929
+ } else if (pagesForTrust.some((p) => p.formPopup) && withCta.length) {
930
+ // Заявка во всплывающем окне по кнопке: глубины нет, есть кнопка на первом экране.
931
+ const popup = pagesForTrust.filter((p) => p.formPopup).length;
932
+ checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : 'warn', `the form opens in a pop-up from a button on ${popup} of ${pagesForTrust.length} sampled page(s); the button is in the first screen on ${withCta.length}`));
907
933
  } else {
908
934
  // Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
909
935
  const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
package/src/facts.mjs ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Сверка чисел отчёта с данными аудита кодом, без модели (14.10.2026, задание «второй шаг проверки»).
3
+ *
4
+ * Простым языком. Hermes вписал в пять отчётов по четыре ложных числа («прочитали 67 страниц» вместо 20,
5
+ * «RFC 29188» вместо 9727), и проверка ИИ не исправила ни одного из 20. Числа в обложке, выводе,
6
+ * приоритетах и строках проверок собираются из замеров, поэтому их можно сверить без модели: в момент
7
+ * черновика движок запоминает, какой текст и какие числа в каждом поле дали данные. Перед проверкой ИИ
8
+ * каждое число в этих полях ищется в запомненном. Чужое число значит ошибку с готовым доказательством:
9
+ * поле возвращается к тексту из данных. Правка модели, которая убирает число из данных, отклоняется.
10
+ */
11
+ import { localiseChecks } from './i18n.mjs';
12
+
13
+ /** Номера RFC, на которые движок ссылается сам. Любой другой номер в отчёте ошибка. */
14
+ export const KNOWN_RFC = new Set(['9727']);
15
+
16
+ /** Числа текста: «23 000» и «23000» одно число, «120-160» два, проценты и доли как есть. */
17
+ export function numbersOf(text) {
18
+ const out = [];
19
+ for (const m of String(text || '').matchAll(/\d{1,3}(?:[   ]\d{3})+(?![\d])|\d+(?:[.,]\d+)?/g)) out.push(m[0].replace(/[   ]/g, '').replace(',', '.'));
20
+ return out;
21
+ }
22
+
23
+ /** Поля, числа которых собраны из данных аудита, с их текстом в момент черновика. */
24
+ export function snapshotFacts(audit, lang = audit.meta?.lang || 'en') {
25
+ const shown = lang === 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
26
+ const fields = {};
27
+ for (const c of shown) {
28
+ if (c.value) fields[`chk:${c.id}:value`] = `${c.label}: ${c.value}`;
29
+ if (c.comment) fields[`chk:${c.id}:comment`] = `${c.label}, пояснение: ${c.comment}`;
30
+ }
31
+ if (audit.client?.subject) fields['sub:subject'] = String(audit.client.subject);
32
+ if (audit.summary?.lead) fields['sum:lead'] = String(audit.summary.lead);
33
+ if (audit.summary?.verdict) fields['sum:verdict'] = String(audit.summary.verdict);
34
+ (audit.summary?.priorities || []).forEach((p, i) => { fields[`sum:prio:${i}`] = String(p); });
35
+ return { at: new Date().toISOString(), fields, approved: {} };
36
+ }
37
+
38
+ /** Числа, которых нет в данных поля (и нет в одобренных правках с доказательством). */
39
+ export function foreignNumbers(facts, id, text) {
40
+ const src = facts?.fields?.[id];
41
+ if (src === undefined) return [];
42
+ const allowed = new Set([...numbersOf(src), ...(facts.approved?.[id] || [])]);
43
+ const bad = numbersOf(text).filter((n) => !allowed.has(n));
44
+ for (const m of String(text).matchAll(/RFC\s*(\d+)/gi)) if (!KNOWN_RFC.has(m[1]) && !bad.includes(m[1])) bad.push(m[1]);
45
+ return [...new Set(bad)];
46
+ }
47
+
48
+ /**
49
+ * Проверка кодом по всем утверждениям: что не сходится с данными, то уже найдено и исправлено,
50
+ * модель для этого не нужна. Возвращает находки в том же виде, что подтверждённые вторым шагом.
51
+ */
52
+ export function codeCheck(audit, statements) {
53
+ const facts = audit.facts;
54
+ if (!facts?.fields) return [];
55
+ const out = [];
56
+ for (const s of statements) {
57
+ const bad = foreignNumbers(facts, s.id, s.text);
58
+ if (!bad.length) continue;
59
+ out.push({ id: s.id, keep: true, byCode: true, evidence: `в данных аудита: «${facts.fields[s.id]}»; в тексте лишние числа: ${bad.join(', ')}`, fix: facts.fields[s.id] });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Правка модели не должна портить верное: если в старом тексте было число из данных, а в правке
66
+ * его нет, или правка вносит число, которого нет ни в данных, ни в доказательстве, правка отклоняется.
67
+ */
68
+ export function fixBreaksData(facts, id, oldText, fix, evidence = '') {
69
+ const src = facts?.fields?.[id];
70
+ if (src === undefined) return false;
71
+ const data = new Set(numbersOf(src));
72
+ const now = new Set(numbersOf(fix));
73
+ const lost = numbersOf(oldText).filter((n) => data.has(n) && !now.has(n));
74
+ if (lost.length) return `правка убирает числа из данных: ${[...new Set(lost)].join(', ')}`;
75
+ const proven = new Set(numbersOf(evidence));
76
+ const alien = [...now].filter((n) => !data.has(n) && !proven.has(n) && !(facts.approved?.[id] || []).includes(n));
77
+ if (alien.length) return `правка вносит числа без доказательства: ${alien.join(', ')}`;
78
+ return false;
79
+ }
80
+
81
+ /** Правка с доказательством принята: её новые числа больше не считаются чужими в этом поле. */
82
+ export function approveNumbers(facts, id, fix) {
83
+ if (!facts?.fields || facts.fields[id] === undefined) return;
84
+ facts.approved = facts.approved || {};
85
+ facts.approved[id] = [...new Set([...(facts.approved[id] || []), ...numbersOf(fix)])];
86
+ }
package/src/i18n.mjs CHANGED
@@ -50,6 +50,7 @@ const RU = {
50
50
  label: 'Склейка адреса с www',
51
51
  value: (v) => (/redirects to/.test(v) ? 'адрес с www перенаправляется на основной'
52
52
  : /does not resolve/.test(v) ? 'адрес с www не существует'
53
+ : /did not answer/.test(v) ? 'адрес с www не ответил вовремя, склейку проверить не удалось'
53
54
  : `адрес с www отвечает ${nums(v)[0] || ''} и не перенаправляет`),
54
55
  },
55
56
  ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
@@ -154,7 +155,7 @@ const RU = {
154
155
  'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
155
156
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
156
157
  'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
157
- 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
158
+ 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'странице' : 'страницах'}`), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
158
159
  'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
159
160
  'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
160
161
  };
@@ -178,8 +179,10 @@ export function localiseChecks(checks, lang = 'ru') {
178
179
  // Строки, которые уже написаны по-русски (второй взгляд по сайту ставит флаг localised), второй раз не переводим.
179
180
  // Уже русская строка (без английских слов вне кавычек) второй раз не переводится: перевод ждёт английский шаблон.
180
181
  const ruAlready = (x) => { const t = String(x || '').replace(/"[^"]*"|«[^»]*»/g, ''); return Boolean(c.localised) || (/[А-Яа-яЁё]/.test(t) && !/[A-Za-z]{3,}/.test(t)); };
181
- try { if (r.value && !ruAlready(c.value)) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
182
- try { if (r.comment && !ruAlready(c.comment)) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
182
+ // Правка проверки (applyFix) уже написана на языке отчёта: её не переводим, иначе «www» в русском тексте
183
+ // отправлял строку в английский шаблон и правка пропадала (cimes.ru, 14.10.2026, три круга без схождения).
184
+ try { if (r.value && !c.fixedValue && !ruAlready(c.value)) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
185
+ try { if (r.comment && !c.fixedComment && !ruAlready(c.comment)) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
183
186
  return out;
184
187
  });
185
188
  }
package/src/narrative.mjs CHANGED
@@ -129,6 +129,7 @@ const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitem
129
129
  import { vocabFor } from './niche.mjs';
130
130
  import { localiseChecks, AREAS_RU } from './i18n.mjs';
131
131
  import { FIX_ACTIONS } from './fix.mjs';
132
+ import { snapshotFacts } from './facts.mjs';
132
133
 
133
134
  const byId = (checks, id) => (checks || []).find((c) => c.id === id);
134
135
  const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
@@ -318,7 +319,10 @@ export function draftNarrative(audit, opts = {}) {
318
319
  }),
319
320
  }));
320
321
 
321
- if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length);
322
+ // «Технически в порядке, чинить нечего» только когда открытых технических проверок нет совсем, частичных тоже (cimes.ru, 14.10.2026).
323
+ if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length + mechanical);
324
+ // Что дали данные в каждом поле с числами: по этому снимку проверка кодом ловит чужие числа (facts.mjs, 14.10.2026).
325
+ a.facts = snapshotFacts(a, lang);
322
326
  return a;
323
327
  }
324
328
 
package/src/prompts.mjs CHANGED
@@ -327,7 +327,8 @@ const PF = {
327
327
  ? { task: `Собери с владельцем 5-7 частых вопросов ${v.peopleGen} с ответами, опубликуй их видимым блоком на страницах главных услуг и только потом разметь схемой FAQPage. Текст в разметке должен слово в слово совпадать с видимым. Вопросов, которых нет на странице, в разметку не добавляй.`, verify: 'На выбранных страницах есть разметка FAQPage минимум с пятью вопросами, и каждый вопрос и ответ из неё дословно виден на странице.' }
328
328
  : { task: `Collect 5 to 7 frequent questions from ${v.peopleGen} with the owner, publish them as a visible block on the main service pages, and only then mark them up with FAQPage. The markup text must match the visible text word for word.`, verify: 'The chosen pages carry FAQPage markup with at least five questions, each question and answer visible on the page word for word.' }),
329
329
  sections: ({ audit, ru }) => {
330
- const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3);
330
+ // Страница-список (CollectionPage, ItemList) разводит по подразделам, разбивать её на разделы незачем (cimes.ru, 14.10.2026).
331
+ const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !(p.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t)));
331
332
  if (!long.length) return null;
332
333
  const list = long.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
333
334
  // Подзаголовки уже есть, но размечены H3: задача не «придумать H2», а поднять существующие (14.10.2026).
package/src/rendered.mjs CHANGED
@@ -14,6 +14,7 @@ import { spawn, spawnSync } from 'node:child_process';
14
14
  import { mkdtempSync, rmSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
+ import { parse as parseHtml } from 'node-html-parser';
17
18
 
18
19
  /**
19
20
  * Это страница сайта или собственная страница ошибки браузера.
@@ -132,7 +133,20 @@ export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 80
132
133
  }
133
134
 
134
135
  const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
135
- const plain = (html) => String(html)
136
+ // Баннер cookie и согласия рисует скрипт, но это не текст страницы: у cimes.ru 14.10.2026 он один дал «скрыто 19 процентов».
137
+ const COOKIE_BOX = /cookie|consent|gdpr|cmp-|onetrust|cookiebot/i;
138
+ const dropCookieBoxes = (html) => {
139
+ try {
140
+ const root = parseHtml(String(html));
141
+ // Только небольшой блок: класс «cookie-accepted» на <body> или обёртке страницы не повод выбросить всю страницу.
142
+ for (const el of root.querySelectorAll('[class], [id]')) {
143
+ if (/^(html|body|main|article)$/i.test(el.tagName || '') || !el.parentNode) continue;
144
+ if (COOKIE_BOX.test(`${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`) && countWords(el.text || '') <= 400) el.remove();
145
+ }
146
+ return root.toString();
147
+ } catch { return String(html); }
148
+ };
149
+ const plain = (html) => dropCookieBoxes(html)
136
150
  .replace(/<!--[\s\S]*?-->/g, ' ')
137
151
  .replace(CHROME_STRIP, ' ')
138
152
  .replace(/<[^>]+>/g, ' ')
package/src/review.mjs CHANGED
@@ -16,6 +16,8 @@ import { localiseChecks } from './i18n.mjs';
16
16
  import { agentPrompts } from './prompts.mjs';
17
17
  import { rescore } from './verify.mjs';
18
18
  import { vocabFor } from './niche.mjs';
19
+ import { approveNumbers, codeCheck, fixBreaksData } from './facts.mjs';
20
+ import { MECHANICAL_IDS } from './narrative.mjs';
19
21
 
20
22
  const PART_OF = (id) => {
21
23
  if (/^task:/.test(id)) return 'tasks';
@@ -66,8 +68,8 @@ export function applyFix(audit, id, text) {
66
68
  if (m[0] === 'chk') {
67
69
  const c = (audit.checks || []).find((x) => x.id === m[1]); if (!c) return false;
68
70
  const label = c.label;
69
- if (m[2] === 'value') c.value = strip(text, label);
70
- else c.comment = strip(text, label);
71
+ if (m[2] === 'value') { c.value = strip(text, label); c.fixedValue = true; }
72
+ else { c.comment = strip(text, label); c.fixedComment = true; }
71
73
  return true;
72
74
  }
73
75
  if (m[0] === 'task') { audit.taskOverrides = audit.taskOverrides || {}; (audit.taskOverrides[m[1]] = audit.taskOverrides[m[1]] || {})[m[2]] = text; return true; }
@@ -121,7 +123,7 @@ For false and misleading give fix: full replacement text in English, no em or en
121
123
  Answer strictly as JSON: {"items":[{"id":"...","verdict":"ok|false|misleading|unverifiable","premiseTrue":true,"fitsNiche":true,"passesNow":false,"possible":true,"why":"short","fix":"..."}]} (leave out fields that do not apply)`);
122
124
 
123
125
  /** Замеры инструмента, на которые опираются строки: проверяющий иначе считает их «непроверяемыми». */
124
- export function toolFacts(audit) {
126
+ export function toolFacts(audit, digest = null) {
125
127
  // Только то, что инструмент измерил запросом к сайту (файлы, роботы, скорость), а не его же выводы:
126
128
  // выводы из строк проверки здесь делали проверку круговой (14.10.2026: 0 из 13 известных ошибок).
127
129
  const by = (id) => (audit.checks || []).find((c) => c.id === id);
@@ -134,6 +136,12 @@ export function toolFacts(audit) {
134
136
  filesAndProbes: { 'https': probe('https'), www: probe('www'), 'redirect-chain': probe('redirect-chain'), ttfb: probe('ttfb'), 'llms.txt': probe('llms'), 'agent.json': probe('agent-card'), 'api-catalog': probe('api-catalog'), markdown: probe('agent-markdown'), 'robots-to-ai-robots': probe('train-bots'), 'js-hidden-text': probe('js-content'), cms: probe('cms') },
135
137
  images: { total: sm.reduce((a, p) => a + (p.images || 0), 0), withoutAltText: sm.reduce((a, p) => a + (p.imagesNoAlt || 0), 0), withoutAltAttribute: sm.reduce((a, p) => a + (p.imagesAltMissing || 0), 0) },
136
138
  perSampledPage: sm.map((p) => ({ url: p.url, words: p.words, h1Count: p.h1Count, h2Count: p.h2Count, tables: p.tables, images: p.images, imagesNoAlt: p.imagesNoAlt, ctaFirstScreen: p.ctaFirstScreen, priceShown: p.priceShown })),
139
+ // Чем доказывать числа обложки и вывода (задание 14.10.2026, «67 страниц» второй шаг опровергнуть не смог).
140
+ pagesRead: sm.length,
141
+ openChecks: (() => { const sh = (audit.meta?.lang === 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || [])); const open = sh.filter((c) => c.status === 'bad' || c.status === 'warn'); return { notFullyPassed: open.length, failed: open.filter((c) => c.status === 'bad').length, technical: open.filter((c) => MECHANICAL_IDS.has(c.id)).length, needOwnerFacts: open.filter((c) => !MECHANICAL_IDS.has(c.id)).length }; })(),
142
+ homepageAssets: (() => { const v = String(probe('resources') || ''); const n = (re) => Number((v.match(re) || [])[1]); return { raw: v, scripts: n(/(\d+)\s*(?:script|скрипт)/i), stylesheets: n(/(\d+)\s*(?:stylesheet|стил)/i), images: n(/(\d+)\s*(?:image|картин)/i) }; })(),
143
+ imagesWithAltText: sm.reduce((a, p) => a + Math.max(0, (p.images || 0) - (p.imagesNoAlt || 0)), 0),
144
+ homepage: { url: sm[0]?.url, description: sm[0]?.description, text: String(((digest?.pages || [])[0] || {}).text || sm[0]?.firstPara || '').slice(0, 2000) },
137
145
  sampleRule: 'Числа вида «X из N страниц выборки» считаются только по sampleUrls (perSampledPage), а не по всем страницам выжимки сайта.',
138
146
  classification: 'Деление открытых проверок на «правятся технически» и «требуют фактов владельца» и оценки областей считает инструмент по своему списку; это не утверждения о сайте.',
139
147
  };
@@ -177,7 +185,7 @@ export async function reviewRound(audit, digest, statements, ask, { lang = audit
177
185
  // Пачки по 20 утверждений одной части: на 170 строках сразу модель пропускала очевидное (14.10.2026).
178
186
  const batches = parts.flatMap((part) => { const all = statements.filter((s) => s.part === part); const out = []; for (let i = 0; i < all.length; i += 20) out.push({ part, st: all.slice(i, i + 20) }); return out; });
179
187
  // Одна и та же выжимка в начале каждого вызова: OpenAI берёт повторяющееся начало из кэша за десятую часть цены.
180
- const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit), digest: digestFor(digest, statements) });
188
+ const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit, digest), digest: digestFor(digest, statements) });
181
189
  const runBatch = async ({ part, st }) => {
182
190
  const messages = [
183
191
  { role: 'system', content: SYSTEM(ru, niche) },
@@ -222,20 +230,39 @@ For each flagged statement find SPECIFIC evidence in the site digest or toolFact
222
230
  Answer strictly as JSON: {"items":[{"id":"...","keep":true,"evidence":"url: field = value","fix":"..."}]}`);
223
231
 
224
232
  /** Второй шаг: подтвердить пометки первого более сильной моделью, только с прямым доказательством. */
225
- export async function confirmFlags(audit, digest, statements, flagged, ask, { lang = audit.meta?.lang || 'en' } = {}) {
233
+ // По 10 пометок за вызов: на 40 сразу второй проверяющий молча пропускал большую часть строк (Hermes, 14.10.2026).
234
+ export const CONFIRM_GROUP = 10;
235
+ export const CONFIRM_RETRY_GROUP = 4;
236
+ export async function confirmFlags(audit, digest, statements, flagged, ask, { lang = audit.meta?.lang || 'en', group = CONFIRM_GROUP, retryGroup = CONFIRM_RETRY_GROUP } = {}) {
226
237
  const ru = lang === 'ru';
227
- if (!flagged.length) return { items: [], usage: { input: 0, output: 0, calls: 0 } };
228
- const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit), digest: digestFor(digest, statements) });
238
+ if (!flagged.length) return { items: [], missing: [], usage: { input: 0, output: 0, cached: 0, calls: 0 } };
239
+ const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit, digest), digest: digestFor(digest, statements) });
229
240
  const byId = Object.fromEntries(statements.map((s) => [s.id, s]));
230
241
  const usage = { input: 0, output: 0, cached: 0, calls: 0 };
231
- const groups = []; for (let i = 0; i < flagged.length; i += 40) groups.push(flagged.slice(i, i + 40));
232
- const res = await Promise.all(groups.map(async (g) => {
233
- const r = await ask([{ role: 'system', content: CONFIRM(ru) }, { role: 'user', content: shared }, { role: 'user', content: JSON.stringify({ flagged: g.map((x) => ({ id: x.id, kind: byId[x.id]?.kind, text: byId[x.id]?.text, firstReviewer: { verdict: x.verdict, why: x.why, fix: x.fix } })) }) }]);
242
+ const chunks = (arr, n) => { const out = []; for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); return out; };
243
+ const askGroup = async (g) => {
244
+ const r = await ask([{ role: 'system', content: CONFIRM(ru) }, { role: 'user', content: shared }, { role: 'user', content: JSON.stringify({ flagged: g.map((x) => ({ id: x.id, kind: byId[x.id]?.kind, text: byId[x.id]?.text, firstReviewer: { verdict: x.verdict, why: x.why, fix: x.fix } })), answerEveryId: g.map((x) => x.id) }) }]);
234
245
  usage.calls += 1; usage.input += r.usage?.input || 0; usage.cached += r.usage?.cached || 0; usage.output += r.usage?.output || 0;
235
- let parsed; try { parsed = JSON.parse(String(r.text).replace(/^```json\s*|```$/g, '')); } catch { throw new Error('второй проверяющий вернул не JSON'); }
236
- return parsed.items || [];
237
- }));
238
- return { items: res.flat(), usage };
246
+ // Разбор терпимый к форме: keep строкой «true», id с пробелом, ответ массивом или в обёртке. На живом cimes.ru
247
+ // (14.10.2026) три строки считались «без ответа», хотя отдельно модель на них отвечала.
248
+ const raw = String(r.text || '');
249
+ let parsed; try { parsed = JSON.parse(raw.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); } catch { const m = raw.match(/\{[\s\S]*\}/); try { parsed = m ? JSON.parse(m[0]) : null; } catch { parsed = null; } }
250
+ const list = Array.isArray(parsed) ? parsed : (parsed?.items || parsed?.flagged || parsed?.results || []);
251
+ const ids = new Set(g.map((x) => x.id));
252
+ const asBool = (v) => (typeof v === 'boolean' ? v : /^(true|да|yes)$/i.test(String(v ?? '').trim()) ? true : /^(false|нет|no)$/i.test(String(v ?? '').trim()) ? false : null);
253
+ const got = list.map((x) => (x && typeof x === 'object' ? { ...x, id: String(x.id || '').trim(), keep: asBool(x.keep) } : null)).filter((x) => x && ids.has(x.id) && x.keep !== null);
254
+ for (const f of g) if (!got.some((x) => x.id === f.id)) lastRaw[f.id] = raw.slice(0, 600);
255
+ return got;
256
+ };
257
+ const lastRaw = {};
258
+ const items = (await Promise.all(chunks(flagged, group).map(askGroup))).flat();
259
+ // Молчание не «ошибки нет»: по каждой пропущенной строке один повторный запрос, маленькими группами.
260
+ let missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
261
+ if (missing.length) {
262
+ items.push(...(await Promise.all(chunks(missing, retryGroup).map(askGroup))).flat());
263
+ missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
264
+ }
265
+ return { items, missing: missing.map((f) => f.id), raw: Object.fromEntries(missing.map((f) => [f.id, lastRaw[f.id] || ''])), usage };
239
266
  }
240
267
 
241
268
  /**
@@ -246,24 +273,43 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
246
273
  const rounds = []; const usage = { input: 0, cached: 0, output: 0, calls: 0, confirm: { input: 0, cached: 0, output: 0, calls: 0 } };
247
274
  const add = (u, t) => { for (const k of ['input', 'cached', 'output', 'calls']) t[k] += u[k] || 0; };
248
275
  let todo = statementsOf(audit, { lang });
249
- let left = [];
276
+ let left = []; let held = null;
250
277
  for (let round = 1; round <= maxRounds && todo.length; round++) {
251
278
  const all = statementsOf(audit, { lang });
252
279
  const { items, usage: u } = await reviewRound(audit, digest, todo, ask, { lang });
253
280
  add(u, usage);
254
- const flagged = items.filter((x) => x.verdict === 'false' || x.verdict === 'misleading');
281
+ // Сначала бесплатно кодом: числа в полях, собранных из данных, сверяются с данными (facts.mjs).
282
+ const byCode = codeCheck(audit, all);
283
+ const codeIds = new Set(byCode.map((x) => x.id));
284
+ const flagged = items.filter((x) => (x.verdict === 'false' || x.verdict === 'misleading') && !codeIds.has(x.id));
255
285
  // Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
256
- const { items: conf, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
286
+ const { items: conf, missing, raw: missingRaw, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
257
287
  add(cu, usage.confirm);
288
+ if (missing.length) {
289
+ held = `второй проверяющий не ответил по ${missing.length} ${missing.length === 1 ? 'строке' : 'строкам'}: ${missing.join(', ')}`;
290
+ log(`проверка, круг ${round}: ${held}`);
291
+ left = missing.map((id) => ({ id, why: 'второй проверяющий не ответил' }));
292
+ rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing, missingRaw });
293
+ break;
294
+ }
258
295
  // Правка, которая ничего не меняет, это подтверждение, а не находка: иначе круги идут впустую (14.10.2026, neronov.pro).
259
296
  const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
260
297
  const textOf = (id) => all.find((s) => s.id === id)?.text || '';
261
- const kept = conf.filter((x) => x.keep && !(x.fix && norm(x.fix) === norm(textOf(x.id))));
298
+ // Правка не должна портить верное: число из данных не убирается, новое число только с доказательством.
299
+ const rejected = [];
300
+ const kept = conf.filter((x) => {
301
+ if (!x.keep || (x.fix && norm(x.fix) === norm(textOf(x.id)))) return false;
302
+ const why = x.fix ? fixBreaksData(audit.facts, x.id, textOf(x.id), x.fix, x.evidence) : false;
303
+ if (why) { rejected.push({ id: x.id, why }); return false; }
304
+ return true;
305
+ });
262
306
  const lost = items.filter((x) => x.verdict === 'unverifiable');
263
- rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: kept.length, unverifiable: lost.length, items: kept.map((x) => ({ ...x, text: all.find((s) => s.id === x.id)?.text })) });
264
- log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, не проверить ${lost.length}`);
307
+ rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: kept.length, byCode: byCode.length, rejected, unverifiable: lost.length, items: [...byCode, ...kept].map((x) => ({ ...x, text: textOf(x.id) })) });
308
+ log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, кодом ${byCode.length}, отклонено правок ${rejected.length}, не проверить ${lost.length}`);
265
309
  const changed = [];
266
- for (const x of kept) if (x.fix && applyFix(audit, x.id, x.fix)) changed.push(x.id);
310
+ // Найденное кодом возвращается к тексту из данных; его не нужно перепроверять моделью.
311
+ for (const x of byCode) applyFix(audit, x.id, x.fix);
312
+ for (const x of kept) if (x.fix && applyFix(audit, x.id, x.fix)) { changed.push(x.id); approveNumbers(audit.facts, x.id, x.fix); }
267
313
  for (const x of lost.slice().sort((a, b) => b.id.localeCompare(a.id))) if (dropStatement(audit, x.id)) changed.push(x.id);
268
314
  rescore(audit);
269
315
  if (!kept.length) { left = []; break; }
@@ -271,5 +317,7 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
271
317
  todo = statementsOf(audit, { lang }).filter((st) => changed.includes(st.id));
272
318
  if (round === maxRounds) left = kept;
273
319
  }
274
- return { ok: left.length === 0, rounds, usage, left };
320
+ // Последний круг: кодом ещё раз, чтобы правки моделей не оставили чужих чисел.
321
+ if (!held) { const after = codeCheck(audit, statementsOf(audit, { lang })); for (const x of after) applyFix(audit, x.id, x.fix); if (after.length) log(`проверка кодом после правок: вернули к данным ${after.length}`); }
322
+ return { ok: !held && left.length === 0, rounds, usage, left, ...(held ? { held } : {}) };
275
323
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 20 подсаженных ошибок (задание 14.10.2026, «второй шаг проверки фактов»): в отчёты пяти эталонов
3
+ * вписываются по четыре ложных числа, как это сделал Hermes, и прогоняется проверка с подменёнными
4
+ * моделями, без сети и денег. Каждая ошибка должна быть исправлена на верное значение или отчёт удержан.
5
+ * На отчётах без подсадки ни одна верная фраза не должна стать неверной.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { replayer } from './etalon.mjs';
9
+ import { collect } from './collect.mjs';
10
+ import { secondLook } from './verify.mjs';
11
+ import { draftNarrative } from './narrative.mjs';
12
+ import { statementsOf, applyFix, reviewAndFix } from './review.mjs';
13
+ import { numbersOf } from './facts.mjs';
14
+
15
+ const dir = new URL('../test/etalon/', import.meta.url);
16
+ // [id, число было, число стало]: те же места, что у Hermes.
17
+ const PLANTS = {
18
+ medeq: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '79'], ['sum:prio:0', '120', '367']],
19
+ neronov: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '23 000', '76 000'], ['sum:lead', '20', '67'], ['sum:verdict', null, '28']],
20
+ brainmd: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '49'], ['sum:prio:1', null, '148']],
21
+ drivee: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '52'], ['sum:prio:0', null, '145']],
22
+ babynanny: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '13 000', '46 000'], ['sum:lead', null, '61'], ['sum:verdict', null, '40']],
23
+ };
24
+
25
+ const json = (o) => ({ text: JSON.stringify(o), usage: { input: 1000, output: 100 } });
26
+ /** Первый шаг: всё «ok», кроме заданных id (их помечает ложными). */
27
+ const firstStep = (flag = new Set()) => async (messages) => {
28
+ const st = JSON.parse(messages[2].content).statements;
29
+ return json({ items: st.map((s) => (flag.has(s.id) ? { id: s.id, verdict: 'false', why: 'подсадка', fix: s.text } : { id: s.id, verdict: 'ok', premiseTrue: true, fitsNiche: true, passesNow: false, possible: true })) });
30
+ };
31
+ const silent = async () => json({ items: [] });
32
+ /** Второй шаг, который «подтверждает» порчу верного числа: правка должна быть отклонена. */
33
+ const spoiler = async (messages) => {
34
+ const fl = JSON.parse(messages[2].content).flagged;
35
+ return json({ items: fl.map((x) => ({ id: x.id, keep: true, evidence: 'на глаз', fix: String(x.text).replace(/\d+/, (n) => String(Number(n) + 1)) })) });
36
+ };
37
+
38
+ async function build(name) {
39
+ const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
40
+ const real = globalThis.fetch;
41
+ globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
42
+ const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
43
+ const second = await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
44
+ globalThis.fetch = real;
45
+ return { audit: draftNarrative(raw, { lang: truth.lang }), digest: second.digest || { robots: '', pages: [] }, lang: truth.lang };
46
+ }
47
+
48
+ const textMap = (audit, lang) => Object.fromEntries(statementsOf(audit, { lang }).map((s) => [s.id, s.text]));
49
+ /** Подсадить: заменить число «было» (или первое число строки) на «стало». */
50
+ function plant(audit, lang, [id, from, to]) {
51
+ const t = textMap(audit, lang)[id];
52
+ if (!t) throw new Error(`нет строки ${id}`);
53
+ const target = from || numbersOf(t)[0];
54
+ if (!target) throw new Error(`в строке ${id} нет числа: «${t}»`);
55
+ const re = new RegExp(`(?<!\\d)(?<!\\d[\\u00A0\\u202F ])${target.replace(/ /g, '[\\u00A0\\u202F ]')}(?![\\d])`);
56
+ if (!re.test(t)) throw new Error(`в строке ${id} нет числа ${target}: «${t}»`);
57
+ applyFix(audit, id, t.replace(re, to));
58
+ return { id, from: target, to, before: t };
59
+ }
60
+
61
+ let fail = 0; const table = [];
62
+ for (const name of Object.keys(PLANTS)) {
63
+ const row = { site: name, fixed: 0, held: 0, missed: 0, spoiled: 0 };
64
+
65
+ // 1. Подсадка, первый шаг ничего не замечает, второй молчит: ловить обязан код.
66
+ {
67
+ const { audit, digest, lang } = await build(name);
68
+ const planted = PLANTS[name].map((p) => plant(audit, lang, p));
69
+ const rev = await reviewAndFix(audit, digest, firstStep(), { lang, askConfirm: silent });
70
+ const after = textMap(audit, lang);
71
+ for (const p of planted) {
72
+ const now = after[p.id] || '';
73
+ const fixed = !numbersOf(now).includes(p.to.replace(/ /g, '')) && now === p.before;
74
+ if (fixed) row.fixed++; else if (!rev.ok) row.held++; else { row.missed++; fail++; console.log(`FAIL ${name} ${p.id}: «${p.to}» прошло молча: «${now}»`); }
75
+ }
76
+ }
77
+ // 2. Подсадка, первый шаг помечает ещё и верную строку, второй молчит: отчёт удержан с причиной.
78
+ {
79
+ const { audit, digest, lang } = await build(name);
80
+ PLANTS[name].forEach((p) => plant(audit, lang, p));
81
+ const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim' && !PLANTS[name].some((p) => p[0] === s.id));
82
+ const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: silent });
83
+ if (rev.ok || !/не ответил по 1 строке/.test(rev.held || '')) { fail++; console.log(`FAIL ${name}: молчание второго шага не удержало отчёт (${rev.held || 'ok'}), строка ${other.id}, круги ${JSON.stringify(rev.rounds.map((r) => ({ f: r.flagged, c: r.byCode, m: r.missing })))}`); }
84
+ }
85
+ // 2б. Ответ в «грязной» форме (```json, keep строкой, id с пробелом) это ответ, а не молчание.
86
+ {
87
+ const { audit, digest, lang } = await build(name);
88
+ const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim');
89
+ const messy = async (messages) => { const fl = JSON.parse(messages[2].content).flagged; return { text: '```json\n' + JSON.stringify({ items: fl.map((x) => ({ id: ` ${x.id} `, keep: 'false' })) }) + '\n```', usage: { input: 1, output: 1 } }; };
90
+ const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: messy });
91
+ if (!rev.ok || rev.held) { fail++; console.log(`FAIL ${name}: «грязный» ответ второго шага принят за молчание (${rev.held})`); }
92
+ }
93
+ // 2в. Русская правка строки с латиницей («www») не затирается переводом: иначе круги не сходятся (cimes.ru, 14.10.2026).
94
+ if (name === 'medeq') {
95
+ const { audit, lang } = await build(name);
96
+ applyFix(audit, 'chk:www:value', 'Склейка адреса с www: адрес с www перенаправляется на основной');
97
+ const t = textMap(audit, lang)['chk:www:value'];
98
+ if (t !== 'Склейка адреса с www: адрес с www перенаправляется на основной') { fail++; console.log(`FAIL правка www затёрта переводом: «${t}»`); }
99
+ }
100
+ // 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
101
+ for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
102
+ const { audit, digest, lang } = await build(name);
103
+ const mine = textMap(audit, lang);
104
+ await reviewAndFix(audit, digest, first, { lang, askConfirm: confirm });
105
+ const after = textMap(audit, lang);
106
+ for (const [id, t] of Object.entries(mine)) if (after[id] !== undefined && after[id] !== t) { row.spoiled++; fail++; console.log(`FAIL ${name} (${label}) ${id}: верное «${t}» стало «${after[id]}»`); }
107
+ }
108
+ table.push(row);
109
+ }
110
+ for (const r of table) console.log(`${r.site.padEnd(10)} исправлено ${r.fixed}, удержано ${r.held}, пропущено ${r.missed}, испорчено верных ${r.spoiled}`);
111
+ const total = table.reduce((a, r) => ({ fixed: a.fixed + r.fixed, held: a.held + r.held, missed: a.missed + r.missed }), { fixed: 0, held: 0, missed: 0 });
112
+ console.log(`подсадка: ${total.fixed + total.held} из 20 исправлены или удержаны, пропущено молча ${total.missed}`);
113
+ if (fail) process.exit(1);
114
+ console.log('PASS planted errors');
package/src/verify.mjs CHANGED
@@ -180,7 +180,9 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
180
180
  // 2. Страница «о компании».
181
181
  const about = byId('trust-about');
182
182
  if (about && about.status !== 'ok') {
183
- const hit = pages.find((p) => hrefs(p.html).some((h) => ABOUT_HREF.test(h)) || ABOUT_HREF.test(new URL(p.url).pathname) || /"@type"\s*:\s*"AboutPage"/.test(p.html) || ABOUT_TITLE.test(titleOf(p.html)) || h1sOf(p.html).some((h) => ABOUT_TITLE.test(h)));
183
+ // Только свои ссылки: «yandex.ru/support/.../about» не страница о компании (cimes.ru, 14.10.2026).
184
+ const ownHref = (h, base) => { try { return new URL(h, base).host.replace(/^www\./, '') === new URL(base).host.replace(/^www\./, ''); } catch { return false; } };
185
+ const hit = pages.find((p) => hrefs(p.html).some((h) => ownHref(h, p.url) && ABOUT_HREF.test(h)) || ABOUT_HREF.test(new URL(p.url).pathname) || /"@type"\s*:\s*"AboutPage"/.test(p.html) || ABOUT_TITLE.test(titleOf(p.html)) || h1sOf(p.html).some((h) => ABOUT_TITLE.test(h)));
184
186
  if (hit) change('trust-about', { status: 'ok', value: ru ? `есть: найдено на ${path(hit.url)}` : `present: found via ${path(hit.url)}` }, { url: hit.url });
185
187
  else change('trust-about', { value: ru ? `страницы о компании нет ни на одной из ${pages.length} прочитанных страниц` : `no about or team page on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
186
188
  }
@@ -207,7 +209,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
207
209
  const inSchema = new Set(); const inLinks = new Set();
208
210
  for (const p of pages) {
209
211
  const ld = String(p.html).replace(/\\\//g, '/');
210
- for (const m of ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) for (const u of m[1].match(/https?:\/\/[^"\s,\]]+/g) || []) { try { const h = new URL(u).host.replace(/^www\./, ''); if (h !== own) inSchema.add(h); } catch { /* мусор */ } }
212
+ for (const m of ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) for (const u of m[1].match(/https?:\/\/[^"\s,\]]+/g) || []) { try { const h = new URL(u).host.replace(/^www\./, ''); if (h !== own && !/^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i.test(h)) inSchema.add(h); } catch { /* мусор */ } }
211
213
  for (const m of String(p.html).matchAll(SOCIAL)) inLinks.add(m[1].toLowerCase());
212
214
  }
213
215
  if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value: `${inSchema.size}: ${[...inSchema].slice(0, 5).join(', ')}` }, { inSchema: [...inSchema] });
@@ -288,7 +290,13 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
288
290
  }
289
291
  audit.verification = { ...audit.verification, at: new Date().toISOString(), pagesRead: pages.length, changes };
290
292
  const src = (audit.sources || [])[0];
291
- if (src && pages.length > (audit.sample || []).length) src.detail = ru ? `robots.txt и все ${pages.length} ${pages.length % 10 >= 2 && pages.length % 10 <= 4 && (pages.length % 100 < 10 || pages.length % 100 >= 20) ? 'страницы' : pages.length % 10 === 1 && pages.length % 100 !== 11 ? 'страницу' : 'страниц'} из карты сайта: на них проверено всё, что отчёт называет отсутствующим; ${(audit.sample || []).length} страниц выборки (включая главную) разобраны подробно` : `robots.txt and all ${pages.length} pages in the sitemap, checked for everything the report calls absent; ${(audit.sample || []).length} sampled pages (home included) read in detail`;
293
+ // «все 150 страниц из карты» при 342 адресах в карте неправда (cimes.ru, 14.10.2026): читается до 150, это и пишем.
294
+ const smTotal = Number((String((audit.checks || []).find((c) => c.id === 'sitemap')?.value || '').match(/(\d+)/) || [])[1]) || 0;
295
+ const partial = smTotal > pages.length;
296
+ const pw = (n) => (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : n % 10 === 1 && n % 100 !== 11 ? 'страницу' : 'страниц');
297
+ if (src && pages.length > (audit.sample || []).length) src.detail = ru
298
+ ? `robots.txt и ${partial ? `${pages.length} из ${smTotal} адресов` : `все ${pages.length} ${pw(pages.length)}`} из карты сайта: на них проверено всё, что отчёт называет отсутствующим; ${(audit.sample || []).length} страниц выборки (включая главную) разобраны подробно`
299
+ : `robots.txt and ${partial ? `${pages.length} of the ${smTotal} URLs` : `all ${pages.length} pages`} in the sitemap, checked for everything the report calls absent; the ${(audit.sample || []).length} sampled pages (homepage included) read in detail`;
292
300
  rescore(audit);
293
301
  // Сжатые копии прочитанных страниц для проверяющего: в audit не пишутся, отдаются вызывающему.
294
302
  Object.defineProperty(audit.verification, 'digest', { value: siteDigest(pages, robotsText), enumerable: false });