@operstack/audit 0.11.0 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.11.0",
3
+ "version": "0.12.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/collect.mjs CHANGED
@@ -229,6 +229,34 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
229
229
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
230
230
  !llms.ok ? 'answer engines get nothing to read; a generated index is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
231
231
 
232
+ // Здоровье llms.txt: мы проверяем не только то, что файл есть, но и то, что он ведёт в живые
233
+ // страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
234
+ // удалили, а в индексе она осталась, и ИИ идёт цитировать редирект или пустоту. Проверяем
235
+ // выборку своих ссылок, а не все: на большом сайте их тысячи, а беда видна и по двадцати.
236
+ const llmsOwn = llmsLinks.filter((u) => { try { const h = new URL(u).host; return h === host || h === altHost; } catch { return false; } });
237
+ if (llmsOwn.length) {
238
+ const sampleSize = Math.min(20, llmsOwn.length);
239
+ const step = Math.max(1, Math.floor(llmsOwn.length / sampleSize));
240
+ const picked = llmsOwn.filter((_, i) => i % step === 0).slice(0, sampleSize);
241
+ const broken = [];
242
+ const redirected = [];
243
+ const closed = [];
244
+ for (const u of picked) {
245
+ const r = await get(u);
246
+ if (r.status >= 300 && r.status < 400) { redirected.push(u); continue; }
247
+ if (!r.ok) { broken.push(u); continue; }
248
+ if (/<meta[^>]+name=["\']robots["\'][^>]+noindex/i.test(r.text || '')) closed.push(u);
249
+ }
250
+ const sick = broken.length + redirected.length + closed.length;
251
+ const parts = [];
252
+ if (broken.length) parts.push(`${broken.length} dead`);
253
+ if (redirected.length) parts.push(`${redirected.length} redirecting`);
254
+ if (closed.length) parts.push(`${closed.length} closed to indexing`);
255
+ checks.push(row('llms-health', 'geo', 'Pages listed in llms.txt', sick > picked.length / 4 ? 'bad' : sick ? 'warn' : 'ok',
256
+ sick ? `${parts.join(', ')} of ${picked.length} checked: ${[...broken, ...redirected, ...closed][0]}` : `all ${picked.length} checked page(s) answer and are open to indexing`,
257
+ sick ? 'an answer engine reads this file instead of crawling: every dead or closed entry is a page it will quote wrongly or not at all' : ''));
258
+ }
259
+
232
260
  // Агентная поверхность: карточки, по которым ИИ-агент понимает, с чем имеет дело и что тут можно
233
261
  // вызвать. Стандарты молодые, поэтому отсутствие это не провал, а предупреждение, и в тексте
234
262
  // прямо сказано, что это раннее и необязательное. Врать про обязательность мы не будем.
package/src/fix.mjs CHANGED
@@ -66,6 +66,7 @@ export const FIX_ACTIONS = {
66
66
  thin: { scope: 'client', why: { ru: 'наполнение тонких страниц это тексты по фактам клиента', en: 'filling thin pages means writing from your facts' } },
67
67
  sections: { scope: 'client', why: { ru: 'разбивка на разделы это переписывание текста', en: 'splitting into sections means rewriting the text' } },
68
68
  tables: { scope: 'client', why: { ru: 'таблица собирается из данных клиента', en: 'a table is built from your data' } },
69
+ 'llms-health': { scope: 'always', action: { ru: 'Пересобрать llms.txt по живым страницам: убрать мёртвые адреса, заменить переадресованные на конечные, исключить закрытые от индексации', en: 'Rebuild llms.txt from the live pages: drop dead URLs, replace redirecting ones with their destination, leave out anything closed to indexing' } },
69
70
  'agent-card': { scope: 'always', action: { ru: 'Положить в /.well-known/agent.json карточку с названием компании, одной строкой о том, чем она занимается, адресом сайта и способом связи', en: 'Put an agent card at /.well-known/agent.json with the company name, one line on what it does, the site address and a way to get in touch' } },
70
71
  'api-catalog': { scope: 'never', why: { ru: 'нужен только тем, у кого есть API для агентов, выдумывать его ради галочки незачем', en: 'only for a business that actually exposes an API for agents; inventing one for the sake of a tick is pointless' } },
71
72
  'agent-markdown': { scope: 'files', action: { ru: 'Отдавать markdown-версию каждой страницы и объявлять её заголовком Link рядом с HTML', en: 'Serve a markdown version of every page and declare it with a Link header beside the HTML' } },
package/src/i18n.mjs CHANGED
@@ -105,6 +105,7 @@ const RU = {
105
105
  tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
106
106
  sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
107
107
  dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
108
+ 'llms-health': { label: 'Страницы из llms.txt', value: (v) => (/^all /.test(v) ? `все ${nums(v)[0]} проверенных страниц отвечают и открыты для индексации` : v.replace(/(\d+) dead/, '$1 мёртвых').replace(/(\d+) redirecting/, '$1 с переадресацией').replace(/(\d+) closed to indexing/, '$1 закрытых от индексации').replace(/ of (\d+) checked: /, ' из $1 проверенных: ')), comment: (c) => (c ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
108
109
  'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: этот файл читают каталоги ИИ-агентов, чтобы понять, кто вы и что предлагаете' : '') },
109
110
  'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
110
111
  'agent-markdown': { label: 'Markdown-версия страниц', value: (v) => (/Link header/.test(v) ? 'предлагается через заголовок Link' : /next to the page/.test(v) ? 'лежит рядом со страницей' : 'не предлагается'), comment: (c) => (c ? 'агент, который просит text/markdown, получает HTML и вынужден его разбирать: отдать markdown дёшево и снимает догадки' : '') },
package/src/prompts.mjs CHANGED
@@ -94,6 +94,10 @@ const P = {
94
94
  ru: { task: 'Пройди по абзацам, где есть цифры, и подпиши к каждой, откуда она: ссылкой на первоисточник или словами («по данным реестра за 2026 год»). Если источника нет ни у тебя, ни у владельца, цифру надо убрать, а не оставлять голой. Не ставь ссылку наугад: неверный источник хуже отсутствующего.', verify: 'В каждом абзаце с цифрой есть либо ссылка на чужой сайт, либо названный словами источник.' },
95
95
  en: { task: 'Go through the paragraphs carrying figures and name where each came from: a link to the primary source, or words ("per the 2026 registry"). If neither you nor the owner has a source, the figure comes out rather than standing bare. Do not guess a link: a wrong source is worse than none.', verify: 'Every paragraph with a figure carries either an outbound link or a source named in words.' },
96
96
  },
97
+ 'llms-health': {
98
+ ru: { task: 'Пересобери llms.txt из списка живых страниц сайта. Каждый адрес в файле должен отвечать кодом 200 и быть открыт для индексации. Переадресованные адреса замени на конечные, мёртвые убери, закрытые от индексации не включай. Если файл собирается скриптом при сборке, почини скрипт, а не файл: иначе он разъедется снова на следующей выкладке.', verify: 'Каждая ссылка из llms.txt отвечает 200, ни одна не ведёт на переадресацию, и ни у одной нет метки noindex.' },
99
+ en: { task: 'Rebuild llms.txt from the site\'s live pages. Every URL in it must answer 200 and be open to indexing. Replace redirecting URLs with their destination, drop dead ones, leave out anything marked noindex. If the file is generated at build time, fix the generator rather than the file, or it drifts again on the next deploy.', verify: 'Every link in llms.txt answers 200, none redirects, and none carries a noindex marker.' },
100
+ },
97
101
  'agent-card': {
98
102
  ru: { task: 'Собери файл /.well-known/agent.json: название компании, одна строка о том, чем она занимается, адрес сайта, способ связи и перечень того, что вы предлагаете. Пиши только правду: это читают каталоги агентов, и выдуманная возможность обернётся жалобой. Файл отдавай с типом application/json.', verify: 'Адрес /.well-known/agent.json отвечает 200, тип application/json, и JSON разбирается без ошибок.' },
99
103
  en: { task: 'Build a /.well-known/agent.json: the company name, one line on what it does, the site address, a way to get in touch and a list of what you offer. Put only true things in it: agent directories read this, and an invented capability turns into a complaint. Serve it as application/json.', verify: '/.well-known/agent.json answers 200 with application/json and parses as valid JSON.' },