@operstack/audit 0.35.0 → 0.36.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/collect.mjs +106 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +41 -25
- package/src/index.mjs +1 -0
- package/src/narrative.mjs +80 -32
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +57 -15
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +22 -3
- package/src/review.mjs +15 -7
- package/src/tasks-pdf.mjs +1 -1
- package/src/test-answer-first.mjs +27 -0
- package/src/test-demand.mjs +3 -3
- package/src/test-i18n.mjs +3 -3
- package/src/test-narrative.mjs +1 -1
- package/src/test-overall.mjs +1 -1
- package/src/test-planted.mjs +15 -0
- package/src/test-presentation.mjs +67 -0
- package/src/test-rendered.mjs +11 -0
- package/src/test-shopify-store.mjs +1 -1
- package/src/verify.mjs +30 -15
package/src/review.mjs
CHANGED
|
@@ -68,8 +68,8 @@ export function applyFix(audit, id, text) {
|
|
|
68
68
|
if (m[0] === 'chk') {
|
|
69
69
|
const c = (audit.checks || []).find((x) => x.id === m[1]); if (!c) return false;
|
|
70
70
|
const label = c.label;
|
|
71
|
-
if (m[2] === 'value') c.value = strip(text, label);
|
|
72
|
-
else c.comment = strip(text, label);
|
|
71
|
+
if (m[2] === 'value') { c.value = strip(text, label); c.fixedValue = true; }
|
|
72
|
+
else { c.comment = strip(text, label); c.fixedComment = true; }
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
75
75
|
if (m[0] === 'task') { audit.taskOverrides = audit.taskOverrides || {}; (audit.taskOverrides[m[1]] = audit.taskOverrides[m[1]] || {})[m[2]] = text; return true; }
|
|
@@ -243,10 +243,18 @@ export async function confirmFlags(audit, digest, statements, flagged, ask, { la
|
|
|
243
243
|
const askGroup = async (g) => {
|
|
244
244
|
const r = await ask([{ role: 'system', content: CONFIRM(ru) }, { role: 'user', content: shared }, { role: 'user', content: JSON.stringify({ flagged: g.map((x) => ({ id: x.id, kind: byId[x.id]?.kind, text: byId[x.id]?.text, firstReviewer: { verdict: x.verdict, why: x.why, fix: x.fix } })), answerEveryId: g.map((x) => x.id) }) }]);
|
|
245
245
|
usage.calls += 1; usage.input += r.usage?.input || 0; usage.cached += r.usage?.cached || 0; usage.output += r.usage?.output || 0;
|
|
246
|
-
|
|
246
|
+
// Разбор терпимый к форме: keep строкой «true», id с пробелом, ответ массивом или в обёртке. На живом cimes.ru
|
|
247
|
+
// (14.10.2026) три строки считались «без ответа», хотя отдельно модель на них отвечала.
|
|
248
|
+
const raw = String(r.text || '');
|
|
249
|
+
let parsed; try { parsed = JSON.parse(raw.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); } catch { const m = raw.match(/\{[\s\S]*\}/); try { parsed = m ? JSON.parse(m[0]) : null; } catch { parsed = null; } }
|
|
250
|
+
const list = Array.isArray(parsed) ? parsed : (parsed?.items || parsed?.flagged || parsed?.results || []);
|
|
247
251
|
const ids = new Set(g.map((x) => x.id));
|
|
248
|
-
|
|
252
|
+
const asBool = (v) => (typeof v === 'boolean' ? v : /^(true|да|yes)$/i.test(String(v ?? '').trim()) ? true : /^(false|нет|no)$/i.test(String(v ?? '').trim()) ? false : null);
|
|
253
|
+
const got = list.map((x) => (x && typeof x === 'object' ? { ...x, id: String(x.id || '').trim(), keep: asBool(x.keep) } : null)).filter((x) => x && ids.has(x.id) && x.keep !== null);
|
|
254
|
+
for (const f of g) if (!got.some((x) => x.id === f.id)) lastRaw[f.id] = raw.slice(0, 600);
|
|
255
|
+
return got;
|
|
249
256
|
};
|
|
257
|
+
const lastRaw = {};
|
|
250
258
|
const items = (await Promise.all(chunks(flagged, group).map(askGroup))).flat();
|
|
251
259
|
// Молчание не «ошибки нет»: по каждой пропущенной строке один повторный запрос, маленькими группами.
|
|
252
260
|
let missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
|
|
@@ -254,7 +262,7 @@ export async function confirmFlags(audit, digest, statements, flagged, ask, { la
|
|
|
254
262
|
items.push(...(await Promise.all(chunks(missing, retryGroup).map(askGroup))).flat());
|
|
255
263
|
missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
|
|
256
264
|
}
|
|
257
|
-
return { items, missing: missing.map((f) => f.id), usage };
|
|
265
|
+
return { items, missing: missing.map((f) => f.id), raw: Object.fromEntries(missing.map((f) => [f.id, lastRaw[f.id] || ''])), usage };
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
/**
|
|
@@ -275,13 +283,13 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
|
|
|
275
283
|
const codeIds = new Set(byCode.map((x) => x.id));
|
|
276
284
|
const flagged = items.filter((x) => (x.verdict === 'false' || x.verdict === 'misleading') && !codeIds.has(x.id));
|
|
277
285
|
// Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
|
|
278
|
-
const { items: conf, missing, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
|
|
286
|
+
const { items: conf, missing, raw: missingRaw, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
|
|
279
287
|
add(cu, usage.confirm);
|
|
280
288
|
if (missing.length) {
|
|
281
289
|
held = `второй проверяющий не ответил по ${missing.length} ${missing.length === 1 ? 'строке' : 'строкам'}: ${missing.join(', ')}`;
|
|
282
290
|
log(`проверка, круг ${round}: ${held}`);
|
|
283
291
|
left = missing.map((id) => ({ id, why: 'второй проверяющий не ответил' }));
|
|
284
|
-
rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing });
|
|
292
|
+
rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing, missingRaw });
|
|
285
293
|
break;
|
|
286
294
|
}
|
|
287
295
|
// Правка, которая ничего не меняет, это подтверждение, а не находка: иначе круги идут впустую (14.10.2026, neronov.pro).
|
package/src/tasks-pdf.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function renderTasksHtml(audit, { lang = audit.meta?.lang || 'en' } = {})
|
|
|
20
20
|
const host = audit.meta?.host || '';
|
|
21
21
|
const L = ru
|
|
22
22
|
? { eb: 'OperStack · задачи для исполнителя', h1: `${host}: что поправить на сайте`, now: 'Сейчас', task: 'Что сделать', verify: 'Как проверить',
|
|
23
|
-
intro: ['Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами,
|
|
23
|
+
intro: ['Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами, её поймёт и разработчик, и помощник вроде ChatGPT.', 'Файл можно переслать исполнителю целиком. Каждая задача идёт вместе со строками «Сейчас» и «Как проверить»: по ним видно, откуда начинать и когда работа сделана.'],
|
|
24
24
|
rule: 'Правило для исполнителя: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца.', foot: 'задачи' }
|
|
25
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
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.'],
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ответ в первом абзаце (15.10.2026, cimes.ru): вводный блок после H1 из нескольких абзацев засчитывается целиком,
|
|
3
|
+
* если вместе он укладывается в 20-90 слов. Длинная статья без подзаголовков по-прежнему мерится первым абзацем.
|
|
4
|
+
*/
|
|
5
|
+
import { analysePage } from './collect.mjs';
|
|
6
|
+
|
|
7
|
+
let bad = 0;
|
|
8
|
+
const is = (n, a, b) => { if (a !== b) { bad++; console.error(`FAIL ${n}: ждали ${b}, вышло ${a}`); } else console.log(`ok ${n}`); };
|
|
9
|
+
const page = (body) => `<html><head><title>T</title></head><body><main>${body}</main></body></html>`;
|
|
10
|
+
|
|
11
|
+
const cimes = page(`<h1>Создание сайта в Новороссийске и любом другом городе России</h1><div class="text">
|
|
12
|
+
<p>Разрабатываем сайты, интернет-магазины и CRM — на 1С-Битрикс и на собственном коде, с интеграциями с 1С, онлайн-оплатой и службами доставки.</p>
|
|
13
|
+
<p class="hero-prices">Визитка — от 28 000 ₽ · корпоративный — от 78 000 ₽ · магазин — от 120 000 ₽. Смета бесплатно за 1–2 рабочих дня.</p></div>
|
|
14
|
+
<div class="buttons"><button class="btn">Обсудить проект</button></div><h2>Услуги</h2><p>${'Текст раздела. '.repeat(40)}</p>`);
|
|
15
|
+
is('cimes: ответ двумя абзацами до кнопок засчитан', analysePage('https://cimes.ru/', cimes).answerFirst, true);
|
|
16
|
+
|
|
17
|
+
const longLead = page(`<h1>Статья</h1><p>${'Слово '.repeat(30)}и 5 причин.</p>${`<p>${'Ещё текст абзаца без цифр. '.repeat(10)}</p>`.repeat(4)}`);
|
|
18
|
+
is('длинный вводный блок: мерится первый абзац', analysePage('https://x.ru/a/', longLead).answerFirst, true);
|
|
19
|
+
|
|
20
|
+
const shortNoFigure = page(`<h1>Услуга</h1><p>Делаем сайты под ключ для бизнеса.</p><button>Заказать</button><p>${'Описание без цифр. '.repeat(20)}</p>`);
|
|
21
|
+
is('короткий абзац до кнопки без цифры: ответа нет', analysePage('https://x.ru/b/', shortNoFigure).answerFirst, false);
|
|
22
|
+
|
|
23
|
+
const techWords = page(`<h1>Дизайн сайтов</h1><p>Делаем дизайн и вёрстку сайтов на HTML5 и CSS3 с адаптивом под телефоны и планшеты, наполняем страницы текстами и фотографиями для бизнеса.</p><button>Заказать</button>`);
|
|
24
|
+
is('HTML5 и CSS3 не цифра ответа', analysePage('https://x.ru/c/', techWords).answerFirst, false);
|
|
25
|
+
|
|
26
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
27
|
+
console.log('\nответ в первом абзаце: все случаи сходятся');
|
package/src/test-demand.mjs
CHANGED
|
@@ -230,13 +230,13 @@ function fakeTopvisor() {
|
|
|
230
230
|
fixture.totals = demandTotals(fixture.queries);
|
|
231
231
|
const withMap = { ...sample, demand: fixture };
|
|
232
232
|
const html = toHtml(withMap);
|
|
233
|
-
ok('в отчёте появилась страница «
|
|
233
|
+
ok('в отчёте появилась страница «NN · Demand»', /\d{2} · Demand/.test(html) && /Demand map/.test(html));
|
|
234
234
|
ok('таблица источников называет Topvisor платным и не входящим в балл', /Topvisor \(volumes for the demand map\)/.test(html) && /counts towards no score/.test(html));
|
|
235
|
-
ok('без карты страницы нет',
|
|
235
|
+
ok('без карты страницы нет', !/\d{2} · Demand/.test(toHtml(sample)));
|
|
236
236
|
is('баллы с картой и без неё одинаковы', JSON.stringify(computeScores(withMap.checks || []).scores), before);
|
|
237
237
|
ok('проверка баллов проходит с картой', verifyScores(withMap).ok !== false);
|
|
238
238
|
const ruHtml = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru' }, demand: { ...fixture, lang: 'ru', regionName: 'США' } });
|
|
239
|
-
ok('русский отчёт: «
|
|
239
|
+
ok('русский отчёт: «NN · Спрос»', /\d{2} · Спрос/.test(ruHtml) && /Карта спроса/.test(ruHtml));
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
package/src/test-i18n.mjs
CHANGED
|
@@ -49,9 +49,9 @@ const lostNumbers = ru.filter((c, i) => {
|
|
|
49
49
|
}).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
|
|
50
50
|
is('числа в тексте сохранились', lostNumbers, []);
|
|
51
51
|
|
|
52
|
-
is('подпись под оценкой переведена', localiseBasisNote('10 of 11 checks pass, 1 needs attention'), '10 из 11 проверок пройдено, 1
|
|
53
|
-
is('склонение на пяти', localiseBasisNote('3 of 5 checks pass, 2 need attention'), '3 из 5 проверок пройдено, 2
|
|
54
|
-
is('провал тоже склоняется', localiseBasisNote('1 of 3 checks pass, 1 needs attention, 1 fails'), '1 из 3 проверок пройдено, 1
|
|
52
|
+
is('подпись под оценкой переведена', localiseBasisNote('10 of 11 checks pass, 1 needs attention'), '10 из 11 проверок пройдено, 1 частично');
|
|
53
|
+
is('склонение на пяти', localiseBasisNote('3 of 5 checks pass, 2 need attention'), '3 из 5 проверок пройдено, 2 частично');
|
|
54
|
+
is('провал тоже склоняется', localiseBasisNote('1 of 3 checks pass, 1 needs attention, 1 fails'), '1 из 3 проверок пройдено, 1 частично, 1 провалена');
|
|
55
55
|
is('неизмеренное переводится', localiseBasisNote('not measured: no link data was collected'), 'не измерялось: no link data was collected');
|
|
56
56
|
|
|
57
57
|
is('английский язык ничего не меняет', localiseChecks(all, 'en'), all);
|
package/src/test-narrative.mjs
CHANGED
|
@@ -67,7 +67,7 @@ ok('резюме говорит, сколько прочитано, а не ск
|
|
|
67
67
|
const en = draftNarrative(audit(), { lang: 'en' });
|
|
68
68
|
ok('и по-английски тоже', /^We read \d+ pages? of /.test(en.summary.lead) && !/is a site of/.test(en.summary.lead));
|
|
69
69
|
const withLang = audit(); withLang.meta.language = 'en';
|
|
70
|
-
is('язык из кода страницы попадает в обзор', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], '
|
|
70
|
+
is('язык из кода страницы попадает в обзор словом', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], 'English');
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Числа сходятся между собой
|
package/src/test-overall.mjs
CHANGED
|
@@ -102,7 +102,7 @@ ok('параметры нельзя поменять на ходу', Object.isFr
|
|
|
102
102
|
const html = toHtml(audit, null);
|
|
103
103
|
is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 61);
|
|
104
104
|
ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
|
|
105
|
-
ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai
|
|
105
|
+
ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai[-\u2011]visibility/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
106
106
|
ok(`${lang}: пять областей напечатаны рядом с баллом`, /class="areas-bars"/.test(html) && (html.match(/class="ar ar-/g) || []).length === 5);
|
|
107
107
|
ok(`${lang}: балл стоит и пилюлей с буквой`, /class="pill pill-(good|fair|poor)"/.test(html));
|
|
108
108
|
const foot = (html.match(/scorecard-foot">([^<]*)</) || [])[1] || '';
|
package/src/test-planted.mjs
CHANGED
|
@@ -82,6 +82,21 @@ for (const name of Object.keys(PLANTS)) {
|
|
|
82
82
|
const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: silent });
|
|
83
83
|
if (rev.ok || !/не ответил по 1 строке/.test(rev.held || '')) { fail++; console.log(`FAIL ${name}: молчание второго шага не удержало отчёт (${rev.held || 'ok'}), строка ${other.id}, круги ${JSON.stringify(rev.rounds.map((r) => ({ f: r.flagged, c: r.byCode, m: r.missing })))}`); }
|
|
84
84
|
}
|
|
85
|
+
// 2б. Ответ в «грязной» форме (```json, keep строкой, id с пробелом) это ответ, а не молчание.
|
|
86
|
+
{
|
|
87
|
+
const { audit, digest, lang } = await build(name);
|
|
88
|
+
const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim');
|
|
89
|
+
const messy = async (messages) => { const fl = JSON.parse(messages[2].content).flagged; return { text: '```json\n' + JSON.stringify({ items: fl.map((x) => ({ id: ` ${x.id} `, keep: 'false' })) }) + '\n```', usage: { input: 1, output: 1 } }; };
|
|
90
|
+
const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: messy });
|
|
91
|
+
if (!rev.ok || rev.held) { fail++; console.log(`FAIL ${name}: «грязный» ответ второго шага принят за молчание (${rev.held})`); }
|
|
92
|
+
}
|
|
93
|
+
// 2в. Русская правка строки с латиницей («www») не затирается переводом: иначе круги не сходятся (cimes.ru, 14.10.2026).
|
|
94
|
+
if (name === 'medeq') {
|
|
95
|
+
const { audit, lang } = await build(name);
|
|
96
|
+
applyFix(audit, 'chk:www:value', 'Склейка адреса с www: адрес с www перенаправляется на основной');
|
|
97
|
+
const t = textMap(audit, lang)['chk:www:value'];
|
|
98
|
+
if (t !== 'Склейка адреса с www: адрес с www перенаправляется на основной') { fail++; console.log(`FAIL правка www затёрта переводом: «${t}»`); }
|
|
99
|
+
}
|
|
85
100
|
// 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
|
|
86
101
|
for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
|
|
87
102
|
const { audit, digest, lang } = await build(name);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка оформления (presentation.mjs): каждое из восьми правил отдельным тестом на настоящем отчёте
|
|
3
|
+
* эталона, куда вписан один огрех. Плюс все эталоны без огрехов должны проходить чисто.
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { replayer } from './etalon.mjs';
|
|
7
|
+
import { collect } from './collect.mjs';
|
|
8
|
+
import { secondLook } from './verify.mjs';
|
|
9
|
+
import { draftNarrative } from './narrative.mjs';
|
|
10
|
+
import { presentationCheck } from './presentation.mjs';
|
|
11
|
+
|
|
12
|
+
const dir = new URL('../test/etalon/', import.meta.url);
|
|
13
|
+
let bad = 0;
|
|
14
|
+
const is = (n, c) => { if (!c) { bad++; console.error(`FAIL ${n}`); } else console.log(`ok ${n}`); };
|
|
15
|
+
|
|
16
|
+
async function build(name) {
|
|
17
|
+
const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
|
|
18
|
+
const real = globalThis.fetch;
|
|
19
|
+
globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
|
|
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
|
+
return { a: draftNarrative(raw, { lang: truth.lang }), lang: truth.lang };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Все эталоны чистые.
|
|
27
|
+
const names = readdirSync(dir).filter((x) => x.endsWith('-truth.json')).map((x) => x.replace('-truth.json', '')).sort();
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
const { a, lang } = await build(name);
|
|
30
|
+
const r = presentationCheck(a, { lang });
|
|
31
|
+
is(`${name}: оформление чистое (${r.problems.map((p) => `[${p.rule}] ${p.id} ${p.why}`).join('; ') || 'ни одной находки'})`, r.ok);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// По одному огреху на правило, на отчёте cimes.ru.
|
|
35
|
+
const fires = async (rule, label, spoil) => {
|
|
36
|
+
const { a, lang } = await build('cimes');
|
|
37
|
+
spoil(a);
|
|
38
|
+
const r = presentationCheck(a, { lang });
|
|
39
|
+
is(`правило ${rule}: ${label}`, r.problems.some((p) => p.rule === rule));
|
|
40
|
+
};
|
|
41
|
+
await fires(1, 'пустой раздел «Неделя 1»', (a) => { a.roadmap[0].items = []; });
|
|
42
|
+
await fires(2, '«Сейчас: 1: t.me»', (a) => { a.summary.priorities[0] = 'Заявить профили. Сейчас: 1: t.me'; });
|
|
43
|
+
await fires(2, 'undefined в тексте', (a) => { a.summary.lead += ' балл undefined'; });
|
|
44
|
+
await fires(3, 'фраза оборвана на «на»', (a) => { a.summary.verdict = 'Та же дата видна на самой странице на'; });
|
|
45
|
+
await fires(3, 'фраза оборвана на «на 1»', (a) => { const c = a.checks.find((x) => x.id === 'dates'); c.value = '1 из 20 страниц выборки указывают дату в разметке; та же дата видна на самой странице на 1'; });
|
|
46
|
+
await fires(4, '«Норма» при 1 из 20', (a) => { const c = a.checks.find((x) => x.id === 'dates'); c.status = 'ok'; c.value = '1 из 20 страниц выборки указывают дату в разметке'; });
|
|
47
|
+
await fires(5, '«Тонкие страницы» в сильных сторонах', (a) => { a.content.strengths = ['Тонкие страницы: медиана 1618 слов на страницу', ...(a.content.strengths || [])]; });
|
|
48
|
+
await fires(6, '«не измеряли» при найденном движке', (a) => { a.checks.push({ id: 'cms', group: 'overview', label: 'Движок сайта', status: 'na', value: '1C-Bitrix (определён по коду страницы)' }); a.overview.rows[0][1] = 'не измеряли'; });
|
|
49
|
+
await fires(7, 'в отчёте 5 страниц, в задаче 2', (a) => { const c = a.checks.find((x) => x.id === 'sections'); c.status = 'warn'; c.value = 'меньше трёх подзаголовков H2 на 5 из 20 страниц выборки'; a.taskOverrides = { sections: { task: 'Разбей на разделы страницы /a/, /b/.' } }; });
|
|
50
|
+
await fires(8, 'длинное тире в нашей фразе', (a) => { a.summary.verdict = 'Сайт в порядке — почти'; });
|
|
51
|
+
|
|
52
|
+
// Команда для разработчика с /dev/null не огрех.
|
|
53
|
+
{
|
|
54
|
+
const { a, lang } = await build('cimes');
|
|
55
|
+
a.taskOverrides = { ...(a.taskOverrides || {}), sections: { verify: 'Три замера (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' } };
|
|
56
|
+
is('команда curl с /dev/null не огрех', !presentationCheck(a, { lang }).problems.some((p) => p.rule === 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Тире в цитате с сайта клиента не огрех.
|
|
60
|
+
{
|
|
61
|
+
const { a, lang } = await build('cimes');
|
|
62
|
+
a.client.subject = 'По описанию на главной: Студия — сайты под ключ.';
|
|
63
|
+
is('тире в цитате с сайта клиента не огрех', !presentationCheck(a, { lang }).problems.some((p) => p.rule === 8));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
67
|
+
console.log('\nпроверка оформления: все правила работают, эталоны чистые');
|
package/src/test-rendered.mjs
CHANGED
|
@@ -121,5 +121,16 @@ ok('английская версия без кириллицы', !/[А-Яа-я
|
|
|
121
121
|
rmSync(dir, { recursive: true, force: true });
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// cimes.ru 14.10.2026: меню, достроенное скриптом вне <nav>, и баннер cookie не «скрытый текст».
|
|
125
|
+
{
|
|
126
|
+
const body = '<main><p>' + 'Создание сайтов под ключ для бизнеса '.repeat(40) + '</p></main>';
|
|
127
|
+
const raw = `<html><body><header><div class="logo">Cimes</div></header>${body}</body></html>`;
|
|
128
|
+
const ren = raw.replace('</header>', '<div class="header-menu dropdown">' + 'Услуги Готовые решения Аспро Максимум Подробнее Демо '.repeat(30) + '</div></header>')
|
|
129
|
+
.replace('</main>', '</main><div class="cookie-settings"><p>' + 'Управление cookie файлами принять все '.repeat(20) + '</p></div>');
|
|
130
|
+
is('меню и баннер cookie не скрытый текст', compareReadings(raw, ren).hiddenShare, 0);
|
|
131
|
+
const spa = `<html><body><div id="root"></div></body></html>`;
|
|
132
|
+
ok('настоящая страница на скриптах по-прежнему видна', compareReadings(spa, spa.replace('<div id="root"></div>', body)).verdict === 'bad');
|
|
133
|
+
}
|
|
134
|
+
|
|
124
135
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
125
136
|
console.log('\nвсе тесты слепоты к скриптам прошли');
|
|
@@ -131,7 +131,7 @@ try {
|
|
|
131
131
|
is('4. FAQPage найдена', check('faq-schema').status, 'ok');
|
|
132
132
|
|
|
133
133
|
// 6. Профили из sameAs со слешами \/
|
|
134
|
-
is('6. профили прочитаны', check('trust-profiles').value, '3:
|
|
134
|
+
is('6. профили прочитаны', check('trust-profiles').value, '3 profiles: Instagram, YouTube, Facebook');
|
|
135
135
|
|
|
136
136
|
// 7, 10. llms.txt
|
|
137
137
|
ok(`7. адрес с обратной кавычкой не мёртвый (${check('llms-health').value})`, check('llms-health').status === 'ok');
|
package/src/verify.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { __readSitemap as readSitemap, policyKinds, computeScores, computeOveral
|
|
|
13
13
|
import { localiseChecks } from './i18n.mjs';
|
|
14
14
|
import { detectNiche } from './niche.mjs';
|
|
15
15
|
import { MECHANICAL_IDS } from './narrative.mjs';
|
|
16
|
+
import { profileLine } from './i18n.mjs';
|
|
16
17
|
import { request as httpsRequest } from 'node:https';
|
|
17
18
|
import { request as httpRequest } from 'node:http';
|
|
18
19
|
|
|
@@ -87,15 +88,19 @@ export function rebuildCritical(audit) {
|
|
|
87
88
|
const ru = audit.meta?.lang === 'ru';
|
|
88
89
|
const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
89
90
|
const bad = shown.filter((c) => c.status === 'bad');
|
|
90
|
-
|
|
91
|
+
// После черновика первым идёт ровно список приоритетов (priorityIds): пересчёт по ходу проверки не должен его менять (cimes.ru, 14.10.2026).
|
|
92
|
+
const pri = (audit.priorityIds || []).map((id) => shown.find((c) => c.id === id)).filter((c) => c && (c.status === 'bad' || c.status === 'warn'));
|
|
93
|
+
audit.critical = (pri.length ? pri : bad).map((c) => ({ title: c.label, text: joinValueComment(c.value, c.comment), level: c.status === 'warn' ? 'warn' : 'bad' }));
|
|
91
94
|
const fix = ru ? 'Исправить' : 'Fix';
|
|
92
95
|
const week1 = (audit.roadmap || [])[0]; const week2 = (audit.roadmap || [])[1];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
96
|
+
// После черновика план уже собран из приоритетов и переведён в действия: пересчёт по ходу проверки его не трогает.
|
|
97
|
+
if (week1 && Array.isArray(week1.items) && !pri.length) {
|
|
98
|
+
// Первая неделя = раздел «Что чинить первым» (все красные строки, технические впереди) плюс до двух быстрых
|
|
99
|
+
// технических частичных. Раньше план уносил красные строки с фактами владельца на недели 2-4, а раздел «Срочно»
|
|
100
|
+
// ставил их первыми: отчёт спорил сам с собой (cimes.ru, 14.10.2026).
|
|
101
|
+
const quick = pri.length ? [...pri] : [...bad.filter((c) => MECHANICAL_IDS.has(c.id)), ...bad.filter((c) => !MECHANICAL_IDS.has(c.id))];
|
|
102
|
+
if (!pri.length) quick.push(...shown.filter((c) => c.status === 'warn' && MECHANICAL_IDS.has(c.id) && c.id !== 'trust-policies').slice(0, Math.max(0, 3 - quick.length) || (quick.length ? 0 : 2)));
|
|
103
|
+
const slow = [];
|
|
99
104
|
const other = week1.items.filter((i) => !String(i).startsWith(`${fix}:`) && !String(i).includes('{{'));
|
|
100
105
|
week1.items = [...quick.map((c) => `${fix}: ${c.label}`), ...other];
|
|
101
106
|
if (week2 && Array.isArray(week2.items)) {
|
|
@@ -103,6 +108,7 @@ export function rebuildCritical(audit) {
|
|
|
103
108
|
week2.items = [...slow.map((c) => `${fix}: ${c.label}`), ...keep, ru ? 'Остальные задачи по порядку из файла задач' : 'The rest of the tasks, in order, from the tasks file'];
|
|
104
109
|
week2.items = [...new Set(week2.items)];
|
|
105
110
|
}
|
|
111
|
+
if (!week1.items.length) audit.roadmap = audit.roadmap.filter((st) => st !== week1);
|
|
106
112
|
}
|
|
107
113
|
return audit;
|
|
108
114
|
}
|
|
@@ -180,9 +186,11 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
180
186
|
// 2. Страница «о компании».
|
|
181
187
|
const about = byId('trust-about');
|
|
182
188
|
if (about && about.status !== 'ok') {
|
|
183
|
-
|
|
189
|
+
// Только свои ссылки: «yandex.ru/support/.../about» не страница о компании (cimes.ru, 14.10.2026).
|
|
190
|
+
const ownHref = (h, base) => { try { return new URL(h, base).host.replace(/^www\./, '') === new URL(base).host.replace(/^www\./, ''); } catch { return false; } };
|
|
191
|
+
const hit = pages.find((p) => hrefs(p.html).some((h) => ownHref(h, p.url) && ABOUT_HREF.test(h)) || ABOUT_HREF.test(new URL(p.url).pathname) || /"@type"\s*:\s*"AboutPage"/.test(p.html) || ABOUT_TITLE.test(titleOf(p.html)) || h1sOf(p.html).some((h) => ABOUT_TITLE.test(h)));
|
|
184
192
|
if (hit) change('trust-about', { status: 'ok', value: ru ? `есть: найдено на ${path(hit.url)}` : `present: found via ${path(hit.url)}` }, { url: hit.url });
|
|
185
|
-
else change('trust-about', { value: ru ? `страницы о компании нет ни на одной из ${pages.length} прочитанных страниц` : `no about or team page on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
193
|
+
else change('trust-about', { status: 'bad', value: ru ? `страницы о компании нет ни на одной из ${pages.length} прочитанных страниц` : `no about or team page on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
// 3. Мессенджеры.
|
|
@@ -202,15 +210,16 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
202
210
|
|
|
203
211
|
// 4. Профили: sameAs с разэкранированными слешами, плюс ссылки на соцсети в самих страницах.
|
|
204
212
|
const prof = byId('trust-profiles');
|
|
205
|
-
|
|
213
|
+
// В разметке только мессенджеры: это уже точный ответ сбора, ссылки t.me на страницах его не меняют (cimes.ru, 15.10.2026).
|
|
214
|
+
if (prof && prof.status !== 'ok' && !prof.messengersOnly && !prof.listings) {
|
|
206
215
|
const own = audit.meta.host.replace(/^www\./, '');
|
|
207
216
|
const inSchema = new Set(); const inLinks = new Set();
|
|
208
217
|
for (const p of pages) {
|
|
209
218
|
const ld = String(p.html).replace(/\\\//g, '/');
|
|
210
|
-
for (const m of ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) for (const u of m[1].match(/https?:\/\/[^"\s,\]]+/g) || []) { try { const h = new URL(u).host.replace(/^www\./, ''); if (h !== own) inSchema.add(h); } catch { /* мусор */ } }
|
|
219
|
+
for (const m of ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) for (const u of m[1].match(/https?:\/\/[^"\s,\]]+/g) || []) { try { const h = new URL(u).host.replace(/^www\./, ''); if (h !== own && !/^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i.test(h)) inSchema.add(h); } catch { /* мусор */ } }
|
|
211
220
|
for (const m of String(p.html).matchAll(SOCIAL)) inLinks.add(m[1].toLowerCase());
|
|
212
221
|
}
|
|
213
|
-
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value:
|
|
222
|
+
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value: profileLine([...inSchema].slice(0, 5), ru ? 'ru' : 'en', inSchema.size) }, { inSchema: [...inSchema] });
|
|
214
223
|
else if (inLinks.size) change('trust-profiles', { status: 'warn', value: ru ? `в разметке не заявлены, но ссылки на сайте есть: ${[...inLinks].join(', ')}` : `not declared in the markup, though the pages link to: ${[...inLinks].join(', ')}` }, { inLinks: [...inLinks] });
|
|
215
224
|
else change('trust-profiles', { value: ru ? `ни в разметке, ни ссылками ни на одной из ${pages.length} прочитанных страниц профилей нет` : `no profile in the markup or as a link on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
216
225
|
}
|
|
@@ -240,7 +249,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
240
249
|
const fmt = (x) => (ru ? `${(x / 1000).toFixed(1).replace('.', ',')} с` : `${(x / 1000).toFixed(1)} s`);
|
|
241
250
|
// Пороги web.dev: до 0,8 с хорошо, до 1,8 с терпимо, дальше плохо.
|
|
242
251
|
const status = mid <= 800 ? 'ok' : mid <= 1800 ? 'warn' : 'bad';
|
|
243
|
-
change('ttfb', { status, value: ru ?
|
|
252
|
+
change('ttfb', { status, value: ru ? (fmt(lo) === fmt(hi) ? `${fmt(mid)} во всех ${runs.length} замерах с сервера в Германии` : `медиана ${fmt(mid)} по ${runs.length} замерам (от ${fmt(lo)} до ${fmt(hi)}), замер с сервера в Германии`) : (fmt(lo) === fmt(hi) ? `${fmt(mid)} in all ${runs.length} requests from a server in Germany` : `median ${fmt(mid)} over ${runs.length} requests (${fmt(lo)} to ${fmt(hi)}), measured from a server in Germany`), comment: ru ? 'хорошим Google считает ответ до 0,8 секунды; посетители из другой страны могут видеть другое время' : 'Google calls 0.8 seconds or less good; visitors elsewhere may see a different time' }, { runs });
|
|
244
253
|
}
|
|
245
254
|
}
|
|
246
255
|
}
|
|
@@ -266,7 +275,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
266
275
|
audit.niche = audit.niche || detectNiche(audit);
|
|
267
276
|
if (audit.niche.niche !== 'b2b') for (const id of ['agent-card', 'api-catalog', 'agent-markdown']) {
|
|
268
277
|
const c = byId(id);
|
|
269
|
-
if (c && c.status === 'warn') change(id, { status: 'na', comment: ru ? 'необязательно для этого сайта: нужно, только если у компании есть свой ИИ-сервис или API' : 'optional for this site: needed only when the business runs its own AI service or API' }, { niche: audit.niche.niche });
|
|
278
|
+
if (c && c.status === 'warn') change(id, { status: 'na', comment: id === 'agent-markdown' ? (ru ? 'необязательно для этого сайта: текстовые копии страниц нужны тем, кого агенты читают как документацию' : 'optional for this site: text copies of pages matter to sites that agents read as documentation') : (ru ? 'необязательно для этого сайта: нужно, только если у компании есть свой ИИ-сервис или API' : 'optional for this site: needed only when the business runs its own AI service or API') }, { niche: audit.niche.niche });
|
|
270
279
|
}
|
|
271
280
|
// Роботы ИИ по-настоящему: robots.txt может их пускать, а сервер обрывать соединение (14.10.2026, medeq-mc.ru).
|
|
272
281
|
const robotsUrl = `${new URL(audit.meta.site).origin}/robots.txt`;
|
|
@@ -288,7 +297,13 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
288
297
|
}
|
|
289
298
|
audit.verification = { ...audit.verification, at: new Date().toISOString(), pagesRead: pages.length, changes };
|
|
290
299
|
const src = (audit.sources || [])[0];
|
|
291
|
-
|
|
300
|
+
// «все 150 страниц из карты» при 342 адресах в карте неправда (cimes.ru, 14.10.2026): читается до 150, это и пишем.
|
|
301
|
+
const smTotal = Number((String((audit.checks || []).find((c) => c.id === 'sitemap')?.value || '').match(/(\d+)/) || [])[1]) || 0;
|
|
302
|
+
const partial = smTotal > pages.length;
|
|
303
|
+
const pw = (n) => (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : n % 10 === 1 && n % 100 !== 11 ? 'страницу' : 'страниц');
|
|
304
|
+
if (src && pages.length > (audit.sample || []).length) src.detail = ru
|
|
305
|
+
? `robots.txt и ${partial ? `${pages.length} из ${smTotal} адресов` : `все ${pages.length} ${pw(pages.length)}`} из карты сайта: на них проверено всё, что отчёт называет отсутствующим; ${(audit.sample || []).length} страниц выборки (включая главную) разобраны подробно`
|
|
306
|
+
: `robots.txt and ${partial ? `${pages.length} of the ${smTotal} URLs` : `all ${pages.length} pages`} in the sitemap, checked for everything the report calls absent; the ${(audit.sample || []).length} sampled pages (homepage included) read in detail`;
|
|
292
307
|
rescore(audit);
|
|
293
308
|
// Сжатые копии прочитанных страниц для проверяющего: в audit не пишутся, отдаются вызывающему.
|
|
294
309
|
Object.defineProperty(audit.verification, 'digest', { value: siteDigest(pages, robotsText), enumerable: false });
|