@operstack/audit 0.35.1 → 0.37.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/README.md +10 -0
- package/package.json +5 -3
- package/src/client-eye.mjs +339 -0
- package/src/collect.mjs +94 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +37 -23
- package/src/index.mjs +2 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +58 -17
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/verify.mjs +19 -12
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -125
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
package/src/test-overall.mjs
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Один балл на странице, в письме и в отчёте.
|
|
3
|
-
*
|
|
4
|
-
* 14.09.2026 человек увидел 46 на странице проверки, 61 в письме и шесть областей из шестидесяти
|
|
5
|
-
* в приложенном PDF. Три числа про один сайт за один день. Считали их разные движки с разными
|
|
6
|
-
* рамками, и единственный вывод, который делает читатель, это что цифры выдуманы.
|
|
7
|
-
*
|
|
8
|
-
* Теперь движок один и живёт в этом пакете, а параметры зафиксированы в VISIBILITY_DEFAULTS.
|
|
9
|
-
* Эти тесты держат три вещи: балл сходится сам с собой, сборщик берёт его у того же движка, и
|
|
10
|
-
* отчёт печатает именно его, на обоих языках. */
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
12
|
-
import { createServer } from 'node:http';
|
|
13
|
-
import { collect, computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
14
|
-
import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
15
|
-
import { toHtml, overallSummary } from './render.mjs';
|
|
16
|
-
|
|
17
|
-
let bad = 0;
|
|
18
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
19
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
20
|
-
const c = (group, status, i = 0) => ({ id: `${group}-${status}-${i}`, group, status, label: group, value: 'x' });
|
|
21
|
-
|
|
22
|
-
// ---- балл по проверкам отчёта: отдельное измерение, оно обязано пересчитываться из checks
|
|
23
|
-
is('веса шести областей дают сто', SCORE_AREAS.reduce((s, a) => s + a.weight, 0), 100);
|
|
24
|
-
const every = (status) => SCORE_AREAS.flatMap((a) => a.groups.map((g) => c(g, status)));
|
|
25
|
-
is('все пройдены: сто', computeOverall(every('ok')).score, 100);
|
|
26
|
-
is('все провалены: ноль', computeOverall(every('bad')).score, 0);
|
|
27
|
-
is('пустой набор не даёт нуля', computeOverall([]).score, null);
|
|
28
|
-
{
|
|
29
|
-
const o = computeOverall([c('technical', 'ok'), c('geo', 'ok')]);
|
|
30
|
-
is('неизмеренное выпадает из знаменателя, а не становится нулём', o.score, 100);
|
|
31
|
-
is('в знаменателе только измеренные веса', o.weighed, 40);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---- параметры измерения зафиксированы: их расхождение и есть расхождение чисел
|
|
35
|
-
is('бюджет времени один на всех', VISIBILITY_DEFAULTS.budgetMs, 8500);
|
|
36
|
-
// Девять плюс главная это десять. Число стоит на витрине словами «другие проверяют одну
|
|
37
|
-
// страницу, мы проверяем десять», поэтому оно обязано совпадать с тем, что движок правда читает.
|
|
38
|
-
is('глубина выборки одна на всех', VISIBILITY_DEFAULTS.samplePages, 9);
|
|
39
|
-
ok('параметры нельзя поменять на ходу', Object.isFrozen(VISIBILITY_DEFAULTS));
|
|
40
|
-
|
|
41
|
-
// ---- балл видимости сходится сам с собой
|
|
42
|
-
{
|
|
43
|
-
const audit = { checks: [], overall: { score: 61, areas: [{ score: 20, max: 25 }, { score: 10, max: 15 }, { score: 12, max: 20 }, { score: 14, max: 25 }, { score: 5, max: 15 }] } };
|
|
44
|
-
is('честный заголовок проверку проходит', verifyScores(audit), []);
|
|
45
|
-
ok('подделанный заголовок ловится', verifyScores({ ...audit, overall: { ...audit.overall, score: 70 } }).some((p) => p.startsWith('overall')));
|
|
46
|
-
ok('шкала не из ста ловится', verifyScores({ checks: [], overall: { score: 20, areas: [{ score: 20, max: 30 }] } }).some((p) => /out of 30/.test(p)));
|
|
47
|
-
}
|
|
48
|
-
{
|
|
49
|
-
const checks = [c('technical', 'ok'), c('geo', 'bad')];
|
|
50
|
-
const audit = { checks, scores: computeScores(checks).scores, reportScore: computeOverall(checks) };
|
|
51
|
-
is('балл по проверкам отчёта сходится с checks', verifyScores(audit), []);
|
|
52
|
-
ok('подделанный балл по проверкам ловится', verifyScores({ ...audit, reportScore: { score: 99 } }).some((p) => p.startsWith('reportScore')));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/*
|
|
56
|
-
* Движок намеренно отказывается мерить приватные адреса: публичная проверка не должна ходить
|
|
57
|
-
* по чужой локальной сети. Значит локальным сайтом «одно число» не докажешь, и это правильно.
|
|
58
|
-
* Здесь проверяется, что в таком случае сборщик не выдумывает балл, а честно отдаёт «не измерено»,
|
|
59
|
-
* и что отчёт от этого не ломается. Совпадение чисел на живом сайте проверяется отдельно,
|
|
60
|
-
* командой `npm run verify:live`, потому что для этого нужна сеть.
|
|
61
|
-
*/
|
|
62
|
-
{
|
|
63
|
-
const server = createServer((req, res) => {
|
|
64
|
-
res.setHeader('content-type', 'text/html');
|
|
65
|
-
res.end('<!doctype html><html><head><title>Local</title></head><body><h1>Local</h1><p>Text.</p></body></html>');
|
|
66
|
-
});
|
|
67
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
68
|
-
const base = `http://127.0.0.1:${server.address().port}/`;
|
|
69
|
-
const direct = await checkVisibility(base, { ...VISIBILITY_DEFAULTS, lang: 'en' });
|
|
70
|
-
const audit = await collect(base, { pages: 2, log: () => {} });
|
|
71
|
-
server.close();
|
|
72
|
-
|
|
73
|
-
ok('приватный адрес движок мерить отказывается', !direct.ok);
|
|
74
|
-
is('и сборщик не выдумывает за него балл', audit.overall.score, null);
|
|
75
|
-
is('а называет это неизмеренным', audit.overall.grade, 'not measured');
|
|
76
|
-
is('и говорит, откуда балл должен был прийти', audit.overall.source, 'visibility');
|
|
77
|
-
ok('причина отказа сохранена, а не потеряна', typeof audit.overall.error === 'string' && audit.overall.error.length > 0);
|
|
78
|
-
ok('балл по проверкам отчёта при этом посчитан', typeof audit.reportScore.score === 'number');
|
|
79
|
-
is('и отчёт по-прежнему сходится сам с собой', verifyScores(audit), []);
|
|
80
|
-
|
|
81
|
-
for (const lang of ['en', 'ru']) {
|
|
82
|
-
audit.meta.lang = lang;
|
|
83
|
-
const html = toHtml(audit, null);
|
|
84
|
-
// Искать надо напечатанный элемент, а не название стиля: `.overall-num` есть в CSS всегда.
|
|
85
|
-
ok(`${lang}: без балла заголовок не печатается вовсе`, !/class="ring ring-/.test(html));
|
|
86
|
-
ok(`${lang}: шесть областей отчёта на месте`, /second-measure/.test(html));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Тот же отчёт, но с настоящим баллом: печать заголовка и обе подписи.
|
|
91
|
-
{
|
|
92
|
-
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
93
|
-
audit.overall = { score: 61, grade: 'B', source: 'visibility', areas: [
|
|
94
|
-
{ id: 'access', label: 'Access for AI crawlers', score: 20, max: 25 },
|
|
95
|
-
{ id: 'index', label: 'Agent index', score: 10, max: 15 },
|
|
96
|
-
{ id: 'entity', label: 'Entity and structure', score: 12, max: 20 },
|
|
97
|
-
{ id: 'content', label: 'Answer-first content', score: 14, max: 25 },
|
|
98
|
-
{ id: 'trust', label: 'Freshness and sources', score: 5, max: 15 }] };
|
|
99
|
-
is('заголовок сходится со своими областями', verifyScores(audit).filter((p) => p.startsWith('overall')), []);
|
|
100
|
-
for (const [lang, grade] of [['en', /Strong|Workable|Weak|Poor|Critical/], ['ru', /Сильно|Рабочее состояние|Слабо|Плохо|Критично/]]) {
|
|
101
|
-
audit.meta.lang = lang;
|
|
102
|
-
const html = toHtml(audit, null);
|
|
103
|
-
is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 61);
|
|
104
|
-
ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
|
|
105
|
-
ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai-visibility/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
106
|
-
ok(`${lang}: пять областей напечатаны рядом с баллом`, /class="areas-bars"/.test(html) && (html.match(/class="ar ar-/g) || []).length === 5);
|
|
107
|
-
ok(`${lang}: балл стоит и пилюлей с буквой`, /class="pill pill-(good|fair|poor)"/.test(html));
|
|
108
|
-
const foot = (html.match(/scorecard-foot">([^<]*)</) || [])[1] || '';
|
|
109
|
-
ok(`${lang}: сказано, что шесть областей не складываются в заголовок`, lang === 'en' ? /not a breakdown/.test(foot) : /\u043d\u0435 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0430/.test(foot));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/*
|
|
114
|
-
* Балл, уже измеренный на странице, приезжает в отчёт готовым и не перемеряется.
|
|
115
|
-
*
|
|
116
|
-
* Живая сверка 14.09.2026: whitewill.ru дал 47 и 44 в двух прогонах подряд, habr.com в одном
|
|
117
|
-
* прогоне вернул отказ. Тот же код, разная удача сети: бюджет 8,5 секунды, и карта сайта то
|
|
118
|
-
* успевает прочитаться, то нет. Покупателю всё равно, что оба числа честные, он видит два разных.
|
|
119
|
-
* Поэтому в воронке измерение происходит один раз.
|
|
120
|
-
*/
|
|
121
|
-
{
|
|
122
|
-
const server = createServer((req, res) => {
|
|
123
|
-
res.setHeader('content-type', 'text/html');
|
|
124
|
-
res.end('<!doctype html><html><head><title>Local</title></head><body><h1>Local</h1><p>Text.</p></body></html>');
|
|
125
|
-
});
|
|
126
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
127
|
-
const base = `http://127.0.0.1:${server.address().port}/`;
|
|
128
|
-
|
|
129
|
-
const fromPage = { ok: true, score: 63, grade: 'B', checkedAt: '2026-09-14T20:00:00.000Z', ms: 4200, areas: [
|
|
130
|
-
{ id: 'access', label: 'Access for AI crawlers', score: 25, max: 25 },
|
|
131
|
-
{ id: 'index', label: 'Agent index', score: 10, max: 15 },
|
|
132
|
-
{ id: 'entity', label: 'Entity and structure', score: 12, max: 20 },
|
|
133
|
-
{ id: 'content', label: 'Answer-first content', score: 11, max: 25 },
|
|
134
|
-
{ id: 'trust', label: 'Freshness and sources', score: 5, max: 15 }] };
|
|
135
|
-
|
|
136
|
-
const audit = await collect(base, { pages: 2, log: () => {}, visibility: fromPage });
|
|
137
|
-
server.close();
|
|
138
|
-
|
|
139
|
-
is('готовый балл берётся как есть', audit.overall.score, 63);
|
|
140
|
-
is('и оценка буквой тоже', audit.overall.grade, 'B');
|
|
141
|
-
is('видно, что балл переиспользован, а не измерен заново', audit.overall.source, 'visibility:reused');
|
|
142
|
-
is('сохранено, когда его измерили', audit.overall.measuredAt, '2026-09-14T20:00:00.000Z');
|
|
143
|
-
is('пять областей приехали целиком', audit.overall.areas.length, 5);
|
|
144
|
-
is('и складываются в заголовок', audit.overall.areas.reduce((a, x) => a + x.score, 0), 63);
|
|
145
|
-
is('отчёт сходится сам с собой', verifyScores(audit), []);
|
|
146
|
-
// Локальный адрес движок бы мерить отказался, а балл всё равно есть: значит второго измерения не было.
|
|
147
|
-
ok('второго измерения не случилось', audit.overall.score === 63 && !audit.overall.error);
|
|
148
|
-
|
|
149
|
-
audit.meta.lang = 'ru';
|
|
150
|
-
is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 63);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/*
|
|
154
|
-
* Подпись под баллом обязана объяснить человеку три вещи, иначе он прочитает разницу в пару
|
|
155
|
-
* пунктов как ошибку: откуда взялось число, что успели прочитать, и почему повтор может дать
|
|
156
|
-
* иначе. Это критерий приёмки, а не украшение: человек, прочитавший сначала страницу, а потом
|
|
157
|
-
* отчёт, не должен удивиться.
|
|
158
|
-
*/
|
|
159
|
-
{
|
|
160
|
-
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
161
|
-
const areas = [{ id: 'access', label: 'Access', score: 20, max: 25 }, { id: 'index', label: 'Index', score: 10, max: 15 },
|
|
162
|
-
{ id: 'entity', label: 'Entity', score: 12, max: 20 }, { id: 'content', label: 'Content', score: 14, max: 25 }, { id: 'trust', label: 'Trust', score: 5, max: 15 }];
|
|
163
|
-
const note = (a) => ((toHtml(a, null).match(/overall-note">([^<]*)</) || [])[1] || '');
|
|
164
|
-
|
|
165
|
-
for (const lang of ['en', 'ru']) {
|
|
166
|
-
audit.meta.lang = lang;
|
|
167
|
-
|
|
168
|
-
audit.overall = { score: 61, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
|
|
169
|
-
const reused = note(audit);
|
|
170
|
-
ok(`${lang}: сказано, что число уже видели на проверке`, lang === 'en' ? /already saw on the free check/.test(reused) : /уже видели в бесплатной проверке/.test(reused));
|
|
171
|
-
ok(`${lang}: сказано, что здесь не меряли заново`, lang === 'en' ? /not measured again here/.test(reused) : /не меряется заново/.test(reused));
|
|
172
|
-
ok(`${lang}: названа дата измерения`, /2026-09-14/.test(reused));
|
|
173
|
-
ok(`${lang}: сказано, сколько страниц прочитано`, /4/.test(reused));
|
|
174
|
-
ok(`${lang}: предупреждает про разброс в пункт-другой`, lang === 'en' ? /a point or two/.test(reused) : /на пункт-другой/.test(reused));
|
|
175
|
-
|
|
176
|
-
audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: true } };
|
|
177
|
-
const fresh = note(audit);
|
|
178
|
-
ok(`${lang}: при собственном измерении сказано именно это`, lang === 'en' ? /Measured here by the same code/.test(fresh) : /Измерено здесь тем же кодом/.test(fresh));
|
|
179
|
-
ok(`${lang}: непрочитанная карта сайта названа, а не спрятана`, lang === 'en' ? /sitemap did not answer in time/.test(fresh) : /карта сайта не ответила вовремя/.test(fresh));
|
|
180
|
-
ok(`${lang}: и сказано, что она не зачтена в минус`, lang === 'en' ? /not counted against the score/.test(fresh) : /не зачтено ни в плюс, ни в минус/.test(fresh));
|
|
181
|
-
ok(`${lang}: в подписи нет чужого языка`, lang === 'en' ? !/[А-Яа-яЁё]/.test(fresh) : !/Measured|sitemap did/.test(fresh));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Русское склонение в подписи: человек это читает.
|
|
185
|
-
audit.meta.lang = 'ru';
|
|
186
|
-
for (const [n, word] of [[1, 'страница'], [2, 'страницы'], [5, 'страниц'], [11, 'страниц'], [21, 'страница']]) {
|
|
187
|
-
audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: n, sitemapRead: true, sitemapUnchecked: false } };
|
|
188
|
-
ok(`ru: ${n} ${word}`, new RegExp(`Прочитано ${n} ${word} сайта`).test(note(audit)));
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ---- один блок «балл и области» на отчёт и на письмо
|
|
193
|
-
//
|
|
194
|
-
// Письмо собирало свою формулировку и свою цифру, отчёт свою. Разошлись. Теперь обе стороны
|
|
195
|
-
// зовут overallSummary, и разойтись нечему: текст ровно один и живёт в пакете.
|
|
196
|
-
{
|
|
197
|
-
const areas = [{ id: 'access', label: 'Can AI crawlers read it', score: 20, max: 25 },
|
|
198
|
-
{ id: 'index', label: 'Is there a map for agents (llms.txt)', score: 5, max: 15 },
|
|
199
|
-
{ id: 'entity', label: 'Is the entity clear (schema)', score: 12, max: 20 },
|
|
200
|
-
{ id: 'content', label: 'Is there something to quote', score: 18, max: 25 },
|
|
201
|
-
{ id: 'trust', label: 'Can it be dated and trusted', score: 8, max: 15 }];
|
|
202
|
-
const overall = { score: 63, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
|
|
203
|
-
|
|
204
|
-
const ru = overallSummary(overall, { lang: 'ru' });
|
|
205
|
-
const en = overallSummary(overall, { lang: 'en' });
|
|
206
|
-
is('заголовок письма это заголовок отчёта', ru.score, 63);
|
|
207
|
-
is('сумма пяти областей даёт заголовок', ru.areas.reduce((a, x) => a + x.score, 0), ru.score);
|
|
208
|
-
ok('оценка словом переведена', ru.grade === 'Рабочее состояние' && en.grade === 'Workable');
|
|
209
|
-
const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
210
|
-
fixture.meta.lang = 'ru'; fixture.overall = overall;
|
|
211
|
-
ok('подпись та же, что печатает отчёт', ru.note === (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1]);
|
|
212
|
-
|
|
213
|
-
// Замер мог пройти на английской странице, а отчёт уходит русскому покупателю: язык отчёта главнее.
|
|
214
|
-
ok('названия областей на языке отчёта, а не замера', ru.areas[0].label === 'Могут ли роботы ИИ прочитать сайт');
|
|
215
|
-
ok('и в английском они английские', en.areas[0].label === 'Can AI crawlers read it');
|
|
216
|
-
ok('в русском блоке нет английских названий', !ru.areas.some((x) => /[A-Za-z]{4}/.test(x.label.replace('llms.txt', ''))));
|
|
217
|
-
|
|
218
|
-
// Шесть областей отчёта это второе измерение, и подпись обязана это говорить в обоих языках.
|
|
219
|
-
ok('подзаголовок второго измерения есть', /Второе, отдельное измерение/.test(ru.secondMeasure) && /second, separate measurement/.test(en.secondMeasure));
|
|
220
|
-
ok('и сказано, что складывать их с заголовком не надо', /в сумме его не дают/.test(ru.secondMeasureFoot) && /will not add up to it/.test(en.secondMeasureFoot));
|
|
221
|
-
ok('в подписи второго измерения нет разметки', !/</.test(ru.secondMeasureFoot) && !/</.test(en.secondMeasureFoot));
|
|
222
|
-
|
|
223
|
-
// Балла может не быть: движок не мерит закрытые адреса. Тогда письмо не называет цифру вовсе.
|
|
224
|
-
is('без балла блока нет', overallSummary({ score: null, grade: 'not measured', areas: [] }, { lang: 'ru' }), null);
|
|
225
|
-
is('и на пустом объекте тоже', overallSummary(null, { lang: 'en' }), null);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---- движок в пакете это та же программа, что стоит на сайтах
|
|
229
|
-
{
|
|
230
|
-
const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|
|
231
|
-
ok('движок несёт свои параметры', /VISIBILITY_DEFAULTS/.test(here));
|
|
232
|
-
ok('и не тянет за собой зависимостей', !/^import /m.test(here.replace(/VISIBILITY_DEFAULTS/g, '')));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
236
|
-
console.log('\nвсе тесты общего балла прошли');
|
package/src/test-planted.mjs
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 20 подсаженных ошибок (задание 14.10.2026, «второй шаг проверки фактов»): в отчёты пяти эталонов
|
|
3
|
-
* вписываются по четыре ложных числа, как это сделал Hermes, и прогоняется проверка с подменёнными
|
|
4
|
-
* моделями, без сети и денег. Каждая ошибка должна быть исправлена на верное значение или отчёт удержан.
|
|
5
|
-
* На отчётах без подсадки ни одна верная фраза не должна стать неверной.
|
|
6
|
-
*/
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { replayer } from './etalon.mjs';
|
|
9
|
-
import { collect } from './collect.mjs';
|
|
10
|
-
import { secondLook } from './verify.mjs';
|
|
11
|
-
import { draftNarrative } from './narrative.mjs';
|
|
12
|
-
import { statementsOf, applyFix, reviewAndFix } from './review.mjs';
|
|
13
|
-
import { numbersOf } from './facts.mjs';
|
|
14
|
-
|
|
15
|
-
const dir = new URL('../test/etalon/', import.meta.url);
|
|
16
|
-
// [id, число было, число стало]: те же места, что у Hermes.
|
|
17
|
-
const PLANTS = {
|
|
18
|
-
medeq: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '79'], ['sum:prio:0', '120', '367']],
|
|
19
|
-
neronov: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '23 000', '76 000'], ['sum:lead', '20', '67'], ['sum:verdict', null, '28']],
|
|
20
|
-
brainmd: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '49'], ['sum:prio:1', null, '148']],
|
|
21
|
-
drivee: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '52'], ['sum:prio:0', null, '145']],
|
|
22
|
-
babynanny: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '13 000', '46 000'], ['sum:lead', null, '61'], ['sum:verdict', null, '40']],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const json = (o) => ({ text: JSON.stringify(o), usage: { input: 1000, output: 100 } });
|
|
26
|
-
/** Первый шаг: всё «ok», кроме заданных id (их помечает ложными). */
|
|
27
|
-
const firstStep = (flag = new Set()) => async (messages) => {
|
|
28
|
-
const st = JSON.parse(messages[2].content).statements;
|
|
29
|
-
return json({ items: st.map((s) => (flag.has(s.id) ? { id: s.id, verdict: 'false', why: 'подсадка', fix: s.text } : { id: s.id, verdict: 'ok', premiseTrue: true, fitsNiche: true, passesNow: false, possible: true })) });
|
|
30
|
-
};
|
|
31
|
-
const silent = async () => json({ items: [] });
|
|
32
|
-
/** Второй шаг, который «подтверждает» порчу верного числа: правка должна быть отклонена. */
|
|
33
|
-
const spoiler = async (messages) => {
|
|
34
|
-
const fl = JSON.parse(messages[2].content).flagged;
|
|
35
|
-
return json({ items: fl.map((x) => ({ id: x.id, keep: true, evidence: 'на глаз', fix: String(x.text).replace(/\d+/, (n) => String(Number(n) + 1)) })) });
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
async function build(name) {
|
|
39
|
-
const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
|
|
40
|
-
const real = globalThis.fetch;
|
|
41
|
-
globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
|
|
42
|
-
const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
|
|
43
|
-
const second = await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
|
|
44
|
-
globalThis.fetch = real;
|
|
45
|
-
return { audit: draftNarrative(raw, { lang: truth.lang }), digest: second.digest || { robots: '', pages: [] }, lang: truth.lang };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const textMap = (audit, lang) => Object.fromEntries(statementsOf(audit, { lang }).map((s) => [s.id, s.text]));
|
|
49
|
-
/** Подсадить: заменить число «было» (или первое число строки) на «стало». */
|
|
50
|
-
function plant(audit, lang, [id, from, to]) {
|
|
51
|
-
const t = textMap(audit, lang)[id];
|
|
52
|
-
if (!t) throw new Error(`нет строки ${id}`);
|
|
53
|
-
const target = from || numbersOf(t)[0];
|
|
54
|
-
if (!target) throw new Error(`в строке ${id} нет числа: «${t}»`);
|
|
55
|
-
const re = new RegExp(`(?<!\\d)(?<!\\d[\\u00A0\\u202F ])${target.replace(/ /g, '[\\u00A0\\u202F ]')}(?![\\d])`);
|
|
56
|
-
if (!re.test(t)) throw new Error(`в строке ${id} нет числа ${target}: «${t}»`);
|
|
57
|
-
applyFix(audit, id, t.replace(re, to));
|
|
58
|
-
return { id, from: target, to, before: t };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let fail = 0; const table = [];
|
|
62
|
-
for (const name of Object.keys(PLANTS)) {
|
|
63
|
-
const row = { site: name, fixed: 0, held: 0, missed: 0, spoiled: 0 };
|
|
64
|
-
|
|
65
|
-
// 1. Подсадка, первый шаг ничего не замечает, второй молчит: ловить обязан код.
|
|
66
|
-
{
|
|
67
|
-
const { audit, digest, lang } = await build(name);
|
|
68
|
-
const planted = PLANTS[name].map((p) => plant(audit, lang, p));
|
|
69
|
-
const rev = await reviewAndFix(audit, digest, firstStep(), { lang, askConfirm: silent });
|
|
70
|
-
const after = textMap(audit, lang);
|
|
71
|
-
for (const p of planted) {
|
|
72
|
-
const now = after[p.id] || '';
|
|
73
|
-
const fixed = !numbersOf(now).includes(p.to.replace(/ /g, '')) && now === p.before;
|
|
74
|
-
if (fixed) row.fixed++; else if (!rev.ok) row.held++; else { row.missed++; fail++; console.log(`FAIL ${name} ${p.id}: «${p.to}» прошло молча: «${now}»`); }
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
// 2. Подсадка, первый шаг помечает ещё и верную строку, второй молчит: отчёт удержан с причиной.
|
|
78
|
-
{
|
|
79
|
-
const { audit, digest, lang } = await build(name);
|
|
80
|
-
PLANTS[name].forEach((p) => plant(audit, lang, p));
|
|
81
|
-
const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim' && !PLANTS[name].some((p) => p[0] === s.id));
|
|
82
|
-
const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: silent });
|
|
83
|
-
if (rev.ok || !/не ответил по 1 строке/.test(rev.held || '')) { fail++; console.log(`FAIL ${name}: молчание второго шага не удержало отчёт (${rev.held || 'ok'}), строка ${other.id}, круги ${JSON.stringify(rev.rounds.map((r) => ({ f: r.flagged, c: r.byCode, m: r.missing })))}`); }
|
|
84
|
-
}
|
|
85
|
-
// 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
|
-
}
|
|
100
|
-
// 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
|
|
101
|
-
for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
|
|
102
|
-
const { audit, digest, lang } = await build(name);
|
|
103
|
-
const mine = textMap(audit, lang);
|
|
104
|
-
await reviewAndFix(audit, digest, first, { lang, askConfirm: confirm });
|
|
105
|
-
const after = textMap(audit, lang);
|
|
106
|
-
for (const [id, t] of Object.entries(mine)) if (after[id] !== undefined && after[id] !== t) { row.spoiled++; fail++; console.log(`FAIL ${name} (${label}) ${id}: верное «${t}» стало «${after[id]}»`); }
|
|
107
|
-
}
|
|
108
|
-
table.push(row);
|
|
109
|
-
}
|
|
110
|
-
for (const r of table) console.log(`${r.site.padEnd(10)} исправлено ${r.fixed}, удержано ${r.held}, пропущено ${r.missed}, испорчено верных ${r.spoiled}`);
|
|
111
|
-
const total = table.reduce((a, r) => ({ fixed: a.fixed + r.fixed, held: a.held + r.held, missed: a.missed + r.missed }), { fixed: 0, held: 0, missed: 0 });
|
|
112
|
-
console.log(`подсадка: ${total.fixed + total.held} из 20 исправлены или удержаны, пропущено молча ${total.missed}`);
|
|
113
|
-
if (fail) process.exit(1);
|
|
114
|
-
console.log('PASS planted errors');
|
package/src/test-prompts.mjs
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Задания для ИИ-агента: полнота, язык, запрет на выдумки. */
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
5
|
-
import { FIX_ACTIONS } from './fix.mjs';
|
|
6
|
-
import { localiseChecks } from './i18n.mjs';
|
|
7
|
-
|
|
8
|
-
let bad = 0;
|
|
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
|
-
const ok = (n, c) => is(n, Boolean(c), true);
|
|
11
|
-
|
|
12
|
-
const c = (id, status, label, value) => ({ id, status, label, value, group: 'technical' });
|
|
13
|
-
const audit = (checks, lang = 'en') => ({ meta: { host: 'x.ru', lang }, checks });
|
|
14
|
-
|
|
15
|
-
const one = agentPrompt(c('alt', 'bad', 'Подписи к картинкам', '3 из 10 картинок с подписью'), { lang: 'ru' });
|
|
16
|
-
ok('в задании есть точка отсчёта', one.includes('**Сейчас:** 3 из 10'));
|
|
17
|
-
ok('в задании есть само действие', one.includes('**Задача:**'));
|
|
18
|
-
ok('в задании есть признак готовности', one.includes('**Как проверить:**'));
|
|
19
|
-
ok('в задании есть запрет выдумывать', one.includes('не выдумывай факты о бизнесе'));
|
|
20
|
-
|
|
21
|
-
// Даже там, где своего текста нет, все три части на месте
|
|
22
|
-
const generic = agentPrompt(c('xmlrpc', 'warn', 'xmlrpc.php', 'открыт'), { lang: 'ru' });
|
|
23
|
-
ok('общее задание тоже с проверкой', generic.includes('**Как проверить:**'));
|
|
24
|
-
ok('и со своим действием', generic.includes('Закрой точку xmlrpc.php'));
|
|
25
|
-
|
|
26
|
-
// Порядок: сначала провалы, потом предупреждения
|
|
27
|
-
const list = agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
|
|
28
|
-
is('первым идёт провал, а не предупреждение', list[0].id, 'robots');
|
|
29
|
-
is('заданий столько же, сколько открытых проверок', list.length, 2);
|
|
30
|
-
is('пройденные проверки заданий не порождают', agentPrompts(audit([c('og', 'ok', 'OG', 'есть')])).length, 0);
|
|
31
|
-
is('лимит режет список', agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'r', 'з')]), { limit: 1 }).length, 1);
|
|
32
|
-
|
|
33
|
-
// Язык: русский файл по английскому аудиту не должен содержать английских проверок
|
|
34
|
-
const ruFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images carry alt text (30%) across 5 sampled page(s)')], 'en'), { lang: 'ru' });
|
|
35
|
-
ok('название проверки переведено', ruFile.includes('Подписи к картинкам'));
|
|
36
|
-
ok('и значение тоже', !ruFile.includes('images carry alt text'));
|
|
37
|
-
const enFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images')], 'en'), { lang: 'en' });
|
|
38
|
-
ok('английский файл без кириллицы', !/[А-Яа-яЁё]/.test(enFile));
|
|
39
|
-
|
|
40
|
-
// Задания не обещают того, чего мы не знаем
|
|
41
|
-
const contact = agentPrompt(c('conv-messenger', 'bad', 'Мессенджер', 'нет'), { lang: 'ru' });
|
|
42
|
-
ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси владельца/i.test(contact));
|
|
43
|
-
const src = agentPrompt(c('sources', 'bad', 'Источники', '2 из 20'), { lang: 'ru' });
|
|
44
|
-
ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник хуже/i.test(src));
|
|
45
|
-
const af = agentPrompt(c('answer-first', 'bad', 'Ответ в начале', '2 из 20'), { lang: 'ru' });
|
|
46
|
-
ok('запрещает придумывать цифру', /не придумывай её|спроси владельца/.test(af));
|
|
47
|
-
|
|
48
|
-
// Покрытие: у каждой проверки, которую мы беремся чинить, задание осмысленное
|
|
49
|
-
const mechanical = Object.entries(FIX_ACTIONS).filter(([, r]) => r.scope === 'always' || r.scope === 'files');
|
|
50
|
-
const empty = mechanical.filter(([id]) => {
|
|
51
|
-
const p = agentPrompt(c(id, 'bad', id, 'значение'), { lang: 'ru' });
|
|
52
|
-
return !p.includes('**Задача:**') || p.split('**Задача:**')[1].trim().length < 20;
|
|
53
|
-
}).map(([id]) => id);
|
|
54
|
-
is('у каждой чинимой проверки есть внятное задание', empty, []);
|
|
55
|
-
|
|
56
|
-
// Ноль, который означает отсутствие, пишется словами, а не цифрой. 14.09.2026 в доставленном
|
|
57
|
-
// письме стояло «Сейчас: 0 знаков», и человек не понимал, что описания у страницы просто нет.
|
|
58
|
-
const zeros = [
|
|
59
|
-
['description', 'no meta description on the page', /описания у страницы нет/],
|
|
60
|
-
['title', 'no title on the page', /заголовка у страницы нет/],
|
|
61
|
-
['h1', 'no H1 on the page', /заголовка H1 на странице нет/],
|
|
62
|
-
// У robots ноль правил это настоящее измерение, а не отсутствие: словами пишется только
|
|
63
|
-
// пропавшая строка Sitemap, поэтому в образце правил три.
|
|
64
|
-
['robots', '3 disallow rule(s) in 2 agent block(s), no sitemap line', /строки с картой сайта нет/],
|
|
65
|
-
];
|
|
66
|
-
const nowLine = (text) => (text.split('\n').find((l) => l.startsWith('**Сейчас:**')) || '');
|
|
67
|
-
for (const [id, value, expect] of zeros) {
|
|
68
|
-
const localised = renderAgentPrompts(audit([c(id, 'bad', id, value)], 'en'), { lang: 'ru' });
|
|
69
|
-
const now = nowLine(localised);
|
|
70
|
-
ok(`${id}: по-английски ноль не выводится цифрой`, !/(^|\s)0 (chars|H1|sitemap)/.test(value));
|
|
71
|
-
ok(`${id}: по-русски отсутствие названо словами`, expect.test(now));
|
|
72
|
-
// Проверяем именно строку «Сейчас»: в тексте задачи числа законны («в 120-160 знаков»).
|
|
73
|
-
ok(`${id}: в строке «Сейчас» нет обрывка с нулём`, !/(^|\s)0\s/.test(now));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Шапка файла объясняет, как этим пользоваться
|
|
77
|
-
const head = renderAgentPrompts(audit([c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
|
|
78
|
-
// Проверяем смысл, а не точную формулировку: шапку переписывали под покупателя без терминала.
|
|
79
|
-
ok('шапка объясняет, что копировать задачу целиком', /задачу целиком/i.test(head) && /Сейчас/.test(head) && /Как проверить/.test(head));
|
|
80
|
-
ok('и предупреждает делать по одной', /по одной за раз/i.test(head));
|
|
81
|
-
ok('и обещает, что код знать не нужно', /кода знать не нужно/i.test(head));
|
|
82
|
-
ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
|
|
83
|
-
|
|
84
|
-
// ---- первая правка на бесплатной странице тремя частями.
|
|
85
|
-
// Список id берётся из исходника бесплатной проверки, а не из головы: новая находка без
|
|
86
|
-
// сопоставления уронит этот тест, а не оставит покупателя без задачи молча.
|
|
87
|
-
{
|
|
88
|
-
const src = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|
|
89
|
-
const ids = [...new Set([...src.matchAll(/id: '([a-z0-9-]+)', level: (?!'na')/g)].map((m) => m[1]))];
|
|
90
|
-
ok(`находок бесплатной проверки в исходнике больше десяти (${ids.length})`, ids.length > 10);
|
|
91
|
-
const missing = [];
|
|
92
|
-
for (const id of ids) for (const lang of ['ru', 'en']) {
|
|
93
|
-
const p = firstFixParts({ id, area: 'x', text: 'что-то измерено' }, { lang });
|
|
94
|
-
if (!p || !p.now || p.task.length < 30 || p.verify.length < 20 || /null|undefined/.test(JSON.stringify(p))) missing.push(`${id}/${lang}`);
|
|
95
|
-
else if (lang === 'en' && /[А-Яа-яЁё]/.test(p.task + p.verify + p.rule + Object.values(p.labels).join(''))) missing.push(`${id}/en: кириллица`);
|
|
96
|
-
}
|
|
97
|
-
is('у каждой находки бесплатной проверки есть три части на обоих языках', missing, []);
|
|
98
|
-
is('и в списке экспорта нет лишних id, которых проверка не выдаёт', FIRST_FIX_IDS.filter((id) => !ids.includes(id)), []);
|
|
99
|
-
is('неизвестная находка даёт null, а не чужое указание', firstFixParts({ id: 'что-то-новое', text: 'x' }, { lang: 'ru' }), null);
|
|
100
|
-
is('находка без текста даёт null', firstFixParts({ id: 'llms-missing', text: '' }, { lang: 'ru' }), null);
|
|
101
|
-
is('пустой вход даёт null', firstFixParts(null), null);
|
|
102
|
-
const p = firstFixParts({ id: 'robots-fetchers-blocked', area: 'Пускает ли роботов ИИ', text: 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.' }, { lang: 'ru' });
|
|
103
|
-
is('«сейчас» это сам текст находки', p.now, 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.');
|
|
104
|
-
ok('задача про открытие поисковых роботов', /Открой в robots\.txt/.test(p.task));
|
|
105
|
-
ok('подписи на русском', p.labels.now === 'Сейчас' && p.labels.task === 'Задача' && p.labels.verify === 'Как проверить');
|
|
106
|
-
ok('правило про выдумки на месте', /не выдумывай факты/.test(p.rule));
|
|
107
|
-
const e = firstFixParts({ id: 'llms-missing', text: 'No llms.txt.' }, { lang: 'en' });
|
|
108
|
-
ok('английские подписи', e.labels.now === 'Now' && e.labels.verify === 'How to check');
|
|
109
|
-
ok('незнакомый язык падает в английский', firstFixParts({ id: 'llms-missing', text: 'x' }, { lang: 'de' }).labels.now === 'Now');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/*
|
|
113
|
-
* У каждой проверки, которая может выйти находкой, своё задание. 01.10.2026 в первых настоящих
|
|
114
|
-
* выдачах (dial93.ru, bristolpropertycentre.co.uk) «Задача» у части проверок просто повторяла
|
|
115
|
-
* название, а «Как проверить» была общей фразой «прогони проверку заново». Список проверок берём
|
|
116
|
-
* прямо из collect.mjs, чтобы новая проверка без задания роняла этот тест.
|
|
117
|
-
*/
|
|
118
|
-
{
|
|
119
|
-
const src = readFileSync(new URL('./collect.mjs', import.meta.url), 'utf8');
|
|
120
|
-
const rows = [...src.matchAll(/row\(\s*['`]([^'`]+)['`]\s*,\s*'([a-z]+)'\s*,\s*['`"]([^'`"]*)['`"]/g)]
|
|
121
|
-
.map((m) => ({ id: m[1].replace('${e.id}', 'chatgpt'), label: m[3].replace('${e.label}', 'ChatGPT') }));
|
|
122
|
-
rows.push({ id: 'js-content', label: 'Text visible without running JavaScript' });
|
|
123
|
-
// Эти строки выходят только со статусом na (справка или осознанный выбор), находкой не бывают.
|
|
124
|
-
const NEVER_OPEN = new Set(['cms', 'robots-ai', 'howto-schema', 'product-schema', 'hreflang', 'links-profile']);
|
|
125
|
-
ok('список проверок из collect.mjs прочитан', rows.length > 40);
|
|
126
|
-
const field = (text, name) => (text.match(new RegExp(`\\*\\*${name}:\\*\\* (.*)`)) || [])[1] || '';
|
|
127
|
-
const empty = [];
|
|
128
|
-
for (const r of rows.filter((x) => !NEVER_OPEN.has(x.id))) {
|
|
129
|
-
for (const lang of ['ru', 'en']) {
|
|
130
|
-
const [chk] = lang === 'ru' ? localiseChecks([c(r.id, 'warn', r.label, 'x')], 'ru') : [c(r.id, 'warn', r.label, 'x')];
|
|
131
|
-
const t = agentPrompt(chk, { lang });
|
|
132
|
-
const task = field(t, lang === 'ru' ? 'Задача' : 'Task').trim();
|
|
133
|
-
const verify = field(t, lang === 'ru' ? 'Как проверить' : 'How to check').trim();
|
|
134
|
-
const generic = lang === 'ru' ? /^Прогони проверку сайта заново/ : /^Run the site check again/;
|
|
135
|
-
if (!task || task === chk.label || task === r.label || task.length < 60 || !verify || generic.test(verify)) empty.push(`${r.id}/${lang}`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
is('у каждой проверки своё задание и своя проверка, не название', empty, []);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
142
|
-
console.log('\nвсе тесты заданий прошли');
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Фраза про короткий ответ зависит от числа страниц (задание 26.09.2026): «Not one page» только при нуле.
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { prospectRow } from './prospect.mjs';
|
|
4
|
-
const audit = (value, status) => ({ meta: { host: 'a.test' }, scores: {}, checks: [{ id: 'answer-first', status, label: 'Answer-first opening paragraph', value }] });
|
|
5
|
-
const warn = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'));
|
|
6
|
-
assert.equal(warn.say, 'Only 5 of your 8 checked pages open with a short direct answer, and that opening is exactly what gets quoted.');
|
|
7
|
-
const bad = prospectRow(audit('0 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'bad'));
|
|
8
|
-
assert.match(bad.say, /^Not one page opens/);
|
|
9
|
-
const ru = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'), { lang: 'ru' });
|
|
10
|
-
assert.equal(ru.say, 'Короткий прямой ответ в первом абзаце есть только на 5 из 8 проверенных страниц, а цитируют именно его.');
|
|
11
|
-
assert.match(prospectRow(audit('', 'bad')).say, /^Not one page opens/, 'без числа прежняя фраза');
|
|
12
|
-
console.log('test-prospect-count: ok');
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Голый домен, который отвечает 301 на www, должен читаться так же, как сам www.
|
|
4
|
-
*
|
|
5
|
-
* 30.09.2026 bristolpropertycentre.co.uk получил в отчёте «no sitemap found at the usual paths» и
|
|
6
|
-
* «1 pages sampled»: robots.txt и карта сайта брались с введённого адреса без перехода по 301, а
|
|
7
|
-
* выборка в бесплатной проверке отсеивала страницы карты, потому что они на www. Тот же сайт,
|
|
8
|
-
* введённый с www, давал сто страниц. Здесь это держится на живом сервере: lvh.me и www.lvh.me оба
|
|
9
|
-
* указывают на 127.0.0.1, и сервер по заголовку Host отправляет голый домен на www.
|
|
10
|
-
*/
|
|
11
|
-
import { createServer } from 'node:http';
|
|
12
|
-
import { lookup } from 'node:dns/promises';
|
|
13
|
-
import { collect } from './collect.mjs';
|
|
14
|
-
import { checkVisibility } from './visibility.mjs';
|
|
15
|
-
|
|
16
|
-
let bad = 0;
|
|
17
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
18
|
-
|
|
19
|
-
let dnsOk = true;
|
|
20
|
-
try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1' && (await lookup('www.lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
21
|
-
if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
|
|
22
|
-
|
|
23
|
-
const PAGES = ['/', '/buy/', '/rent/', '/sell/', '/about/', '/contact/'];
|
|
24
|
-
const page = (title) => `<!doctype html><html lang="en"><head><title>${title}</title>
|
|
25
|
-
<meta name="description" content="A page description long enough not to count as empty for the check.">
|
|
26
|
-
</head><body><main><h1>${title}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}</main></body></html>`;
|
|
27
|
-
|
|
28
|
-
const server = createServer((req, res) => {
|
|
29
|
-
const host = (req.headers.host || '').toLowerCase();
|
|
30
|
-
const www = `http://www.${host.replace(/^www\./, '')}`;
|
|
31
|
-
if (!host.startsWith('www.')) { res.writeHead(301, { location: `${www}${req.url}` }); return res.end(); }
|
|
32
|
-
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
|
|
33
|
-
if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
|
|
34
|
-
if (req.url === '/llms.txt') return send(200, 'text/plain', `# Site\n\n- [Buy](${www}/buy/)\n`);
|
|
35
|
-
if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
|
|
36
|
-
if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(`Page ${req.url}`));
|
|
37
|
-
return send(404, 'text/plain', 'no');
|
|
38
|
-
});
|
|
39
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
40
|
-
const port = server.address().port;
|
|
41
|
-
const apex = `http://lvh.me:${port}/`;
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const a = await collect(apex, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
45
|
-
const check = (id) => a.checks.find((c) => c.id === id) || {};
|
|
46
|
-
ok('конечный адрес сайта на www', a.meta.site.startsWith(`http://www.lvh.me:${port}`));
|
|
47
|
-
ok(`robots.txt прочитан через 301 (${check('robots').status}: ${check('robots').value})`, check('robots').status === 'ok');
|
|
48
|
-
ok(`карта сайта найдена (${check('sitemap').value})`, check('sitemap').status === 'ok' && /6 URL/.test(check('sitemap').value));
|
|
49
|
-
ok('карта не считается чужим хостом', !a.checks.some((c) => c.id === 'sitemap-foreign'));
|
|
50
|
-
ok(`llms.txt прочитан через 301 (${check('llms').value})`, check('llms').status === 'ok');
|
|
51
|
-
ok('в обзоре шесть страниц в карте', a.overview.rows.find((r) => r[0] === 'Pages in sitemap')?.[1] === '6');
|
|
52
|
-
const sampled = Number((a.sources[0].detail.match(/homepage and (\d+) sampled/) || [])[1]);
|
|
53
|
-
ok(`выборка не одна главная (${sampled} страниц)`, sampled >= 5);
|
|
54
|
-
ok(`www-канонизация названа от голого домена к www (${check('www').value})`, /^lvh\.me:\d+ redirects to www\.lvh\.me/.test(check('www').value));
|
|
55
|
-
|
|
56
|
-
const v = await checkVisibility(apex, { lang: 'en', samplePages: 5, budgetMs: 8000 });
|
|
57
|
-
ok('бесплатная проверка прочитала сайт', v.ok);
|
|
58
|
-
ok(`бесплатная проверка нашла карту (${v.sitemap?.count})`, v.sitemap?.found && v.sitemap.count === 6);
|
|
59
|
-
ok(`в выборке не только главная (${v.sample?.length} страниц)`, (v.sample?.length || 0) >= 3);
|
|
60
|
-
} finally { server.close(); }
|
|
61
|
-
|
|
62
|
-
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
63
|
-
console.log('\nвсё сходится');
|