@operstack/audit 0.33.4 → 0.34.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 +8 -3
- package/src/collect.mjs +70 -18
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +25 -19
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +42 -17
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +4 -1
- package/src/review.mjs +271 -0
- package/src/tasks-pdf.mjs +67 -0
- package/src/test-agent-surface.mjs +1 -1
- package/src/test-etalon.mjs +42 -0
- package/src/test-narrative.mjs +2 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +1 -1
- package/src/verify.mjs +347 -0
package/src/render.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* through headless Chrome. Every narrative field is read from the JSON; a field that still
|
|
4
4
|
* holds a {{placeholder}} is rendered highlighted so it cannot ship unnoticed.
|
|
5
5
|
*/
|
|
6
|
-
import { writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
import { writeFileSync, existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
10
10
|
import { computeScores, computeOverall } from './collect.mjs';
|
|
11
11
|
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
12
12
|
import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
|
|
@@ -35,7 +35,7 @@ const LABELS = {
|
|
|
35
35
|
// Подпись обязана сказать три вещи: откуда число, что именно успели прочитать, и почему
|
|
36
36
|
// повтор может дать на пункт-другой иначе. Без последнего человек читает разницу как ошибку.
|
|
37
37
|
overallNote: (o) => {
|
|
38
|
-
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''}. ` : '';
|
|
38
|
+
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''} for this score; the rest of the report counts the whole sample. ` : '';
|
|
39
39
|
const where = o.source === 'visibility:reused'
|
|
40
40
|
? `This is the same figure you already saw on the free check${o.measuredAt ? ` on ${String(o.measuredAt).slice(0, 10)}` : ''}. It is not measured again here, so the page, the email and this report always carry one number. `
|
|
41
41
|
: 'Measured here by the same code and the same settings as the free check at oper-stack.com/ai-visibility. ';
|
|
@@ -60,10 +60,10 @@ const LABELS = {
|
|
|
60
60
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
61
61
|
secContent: 'Content and on-page', contentEyebrow: '05 · Content', contentTitle: 'Content and on-page SEO', strengths: 'Strengths', weaknesses: 'Weaknesses', gaps: 'Structural gaps: pages that do not exist yet',
|
|
62
62
|
secAeo: 'AEO and GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Answer engines: AEO', aeoLead: 'Whether the site\'s answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.', aeoWorks: 'What already works', aeoBlocks: 'What blocks the wins', recommendation: 'Recommendation:', geoTitle: 'Generative engines: GEO', colSignal: 'Signal', colDetail: 'Detail',
|
|
63
|
-
secOffpage: 'Off-page, conversion, limitations', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '
|
|
64
|
-
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same numbers.',
|
|
63
|
+
secOffpage: 'Off-page, conversion, limitations', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '10 · Limitations', sourcesTitle: 'Where the data comes from', colSource: 'Source', colAccess: 'Access', colWhatRead: 'What was read',
|
|
64
|
+
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same or very close numbers.',
|
|
65
65
|
limitations: 'Limitations',
|
|
66
|
-
secRoadmap: 'Roadmap', roadmapEyebrow: '
|
|
66
|
+
secRoadmap: 'Roadmap', roadmapEyebrow: '09 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
67
67
|
runningHead: 'SEO, AEO and GEO audit',
|
|
68
68
|
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
69
69
|
areas: null,
|
|
@@ -83,7 +83,7 @@ const LABELS = {
|
|
|
83
83
|
overallNote: (o) => {
|
|
84
84
|
const p = o.basis ? o.basis.pages : 0;
|
|
85
85
|
const pl = ruPages(p);
|
|
86
|
-
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}
|
|
86
|
+
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}: это для балла; остальные разделы отчёта считаются по всей выборке. ` : '';
|
|
87
87
|
const where = o.source === 'visibility:reused'
|
|
88
88
|
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
89
89
|
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.ru/ai-visibility. ';
|
|
@@ -108,10 +108,10 @@ const LABELS = {
|
|
|
108
108
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
109
109
|
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры: страниц, которых пока нет',
|
|
110
110
|
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Может ли ответ с вашего сайта попасть в блок «Люди также спрашивают», в ответы ИИ, в ChatGPT и Perplexity.', aeoWorks: 'Что уже работает', aeoBlocks: 'Что мешает', recommendation: 'Рекомендация:', geoTitle: 'Генеративные системы: GEO', colSignal: 'Сигнал', colDetail: 'Подробности',
|
|
111
|
-
secOffpage: 'Ссылки, конверсия, ограничения', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '
|
|
112
|
-
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же цифры.',
|
|
111
|
+
secOffpage: 'Ссылки, конверсия, ограничения', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '10 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
112
|
+
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же или очень близкие цифры.',
|
|
113
113
|
limitations: 'Ограничения',
|
|
114
|
-
secRoadmap: 'План работ', roadmapEyebrow: '
|
|
114
|
+
secRoadmap: 'План работ', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
115
115
|
runningHead: 'Аудит SEO, AEO и GEO',
|
|
116
116
|
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
117
117
|
areas: AREAS_RU,
|
|
@@ -665,7 +665,8 @@ export function toHtml(audit, brand = null) {
|
|
|
665
665
|
* 54 пикселя, от верха листа до таблицы уходит 189, снизу отступ и подвал ещё 106.
|
|
666
666
|
* Остаётся около 790 пикселей, это четырнадцать строк вместе с шапкой таблицы.
|
|
667
667
|
*/
|
|
668
|
-
|
|
668
|
+
// Строки делятся поровну: 30 строк это три листа по 10, а не 14, 14 и лист с двумя строками (14.10.2026).
|
|
669
|
+
const APPENDIX_ROWS = Math.ceil(allSampleRows.length / Math.max(1, Math.ceil(allSampleRows.length / 14)));
|
|
669
670
|
for (let i = 0; i < allSampleRows.length; i += APPENDIX_ROWS) {
|
|
670
671
|
const chunk = allSampleRows.slice(i, i + APPENDIX_ROWS);
|
|
671
672
|
const part = Math.floor(i / APPENDIX_ROWS) + 1;
|
|
@@ -678,7 +679,66 @@ export function toHtml(audit, brand = null) {
|
|
|
678
679
|
if (a.demand && Array.isArray(a.demand.queries)) {
|
|
679
680
|
pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
|
|
680
681
|
}
|
|
681
|
-
return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
|
|
682
|
+
return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}${FIT_SCRIPT}</body></html>`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
/*
|
|
687
|
+
* Подгонка листов перед печатью (14.10.2026). Chrome печатает каждый .page на A4, и всё, что не
|
|
688
|
+
* влезло, уходит на следующий лист: клиент получал отчёт с листом, где один подвал. Скрипт в самой
|
|
689
|
+
* странице до печати (1) переносит хвост длинной таблицы на новый лист, (2) малый перелив ужимает
|
|
690
|
+
* масштабом, не мельче 0.9, (3) перенумеровывает подвалы. Работает и у печати на сервере, потому что
|
|
691
|
+
* живёт в HTML, а не в печатающем коде. Проверка числа листов после печати: pdfPageCount.
|
|
692
|
+
*/
|
|
693
|
+
export const FIT_SCRIPT = `<script>(document.fonts?document.fonts.ready:Promise.resolve()).then(function(){var H=297*96/25.4;function rowsOf(t){return Array.prototype.filter.call(t.querySelectorAll('tr'),function(r){return !r.querySelector('th')})}
|
|
694
|
+
for(var g=0;g<30;g++){var did=false,pages=document.querySelectorAll('.page');for(var i=0;i<pages.length;i++){var el=pages[i];if(el.scrollHeight<=H+1)continue;var tbs=el.querySelectorAll('table');if(!tbs.length)continue;var tb=tbs[tbs.length-1],rows=rowsOf(tb);if(el.scrollHeight/H<1.06||rows.length<4)continue;
|
|
695
|
+
var clone=el.cloneNode(true),ctbs=clone.querySelectorAll('table'),ctb=ctbs[ctbs.length-1],crows=rowsOf(ctb),k=rows.length;while(el.scrollHeight>H+1&&k>1){rows[--k].style.display='none'}if(k===rows.length)continue;
|
|
696
|
+
for(var j=k;j<rows.length;j++)rows[j].parentNode.removeChild(rows[j]);for(var j2=0;j2<k;j2++)crows[j2].parentNode.removeChild(crows[j2]);
|
|
697
|
+
var keep=[];for(var n=ctb;n&&n!==clone;n=n.parentNode)keep.push(n);Array.prototype.slice.call(clone.children).forEach(function(c){if(keep.indexOf(c)>=0)return;if(/page-footer|masthead|eyebrow/.test(c.className||''))return;if(/^H[1-3]$/.test(c.tagName))return;if(c.compareDocumentPosition(ctb)&4)c.parentNode.removeChild(c)});
|
|
698
|
+
var top=tb;while(top.parentNode&&top.parentNode!==el)top=top.parentNode;var nx=top.nextElementSibling;while(nx){var after=nx.nextElementSibling;if(!/page-footer/.test(nx.className||''))nx.parentNode.removeChild(nx);nx=after}el.parentNode.insertBefore(clone,el.nextSibling);did=true;break}if(!did)break}
|
|
699
|
+
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H+1){var z=Math.max(0.9,Math.floor((H-2)/h*1000)/1000);el.style.zoom=z;el.style.height=(H/z)+'px';el.style.minHeight=(H/z)+'px';el.style.overflow='hidden';if(z===0.9&&h*z>H+1)document.documentElement.setAttribute('data-overflow','1')}});
|
|
700
|
+
var no=0;Array.prototype.forEach.call(document.querySelectorAll('.page-footer span:last-child'),function(s){if(/^\\d+$/.test(s.textContent.trim()))s.textContent=String(++no)});
|
|
701
|
+
document.documentElement.setAttribute('data-pages',String(document.querySelectorAll('.page').length))});</script>`;
|
|
702
|
+
|
|
703
|
+
/** Сколько листов в PDF: по объектам /Type /Page, без сторонних библиотек. */
|
|
704
|
+
export function pdfPageCount(buf) {
|
|
705
|
+
const s = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
|
|
706
|
+
return (s.match(/\/Type\s*\/Page(?![s\w])/g) || []).length;
|
|
707
|
+
}
|
|
708
|
+
/** Сколько блоков .page в HTML до подгонки: подгонка может только добавить листы с хвостом таблицы. */
|
|
709
|
+
export function htmlPageCount(html) {
|
|
710
|
+
return (String(html).match(/<div class="page[ "]/g) || []).length;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
/*
|
|
715
|
+
* Печать в два шага (14.10.2026). Сначала Chrome открывает отчёт и отдаёт DOM после подгонки листов
|
|
716
|
+
* (FIT_SCRIPT), потом печатается уже подогнанный DOM. Так мы знаем, сколько листов должно выйти, и
|
|
717
|
+
* сверяем с тем, сколько вышло. Не сошлось или что-то не влезло даже после ужатия: PDF не отдаём,
|
|
718
|
+
* отчёт с пустым листом клиенту не уходит.
|
|
719
|
+
*/
|
|
720
|
+
export function printFitted(chrome, htmlFileIn, pdfPathIn) {
|
|
721
|
+
// Относительный путь давал Chrome адрес file://report.html и ошибку RR_INVALID_URL.
|
|
722
|
+
const htmlFile = resolvePath(htmlFileIn); const pdfPath = resolvePath(pdfPathIn);
|
|
723
|
+
const run = (args) => {
|
|
724
|
+
const profile = mkdtempSync(join(tmpdir(), 'operstack-audit-'));
|
|
725
|
+
const r = spawnSync(chrome, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profile}`, '--virtual-time-budget=8000', ...args], { encoding: 'utf8', timeout: 90000, maxBuffer: 64 * 1024 * 1024 });
|
|
726
|
+
try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* временная папка */ }
|
|
727
|
+
return r;
|
|
728
|
+
};
|
|
729
|
+
const dom = run(['--dump-dom', `file://${htmlFile}`]);
|
|
730
|
+
const fitted = String(dom.stdout || '');
|
|
731
|
+
const want = Number((fitted.match(/data-pages="(\d+)"/) || [])[1]);
|
|
732
|
+
if (!want) return { ok: false, error: `layout gate: Chrome did not lay the report out${dom.stderr ? `: ${dom.stderr.slice(-300)}` : ''}` };
|
|
733
|
+
if (/data-overflow="1"/.test(fitted)) return { ok: false, error: 'layout gate: a page does not fit A4 even at 90% scale; shorten it before sending' };
|
|
734
|
+
const fittedFile = htmlFile.replace(/\.html?$/, '') + '.fitted.html';
|
|
735
|
+
writeFileSync(fittedFile, `<!doctype html>${fitted.replace(/<script>\(document\.fonts[\s\S]*?<\/script>/, '')}`);
|
|
736
|
+
const r = run(['--no-pdf-header-footer', `--print-to-pdf=${pdfPath}`, `file://${fittedFile}`]);
|
|
737
|
+
rmSync(fittedFile, { force: true });
|
|
738
|
+
if (!existsSync(pdfPath)) return { ok: false, error: `Chrome did not write the PDF${r.stderr ? `: ${r.stderr.slice(-300)}` : ''}` };
|
|
739
|
+
const got = pdfPageCount(readFileSync(pdfPath));
|
|
740
|
+
if (got !== want) { rmSync(pdfPath, { force: true }); return { ok: false, error: `layout gate: ${got} sheets printed for ${want} report pages, so some sheet is a spill-over; the PDF was not kept` }; }
|
|
741
|
+
return { ok: true, pages: got };
|
|
682
742
|
}
|
|
683
743
|
|
|
684
744
|
function findChrome() {
|
|
@@ -695,10 +755,8 @@ export async function render(audit, { out, pdf = false, brand = null } = {}) {
|
|
|
695
755
|
if (!chrome) { console.error('no Chrome found; set CHROME_PATH or open the HTML and print to PDF'); }
|
|
696
756
|
else {
|
|
697
757
|
pdfPath = out.replace(/\.html?$/, '') + '.pdf';
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
rmSync(profile, { recursive: true, force: true });
|
|
701
|
-
if (!existsSync(pdfPath)) { console.error(`Chrome did not write the PDF${r.stderr ? `: ${r.stderr.slice(-300)}` : ''}`); pdfPath = null; }
|
|
758
|
+
const r = printFitted(chrome, out, pdfPath);
|
|
759
|
+
if (!r.ok) { console.error(r.error); pdfPath = null; }
|
|
702
760
|
}
|
|
703
761
|
}
|
|
704
762
|
return { html, pdf: pdfPath };
|
package/src/rendered.mjs
CHANGED
|
@@ -166,7 +166,10 @@ export function jsBlindnessCheck(pages, lang = 'en') {
|
|
|
166
166
|
id: 'js-content', group: 'technical',
|
|
167
167
|
label: lang === 'ru' ? 'Виден ли текст без выполнения скриптов' : 'Text visible without running JavaScript',
|
|
168
168
|
status: 'na',
|
|
169
|
-
|
|
169
|
+
// Браузер есть, но страница не догрузилась (14.10.2026: на medeq-mc.ru висел скрипт Яндекс.Карт): клиенту про «машину» не пишем.
|
|
170
|
+
value: findChrome()
|
|
171
|
+
? (lang === 'ru' ? 'не измеряли: страница не догрузилась в браузере за отведённое время (часто это зависший внешний скрипт, например карты)' : 'not measured: the page did not finish loading in a browser in time (often a stuck third-party script such as a map)')
|
|
172
|
+
: (lang === 'ru' ? 'не измеряли в этой проверке' : 'not measured in this check'),
|
|
170
173
|
comment: lang === 'ru' ? 'проверка сравнивает страницу, прочитанную без скриптов, с ней же в настоящем браузере' : 'the check compares the page read without scripts against the same page in a real browser',
|
|
171
174
|
};
|
|
172
175
|
}
|
package/src/review.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка отчёта ИИ до выдачи и автоисправление (14.10.2026).
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Отчёт разбирается на отдельные утверждения: каждая строка проверки, каждая фраза
|
|
5
|
+
* вывода, каждая задача. Утверждения делятся на части (техника, тексты, задачи, выводы и обложка),
|
|
6
|
+
* и каждую часть одновременно проверяет отдельный вызов модели, не тот, что писал текст. Он видит
|
|
7
|
+
* само утверждение, замер, на котором оно стоит, и сохранённые копии страниц сайта, и отвечает по
|
|
8
|
+
* каждому: подтверждено, ложно, вводит в заблуждение или проверить нельзя, с правкой. Ложное и
|
|
9
|
+
* вводящее в заблуждение переписывается по правке, затем перепроверяются только изменённые фразы.
|
|
10
|
+
* До трёх кругов. Не сошлось: отчёт не уходит.
|
|
11
|
+
*
|
|
12
|
+
* Модель сюда не зашита: вызывающий передаёт ask(messages) → { text, usage }. Так один и тот же
|
|
13
|
+
* код проверяет и на сервере (OpenAI), и в тестах (заглушка).
|
|
14
|
+
*/
|
|
15
|
+
import { localiseChecks } from './i18n.mjs';
|
|
16
|
+
import { agentPrompts } from './prompts.mjs';
|
|
17
|
+
import { rescore } from './verify.mjs';
|
|
18
|
+
import { vocabFor } from './niche.mjs';
|
|
19
|
+
|
|
20
|
+
const PART_OF = (id) => {
|
|
21
|
+
if (/^task:/.test(id)) return 'tasks';
|
|
22
|
+
if (/^(sum|sub|ov|road):/.test(id)) return 'summary';
|
|
23
|
+
if (/^chk:(https|favicon|redirect-chain|www|ttfb|robots|sitemap|canonical|viewport|og-|twitter|resources|hreflang|utility|sitemap-health|js-content|title|description|h1|title-all|description-all|alt|h1-sample|dup-titles|xmlrpc)/.test(id)) return 'tech';
|
|
24
|
+
return 'content';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Все утверждения отчёта и задач, по одному на строку, с тем, на чём они стоят. */
|
|
28
|
+
export function statementsOf(audit, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
29
|
+
const ru = lang === 'ru';
|
|
30
|
+
const out = [];
|
|
31
|
+
const kindOf = (id) => (/^chk:.*:value$/.test(id) ? 'measure' : /^chk:.*:comment$/.test(id) ? 'explain' : /^task:.*:task$/.test(id) ? 'task' : /^task:.*:verify$/.test(id) ? 'verify' : /^road:/.test(id) ? 'plan' : 'claim');
|
|
32
|
+
const add = (id, text, basis = '') => { if (text && String(text).trim() && !String(text).includes('{{')) out.push({ id, kind: kindOf(id), part: PART_OF(id), text: String(text), basis }); };
|
|
33
|
+
const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
34
|
+
for (const c of shown) {
|
|
35
|
+
if (c.status === 'na' && !c.value) continue;
|
|
36
|
+
add(`chk:${c.id}:value`, `${c.label}: ${c.value}`, `статус ${c.status}`);
|
|
37
|
+
if (c.comment) add(`chk:${c.id}:comment`, `${c.label}, пояснение: ${c.comment}`);
|
|
38
|
+
}
|
|
39
|
+
add('sub:subject', audit.client?.subject);
|
|
40
|
+
add('sum:lead', audit.summary?.lead);
|
|
41
|
+
add('sum:verdict', audit.summary?.verdict);
|
|
42
|
+
(audit.summary?.priorities || []).forEach((x, i) => add(`sum:prio:${i}`, x));
|
|
43
|
+
(audit.overview?.rows || []).forEach((r, i) => add(`ov:row:${i}`, `${r[0]}: ${r[1]}`));
|
|
44
|
+
if (audit.overview?.note) add('ov:note', audit.overview.note);
|
|
45
|
+
for (const [k, arr] of [['str', audit.content?.strengths], ['weak', audit.content?.weaknesses], ['gap', audit.content?.gaps]]) (arr || []).forEach((x, i) => add(`cont:${k}:${i}`, x));
|
|
46
|
+
(audit.aeo?.works || []).forEach((x, i) => add(`aeo:works:${i}`, x));
|
|
47
|
+
(audit.aeo?.blocks || []).forEach((x, i) => add(`aeo:blocks:${i}`, x));
|
|
48
|
+
add('aeo:rec', audit.aeo?.recommendation);
|
|
49
|
+
if (audit.geo?.note) add('geo:note', audit.geo.note);
|
|
50
|
+
(audit.offpage?.listed || []).forEach((x, i) => add(`off:listed:${i}`, x));
|
|
51
|
+
(audit.conversion?.rows || []).forEach((r, i) => add(`conv:row:${i}`, `${r[0]}: ${r[1]}`));
|
|
52
|
+
(audit.roadmap || []).forEach((st, j) => (st.items || []).forEach((x, i) => add(`road:${j}:${i}`, x)));
|
|
53
|
+
for (const t of agentPrompts(audit, { lang })) {
|
|
54
|
+
const part = (name) => (t.text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*([^\\n]+)`)) || [])[1];
|
|
55
|
+
add(`task:${t.id}:task`, part(ru ? 'Задача' : 'Task'), `задача к строке ${t.id}`);
|
|
56
|
+
add(`task:${t.id}:verify`, part(ru ? 'Как проверить' : 'How to check'), `как проверить задачу ${t.id}`);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Применить правку проверяющего к одному утверждению. Возвращает true, если было что менять. */
|
|
62
|
+
export function applyFix(audit, id, text) {
|
|
63
|
+
const m = id.split(':');
|
|
64
|
+
const set = (obj, key, v) => { if (obj && key in obj) { obj[key] = v; return true; } return false; };
|
|
65
|
+
const strip = (x, label) => String(x).replace(new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:, пояснение)?:\\s*`), '');
|
|
66
|
+
if (m[0] === 'chk') {
|
|
67
|
+
const c = (audit.checks || []).find((x) => x.id === m[1]); if (!c) return false;
|
|
68
|
+
const label = c.label;
|
|
69
|
+
if (m[2] === 'value') c.value = strip(text, label);
|
|
70
|
+
else c.comment = strip(text, label);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (m[0] === 'task') { audit.taskOverrides = audit.taskOverrides || {}; (audit.taskOverrides[m[1]] = audit.taskOverrides[m[1]] || {})[m[2]] = text; return true; }
|
|
74
|
+
if (id === 'sub:subject') return set(audit.client, 'subject', text);
|
|
75
|
+
if (id === 'sum:lead') return set(audit.summary, 'lead', text);
|
|
76
|
+
if (id === 'sum:verdict') return set(audit.summary, 'verdict', text);
|
|
77
|
+
if (m[0] === 'sum' && m[1] === 'prio') { audit.summary.priorities[Number(m[2])] = text; return true; }
|
|
78
|
+
if (m[0] === 'ov' && m[1] === 'row') { const r = audit.overview.rows[Number(m[2])]; r[1] = String(text).replace(new RegExp(`^${r[0]}:\\s*`), ''); return true; }
|
|
79
|
+
if (id === 'ov:note') return set(audit.overview, 'note', text);
|
|
80
|
+
if (m[0] === 'cont') { const k = { str: 'strengths', weak: 'weaknesses', gap: 'gaps' }[m[1]]; audit.content[k][Number(m[2])] = text; return true; }
|
|
81
|
+
if (m[0] === 'aeo' && m[1] !== 'rec') { audit.aeo[m[1]][Number(m[2])] = text; return true; }
|
|
82
|
+
if (id === 'aeo:rec') return set(audit.aeo, 'recommendation', text);
|
|
83
|
+
if (id === 'geo:note') return set(audit.geo, 'note', text);
|
|
84
|
+
if (m[0] === 'off') { audit.offpage.listed[Number(m[2])] = text; return true; }
|
|
85
|
+
if (m[0] === 'conv') { const r = audit.conversion.rows[Number(m[2])]; r[1] = String(text).replace(new RegExp(`^${r[0]}:\\s*`), ''); return true; }
|
|
86
|
+
if (m[0] === 'road') { audit.roadmap[Number(m[1])].items[Number(m[2])] = text; return true; }
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Утверждение «нельзя проверить» удаляется, если это не строка замера: в отчёт идёт только то, что доказано. */
|
|
91
|
+
export function dropStatement(audit, id) {
|
|
92
|
+
const m = id.split(':');
|
|
93
|
+
if (m[0] === 'cont') { const k = { str: 'strengths', weak: 'weaknesses', gap: 'gaps' }[m[1]]; audit.content[k].splice(Number(m[2]), 1); return true; }
|
|
94
|
+
if (m[0] === 'aeo' && m[1] !== 'rec') { audit.aeo[m[1]].splice(Number(m[2]), 1); return true; }
|
|
95
|
+
if (m[0] === 'off') { audit.offpage.listed.splice(Number(m[2]), 1); return true; }
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SYSTEM = (ru, niche) => (ru
|
|
100
|
+
? `Ты проверяющий отчёта о сайте перед отправкой платному клиенту. Одна неправда бьёт по репутации, но и ложная тревога дорого стоит: каждую твою правку текст отчёта примет. Ниша сайта: ${niche}.
|
|
101
|
+
Тебе дают: утверждения (id, kind, текст, на чём стоит), toolFacts (замеры инструмента: размер выборки, число адресов в карте, результаты запросов к файлам и роботам) и выжимку сайта (robots.txt и по каждой странице заголовок, описание, H1, H2, типы разметки, sameAs, картинки и пустые alt, формы, соцсети, мессенджеры, слова, начало текста).
|
|
102
|
+
Правила по виду (kind):
|
|
103
|
+
- measure (строка замера «Метка: значение»): сверь каждое число и каждое «есть/нет» с таблицей страниц в выжимке (она покрывает весь сайт, pagesTotal страниц) и с toolFacts. Особенно проверяй «нет», «ни одного», «0 из», «на всех»: найди в таблице хоть одну страницу, которая это опровергает (например, ссылки на соцсети в social, текст политики в privacyText, правила в termsText, раскрывающиеся блоки в accordions, видимые даты в visibleDates, цены в prices, число H1). Нашёл: false. Не нашёл противоречия: ok. unverifiable для measure не ставь.
|
|
104
|
+
- explain (пояснение к строке): ok, если по делу; misleading, если преувеличивает, вводит в заблуждение или не подходит нише.
|
|
105
|
+
- claim (вывод, обложка, обзор, сильные и слабые стороны, рекомендация): сверь с выжимкой и toolFacts; false или misleading с правкой; unverifiable, если ничем не подтверждается.
|
|
106
|
+
- task (задача исполнителю): вместо verdict заполни поля premiseTrue (true, если проблема, которую решает задача, на сайте действительно есть, и задача не просит чинить то, чего нет), fitsNiche (true, если совет подходит нише и не велит выдумывать факты). Если хоть одно false, дай fix.
|
|
107
|
+
- verify («Как проверить» у задачи): вместо verdict заполни поля passesNow (true, если эта проверка УЖЕ проходит на сайте прямо сейчас, до всякой работы) и possible (true, если её можно выполнить). Это не вопрос «верно ли», задача ещё не сделана, и это нормально. Если passesNow true или possible false, дай fix.
|
|
108
|
+
- plan (пункт плана): ok, если пункт соответствует строке отчёта.
|
|
109
|
+
Для false и misleading дай fix: полный новый текст по-русски, без длинного и среднего тире, только с фактами из выжимки и toolFacts, в той же форме («Метка: значение» для measure).
|
|
110
|
+
Ответ строго JSON: {"items":[{"id":"...","verdict":"ok|false|misleading|unverifiable","premiseTrue":true,"fitsNiche":true,"passesNow":false,"possible":true,"why":"коротко","fix":"..."}]} (лишние для вида поля не заполняй)`
|
|
111
|
+
: `You review a website report before a paying client receives it. One false statement damages the business, but a false alarm costs too: the report takes every fix you give. Site niche: ${niche}.
|
|
112
|
+
You get: statements (id, kind, text, basis), toolFacts (tool measurements: sample size, sitemap URL count, file and robot probe results) and a site digest (robots.txt and, per page, title, description, H1, H2, schema types, sameAs, images and empty alts, forms, social, messengers, words, opening text).
|
|
113
|
+
Rules by kind:
|
|
114
|
+
- measure ("Label: value" check row): check every number and every present/absent claim against the per-page table in the digest (it covers the whole site, pagesTotal pages) and toolFacts. Look hardest at "none", "no", "0 of", "on every": find any page in the table that contradicts it (social links in social, policy text in privacyText, terms in termsText, accordions, visibleDates, prices, H1 counts). Found one: false. Found no contradiction: ok. Never unverifiable.
|
|
115
|
+
- explain (a row's note): ok if relevant; misleading if it overstates, misleads or does not fit the niche.
|
|
116
|
+
- claim (summary, cover, overview, strengths, weaknesses, recommendation): check against digest and toolFacts; false or misleading with a fix; unverifiable if nothing supports it.
|
|
117
|
+
- task (a task for the developer): instead of verdict fill premiseTrue (true if the problem it solves really exists on the site and it does not ask to fix what is not there) and fitsNiche (true if it fits the niche and asks to invent nothing). If either is false, give fix.
|
|
118
|
+
- verify (a task's "How to check"): instead of verdict fill passesNow (true if this check ALREADY passes on the site right now, before any work) and possible (true if it can be met). The task is not done yet and that is fine. If passesNow is true or possible is false, give fix.
|
|
119
|
+
- plan (a roadmap item): ok if it matches a report row.
|
|
120
|
+
For false and misleading give fix: full replacement text in English, no em or en dashes, facts from digest and toolFacts only, same shape ("Label: value" for measure).
|
|
121
|
+
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
|
+
|
|
123
|
+
/** Замеры инструмента, на которые опираются строки: проверяющий иначе считает их «непроверяемыми». */
|
|
124
|
+
export function toolFacts(audit) {
|
|
125
|
+
// Только то, что инструмент измерил запросом к сайту (файлы, роботы, скорость), а не его же выводы:
|
|
126
|
+
// выводы из строк проверки здесь делали проверку круговой (14.10.2026: 0 из 13 известных ошибок).
|
|
127
|
+
const by = (id) => (audit.checks || []).find((c) => c.id === id);
|
|
128
|
+
const probe = (id) => (by(id) ? by(id).value : undefined);
|
|
129
|
+
const sm = (audit.sample || []).filter((p) => p && p.title !== undefined);
|
|
130
|
+
return {
|
|
131
|
+
sampled: sm.length,
|
|
132
|
+
sampleUrls: sm.map((p) => p.url),
|
|
133
|
+
sitemapUrlCount: (String(probe('sitemap') || '').match(/(\d+)/) || [])[1],
|
|
134
|
+
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
|
+
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 })),
|
|
137
|
+
classification: 'Деление открытых проверок на «правятся технически» и «требуют фактов владельца» и оценки областей считает инструмент по своему списку; это не утверждения о сайте.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function digestFor(digest, statements, full = 4000) {
|
|
142
|
+
const text = statements.map((s) => s.text).join(' ');
|
|
143
|
+
const mentioned = new Set();
|
|
144
|
+
for (const p of digest.pages || []) { let path = ''; try { path = new URL(p.url).pathname; } catch { /* */ } if (path && path !== '/' && text.includes(path)) mentioned.add(p.url); }
|
|
145
|
+
if (digest.pages && digest.pages[0]) mentioned.add(digest.pages[0].url);
|
|
146
|
+
// Таблица фактов по каждой странице сайта без текста; текст только у страниц, о которых говорят утверждения.
|
|
147
|
+
// Компактная строка на страницу: пустые поля и повторяющиеся по всему сайту значения (одни и те же соцсети,
|
|
148
|
+
// одинаковые типы разметки) не повторяем в каждой строке, иначе на сайте в 150 страниц цена проверки удваивается.
|
|
149
|
+
const pages = digest.pages || [];
|
|
150
|
+
const common = (key) => { const vals = pages.map((p) => JSON.stringify(p[key] || [])); const top = vals.sort((a, b) => vals.filter((x) => x === b).length - vals.filter((x) => x === a).length)[0]; return top && vals.filter((x) => x === top).length > pages.length / 2 ? top : null; };
|
|
151
|
+
const siteWide = { schema: common('schema'), social: common('social'), sameAs: common('sameAs') };
|
|
152
|
+
const slim = (p) => {
|
|
153
|
+
const o = {};
|
|
154
|
+
for (const [k, v] of Object.entries(p)) {
|
|
155
|
+
if (k === 'text') continue;
|
|
156
|
+
if (Array.isArray(v) && !v.length) continue;
|
|
157
|
+
if (v === '' || v === 0 || v === false) continue;
|
|
158
|
+
if (siteWide[k] && JSON.stringify(v) === siteWide[k]) continue;
|
|
159
|
+
o[k] = Array.isArray(v) ? v.slice(0, k === 'h2' ? 8 : 5) : v;
|
|
160
|
+
}
|
|
161
|
+
if (mentioned.has(p.url)) o.text = String(p.text || '').slice(0, full);
|
|
162
|
+
return o;
|
|
163
|
+
};
|
|
164
|
+
return { robots: String(digest.robots || '').slice(0, 1500), pagesTotal: pages.length, sameOnMostPages: Object.fromEntries(Object.entries(siteWide).filter(([, v]) => v).map(([k, v]) => [k, JSON.parse(v)])), pages: pages.map(slim) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Один круг: все части параллельно. Возвращает вердикты по id и учёт токенов.
|
|
169
|
+
* @param {(messages: object[]) => Promise<{text: string, usage?: object}>} ask
|
|
170
|
+
*/
|
|
171
|
+
export async function reviewRound(audit, digest, statements, ask, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
172
|
+
const ru = lang === 'ru';
|
|
173
|
+
const niche = vocabFor(audit, lang).niche;
|
|
174
|
+
const parts = [...new Set(statements.map((s) => s.part))];
|
|
175
|
+
const usage = { input: 0, output: 0, calls: 0 };
|
|
176
|
+
// Пачки по 20 утверждений одной части: на 170 строках сразу модель пропускала очевидное (14.10.2026).
|
|
177
|
+
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
|
+
// Одна и та же выжимка в начале каждого вызова: OpenAI берёт повторяющееся начало из кэша за десятую часть цены.
|
|
179
|
+
const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit), digest: digestFor(digest, statements) });
|
|
180
|
+
const runBatch = async ({ part, st }) => {
|
|
181
|
+
const messages = [
|
|
182
|
+
{ role: 'system', content: SYSTEM(ru, niche) },
|
|
183
|
+
{ role: 'user', content: shared },
|
|
184
|
+
{ role: 'user', content: JSON.stringify({ statements: st.map(({ id, kind, text, basis }) => ({ id, kind, text, basis })) }) },
|
|
185
|
+
];
|
|
186
|
+
let r; let lastErr;
|
|
187
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
188
|
+
try { r = await ask(messages); break; } catch (e) { lastErr = e; }
|
|
189
|
+
}
|
|
190
|
+
if (!r) throw new Error(`проверка части «${part}» не ответила: ${lastErr?.message || 'нет ответа'}`);
|
|
191
|
+
usage.calls += 1; usage.input += r.usage?.input || 0; usage.cached = (usage.cached || 0) + (r.usage?.cached || 0); usage.output += r.usage?.output || 0;
|
|
192
|
+
let parsed; try { parsed = JSON.parse(String(r.text).replace(/^```json\s*|```$/g, '')); } catch { throw new Error(`проверка части «${part}» вернула не JSON`); }
|
|
193
|
+
return (parsed.items || []).filter((x) => st.some((s) => s.id === x.id));
|
|
194
|
+
};
|
|
195
|
+
// Первый вызов один: он кладёт общую выжимку в кэш OpenAI, остальные идут параллельно уже по кэшу.
|
|
196
|
+
const first = batches.length ? [await runBatch(batches[0])] : [];
|
|
197
|
+
const rest = await Promise.all(batches.slice(1).map(runBatch));
|
|
198
|
+
const items = [...first, ...rest].flat();
|
|
199
|
+
// Утверждение, по которому проверяющий промолчал, считаем непроверенным, а не подтверждённым.
|
|
200
|
+
const seen = new Set(items.map((x) => x.id));
|
|
201
|
+
for (const s of statements) if (!seen.has(s.id)) items.push({ id: s.id, verdict: 'unverifiable', why: 'проверяющий не ответил по этой строке' });
|
|
202
|
+
const kind = Object.fromEntries(statements.map((s) => [s.id, s.kind]));
|
|
203
|
+
// Вердикт по задачам и проверкам выставляет код по ответам «да/нет»: на вопрос «верно ли» модели
|
|
204
|
+
// отвечали «ложно, ещё не сделано» (14.10.2026), а на прямые вопросы отвечают точно.
|
|
205
|
+
for (const x of items) {
|
|
206
|
+
if (kind[x.id] === 'verify') x.verdict = x.passesNow === true || x.possible === false ? 'misleading' : 'ok';
|
|
207
|
+
if (kind[x.id] === 'task') x.verdict = x.premiseTrue === false || x.fitsNiche === false ? 'misleading' : 'ok';
|
|
208
|
+
}
|
|
209
|
+
// Для замеров, задач и плана «не проверить» значит «не опровергнуто»: это не вывод, а замер или указание.
|
|
210
|
+
for (const x of items) if (x.verdict === 'unverifiable' && kind[x.id] !== 'claim' && kind[x.id] !== 'explain') x.verdict = 'ok';
|
|
211
|
+
return { items, usage };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
const CONFIRM = (ru) => (ru
|
|
216
|
+
? `Ты второй проверяющий. Первый пометил утверждения отчёта о сайте как ложные или вводящие в заблуждение. Он часто ошибается: помечает верное. Твоя задача отсеять ложные тревоги.
|
|
217
|
+
По каждому помеченному утверждению найди в выжимке сайта или toolFacts КОНКРЕТНОЕ доказательство: адрес страницы и поле (или файл) со значением, которое прямо опровергает утверждение, либо (для задачи) показывает, что проблемы нет, совет чужой ниши или проверка уже проходит. Нашёл: keep true, evidence с адресом и значением, fix с полным новым текстом по-русски без длинного и среднего тире. Не нашёл прямого доказательства: keep false. Мнения, стиль и «можно сказать лучше» это не доказательство.
|
|
218
|
+
Ответ строго JSON: {"items":[{"id":"...","keep":true,"evidence":"адрес: поле = значение","fix":"..."}]}`
|
|
219
|
+
: `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.
|
|
221
|
+
Answer strictly as JSON: {"items":[{"id":"...","keep":true,"evidence":"url: field = value","fix":"..."}]}`);
|
|
222
|
+
|
|
223
|
+
/** Второй шаг: подтвердить пометки первого более сильной моделью, только с прямым доказательством. */
|
|
224
|
+
export async function confirmFlags(audit, digest, statements, flagged, ask, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
225
|
+
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) });
|
|
228
|
+
const byId = Object.fromEntries(statements.map((s) => [s.id, s]));
|
|
229
|
+
const usage = { input: 0, output: 0, cached: 0, calls: 0 };
|
|
230
|
+
const groups = []; for (let i = 0; i < flagged.length; i += 40) groups.push(flagged.slice(i, i + 40));
|
|
231
|
+
const res = await Promise.all(groups.map(async (g) => {
|
|
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 } })) }) }]);
|
|
233
|
+
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 { throw new Error('второй проверяющий вернул не JSON'); }
|
|
235
|
+
return parsed.items || [];
|
|
236
|
+
}));
|
|
237
|
+
return { items: res.flat(), usage };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Полная проверка с автоисправлением, до maxRounds кругов.
|
|
242
|
+
* @returns {{ ok: boolean, rounds: object[], usage: object, left: object[] }}
|
|
243
|
+
*/
|
|
244
|
+
export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang || 'en', maxRounds = 3, log = () => {}, askConfirm = ask } = {}) {
|
|
245
|
+
const rounds = []; const usage = { input: 0, cached: 0, output: 0, calls: 0, confirm: { input: 0, cached: 0, output: 0, calls: 0 } };
|
|
246
|
+
const add = (u, t) => { for (const k of ['input', 'cached', 'output', 'calls']) t[k] += u[k] || 0; };
|
|
247
|
+
let todo = statementsOf(audit, { lang });
|
|
248
|
+
let left = [];
|
|
249
|
+
for (let round = 1; round <= maxRounds && todo.length; round++) {
|
|
250
|
+
const all = statementsOf(audit, { lang });
|
|
251
|
+
const { items, usage: u } = await reviewRound(audit, digest, todo, ask, { lang });
|
|
252
|
+
add(u, usage);
|
|
253
|
+
const flagged = items.filter((x) => x.verdict === 'false' || x.verdict === 'misleading');
|
|
254
|
+
// Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
|
|
255
|
+
const { items: conf, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
|
|
256
|
+
add(cu, usage.confirm);
|
|
257
|
+
const kept = conf.filter((x) => x.keep);
|
|
258
|
+
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: all.find((s) => s.id === x.id)?.text })) });
|
|
260
|
+
log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, не проверить ${lost.length}`);
|
|
261
|
+
const changed = [];
|
|
262
|
+
for (const x of kept) if (x.fix && applyFix(audit, x.id, x.fix)) changed.push(x.id);
|
|
263
|
+
for (const x of lost.slice().sort((a, b) => b.id.localeCompare(a.id))) if (dropStatement(audit, x.id)) changed.push(x.id);
|
|
264
|
+
rescore(audit);
|
|
265
|
+
if (!kept.length) { left = []; break; }
|
|
266
|
+
left = kept.filter((x) => !x.fix);
|
|
267
|
+
todo = statementsOf(audit, { lang }).filter((st) => changed.includes(st.id));
|
|
268
|
+
if (round === maxRounds) left = kept;
|
|
269
|
+
}
|
|
270
|
+
return { ok: left.length === 0, rounds, usage, left };
|
|
271
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Задачи для исполнителя отдельным PDF вместо файла .md (14.10.2026).
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Владелец сайта не знает, чем открыть .md, и не перешлёт его исполнителю. PDF в
|
|
5
|
+
* оформлении отчёта открывается везде: у каждой задачи «Сейчас», «Что сделать» и «Как проверить»,
|
|
6
|
+
* и файл целиком пересылается тому, кто ведёт сайт.
|
|
7
|
+
*/
|
|
8
|
+
import { writeFileSync, existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
12
|
+
import { agentPrompts } from './prompts.mjs';
|
|
13
|
+
import { pdfPageCount } from './render.mjs';
|
|
14
|
+
|
|
15
|
+
const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
16
|
+
const md = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
17
|
+
|
|
18
|
+
export function renderTasksHtml(audit, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
19
|
+
const ru = lang === 'ru';
|
|
20
|
+
const host = audit.meta?.host || '';
|
|
21
|
+
const L = ru
|
|
22
|
+
? { eb: 'OperStack · задачи для исполнителя', h1: `${host}: что поправить на сайте`, now: 'Сейчас', task: 'Что сделать', verify: 'Как проверить',
|
|
23
|
+
intro: ['Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами, и её можно отдать тому, кто ведёт ваш сайт, или помощнику вроде ChatGPT.', 'Отдавайте по одной задаче, вместе со строками «Сейчас» и «Как проверить»: так исполнитель поймёт, откуда начинать и когда работа сделана.', 'Этот файл можно целиком переслать тому, кто ведёт ваш сайт.'],
|
|
24
|
+
rule: 'Правило для исполнителя: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца.', foot: 'задачи' }
|
|
25
|
+
: { eb: 'OperStack · tasks for your developer', h1: `${host}: what to fix on the site`, now: 'Now', task: 'What to do', verify: 'How to check',
|
|
26
|
+
intro: ['Below is one task for every problem found, each in plain words, ready to hand to whoever runs your site or to an assistant like ChatGPT.', 'Hand over one task at a time, with its "Now" and "How to check" lines: that is how the developer knows where to start and when it is done.', 'You can forward this whole file to whoever runs your site.'],
|
|
27
|
+
rule: 'Rule for the developer: invent no facts about the business. Anything not on the site and not told to you, ask the owner.', foot: 'tasks' };
|
|
28
|
+
const items = agentPrompts(audit, { lang }).map((t, n) => {
|
|
29
|
+
const get = (k) => (t.text.match(new RegExp(`\\*\\*${k}:\\*\\*\\s*([^\\n]+)`)) || [])[1] || '';
|
|
30
|
+
const title = (t.text.match(/^### (.+)$/m) || [])[1] || t.id;
|
|
31
|
+
return `<section class="t"><h3><span>${n + 1}</span>${esc(title)}</h3><p class="now"><em>${L.now}</em>${md(get(ru ? 'Сейчас' : 'Now'))}</p><p><em>${L.task}</em>${md(get(ru ? 'Задача' : 'Task'))}</p><p class="chk"><em>${L.verify}</em>${md(get(ru ? 'Как проверить' : 'How to check'))}</p></section>`;
|
|
32
|
+
});
|
|
33
|
+
return `<!doctype html><html lang="${ru ? 'ru' : 'en'}"><head><meta charset="utf-8"><title>${esc(L.h1)}</title>
|
|
34
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
|
35
|
+
<style>@page{size:A4;margin:16mm 16mm 18mm}body{font:10pt/1.5 "IBM Plex Sans",sans-serif;color:#16202B;margin:0}
|
|
36
|
+
.eb{font-size:7.5pt;letter-spacing:.14em;text-transform:uppercase;color:#177C70;font-weight:600;margin:0 0 6px}
|
|
37
|
+
h1{font-family:Fraunces,serif;font-size:22pt;line-height:1.15;margin:0 0 10px}
|
|
38
|
+
.intro{background:#E7F3EF;border-radius:10px;padding:10px 14px;margin:10px 0 16px}.intro p{margin:0 0 6px}
|
|
39
|
+
.t{border-top:1px solid #DCE3E6;padding:10px 0 8px;break-inside:avoid}
|
|
40
|
+
h3{font-family:Fraunces,serif;font-size:12.5pt;margin:0 0 6px;display:flex;gap:8px;align-items:baseline}h3 span{font-family:"IBM Plex Sans";font-size:9pt;color:#fff;background:#177C70;border-radius:10px;padding:1px 7px}
|
|
41
|
+
p{margin:0 0 5px}em{font-style:normal;font-weight:600;font-size:8pt;text-transform:uppercase;letter-spacing:.06em;color:#5A6470;display:block}
|
|
42
|
+
.now{color:#3F4854}.chk{color:#0F5C53}.rule{margin-top:14px;font-size:9pt;color:#5A6470}</style></head><body>
|
|
43
|
+
<p class="eb">${esc(L.eb)}</p><h1>${esc(L.h1)}</h1><div class="intro">${L.intro.map((x) => `<p>${esc(x)}</p>`).join('')}</div>
|
|
44
|
+
${items.join('\n')}
|
|
45
|
+
<p class="rule">${esc(L.rule)}</p></body></html>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findChrome() {
|
|
49
|
+
return [process.env.CHROME_PATH, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].filter(Boolean).find((p) => existsSync(p));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Печать задач в PDF. Возвращает { pdf, pages } или бросает ошибку: без PDF задачи клиенту не уходят. */
|
|
53
|
+
export function renderTasksPdf(audit, { out, lang } = {}) {
|
|
54
|
+
const html = renderTasksHtml(audit, { lang });
|
|
55
|
+
const htmlPath = resolvePath(out.replace(/\.pdf$/, '') + '.html');
|
|
56
|
+
const pdfPath = resolvePath(out);
|
|
57
|
+
writeFileSync(htmlPath, html);
|
|
58
|
+
const chrome = findChrome();
|
|
59
|
+
if (!chrome) throw new Error('нет Chrome: задачи в PDF не напечатаны');
|
|
60
|
+
const profile = mkdtempSync(join(tmpdir(), 'operstack-tasks-'));
|
|
61
|
+
const r = spawnSync(chrome, ['--headless=new', '--disable-gpu', '--no-first-run', `--user-data-dir=${profile}`, '--virtual-time-budget=8000', '--no-pdf-header-footer', `--print-to-pdf=${pdfPath}`, `file://${htmlPath}`], { encoding: 'utf8', timeout: 90000 });
|
|
62
|
+
try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* временная папка */ }
|
|
63
|
+
if (!existsSync(pdfPath)) throw new Error(`Chrome не напечатал задачи${r.stderr ? `: ${r.stderr.slice(-200)}` : ''}`);
|
|
64
|
+
const pages = pdfPageCount(readFileSync(pdfPath));
|
|
65
|
+
if (!pages) throw new Error('в PDF задач нет ни одного листа');
|
|
66
|
+
return { pdf: pdfPath, html: htmlPath, pages };
|
|
67
|
+
}
|
|
@@ -23,7 +23,7 @@ ok('и объяснено почему', /есть API/.test(FIX_ACTIONS['api-ca
|
|
|
23
23
|
// Задания для агента полные и не врут про обязательность
|
|
24
24
|
const card = agentPrompt({ id: 'agent-card', label: 'Карточка агента', status: 'warn', value: 'нет' }, { lang: 'ru' });
|
|
25
25
|
ok('в задании про карточку есть проверка', /Как проверить/.test(card));
|
|
26
|
-
ok('и
|
|
26
|
+
ok('и не велит делать карточку без агента', /только правду|выдуманная возможность|ради галочки нельзя/.test(card));
|
|
27
27
|
const md = agentPrompt({ id: 'agent-markdown', label: 'Markdown-версия', status: 'warn', value: 'нет' }, { lang: 'ru' });
|
|
28
28
|
ok('в задании про markdown сказано, что класть, а что нет', /меню и подвал/.test(md));
|
|
29
29
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Эталон: движок прогоняется по записанным ответам пяти сайтов и сверяется с ответами, проверенными
|
|
3
|
+
* вручную (test/etalon/*-truth.json). Одно расхождение, и версия не выкладывается.
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { replayer } from './etalon.mjs';
|
|
7
|
+
import { collect } from './collect.mjs';
|
|
8
|
+
import { secondLook } from './verify.mjs';
|
|
9
|
+
import { localiseChecks } from './i18n.mjs';
|
|
10
|
+
|
|
11
|
+
const dir = new URL('../test/etalon/', import.meta.url);
|
|
12
|
+
let fail = 0; let pass = 0;
|
|
13
|
+
const real = globalThis.fetch;
|
|
14
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('-truth.json')).sort()) {
|
|
15
|
+
const truth = JSON.parse(readFileSync(new URL(f, dir), 'utf8'));
|
|
16
|
+
const name = f.replace(/-truth\.json$/, '');
|
|
17
|
+
const rec = new URL(`${name}.json.gz`, dir);
|
|
18
|
+
if (!existsSync(rec)) { console.log(`FAIL ${name}: нет записи ответов сайта`); fail++; continue; }
|
|
19
|
+
globalThis.fetch = replayer(rec);
|
|
20
|
+
const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
|
|
21
|
+
await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
|
|
22
|
+
globalThis.fetch = real;
|
|
23
|
+
const by = Object.fromEntries(raw.checks.map((c) => [c.id, c]));
|
|
24
|
+
// Число по выборке без слова «выборки» читается как число по всему сайту и преуменьшает проблему (14.10.2026).
|
|
25
|
+
const sampleN = (raw.sample || []).filter((p) => p && p.title !== undefined).length;
|
|
26
|
+
const shown = truth.lang === 'ru' ? localiseChecks(raw.checks, 'ru') : raw.checks;
|
|
27
|
+
for (const c of shown) {
|
|
28
|
+
const v = String(c.value || '');
|
|
29
|
+
const re = truth.lang === 'ru' ? /из (\d+) (?:страниц\S*|проверенн\S*)(?!\s+(?:выборки|карты|сайта|прочитанн))/gu : /of (\d+) (?!sampled|pages read|pages in the sitemap)(?:pages?)\b/g;
|
|
30
|
+
for (const m of v.matchAll(re)) if (Number(m[1]) === sampleN) { fail++; console.log(`FAIL ${name} ${c.id}: число по выборке без слова «выборки»: «${v}»`); }
|
|
31
|
+
}
|
|
32
|
+
if (truth.niche && raw.niche?.niche !== truth.niche) { console.log(`FAIL ${name}: ниша ${raw.niche?.niche}, правильно ${truth.niche}`); fail++; } else pass++;
|
|
33
|
+
for (const [id, exp] of Object.entries(truth.checks || {})) {
|
|
34
|
+
const c = by[id];
|
|
35
|
+
const okStatus = !exp.status || (c && c.status === exp.status);
|
|
36
|
+
const okValue = !exp.valueRegex || (c && new RegExp(String(exp.valueRegex).replace(/\\w/g, '[\\p{L}\\p{N}_]'), 'iu').test(String(c.value)));
|
|
37
|
+
if (okStatus && okValue) pass++;
|
|
38
|
+
else { fail++; console.log(`FAIL ${name} ${id}: движок ${c ? `${c.status} «${c.value}»` : 'нет строки'}, ждали ${exp.status || ''} ${exp.valueRegex ? `/${exp.valueRegex}/` : ''}`); }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
console.log(fail ? `эталон: ${fail} расхождений, ${pass} совпадений` : `эталон: всё сходится (${pass} проверок)`);
|
|
42
|
+
if (fail) process.exit(1);
|