@operstack/audit 0.25.0 → 0.27.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.25.0",
3
+ "version": "0.27.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",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "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-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs",
32
+ "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-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs",
33
33
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
34
  "verify:live": "node src/verify-live.mjs"
35
35
  },
package/src/prompts.mjs CHANGED
@@ -128,6 +128,80 @@ const HEAD = {
128
128
  * @param {object} check строка проверки из аудита
129
129
  * @param {{lang?: 'ru'|'en', urls?: string[]}} opts
130
130
  */
131
+
132
+ /**
133
+ * Где именно делать правку, смотря на чём собран сайт.
134
+ *
135
+ * Простым языком. Задание «поставьте разметку организации» верно для всех, но человек на WordPress
136
+ * спрашивает «где?», и человек на Astro спрашивает то же про другое место. Раньше он искал сам.
137
+ * Здесь названо конкретное место для четырёх самых частых случаев.
138
+ *
139
+ * Платформа не угадывается: берётся из проверки `cms`, то есть из того, что сайт сам о себе сказал
140
+ * в мета-теге generator. Молчит, значит показываем все варианты, а не выбираем наугад.
141
+ *
142
+ * Добавлено 17.09.2026 после разбора конкурентов: у обоих код правки даётся под конкретный стек, и
143
+ * это единственное место, где их подсказка была полезнее нашей.
144
+ */
145
+ const WHERE = {
146
+ 'org-schema': {
147
+ wordpress: { ru: 'тема: header.php или functions.php через wp_head, либо плагин разметки', en: 'theme: header.php, or functions.php via wp_head, or a schema plugin' },
148
+ astro: { ru: 'src/layouts, ваш общий макет, внутрь <head>', en: 'src/layouts, your shared layout, inside <head>' },
149
+ next: { ru: 'app/layout.tsx, тег <script type="application/ld+json">', en: 'app/layout.tsx, a <script type="application/ld+json"> tag' },
150
+ shopify: { ru: 'theme.liquid, перед </head>', en: 'theme.liquid, before </head>' },
151
+ },
152
+ 'og-title': {
153
+ wordpress: { ru: 'плагин SEO (Yoast, Rank Math) заполняет og:title из заголовка записи', en: 'an SEO plugin (Yoast, Rank Math) fills og:title from the post title' },
154
+ astro: { ru: 'общий макет, где собирается <head>', en: 'the shared layout where <head> is assembled' },
155
+ next: { ru: 'export const metadata, поле openGraph.title', en: 'export const metadata, the openGraph.title field' },
156
+ shopify: { ru: 'theme.liquid, секция мета-тегов', en: 'theme.liquid, the meta tag section' },
157
+ },
158
+ 'og-image': {
159
+ wordpress: { ru: 'изображение записи подставляется в og:image плагином SEO', en: 'the featured image becomes og:image through the SEO plugin' },
160
+ astro: { ru: 'общий макет, поле ogImage у страницы', en: 'the shared layout, the page ogImage field' },
161
+ next: { ru: 'export const metadata, поле openGraph.images', en: 'export const metadata, the openGraph.images field' },
162
+ shopify: { ru: 'theme.liquid, мета-теги', en: 'theme.liquid, the meta tags' },
163
+ },
164
+ canonical: {
165
+ wordpress: { ru: 'плагин SEO ставит canonical сам; проверьте, не выключен ли он для этого типа записей', en: 'the SEO plugin sets canonical; check it is not switched off for this post type' },
166
+ astro: { ru: 'общий макет, <link rel="canonical">', en: 'the shared layout, <link rel="canonical">' },
167
+ next: { ru: 'export const metadata, поле alternates.canonical', en: 'export const metadata, the alternates.canonical field' },
168
+ shopify: { ru: 'theme.liquid, {{ canonical_url }}', en: 'theme.liquid, {{ canonical_url }}' },
169
+ },
170
+ favicon: {
171
+ wordpress: { ru: 'Внешний вид, Свойства сайта, Значок сайта', en: 'Appearance, Site Identity, Site Icon' },
172
+ astro: { ru: 'public/favicon.svg и <link rel="icon"> в макете', en: 'public/favicon.svg and <link rel="icon"> in the layout' },
173
+ next: { ru: 'app/icon.png или app/favicon.ico', en: 'app/icon.png or app/favicon.ico' },
174
+ shopify: { ru: 'Настройки темы, Favicon', en: 'Theme settings, Favicon' },
175
+ },
176
+ 'redirect-chain': {
177
+ wordpress: { ru: 'плагин переадресаций: замените цепочку одним правилом на конечный адрес', en: 'the redirect plugin: replace the chain with one rule to the final address' },
178
+ astro: { ru: 'vercel.json или netlify.toml, раздел redirects', en: 'vercel.json or netlify.toml, the redirects section' },
179
+ next: { ru: 'next.config.js, функция redirects()', en: 'next.config.js, the redirects() function' },
180
+ shopify: { ru: 'Интернет-магазин, Навигация, Переадресации адресов', en: 'Online Store, Navigation, URL Redirects' },
181
+ },
182
+ };
183
+
184
+ const PLATFORM_LABEL = { wordpress: 'WordPress', astro: 'Astro', next: 'Next.js', shopify: 'Shopify' };
185
+
186
+ /** На чём собран сайт, по словам самого сайта. Не угадываем: молчит, значит показываем все. */
187
+ export function detectPlatform(audit) {
188
+ const cms = (audit?.checks || []).find((c) => c.id === 'cms');
189
+ const said = String(cms?.value || '').toLowerCase();
190
+ if (/wordpress|woocommerce/.test(said)) return 'wordpress';
191
+ if (/astro/.test(said)) return 'astro';
192
+ if (/next/.test(said)) return 'next';
193
+ if (/shopify/.test(said)) return 'shopify';
194
+ return null;
195
+ }
196
+
197
+ function whereLine(id, platform, lang) {
198
+ const map = WHERE[id];
199
+ if (!map) return '';
200
+ const head = lang === 'ru' ? 'Где' : 'Where';
201
+ if (platform && map[platform]) return `**${head}:** ${PLATFORM_LABEL[platform]}, ${map[platform][lang]}`;
202
+ return `**${head}:** ${Object.entries(map).map(([k, v]) => `${PLATFORM_LABEL[k]}, ${v[lang]}`).join('; ')}`;
203
+ }
204
+
131
205
  export function agentPrompt(check, opts = {}) {
132
206
  const lang = opts.lang === 'ru' ? 'ru' : 'en';
133
207
  const h = HEAD[lang];
@@ -138,6 +212,8 @@ export function agentPrompt(check, opts = {}) {
138
212
  const lines = [`### ${check.label}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
139
213
  // Проверка обязательна у каждого задания: без неё агент не знает, когда остановиться.
140
214
  lines.push('', `**${h.verify}:** ${own ? own.verify : h.genericVerify}`);
215
+ const where = whereLine(check.id, opts.platform || null, lang);
216
+ if (where) lines.push('', where);
141
217
  if (opts.urls && opts.urls.length) { lines.push('', `**${h.pages}:** ${opts.urls.slice(0, 8).join(', ')}`); }
142
218
  lines.push('', h.rule);
143
219
  return lines.join('\n');
@@ -153,7 +229,8 @@ export function agentPrompts(audit, opts = {}) {
153
229
  const checks = source.filter((c) => c.status === 'bad' || c.status === 'warn');
154
230
  const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
155
231
  const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
156
- return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[c.id]), text: agentPrompt(c, { lang }) }));
232
+ const platform = opts.platform || detectPlatform(audit);
233
+ return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[c.id]), text: agentPrompt(c, { lang, platform }) }));
157
234
  }
158
235
 
159
236
  /** Файл, который отдаётся клиенту вместе с отчётом. */
@@ -40,6 +40,36 @@ const AI_AGENTS = [
40
40
  { agent: 'duckassistbot', label: 'DuckDuckGo AI (DuckAssistBot)', labelRu: 'DuckDuckGo AI (DuckAssistBot)', kind: 'search' },
41
41
  ];
42
42
 
43
+ /**
44
+ * Готовность по каждому движку отдельно.
45
+ *
46
+ * Простым языком. «Цитируемость 20 из 25» человеку ничего не говорит, а «Gemini 61» говорит сразу.
47
+ * Поэтому те же самые измерения пересобираются в одно число на движок.
48
+ *
49
+ * Здесь не придумано ни одного нового замера. Число собирается ТОЛЬКО из того, что мы уже
50
+ * измерили, и под каждым движком видно, из чего оно сложилось. Это принципиально: конкурент
51
+ * показывает такие числа без объяснения, откуда они, и мы за это его же и критикуем.
52
+ *
53
+ * Почему веса у движков разные. ChatGPT, Perplexity и Claude достают страницу живьём в момент
54
+ * ответа, поэтому у них решает доступ их собственным роботам и то, есть ли что процитировать.
55
+ * Gemini и Copilot отвечают из чужого индекса, Google и Bing, поэтому у них сильнее весит
56
+ * разметка: в индексе страницу сначала надо понять. Веса названы здесь и больше нигде, чтобы
57
+ * их нельзя было тихо поменять в двух местах по-разному.
58
+ *
59
+ * Неизмеренная область в расчёт не идёт вовсе, и оставшиеся веса делятся заново. Правило то же,
60
+ * что и у общего балла: не измерили, значит не считаем ни в плюс, ни в минус.
61
+ */
62
+ const LIVE_MIX = Object.freeze({ access: 0.45, content: 0.30, trust: 0.15, entity: 0.10 });
63
+ const INDEX_MIX = Object.freeze({ access: 0.30, entity: 0.30, content: 0.25, trust: 0.15 });
64
+
65
+ const ENGINES = [
66
+ { id: 'chatgpt', label: 'ChatGPT', agents: ['oai-searchbot', 'chatgpt-user'], mix: LIVE_MIX, live: true },
67
+ { id: 'perplexity', label: 'Perplexity', agents: ['perplexitybot', 'perplexity-user'], mix: LIVE_MIX, live: true },
68
+ { id: 'claude', label: 'Claude', agents: ['claude-searchbot', 'claude-user'], mix: LIVE_MIX, live: true },
69
+ { id: 'gemini', label: 'Gemini', agents: ['google-extended'], mix: INDEX_MIX, live: false },
70
+ { id: 'copilot', label: 'Copilot', agents: ['bingbot'], mix: INDEX_MIX, live: false },
71
+ ];
72
+
43
73
  /**
44
74
  * Russian plural form: pl(1, ...) is "страница", pl(2, ...) "страницы", pl(5, ...) "страниц".
45
75
  * Two shapes are needed and they differ: a bare count takes the counted form, while "из N"
@@ -66,6 +96,8 @@ export const MESSAGES = {
66
96
  rulesUnknown: ' Its robots.txt could not be read either, so there is nothing to say about AI crawlers: that is not counted for or against the site.',
67
97
  howToCheckBots: ' To know for certain, open the server log and look for GPTBot, ClaudeBot and PerplexityBot, or ask whoever runs the protection which bots are on the allowlist.',
68
98
  area: { access: 'Can AI crawlers read it', index: 'Is there a map for agents (llms.txt)', entity: 'Is the entity clear (schema)', content: 'Is there something to quote', trust: 'Can it be dated and trusted' },
99
+ engineLive: 'fetches the page live when it answers',
100
+ engineIndex: 'answers from an index, so schema weighs more',
69
101
  llmsUnreadable: (status) => `The site did not let this check read llms.txt${status ? ` (HTTP ${status})` : ''}, so whether the site has a map for agents could not be measured. It is not counted for or against the site: a file we were refused is not the same as a file that is not there.`,
70
102
  robotsMissing: 'There is no robots.txt on the site. By the standard that means nothing is disallowed, so every AI crawler may read it. A file is not required; add one only when something needs closing off.',
71
103
  sampleShort: (read, wanted) => `Only ${read} of ${wanted} pages answered in time, twice in a row, so there was not enough of the site to score. We do not show a number here: a score that goes up because we read less is a score nobody can defend. The site may simply be slow right now; run the check again in a minute.`,
@@ -117,6 +149,8 @@ export const MESSAGES = {
117
149
  rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
118
150
  howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
119
151
  area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
152
+ engineLive: 'достаёт страницу живьём в момент ответа',
153
+ engineIndex: 'отвечает из индекса, поэтому разметка весит больше',
120
154
  llmsUnreadable: (status) => `Сайт не дал этой проверке прочитать llms.txt${status ? ` (код ${status})` : ''}, поэтому есть ли у сайта карта для агентов, измерить не вышло. В плюс или в минус это не зачтено: файл, который нам не отдали, это не то же самое, что файла нет.`,
121
155
  robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
122
156
  sampleShort: (read, wanted) => `Из ${wanted} страниц ответили ${read}, и со второй попытки тоже, поэтому считать балл не по чему. Числа здесь не будет: балл, который растёт оттого, что мы прочитали меньше, защитить нельзя. Возможно, сайт сейчас просто медленный: запустите проверку через минуту.`,
@@ -316,7 +350,55 @@ function analysePage(html, url) {
316
350
  const named = Boolean(blockText.match(SOURCE_RE)) || Boolean(blockText.match(ILLUSTRATIVE_RE)) || OUTBOUND_LINK_RE.test(block);
317
351
  if (!named) unsourcedFigures += found;
318
352
  }
319
- return { url, title, description, robotsMeta, canonical, ogTitle, schemaTypes: [...types], datePublished, dateModified, words, h1, h2Count, tables, firstPara: firstPara.slice(0, 220), answerFirst, claimFigures, unsourcedFigures, noai: /noai|noimageai/.test(robotsMeta) };
353
+ // Ссылки и картинки считаются по тому же телу, из которого выброшена обвязка сайта: меню и
354
+ // подвал есть на каждой странице одинаково, и в свойствах страницы им делать нечего.
355
+ const links = (bodyHtml.match(/<a[^>]+href=/gi) || []).length;
356
+ const images = (bodyHtml.match(/<img[\s>]/gi) || []).length;
357
+ return { url, title, description, robotsMeta, canonical, ogTitle, schemaTypes: [...types], datePublished, dateModified, words, h1, h2Count, tables, links, images, firstPara: firstPara.slice(0, 220), answerFirst, claimFigures, unsourcedFigures, noai: /noai|noimageai/.test(robotsMeta) };
358
+ }
359
+
360
+
361
+ /**
362
+ * Сводит измеренное в одно число на движок. Возвращает и само число, и подписи под ним: какие
363
+ * роботы этого движка пущены, и какая из областей тянет сильнее всего вниз. Без подписей число
364
+ * было бы гаданием, а мы обещаем обратное.
365
+ */
366
+ export function engineReadiness(verdicts, parts, T, label = (v) => v.label) {
367
+ const byAgent = new Map(verdicts.map((v) => [v.agent, v]));
368
+ return ENGINES.map((e) => {
369
+ const own = e.agents.map((a) => byAgent.get(a)).filter(Boolean);
370
+ const known = own.filter((v) => v.verdict !== 'unknown');
371
+ const share = {
372
+ // Доступ считается по роботам ЭТОГО движка, а не по всем четырнадцати: закрытый Applebot
373
+ // не мешает ChatGPT процитировать страницу.
374
+ access: known.length ? known.filter((v) => v.verdict !== 'blocked').length / known.length : null,
375
+ entity: parts.entity,
376
+ content: parts.content,
377
+ trust: parts.trust,
378
+ };
379
+ let sum = 0;
380
+ let weight = 0;
381
+ for (const [key, w] of Object.entries(e.mix)) {
382
+ if (share[key] === null || share[key] === undefined) continue;
383
+ sum += share[key] * w;
384
+ weight += w;
385
+ }
386
+ const score = weight ? Math.round((sum / weight) * 100) : null;
387
+ const blocked = own.filter((v) => v.verdict === 'blocked').map(label);
388
+ // Что тянет вниз: самая слабая из измеренных областей, кроме доступа, о нём сказано отдельно.
389
+ const areasByWeak = [['content', share.content], ['trust', share.trust], ['entity', share.entity]]
390
+ .filter(([, v]) => v !== null && v !== undefined)
391
+ .sort((a, b) => a[1] - b[1]);
392
+ const weakest = areasByWeak.length && areasByWeak[0][1] < 0.999 ? T.area[areasByWeak[0][0]] : null;
393
+ return {
394
+ id: e.id,
395
+ label: e.label,
396
+ score,
397
+ how: e.live ? T.engineLive : T.engineIndex,
398
+ blocked,
399
+ weakest,
400
+ };
401
+ });
320
402
  }
321
403
 
322
404
  async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
@@ -720,8 +802,24 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
720
802
  return {
721
803
  ok: true, url: home.url, host, checkedAt: new Date().toISOString(), ms: Date.now() - started,
722
804
  score: total, grade, areas, fixes,
723
- sample: pages.map((p) => ({ url: p.url, title: p.title, words: p.words, answerFirst: p.answerFirst, dated: Boolean(p.datePublished || p.dateModified), schema: p.schemaTypes.slice(0, 4) })),
805
+ /*
806
+ * Свойства каждой прочитанной страницы. Заголовок, H1, подзаголовки, таблицы, ссылки и
807
+ * картинки считались и раньше, но наружу не отдавались, и человек видел «19 из 25», не видя,
808
+ * из чего это сложилось. Показать то, что уже измерено, дешевле, чем объяснять словами.
809
+ */
810
+ sample: pages.map((p) => ({
811
+ url: p.url, title: p.title, h1: p.h1, words: p.words, h2: p.h2Count, tables: p.tables,
812
+ links: p.links, images: p.images,
813
+ answerFirst: p.answerFirst, dated: Boolean(p.datePublished || p.dateModified),
814
+ schema: p.schemaTypes.slice(0, 4),
815
+ })),
724
816
  sitemap: { found: sitemap.found, count: sitemap.count, lastmod: sitemap.lastmod, unchecked: Boolean(!sitemap.found && (sitemap.timedOut || sitemap.skipped)) },
725
817
  crawlers: verdicts.map((v) => ({ label: agentLabel(v), kind: v.kind, verdict: v.verdict })),
818
+ engines: engineReadiness(verdicts, {
819
+ access: null, // считается внутри по роботам каждого движка
820
+ entity: entity / 20,
821
+ content: sampleOk ? content / 25 : null,
822
+ trust: sampleOk ? trust / 15 : null,
823
+ }, T, agentLabel),
726
824
  };
727
825
  }