@operstack/audit 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "LICENSE"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
30
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
31
31
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
32
32
  },
33
33
  "keywords": [
package/src/collect.mjs CHANGED
@@ -3,11 +3,21 @@
3
3
  * robots.txt, sitemaps, llms.txt, the homepage and a sample of pages from the sitemap.
4
4
  * No Search Console, no analytics. The narrative fields are left for the analyst.
5
5
  */
6
+ import { readFileSync } from 'node:fs';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
6
9
  import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
7
10
  import { parse } from 'node-html-parser';
8
11
  import { localiseChecks } from './i18n.mjs';
9
12
 
10
- const UA = 'Mozilla/5.0 (compatible; OperStackAudit/0.1; +https://oper-stack.com)';
13
+ /** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
14
+ * в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
15
+ const VERSION = (() => {
16
+ try { return JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version; }
17
+ catch { return '0'; }
18
+ })();
19
+
20
+ const UA = `Mozilla/5.0 (compatible; OperStackAudit/${VERSION}; +https://oper-stack.com)`;
11
21
 
12
22
  async function get(url, { method = 'GET', timeout = 15000 } = {}) {
13
23
  const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
@@ -229,6 +239,34 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
229
239
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
230
240
  !llms.ok ? 'answer engines get nothing to read; a generated index is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
231
241
 
242
+ // Здоровье llms.txt: мы проверяем не только то, что файл есть, но и то, что он ведёт в живые
243
+ // страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
244
+ // удалили, а в индексе она осталась, и ИИ идёт цитировать редирект или пустоту. Проверяем
245
+ // выборку своих ссылок, а не все: на большом сайте их тысячи, а беда видна и по двадцати.
246
+ const llmsOwn = llmsLinks.filter((u) => { try { const h = new URL(u).host; return h === host || h === altHost; } catch { return false; } });
247
+ if (llmsOwn.length) {
248
+ const sampleSize = Math.min(20, llmsOwn.length);
249
+ const step = Math.max(1, Math.floor(llmsOwn.length / sampleSize));
250
+ const picked = llmsOwn.filter((_, i) => i % step === 0).slice(0, sampleSize);
251
+ const broken = [];
252
+ const redirected = [];
253
+ const closed = [];
254
+ for (const u of picked) {
255
+ const r = await get(u);
256
+ if (r.status >= 300 && r.status < 400) { redirected.push(u); continue; }
257
+ if (!r.ok) { broken.push(u); continue; }
258
+ if (/<meta[^>]+name=["\']robots["\'][^>]+noindex/i.test(r.text || '')) closed.push(u);
259
+ }
260
+ const sick = broken.length + redirected.length + closed.length;
261
+ const parts = [];
262
+ if (broken.length) parts.push(`${broken.length} dead`);
263
+ if (redirected.length) parts.push(`${redirected.length} redirecting`);
264
+ if (closed.length) parts.push(`${closed.length} closed to indexing`);
265
+ checks.push(row('llms-health', 'geo', 'Pages listed in llms.txt', sick > picked.length / 4 ? 'bad' : sick ? 'warn' : 'ok',
266
+ sick ? `${parts.join(', ')} of ${picked.length} checked: ${[...broken, ...redirected, ...closed][0]}` : `all ${picked.length} checked page(s) answer and are open to indexing`,
267
+ sick ? 'an answer engine reads this file instead of crawling: every dead or closed entry is a page it will quote wrongly or not at all' : ''));
268
+ }
269
+
232
270
  // Агентная поверхность: карточки, по которым ИИ-агент понимает, с чем имеет дело и что тут можно
233
271
  // вызвать. Стандарты молодые, поэтому отсутствие это не провал, а предупреждение, и в тексте
234
272
  // прямо сказано, что это раннее и необязательное. Врать про обязательность мы не будем.
@@ -385,7 +423,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
385
423
  const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
386
424
 
387
425
  return {
388
- meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
426
+ meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
389
427
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
390
428
  scores,
391
429
  scoreBasis,
package/src/fix.mjs CHANGED
@@ -66,6 +66,7 @@ export const FIX_ACTIONS = {
66
66
  thin: { scope: 'client', why: { ru: 'наполнение тонких страниц это тексты по фактам клиента', en: 'filling thin pages means writing from your facts' } },
67
67
  sections: { scope: 'client', why: { ru: 'разбивка на разделы это переписывание текста', en: 'splitting into sections means rewriting the text' } },
68
68
  tables: { scope: 'client', why: { ru: 'таблица собирается из данных клиента', en: 'a table is built from your data' } },
69
+ 'llms-health': { scope: 'always', action: { ru: 'Пересобрать llms.txt по живым страницам: убрать мёртвые адреса, заменить переадресованные на конечные, исключить закрытые от индексации', en: 'Rebuild llms.txt from the live pages: drop dead URLs, replace redirecting ones with their destination, leave out anything closed to indexing' } },
69
70
  'agent-card': { scope: 'always', action: { ru: 'Положить в /.well-known/agent.json карточку с названием компании, одной строкой о том, чем она занимается, адресом сайта и способом связи', en: 'Put an agent card at /.well-known/agent.json with the company name, one line on what it does, the site address and a way to get in touch' } },
70
71
  'api-catalog': { scope: 'never', why: { ru: 'нужен только тем, у кого есть API для агентов, выдумывать его ради галочки незачем', en: 'only for a business that actually exposes an API for agents; inventing one for the sake of a tick is pointless' } },
71
72
  'agent-markdown': { scope: 'files', action: { ru: 'Отдавать markdown-версию каждой страницы и объявлять её заголовком Link рядом с HTML', en: 'Serve a markdown version of every page and declare it with a Link header beside the HTML' } },
package/src/i18n.mjs CHANGED
@@ -10,8 +10,10 @@
10
10
  * audit.checks = localiseChecks(audit.checks, 'ru')
11
11
  */
12
12
 
13
- /** Достаёт все целые и дробные числа из строки по порядку. */
14
- const nums = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => x.replace(',', '.'));
13
+ /** Достаёт все целые и дробные числа из строки по порядку.
14
+ * Сборщик пишет по-английски, и там запятая это разделитель тысяч: «median 1,600 words».
15
+ * Пока её принимали за дробную часть, 1600 слов превращались в 1,6 и уезжали в отчёт покупателю. */
16
+ const nums = (s) => (String(s).match(/\d+(?:[.,]\d{3})*(?:\.\d+)?/g) || []).map((x) => x.replace(/,(?=\d{3}\b)/g, ''));
15
17
  /** Склонение: 1 страница, 2 страницы, 5 страниц. */
16
18
  const plural = (n, one, few, many) => {
17
19
  const d = Math.abs(n) % 10; const dd = Math.abs(n) % 100;
@@ -78,12 +80,12 @@ const RU = {
78
80
  : `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
79
81
  comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
80
82
  },
81
- title: { label: 'Заголовок главной', value: (v) => { const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; } },
82
- description: { label: 'Описание главной', value: (v) => (/missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`) },
83
- h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1` },
83
+ title: { label: 'Заголовок главной', value: (v) => { const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; }, comment: (c) => (/^short/.test(c) ? 'коротко: в заголовке нет слов, ради которых по нему кликают' : /cut in results/.test(c) ? 'в выдаче обрежется' : '') },
84
+ description: { label: 'Описание главной', value: (v) => (/missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`), comment: (c) => (/technical garbage/.test(c) ? 'в сниппете покажется технический мусор вместо текста' : /missing/.test(c) ? 'описания нет' : '') },
85
+ h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1`, comment: (c) => (/dilutes/.test(c) ? 'больше одного H1 размывает тему страницы' : /no H1/.test(c) ? 'H1 на странице нет' : '') },
84
86
  canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
85
87
  viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
86
- og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v) },
88
+ og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
87
89
  schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
88
90
  'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`) },
89
91
  'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
@@ -105,6 +107,7 @@ const RU = {
105
107
  tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
106
108
  sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
107
109
  dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
110
+ 'llms-health': { label: 'Страницы из llms.txt', value: (v) => (/^all /.test(v) ? `все ${nums(v)[0]} проверенных страниц отвечают и открыты для индексации` : v.replace(/(\d+) dead/, '$1 мёртвых').replace(/(\d+) redirecting/, '$1 с переадресацией').replace(/(\d+) closed to indexing/, '$1 закрытых от индексации').replace(/ of (\d+) checked: /, ' из $1 проверенных: ')), comment: (c) => (c ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
108
111
  'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: этот файл читают каталоги ИИ-агентов, чтобы понять, кто вы и что предлагаете' : '') },
109
112
  'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
110
113
  'agent-markdown': { label: 'Markdown-версия страниц', value: (v) => (/Link header/.test(v) ? 'предлагается через заголовок Link' : /next to the page/.test(v) ? 'лежит рядом со страницей' : 'не предлагается'), comment: (c) => (c ? 'агент, который просит text/markdown, получает HTML и вынужден его разбирать: отдать markdown дёшево и снимает догадки' : '') },
package/src/narrative.mjs CHANGED
@@ -123,6 +123,9 @@ const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
123
123
  * @param {object} audit результат collect()
124
124
  * @param {{lang?: 'ru'|'en'}} opts
125
125
  */
126
+ /** Проверки, которые закрывает только текст клиента: у них есть своя формулировка работы. */
127
+ const TEXT_WORK_IDS = new Set(['thin', 'answer-first', 'sources', 'sections', 'tables']);
128
+
126
129
  export function draftNarrative(audit, opts = {}) {
127
130
  const lang = opts.lang || audit.meta?.lang || 'en';
128
131
  const t = T[lang === 'ru' ? 'ru' : 'en'];
@@ -169,7 +172,14 @@ export function draftNarrative(audit, opts = {}) {
169
172
  return c.label;
170
173
  };
171
174
  const action = (c) => `${actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
172
- const top = [...bad, ...warn].slice(0, 3);
175
+ // В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
176
+ // вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
177
+ // такая строка первым пунктом плана читается как отговорка, а не как задача.
178
+ const actionable = (c) => {
179
+ const rule = FIX_ACTIONS[c.id];
180
+ return Boolean((rule && rule.action) || TEXT_WORK_IDS.has(c.id));
181
+ };
182
+ const top = [...bad.filter(actionable), ...warn.filter(actionable), ...bad.filter((c) => !actionable(c)), ...warn.filter((c) => !actionable(c))].slice(0, 3);
173
183
  a.summary.priorities = (a.summary.priorities || []).map((p, i) => {
174
184
  if (!String(p).includes('{{')) return p;
175
185
  const c = top[i];
package/src/prompts.mjs CHANGED
@@ -94,6 +94,10 @@ const P = {
94
94
  ru: { task: 'Пройди по абзацам, где есть цифры, и подпиши к каждой, откуда она: ссылкой на первоисточник или словами («по данным реестра за 2026 год»). Если источника нет ни у тебя, ни у владельца, цифру надо убрать, а не оставлять голой. Не ставь ссылку наугад: неверный источник хуже отсутствующего.', verify: 'В каждом абзаце с цифрой есть либо ссылка на чужой сайт, либо названный словами источник.' },
95
95
  en: { task: 'Go through the paragraphs carrying figures and name where each came from: a link to the primary source, or words ("per the 2026 registry"). If neither you nor the owner has a source, the figure comes out rather than standing bare. Do not guess a link: a wrong source is worse than none.', verify: 'Every paragraph with a figure carries either an outbound link or a source named in words.' },
96
96
  },
97
+ 'llms-health': {
98
+ ru: { task: 'Пересобери llms.txt из списка живых страниц сайта. Каждый адрес в файле должен отвечать кодом 200 и быть открыт для индексации. Переадресованные адреса замени на конечные, мёртвые убери, закрытые от индексации не включай. Если файл собирается скриптом при сборке, почини скрипт, а не файл: иначе он разъедется снова на следующей выкладке.', verify: 'Каждая ссылка из llms.txt отвечает 200, ни одна не ведёт на переадресацию, и ни у одной нет метки noindex.' },
99
+ en: { task: 'Rebuild llms.txt from the site\'s live pages. Every URL in it must answer 200 and be open to indexing. Replace redirecting URLs with their destination, drop dead ones, leave out anything marked noindex. If the file is generated at build time, fix the generator rather than the file, or it drifts again on the next deploy.', verify: 'Every link in llms.txt answers 200, none redirects, and none carries a noindex marker.' },
100
+ },
97
101
  'agent-card': {
98
102
  ru: { task: 'Собери файл /.well-known/agent.json: название компании, одна строка о том, чем она занимается, адрес сайта, способ связи и перечень того, что вы предлагаете. Пиши только правду: это читают каталоги агентов, и выдуманная возможность обернётся жалобой. Файл отдавай с типом application/json.', verify: 'Адрес /.well-known/agent.json отвечает 200, тип application/json, и JSON разбирается без ошибок.' },
99
103
  en: { task: 'Build a /.well-known/agent.json: the company name, one line on what it does, the site address, a way to get in touch and a list of what you offer. Put only true things in it: agent directories read this, and an invented capability turns into a complaint. Serve it as application/json.', verify: '/.well-known/agent.json answers 200 with application/json and parses as valid JSON.' },
package/src/test-i18n.mjs CHANGED
@@ -20,7 +20,11 @@ ok('статусы не тронуты', ru.every((c, i) => c.status === all[i].
20
20
  ok('идентификаторы не тронуты', ru.every((c, i) => c.id === all[i].id));
21
21
 
22
22
  // в русском тексте не должно остаться английских слов, кроме технических имён
23
- const TECH = /https?|robots\.txt|llms\.txt|sitemap|xmlrpc|HTTP|HTTPS|H1|H2|og:|twitter:|JSON-LD|FAQPage|Organization|WebSite|Article|LocalBusiness|viewport|width|initial-scale|device-width|user-scalable|maximum-scale|Disallow|User-agent|gptbot|google-extended|ccbot|anthropic-ai|claudebot|applebot-extended|facebookbot|meta-externalagent|bytespider|oai-searchbot|chatgpt-user|perplexitybot|claude-searchbot|duckassistbot|applebot|sameAs|com|ru|org|io|me|net|dev|github|npmjs|apify|producthunt|wikidata|moregroup|estate|Content|Type|Cache|Control|OperStack|SEO|AEO|GEO|AI|XML|CMS|PDF|URL/gi;
23
+ // Технические имена, которые обязаны остаться латиницей. Границы слова здесь не украшение:
24
+ // без них \bio\b съедало «io» внутри description, а Estate внутри RealEstateAgent, и тест
25
+ // докладывал о несуществующих английских хвостах вроде «descriptn» и «RealAgent».
26
+ const SCHEMA_TYPES = 'RealEstateAgent|Corporation|BreadcrumbList|WebPage|Product|Service|Place|Person|ItemList|HowTo|QAPage|NewsArticle|BlogPosting|CollectionPage|AboutPage|ContactPage|SoftwareApplication';
27
+ const TECH = new RegExp(`\\b(?:https?|robots\.txt|llms\.txt|sitemap|xmlrpc|HTTP|HTTPS|H1|H2|og:|twitter:|JSON-LD|FAQPage|Organization|WebSite|Article|LocalBusiness|viewport|width|initial-scale|device-width|user-scalable|maximum-scale|Disallow|User-agent|gptbot|google-extended|ccbot|anthropic-ai|claudebot|applebot-extended|facebookbot|meta-externalagent|bytespider|oai-searchbot|chatgpt-user|perplexitybot|claude-searchbot|duckassistbot|applebot|sameAs|com|ru|org|io|me|net|dev|github|npmjs|apify|producthunt|wikidata|moregroup|estate|Content|Type|Cache|Control|OperStack|SEO|AEO|GEO|AI|XML|CMS|PDF|URL|${SCHEMA_TYPES})\\b`, 'gi');
24
28
  const leftovers = [];
25
29
  for (const c of ru) {
26
30
  for (const field of ['label', 'value', 'comment']) {
@@ -36,11 +40,12 @@ for (const c of ru) {
36
40
  is('английских слов в русском тексте не осталось', leftovers, []);
37
41
 
38
42
  // числа не потерялись
39
- const digits = (s) => (String(s).match(/\d+/g) || []).join(',');
43
+ // Английские тысячи пишутся через запятую, русские слитно: 1,600 и 1600 это одно число.
44
+ const digits = (s) => (String(s).replace(/,(?=\d{3}\b)/g, '').match(/\d+/g) || []).join('|');
40
45
  const lostNumbers = ru.filter((c, i) => {
41
46
  const before = digits(all[i].value);
42
47
  const after = digits(c.value);
43
- return before && after && before.split(',').some((n) => !after.split(',').includes(n));
48
+ return before && after && before.split('|').some((n) => !after.split('|').includes(n));
44
49
  }).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
45
50
  is('числа в тексте сохранились', lostNumbers, []);
46
51