@operstack/audit 0.33.3 → 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 +222 -46
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +26 -20
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +57 -21
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +12 -2
- 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 +11 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +11 -2
- package/src/test-shopify-store.mjs +159 -0
- package/src/verify.mjs +347 -0
- package/src/visibility.mjs +40 -10
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);
|
package/src/test-narrative.mjs
CHANGED
|
@@ -60,10 +60,19 @@ ok('упоминания на чужих сайтах помечены как н
|
|
|
60
60
|
ok('и объяснено почему', /за деньги/.test(d.geo.rows[1][2]));
|
|
61
61
|
ok('в разделе ссылок сказано, что данные платные', /не покупаем данные/.test(d.offpage.note));
|
|
62
62
|
ok('язык не выдуман, если его нет в коде', /не указан/.test(d.overview.rows[2][1]));
|
|
63
|
+
// Число в начале резюме это выборка, а не размер сайта: «сайт из 19 страниц» о магазине на
|
|
64
|
+
// сотни товаров неправда (brainmd.com, 12.10.2026).
|
|
65
|
+
ok('резюме говорит, сколько прочитано, а не сколько страниц у сайта', /^Мы прочитали \d+ страниц/.test(d.summary.lead) && !/сайт из/.test(d.summary.lead));
|
|
66
|
+
{
|
|
67
|
+
const en = draftNarrative(audit(), { lang: 'en' });
|
|
68
|
+
ok('и по-английски тоже', /^We read \d+ pages? of /.test(en.summary.lead) && !/is a site of/.test(en.summary.lead));
|
|
69
|
+
const withLang = audit(); withLang.meta.language = 'en';
|
|
70
|
+
is('язык из кода страницы попадает в обзор', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], 'en');
|
|
71
|
+
}
|
|
63
72
|
|
|
64
73
|
// Числа сходятся между собой
|
|
65
74
|
const openN = 4; const mech = 2; // alt, conv-cta механические; thin и answer-first это тексты
|
|
66
|
-
ok('в выводе число открытых проверок верное', d.summary.verdict.
|
|
75
|
+
ok('в выводе число открытых проверок верное', d.summary.verdict.startsWith(`${openN} `) && /не полностью/.test(d.summary.verdict));
|
|
67
76
|
ok('и механических не больше, чем всех', d.summary.verdict.includes(`${mech} правятся`) || d.summary.verdict.includes(`${mech} правится`));
|
|
68
77
|
|
|
69
78
|
// Приоритеты это действие, а не название проверки
|
|
@@ -74,7 +83,7 @@ ok('работа с текстами названа работой, а не от
|
|
|
74
83
|
// Русский отчёт по английскому аудиту не должен содержать английских названий проверок
|
|
75
84
|
const ruText = JSON.stringify([d.summary, d.content, d.aeo, d.roadmap, d.closing]);
|
|
76
85
|
ok('в русском черновике нет английских названий проверок', !/Image alt text|Thin pages|call to action/.test(ruText));
|
|
77
|
-
ok('области названы по-русски',
|
|
86
|
+
ok('области названы по-русски', /доступ и адреса|техническая часть|конверсия/i.test(d.summary.lead));
|
|
78
87
|
|
|
79
88
|
// Дорожная карта: уже написанные строки переведены, пустые заполнены
|
|
80
89
|
ok('готовая строка карты переведена', d.roadmap[0].items[0].includes('подписи'));
|
package/src/test-prompts.mjs
CHANGED
|
@@ -39,9 +39,9 @@ ok('английский файл без кириллицы', !/[А-Яа-яЁё]
|
|
|
39
39
|
|
|
40
40
|
// Задания не обещают того, чего мы не знаем
|
|
41
41
|
const contact = agentPrompt(c('conv-messenger', 'bad', 'Мессенджер', 'нет'), { lang: 'ru' });
|
|
42
|
-
ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси
|
|
42
|
+
ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси владельца/i.test(contact));
|
|
43
43
|
const src = agentPrompt(c('sources', 'bad', 'Источники', '2 из 20'), { lang: 'ru' });
|
|
44
|
-
ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник
|
|
44
|
+
ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник хуже/i.test(src));
|
|
45
45
|
const af = agentPrompt(c('answer-first', 'bad', 'Ответ в начале', '2 из 20'), { lang: 'ru' });
|
|
46
46
|
ok('запрещает придумывать цифру', /не придумывай её|спроси владельца/.test(af));
|
|
47
47
|
|
package/src/test-rendered.mjs
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
import { chmodSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom } from './rendered.mjs';
|
|
6
|
+
import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom, removeProfile } from './rendered.mjs';
|
|
7
7
|
|
|
8
8
|
let bad = 0;
|
|
9
9
|
const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
|
|
10
10
|
const ok = (n, c) => is(n, Boolean(c), true);
|
|
11
11
|
|
|
12
|
+
// Профиль Chrome: ENOTEMPTY на macOS ронял весь сбор (brainmd.com, 12.10.2026).
|
|
13
|
+
{
|
|
14
|
+
let opts = null;
|
|
15
|
+
is('удаление профиля с повторами', removeProfile('/tmp/x', (d, o) => { opts = o; }), true);
|
|
16
|
+
ok('повторы включены', opts && opts.maxRetries >= 3 && opts.recursive);
|
|
17
|
+
const fail = () => { const e = new Error('ENOTEMPTY: directory not empty'); e.code = 'ENOTEMPTY'; throw e; };
|
|
18
|
+
is('ENOTEMPTY не роняет сбор', removeProfile('/tmp/x', fail), false);
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
const text = (n) => '<p>' + 'слово '.repeat(n) + '</p>';
|
|
13
22
|
const body = (inner) => `<html><body>${inner}</body></html>`;
|
|
14
23
|
|
|
@@ -55,7 +64,7 @@ ok('а пишем, что часть текста не доходит', /не д
|
|
|
55
64
|
|
|
56
65
|
const none = jsBlindnessCheck([], 'ru');
|
|
57
66
|
is('без браузера статус «не измеряли»', none.status, 'na');
|
|
58
|
-
ok('и сказано
|
|
67
|
+
ok('и сказано почему, без слов про «машину»', /не измеряли/.test(none.value) && !/машин/.test(none.value));
|
|
59
68
|
|
|
60
69
|
const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
|
|
61
70
|
ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
|