@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-agency-ids.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/** Идентификаторы проверок, снятые с настоящего прогона: тест сверяет с ними фразы владельцу,
|
|
2
|
-
* чтобы переименованная проверка не оставила строку, которая никогда не сработает. */
|
|
3
|
-
export const CHECK_IDS = [
|
|
4
|
-
"agent-card",
|
|
5
|
-
"agent-markdown",
|
|
6
|
-
"ai-search-access",
|
|
7
|
-
"alt",
|
|
8
|
-
"answer-first",
|
|
9
|
-
"api-catalog",
|
|
10
|
-
"canonical",
|
|
11
|
-
"conv-contact-path",
|
|
12
|
-
"conv-cta",
|
|
13
|
-
"conv-form-depth",
|
|
14
|
-
"conv-form-fields",
|
|
15
|
-
"conv-messenger",
|
|
16
|
-
"dates",
|
|
17
|
-
"description",
|
|
18
|
-
"dup-titles",
|
|
19
|
-
"faq-schema",
|
|
20
|
-
"h1",
|
|
21
|
-
"h1-sample",
|
|
22
|
-
"hreflang",
|
|
23
|
-
"https",
|
|
24
|
-
"llms",
|
|
25
|
-
"llms-health",
|
|
26
|
-
"og",
|
|
27
|
-
"org-schema",
|
|
28
|
-
"resources",
|
|
29
|
-
"robots",
|
|
30
|
-
"robots-ai",
|
|
31
|
-
"schema",
|
|
32
|
-
"sections",
|
|
33
|
-
"sitemap",
|
|
34
|
-
"sitemap-health",
|
|
35
|
-
"sources",
|
|
36
|
-
"tables",
|
|
37
|
-
"thin",
|
|
38
|
-
"title",
|
|
39
|
-
"trust-about",
|
|
40
|
-
"trust-contact",
|
|
41
|
-
"trust-entity",
|
|
42
|
-
"trust-policies",
|
|
43
|
-
"trust-profiles",
|
|
44
|
-
"ttfb",
|
|
45
|
-
"utility",
|
|
46
|
-
"viewport",
|
|
47
|
-
"www"
|
|
48
|
-
];
|
package/src/test-agency.mjs
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Правило, которое защищает этот файл: платное оформление не включается без лицензии, а
|
|
3
|
-
* бесплатная подпись работает всегда. Если сломать первое, агентский план перестанет продаваться,
|
|
4
|
-
* если второе, отчёт перестанет подписываться именем покупателя.
|
|
5
|
-
*
|
|
6
|
-
* Ключ здесь генерируется прямо в тесте одноразовой парой Ed25519: настоящий приватный ключ в
|
|
7
|
-
* репозитории не лежит и лежать не должен.
|
|
8
|
-
*/
|
|
9
|
-
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
10
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
11
|
-
import { tmpdir } from 'node:os';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { resolveBranding, verifyKey, softenHex } from './agency.mjs';
|
|
14
|
-
import { toHtml } from './render.mjs';
|
|
15
|
-
|
|
16
|
-
let failed = 0;
|
|
17
|
-
const ok = (name, cond) => { if (cond) console.log(`ok ${name}`); else { failed++; console.error(`FAIL ${name}`); } };
|
|
18
|
-
|
|
19
|
-
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
20
|
-
const pub = publicKey.export({ type: 'spki', format: 'pem' });
|
|
21
|
-
const makeKey = (data) => {
|
|
22
|
-
const payload = Buffer.from(JSON.stringify(data));
|
|
23
|
-
return `OSK1.${payload.toString('base64url')}.${sign(null, payload, privateKey).toString('base64url')}`;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// Проверка подписи
|
|
27
|
-
ok('правильный ключ принимается', verifyKey(makeKey({ email: 'a@b.co', plan: 'agency' }), pub).ok);
|
|
28
|
-
ok('мусор отвергается', !verifyKey('OSK1.aaa.bbb', pub).ok);
|
|
29
|
-
// Портить надо байты, а не символы. В base64url последний символ кодирует не все шесть бит,
|
|
30
|
-
// поэтому несколько разных символов дают одни и те же байты, и подпись оставалась верной
|
|
31
|
-
// примерно в каждом пятом прогоне. Летучий тест хуже, чем никакого.
|
|
32
|
-
const tampered = (k) => {
|
|
33
|
-
const [p, payload, sig] = k.split('.');
|
|
34
|
-
const bytes = Buffer.from(sig, 'base64url');
|
|
35
|
-
bytes[0] ^= 0xff;
|
|
36
|
-
return `${p}.${payload}.${bytes.toString('base64url')}`;
|
|
37
|
-
};
|
|
38
|
-
ok('чужая подпись отвергается', !verifyKey(tampered(makeKey({ plan: 'agency' })), pub).ok);
|
|
39
|
-
ok('просроченный план отвергается', !verifyKey(makeKey({ plan: 'agency', expires: '2020-01-01' }), pub).ok);
|
|
40
|
-
ok('ключ другого продукта отвергается', !verifyKey(makeKey({ plan: 'site-kit' }), pub).ok);
|
|
41
|
-
ok('срок в будущем проходит', verifyKey(makeKey({ plan: 'agency', expires: '2999-01-01' }), pub).ok);
|
|
42
|
-
|
|
43
|
-
// Что бесплатно, а что нет
|
|
44
|
-
const free = resolveBranding({ by: 'Harper & Vale' });
|
|
45
|
-
ok('подпись именем работает без лицензии', free.preparedBy === 'Harper & Vale' && free.notes.length === 0);
|
|
46
|
-
ok('без платных опций строка об инструменте остаётся', free.showToolLine === true);
|
|
47
|
-
|
|
48
|
-
const blocked = resolveBranding({ by: 'X', color: '#123456', toolLine: false });
|
|
49
|
-
ok('без лицензии цвет не применяется', !blocked.accent && blocked.showToolLine === true);
|
|
50
|
-
ok('и человеку сказано почему', blocked.notes.some((n) => /agency plan/i.test(n)));
|
|
51
|
-
|
|
52
|
-
// Цвет
|
|
53
|
-
ok('светлая версия цвета светлее исходной', parseInt(softenHex('#0b7a75').slice(1, 3), 16) > 0x0b);
|
|
54
|
-
ok('кривой цвет не проходит', resolveBranding({ color: 'нет', licence: makeKey({ plan: 'agency' }) }).accent === '');
|
|
55
|
-
|
|
56
|
-
// Вёрстка
|
|
57
|
-
const audit = {
|
|
58
|
-
meta: { host: 'example.com', tool: '@operstack/audit 0.0.0', collectedAt: '2026-01-01T00:00:00Z', auditType: 'External', lang: 'en' },
|
|
59
|
-
client: { name: 'Client', subject: 'Sells things', reportDate: '2026-01-01', preparedBy: 'OperStack' },
|
|
60
|
-
scores: {}, scoreBasis: {}, summary: { lead: 'a', verdict: 'b', priorities: ['c'] },
|
|
61
|
-
overview: { rows: [], note: '' }, sample: [], checks: [], critical: [],
|
|
62
|
-
content: { strengths: [], weaknesses: [], gaps: [] },
|
|
63
|
-
aeo: { works: [], blocks: [], recommendation: '' }, geo: { rows: [], callout: '' },
|
|
64
|
-
offpage: { listed: [], note: '' }, conversion: { rows: [] }, sources: [], limitations: [],
|
|
65
|
-
roadmap: [], closing: 'done',
|
|
66
|
-
};
|
|
67
|
-
const plain = toHtml(audit);
|
|
68
|
-
ok('без оформления на обложке стоит наше имя', plain.includes('OperStack') && !plain.includes('<img class="cover-logo"'));
|
|
69
|
-
|
|
70
|
-
const branded = toHtml(audit, { preparedBy: 'Harper & Vale Digital', logo: 'data:image/svg+xml;base64,AAA', accent: '#7b3fa0', showToolLine: false });
|
|
71
|
-
ok('имя агентства попало на обложку', branded.includes('Harper & Vale Digital'));
|
|
72
|
-
ok('логотип попал на обложку', branded.includes('<img class="cover-logo"'));
|
|
73
|
-
ok('цвет агентства подменил акцент', branded.includes('--accent:#7b3fa0'));
|
|
74
|
-
ok('строка про инструмент убрана', !branded.includes('@operstack/audit 0.0.0'));
|
|
75
|
-
ok('но воспроизводимость обещана по-прежнему', /reproduced from it/i.test(branded));
|
|
76
|
-
ok('нашего имени в фирменном отчёте не осталось', !/OperStack/.test(branded));
|
|
77
|
-
|
|
78
|
-
// Список сайтов
|
|
79
|
-
const { parseList } = await import('./batch.mjs');
|
|
80
|
-
const list = parseList('# комментарий\n\nexample.com, Northline Dental\nhttps://two.example\nне адрес\n');
|
|
81
|
-
ok('список разбирается', list.length === 3 && list[0].name === 'Northline Dental');
|
|
82
|
-
ok('адрес без схемы достраивается', list[0].url.startsWith('https://example.com'));
|
|
83
|
-
ok('строка-мусор помечена, а не роняет прогон', list[2].invalid === true);
|
|
84
|
-
|
|
85
|
-
// Ключ из файла рядом с работой
|
|
86
|
-
const dir = mkdtempSync(join(tmpdir(), 'operstack-agency-'));
|
|
87
|
-
writeFileSync(join(dir, '.operstack-licence'), makeKey({ plan: 'agency', email: 'x@y.z' }));
|
|
88
|
-
const cwd = process.cwd();
|
|
89
|
-
process.chdir(dir);
|
|
90
|
-
const { findKey } = await import('./agency.mjs');
|
|
91
|
-
ok('ключ читается из файла .operstack-licence', findKey().startsWith('OSK1.'));
|
|
92
|
-
process.chdir(cwd);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// ── Разведка кандидатов ───────────────────────────────────────────────────────
|
|
96
|
-
// Правило: в письмо никогда не попадает строка, которую владелец бизнеса не поймёт, и
|
|
97
|
-
// ни одна фраза не ссылается на проверку, которой в сборщике нет.
|
|
98
|
-
const { OWNER_LINES, overallScore, prospectRow, rank, toCsv, toMarkdown } = await import('./prospect.mjs');
|
|
99
|
-
const { CHECK_IDS } = await import('./test-agency-ids.mjs');
|
|
100
|
-
|
|
101
|
-
const unknown = Object.keys(OWNER_LINES).filter((id) => !CHECK_IDS.includes(id));
|
|
102
|
-
ok('каждая фраза ссылается на существующую проверку', unknown.length === 0);
|
|
103
|
-
ok('у каждой фразы есть оба языка', Object.values(OWNER_LINES).every((v) => v.en && v.ru));
|
|
104
|
-
|
|
105
|
-
ok('неизмеренная область не тянет оценку вниз', overallScore({ a: 10, b: null }) === 100);
|
|
106
|
-
ok('пустые оценки дают не ноль, а «не измеряли»', overallScore({}) === null);
|
|
107
|
-
|
|
108
|
-
const mk = (id, status, scores) => ({
|
|
109
|
-
meta: { host: `${id}.example` }, client: { name: id }, scores,
|
|
110
|
-
checks: [{ id, group: 'x', label: 'L', status, value: 'v' }],
|
|
111
|
-
});
|
|
112
|
-
const weak = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
|
|
113
|
-
ok('перекрытый доступ роботов ИИ попадает в письмо', /ChatGPT/.test(weak.say));
|
|
114
|
-
ok('провал посчитан', weak.failing === 1 && weak.warning === 0);
|
|
115
|
-
|
|
116
|
-
const dull = prospectRow(mk('utility', 'warn', { a: 9 }));
|
|
117
|
-
const sharp = prospectRow({
|
|
118
|
-
meta: { host: 'x.example' }, client: { name: 'x' }, scores: { a: 5 },
|
|
119
|
-
checks: [
|
|
120
|
-
{ id: 'utility', group: 'x', label: 'L', status: 'warn', value: 'v' },
|
|
121
|
-
{ id: 'ai-search-access', group: 'x', label: 'L', status: 'warn', value: 'v' },
|
|
122
|
-
],
|
|
123
|
-
});
|
|
124
|
-
ok('при равной тяжести в письмо идёт сильная находка, а не первая', /ChatGPT/.test(sharp.say));
|
|
125
|
-
ok('слабая находка всё равно объяснена по-человечески', !/^utility/.test(dull.say));
|
|
126
|
-
|
|
127
|
-
const ranked = rank([prospectRow(mk('alt', 'warn', { a: 9 })), weak, { ...dull, score: null }]);
|
|
128
|
-
ok('сначала идёт тот, у кого хуже', ranked[0].score < ranked[1].score);
|
|
129
|
-
ok('сайт без оценки уходит в конец, а не наверх', ranked[ranked.length - 1].score === null);
|
|
130
|
-
|
|
131
|
-
const csv = toCsv([weak]);
|
|
132
|
-
ok('в CSV есть заголовок и строка', csv.split('\n').length === 2 && csv.startsWith('site,name,score'));
|
|
133
|
-
ok('запятая внутри фразы не ломает CSV', toCsv([{ ...weak, say: 'a, b' }]).includes('"a, b"'));
|
|
134
|
-
ok('в таблице сказано, что строки измерены, а не придуманы', /measured check, not an opinion/.test(toMarkdown([weak])));
|
|
135
|
-
|
|
136
|
-
const russian = prospectRow(mk('ai-search-access', 'bad', { a: 2 }), { lang: 'ru' });
|
|
137
|
-
ok('русская фраза приходит по-русски', /Perplexity не могут/.test(russian.say));
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// Три дыры, найденные прогоном по списку, который специально собран из ломающего.
|
|
141
|
-
const dead = prospectRow({ meta: { host: 'gone.example', reachable: false }, scores: {}, checks: [] });
|
|
142
|
-
ok('мёртвый сайт помечен, а не выдан как кандидат', dead.reachable === false);
|
|
143
|
-
const { split } = await import('./prospect.mjs');
|
|
144
|
-
const parts = split([dead, prospectRow(mk('alt', 'warn', { a: 9 }))]);
|
|
145
|
-
ok('мёртвые не попадают в таблицу кандидатов', parts.live.length === 1 && parts.dead.length === 1);
|
|
146
|
-
ok('и названы отдельным списком', /Did not answer/.test(toMarkdown([dead, prospectRow(mk('alt', 'warn', { a: 9 }))])));
|
|
147
|
-
ok('мёртвого нет в CSV', !toCsv([dead]).includes('gone.example'));
|
|
148
|
-
|
|
149
|
-
const bad = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
|
|
150
|
-
ok('статус bad считается провалом, а не пропускается', bad.failing === 1);
|
|
151
|
-
ok('и тяжёлая находка доходит до письма', /ChatGPT/.test(bad.say));
|
|
152
|
-
|
|
153
|
-
const twice = rank([prospectRow(mk('alt', 'warn', { a: 9 })), prospectRow(mk('alt', 'warn', { a: 9 }))]);
|
|
154
|
-
ok('один сайт в списке дважды даёт одну строку', twice.length === 1);
|
|
155
|
-
|
|
156
|
-
console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
|
|
157
|
-
process.exit(failed ? 1 : 0);
|
|
158
|
-
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Агентная поверхность: три проверки, их перевод, работа и задание для агента. */
|
|
3
|
-
import { FIX_ACTIONS } from './fix.mjs';
|
|
4
|
-
import { localiseChecks } from './i18n.mjs';
|
|
5
|
-
import { agentPrompt } from './prompts.mjs';
|
|
6
|
-
import { draftNarrative } from './narrative.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 ids = ['agent-card', 'api-catalog', 'agent-markdown'];
|
|
13
|
-
|
|
14
|
-
// У каждой проверки есть решение и перевод
|
|
15
|
-
for (const id of ids) ok(`у проверки ${id} описано, что делать или почему не делаем`, Boolean(FIX_ACTIONS[id] && (FIX_ACTIONS[id].action || FIX_ACTIONS[id].why)));
|
|
16
|
-
const ru = localiseChecks(ids.map((id) => ({ id, group: 'geo', label: 'English label', status: 'warn', value: 'not present', comment: 'an early, optional standard: it is the file agent directories read to learn who you are and what you offer' })), 'ru');
|
|
17
|
-
for (const r of ru) ok(`проверка ${r.id} переведена`, !/English label/.test(r.label) && !/not present/.test(r.value));
|
|
18
|
-
|
|
19
|
-
// Каталог API мы не обещаем чинить: он нужен не всем
|
|
20
|
-
is('каталог API не обещаем', FIX_ACTIONS['api-catalog'].scope, 'never');
|
|
21
|
-
ok('и объяснено почему', /есть API/.test(FIX_ACTIONS['api-catalog'].why.ru));
|
|
22
|
-
|
|
23
|
-
// Задания для агента полные и не врут про обязательность
|
|
24
|
-
const card = agentPrompt({ id: 'agent-card', label: 'Карточка агента', status: 'warn', value: 'нет' }, { lang: 'ru' });
|
|
25
|
-
ok('в задании про карточку есть проверка', /Как проверить/.test(card));
|
|
26
|
-
ok('и не велит делать карточку без агента', /только правду|выдуманная возможность|ради галочки нельзя/.test(card));
|
|
27
|
-
const md = agentPrompt({ id: 'agent-markdown', label: 'Markdown-версия', status: 'warn', value: 'нет' }, { lang: 'ru' });
|
|
28
|
-
ok('в задании про markdown сказано, что класть, а что нет', /меню и подвал/.test(md));
|
|
29
|
-
|
|
30
|
-
// В отчёте это не превращается в приговор: стандарт ранний
|
|
31
|
-
const audit = {
|
|
32
|
-
meta: { host: 'x.ru', lang: 'ru' },
|
|
33
|
-
scores: { 'GEO, visibility in AI systems': 7 },
|
|
34
|
-
client: { name: '{{a}}', subject: '{{b}}' },
|
|
35
|
-
summary: { lead: '{{a}}', verdict: '{{b}}', priorities: ['{{1}}'] },
|
|
36
|
-
overview: { rows: [], note: '{{n}}' }, content: { strengths: [], weaknesses: [], gaps: [] },
|
|
37
|
-
aeo: { works: [], blocks: [], recommendation: '{{r}}' }, geo: { rows: [], callout: '{{c}}' },
|
|
38
|
-
offpage: { listed: [], note: '{{n}}' }, conversion: { rows: [] }, roadmap: [], closing: '{{c}}',
|
|
39
|
-
sample: [], checks: [{ id: 'agent-card', group: 'geo', label: 'Карточка агента', status: 'warn', value: 'нет' }],
|
|
40
|
-
};
|
|
41
|
-
const d = draftNarrative(audit, { lang: 'ru' });
|
|
42
|
-
ok('в приоритетах появляется работа по карточке', /agent\.json|карточк/i.test(JSON.stringify(d.summary.priorities)));
|
|
43
|
-
ok('и вывод не называет это провалом', !/критичн/i.test(d.summary.verdict) || /нет/.test(d.summary.verdict));
|
|
44
|
-
|
|
45
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
46
|
-
console.log('\nвсе тесты агентной поверхности прошли');
|
package/src/test-blocked.mjs
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Сайт нас не пустил. Что мы имеем право сказать про него владельцу.
|
|
4
|
-
*
|
|
5
|
-
* До 15.09.2026 проверка писала: «Роботы ИИ её не проходят, поэтому ChatGPT, Perplexity и
|
|
6
|
-
* остальных разворачивают ровно так же». Это вывод о ЧУЖОМ сайте, сделанный из того, что
|
|
7
|
-
* развернули НАС. Замерено руками на alternativeto.net: тот же адрес отдаёт GPTBot, ClaudeBot
|
|
8
|
-
* и PerplexityBot полную страницу (200, около 900 КБ), а нашей проверке 403. Защита держит
|
|
9
|
-
* белый список проверенных роботов.
|
|
10
|
-
*
|
|
11
|
-
* Здесь держится граница: говорим, что не пустили нас, и что написано в правилах сайта.
|
|
12
|
-
* Про поведение защиты не гадаем.
|
|
13
|
-
*/
|
|
14
|
-
import { blockedNote } 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
|
-
const OPEN = 'User-agent: *\nDisallow: /admin/\n';
|
|
20
|
-
const CLOSED = 'User-agent: GPTBot\nDisallow: /\n\nUser-agent: ClaudeBot\nDisallow: /\n';
|
|
21
|
-
|
|
22
|
-
// ---- главное: ни при каких вводных не утверждать, что роботов ИИ развернули
|
|
23
|
-
{
|
|
24
|
-
for (const lang of ['ru', 'en']) {
|
|
25
|
-
for (const [what, robots] of [['правила открыты', OPEN], ['правила закрыты', CLOSED], ['robots не прочитан', null]]) {
|
|
26
|
-
for (const challenged of [true, false]) {
|
|
27
|
-
const t = blockedNote({ status: 403, challenged, robotsText: robots, lang }).text;
|
|
28
|
-
ok(`${lang}/${what}/${challenged ? 'защита' : 'отказ'}: не утверждаем, что роботов ИИ развернули`, lang === 'ru'
|
|
29
|
-
? !/(разворачивают|не проходят|получают тот же ответ|процитировать (здесь|с сайта) нельзя|нечего)/.test(t.replace('сказать нечего', ''))
|
|
30
|
-
: !/(turned away|do not run one|get the same answer|nothing here can be read)/.test(t));
|
|
31
|
-
ok(`${lang}/${what}/${challenged ? 'защита' : 'отказ'}: сказано, как проверить точно`, lang === 'ru' ? /журнале сервера/.test(t) : /server log/.test(t));
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ---- что именно говорим в каждом из трёх случаев
|
|
38
|
-
{
|
|
39
|
-
const ru = (robots) => blockedNote({ status: 403, challenged: true, robotsText: robots, lang: 'ru' });
|
|
40
|
-
const en = (robots) => blockedNote({ status: 403, challenged: true, robotsText: robots, lang: 'en' });
|
|
41
|
-
|
|
42
|
-
const open = ru(OPEN);
|
|
43
|
-
ok('правила открыты: сказано, что пускают', /правила самого сайта роботов ИИ при этом пускают/i.test(open.text));
|
|
44
|
-
ok('правила открыты: сказано, что не зачтено в минус', /не зачтено/.test(open.text));
|
|
45
|
-
ok('правила открыты: названы конкретные роботы', /OAI-SearchBot|GPTBot/.test(open.text));
|
|
46
|
-
ok('правила открыты: поле говорит «не закрыты»', open.botsBlocked === false);
|
|
47
|
-
|
|
48
|
-
const closed = ru(CLOSED);
|
|
49
|
-
ok('правила закрыты: названо письменным отказом', /письменный отказ/.test(closed.text));
|
|
50
|
-
ok('правила закрыты: названы именно закрытые', /GPTBot/.test(closed.text) && /Claude/.test(closed.text));
|
|
51
|
-
ok('правила закрыты: поле говорит «закрыты»', closed.botsBlocked === true);
|
|
52
|
-
|
|
53
|
-
const none = ru(null);
|
|
54
|
-
ok('robots не прочитан: молчим про роботов', /про роботов ИИ сказать нечего/.test(none.text));
|
|
55
|
-
ok('robots не прочитан: и это не в минус', /не зачтено/.test(none.text));
|
|
56
|
-
ok('robots не прочитан: поле пустое, а не false', none.botsBlocked === null);
|
|
57
|
-
|
|
58
|
-
ok('английский: правила открыты', /rules do allow the AI crawlers/.test(en(OPEN).text));
|
|
59
|
-
ok('английский: правила закрыты', /rules also close it to AI crawlers/.test(en(CLOSED).text));
|
|
60
|
-
ok('английский: robots не прочитан', /robots\.txt could not be read/.test(en(null).text));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ---- защита и простой отказ описываются по-разному: лечатся они тоже по-разному
|
|
64
|
-
{
|
|
65
|
-
const ch = blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'ru' }).text;
|
|
66
|
-
const wall = blockedNote({ status: 403, challenged: false, robotsText: OPEN, lang: 'ru' }).text;
|
|
67
|
-
ok('защита названа защитой браузера', /проверкой браузера \(Cloudflare/.test(ch));
|
|
68
|
-
ok('простой отказ назван отказом незнакомым клиентам', /отказывает незнакомым клиентам/.test(wall));
|
|
69
|
-
ok('код ответа назван в обоих', /403/.test(ch) && /403/.test(wall));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ---- чужого языка в тексте нет: его читает человек
|
|
73
|
-
{
|
|
74
|
-
const ruText = blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'ru' }).text;
|
|
75
|
-
// Имена роботов это их настоящие идентификаторы, они латиницей и на русском тоже: ChatGPT-User
|
|
76
|
-
// нельзя перевести, иначе владелец не найдёт его в журнале сервера. Вычёркиваем их и смотрим,
|
|
77
|
-
// не осталось ли непереведённых слов.
|
|
78
|
-
const bare = ruText.replace(/ChatGPT-User|OAI-SearchBot|Claude-SearchBot|Claude-User|Applebot-Extended|Google-Extended|anthropic-ai|PerplexityBot|Perplexity-User|Cloudflare|GPTBot|ClaudeBot|ChatGPT|OpenAI|Perplexity|Claude|robots\.txt|Anthropic|Gemini|Bingbot|Copilot|Bing|Apple Intelligence/g, '');
|
|
79
|
-
ok('в русском тексте не осталось английских слов', !/[A-Za-z]{4}/.test(bare));
|
|
80
|
-
ok('в английском нет кириллицы', !/[А-Яа-яЁё]/.test(blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'en' }).text));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---- несуществующий адрес: человек чаще всего просто опечатался
|
|
84
|
-
//
|
|
85
|
-
// Стояло «Сайт ответил ничем на https://…»: и коряво, и бесполезно. Человек, который ошибся в
|
|
86
|
-
// букве, должен узнать об этом первым делом, а не гадать, что с его сайтом.
|
|
87
|
-
{
|
|
88
|
-
const { checkVisibility } = await import('./visibility.mjs');
|
|
89
|
-
for (const lang of ['ru', 'en']) {
|
|
90
|
-
const v = await checkVisibility('https://takogo-domena-tochno-net-4f7a2b9c.ru/', { budgetMs: 8500, samplePages: 3, lang });
|
|
91
|
-
ok(`${lang}: балла у несуществующего адреса нет`, v.ok === false);
|
|
92
|
-
ok(`${lang}: названа вероятная причина, опечатка`, lang === 'ru' ? /опечатка в адресе/.test(v.error) : /typo in the address/.test(v.error));
|
|
93
|
-
ok(`${lang}: сказано, что делать`, lang === 'ru' ? /проверьте написание/.test(v.error) : /check the spelling/.test(v.error));
|
|
94
|
-
ok(`${lang}: нет старого «ответил ничем»`, lang === 'ru' ? !/ответил ничем/.test(v.error) : !/answered nothing/.test(v.error));
|
|
95
|
-
ok(`${lang}: чужого языка нет`, lang === 'ru' ? !/[A-Za-z]{5}/.test(v.error.replace(/https?:\/\/[^\s]+/g, '')) : !/[А-Яа-яЁё]/.test(v.error));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
100
|
-
console.log('\nпро чужой сайт говорим только измеренное');
|
package/src/test-crawl-links.mjs
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Сайт без карты сайта читается по ссылкам, а не одной главной.
|
|
4
|
-
*
|
|
5
|
-
* 30.09.2026 elitelivingrealty.com и solohomes.co.uk (на www нет ни robots.txt, ни карты) дали
|
|
6
|
-
* в отчёте выборку из одной главной. Здесь: разбор ссылок и правил robots.txt отдельно, потом
|
|
7
|
-
* живой сервер без карты, где страницы находятся только по ссылкам, на двух уровнях.
|
|
8
|
-
*/
|
|
9
|
-
import { createServer } from 'node:http';
|
|
10
|
-
import { lookup } from 'node:dns/promises';
|
|
11
|
-
import { internalLinks, robotsAllows } from './visibility.mjs';
|
|
12
|
-
import { collect } from './collect.mjs';
|
|
13
|
-
import { checkVisibility } from './visibility.mjs';
|
|
14
|
-
|
|
15
|
-
let bad = 0;
|
|
16
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
17
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
18
|
-
|
|
19
|
-
// ---- разбор ссылок
|
|
20
|
-
{
|
|
21
|
-
const html = `<a href="/buy/">Buy</a><a href="/buy">dup</a><a href="https://www.x.com/rent/?page=2#top">Rent</a>
|
|
22
|
-
<a href="https://x.com/sell/">apex</a><a href="https://other.com/">other</a><a href="mailto:a@x.com">m</a>
|
|
23
|
-
<a href="tel:+1">t</a><a href="#top">anchor</a><a href="/brochure.pdf">pdf</a><a href="/about.html">about</a>
|
|
24
|
-
<a href="/cart/">cart</a><a href="/tag/villas/">tag</a><a href="/x/" rel="nofollow">nf</a><a href="javascript:void(0)">js</a>`;
|
|
25
|
-
is('свои страницы, без повторов, файлов и служебных', internalLinks(html, 'https://www.x.com/'),
|
|
26
|
-
['https://www.x.com/buy/', 'https://www.x.com/rent/', 'https://x.com/sell/', 'https://www.x.com/about.html']);
|
|
27
|
-
is('относительные ссылки от <base>', internalLinks('<base href="https://www.x.com/en/"><a href="team/">t</a>', 'https://www.x.com/'), ['https://www.x.com/en/team/']);
|
|
28
|
-
is('битый html не роняет разбор', internalLinks(undefined, 'https://x.com/'), []);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ---- robots.txt
|
|
32
|
-
{
|
|
33
|
-
const allow = robotsAllows('User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /private/\nDisallow: /*.php$\nAllow: /private/open/\n');
|
|
34
|
-
ok('обычная страница разрешена', allow('https://x.com/buy/'));
|
|
35
|
-
ok('закрытый раздел нет', !allow('https://x.com/private/a/'));
|
|
36
|
-
ok('Allow длиннее Disallow побеждает', allow('https://x.com/private/open/a/'));
|
|
37
|
-
ok('звёздочка и доллар', !allow('https://x.com/a/b.php') && allow('https://x.com/a/b.php5'));
|
|
38
|
-
ok('блок чужого робота не применяется', allow('https://x.com/'));
|
|
39
|
-
ok('пустой robots.txt разрешает всё', robotsAllows('')('https://x.com/a/'));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ---- живой сервер без карты сайта
|
|
43
|
-
let dnsOk = true;
|
|
44
|
-
try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
45
|
-
if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(bad ? 1 : 0); }
|
|
46
|
-
|
|
47
|
-
const LINKS = {
|
|
48
|
-
'/': ['/buy/', '/rent/', '/private/', '/contact/', 'https://elsewhere.example/'],
|
|
49
|
-
'/buy/': ['/buy/villas/', '/buy/flats/', '/'],
|
|
50
|
-
'/rent/': ['/rent/long-term/'],
|
|
51
|
-
'/contact/': [], '/buy/villas/': [], '/buy/flats/': [], '/rent/long-term/': [], '/private/': [],
|
|
52
|
-
};
|
|
53
|
-
const page = (path) => `<!doctype html><html lang="en"><head><title>Page ${path}</title>
|
|
54
|
-
<meta name="description" content="A page description long enough not to count as empty for the check."></head>
|
|
55
|
-
<body><main><h1>Page ${path}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}
|
|
56
|
-
<nav>${LINKS[path].map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
|
|
57
|
-
const hits = [];
|
|
58
|
-
const server = createServer((req, res) => {
|
|
59
|
-
hits.push(req.url);
|
|
60
|
-
if (req.url === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end('User-agent: *\nDisallow: /private/\n'); }
|
|
61
|
-
if (LINKS[req.url]) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(req.url)); }
|
|
62
|
-
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
|
|
63
|
-
});
|
|
64
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
65
|
-
const base = `http://lvh.me:${server.address().port}`;
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const a = await collect(`${base}/`, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
69
|
-
const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
|
|
70
|
-
ok('карты нет, и отчёт это говорит', (a.checks.find((c) => c.id === 'sitemap') || {}).status === 'bad');
|
|
71
|
-
is('прочитаны все открытые страницы по ссылкам, двумя уровнями', [...read].sort(), ['/', '/buy/', '/buy/flats/', '/buy/villas/', '/contact/', '/rent/', '/rent/long-term/']);
|
|
72
|
-
ok('закрытое в robots.txt не читается', !hits.includes('/private/'));
|
|
73
|
-
ok('страница контактов идёт раньше остальных ссылок', read.indexOf('/contact/') < read.indexOf('/buy/'));
|
|
74
|
-
is('счёт найденных по ссылкам', a.reading.fromLinks, 6);
|
|
75
|
-
ok('в источниках сказано, откуда страницы', /found by following links/.test(a.sources[0].detail));
|
|
76
|
-
|
|
77
|
-
const few = await collect(`${base}/`, { pages: 3, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
78
|
-
is('предел страниц соблюдается', few.sample.length, 3);
|
|
79
|
-
|
|
80
|
-
const v = await checkVisibility(`${base}/`, { lang: 'en', samplePages: 5, budgetMs: 8000 });
|
|
81
|
-
ok('бесплатная проверка прочитала сайт', v.ok);
|
|
82
|
-
const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
|
|
83
|
-
ok(`бесплатная проверка взяла страницы со ссылок главной (${vRead.join(', ')})`, vRead.length >= 4 && vRead.includes('/buy/') && !vRead.includes('/private/'));
|
|
84
|
-
} finally { server.close(); }
|
|
85
|
-
|
|
86
|
-
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
87
|
-
console.log('\nвсё сходится');
|