@operstack/audit 0.19.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.mjs +1 -1
- package/src/prompts.mjs +81 -0
- package/src/test-blocked.mjs +16 -0
- package/src/test-prompts.mjs +30 -1
- package/src/visibility.mjs +8 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.mjs
CHANGED
|
@@ -6,4 +6,4 @@ export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18
|
|
|
6
6
|
export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
|
|
7
7
|
export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
|
|
8
8
|
export { draftNarrative, stillEmpty } from './narrative.mjs';
|
|
9
|
-
export { agentPrompt, agentPrompts, renderAgentPrompts } from './prompts.mjs';
|
|
9
|
+
export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
package/src/prompts.mjs
CHANGED
|
@@ -174,3 +174,84 @@ export function renderAgentPrompts(audit, opts = {}) {
|
|
|
174
174
|
'You do not need to know any code. Your job is to say what to do and look at the result.', ''];
|
|
175
175
|
return [intro.join('\n').trimEnd(), ...list.map((x) => x.text)].join('\n\n');
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Первая правка на бесплатной странице тремя частями: что сейчас, что поменять, как проверить.
|
|
180
|
+
*
|
|
181
|
+
* Зачем: бесплатная проверка (`checkVisibility`) отдаёт находки как { id, area, text }, а
|
|
182
|
+
* задания выше написаны по строкам аудита с другими ключами (`llms` против `llms-missing`).
|
|
183
|
+
* Страница показывала только текст находки, а три части приходили лишь письмом. Здесь каждая
|
|
184
|
+
* находка бесплатной проверки сопоставлена с заданием явно, по одному ключу на id; для находок,
|
|
185
|
+
* у которых задания в наборе нет, текст написан тут же. Неизвестный id даёт null: страница
|
|
186
|
+
* тогда честно показывает одну находку, а не чужое указание по чужому сайту.
|
|
187
|
+
*
|
|
188
|
+
* «Сейчас» это сам текст находки: он уже измерен и назван, второй раз не формулируем.
|
|
189
|
+
*/
|
|
190
|
+
const FIRST_FIX_BY_PROMPT = {
|
|
191
|
+
'robots-all-blocked': 'robots',
|
|
192
|
+
'robots-fetchers-blocked': 'ai-search-access',
|
|
193
|
+
'llms-missing': 'llms',
|
|
194
|
+
'canonical-missing': 'canonical',
|
|
195
|
+
'og-missing': 'og',
|
|
196
|
+
'schema-org-missing': 'schema',
|
|
197
|
+
'schema-faq-missing': 'faq-schema',
|
|
198
|
+
'dates-missing': 'dates',
|
|
199
|
+
'answer-first-missing': 'answer-first',
|
|
200
|
+
'sources-missing': 'sources',
|
|
201
|
+
'thin-pages': 'thin',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const FIRST_FIX_OWN = {
|
|
205
|
+
'robots-training-blocked': {
|
|
206
|
+
ru: { task: 'Реши, хочешь ли ты, чтобы модели ИИ учились на сайте. Если да, убери из robots.txt запреты для обучающих роботов, которые названы в строке «Сейчас». Если нет, оставь как есть: это осознанный выбор, а не ошибка, и балл он снижает немного.', verify: 'В robots.txt для названных обучающих роботов нет строки Disallow, либо решение закрыть их записано владельцем словами.' },
|
|
207
|
+
en: { task: 'Decide whether you want AI models to learn from the site. If yes, remove the robots.txt rules that block the training crawlers named under Now. If no, leave it: that is a deliberate choice, not a mistake, and it costs only a little of the score.', verify: 'robots.txt has no Disallow for the training crawlers named, or the decision to keep them out is written down by the owner.' },
|
|
208
|
+
},
|
|
209
|
+
'noai-meta': {
|
|
210
|
+
ru: { task: 'На одной из страниц стоит мета-тег robots со значением noai: он просит системы ИИ не использовать содержимое. Если хочешь, чтобы сайт цитировали в ответах, убери это значение из шаблона страниц. Если это осознанный запрет, оставь.', verify: 'В HTML проверенных страниц нет мета-тега robots со значением noai.' },
|
|
211
|
+
en: { task: 'One of the pages carries a robots meta tag with the value noai: it asks AI systems not to use the content. If you want the site quoted in answers, remove that value from the page template. If the ban is deliberate, leave it.', verify: 'The HTML of the sampled pages has no robots meta tag with the value noai.' },
|
|
212
|
+
},
|
|
213
|
+
'llms-foreign': {
|
|
214
|
+
ru: { task: 'Перепиши llms.txt так, чтобы ссылки в нём вели на страницы этого сайта, а не на чужие домены. Чужую ссылку оставь только там, где без неё нельзя (например, профиль в каталоге), и не в начале файла.', verify: 'Большинство ссылок в llms.txt ведут на этот же домен, и первые из них тоже.' },
|
|
215
|
+
en: { task: 'Rewrite llms.txt so its links point at pages of this site rather than other domains. Keep an outside link only where it cannot be avoided (a directory profile, say), and never at the top of the file.', verify: 'Most links in llms.txt point at this domain, and so do the first ones.' },
|
|
216
|
+
},
|
|
217
|
+
'llms-not-text': {
|
|
218
|
+
ru: { task: 'Адрес /llms.txt отдаёт HTML-страницу вместо текста. Сделай так, чтобы по этому адресу отдавался сам текстовый файл с типом text/plain, а не страница сайта и не заглушка «не найдено» с кодом 200.', verify: '/llms.txt отвечает кодом 200 с типом text/plain, и в ответе текстовый указатель, а не разметка HTML.' },
|
|
219
|
+
en: { task: '/llms.txt serves an HTML page instead of text. Make that address return the text file itself with the type text/plain, not a site page and not a "not found" page with a 200 status.', verify: '/llms.txt answers 200 with text/plain, and the body is a text index rather than HTML markup.' },
|
|
220
|
+
},
|
|
221
|
+
'schema-article-missing': {
|
|
222
|
+
ru: { task: 'Добавь на страницы статей и записей разметку Article или BlogPosting в JSON-LD: заголовок, дата публикации, дата изменения, автор, издатель. Бери данные со страницы, ничего не придумывай.', verify: 'На странице статьи есть JSON-LD с типом Article или BlogPosting, и проверка разметки не показывает ошибок.' },
|
|
223
|
+
en: { task: 'Add Article or BlogPosting markup in JSON-LD to the article pages: headline, date published, date modified, author, publisher. Take the data from the page, invent nothing.', verify: 'An article page carries JSON-LD of type Article or BlogPosting and the markup validator shows no errors.' },
|
|
224
|
+
},
|
|
225
|
+
'few-h2': {
|
|
226
|
+
ru: { task: 'Разбей длинные страницы на разделы: каждый начинается заголовком H2, который звучит как вопрос или тема раздела. На странице должно быть не меньше трёх таких разделов. Ради объёма ничего не дописывай, только структурируй то, что есть.', verify: 'На каждой проверенной странице не меньше трёх заголовков H2, и по ним понятно, о чём страница, без чтения текста.' },
|
|
227
|
+
en: { task: 'Split the long pages into sections, each opening with an H2 that reads as the question or topic of that section. A page needs at least three. Add nothing for length, only structure what is there.', verify: 'Every sampled page has at least three H2 headings, and they alone tell what the page is about.' },
|
|
228
|
+
},
|
|
229
|
+
'no-tables': {
|
|
230
|
+
ru: { task: 'Там, где на странице есть цифры, которые сравнивают (цены, сроки, площади, условия), собери их в таблицу с подписанными столбцами. Строй её только из данных, которые уже есть на странице или у владельца.', verify: 'На страницах со сравниваемыми цифрами есть хотя бы одна настоящая таблица (тег table), а не картинка и не список.' },
|
|
231
|
+
en: { task: 'Where a page has figures that get compared (prices, timings, areas, terms), put them in a table with labelled columns. Build it only from data already on the page or held by the owner.', verify: 'Pages with comparable figures carry at least one real table (a table tag), not an image and not a list.' },
|
|
232
|
+
},
|
|
233
|
+
'sitemap-no-lastmod': {
|
|
234
|
+
ru: { task: 'Проставь в карте сайта (sitemap.xml) каждой странице поле lastmod с настоящей датой последнего изменения содержимого. Дату сборки или сегодняшнее число во все строки не ставь: это хуже, чем пустое поле.', verify: 'У каждого url в sitemap.xml есть lastmod, даты различаются между страницами и совпадают с датой изменения на самой странице.' },
|
|
235
|
+
en: { task: 'Give every page in sitemap.xml a lastmod with the real date its content last changed. Do not stamp the build date or today on every line: that is worse than leaving it empty.', verify: 'Every url in sitemap.xml has a lastmod, the dates differ between pages and match the modified date on the page itself.' },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Все id находок бесплатной проверки, для которых есть три части. */
|
|
240
|
+
export const FIRST_FIX_IDS = [...Object.keys(FIRST_FIX_BY_PROMPT), ...Object.keys(FIRST_FIX_OWN)];
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {{id?: string, area?: string, text?: string}} finding элемент `fixes[]` из `checkVisibility`
|
|
244
|
+
* @param {{lang?: 'ru'|'en'}} opts
|
|
245
|
+
* @returns {{now: string, task: string, verify: string, rule: string, labels: {now: string, task: string, verify: string}}|null}
|
|
246
|
+
*/
|
|
247
|
+
export function firstFixParts(finding, opts = {}) {
|
|
248
|
+
const lang = opts.lang === 'ru' ? 'ru' : 'en';
|
|
249
|
+
const h = HEAD[lang];
|
|
250
|
+
const id = finding && finding.id;
|
|
251
|
+
const now = String((finding && finding.text) || '').trim();
|
|
252
|
+
if (!id || !now) return null;
|
|
253
|
+
const own = FIRST_FIX_OWN[id] ? withLang(FIRST_FIX_OWN[id], lang)
|
|
254
|
+
: (FIRST_FIX_BY_PROMPT[id] && P[FIRST_FIX_BY_PROMPT[id]] ? withLang(P[FIRST_FIX_BY_PROMPT[id]], lang) : null);
|
|
255
|
+
if (!own || !own.task || !own.verify) return null;
|
|
256
|
+
return { now, task: own.task, verify: own.verify, rule: h.rule, labels: { now: h.now, task: h.task, verify: h.verify } };
|
|
257
|
+
}
|
package/src/test-blocked.mjs
CHANGED
|
@@ -80,5 +80,21 @@ const CLOSED = 'User-agent: GPTBot\nDisallow: /\n\nUser-agent: ClaudeBot\nDisall
|
|
|
80
80
|
ok('в английском нет кириллицы', !/[А-Яа-яЁё]/.test(blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'en' }).text));
|
|
81
81
|
}
|
|
82
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
|
+
|
|
83
99
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
84
100
|
console.log('\nпро чужой сайт говорим только измеренное');
|
package/src/test-prompts.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Задания для ИИ-агента: полнота, язык, запрет на выдумки. */
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
4
5
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
5
6
|
|
|
6
7
|
let bad = 0;
|
|
@@ -79,5 +80,33 @@ ok('и предупреждает делать по одной', /по одно
|
|
|
79
80
|
ok('и обещает, что код знать не нужно', /кода знать не нужно/i.test(head));
|
|
80
81
|
ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
|
|
81
82
|
|
|
83
|
+
// ---- первая правка на бесплатной странице тремя частями.
|
|
84
|
+
// Список id берётся из исходника бесплатной проверки, а не из головы: новая находка без
|
|
85
|
+
// сопоставления уронит этот тест, а не оставит покупателя без задачи молча.
|
|
86
|
+
{
|
|
87
|
+
const src = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|
|
88
|
+
const ids = [...new Set([...src.matchAll(/id: '([a-z0-9-]+)', level: (?!'na')/g)].map((m) => m[1]))];
|
|
89
|
+
ok(`находок бесплатной проверки в исходнике больше десяти (${ids.length})`, ids.length > 10);
|
|
90
|
+
const missing = [];
|
|
91
|
+
for (const id of ids) for (const lang of ['ru', 'en']) {
|
|
92
|
+
const p = firstFixParts({ id, area: 'x', text: 'что-то измерено' }, { lang });
|
|
93
|
+
if (!p || !p.now || p.task.length < 30 || p.verify.length < 20 || /null|undefined/.test(JSON.stringify(p))) missing.push(`${id}/${lang}`);
|
|
94
|
+
else if (lang === 'en' && /[А-Яа-яЁё]/.test(p.task + p.verify + p.rule + Object.values(p.labels).join(''))) missing.push(`${id}/en: кириллица`);
|
|
95
|
+
}
|
|
96
|
+
is('у каждой находки бесплатной проверки есть три части на обоих языках', missing, []);
|
|
97
|
+
is('и в списке экспорта нет лишних id, которых проверка не выдаёт', FIRST_FIX_IDS.filter((id) => !ids.includes(id)), []);
|
|
98
|
+
is('неизвестная находка даёт null, а не чужое указание', firstFixParts({ id: 'что-то-новое', text: 'x' }, { lang: 'ru' }), null);
|
|
99
|
+
is('находка без текста даёт null', firstFixParts({ id: 'llms-missing', text: '' }, { lang: 'ru' }), null);
|
|
100
|
+
is('пустой вход даёт null', firstFixParts(null), null);
|
|
101
|
+
const p = firstFixParts({ id: 'robots-fetchers-blocked', area: 'Пускает ли роботов ИИ', text: 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.' }, { lang: 'ru' });
|
|
102
|
+
is('«сейчас» это сам текст находки', p.now, 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.');
|
|
103
|
+
ok('задача про открытие поисковых роботов', /Открой в robots\.txt/.test(p.task));
|
|
104
|
+
ok('подписи на русском', p.labels.now === 'Сейчас' && p.labels.task === 'Задача' && p.labels.verify === 'Как проверить');
|
|
105
|
+
ok('правило про выдумки на месте', /не выдумывай факты/.test(p.rule));
|
|
106
|
+
const e = firstFixParts({ id: 'llms-missing', text: 'No llms.txt.' }, { lang: 'en' });
|
|
107
|
+
ok('английские подписи', e.labels.now === 'Now' && e.labels.verify === 'How to check');
|
|
108
|
+
ok('незнакомый язык падает в английский', firstFixParts({ id: 'llms-missing', text: 'x' }, { lang: 'de' }).labels.now === 'Now');
|
|
109
|
+
}
|
|
110
|
+
|
|
82
111
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
83
112
|
console.log('\nвсе тесты заданий прошли');
|
package/src/visibility.mjs
CHANGED
|
@@ -48,7 +48,9 @@ export const MESSAGES = {
|
|
|
48
48
|
en: {
|
|
49
49
|
badInput: 'Enter a public site address, for example example.com',
|
|
50
50
|
tooSlow: 'The site took too long to answer',
|
|
51
|
-
badAnswer: (status, url) => `The site answered ${status
|
|
51
|
+
badAnswer: (status, url) => `The site answered ${status} for ${url}`,
|
|
52
|
+
// Чаще всего это опечатка в адресе, и сказать об этом надо первым делом, а не после разбора.
|
|
53
|
+
unreachable: (url) => `Nothing answered at ${url}. Most often that is a typo in the address: check the spelling, and if the site really is there, it may be down right now.`,
|
|
52
54
|
botWall: (status) => `The site refuses unknown clients (HTTP ${status}), so this check could not read a single page.`,
|
|
53
55
|
botChallenge: (status) => `The site sits behind a browser challenge (Cloudflare, HTTP ${status}), so this check could not read a single page.`,
|
|
54
56
|
// Что мы знаем про роботов ИИ, когда нас самих развернули: ровно то, что написано в правилах
|
|
@@ -98,7 +100,8 @@ export const MESSAGES = {
|
|
|
98
100
|
ru: {
|
|
99
101
|
badInput: 'Введите адрес публичного сайта, например example.ru',
|
|
100
102
|
tooSlow: 'Сайт слишком долго не отвечал',
|
|
101
|
-
badAnswer: (status, url) => `Сайт ответил
|
|
103
|
+
badAnswer: (status, url) => `Сайт ответил кодом ${status} на ${url}`,
|
|
104
|
+
unreachable: (url) => `По адресу ${url} никто не ответил. Чаще всего это опечатка в адресе: проверьте написание. Если сайт там действительно есть, значит он сейчас недоступен.`,
|
|
102
105
|
botWall: (status) => `Сайт отказывает незнакомым клиентам (код ${status}), поэтому прочитать хотя бы одну страницу проверка не смогла.`,
|
|
103
106
|
botChallenge: (status) => `Сайт закрыт проверкой браузера (Cloudflare, код ${status}), поэтому прочитать хотя бы одну страницу проверка не смогла.`,
|
|
104
107
|
rulesAllowBots: (list) => ` Правила самого сайта роботов ИИ при этом пускают (${list}), а защита такого типа проверенных роботов нередко пропускает. Пустят ли их на деле, решает защита, а не эта проверка, поэтому в плюс или в минус сайту это не зачтено.`,
|
|
@@ -403,8 +406,9 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
403
406
|
*/
|
|
404
407
|
const note = blockedNote({ status: home.status, challenged, blocked, robotsText: robotsRes.ok ? robotsRes.text : null, lang });
|
|
405
408
|
const error = home.error === 'timeout' ? T.tooSlow
|
|
406
|
-
:
|
|
407
|
-
:
|
|
409
|
+
: home.error === 'unreachable' || !home.status ? T.unreachable(url)
|
|
410
|
+
: blocked ? note.text
|
|
411
|
+
: T.badAnswer(home.status, url);
|
|
408
412
|
// `botsBlocked` говорит только про написанные правила, а не про поведение защиты: по нему
|
|
409
413
|
// нельзя утверждать, что робота не пустили, можно лишь, что ему не разрешали.
|
|
410
414
|
return { ok: false, blocked, challenged, status: home.status, error, botsBlocked: note.botsBlocked };
|