@operstack/audit 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/collect.mjs +6 -2
- package/src/facts.mjs +86 -0
- package/src/i18n.mjs +1 -1
- package/src/narrative.mjs +3 -0
- package/src/prompts.mjs +10 -1
- package/src/review.mjs +67 -23
- package/src/test-planted.mjs +99 -0
- package/src/verify.mjs +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|
|
@@ -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
|
@@ -790,7 +790,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
790
790
|
const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
|
|
791
791
|
const dupTitles = [...titles].filter(([, n]) => n > 1);
|
|
792
792
|
if (good.length > 1) checks.push(row('dup-titles', 'onpage', 'Duplicate titles in sample', dupTitles.length ? 'warn' : 'ok', dupTitles.length ? `${dupTitles.length} title(s) shared by several pages, e.g. "${dupTitles[0][0]}"` : 'every sampled page has its own title'));
|
|
793
|
-
|
|
793
|
+
// Список статей, отзывы с виджетом, рубрики короткие по устройству: их не считаем тонкими (14.10.2026, neronov.pro).
|
|
794
|
+
const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
|
|
795
|
+
const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
|
|
794
796
|
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`));
|
|
795
797
|
const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
|
|
796
798
|
if (utility.length) {
|
|
@@ -899,7 +901,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
899
901
|
const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
|
|
900
902
|
if (depths.length) {
|
|
901
903
|
const md = median(depths);
|
|
902
|
-
|
|
904
|
+
// Если кнопка действия стоит на первом экране почти везде, форма внизу не беда: к ней ведёт кнопка (14.10.2026, neronov.pro).
|
|
905
|
+
const ctaShare = pagesForTrust.length ? pagesForTrust.filter((p) => p.ctaFirstScreen).length / pagesForTrust.length : 0;
|
|
906
|
+
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' : ''));
|
|
903
907
|
} else {
|
|
904
908
|
// Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
|
|
905
909
|
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
|
@@ -154,7 +154,7 @@ const RU = {
|
|
|
154
154
|
'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
155
|
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
|
|
156
156
|
'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) => (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
158
|
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
|
|
159
159
|
'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
|
|
160
160
|
};
|
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');
|
|
@@ -319,6 +320,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
319
320
|
}));
|
|
320
321
|
|
|
321
322
|
if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length);
|
|
323
|
+
// Что дали данные в каждом поле с числами: по этому снимку проверка кодом ловит чужие числа (facts.mjs, 14.10.2026).
|
|
324
|
+
a.facts = snapshotFacts(a, lang);
|
|
322
325
|
return a;
|
|
323
326
|
}
|
|
324
327
|
|
package/src/prompts.mjs
CHANGED
|
@@ -365,6 +365,12 @@ const PF = {
|
|
|
365
365
|
const homeH2 = (home?.h2List || []).join('|');
|
|
366
366
|
// Страницы, где все H2 те же, что на главной: это шаблон, обрамляющий почти пустую запись (14.10.2026, карточки врачей medeq-mc.ru).
|
|
367
367
|
const templated = homeH2 && (home.h2List || []).length >= 3 ? sm.slice(1).filter((p) => (p.h2List || []).join('|') === homeH2) : [];
|
|
368
|
+
// Список статей и страница отзывов с виджетом короткие по устройству, дописывать их не нужно (14.10.2026).
|
|
369
|
+
const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
|
|
370
|
+
const shortReal = sm.filter((p) => (p.words || 0) < 300 && !LISTY.test(pathOf(p.url)));
|
|
371
|
+
if (!templated.length && !shortReal.length && sm.some((p) => (p.words || 0) < 300)) return ru
|
|
372
|
+
? { task: `Короткие страницы выборки это списки и отзывы (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): дописывать их не нужно. Если отзывы подгружаются виджетом, проверь, что хотя бы несколько отзывов видны в самом коде страницы, иначе поиск и ИИ их не читают.`, verify: 'На странице отзывов несколько отзывов видны в исходном коде страницы, а не только в виджете.' }
|
|
373
|
+
: { task: `The short sampled pages are lists and reviews (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): no need to pad them. If reviews load through a widget, make sure a few reviews are present in the page source, otherwise search and AI cannot read them.`, verify: 'A few reviews are visible in the page source, not only in the widget.' };
|
|
368
374
|
if (templated.length >= 2) {
|
|
369
375
|
const list = templated.slice(0, 4).map((p) => pathOf(p.url)).join(', ');
|
|
370
376
|
return ru
|
|
@@ -375,7 +381,10 @@ const PF = {
|
|
|
375
381
|
? { task: `Допиши короткие страницы фактами, которые знает владелец и которые нужны ${v.peopleDat || v.people}: что это, кому, как, где и когда. ${v.price} Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов.`, verify: 'Каждая дописанная страница стала длиннее 300 слов за счёт фактов (названия, условия, адреса, сроки), а не общих слов.' }
|
|
376
382
|
: { task: `Fill the short pages with facts the owner has and ${v.people} need: what it is, for whom, how, where and when. ${v.price} No filler for length. If there are no facts, list questions for the owner.`, verify: 'Each page you filled is now over 300 words thanks to facts (names, terms, places, timings), not general phrases.' };
|
|
377
383
|
},
|
|
378
|
-
'trust-policies': ({ ru }) => (ru
|
|
384
|
+
'trust-policies': ({ audit, ru }) => (audit?.verification?.consentBox ? (ru
|
|
385
|
+
? { task: 'Галочка согласия на обработку данных у формы уже есть, не хватает самой политики конфиденциальности. Её текст готовит владелец или юрист, не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку на неё в подвал и рядом с галочкой согласия у формы.', verify: 'Страница политики конфиденциальности открывается с кодом 200, ссылка на неё есть в подвале каждой страницы и рядом с галочкой согласия у формы.' }
|
|
386
|
+
: { task: 'The form already has a consent checkbox; what is missing is the privacy policy itself. The owner or a lawyer writes it. Once it exists, publish it as a page and link it from the footer and next to the consent checkbox.', verify: 'The privacy policy page answers 200 and is linked from every footer and next to the consent checkbox.' })
|
|
387
|
+
: null) || (ru
|
|
379
388
|
? { task: 'Политику конфиденциальности и согласие на обработку персональных данных готовит владелец или юрист: текст не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку в подвал и добавь к каждой форме, которая собирает имя, телефон или почту, галочку согласия со ссылкой на политику. Если правила или условия услуг лежат внутри другой страницы, вынеси их на отдельную и тоже поставь ссылку в подвал.', verify: 'В подвале каждой страницы есть ссылка на политику, она открывается с кодом 200, и у каждой формы есть галочка согласия.' }
|
|
380
389
|
: { task: 'The owner or a lawyer writes the privacy policy and terms: do not draft legal text. Once it exists, publish it as its own page, link it from the footer, and add a consent checkbox linking to it on every form that collects a name, phone or email.', verify: 'Every page footer links the policy, it answers 200, and every form has a consent checkbox.' }),
|
|
381
390
|
'trust-profiles': ({ check, ru }) => (/ссылки на сайте есть|pages link to/i.test(String(check.value)) ? (ru
|
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';
|
|
@@ -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);
|
|
@@ -133,7 +135,14 @@ export function toolFacts(audit) {
|
|
|
133
135
|
sitemapUrlCount: (String(probe('sitemap') || '').match(/(\d+)/) || [])[1],
|
|
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
|
-
perSampledPage: sm.map((p) => ({ url: p.url, words: p.words, h1Count: p.h1Count, h2Count: p.h2Count, images: p.images, imagesNoAlt: p.imagesNoAlt, ctaFirstScreen: p.ctaFirstScreen })),
|
|
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) },
|
|
145
|
+
sampleRule: 'Числа вида «X из N страниц выборки» считаются только по sampleUrls (perSampledPage), а не по всем страницам выжимки сайта.',
|
|
137
146
|
classification: 'Деление открытых проверок на «правятся технически» и «требуют фактов владельца» и оценки областей считает инструмент по своему списку; это не утверждения о сайте.',
|
|
138
147
|
};
|
|
139
148
|
}
|
|
@@ -176,7 +185,7 @@ export async function reviewRound(audit, digest, statements, ask, { lang = audit
|
|
|
176
185
|
// Пачки по 20 утверждений одной части: на 170 строках сразу модель пропускала очевидное (14.10.2026).
|
|
177
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; });
|
|
178
187
|
// Одна и та же выжимка в начале каждого вызова: OpenAI берёт повторяющееся начало из кэша за десятую часть цены.
|
|
179
|
-
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) });
|
|
180
189
|
const runBatch = async ({ part, st }) => {
|
|
181
190
|
const messages = [
|
|
182
191
|
{ role: 'system', content: SYSTEM(ru, niche) },
|
|
@@ -214,27 +223,38 @@ export async function reviewRound(audit, digest, statements, ask, { lang = audit
|
|
|
214
223
|
|
|
215
224
|
const CONFIRM = (ru) => (ru
|
|
216
225
|
? `Ты второй проверяющий. Первый пометил утверждения отчёта о сайте как ложные или вводящие в заблуждение. Он часто ошибается: помечает верное. Твоя задача отсеять ложные тревоги.
|
|
217
|
-
По каждому помеченному утверждению найди в выжимке сайта или toolFacts КОНКРЕТНОЕ доказательство: адрес страницы и поле (или файл) со значением, которое прямо опровергает утверждение, либо (для задачи) показывает, что проблемы нет, совет чужой ниши или проверка уже проходит. Нашёл: keep true, evidence с адресом и значением, fix с полным новым текстом по-русски без длинного и среднего тире. Не нашёл прямого доказательства: keep false. Мнения, стиль и «можно сказать лучше» это не
|
|
226
|
+
По каждому помеченному утверждению найди в выжимке сайта или toolFacts КОНКРЕТНОЕ доказательство: адрес страницы и поле (или файл) со значением, которое прямо опровергает утверждение, либо (для задачи) показывает, что проблемы нет, совет чужой ниши или проверка уже проходит. Нашёл: keep true, evidence с адресом и значением, fix с полным новым текстом по-русски без длинного и среднего тире. Не нашёл прямого доказательства: keep false. Мнения, стиль и «можно сказать лучше» это не доказательство: перефразировка без изменения смысла это keep false. Числа «X из N страниц выборки» сверяй только со страницами из toolFacts.sampleUrls и perSampledPage, не со всем сайтом.
|
|
218
227
|
Ответ строго JSON: {"items":[{"id":"...","keep":true,"evidence":"адрес: поле = значение","fix":"..."}]}`
|
|
219
228
|
: `You are the second reviewer. The first one flagged report statements as false or misleading. It often errs and flags correct text. Your job is to drop the false alarms.
|
|
220
|
-
For each flagged statement find SPECIFIC evidence in the site digest or toolFacts: a page URL and a field (or file) whose value directly contradicts the statement, or (for a task) shows the problem is absent, the advice belongs to another niche, or the check already passes. Found it: keep true, evidence with URL and value, fix with the full replacement in English, no em or en dashes. No direct evidence: keep false. Opinions, style and "could be phrased better" are not evidence.
|
|
229
|
+
For each flagged statement find SPECIFIC evidence in the site digest or toolFacts: a page URL and a field (or file) whose value directly contradicts the statement, or (for a task) shows the problem is absent, the advice belongs to another niche, or the check already passes. Found it: keep true, evidence with URL and value, fix with the full replacement in English, no em or en dashes. No direct evidence: keep false. Opinions, style and "could be phrased better" are not evidence: a rewording that keeps the meaning is keep false. Check "X of N sampled pages" only against toolFacts.sampleUrls and perSampledPage, not the whole site.
|
|
221
230
|
Answer strictly as JSON: {"items":[{"id":"...","keep":true,"evidence":"url: field = value","fix":"..."}]}`);
|
|
222
231
|
|
|
223
232
|
/** Второй шаг: подтвердить пометки первого более сильной моделью, только с прямым доказательством. */
|
|
224
|
-
|
|
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 } = {}) {
|
|
225
237
|
const ru = lang === 'ru';
|
|
226
|
-
if (!flagged.length) return { items: [], usage: { input: 0, output: 0, calls: 0 } };
|
|
227
|
-
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) });
|
|
228
240
|
const byId = Object.fromEntries(statements.map((s) => [s.id, s]));
|
|
229
241
|
const usage = { input: 0, output: 0, cached: 0, calls: 0 };
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
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) }) }]);
|
|
233
245
|
usage.calls += 1; usage.input += r.usage?.input || 0; usage.cached += r.usage?.cached || 0; usage.output += r.usage?.output || 0;
|
|
234
|
-
let parsed; try { parsed = JSON.parse(String(r.text).replace(/^```json\s*|```$/g, '')); } catch {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
246
|
+
let parsed; try { parsed = JSON.parse(String(r.text).replace(/^```json\s*|```$/g, '')); } catch { return []; }
|
|
247
|
+
const ids = new Set(g.map((x) => x.id));
|
|
248
|
+
return (parsed.items || []).filter((x) => x && ids.has(x.id) && typeof x.keep === 'boolean');
|
|
249
|
+
};
|
|
250
|
+
const items = (await Promise.all(chunks(flagged, group).map(askGroup))).flat();
|
|
251
|
+
// Молчание не «ошибки нет»: по каждой пропущенной строке один повторный запрос, маленькими группами.
|
|
252
|
+
let missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
|
|
253
|
+
if (missing.length) {
|
|
254
|
+
items.push(...(await Promise.all(chunks(missing, retryGroup).map(askGroup))).flat());
|
|
255
|
+
missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
|
|
256
|
+
}
|
|
257
|
+
return { items, missing: missing.map((f) => f.id), usage };
|
|
238
258
|
}
|
|
239
259
|
|
|
240
260
|
/**
|
|
@@ -245,21 +265,43 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
|
|
|
245
265
|
const rounds = []; const usage = { input: 0, cached: 0, output: 0, calls: 0, confirm: { input: 0, cached: 0, output: 0, calls: 0 } };
|
|
246
266
|
const add = (u, t) => { for (const k of ['input', 'cached', 'output', 'calls']) t[k] += u[k] || 0; };
|
|
247
267
|
let todo = statementsOf(audit, { lang });
|
|
248
|
-
let left = [];
|
|
268
|
+
let left = []; let held = null;
|
|
249
269
|
for (let round = 1; round <= maxRounds && todo.length; round++) {
|
|
250
270
|
const all = statementsOf(audit, { lang });
|
|
251
271
|
const { items, usage: u } = await reviewRound(audit, digest, todo, ask, { lang });
|
|
252
272
|
add(u, usage);
|
|
253
|
-
|
|
273
|
+
// Сначала бесплатно кодом: числа в полях, собранных из данных, сверяются с данными (facts.mjs).
|
|
274
|
+
const byCode = codeCheck(audit, all);
|
|
275
|
+
const codeIds = new Set(byCode.map((x) => x.id));
|
|
276
|
+
const flagged = items.filter((x) => (x.verdict === 'false' || x.verdict === 'misleading') && !codeIds.has(x.id));
|
|
254
277
|
// Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
|
|
255
|
-
const { items: conf, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
|
|
278
|
+
const { items: conf, missing, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
|
|
256
279
|
add(cu, usage.confirm);
|
|
257
|
-
|
|
280
|
+
if (missing.length) {
|
|
281
|
+
held = `второй проверяющий не ответил по ${missing.length} ${missing.length === 1 ? 'строке' : 'строкам'}: ${missing.join(', ')}`;
|
|
282
|
+
log(`проверка, круг ${round}: ${held}`);
|
|
283
|
+
left = missing.map((id) => ({ id, why: 'второй проверяющий не ответил' }));
|
|
284
|
+
rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing });
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
// Правка, которая ничего не меняет, это подтверждение, а не находка: иначе круги идут впустую (14.10.2026, neronov.pro).
|
|
288
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
289
|
+
const textOf = (id) => all.find((s) => s.id === id)?.text || '';
|
|
290
|
+
// Правка не должна портить верное: число из данных не убирается, новое число только с доказательством.
|
|
291
|
+
const rejected = [];
|
|
292
|
+
const kept = conf.filter((x) => {
|
|
293
|
+
if (!x.keep || (x.fix && norm(x.fix) === norm(textOf(x.id)))) return false;
|
|
294
|
+
const why = x.fix ? fixBreaksData(audit.facts, x.id, textOf(x.id), x.fix, x.evidence) : false;
|
|
295
|
+
if (why) { rejected.push({ id: x.id, why }); return false; }
|
|
296
|
+
return true;
|
|
297
|
+
});
|
|
258
298
|
const lost = items.filter((x) => x.verdict === 'unverifiable');
|
|
259
|
-
rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: kept.length, unverifiable: lost.length, items: kept.map((x) => ({ ...x, text:
|
|
260
|
-
log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, не проверить ${lost.length}`);
|
|
299
|
+
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) })) });
|
|
300
|
+
log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, кодом ${byCode.length}, отклонено правок ${rejected.length}, не проверить ${lost.length}`);
|
|
261
301
|
const changed = [];
|
|
262
|
-
|
|
302
|
+
// Найденное кодом возвращается к тексту из данных; его не нужно перепроверять моделью.
|
|
303
|
+
for (const x of byCode) applyFix(audit, x.id, x.fix);
|
|
304
|
+
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); }
|
|
263
305
|
for (const x of lost.slice().sort((a, b) => b.id.localeCompare(a.id))) if (dropStatement(audit, x.id)) changed.push(x.id);
|
|
264
306
|
rescore(audit);
|
|
265
307
|
if (!kept.length) { left = []; break; }
|
|
@@ -267,5 +309,7 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
|
|
|
267
309
|
todo = statementsOf(audit, { lang }).filter((st) => changed.includes(st.id));
|
|
268
310
|
if (round === maxRounds) left = kept;
|
|
269
311
|
}
|
|
270
|
-
|
|
312
|
+
// Последний круг: кодом ещё раз, чтобы правки моделей не оставили чужих чисел.
|
|
313
|
+
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}`); }
|
|
314
|
+
return { ok: !held && left.length === 0, rounds, usage, left, ...(held ? { held } : {}) };
|
|
271
315
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
// 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
|
|
86
|
+
for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
|
|
87
|
+
const { audit, digest, lang } = await build(name);
|
|
88
|
+
const mine = textMap(audit, lang);
|
|
89
|
+
await reviewAndFix(audit, digest, first, { lang, askConfirm: confirm });
|
|
90
|
+
const after = textMap(audit, lang);
|
|
91
|
+
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]}»`); }
|
|
92
|
+
}
|
|
93
|
+
table.push(row);
|
|
94
|
+
}
|
|
95
|
+
for (const r of table) console.log(`${r.site.padEnd(10)} исправлено ${r.fixed}, удержано ${r.held}, пропущено ${r.missed}, испорчено верных ${r.spoiled}`);
|
|
96
|
+
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 });
|
|
97
|
+
console.log(`подсадка: ${total.fixed + total.held} из 20 исправлены или удержаны, пропущено молча ${total.missed}`);
|
|
98
|
+
if (fail) process.exit(1);
|
|
99
|
+
console.log('PASS planted errors');
|
package/src/verify.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { request as httpRequest } from 'node:http';
|
|
|
18
18
|
|
|
19
19
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36';
|
|
20
20
|
const MESSENGER = /(?:wa\.me\/|api\.whatsapp\.com|whatsapp:\/\/|\bt\.me\/|telegram\.me\/|viber:\/\/|invite\.viber\.com|\bmax\.ru\/)/i;
|
|
21
|
-
|
|
21
|
+
// Соцсети и отраслевые каталоги: для врача профиль на ПроДокторов важнее соцсетей (14.10.2026, neronov.pro).
|
|
22
|
+
const SOCIAL = /\/\/(?:www\.)?(vk\.com|ok\.ru|t\.me|instagram\.com|facebook\.com|youtube\.com|rutube\.ru|dzen\.ru|linkedin\.com|tiktok\.com|x\.com|twitter\.com|pinterest\.com|prodoctorov\.ru|napopravku\.ru|docdoc\.ru|sberhealth\.ru|zoon\.ru|2gis\.ru|yandex\.ru\/maps\/org|yandex\.ru\/profile|google\.com\/maps|g\.page|yell\.ru|profi\.ru|avito\.ru\/brands|trustpilot\.com|yelp\.com|g2\.com|clutch\.co)\/[^"'\s?#]+/gi;
|
|
22
23
|
const ABOUT_HREF = /\/(about|about-us|about-me|our-story|team|company|o-nas|o-kompanii|o-czentre|o-centre|o-klinike|kompaniya|obo-mne|o-sebe|ob-avtore|o-proekte|our-practice|o-vrache|o-klinike)\/?($|[?#])/i;
|
|
23
24
|
const ABOUT_TITLE = /^(о нас|о компании|о клинике|о центре|обо мне|о себе|об авторе|о проекте|о враче|о докторе|о специалисте|о практике|about|about us|about me|about the doctor|our practice|our story|who we are)(?![\p{L}])/iu;
|
|
24
25
|
const POLICY_TEXT = { privacy: /политик[аи] (?:конфиденциальности|обработки персональных данных)|privacy policy/i, terms: /пользовательское соглашение|публичная оферта|договор(?:-| )оферт|правила (?:и порядок )?оказания (?:платных )?услуг|terms (?:of (?:service|use)|and conditions)/i };
|
|
@@ -92,7 +93,9 @@ export function rebuildCritical(audit) {
|
|
|
92
93
|
if (week1 && Array.isArray(week1.items)) {
|
|
93
94
|
// В «быстрые победы» только техническое: то, что требует фактов владельца или юриста, уходит во вторую очередь (14.10.2026).
|
|
94
95
|
const quick = bad.filter((c) => MECHANICAL_IDS.has(c.id) && c.id !== 'trust-policies');
|
|
95
|
-
|
|
96
|
+
let slow = bad.filter((c) => !quick.includes(c));
|
|
97
|
+
// Пустая первая неделя при красной строке выглядит как «срочного нет», хотя приоритет 1 есть (14.10.2026, neronov.pro).
|
|
98
|
+
if (!quick.length && slow.length) { quick.push(slow[0]); slow = slow.slice(1); }
|
|
96
99
|
const other = week1.items.filter((i) => !String(i).startsWith(`${fix}:`) && !String(i).includes('{{'));
|
|
97
100
|
week1.items = [...quick.map((c) => `${fix}: ${c.label}`), ...other];
|
|
98
101
|
if (week2 && Array.isArray(week2.items)) {
|
|
@@ -160,10 +163,12 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
160
163
|
const textOnly = Object.entries(inText).filter(([k]) => !kinds.some((x) => (k === 'privacy' ? /privacy|konfid|personal|polit/.test(x) : !/privacy|konfid|personal|polit/.test(x))));
|
|
161
164
|
if (kinds.length || textOnly.length) {
|
|
162
165
|
const parts = [];
|
|
163
|
-
if (kinds.length) parts.push(ru ? `ссылки есть: ${kinds.join(', ')}` : `linked: ${kinds.join(', ')}`);
|
|
166
|
+
if (kinds.length) parts.push(ru ? `ссылки есть: ${kinds.map((k) => ({ privacy: 'политика конфиденциальности', terms: 'условия или оферта', consent: 'согласие на обработку данных', legal: 'правовая информация', cookie: 'политика cookie', disclaimer: 'отказ от ответственности' })[k] || k).join(', ')}` : `linked: ${kinds.join(', ')}`);
|
|
164
167
|
for (const [k, u] of textOnly) parts.push(ru ? `${k === 'privacy' ? 'политика конфиденциальности' : 'правила или условия оказания услуг'} есть только в тексте страницы ${path(u)}, отдельной страницы и ссылки нет` : `${k === 'privacy' ? 'privacy policy' : 'terms'} only inside the text of ${path(u)}, no page of its own and no link`);
|
|
165
168
|
const missingPrivacy = !kinds.some((x) => /privacy|konfid|personal|polit/.test(x)) && !inText.privacy;
|
|
166
169
|
if (missingPrivacy) parts.push(ru ? 'политики конфиденциальности нет нигде на сайте' : 'no privacy policy anywhere on the site');
|
|
170
|
+
const consentBox = pages.some((p) => /type=["']checkbox["'][^>]*(?:consent|soglas|personal|pd)|(?:consent|soglas|personal)[^>]*type=["']checkbox/i.test(p.html) || /согласие на обработку[^<]{0,80}<\/(?:label|span|a)/i.test(p.html));
|
|
171
|
+
audit.verification = audit.verification || {}; audit.verification.consentBox = consentBox;
|
|
167
172
|
const withForms = pages.filter((p) => /<form[\s\S]*?(?:type=["']?tel|name=["'][^"']*(?:phone|tel|name|email)|placeholder=["'][^"']*(?:телефон|номер|имя|почт))/i.test(p.html)).length;
|
|
168
173
|
if (missingPrivacy && withForms && ru) parts.push(`при этом формы, которые собирают контакты, стоят на ${withForms} из ${pages.length} страниц; по 152-ФЗ сайт, который собирает персональные данные, обязан опубликовать политику их обработки и получать согласие`);
|
|
169
174
|
change('trust-policies', { status: missingPrivacy ? 'bad' : kinds.length >= 2 ? 'ok' : 'warn', value: parts.join('; ') }, { pagesRead: pages.length, linked: kinds, inText, withForms });
|
|
@@ -334,7 +339,9 @@ export function siteDigest(pages, robotsText = '') {
|
|
|
334
339
|
words: text.split(/\s+/).filter(Boolean).length,
|
|
335
340
|
visibleDates: [...new Set((text.match(/(?<!\d)\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2}(?!\d)/g) || []))].slice(0, 6),
|
|
336
341
|
prices: [...new Set((text.match(/\d[\d\s]{0,9}(?:₽|руб(?:\.|лей|ля|ль)?(?![а-яё])|USD|\$)/gi) || []).map((x) => x.trim()))].slice(0, 6),
|
|
337
|
-
|
|
342
|
+
// Политика и согласие раздельно: согласие на обработку данных это не политика (14.10.2026, neronov.pro).
|
|
343
|
+
privacyText: /политик[аи] (?:конфиденциальности|в отношении обработки персональных данных|обработки персональных данных)|privacy policy/i.test(text),
|
|
344
|
+
consentText: /согласи[ея] на обработку|consent to (?:the )?processing/i.test(text),
|
|
338
345
|
termsText: /правила (?:и порядок )?оказания|публичная оферта|пользовательское соглашение|terms (?:of|and)/i.test(text),
|
|
339
346
|
accordions: (h.match(/class=["'][^"']*(?:faq|accordion|spoiler|toggle)[^"']*["']/gi) || []).length,
|
|
340
347
|
tableTags: (h.match(/<table\b/gi) || []).length,
|