@operstack/audit 0.35.0 → 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 +1 -1
- package/src/collect.mjs +34 -8
- package/src/i18n.mjs +6 -3
- package/src/narrative.mjs +2 -1
- package/src/prompts.mjs +2 -1
- package/src/rendered.mjs +15 -1
- package/src/review.mjs +15 -7
- package/src/test-planted.mjs +15 -0
- package/src/verify.mjs +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.35.
|
|
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",
|
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|&|&) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing
|
|
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|&|&) 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/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])} вниз по странице, измерено на ${
|
|
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
|
-
|
|
182
|
-
|
|
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
|
@@ -319,7 +319,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
319
319
|
}),
|
|
320
320
|
}));
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
// «Технически в порядке, чинить нечего» только когда открытых технических проверок нет совсем, частичных тоже (cimes.ru, 14.10.2026).
|
|
323
|
+
if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length + mechanical);
|
|
323
324
|
// Что дали данные в каждом поле с числами: по этому снимку проверка кодом ловит чужие числа (facts.mjs, 14.10.2026).
|
|
324
325
|
a.facts = snapshotFacts(a, lang);
|
|
325
326
|
return a;
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -68,8 +68,8 @@ export function applyFix(audit, id, text) {
|
|
|
68
68
|
if (m[0] === 'chk') {
|
|
69
69
|
const c = (audit.checks || []).find((x) => x.id === m[1]); if (!c) return false;
|
|
70
70
|
const label = c.label;
|
|
71
|
-
if (m[2] === 'value') c.value = strip(text, label);
|
|
72
|
-
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; }
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
75
75
|
if (m[0] === 'task') { audit.taskOverrides = audit.taskOverrides || {}; (audit.taskOverrides[m[1]] = audit.taskOverrides[m[1]] || {})[m[2]] = text; return true; }
|
|
@@ -243,10 +243,18 @@ export async function confirmFlags(audit, digest, statements, flagged, ask, { la
|
|
|
243
243
|
const askGroup = async (g) => {
|
|
244
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) }) }]);
|
|
245
245
|
usage.calls += 1; usage.input += r.usage?.input || 0; usage.cached += r.usage?.cached || 0; usage.output += r.usage?.output || 0;
|
|
246
|
-
|
|
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 || []);
|
|
247
251
|
const ids = new Set(g.map((x) => x.id));
|
|
248
|
-
|
|
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;
|
|
249
256
|
};
|
|
257
|
+
const lastRaw = {};
|
|
250
258
|
const items = (await Promise.all(chunks(flagged, group).map(askGroup))).flat();
|
|
251
259
|
// Молчание не «ошибки нет»: по каждой пропущенной строке один повторный запрос, маленькими группами.
|
|
252
260
|
let missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
|
|
@@ -254,7 +262,7 @@ export async function confirmFlags(audit, digest, statements, flagged, ask, { la
|
|
|
254
262
|
items.push(...(await Promise.all(chunks(missing, retryGroup).map(askGroup))).flat());
|
|
255
263
|
missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
|
|
256
264
|
}
|
|
257
|
-
return { items, missing: missing.map((f) => f.id), usage };
|
|
265
|
+
return { items, missing: missing.map((f) => f.id), raw: Object.fromEntries(missing.map((f) => [f.id, lastRaw[f.id] || ''])), usage };
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
/**
|
|
@@ -275,13 +283,13 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
|
|
|
275
283
|
const codeIds = new Set(byCode.map((x) => x.id));
|
|
276
284
|
const flagged = items.filter((x) => (x.verdict === 'false' || x.verdict === 'misleading') && !codeIds.has(x.id));
|
|
277
285
|
// Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
|
|
278
|
-
const { items: conf, missing, 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 });
|
|
279
287
|
add(cu, usage.confirm);
|
|
280
288
|
if (missing.length) {
|
|
281
289
|
held = `второй проверяющий не ответил по ${missing.length} ${missing.length === 1 ? 'строке' : 'строкам'}: ${missing.join(', ')}`;
|
|
282
290
|
log(`проверка, круг ${round}: ${held}`);
|
|
283
291
|
left = missing.map((id) => ({ id, why: 'второй проверяющий не ответил' }));
|
|
284
|
-
rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing });
|
|
292
|
+
rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing, missingRaw });
|
|
285
293
|
break;
|
|
286
294
|
}
|
|
287
295
|
// Правка, которая ничего не меняет, это подтверждение, а не находка: иначе круги идут впустую (14.10.2026, neronov.pro).
|
package/src/test-planted.mjs
CHANGED
|
@@ -82,6 +82,21 @@ for (const name of Object.keys(PLANTS)) {
|
|
|
82
82
|
const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: silent });
|
|
83
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
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
|
+
}
|
|
85
100
|
// 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
|
|
86
101
|
for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
|
|
87
102
|
const { audit, digest, lang } = await build(name);
|
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
|
-
|
|
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
|
-
|
|
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 });
|