@operstack/audit 0.36.2 → 0.38.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.
Files changed (50) hide show
  1. package/README.md +10 -0
  2. package/bin/audit.mjs +2 -2
  3. package/package.json +5 -3
  4. package/src/client-eye.mjs +389 -0
  5. package/src/collect.mjs +171 -85
  6. package/src/fix.mjs +124 -5
  7. package/src/foundation.mjs +2 -1
  8. package/src/i18n.mjs +50 -25
  9. package/src/index.mjs +2 -1
  10. package/src/narrative.mjs +56 -25
  11. package/src/niche.mjs +13 -12
  12. package/src/pagekind.mjs +18 -1
  13. package/src/presentation.mjs +3 -2
  14. package/src/prompts.mjs +163 -42
  15. package/src/render.mjs +61 -30
  16. package/src/rendered.mjs +6 -2
  17. package/src/tasks-pdf.mjs +10 -5
  18. package/src/verify.mjs +26 -13
  19. package/src/visibility.mjs +137 -45
  20. package/src/vy.mjs +13 -0
  21. package/src/test-agency-ids.mjs +0 -48
  22. package/src/test-agency.mjs +0 -158
  23. package/src/test-agent-surface.mjs +0 -46
  24. package/src/test-answer-first.mjs +0 -27
  25. package/src/test-blocked.mjs +0 -100
  26. package/src/test-crawl-links.mjs +0 -87
  27. package/src/test-demand.mjs +0 -243
  28. package/src/test-etalon.mjs +0 -42
  29. package/src/test-fix.mjs +0 -115
  30. package/src/test-flaky-files.mjs +0 -81
  31. package/src/test-foundation.mjs +0 -126
  32. package/src/test-handover.mjs +0 -148
  33. package/src/test-i18n.mjs +0 -68
  34. package/src/test-narrative.mjs +0 -117
  35. package/src/test-overall.mjs +0 -236
  36. package/src/test-planted.mjs +0 -114
  37. package/src/test-presentation.mjs +0 -67
  38. package/src/test-prompts.mjs +0 -142
  39. package/src/test-prospect-count.mjs +0 -12
  40. package/src/test-redirect-host.mjs +0 -63
  41. package/src/test-rendered.mjs +0 -136
  42. package/src/test-report-wording.mjs +0 -35
  43. package/src/test-ru-report.mjs +0 -95
  44. package/src/test-ru-sources.mjs +0 -41
  45. package/src/test-sample-fairness.mjs +0 -50
  46. package/src/test-sample.mjs +0 -91
  47. package/src/test-scores.mjs +0 -75
  48. package/src/test-shopify-store.mjs +0 -159
  49. package/src/test-sitemap.mjs +0 -91
  50. package/src/test-unmeasured.mjs +0 -173
@@ -21,22 +21,25 @@
21
21
  */
22
22
 
23
23
  const UA = 'Mozilla/5.0 (compatible; OperStackVisibility/0.1; +https://oper-stack.com/ai-visibility/)';
24
- const MAX_BODY = 600_000;
24
+ // 2,5 МБ: у страниц на Tilda и Wix шапка со скриптами бывает больше мегабайта, и при 600 КБ текст страницы не
25
+ // попадал в чтение вовсе (svetak.ru, 1,3 МБ, 15.10.2026). Не влезла и так: страница помечается и в счёт текста не идёт.
26
+ const MAX_BODY = 2_500_000;
25
27
 
28
+ // Обучающий робот подписан словом «обучение»: «Claude (ClaudeBot)» читалось как робот ответов Claude (etochay.ru, 15.10.2026).
26
29
  const AI_AGENTS = [
27
30
  { agent: 'oai-searchbot', label: 'ChatGPT search (OAI-SearchBot)', labelRu: 'Поиск ChatGPT (OAI-SearchBot)', kind: 'search' },
28
31
  { agent: 'chatgpt-user', label: 'ChatGPT browsing (ChatGPT-User)', labelRu: 'Просмотр страниц ChatGPT (ChatGPT-User)', kind: 'search' },
29
32
  { agent: 'gptbot', label: 'OpenAI training (GPTBot)', labelRu: 'Обучение OpenAI (GPTBot)', kind: 'train' },
30
- { agent: 'perplexitybot', label: 'Perplexity (PerplexityBot)', labelRu: 'Perplexity (PerplexityBot)', kind: 'search' },
33
+ { agent: 'perplexitybot', label: 'Perplexity search (PerplexityBot)', labelRu: 'Поиск Perplexity (PerplexityBot)', kind: 'search' },
31
34
  { agent: 'perplexity-user', label: 'Perplexity browsing (Perplexity-User)', labelRu: 'Просмотр страниц Perplexity (Perplexity-User)', kind: 'search' },
32
- { agent: 'claudebot', label: 'Claude (ClaudeBot)', labelRu: 'Claude (ClaudeBot)', kind: 'train' },
35
+ { agent: 'claudebot', label: 'Claude training (ClaudeBot)', labelRu: 'Обучение Claude (ClaudeBot)', kind: 'train' },
33
36
  { agent: 'claude-searchbot', label: 'Claude search (Claude-SearchBot)', labelRu: 'Поиск Claude (Claude-SearchBot)', kind: 'search' },
34
37
  { agent: 'claude-user', label: 'Claude browsing (Claude-User)', labelRu: 'Просмотр страниц Claude (Claude-User)', kind: 'search' },
35
- { agent: 'anthropic-ai', label: 'Anthropic (anthropic-ai)', labelRu: 'Anthropic (anthropic-ai)', kind: 'train' },
38
+ { agent: 'anthropic-ai', label: 'Anthropic training (anthropic-ai)', labelRu: 'Обучение Anthropic (anthropic-ai)', kind: 'train' },
36
39
  { agent: 'google-extended', label: 'Gemini grounding (Google-Extended)', labelRu: 'Gemini (Google-Extended)', kind: 'train' },
37
40
  { agent: 'bingbot', label: 'Copilot and Bing (Bingbot)', labelRu: 'Copilot и Bing (Bingbot)', kind: 'search' },
38
41
  { agent: 'applebot-extended', label: 'Apple Intelligence (Applebot-Extended)', labelRu: 'Apple Intelligence (Applebot-Extended)', kind: 'train' },
39
- { agent: 'ccbot', label: 'Common Crawl (CCBot)', labelRu: 'Common Crawl (CCBot)', kind: 'train' },
42
+ { agent: 'ccbot', label: 'Common Crawl training sets (CCBot)', labelRu: 'Наборы для обучения Common Crawl (CCBot)', kind: 'train' },
40
43
  { agent: 'duckassistbot', label: 'DuckDuckGo AI (DuckAssistBot)', labelRu: 'DuckDuckGo AI (DuckAssistBot)', kind: 'search' },
41
44
  ];
42
45
 
@@ -83,6 +86,16 @@ const pl = (n, one, few, many) => { const a = Math.abs(n) % 100; const b = a % 1
83
86
  const ofPages = (n) => (n === 1 ? 'проверенной страницы' : 'проверенных страниц');
84
87
 
85
88
  /** Exported so the offline test can assert the Russian forms without hitting the network. */
89
+ /** Content-Signal человеческими словами: «search=yes, ai-input=yes, ai-train=no» → «поиск и ответы ИИ, обучение нет». */
90
+ function signalWords(signal, lang) {
91
+ const W = lang === 'ru' ? { search: 'поиск', 'ai-input': 'ответы ИИ', 'ai-train': 'обучение' } : { search: 'search', 'ai-input': 'AI answers', 'ai-train': 'training' };
92
+ const pairs = String(signal || '').split(/[,;]\s*/).map((x) => x.split('=').map((y) => y.trim().toLowerCase())).filter(([k]) => W[k]);
93
+ const yes = pairs.filter(([, v]) => v === 'yes').map(([k]) => W[k]); const no = pairs.filter(([, v]) => v === 'no').map(([k]) => W[k]);
94
+ const and = lang === 'ru' ? ' и ' : ' and ';
95
+ const join = (xs) => (xs.length > 1 ? `${xs.slice(0, -1).join(', ')}${and}${xs[xs.length - 1]}` : xs[0] || '');
96
+ if (!pairs.length) return lang === 'ru' ? `то, что в ней указано: ${signal}` : `what it says: ${signal}`;
97
+ return `${join(yes) || (lang === 'ru' ? 'ничего' : 'nothing')}${no.length ? (lang === 'ru' ? `; запрещает ${join(no)}` : `; it forbids ${join(no)}`) : ''}`;
98
+ }
86
99
  export const MESSAGES = {
87
100
  en: {
88
101
  badInput: 'Enter a public site address, for example example.com',
@@ -108,12 +121,13 @@ export const MESSAGES = {
108
121
  fetchersBlocked: (list) => `Blocked answer-engine fetchers: ${list}. These are the bots that cite pages live.`,
109
122
  trainingBlocked: (list) => `Blocked training crawlers: ${list}. Models will not learn the brand from the site.`,
110
123
  noaiMeta: 'A page carries a noai robots meta tag.',
111
- allAllowed: (n, signal) => `All ${n} AI crawlers and fetchers are allowed${signal ? ` (Content-Signal: ${signal})` : ''}.`,
112
- llmsMissing: 'No llms.txt. Answer engines get no map of what the site is and which pages matter.',
124
+ homeWord: 'the home page',
125
+ allAllowed: (n, signal) => `All ${n} AI crawlers and fetchers are allowed${signal ? ` (the site's Content-Signal line allows ${signalWords(signal, 'en')})` : ''}.`,
126
+ llmsMissing: 'No llms.txt. AI assistants get no short map of what the site is and which pages matter.',
113
127
  llmsNotText: '/llms.txt answers with an HTML page instead of a text index.',
114
128
  llmsForeign: (hosts) => `llms.txt links mostly to other hosts (${hosts}). An AI system may misidentify the business.`,
115
- llmsOk: (n) => `llms.txt present with ${n} link(s) to the site's own pages.`,
116
- orgOk: 'Organization or business schema names the entity behind the site.',
129
+ llmsOk: (n) => `llms.txt present with ${n} ${n === 1 ? 'link' : 'links'} to the site's own pages.`,
130
+ orgOk: 'Organization schema names the business behind the site.',
117
131
  orgMissing: 'No Organization or business schema: AI systems have no entity to attach the site to.',
118
132
  faqOk: 'FAQPage schema found, the easiest format for an engine to quote.',
119
133
  faqMissing: 'No FAQPage schema on the sampled pages.',
@@ -122,19 +136,20 @@ export const MESSAGES = {
122
136
  pageTypeMissing: 'The sampled pages carry no page-level type (Article, Service, Product, Course and the like), so an engine cannot say what kind of thing it is quoting.',
123
137
  canonicalMissing: 'The homepage has no canonical tag.',
124
138
  ogMissing: 'No Open Graph tags on the homepage: shared links and previews render without a title or image.',
125
- thinPages: (thin, total) => `${thin} of ${total} sampled page(s) hold under 300 words. Engines rarely cite thin pages.`,
139
+ thinPages: (thin, total) => `${thin} of ${total} sampled ${total === 1 ? 'page holds' : 'pages hold'} under 300 words. Engines rarely cite thin pages.`,
126
140
  avgWords: (avg) => `Sampled pages carry ${avg} words on average.`,
127
- answerFirstMissing: (n, total, where) => `${n} of ${total} measured page(s) open with an answer-first paragraph (20 to 90 words with a figure right after the H1). That paragraph is what gets quoted. Missing on: ${where}.`,
141
+ wordsOne: (n) => `The page read carries ${n} words.`,
142
+ answerFirstMissing: (n, total, where) => `${n} of ${total} measured ${total === 1 ? 'page opens' : 'pages open'} with an answer-first paragraph (20 to 90 words with a figure right after the H1). That paragraph is what gets quoted. Missing on: ${where}.`,
128
143
  answerFirstOk: 'Every sampled page opens with an answer-first paragraph carrying a figure.',
129
- fewH2: (n) => `${n} sampled page(s) have fewer than three H2 sections.`,
130
- noTables: 'No tables on the measured pages. Tables are the second most quoted format after the first paragraph.',
131
- measured: (list) => `Measured on these pages: ${list}. Every content finding is a share of them, so a fix on one page moves the score by its share.`,
132
- homeOnly: 'Only the homepage could be read, so this area is measured on one page rather than several. A sitemap that answers would give a fuller picture.',
133
- datesMissing: (n) => `${n} sampled page(s) expose no publication or modified date. Engines prefer sources they can date.`,
134
- datesOk: 'Sampled pages expose publication dates.',
135
- noFigures: 'The sampled pages state no figures, so there is nothing that needs a source named.',
136
- sourcesMissing: (n, total) => `${n} of ${total} sampled page(s) state figures without naming where they came from ("according to", "data from").`,
137
- sourcesOk: 'Every sampled page that states figures names where they came from.',
144
+ fewH2: (n) => `${n} sampled ${n === 1 ? 'page has' : 'pages have'} fewer than three H2 sections.`,
145
+ noTables: 'No tables on the measured pages. After the first paragraph, tables are what gets quoted most.',
146
+ measured: (list, skipped = '', legal = '') => `Measured on these pages: ${list}. Every content finding is a share of them, so a fix on one page moves the score by its share.${skipped ? ` Read but not measured: ${skipped}.` : ''}${legal ? ` Legal pages need no answer paragraph, so that check leaves out ${legal}.` : ''}`,
147
+ homeOnly: 'Only the homepage could be read, so this area is measured on one page rather than several. If the sitemap (sitemap.xml) opened, the check would read more pages.',
148
+ datesMissing: (n) => `${n} sampled ${n === 1 ? 'page carries' : 'pages carry'} no publication or modified date in the markup (datePublished, dateModified). Engines prefer sources they can date.`,
149
+ datesOk: 'Sampled pages carry publication dates in the markup.',
150
+ noFigures: 'The sampled pages state no statistics (shares, counts, comparisons) that would need a source named; prices and contact details need none.',
151
+ sourcesMissing: (n, total) => `${n} of ${total} sampled ${total === 1 ? 'page states' : 'pages state'} figures without naming where they came from ("according to", "data from").`,
152
+ sourcesOk: 'Every sampled page that states statistics names where they came from.',
138
153
  sitemapNoLastmod: 'The sitemap carries no lastmod dates.',
139
154
  sitemapTimedOut: 'The sitemap did not answer in time, so it was not checked. The score does not count this either way.',
140
155
  sitemapMissing: 'No XML sitemap found at the usual paths or in robots.txt.',
@@ -160,12 +175,13 @@ export const MESSAGES = {
160
175
  fetchersBlocked: (list) => `Закрыты поисковые роботы ответных систем: ${list}. Именно они достают страницу, чтобы процитировать её в ответе.`,
161
176
  trainingBlocked: (list) => `Закрыты обучающие роботы: ${list}. Модели не узнают о бренде с сайта.`,
162
177
  noaiMeta: 'На одной из страниц стоит мета-тег robots со значением noai.',
163
- allAllowed: (n, signal) => `Все ${n} роботов ИИ допущены на сайт${signal ? ` (Content-Signal: ${signal})` : ''}.`,
164
- llmsMissing: 'Нет llms.txt. Ответные системы не получают карты: что это за сайт и какие страницы главные.',
178
+ homeWord: 'главная',
179
+ allAllowed: (n, signal) => `Все ${n} роботов ИИ допущены на сайт${signal ? ` (строка Content-Signal разрешает ${signalWords(signal, 'ru')})` : ''}.`,
180
+ llmsMissing: 'Нет llms.txt. Системы ИИ не получают короткой карты сайта: что это за сайт и какие страницы главные.',
165
181
  llmsNotText: '/llms.txt отдаёт HTML-страницу вместо текстового указателя.',
166
182
  llmsForeign: (hosts) => `Ссылки в llms.txt ведут в основном на чужие домены (${hosts}). Система ИИ может перепутать, чей это бизнес.`,
167
183
  llmsOk: (n) => `llms.txt есть, в нём ${n} ${pl(n, 'ссылка', 'ссылки', 'ссылок')} на страницы самого сайта.`,
168
- orgOk: 'Разметка Organization или бизнеса называет, кто стоит за сайтом.',
184
+ orgOk: 'Разметка организации (Organization) называет, кто стоит за сайтом.',
169
185
  orgMissing: 'Нет разметки Organization или бизнеса: системам ИИ не к кому привязать сайт.',
170
186
  faqOk: 'Найдена разметка FAQPage, самый удобный для цитирования формат.',
171
187
  faqMissing: 'На проверенных страницах нет разметки FAQPage.',
@@ -178,6 +194,7 @@ export const MESSAGES = {
178
194
  ? 'Единственная проверенная страница короче 300 слов. Короткие страницы цитируют редко.'
179
195
  : `Из ${total} ${ofPages(total)} ${thin} короче 300 слов. Короткие страницы цитируют редко.`),
180
196
  avgWords: (avg) => `На проверенных страницах в среднем ${avg} ${pl(avg, 'слово', 'слова', 'слов')}.`,
197
+ wordsOne: (n) => `На прочитанной странице ${n} ${pl(n, 'слово', 'слова', 'слов')}.`,
181
198
  answerFirstMissing: (n, total, where) => (total === 1
182
199
  ? `Единственная проверенная страница не начинается с абзаца-ответа (20-90 слов с цифрой сразу после H1). Именно этот абзац попадает в цитату. Чинить здесь: ${where}.`
183
200
  : `Из ${total} ${ofPages(total)} с абзаца-ответа (20-90 слов с цифрой сразу после H1) ${n === 0 ? 'не начинается ни одна' : n === 1 ? 'начинается одна' : `начинаются ${n}`}. Именно этот абзац попадает в цитату. Чинить здесь: ${where}.`),
@@ -185,18 +202,18 @@ export const MESSAGES = {
185
202
  fewH2: (n, total) => (total === 1
186
203
  ? 'У единственной проверенной страницы меньше трёх подзаголовков H2.'
187
204
  : `У ${n} из ${total} ${ofPages(total)} меньше трёх подзаголовков H2.`),
188
- noTables: 'На проверенных страницах нет таблиц. Таблица второй по цитируемости формат после первого абзаца.',
189
- measured: (list) => `Мерили по этим страницам: ${list}. Каждая находка по содержанию считается долей от них, поэтому правка на одной странице двигает балл на свою долю.`,
190
- homeOnly: 'Прочитать удалось только главную, поэтому эта область измерена по одной странице, а не по нескольким. Отвечающая карта сайта дала бы полную картину.',
205
+ noTables: 'На проверенных страницах нет таблиц. После первого абзаца таблицы цитируют чаще всего.',
206
+ measured: (list, skipped = '', legal = '') => `Мерили по этим страницам: ${list}. Каждая находка по содержанию считается долей от них, поэтому правка на одной странице двигает балл на свою долю.${skipped ? ` Прочитали, но не мерили: ${skipped}.` : ''}${legal ? ` Юридическим страницам абзац-ответ не нужен, поэтому эта проверка не считает ${legal}.` : ''}`,
207
+ homeOnly: 'Прочитать удалось только главную, поэтому эта область измерена по одной странице, а не по нескольким. Если карта сайта (sitemap.xml) будет открываться, проверка прочитает больше страниц.',
191
208
  datesMissing: (n, total) => (total === 1
192
- ? 'Единственная проверенная страница не показывает дату публикации или изменения. Системы предпочитают источники, которые можно датировать.'
193
- : `${n} из ${total} ${ofPages(total)} не ${n === 1 ? 'показывает' : 'показывают'} дату публикации или изменения. Системы предпочитают источники, которые можно датировать.`),
194
- datesOk: 'Проверенные страницы показывают даты публикации.',
195
- noFigures: 'На проверенных страницах нет цифр, поэтому и источники называть нечему.',
209
+ ? 'В разметке единственной проверенной страницы нет даты публикации или изменения (datePublished, dateModified). Системы предпочитают источники, которые можно датировать.'
210
+ : `${n} из ${total} ${ofPages(total)} не ${n === 1 ? 'указывает' : 'указывают'} в разметке дату публикации или изменения (datePublished, dateModified). Системы предпочитают источники, которые можно датировать.`),
211
+ datesOk: 'Проверенные страницы указывают даты публикации в разметке.',
212
+ noFigures: 'На проверенных страницах нет цифр статистики (долей, количеств, сравнений), которым нужен источник; ценам и контактам источник не нужен.',
196
213
  sourcesMissing: (n, total) => (total === 1
197
214
  ? 'Единственная проверенная страница с цифрами не называет, откуда они («по данным», «источник»).'
198
215
  : `${n} из ${total} ${ofPages(total)} ${n === 1 ? 'приводит' : 'приводят'} цифры, не называя, откуда они («по данным», «источник»).`),
199
- sourcesOk: 'Каждая проверенная страница с цифрами называет их источник.',
216
+ sourcesOk: 'Каждая проверенная страница с цифрами статистики называет их источник.',
200
217
  sitemapNoLastmod: 'В карте сайта нет дат изменения (lastmod).',
201
218
  sitemapTimedOut: 'Карта сайта не ответила вовремя, поэтому её не проверяли. На оценку это не влияет ни в какую сторону.',
202
219
  sitemapMissing: 'XML-карта сайта не найдена ни по обычным адресам, ни в robots.txt.',
@@ -325,7 +342,9 @@ async function get(url, { timeout = 6000, method = 'GET' } = {}) {
325
342
  finally { clearTimeout(t); }
326
343
  }
327
344
 
328
- const strip = (s) => s.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
345
+ // Страница длиннее предела чтения обрывается посреди <script>: хвост скрипта шёл в текст как десятки тысяч «слов»
346
+ // (svetak.ru: 65 778 слов на главной вместо ~1 200, прогон на 20 сайтах 15.10.2026). Незакрытый скрипт в конце вырезаем.
347
+ const strip = (s) => s.replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/gi, ' ').replace(/<(script|style)\b[\s\S]*$/i, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
329
348
  const attr = (html, re) => { const m = html.match(re); return m ? m[1].trim() : ''; };
330
349
 
331
350
  export function parseRobots(text) {
@@ -414,13 +433,12 @@ function analysePage(html, url) {
414
433
  const bodyHtml = stripChrome((html.match(/<body[\s\S]*<\/body>/i) || [html])[0]);
415
434
  const text = strip(bodyHtml);
416
435
  const words = (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
436
+ const truncated = !/<\/body>/i.test(html) && html.length >= MAX_BODY - 10;
417
437
  const h1 = strip((bodyHtml.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) || ['', ''])[1]);
418
438
  const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
419
439
  const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
420
- const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
421
- const firstPara = strip((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
422
- const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
423
- const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && hasFigure(firstPara);
440
+ // Абзац-ответ под H1 тем же правилом, что в платном отчёте и проверке глазами клиента (leadAnswer в visibility.mjs, 15.10.2026).
441
+ const { firstPara, firstParaWords, answerFirst } = leadAnswer(html);
424
442
  /*
425
443
  * Цифру и её источник ищем в одном блоке, а не по всей странице: иначе одно «по данным» внизу
426
444
  * прикрывает десять неподписанных чисел сверху. Таблица берётся целиком, потому что подпись
@@ -443,7 +461,7 @@ function analysePage(html, url) {
443
461
  // подвал есть на каждой странице одинаково, и в свойствах страницы им делать нечего.
444
462
  const links = (bodyHtml.match(/<a[^>]+href=/gi) || []).length;
445
463
  const images = (bodyHtml.match(/<img[\s>]/gi) || []).length;
446
- 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) };
464
+ return { url, truncated, 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) };
447
465
  }
448
466
 
449
467
 
@@ -844,7 +862,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
844
862
  const PAGE_TYPES = /^(Service|Product|Course|Event|HowTo|Recipe|SoftwareApplication|WebApplication|MobileApplication|JobPosting|Book|Review|QAPage|MedicalWebPage|CollectionPage|ItemPage|AboutPage|ProfilePage)$/;
845
863
  const pageType = [...allTypes].find((t) => PAGE_TYPES.test(t)) || null;
846
864
  let entity = 0; const entityFindings = [];
847
- if (hasOrg) { entity += 8; entityFindings.push({ level: 'pass', text: T.orgOk }); } else entityFindings.push({ id: 'schema-org-missing', level: 'fail', text: T.orgMissing });
865
+ if (hasOrg) { entity += 8; entityFindings.push({ id: 'schema-org-ok', level: 'pass', text: T.orgOk }); } else entityFindings.push({ id: 'schema-org-missing', level: 'fail', text: T.orgMissing });
848
866
  if (hasFaq) { entity += 5; entityFindings.push({ level: 'pass', text: T.faqOk }); } else entityFindings.push({ id: 'schema-faq-missing', level: 'warn', text: T.faqMissing });
849
867
  if (hasArticle) {
850
868
  entity += 3;
@@ -868,29 +886,49 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
868
886
  * Живой пользователь добавил таблицу на главную, балл не сдвинулся, и он не понял почему.
869
887
  * Понять было нельзя: мы туда не смотрели.
870
888
  */
871
- const contentPages = [homePage, ...sampled];
889
+ /*
890
+ * Служебные страницы и повтор главной не считаются страницами с текстом (проверяющий, круг 3, 15.10.2026): /404,
891
+ * личный кабинет /lk, /sitemap.php, политика cookie и «/home» с тем же текстом, что главная, сдвигали все доли
892
+ * «N из 10». Страница, прочитанная не целиком (truncated), тоже не идёт в счёт текста.
893
+ */
894
+ const SERVICE_PATH = /\/(404|lk|login|signin|account|cabinet|kabinet|cart|korzina|checkout|search|poisk|sitemap(?:\.php|\.xml)?|cookies?|cookie-policy)(\/|$|\.)/i;
895
+ const pathOfU = (u) => { try { return new URL(u).pathname; } catch { return ''; } };
896
+ const sameAsHome = (p) => p !== homePage && homePage && /^\/(home|index(\.html?)?|main|glavnaya)\/?$/i.test(pathOfU(p.url)) && Math.abs((p.words || 0) - (homePage.words || 0)) <= Math.max(10, (homePage.words || 0) * 0.05);
897
+ const contentPages = [homePage, ...sampled].filter((p, i) => i === 0 || (!SERVICE_PATH.test(pathOfU(p.url)) && !sameAsHome(p) && !p.truncated));
872
898
  /* Say so. A score built from one page is not the same measurement as one built from four. */
873
899
  const homeOnly = !sampled.length;
874
900
  const thin = contentPages.filter((p) => p.words < 300).length;
875
- const answerFirst = contentPages.filter((p) => p.answerFirst).length;
901
+ // Юридическим страницам ответ с цифрой не нужен, как в платном отчёте (pagekind.mjs): в счёт ответа их не берём
902
+ // (проверяющий: «/terms в списке страниц, где нужен абзац с цифрой», moregroup.estate, 15.10.2026).
903
+ const LEGAL_PAGE = /\/(privacy|privacy-policy|terms|terms-of-service|terms-and-conditions|tos|legal|cookies?|cookie-policy|disclaimer|impressum|politika|politika-konfidencialnosti|policy|policies|soglasie|soglashenie|oferta|offer-agreement)(\/|$|\.)/i;
904
+ const answerPages = contentPages.filter((p) => { try { return !LEGAL_PAGE.test(new URL(p.url).pathname); } catch { return true; } });
905
+ const answerBase = answerPages.length ? answerPages : contentPages;
906
+ const answerFirst = answerBase.filter((p) => p.answerFirst).length;
876
907
  const structured = contentPages.filter((p) => p.h2Count >= 3).length;
877
908
  const withTables = contentPages.filter((p) => p.tables > 0).length;
878
909
  let content = 0; const contentFindings = [];
879
910
  content += Math.round(10 * (1 - thin / contentPages.length));
880
- if (thin) contentFindings.push({ id: 'thin-pages', level: thin === contentPages.length ? 'fail' : 'warn', text: T.thinPages(thin, contentPages.length) }); else contentFindings.push({ level: 'pass', text: T.avgWords(Math.round(contentPages.reduce((a, p) => a + p.words, 0) / contentPages.length)) });
881
- content += Math.round(8 * (answerFirst / contentPages.length));
911
+ if (thin) contentFindings.push({ id: 'thin-pages', level: thin === contentPages.length ? 'fail' : 'warn', text: T.thinPages(thin, contentPages.length) }); else contentFindings.push({ level: 'pass', text: (contentPages.length === 1 ? T.wordsOne : T.avgWords)(Math.round(contentPages.reduce((a, p) => a + p.words, 0) / contentPages.length)) });
912
+ content += Math.round(8 * (answerFirst / answerBase.length));
882
913
  // Адрес страницы в находке это разница между «полдня искали» и «поправил за минуту»: живой
883
914
  // пользователь 16.09.2026 потратил полдня, чтобы понять, какая именно страница виновата.
884
- const shortPath = (u) => { try { const x = new URL(u); return (x.pathname === '/' ? '/' : x.pathname.replace(/\/$/, '')); } catch { return u; } };
915
+ // Главная словом, а не «/» (проверяющий: «Мерили по этим страницам: /.», 15.10.2026).
916
+ // Кириллический адрес буквами, а не «%D0%BA…» (gruzotaksi.by, 15.10.2026).
917
+ const shortPath = (u) => { try { const x = new URL(u); const p = (() => { try { return decodeURI(x.pathname); } catch { return x.pathname; } })(); return (p === '/' ? T.homeWord : p.replace(/\/$/, '')); } catch { return u; } };
885
918
  const pageList = (list) => list.map(shortPath).join(', ');
886
- if (answerFirst < contentPages.length) contentFindings.push({ id: 'answer-first-missing', level: answerFirst ? 'warn' : 'fail', text: T.answerFirstMissing(answerFirst, contentPages.length, pageList(contentPages.filter((p) => !p.answerFirst).map((p) => p.url))) }); else contentFindings.push({ level: 'pass', text: T.answerFirstOk });
919
+ if (answerFirst < answerBase.length) contentFindings.push({ id: 'answer-first-missing', level: answerFirst ? 'warn' : 'fail', text: T.answerFirstMissing(answerFirst, answerBase.length, pageList(answerBase.filter((p) => !p.answerFirst).map((p) => p.url))) }); else contentFindings.push({ level: 'pass', text: T.answerFirstOk });
887
920
  content += Math.round(4 * (structured / contentPages.length)) + Math.round(3 * (withTables / contentPages.length));
888
921
  if (structured < contentPages.length) contentFindings.push({ id: 'few-h2', level: 'warn', text: T.fewH2(contentPages.length - structured, contentPages.length) });
889
922
  if (!withTables) contentFindings.push({ id: 'no-tables', level: 'warn', text: T.noTables });
890
923
  if (homeOnly) contentFindings.push({ level: 'warn', text: T.homeOnly });
891
924
  // Какие именно страницы мы мерили. Без этой строки человек чинит витрину, видит движение балла
892
925
  // на один пункт и не понимает, почему: находки считаются долей от всех измеренных страниц.
893
- contentFindings.push({ level: 'pass', text: T.measured(pageList(contentPages.map((p) => p.url))) });
926
+ // Пояснение, а не проверка: id measured-on, сайты не считают его пройденной проверкой и показывают без «есть» (15.10.2026).
927
+ // Почему прочитано 10, а мерили по 8: называем пропущенные страницы и причину (прогон на 20 сайтах, 15.10.2026).
928
+ const why = (p) => (SERVICE_PATH.test(pathOfU(p.url)) ? (T === MESSAGES.ru ? 'служебная' : 'a utility page') : sameAsHome(p) ? (T === MESSAGES.ru ? 'копия главной' : 'a copy of the homepage') : (T === MESSAGES.ru ? 'страница слишком тяжёлая, текст не прочитался целиком' : 'too heavy to read whole'));
929
+ const skippedPages = [homePage, ...sampled].filter((p) => p && !contentPages.includes(p));
930
+ const legalPages = contentPages.filter((p) => !answerPages.includes(p));
931
+ contentFindings.push({ id: 'measured-on', level: 'pass', text: T.measured(pageList(contentPages.map((p) => p.url)), skippedPages.map((p) => `${shortPath(p.url)} (${why(p)})`).join(', '), answerPages.length && legalPages.length ? pageList(legalPages.map((p) => p.url)) : '') });
894
932
 
895
933
  // Area 5: freshness and sources (15)
896
934
  const dated = contentPages.filter((p) => p.datePublished || p.dateModified).length;
@@ -989,3 +1027,57 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
989
1027
  }, T, agentLabel),
990
1028
  };
991
1029
  }
1030
+
1031
+ /**
1032
+ * Абзац-ответ под H1 (answer-first): одно правило для бесплатной проверки (visibility.mjs), отчёта (collect.mjs) и
1033
+ * проверки глазами клиента (client-eye.mjs). Живёт здесь, потому что visibility.mjs стоит на сайтах одним файлом без зависимостей.
1034
+ *
1035
+ * До 15.10.2026 правил было три, и они спорили между собой: бесплатная проверка смотрела только первый <p> после H1,
1036
+ * отчёт ещё второй и третий абзац, блок фактов и предложение в div у Tilda, а проверка глазами клиента засчитывала
1037
+ * любой абзац от 10 слов с числом. На прогоне по 20 сайтам отчёт писал «ответа с цифрой нет», а проверка глазами
1038
+ * клиента тут же находила его на странице (plitex-s.ru, svetak.ru).
1039
+ *
1040
+ * near: короткая фраза с цифрой под H1, которая ответом ещё не считается («Сдаем в аренду 2 студии в Москве…»,
1041
+ * 15 слов). Отчёт тогда пишет не «добавьте абзац», а «разверните эту фразу в абзац на 20-90 слов»: владелец видит
1042
+ * фразу на своей странице, и совет «добавить» читается как ошибка.
1043
+ */
1044
+ const leadPlain = (x) => String(x || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/g, ' ').replace(/\s+/g, ' ').trim();
1045
+ const leadWords = (t) => (String(t || '').match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
1046
+ // Цифра ответа это число, а не часть слова: «HTML5, CSS3» и «1С» цифрой не считаются (cimes.ru, 15.10.2026).
1047
+ export const LEAD_FIGURE = /(?<![\p{L}\d])\d+(?:[\s., ]\d+)*(?![\p{L}\d])/u;
1048
+ // В предложении вне абзаца число должно быть фактом: от двух цифр или с единицей («10 лет», «2–3 недели», «$35»).
1049
+ // «Курорт 2.0» это номер версии, не ответ (cimes.ru /solutions/aspro-kurort/).
1050
+ const LEAD_FACT = /(?<![\p{L}\d.,])(?:\d{2,}|\d+(?:[.,]\d+)?\s?(?:%|₽|\$|€|£|руб|тыс|млн|лет|год|мес|нед|дн|час|мин|шт|раз|км|кв|year|month|week|day|hour|minute|times))/iu;
1051
+ const LEAD_SENTENCES = /(?<=[.!?])\s+/;
1052
+
1053
+ export function leadAnswer(html) {
1054
+ const body = (String(html || '').match(/<body[\s\S]*<\/body>/i) || [String(html || '')])[0];
1055
+ // Вводный блок читается с <aside>: «In one sentence» (oper-stack.com/pricing/) и «Главное» (moregroupestate.ru) стоят
1056
+ // сразу под H1 в aside (прогон на своих сайтах, 15.10.2026).
1057
+ const leadSource = body.replace(/<(nav|footer|script|style)[\s\S]*?<\/\1>/gi, ' ');
1058
+ const hasH1 = /<\/h1>/i.test(leadSource);
1059
+ const afterH1 = leadSource.split(/<\/h1>/i)[1] || '';
1060
+ // Вводный блок это все абзацы до первого подзаголовка, кнопки или формы: на cimes.ru ответ стоял двумя абзацами,
1061
+ // описание на 19 слов и «Визитка от 28 000 ₽ · … Смета за 1–2 дня»; вместе они укладываются в 20-90 слов.
1062
+ const leadZone = afterH1.split(/<h[2-6]\b|<button\b|<form\b|<a\b[^>]*class=["'][^"']*\bbtn\b/i)[0];
1063
+ const leadParas = [...leadZone.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => leadPlain(m[1])).filter(Boolean);
1064
+ const leadBlock = leadParas.join(' ');
1065
+ const onePara = leadPlain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
1066
+ // Без H1 первый абзац берём из тела страницы без шапки, меню и боковой колонки (14.10.2026, medeq-mc.ru).
1067
+ const chromeless = body.replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<header[\s\S]*?<\/header>/gi, (b) => (/<h1[\s>]/i.test(b) ? b : ' '));
1068
+ const firstPara = (leadParas.length > 1 && leadWords(onePara) < 20 && leadWords(leadBlock) <= 90 ? leadBlock : onePara)
1069
+ || ([...chromeless.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => leadPlain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
1070
+ const firstParaWords = leadWords(firstPara);
1071
+ // Видимый текст вводного блока без ссылок и кнопок (у Tilda текст в div, а не в <p>), первые 90 слов.
1072
+ const leadText = leadPlain(leadZone.replace(/<(script|style|svg|noscript|nav|template)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<(a|button|label|select)\b[\s\S]*?<\/\1>/gi, ' ')).split(/\s+/).filter(Boolean).slice(0, 90).join(' ');
1073
+ const sentences = leadText.split(LEAD_SENTENCES).map((x) => x.trim()).filter((x) => /[.!?]$/.test(x));
1074
+ const paraAnswer = leadParas.slice(0, 3).find((t) => leadWords(t) >= 20 && leadWords(t) <= 90 && LEAD_FIGURE.test(t));
1075
+ // Предложение засчитывается, только когда абзацев <p> в блоке нет: иначе карточки «89 900 ₽ Хит продаж …» на
1076
+ // страницах-списках сойдут за ответ. Конец предложения это знак перед пробелом: «2.0» не конец.
1077
+ const textAnswer = leadParas.length ? '' : (sentences.find((x) => leadWords(x) >= 8 && leadWords(x) <= 40 && LEAD_FACT.test(x.replace(/(?<=\d)[.,](?=\d)/g, ''))) || '');
1078
+ const answerFirst = (firstParaWords >= 20 && firstParaWords <= 90 && LEAD_FIGURE.test(firstPara)) || Boolean(paraAnswer) || Boolean(textAnswer);
1079
+ const answer = answerFirst ? ((firstParaWords >= 20 && firstParaWords <= 90 && LEAD_FIGURE.test(firstPara) ? firstPara : '') || paraAnswer || textAnswer) : '';
1080
+ const near = !hasH1 || answerFirst ? '' : (leadParas.slice(0, 3).find((t) => leadWords(t) >= 6 && leadWords(t) < 20 && LEAD_FIGURE.test(t))
1081
+ || sentences.find((x) => leadWords(x) >= 6 && leadWords(x) <= 40 && LEAD_FIGURE.test(x.replace(/(?<=\d)[.,](?=\d)/g, ''))) || '');
1082
+ return { hasH1, answerFirst, answer, firstPara, firstParaWords, near: near.length > 200 ? `${near.slice(0, 197).replace(/\s+\S*$/, '')}…` : near };
1083
+ }
package/src/vy.mjs ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Всё, что читает клиент, на «вы» (решение Максима, 15.10.2026). Задачи и первая правка писались для исполнителя на
3
+ * «ты» («Перепиши», «спроси владельца»), а клиент читает их сам, и рядом стоит «Отдайте это тому, кто ведёт сайт».
4
+ * Перевод по словарю повелительных форм и местоимений, с сохранением заглавной буквы. Только русский текст.
5
+ */
6
+ const IMP = 'добавь оставь убери бери спроси поставь сделай выдумывай перепиши допиши впиши собери трогай почини делай добавляй проставь пиши найди опубликуй ставь дописывай перечисли пройди обещай проверь заполняй выведи придумывай подпиши замени напиши уточни сообщи правь сочиняй выбирай включай повторяй переведи назови разбавляй выпиши выясни включи рисуй объедини подключи удаляй структурируй сверь спрашивай закрывай сгенерируй размечай продублируй публикуй пересобери отдавай объяви клади посмотри прячь дублируй возьми выбери поправь создавай положи дай научи переименовывай исключи используй подними перенеси меняй убирай отключи скажи сними пропиши дополни создай сохрани начинай выключи смени вынеси прогони оставляй переключи исправь реши размети сократи разбей укажи вставь удали свяжи приведи сведи подтверди перепроверь проставляй заведи заменяй сгенерируйте вычисти зафиксируй открой закрой задай раздели перестрой распиши выставь смотри пересчитай поменяй пришли отправь сравни разверни строй настрой оформи учти держи сохраняй следи проследи пометь отметь допусти разметь измерь пропусти отметь вынеси замени перенеси покажи выведи сократи'.split(' ');
7
+ const IRREG = { разметь: 'разметьте', измерь: 'измерьте', дай: 'дайте', клади: 'кладите', прячь: 'прячьте', правь: 'правьте', ставь: 'ставьте', сверь: 'сверьте', проверь: 'проверьте', поправь: 'поправьте', исправь: 'исправьте', оставь: 'оставьте', добавь: 'добавьте', поставь: 'поставьте', проставь: 'проставьте', вставь: 'вставьте', выставь: 'выставьте', отправь: 'отправьте', перепроверь: 'перепроверьте', разбей: 'разбейте', размети: 'разметьте', смотри: 'смотрите', строй: 'стройте', настрой: 'настройте', пометь: 'пометьте', отметь: 'отметьте' };
8
+ const MAP = new Map();
9
+ for (const w of IMP) if (!/те$/.test(w)) MAP.set(w, IRREG[w] || `${w}те`);
10
+ for (const [a, b] of Object.entries({ ты: 'вы', тебе: 'вам', тебя: 'вас', тобой: 'вами', твой: 'ваш', твоя: 'ваша', твоё: 'ваше', твое: 'ваше', твои: 'ваши', твоего: 'вашего', твоей: 'вашей', твоих: 'ваших', твоим: 'вашим', твоими: 'вашими', твоему: 'вашему', хочешь: 'хотите', знаешь: 'знаете', можешь: 'можете', будешь: 'будете', предлагаешь: 'предлагаете', найдёшь: 'найдёте', найдешь: 'найдёте', видишь: 'видите', сможешь: 'сможете', получишь: 'получите', уверен: 'уверены' })) MAP.set(a, b);
11
+ export function toVy(text) {
12
+ return String(text ?? '').replace(/(?<![А-Яа-яЁё])[А-Яа-яЁё]+(?![А-Яа-яЁё])/g, (w) => { const x = MAP.get(w.toLowerCase()); if (!x) return w; return w[0] === w[0].toUpperCase() && w[0] !== w[0].toLowerCase() ? x[0].toUpperCase() + x.slice(1) : x; });
13
+ }
@@ -1,48 +0,0 @@
1
- /** Идентификаторы проверок, снятые с настоящего прогона: тест сверяет с ними фразы владельцу,
2
- * чтобы переименованная проверка не оставила строку, которая никогда не сработает. */
3
- export const CHECK_IDS = [
4
- "agent-card",
5
- "agent-markdown",
6
- "ai-search-access",
7
- "alt",
8
- "answer-first",
9
- "api-catalog",
10
- "canonical",
11
- "conv-contact-path",
12
- "conv-cta",
13
- "conv-form-depth",
14
- "conv-form-fields",
15
- "conv-messenger",
16
- "dates",
17
- "description",
18
- "dup-titles",
19
- "faq-schema",
20
- "h1",
21
- "h1-sample",
22
- "hreflang",
23
- "https",
24
- "llms",
25
- "llms-health",
26
- "og",
27
- "org-schema",
28
- "resources",
29
- "robots",
30
- "robots-ai",
31
- "schema",
32
- "sections",
33
- "sitemap",
34
- "sitemap-health",
35
- "sources",
36
- "tables",
37
- "thin",
38
- "title",
39
- "trust-about",
40
- "trust-contact",
41
- "trust-entity",
42
- "trust-policies",
43
- "trust-profiles",
44
- "ttfb",
45
- "utility",
46
- "viewport",
47
- "www"
48
- ];
@@ -1,158 +0,0 @@
1
- /**
2
- * Правило, которое защищает этот файл: платное оформление не включается без лицензии, а
3
- * бесплатная подпись работает всегда. Если сломать первое, агентский план перестанет продаваться,
4
- * если второе, отчёт перестанет подписываться именем покупателя.
5
- *
6
- * Ключ здесь генерируется прямо в тесте одноразовой парой Ed25519: настоящий приватный ключ в
7
- * репозитории не лежит и лежать не должен.
8
- */
9
- import { generateKeyPairSync, sign } from 'node:crypto';
10
- import { mkdtempSync, writeFileSync } from 'node:fs';
11
- import { tmpdir } from 'node:os';
12
- import { join } from 'node:path';
13
- import { resolveBranding, verifyKey, softenHex } from './agency.mjs';
14
- import { toHtml } from './render.mjs';
15
-
16
- let failed = 0;
17
- const ok = (name, cond) => { if (cond) console.log(`ok ${name}`); else { failed++; console.error(`FAIL ${name}`); } };
18
-
19
- const { publicKey, privateKey } = generateKeyPairSync('ed25519');
20
- const pub = publicKey.export({ type: 'spki', format: 'pem' });
21
- const makeKey = (data) => {
22
- const payload = Buffer.from(JSON.stringify(data));
23
- return `OSK1.${payload.toString('base64url')}.${sign(null, payload, privateKey).toString('base64url')}`;
24
- };
25
-
26
- // Проверка подписи
27
- ok('правильный ключ принимается', verifyKey(makeKey({ email: 'a@b.co', plan: 'agency' }), pub).ok);
28
- ok('мусор отвергается', !verifyKey('OSK1.aaa.bbb', pub).ok);
29
- // Портить надо байты, а не символы. В base64url последний символ кодирует не все шесть бит,
30
- // поэтому несколько разных символов дают одни и те же байты, и подпись оставалась верной
31
- // примерно в каждом пятом прогоне. Летучий тест хуже, чем никакого.
32
- const tampered = (k) => {
33
- const [p, payload, sig] = k.split('.');
34
- const bytes = Buffer.from(sig, 'base64url');
35
- bytes[0] ^= 0xff;
36
- return `${p}.${payload}.${bytes.toString('base64url')}`;
37
- };
38
- ok('чужая подпись отвергается', !verifyKey(tampered(makeKey({ plan: 'agency' })), pub).ok);
39
- ok('просроченный план отвергается', !verifyKey(makeKey({ plan: 'agency', expires: '2020-01-01' }), pub).ok);
40
- ok('ключ другого продукта отвергается', !verifyKey(makeKey({ plan: 'site-kit' }), pub).ok);
41
- ok('срок в будущем проходит', verifyKey(makeKey({ plan: 'agency', expires: '2999-01-01' }), pub).ok);
42
-
43
- // Что бесплатно, а что нет
44
- const free = resolveBranding({ by: 'Harper & Vale' });
45
- ok('подпись именем работает без лицензии', free.preparedBy === 'Harper & Vale' && free.notes.length === 0);
46
- ok('без платных опций строка об инструменте остаётся', free.showToolLine === true);
47
-
48
- const blocked = resolveBranding({ by: 'X', color: '#123456', toolLine: false });
49
- ok('без лицензии цвет не применяется', !blocked.accent && blocked.showToolLine === true);
50
- ok('и человеку сказано почему', blocked.notes.some((n) => /agency plan/i.test(n)));
51
-
52
- // Цвет
53
- ok('светлая версия цвета светлее исходной', parseInt(softenHex('#0b7a75').slice(1, 3), 16) > 0x0b);
54
- ok('кривой цвет не проходит', resolveBranding({ color: 'нет', licence: makeKey({ plan: 'agency' }) }).accent === '');
55
-
56
- // Вёрстка
57
- const audit = {
58
- meta: { host: 'example.com', tool: '@operstack/audit 0.0.0', collectedAt: '2026-01-01T00:00:00Z', auditType: 'External', lang: 'en' },
59
- client: { name: 'Client', subject: 'Sells things', reportDate: '2026-01-01', preparedBy: 'OperStack' },
60
- scores: {}, scoreBasis: {}, summary: { lead: 'a', verdict: 'b', priorities: ['c'] },
61
- overview: { rows: [], note: '' }, sample: [], checks: [], critical: [],
62
- content: { strengths: [], weaknesses: [], gaps: [] },
63
- aeo: { works: [], blocks: [], recommendation: '' }, geo: { rows: [], callout: '' },
64
- offpage: { listed: [], note: '' }, conversion: { rows: [] }, sources: [], limitations: [],
65
- roadmap: [], closing: 'done',
66
- };
67
- const plain = toHtml(audit);
68
- ok('без оформления на обложке стоит наше имя', plain.includes('OperStack') && !plain.includes('<img class="cover-logo"'));
69
-
70
- const branded = toHtml(audit, { preparedBy: 'Harper & Vale Digital', logo: 'data:image/svg+xml;base64,AAA', accent: '#7b3fa0', showToolLine: false });
71
- ok('имя агентства попало на обложку', branded.includes('Harper &amp; Vale Digital'));
72
- ok('логотип попал на обложку', branded.includes('<img class="cover-logo"'));
73
- ok('цвет агентства подменил акцент', branded.includes('--accent:#7b3fa0'));
74
- ok('строка про инструмент убрана', !branded.includes('@operstack/audit 0.0.0'));
75
- ok('но воспроизводимость обещана по-прежнему', /reproduced from it/i.test(branded));
76
- ok('нашего имени в фирменном отчёте не осталось', !/OperStack/.test(branded));
77
-
78
- // Список сайтов
79
- const { parseList } = await import('./batch.mjs');
80
- const list = parseList('# комментарий\n\nexample.com, Northline Dental\nhttps://two.example\nне адрес\n');
81
- ok('список разбирается', list.length === 3 && list[0].name === 'Northline Dental');
82
- ok('адрес без схемы достраивается', list[0].url.startsWith('https://example.com'));
83
- ok('строка-мусор помечена, а не роняет прогон', list[2].invalid === true);
84
-
85
- // Ключ из файла рядом с работой
86
- const dir = mkdtempSync(join(tmpdir(), 'operstack-agency-'));
87
- writeFileSync(join(dir, '.operstack-licence'), makeKey({ plan: 'agency', email: 'x@y.z' }));
88
- const cwd = process.cwd();
89
- process.chdir(dir);
90
- const { findKey } = await import('./agency.mjs');
91
- ok('ключ читается из файла .operstack-licence', findKey().startsWith('OSK1.'));
92
- process.chdir(cwd);
93
-
94
-
95
- // ── Разведка кандидатов ───────────────────────────────────────────────────────
96
- // Правило: в письмо никогда не попадает строка, которую владелец бизнеса не поймёт, и
97
- // ни одна фраза не ссылается на проверку, которой в сборщике нет.
98
- const { OWNER_LINES, overallScore, prospectRow, rank, toCsv, toMarkdown } = await import('./prospect.mjs');
99
- const { CHECK_IDS } = await import('./test-agency-ids.mjs');
100
-
101
- const unknown = Object.keys(OWNER_LINES).filter((id) => !CHECK_IDS.includes(id));
102
- ok('каждая фраза ссылается на существующую проверку', unknown.length === 0);
103
- ok('у каждой фразы есть оба языка', Object.values(OWNER_LINES).every((v) => v.en && v.ru));
104
-
105
- ok('неизмеренная область не тянет оценку вниз', overallScore({ a: 10, b: null }) === 100);
106
- ok('пустые оценки дают не ноль, а «не измеряли»', overallScore({}) === null);
107
-
108
- const mk = (id, status, scores) => ({
109
- meta: { host: `${id}.example` }, client: { name: id }, scores,
110
- checks: [{ id, group: 'x', label: 'L', status, value: 'v' }],
111
- });
112
- const weak = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
113
- ok('перекрытый доступ роботов ИИ попадает в письмо', /ChatGPT/.test(weak.say));
114
- ok('провал посчитан', weak.failing === 1 && weak.warning === 0);
115
-
116
- const dull = prospectRow(mk('utility', 'warn', { a: 9 }));
117
- const sharp = prospectRow({
118
- meta: { host: 'x.example' }, client: { name: 'x' }, scores: { a: 5 },
119
- checks: [
120
- { id: 'utility', group: 'x', label: 'L', status: 'warn', value: 'v' },
121
- { id: 'ai-search-access', group: 'x', label: 'L', status: 'warn', value: 'v' },
122
- ],
123
- });
124
- ok('при равной тяжести в письмо идёт сильная находка, а не первая', /ChatGPT/.test(sharp.say));
125
- ok('слабая находка всё равно объяснена по-человечески', !/^utility/.test(dull.say));
126
-
127
- const ranked = rank([prospectRow(mk('alt', 'warn', { a: 9 })), weak, { ...dull, score: null }]);
128
- ok('сначала идёт тот, у кого хуже', ranked[0].score < ranked[1].score);
129
- ok('сайт без оценки уходит в конец, а не наверх', ranked[ranked.length - 1].score === null);
130
-
131
- const csv = toCsv([weak]);
132
- ok('в CSV есть заголовок и строка', csv.split('\n').length === 2 && csv.startsWith('site,name,score'));
133
- ok('запятая внутри фразы не ломает CSV', toCsv([{ ...weak, say: 'a, b' }]).includes('"a, b"'));
134
- ok('в таблице сказано, что строки измерены, а не придуманы', /measured check, not an opinion/.test(toMarkdown([weak])));
135
-
136
- const russian = prospectRow(mk('ai-search-access', 'bad', { a: 2 }), { lang: 'ru' });
137
- ok('русская фраза приходит по-русски', /Perplexity не могут/.test(russian.say));
138
-
139
-
140
- // Три дыры, найденные прогоном по списку, который специально собран из ломающего.
141
- const dead = prospectRow({ meta: { host: 'gone.example', reachable: false }, scores: {}, checks: [] });
142
- ok('мёртвый сайт помечен, а не выдан как кандидат', dead.reachable === false);
143
- const { split } = await import('./prospect.mjs');
144
- const parts = split([dead, prospectRow(mk('alt', 'warn', { a: 9 }))]);
145
- ok('мёртвые не попадают в таблицу кандидатов', parts.live.length === 1 && parts.dead.length === 1);
146
- ok('и названы отдельным списком', /Did not answer/.test(toMarkdown([dead, prospectRow(mk('alt', 'warn', { a: 9 }))])));
147
- ok('мёртвого нет в CSV', !toCsv([dead]).includes('gone.example'));
148
-
149
- const bad = prospectRow(mk('ai-search-access', 'bad', { a: 2 }));
150
- ok('статус bad считается провалом, а не пропускается', bad.failing === 1);
151
- ok('и тяжёлая находка доходит до письма', /ChatGPT/.test(bad.say));
152
-
153
- const twice = rank([prospectRow(mk('alt', 'warn', { a: 9 })), prospectRow(mk('alt', 'warn', { a: 9 }))]);
154
- ok('один сайт в списке дважды даёт одну строку', twice.length === 1);
155
-
156
- console.log(failed ? `\n${failed} проверок не прошли` : '\nагентский план: лицензия, оформление и список проверены');
157
- process.exit(failed ? 1 : 0);
158
-