@operstack/audit 0.10.0 → 0.11.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 CHANGED
@@ -46,6 +46,19 @@ An agent can fill these fields from the JSON: the Claude Code plugin `operstack-
46
46
 
47
47
  Ten A4 pages: cover, executive summary with a scorecard, site overview with the sampled pages, critical issues, technical and on-page checklist, content and structure, AEO and GEO, off-page and conversion and limitations, roadmap in three phases, closing message. HTML always; PDF with `--pdf` when Chrome or Chromium is installed (`CHROME_PATH` overrides the lookup).
48
48
 
49
+ ## The agent surface
50
+
51
+ **In plain words.** Assistants like ChatGPT and Claude are starting to read a couple of small files
52
+ to work out what a site is and what it can do, the way a search engine reads robots.txt. Almost
53
+ nobody has them yet, which is exactly why having them is cheap and noticeable. These checks say
54
+ whether yours exist, and they are honest about it being early days: a missing file is a note, not a
55
+ failure.
56
+
57
+ **What it checks.** `/.well-known/agent.json` (the agent card: who you are, what you offer, how to
58
+ reach you), `/.well-known/api-catalog` per RFC 9727, and whether a page offers a markdown version,
59
+ either through a `Link` header or as a file beside it. The API catalogue is deliberately never
60
+ offered as work: it belongs to a business that actually exposes an API.
61
+
49
62
  ## What an AI does not see: the JavaScript check
50
63
 
51
64
  **In plain words.** Some sites draw themselves with scripts. A person opens the page and sees the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -27,7 +27,7 @@
27
27
  "LICENSE"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-rendered.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
30
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
31
31
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
32
32
  },
33
33
  "keywords": [
package/src/collect.mjs CHANGED
@@ -16,7 +16,9 @@ async function get(url, { method = 'GET', timeout = 15000 } = {}) {
16
16
  const res = await fetch(url, { method, redirect: 'manual', signal: c.signal, headers: { 'user-agent': UA, accept: 'text/html,application/xml,text/plain,*/*' } });
17
17
  const ttfb = Date.now() - started;
18
18
  const text = method === 'GET' ? await res.text() : '';
19
- return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', text, ttfb, url };
19
+ // Заголовок Link нужен проверке «есть ли markdown-версия страницы»: им сайт говорит агенту,
20
+ // где лежит текстовая копия. Возвращаем его сразу, чтобы не ходить за страницей второй раз.
21
+ return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', link: res.headers.get('link') || '', text, ttfb, url };
20
22
  } catch (e) { return { ok: false, status: 0, error: e.name === 'AbortError' ? 'timeout' : e.message, text: '', ttfb: Date.now() - started, url }; }
21
23
  finally { clearTimeout(t); }
22
24
  }
@@ -227,6 +229,30 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
227
229
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
228
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` : ''));
229
231
 
232
+ // Агентная поверхность: карточки, по которым ИИ-агент понимает, с чем имеет дело и что тут можно
233
+ // вызвать. Стандарты молодые, поэтому отсутствие это не провал, а предупреждение, и в тексте
234
+ // прямо сказано, что это раннее и необязательное. Врать про обязательность мы не будем.
235
+ log('agent surface');
236
+ const agentCard = await get(`${origin}/.well-known/agent.json`);
237
+ const agentCardOk = agentCard.ok && /^\s*\{/.test(agentCard.text || '');
238
+ checks.push(row('agent-card', 'geo', 'Agent card (/.well-known/agent.json)', agentCardOk ? 'ok' : 'warn',
239
+ agentCardOk ? 'present and valid JSON' : agentCard.ok ? 'present but not JSON' : 'not present',
240
+ agentCardOk ? '' : 'an early, optional standard: it is the file agent directories read to learn who you are and what you offer'));
241
+
242
+ const apiCatalog = await get(`${origin}/.well-known/api-catalog`);
243
+ checks.push(row('api-catalog', 'geo', 'API catalogue (RFC 9727)', apiCatalog.ok ? 'ok' : 'warn',
244
+ apiCatalog.ok ? 'present' : 'not present',
245
+ apiCatalog.ok ? '' : 'optional, and only worth adding when the business actually exposes an API for agents to call'));
246
+
247
+ // Markdown-версия страницы: её просят агенты, которым HTML мешает. Признаём оба способа,
248
+ // заголовок Link и файл рядом со страницей, потому что оба встречаются.
249
+ const homeMd = await get(`${home.final.replace(/\/$/, '')}/index.md`);
250
+ const linkHeader = String(home.response.link || '');
251
+ const mdOk = (homeMd.ok && !/<html/i.test((homeMd.text || '').slice(0, 300))) || /text\/markdown/i.test(linkHeader);
252
+ checks.push(row('agent-markdown', 'geo', 'Markdown version of the pages', mdOk ? 'ok' : 'warn',
253
+ mdOk ? (/text\/markdown/i.test(linkHeader) ? 'offered through the Link header' : 'served next to the page') : 'not offered',
254
+ mdOk ? '' : 'an agent that asks for text/markdown gets HTML and has to strip it: giving it markdown is cheap and removes the guesswork'));
255
+
230
256
  log(`sampling up to ${pages} pages`);
231
257
  const norm = (u) => { try { const x = new URL(u); x.hash = ''; x.search = ''; if (!x.pathname.includes('.') && !x.pathname.endsWith('/')) x.pathname += '/'; return x.href.toLowerCase(); } catch { return u; } };
232
258
  const seenUrl = new Set([norm(home.final), norm(startUrl)]);
package/src/fix.mjs CHANGED
@@ -66,6 +66,9 @@ 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
+ '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
+ '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
+ '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' } },
69
72
  'js-content': { scope: 'files', action: { ru: 'Отдавать текст страницы сразу в HTML, а не дорисовывать его скриптами: предрендер или серверная отрисовка тех страниц, где текст сейчас появляется только в браузере', en: 'Serve the page text in the HTML itself instead of drawing it with scripts: prerender or server-render the pages whose text currently appears only in a browser' } },
70
73
  'links-profile': { scope: 'never', why: { ru: 'данные по ссылкам платные и ни в одну оценку не входят', en: 'link data is paid, and it moves no score of ours' } },
71
74
  };
package/src/i18n.mjs CHANGED
@@ -105,6 +105,9 @@ 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
+ 'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: этот файл читают каталоги ИИ-агентов, чтобы понять, кто вы и что предлагаете' : '') },
109
+ 'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
110
+ 'agent-markdown': { label: 'Markdown-версия страниц', value: (v) => (/Link header/.test(v) ? 'предлагается через заголовок Link' : /next to the page/.test(v) ? 'лежит рядом со страницей' : 'не предлагается'), comment: (c) => (c ? 'агент, который просит text/markdown, получает HTML и вынужден его разбирать: отдать markdown дёшево и снимает догадки' : '') },
108
111
  'links-profile': { label: 'Профиль внешних ссылок (необязательное дополнение)', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'ссылающийся домен', 'ссылающихся домена', 'ссылающихся доменов')}, ${plural(Number(n[1]), 'ссылка', 'ссылки', 'ссылок')}, ${pct(n[2])} передают вес, показатель авторитета ${n[3]} из 100`; }, comment: (c) => `${c.split('.')[0]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
109
112
  'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
110
113
  'trust-contact': {
package/src/prompts.mjs CHANGED
@@ -94,6 +94,14 @@ 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
+ 'agent-card': {
98
+ ru: { task: 'Собери файл /.well-known/agent.json: название компании, одна строка о том, чем она занимается, адрес сайта, способ связи и перечень того, что вы предлагаете. Пиши только правду: это читают каталоги агентов, и выдуманная возможность обернётся жалобой. Файл отдавай с типом application/json.', verify: 'Адрес /.well-known/agent.json отвечает 200, тип application/json, и JSON разбирается без ошибок.' },
99
+ 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.' },
100
+ },
101
+ 'agent-markdown': {
102
+ ru: { task: 'Сделай так, чтобы у каждой страницы была markdown-версия того же текста, и объяви её заголовком Link со ссылкой на неё и типом text/markdown. Markdown должен содержать тот же текст, что и страница: заголовки, абзацы, списки и таблицы. Служебную обвязку, меню и подвал в него не клади.', verify: 'Запрос страницы возвращает заголовок Link с text/markdown, и по этой ссылке отдаётся текст той же страницы, а не HTML.' },
103
+ en: { task: 'Give every page a markdown version of the same text and declare it with a Link header pointing at it with type text/markdown. The markdown must carry the same text as the page: headings, paragraphs, lists and tables. Leave the navigation and footer out of it.', verify: 'A request for the page returns a Link header with text/markdown, and that URL serves the text of the same page rather than HTML.' },
104
+ },
97
105
  'js-content': {
98
106
  ru: { task: 'Найди страницы, где основной текст появляется только после выполнения скриптов, и сделай так, чтобы он отдавался сразу в HTML: предрендер при сборке или серверная отрисовка. Проверить просто: открой адрес страницы командой curl и посмотри, есть ли в ответе текст статьи. Не прячь текст в атрибуты и не дублируй его скрытым блоком ради роботов: это считается обманом и наказывается.', verify: 'В ответе на обычный запрос без браузера виден весь основной текст страницы, а не только каркас.' },
99
107
  en: { task: 'Find the pages whose main text appears only after scripts run, and make that text arrive in the HTML itself: prerender at build time or render on the server. Checking is simple: fetch the URL with curl and see whether the article text is in the response. Do not hide the text in attributes and do not duplicate it in a hidden block for robots: that counts as cloaking and is punished.', verify: 'A plain request without a browser returns the whole main text of the page, not just the shell.' },
@@ -0,0 +1,46 @@
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все тесты агентной поверхности прошли');