@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.
- package/README.md +10 -0
- package/bin/audit.mjs +2 -2
- package/package.json +5 -3
- package/src/client-eye.mjs +389 -0
- package/src/collect.mjs +171 -85
- package/src/fix.mjs +124 -5
- package/src/foundation.mjs +2 -1
- package/src/i18n.mjs +50 -25
- package/src/index.mjs +2 -1
- package/src/narrative.mjs +56 -25
- package/src/niche.mjs +13 -12
- package/src/pagekind.mjs +18 -1
- package/src/presentation.mjs +3 -2
- package/src/prompts.mjs +163 -42
- package/src/render.mjs +61 -30
- package/src/rendered.mjs +6 -2
- package/src/tasks-pdf.mjs +10 -5
- package/src/verify.mjs +26 -13
- package/src/visibility.mjs +137 -45
- package/src/vy.mjs +13 -0
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-answer-first.mjs +0 -27
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-presentation.mjs +0 -67
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -136
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
package/src/visibility.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
orgOk: 'Organization
|
|
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
|
|
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
|
-
|
|
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
|
|
130
|
-
noTables: 'No tables on the measured pages.
|
|
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.
|
|
133
|
-
datesMissing: (n) => `${n} sampled page
|
|
134
|
-
datesOk: 'Sampled pages
|
|
135
|
-
noFigures: 'The sampled pages state no
|
|
136
|
-
sourcesMissing: (n, total) => `${n} of ${total} sampled page
|
|
137
|
-
sourcesOk: 'Every sampled page that states
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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(/ /g, ' ').replace(/&/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
|
-
|
|
421
|
-
const firstPara
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
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(/ | /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
|
+
}
|
package/src/test-agency-ids.mjs
DELETED
|
@@ -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
|
-
];
|
package/src/test-agency.mjs
DELETED
|
@@ -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 & 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
|
-
|