@operstack/audit 0.9.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,33 @@ 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
+
62
+ ## What an AI does not see: the JavaScript check
63
+
64
+ **In plain words.** Some sites draw themselves with scripts. A person opens the page and sees the
65
+ full text; a robot that does not run scripts sees an empty frame. The robots that pull a page into
66
+ an AI answer are exactly that kind of robot. So the owner reads his own page, sees plenty of text,
67
+ and cannot understand why nobody quotes it. This check reads the page twice, as a plain request and
68
+ in a real browser, and tells you how much of the text only exists in the second reading.
69
+
70
+ **What it does.** `collect` runs Chrome or Chromium over the homepage and two sampled pages, strips
71
+ the navigation and footer from both readings, and compares the word counts. Forty percent hidden, or
72
+ a page that is nearly empty without scripts, fails. Ten to forty warns. Under ten passes. With no
73
+ browser installed the check says it was not measured and why. `--no-rendered` skips it,
74
+ `--rendered-pages N` changes how many pages are opened; each page costs roughly half a minute.
75
+
49
76
  ## Writing the report: `draft`
50
77
 
51
78
  **If you have never done SEO, start here.** An audit tells you what a search engine and an AI see when they open your site. This tool writes that report for you. It does not guess anything about your business: every sentence in it comes from something that was measured on your pages, and where nothing could be measured it says so instead of filling the gap with words. You read it, correct what only you know, and send it.
package/bin/audit.mjs CHANGED
@@ -40,8 +40,9 @@ const has = (k) => rest.includes(k);
40
40
  if (!cmd || cmd === '--help' || cmd === '-h') {
41
41
  console.log([
42
42
  'Usage:',
43
- ' operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json] [--lang ru]',
44
- ' collect the public signals of a site into audit.json',
43
+ ' operstack-audit collect <url> [--pages 20] [--out audit.json] [--lang ru] [--no-rendered]',
44
+ ' collect the public signals of a site into audit.json; a real browser also measures how',
45
+ ' much of the text is invisible without JavaScript (--no-rendered skips that)',
45
46
  ' operstack-audit render <audit.json> [--out report.html] [--pdf]',
46
47
  ' render the report; --pdf also prints a PDF with headless Chrome',
47
48
  ' operstack-audit draft <audit.json> [--lang ru|en] [--out audit.json]',
@@ -64,7 +65,14 @@ if (cmd === 'collect') {
64
65
  if (!/^https?:\/\//.test(target || '')) { console.error('collect needs an absolute URL'); process.exit(2); }
65
66
  const backlinksFile = opt('--backlinks', '');
66
67
  const backlinks = backlinksFile ? JSON.parse(readFileSync(resolve(backlinksFile), 'utf8')) : null;
67
- const audit = await collect(target, { pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'), log: (m) => console.error(` ${m}`) });
68
+ // Проверка «виден ли текст без скриптов» требует браузера и добавляет полминуты на страницу.
69
+ // В платном аудите она нужна всегда: именно здесь мы можем соврать про чужой сайт. Выключается
70
+ // флагом --no-rendered, например когда браузера нет или прогон идёт по расписанию.
71
+ const audit = await collect(target, {
72
+ pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'),
73
+ rendered: !has('--no-rendered'), renderedPages: Number(opt('--rendered-pages', 3)),
74
+ log: (m) => console.error(` ${m}`),
75
+ });
68
76
  const out = resolve(opt('--out', 'audit.json'));
69
77
  writeFileSync(out, JSON.stringify(audit, null, 2));
70
78
  console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.9.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",
@@ -15,7 +15,8 @@
15
15
  "./fix": "./src/fix.mjs",
16
16
  "./foundation": "./src/foundation.mjs",
17
17
  "./narrative": "./src/narrative.mjs",
18
- "./prompts": "./src/prompts.mjs"
18
+ "./prompts": "./src/prompts.mjs",
19
+ "./rendered": "./src/rendered.mjs"
19
20
  },
20
21
  "files": [
21
22
  "bin",
@@ -26,7 +27,7 @@
26
27
  "LICENSE"
27
28
  ],
28
29
  "scripts": {
29
- "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 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",
30
31
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
31
32
  },
32
33
  "keywords": [
package/src/collect.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  * robots.txt, sitemaps, llms.txt, the homepage and a sample of pages from the sitemap.
4
4
  * No Search Console, no analytics. The narrative fields are left for the analyst.
5
5
  */
6
+ import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
6
7
  import { parse } from 'node-html-parser';
7
8
  import { localiseChecks } from './i18n.mjs';
8
9
 
@@ -15,7 +16,9 @@ async function get(url, { method = 'GET', timeout = 15000 } = {}) {
15
16
  const res = await fetch(url, { method, redirect: 'manual', signal: c.signal, headers: { 'user-agent': UA, accept: 'text/html,application/xml,text/plain,*/*' } });
16
17
  const ttfb = Date.now() - started;
17
18
  const text = method === 'GET' ? await res.text() : '';
18
- 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 };
19
22
  } catch (e) { return { ok: false, status: 0, error: e.name === 'AbortError' ? 'timeout' : e.message, text: '', ttfb: Date.now() - started, url }; }
20
23
  finally { clearTimeout(t); }
21
24
  }
@@ -162,7 +165,7 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
162
165
  return out;
163
166
  }
164
167
 
165
- export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en' } = {}) {
168
+ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3 } = {}) {
166
169
  const opts = { backlinks };
167
170
  const origin = new URL(startUrl).origin;
168
171
  const host = new URL(startUrl).host;
@@ -226,6 +229,30 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
226
229
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
227
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` : ''));
228
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
+
229
256
  log(`sampling up to ${pages} pages`);
230
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; } };
231
258
  const seenUrl = new Set([norm(home.final), norm(startUrl)]);
@@ -335,6 +362,25 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
335
362
 
336
363
  const { scores, scoreBasis } = computeScores(checks);
337
364
  // Оценки считаются до перевода: язык на цифры не влияет.
365
+ // Сколько текста не видно без выполнения скриптов. Меряется настоящим браузером и только по
366
+ // просьбе: запуск браузера это десятки секунд на страницу, и еженедельному мониторингу это ни к
367
+ // чему. Для платного аудита включено, потому что именно здесь мы можем соврать про чужой сайт.
368
+ if (rendered) {
369
+ const targets = [{ url: home.final, raw: home.response.ok ? home.response.text : '' },
370
+ ...good.filter((p) => p.url !== home.final).slice(0, Math.max(0, renderedPages - 1)).map((p) => ({ url: p.url, raw: '' }))];
371
+ const measured = [];
372
+ for (const t of targets) {
373
+ if (!t.url) continue;
374
+ log(`rendering ${t.url} in a real browser`);
375
+ const dom = renderedDom(t.url);
376
+ if (!dom) { log('no browser here, the JavaScript check will say it was not measured'); break; }
377
+ const rawHtml = t.raw || (await get(t.url)).text;
378
+ if (!rawHtml) continue;
379
+ measured.push({ url: t.url, ...compareReadings(rawHtml, dom) });
380
+ }
381
+ checks.push(jsBlindnessCheck(measured, lang));
382
+ }
383
+
338
384
  const localised = localiseChecks(checks, lang);
339
385
  const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
340
386
 
package/src/fix.mjs CHANGED
@@ -66,6 +66,10 @@ 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' } },
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' } },
69
73
  'links-profile': { scope: 'never', why: { ru: 'данные по ссылкам платные и ни в одну оценку не входят', en: 'link data is paid, and it moves no score of ours' } },
70
74
  };
71
75
 
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,18 @@ 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
+ },
105
+ 'js-content': {
106
+ ru: { task: 'Найди страницы, где основной текст появляется только после выполнения скриптов, и сделай так, чтобы он отдавался сразу в HTML: предрендер при сборке или серверная отрисовка. Проверить просто: открой адрес страницы командой curl и посмотри, есть ли в ответе текст статьи. Не прячь текст в атрибуты и не дублируй его скрытым блоком ради роботов: это считается обманом и наказывается.', verify: 'В ответе на обычный запрос без браузера виден весь основной текст страницы, а не только каркас.' },
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.' },
108
+ },
97
109
  thin: {
98
110
  ru: { task: 'Возьми страницы короче трёхсот слов и допиши их фактами, которые знает владелец: условия, цены, сроки, ограничения, частые вопросы покупателей с ответами. Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов и оставь страницу как есть.', verify: 'Страница стала длиннее трёхсот слов, и каждый добавленный абзац содержит факт, а не общие слова.' },
99
111
  en: { task: 'Take the pages under three hundred words and fill them with facts the owner has: terms, prices, timings, limits, the questions buyers actually ask with the answers. Do not pad for length. Where the facts are missing, write the owner a list of questions and leave the page alone.', verify: 'The page is over three hundred words and every added paragraph carries a fact rather than filler.' },
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Сколько текста страницы не видит тот, кто не выполняет JavaScript.
3
+ *
4
+ * Человеческими словами: если сайт рисует себя скриптами, то поисковый робот и особенно робот,
5
+ * который достаёт страницу для ответа ИИ, могут увидеть пустую страницу. Владелец при этом видит
6
+ * полный текст и не понимает, почему его не цитируют. Здесь это измеряется прямо: страница
7
+ * читается дважды, как обычный запрос без скриптов и как настоящий браузер, и считается, какая
8
+ * доля текста появляется только во втором случае.
9
+ *
10
+ * Браузер берётся тот же, которым печатается PDF отчёта: свой Chrome или Chromium. Если его нет,
11
+ * проверка не выдумывает результат, а говорит, что не измеряла, и почему.
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ /** Тот же поиск браузера, что у печати PDF: одна привычка на весь пакет. */
19
+ export function findChrome() {
20
+ const candidates = [
21
+ process.env.CHROME_PATH,
22
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
23
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
24
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
25
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
26
+ ].filter(Boolean);
27
+ for (const c of candidates) {
28
+ const r = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 8000 });
29
+ if (r.status === 0) return c;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /** Текст страницы после выполнения скриптов. null, если браузера нет или он не справился. */
35
+ export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 8000 } = {}) {
36
+ const bin = chrome || findChrome();
37
+ if (!bin) return null;
38
+ const profile = mkdtempSync(join(tmpdir(), 'operstack-render-'));
39
+ try {
40
+ const r = spawnSync(bin, [
41
+ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
42
+ '--disable-extensions', '--mute-audio', '--hide-scrollbars',
43
+ `--user-data-dir=${profile}`, `--virtual-time-budget=${waitMs}`, '--dump-dom', url,
44
+ ], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
45
+ if (r.status !== 0 || !r.stdout) return null;
46
+ return r.stdout;
47
+ } catch { return null; } finally { rmSync(profile, { recursive: true, force: true }); }
48
+ }
49
+
50
+ const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
51
+ const plain = (html) => String(html)
52
+ .replace(/<!--[\s\S]*?-->/g, ' ')
53
+ .replace(CHROME_STRIP, ' ')
54
+ .replace(/<[^>]+>/g, ' ')
55
+ .replace(/&nbsp;/g, ' ')
56
+ .replace(/\s+/g, ' ')
57
+ .trim();
58
+ const countWords = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
59
+
60
+ /**
61
+ * Сравнение двух чтений одной страницы.
62
+ * @returns {{raw:number, rendered:number, hiddenShare:number, verdict:'ok'|'warn'|'bad'}}
63
+ */
64
+ export function compareReadings(rawHtml, renderedHtml) {
65
+ const raw = countWords(plain(rawHtml));
66
+ const rendered = countWords(plain(renderedHtml));
67
+ // Отрицательной разницы не бывает: если браузер увидел меньше, значит он не досчитал, а не
68
+ // страница уменьшилась. Такой случай считаем нулевой разницей, а не отрицательной долей.
69
+ const extra = Math.max(0, rendered - raw);
70
+ const hiddenShare = rendered > 0 ? Math.round((extra / rendered) * 100) : 0;
71
+ const verdict = hiddenShare >= 40 || (raw < 50 && rendered >= 200) ? 'bad' : hiddenShare >= 10 ? 'warn' : 'ok';
72
+ return { raw, rendered, hiddenShare, verdict };
73
+ }
74
+
75
+ const WORD = { ru: (n) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'слово' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'слова' : 'слов'}` };
76
+
77
+ /** Строка проверки для аудита. Текст пишется так, чтобы владелец понял, чем ему это грозит. */
78
+ export function jsBlindnessCheck(pages, lang = 'en') {
79
+ const measured = pages.filter((p) => p && p.rendered > 0);
80
+ if (!measured.length) {
81
+ return {
82
+ id: 'js-content', group: 'technical',
83
+ label: lang === 'ru' ? 'Виден ли текст без выполнения скриптов' : 'Text visible without running JavaScript',
84
+ status: 'na',
85
+ value: lang === 'ru' ? 'не измеряли: на этой машине нет браузера Chrome или Chromium' : 'not measured: no Chrome or Chromium on this machine',
86
+ comment: lang === 'ru' ? 'проверка сравнивает страницу, прочитанную без скриптов, с ней же в настоящем браузере' : 'the check compares the page read without scripts against the same page in a real browser',
87
+ };
88
+ }
89
+ const worst = measured.slice().sort((a, b) => b.hiddenShare - a.hiddenShare)[0];
90
+ const median = measured.map((p) => p.hiddenShare).sort((a, b) => a - b)[Math.floor(measured.length / 2)];
91
+ const status = measured.some((p) => p.verdict === 'bad') ? 'bad' : measured.some((p) => p.verdict === 'warn') ? 'warn' : 'ok';
92
+ const value = lang === 'ru'
93
+ ? `без скриптов видно всё, кроме ${median} процентов текста (медиана по ${measured.length} ${measured.length === 1 ? 'странице' : 'страницам'}), хуже всего ${worst.url}: там скрыто ${worst.hiddenShare} процентов, ${WORD.ru(worst.raw)} без скриптов против ${WORD.ru(worst.rendered)} в браузере`
94
+ : `${median}% of the text is invisible without JavaScript (median over ${measured.length} page${measured.length === 1 ? '' : 's'}); worst is ${worst.url} at ${worst.hiddenShare}%, ${worst.raw} words without scripts against ${worst.rendered} in a browser`;
95
+ // Слова по размеру беды: на четырнадцати процентах писать «страница почти пустая» это враньё
96
+ // в ту же сторону, от которого мы уходим.
97
+ const comment = status === 'ok'
98
+ ? (lang === 'ru' ? 'страницы читаются целиком и без браузера, это то, что нужно поиску и ИИ' : 'the pages read whole without a browser, which is what search and AI need')
99
+ : status === 'bad'
100
+ ? (lang === 'ru' ? 'для простых роботов поиска и для роботов, которые достают страницу в ответ ИИ, эта страница почти пустая: они не выполняют скрипты' : 'to simple search crawlers and to the fetchers that pull a page into an AI answer this page is nearly empty: they do not run scripts')
101
+ : (lang === 'ru' ? 'эта часть текста не доходит до роботов, которые не выполняют скрипты, а именно они приносят страницу в ответ ИИ' : 'this part of the text does not reach the robots that do not run scripts, and those are the ones that bring a page into an AI answer');
102
+ return { id: 'js-content', group: 'technical', label: lang === 'ru' ? 'Виден ли текст без выполнения скриптов' : 'Text visible without running JavaScript', status, value, comment };
103
+ }
@@ -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все тесты агентной поверхности прошли');
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /** Слепота к скриптам: считаем честно, не измерив, так и говорим. */
3
+ import { compareReadings, jsBlindnessCheck } from './rendered.mjs';
4
+
5
+ let bad = 0;
6
+ 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}`); };
7
+ const ok = (n, c) => is(n, Boolean(c), true);
8
+
9
+ const text = (n) => '<p>' + 'слово '.repeat(n) + '</p>';
10
+ const body = (inner) => `<html><body>${inner}</body></html>`;
11
+
12
+ is('одинаковые чтения дают ноль скрытого', compareReadings(body(text(300)), body(text(300))).hiddenShare, 0);
13
+ is('и вердикт «в порядке»', compareReadings(body(text(300)), body(text(300))).verdict, 'ok');
14
+
15
+ const empty = compareReadings(body('<div id="root"></div>'), body(text(300)));
16
+ is('пустой каркас против полной страницы это сто процентов', empty.hiddenShare, 100);
17
+ is('и это приговор', empty.verdict, 'bad');
18
+
19
+ const half = compareReadings(body(text(100)), body(text(200)));
20
+ is('половина текста за скриптами считается верно', half.hiddenShare, 50);
21
+ is('и это тоже приговор', half.verdict, 'bad');
22
+
23
+ is('пять процентов за скриптами это ещё в порядке', compareReadings(body(text(285)), body(text(300))).verdict, 'ok');
24
+ const tenth = compareReadings(body(text(270)), body(text(300)));
25
+ is('десятая часть текста за скриптами это уже предупреждение', tenth.verdict, 'warn');
26
+ is('и доля посчитана', tenth.hiddenShare, 10);
27
+
28
+ // Меню и подвал не считаются: их и так никто не цитирует
29
+ const chrome = compareReadings(body('<nav>меню меню меню</nav>' + text(100)), body('<nav>меню меню меню другое</nav>' + text(100)));
30
+ is('разница только в меню не считается скрытым текстом', chrome.hiddenShare, 0);
31
+
32
+ // Браузер увидел меньше: это его беда, а не отрицательная доля
33
+ is('отрицательной доли не бывает', compareReadings(body(text(300)), body(text(100))).hiddenShare, 0);
34
+
35
+ // Строка проверки
36
+ const good = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' }], 'ru');
37
+ is('чистый сайт получает «ok»', good.status, 'ok');
38
+ ok('и понятный текст', /читаются целиком/.test(good.comment));
39
+
40
+ const worst = jsBlindnessCheck([
41
+ { url: 'https://x.ru/a', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' },
42
+ { url: 'https://x.ru/b', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' },
43
+ ], 'ru');
44
+ is('одна плохая страница делает проверку проваленной', worst.status, 'bad');
45
+ ok('в тексте названа худшая страница', worst.value.includes('https://x.ru/b'));
46
+ ok('и сказано, сколько слов видно без скриптов', /10 слов без скриптов против 400/.test(worst.value));
47
+ ok('приговор говорит про роботов ИИ', /достают страницу в ответ ИИ/.test(worst.comment));
48
+
49
+ const mild = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 86, rendered: 100, hiddenShare: 14, verdict: 'warn' }], 'ru');
50
+ ok('на четырнадцати процентах не пишем «страница почти пустая»', !/почти пустая/.test(mild.comment));
51
+ ok('а пишем, что часть текста не доходит', /не доходит/.test(mild.comment));
52
+
53
+ const none = jsBlindnessCheck([], 'ru');
54
+ is('без браузера статус «не измеряли»', none.status, 'na');
55
+ ok('и сказано почему', /нет браузера/.test(none.value));
56
+
57
+ const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
58
+ ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
59
+
60
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
61
+ console.log('\nвсе тесты слепоты к скриптам прошли');