@operstack/audit 0.33.2 → 0.33.4

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.33.2",
3
+ "version": "0.33.4",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs",
32
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs",
33
33
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
34
  "verify:live": "node src/verify-live.mjs"
35
35
  },
package/src/collect.mjs CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
10
10
  import { parse } from 'node-html-parser';
11
11
  import { localiseChecks } from './i18n.mjs';
12
- import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows } from './visibility.mjs';
12
+ import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows, linksInText } from './visibility.mjs';
13
13
 
14
14
  /** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
15
15
  * в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
@@ -154,6 +154,34 @@ const row = (id, group, label, status, value, comment = '') => ({ id, group, lab
154
154
  */
155
155
  const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank|по данным|источник:|источники:|согласно|по информации|по сведениям|росстат|центробанк|банк россии|росреестр|минфин|минэкономразвития)(?!\p{L})/giu;
156
156
 
157
+ /**
158
+ * Движок сайта по следам в коде, когда мета-тега generator нет. Shopify его не ставит, и до 0.33.4
159
+ * отчёт о магазине на Shopify писал «CMS: не измерено», хотя в коде стоят Shopify.shop и
160
+ * cdn.shopify.com (brainmd.com, 12.10.2026). Только надёжные следы: лучше промолчать, чем угадать.
161
+ */
162
+ const STACK_SIGNS = [
163
+ ['Shopify', /\bShopify\.shop\b|cdn\.shopify\.com|\/cdn\/shop\/|myshopify\.com/i],
164
+ ['WordPress', /\/wp-content\/|\/wp-includes\//i],
165
+ ['Tilda', /static\.tildacdn\.|tilda-blocks/i],
166
+ ['Wix', /static\.wixstatic\.com|wix-warmup-data/i],
167
+ ['Squarespace', /static1\.squarespace\.com|Static\.SQUARESPACE_CONTEXT/i],
168
+ ['Webflow', /\bdata-wf-page=|assets\.website-files\.com|cdn\.prod\.website-files\.com/i],
169
+ ['1C-Bitrix', /\/bitrix\/(js|templates|cache)\//i],
170
+ ['BigCommerce', /cdn\d*\.bigcommerce\.com/i],
171
+ ['Magento', /Magento_|mage\/cookies/i],
172
+ ['Joomla', /\/media\/jui\/|\/components\/com_/i],
173
+ ['Drupal', /\/sites\/default\/files\/|drupal-settings-json/i],
174
+ ['Ghost', /ghost-(portal|search)|\/ghost\/api\//i],
175
+ ['Next.js', /\/_next\/static\//],
176
+ ['Nuxt', /\/_nuxt\//],
177
+ ['Gatsby', /___gatsby/],
178
+ ];
179
+ export function detectStack(html) {
180
+ const src = String(html || '');
181
+ const hit = STACK_SIGNS.find(([, re]) => re.test(src));
182
+ return hit ? hit[0] : '';
183
+ }
184
+
157
185
  function analysePage(url, html) {
158
186
  const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
159
187
  const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
@@ -168,6 +196,14 @@ function analysePage(url, html) {
168
196
  const og = { title: attr('meta[property="og:title"]', 'content'), description: attr('meta[property="og:description"]', 'content'), image: attr('meta[property="og:image"]', 'content') };
169
197
  const twitter = attr('meta[name="twitter:card"]', 'content');
170
198
  const generator = attr('meta[name="generator"]', 'content');
199
+ /*
200
+ * Язык страницы. До 0.33.4 его не читали вовсе: в meta.language шёл og:locale, которого
201
+ * analysePage не собирал, и отчёт писал «язык не указан» даже там, где lang стоит. На brainmd.com
202
+ * тег <html> ещё и разбит на несколько строк (12.10.2026), поэтому кроме парсера есть
203
+ * запасное правило, которому перевод строки внутри тега не мешает.
204
+ */
205
+ const htmlLang = (root.querySelector('html')?.getAttribute('lang') || (String(html).match(/<html\b[^>]*?\slang\s*=\s*["']?([a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})*)/i) || [])[1] || attr('meta[property="og:locale"]', 'content') || '').trim();
206
+ const stack = generator || detectStack(html);
171
207
  const hreflang = root.querySelectorAll('link[rel="alternate"][hreflang]').length;
172
208
  const scripts = root.querySelectorAll('script[src]').length;
173
209
  const stylesheets = root.querySelectorAll('link[rel="stylesheet"]').length;
@@ -196,13 +232,15 @@ function analysePage(url, html) {
196
232
  // шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
197
233
  const words = countWords(plain(bodyHtml)) || countWords(text);
198
234
  const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
199
- const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
235
+ // Shopify и другие пишут слеши в JSON-LD как \/: без раскодирования "sameAs" с семью профилями
236
+ // brainmd.com читался как «профилей нет», и это шло в приоритеты отчёта (12.10.2026).
237
+ const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n').replace(/\\\//g, '/');
200
238
  const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
201
239
  const shortcode = /\[[a-z_-]+ [^\]]*\]/i.test(description) || /^[A-Za-z0-9+/=]{40,}$/.test(description);
202
240
  // Conversion path, read from the page itself. No analytics is needed to see whether a visitor
203
241
  // who wants to talk to this business can find a way to do it, and how far down it is.
204
242
  const bodyInner = body?.innerHTML || html;
205
- const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|закаж|закажите|купить|узнать цен|проверить сайт|начать/i;
243
+ const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |buy now|add to (cart|bag|basket)|shop (now|all|the)|take the quiz|start the quiz|subscribe (and|&|&amp;) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|закаж|закажите|купить|в корзину|добавить в корзину|узнать цен|проверить сайт|начать/i;
206
244
  const hrefsLower = links.map((h) => h.toLowerCase());
207
245
  const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
208
246
  const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
@@ -226,9 +264,37 @@ function analysePage(url, html) {
226
264
  const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
227
265
  const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
228
266
  const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
229
- const firstScreen = bodyInner.slice(0, Math.max(1200, Math.round(bodyInner.length * 0.15)));
230
- const ctaFirstScreen = (firstScreen.match(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi) || [])
231
- .some((el) => CONTACT_INTENT.test(el.replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(el));
267
+ /*
268
+ * Первый экран меряем долей видимого текста, а не долей разметки. До 0.33.4 брались первые 15%
269
+ * HTML, а у магазина на Shopify их съедают меню, галерея и иконки: кнопка «Add To Cart» на
270
+ * странице товара стоит на 4% текста и на 30% разметки, и все 21 страница brainmd.com вышли
271
+ * «без призыва на первом экране» (12.10.2026). Скрипты, стили, svg, меню и подвал не видны как
272
+ * текст первого экрана и в счёт не идут.
273
+ */
274
+ const screenHtml = bodyInner.replace(/<!--[\s\S]*?-->/g, ' ')
275
+ .replace(/<(script|style|svg|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, ' ')
276
+ .replace(/<(nav|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, ' ');
277
+ const textLen = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().length;
278
+ const screenText = textLen(screenHtml);
279
+ const firstScreenChars = Math.max(300, Math.round(screenText * 0.15));
280
+ let ctaFirstScreen = false;
281
+ {
282
+ let seen = 0; let from = 0;
283
+ for (const m of screenHtml.matchAll(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi)) {
284
+ seen += textLen(screenHtml.slice(from, m.index)); from = m.index;
285
+ if (seen > firstScreenChars) break;
286
+ if (CONTACT_INTENT.test(m[0].replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(m[0])) { ctaFirstScreen = true; break; }
287
+ }
288
+ }
289
+ /*
290
+ * Видна ли цена. Цена в разметке (Offer, "price") или сумма в валюте в тексте страницы. До 0.33.4
291
+ * строку «Цена видна» черновик писал «цены нет» не глядя, и магазин brainmd.com с ценами на каждом
292
+ * товаре получил «no price on the pages we read» (12.10.2026).
293
+ */
294
+ const priceShown = /"@type"\s*:\s*"(?:Offer|AggregateOffer)"|"price"\s*:\s*"?\d/.test(ld)
295
+ // Нулевая сумма это пустая корзина в шапке («Spend $0.00 more»), а не цена: на brainmd.com она
296
+ // стояла на каждой странице, и цена «нашлась» даже на странице контактов.
297
+ || /(?:[$€£₽]\s?(?:[1-9]|0[.,]\d*[1-9])|(?<![\d.,])[1-9][\d\s.,]*\s?(?:₽|руб\.?|USD|EUR|GBP)(?![\p{L}]))/u.test(text);
232
298
  // Trust signals a visitor and a rating system both look for, all of them public on the page.
233
299
  const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
234
300
  const trust = {
@@ -272,7 +338,7 @@ function analysePage(url, html) {
272
338
  const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
273
339
  try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
274
340
  })).length;
275
- return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, trust };
341
+ return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, priceShown, trust };
276
342
  }
277
343
 
278
344
  /** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
@@ -286,37 +352,81 @@ function analysePage(url, html) {
286
352
  * Если остановились раньше, это возвращается наружу и попадает в отчёт словами. Выдать часть за
287
353
  * целое нельзя: «1 200 адресов в карте сайта» на сайте с сорока тысячами адресов это неверное
288
354
  * измерение, а не округление. */
289
- async function readSitemap(startUrl, { maxFiles = 6, budgetMs = 45000, maxUrls = 5000, timeout } = {}) {
355
+ async function readSitemap(startUrl, { maxFiles = 50, budgetMs = 45000, maxUrls = 50000, timeout, concurrency = 4 } = {}) {
290
356
  const deadline = Date.now() + budgetMs;
291
357
  const seen = new Set();
292
358
  const queue = [{ url: startUrl, depth: 0 }];
293
359
  const urls = [];
294
360
  let filesRead = 0; let nestedFound = 0; let stopped = '';
361
+ /*
362
+ * Вложенные карты читаем по несколько сразу. До 0.33.4 читали по одной и не больше шести файлов:
363
+ * у brainmd.com в индексе девять карт, и шестой лимит отрезал sitemap_articles_1.xml с 980
364
+ * адресами. Отчёт писал «в карте 349 страниц» при 1 357 настоящих (12.10.2026). Теперь лимит
365
+ * пятьдесят файлов, а время держит тот же бюджет.
366
+ */
295
367
  while (queue.length) {
296
368
  if (filesRead >= maxFiles) { stopped = 'files'; break; }
297
369
  if (Date.now() > deadline) { stopped = 'time'; break; }
298
370
  if (urls.length >= maxUrls) { stopped = 'urls'; break; }
299
- const { url, depth } = queue.shift();
300
- if (seen.has(url) || depth > 3) continue;
301
- seen.add(url);
302
- const r = await getFollowed(url, timeout ? { timeout } : {});
303
- filesRead++;
304
- if (!r.ok) continue;
305
- // Спускаться вглубь можно только из индекса карт. Прежнее условие из-за приоритета операторов
306
- // читало `A || (B && C && D)` и уходило в рекурсию по любой ссылке на .xml, даже из обычного urlset.
307
- const isIndex = /<sitemapindex/i.test(r.text);
308
- for (const m of r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
309
- const l = m[1];
310
- if (isIndex && (/\.xml(\.gz)?$/i.test(l) || (/sitemap/i.test(l) && !/\.(html?|php)$/i.test(l)))) { nestedFound += 1; queue.push({ url: l, depth: depth + 1 }); }
311
- else urls.push(l);
371
+ const batch = [];
372
+ while (queue.length && batch.length < Math.min(concurrency, maxFiles - filesRead)) {
373
+ const next = queue.shift();
374
+ if (seen.has(next.url) || next.depth > 3) continue;
375
+ seen.add(next.url);
376
+ batch.push(next);
312
377
  }
378
+ if (!batch.length) continue;
379
+ const answers = await Promise.all(batch.map(({ url }) => getFollowed(url, timeout ? { timeout } : {})));
380
+ filesRead += batch.length;
381
+ batch.forEach(({ depth }, i) => {
382
+ const r = answers[i];
383
+ if (!r.ok) return;
384
+ // Спускаться вглубь можно только из индекса карт. Прежнее условие из-за приоритета операторов
385
+ // читало `A || (B && C && D)` и уходило в рекурсию по любой ссылке на .xml, даже из обычного urlset.
386
+ const isIndex = /<sitemapindex/i.test(r.text);
387
+ for (const m of r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
388
+ // В XML амперсанд записан как &amp;: Shopify так пишет адреса вложенных карт (?from=..&amp;to=..).
389
+ const l = m[1].replace(/&amp;/g, '&');
390
+ if (isIndex && (/\.xml(\.gz)?(\?|$)/i.test(l) || (/sitemap/i.test(l) && !/\.(html?|php)$/i.test(l)))) { nestedFound += 1; queue.push({ url: l, depth: depth + 1 }); }
391
+ else urls.push(l);
392
+ }
393
+ });
313
394
  }
314
- return { urls, filesRead, nestedFound, pending: queue.length, stopped };
395
+ const pending = queue.filter((q) => !seen.has(q.url)).length;
396
+ if (!pending && stopped === 'files') stopped = '';
397
+ return { urls, filesRead, nestedFound, pending, stopped };
315
398
  }
316
399
 
317
400
  export const __readSitemap = readSitemap;
318
401
 
319
- export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', visibility: givenVisibility = null, requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 6, tier = '' } = {}) {
402
+ /** Адрес из карты сайта, который стоит читать как страницу. Shopify кладёт в карту agents.md, у
403
+ * других бывают PDF и картинки: это не страницы, и в выборке они только занимают место. */
404
+ export function isPageUrl(u) {
405
+ try { const x = new URL(u); return !NOT_A_PAGE_FILE.test(x.pathname) && !NOT_A_PAGE_PATH.test(x.pathname); } catch { return false; }
406
+ }
407
+ const NOT_A_PAGE_FILE = /\.(?!html?$|php$|aspx?$)[a-z0-9]{2,5}$/i;
408
+ const NOT_A_PAGE_PATH = /\/(cart|checkout|account|login|customer_authentication|customer_identity|wishlist)(\/|$)/i;
409
+
410
+ /** Как часто главная ссылается на каждую свою страницу: адрес (в виде norm) к числу ссылок. */
411
+ export function homeLinkRank(html, base, ownHosts) {
412
+ const rank = new Map();
413
+ const bareHost = (h) => h.replace(/^www\./, '').toLowerCase();
414
+ const own = new Set([...ownHosts].map(bareHost));
415
+ const src = String(html || '').replace(/<!--[\s\S]*?-->/g, ' ').replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/gi, ' ');
416
+ for (const m of src.matchAll(/<a\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
417
+ let u; try { u = new URL((m[1] ?? m[2]).replace(/&amp;/g, '&'), base); } catch { continue; }
418
+ if (!/^https?:$/.test(u.protocol) || !own.has(bareHost(u.host))) continue;
419
+ u.hash = ''; u.search = '';
420
+ if (!u.pathname.includes('.') && !u.pathname.endsWith('/')) u.pathname += '/';
421
+ const k = u.href.toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
422
+ rank.set(k, (rank.get(k) || 0) + 1);
423
+ }
424
+ return { has: (n) => rank.has(n.replace(/^https?:\/\/(www\.)?/, '')), get: (n) => rank.get(n.replace(/^https?:\/\/(www\.)?/, '')) || 0 };
425
+ }
426
+
427
+
428
+
429
+ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', visibility: givenVisibility = null, requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 50, tier = '' } = {}) {
320
430
  REQUEST_TIMEOUT = requestTimeout;
321
431
  const opts = { backlinks };
322
432
  const origin = new URL(startUrl).origin;
@@ -448,7 +558,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
448
558
  log('llms.txt');
449
559
  const llms = await getFollowed(`${siteOrigin}/llms.txt`);
450
560
  const llmsIsText = llms.ok && !/<html/i.test(llms.text.slice(0, 500));
451
- const llmsLinks = llmsIsText ? [...llms.text.matchAll(/https?:\/\/[^\s)\]]+/g)].map((m) => m[0]) : [];
561
+ // Ссылки из markdown: хвостовые `, кавычки и точки не часть адреса. С ними brainmd.com/.well-known/ucp`
562
+ // считался мёртвым, а «brainmd.com.» чужим хостом (12.10.2026).
563
+ const llmsLinks = llmsIsText ? linksInText(llms.text) : [];
452
564
  const llmsForeign = llmsLinks.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return false; } });
453
565
  checks.push(row('llms', 'geo', 'llms.txt', !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : 'ok',
454
566
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
@@ -509,14 +621,24 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
509
621
  log(`sampling up to ${pages} pages`);
510
622
  const norm = (u) => { try { const x = new URL(u); x.hash = ''; x.search = ''; if (!x.pathname.includes('.') && !x.pathname.endsWith('/')) x.pathname += '/'; return x.href.toLowerCase(); } catch { return u; } };
511
623
  const seenUrl = new Set([norm(home.final), norm(startUrl)]);
512
- const rest = sitemapPages.filter((u) => { const n = norm(u); if (seenUrl.has(n)) return false; seenUrl.add(n); return true; });
624
+ const rest = sitemapPages.filter((u) => { if (!isPageUrl(u)) return false; const n = norm(u); if (seenUrl.has(n)) return false; seenUrl.add(n); return true; });
513
625
  // The contact, about and policy pages are what the trust and conversion checks are about, so they
514
626
  // are read first rather than left to the luck of sitemap order. A 44 page site sampled 20 deep was
515
627
  // reporting "no messenger and no contact page" while both sat in the sitemap, unread.
516
628
  const TRUST_PAGE = /\/(contact|contacts|contact-us|kontakt|kontakty|about|about-us|o-nas|team|company|privacy|privacy-policy|politika[a-z-]*|terms|terms-of-use|usloviya|oferta|legal|impressum)\/?$/i;
517
629
  const isTrust = (u) => { try { return TRUST_PAGE.test(new URL(u).pathname); } catch { return false; } };
518
630
  const trustFirst = rest.filter(isTrust);
519
- const pool = [home.final, ...trustFirst, ...rest.filter((u) => !trustFirst.includes(u))];
631
+ /*
632
+ * После страниц доверия идут те, на которые ссылается главная, и чем чаще ссылается, тем раньше.
633
+ * Карта сайта перечисляет страницы в порядке движка, а не по важности: у магазина на Shopify
634
+ * это порядок заведения товаров, и в выборку brainmd.com попали книги, диски и MP3 вместо
635
+ * основных добавок. Отчёт написал «FAQPage не найдена», хотя она стоит на страницах добавок
636
+ * (12.10.2026). Главная показывает то, что сайт продаёт, поэтому по ней и выбираем.
637
+ */
638
+ const homeRank = homeLinkRank(home.response.text || '', home.final, ownHosts);
639
+ const promoted = rest.filter((u) => !trustFirst.includes(u) && homeRank.has(norm(u)))
640
+ .sort((a, b) => homeRank.get(norm(b)) - homeRank.get(norm(a)));
641
+ const pool = [home.final, ...trustFirst, ...promoted, ...rest.filter((u) => !trustFirst.includes(u) && !promoted.includes(u))];
520
642
  const wanted = pool.slice(0, pages);
521
643
  /*
522
644
  * Обход по ссылкам. Карта дала меньше страниц, чем нужно (или её нет вовсе): недостающие
@@ -622,7 +744,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
622
744
  withType('Product').length ? '' : 'only for pages that really sell a named thing with a price'));
623
745
  checks.push(row('org-schema', 'geo', 'Organization or business schema', good.some((p) => p.schemaTypes.some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|LocalBusiness|RealEstateAgent|Dentist|Physician|Hospital|Pharmacy|VeterinaryCare|LegalService|Attorney|Notary|AccountingService|InsuranceAgency|FinancialService|BankOrCreditUnion|AutomotiveBusiness|AutoRepair|AutoDealer|HomeAndConstructionBusiness|Plumber|Electrician|RoofingContractor|HVACBusiness|HousePainter|Locksmith|MovingCompany|GeneralContractor|Restaurant|FoodEstablishment|CafeOrCoffeeShop|Bakery|BarOrPub|LodgingBusiness|Hotel|TravelAgency|HealthAndBeautyBusiness|BeautySalon|HairSalon|DaySpa|NailSalon|TattooParlor|SportsActivityLocation|HealthClub|Gym|ProfessionalService|Store|ClothingStore|GroceryStore|HardwareStore|FurnitureStore|JewelryStore|PetStore|ChildCare|Electrician|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency|SoftwareApplication/.test(t))) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|LocalBusiness|RealEstateAgent|Dentist|Physician|Hospital|Pharmacy|VeterinaryCare|LegalService|Attorney|Notary|AccountingService|InsuranceAgency|FinancialService|BankOrCreditUnion|AutomotiveBusiness|AutoRepair|AutoDealer|HomeAndConstructionBusiness|Plumber|Electrician|RoofingContractor|HVACBusiness|HousePainter|Locksmith|MovingCompany|GeneralContractor|Restaurant|FoodEstablishment|CafeOrCoffeeShop|Bakery|BarOrPub|LodgingBusiness|Hotel|TravelAgency|HealthAndBeautyBusiness|BeautySalon|HairSalon|DaySpa|NailSalon|TattooParlor|SportsActivityLocation|HealthClub|Gym|ProfessionalService|Store|ClothingStore|GroceryStore|HardwareStore|FurnitureStore|JewelryStore|PetStore|ChildCare|Electrician|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency|SoftwareApplication/.test(t))) ? 'present' : 'not found: answer engines have no entity to attach the site to'));
624
746
  checks.push(row('resources', 'technical', 'Page weight (homepage)', hp.scripts > 30 ? 'bad' : hp.scripts > 15 ? 'warn' : 'ok', `${hp.scripts} script file(s), ${hp.stylesheets} stylesheet(s), ${hp.images} image(s)`));
625
- if (hp.generator) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator));
747
+ if (hp.stack) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator ? hp.generator : `${hp.stack} (detected from the page code)`));
626
748
  checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
627
749
  }
628
750
  const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
@@ -678,7 +800,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
678
800
  }
679
801
  const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
680
802
  if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
681
- if (/wordpress/i.test(hp?.generator || '')) { const x = await get(`${siteOrigin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
803
+ if (/wordpress/i.test(hp?.stack || '')) { const x = await get(`${siteOrigin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
682
804
 
683
805
  // ---- Off-page and trust ----------------------------------------------------------------
684
806
  // Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
@@ -814,16 +936,18 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
814
936
  const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
815
937
  const S = ru ? {
816
938
  rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Что продаётся'],
939
+ partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
817
940
  site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
818
941
  siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
819
942
  paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
820
- limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в аудите по публичным сигналам не используются'],
943
+ limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
821
944
  fix: 'Исправить',
822
945
  roadmap: [['Неделя 1', 'Срочное, быстрые победы'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Рост: поиск и ответы ИИ']],
823
946
  geo: ['Понятно ли, кто вы', 'Упоминания на других сайтах', 'Отзывы и пресса'],
824
947
  conv: ['Форма связи', 'Цена видна', 'Мессенджеры'],
825
948
  } : {
826
949
  rows: ['CMS / stack', 'Pages in sitemap', 'Language', 'What is sold'],
950
+ partial: (n, read, total) => `at least ${n}: ${read} of ${total} sitemap files were read, the rest were not reached`,
827
951
  site: 'The site itself', free: 'Free, no account needed',
828
952
  siteDetail: (n, viaLinks) => `robots.txt, the sitemaps, llms.txt, the homepage and ${n} sampled page(s), read exactly as any visitor reads them${viaLinks ? `; ${viaLinks} of the pages were found by following links from the homepage, because the sitemap listed too few` : ''}`,
829
953
  paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
@@ -836,14 +960,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
836
960
  const criticalShown = ru ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical;
837
961
 
838
962
  return {
839
- meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
963
+ meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: ['9', '29', 'watch', 'free'].includes(String(tier)) ? (lang === 'ru' ? 'Замер по публичным сигналам' : 'External check (no Search Console or analytics access)') : (lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)'), lang, language: hp ? (hp.htmlLang || '') : '' },
840
964
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
841
965
  overall,
842
966
  reportScore,
843
967
  scores,
844
968
  scoreBasis,
845
969
  summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
846
- overview: { rows: [[S.rows[0], hp?.generator || '{{stack}}'], [S.rows[1], String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
970
+ overview: { rows: [[S.rows[0], hp?.generator || hp?.stack || '{{stack}}'], [S.rows[1], sitemapRead.stopped ? S.partial(sitemapPages.length, sitemapRead.filesRead, sitemapRead.filesRead + sitemapRead.pending) : String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
847
971
  critical: criticalShown,
848
972
  checks: localised,
849
973
  content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
@@ -867,7 +991,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
867
991
  reading,
868
992
  /* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
869
993
  entity: { profiles: profileUrls, hosts: profiles, telephone: Boolean(hp?.trust?.telephone), email: Boolean(hp?.trust?.email) },
870
- sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, description: p.description, h1: p.h1, firstPara: p.firstPara, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates } : p),
994
+ sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, description: p.description, h1: p.h1, firstPara: p.firstPara, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates, priceShown: p.priceShown, ctaFirstScreen: p.ctaFirstScreen } : p),
871
995
  notes,
872
996
  };
873
997
  }
package/src/i18n.mjs CHANGED
@@ -120,7 +120,7 @@ const RU = {
120
120
  'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
121
121
  resources: { label: 'Вес главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
122
122
  hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
123
- cms: { label: 'Движок сайта', value: (v) => v },
123
+ cms: { label: 'Движок сайта', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)') },
124
124
  xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
125
125
  alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${pages(n[3])} выборки`; } },
126
126
  'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному H1 на страницу' : `${nums(v)[0]} из ${pages(nums(v)[1])} имеют больше одного H1`) },
package/src/narrative.mjs CHANGED
@@ -30,7 +30,7 @@ const T = {
30
30
  * не понять ни человеку, ни поисковику, ни ИИ.
31
31
  */
32
32
  sells: (d) => d ? `${d} (прочитано из описания главной страницы)` : 'на главной не сказано, чем занимается компания: ни человеку, ни поисковику, ни ИИ это отсюда не понять',
33
- leadWhat: (h, n) => `${h} это сайт из ${n} ${plural(n, 'страницы', 'страниц', 'страниц')}, и он читается машинами: мы открыли его так же, как их открывают поисковик и ИИ.`,
33
+ leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
34
34
  leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
35
35
  leadBad: (area, n) => n ? `Держит сайт назад другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
36
36
  verdict: (openN, mech) => openN
@@ -39,6 +39,7 @@ const T = {
39
39
  noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
40
40
  ctaYes: 'на первом экране есть понятный призыв к действию',
41
41
  ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
42
+ ctaSome: 'призыв к действию на первом экране есть не на всех страницах',
42
43
  contactYes: 'связаться можно почти с любой страницы',
43
44
  contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
44
45
  strengthNone: 'Сильных сторон в текстах проверки не нашли.',
@@ -60,6 +61,7 @@ const T = {
60
61
  convForm: (v) => v,
61
62
  priceRow: 'Цена видна',
62
63
  priceNo: 'на проверенных страницах цены нет',
64
+ priceYes: (n, total) => `да: цена есть на ${n} из ${total} проверенных страниц`,
63
65
  closing: (host, bad) => bad
64
66
  ? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается то, что чинится технически: это быстро и это видно в цифрах. Потом берутся тексты, и вот там понадобитесь вы, потому что цифры и факты про ваш бизнес знаете только вы.`
65
67
  : `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
@@ -72,7 +74,7 @@ const T = {
72
74
  subjectNone: 'The homepage does not say what the business does. That is the first problem: if a person cannot tell, neither can a search engine or an AI.',
73
75
  unknownLang: 'not declared in the page code',
74
76
  sells: (d) => d ? `${d} (read from the homepage description)` : 'the homepage does not say what the company does: neither a person, nor a search engine, nor an AI can tell from it',
75
- leadWhat: (h, n) => `${h} is a site of ${n} page${n === 1 ? '' : 's'}, and it was read the way machines read it: the way a search engine and an AI open it.`,
77
+ leadWhat: (h, n) => `We read ${n} page${n === 1 ? '' : 's'} of ${h} the way machines read them: the way a search engine and an AI open them.`,
76
78
  leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
77
79
  leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed, and the hardest area is "${area}".` : `No check is failing, but the last points are missing, mostly in "${area}".`,
78
80
  verdict: (openN, mech) => openN
@@ -81,6 +83,7 @@ const T = {
81
83
  noteOffer: (cta, contact) => `What a visitor meets: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
82
84
  ctaYes: 'the first screen has a clear thing to do',
83
85
  ctaNo: 'the first screen has nothing to click, which means arrivals leave',
86
+ ctaSome: 'only some pages give the first screen a clear thing to do',
84
87
  contactYes: 'getting in touch is possible from almost any page',
85
88
  contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
86
89
  strengthNone: 'The checks found no strength in the text.',
@@ -100,6 +103,7 @@ const T = {
100
103
  offpageNote: 'We do not buy link data, so we print no number of links and no score built on one. What is checkable for free: whether the site says where else to find you, and whether that matches reality.',
101
104
  priceRow: 'Price visible',
102
105
  priceNo: 'no price on the pages we read',
106
+ priceYes: (n, total) => `yes: a price is shown on ${n} of ${total} pages we read`,
103
107
  closing: (host, bad) => bad
104
108
  ? `We looked at ${host} the way a machine looks at it and wrote down what we saw, without generalities. What gets fixed first is what is mechanical: it is quick and it shows in the numbers. Then comes the text, and there we need you, because only you know the figures and facts of your business.`
105
109
  : `We looked at ${host} the way a machine looks at it. Technically it is in order and there is nothing to repair, and that is a finding, not a compliment. Growth from here comes through text: answers to buyers' questions, with figures and sources.`,
@@ -170,6 +174,7 @@ export function draftNarrative(audit, opts = {}) {
170
174
 
171
175
  // Резюме.
172
176
  if (String(a.summary.lead).includes('{{')) {
177
+ // Число это выборка, а не размер сайта: «сайт из 19 страниц» про магазин на сотни товаров неправда (12.10.2026).
173
178
  a.summary.lead = [t.leadWhat(host, sample.length), t.leadGood(strong), t.leadBad(weakest, bad.length)].join(' ');
174
179
  }
175
180
  if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical);
@@ -213,7 +218,7 @@ export function draftNarrative(audit, opts = {}) {
213
218
  if (String(a.overview.note).includes('{{')) {
214
219
  const cta = byId(checks, 'conv-cta');
215
220
  const path = byId(checks, 'conv-contact-path');
216
- a.overview.note = t.noteOffer(open(cta) ? t.ctaNo : t.ctaYes, open(path) ? t.contactNo : t.contactYes);
221
+ a.overview.note = t.noteOffer(open(cta) ? (cta.status === 'bad' ? t.ctaNo : t.ctaSome) : t.ctaYes, open(path) ? t.contactNo : t.contactYes);
217
222
  }
218
223
 
219
224
  // Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
@@ -255,7 +260,13 @@ export function draftNarrative(audit, opts = {}) {
255
260
  if (!String(status).includes('{{')) return [label, status];
256
261
  if (/form|форм/i.test(label)) { const c = byId(checks, 'conv-form-fields') || byId(checks, 'conv-form-depth'); return [label, c ? c.value : t.notMeasured]; }
257
262
  if (/messenger|мессендж/i.test(label)) { const c = byId(checks, 'conv-messenger'); return [label, c ? c.value : t.notMeasured]; }
258
- if (/price|цена/i.test(label)) return [label, t.priceNo];
263
+ if (/price|цена/i.test(label)) {
264
+ // Только по прочитанному: старая сборка без отметки цены остаётся «не измеряли», а не «цены нет».
265
+ const read = (a.sample || []).filter((p) => p && p.title !== undefined);
266
+ if (!read.length || read.every((p) => p.priceShown === undefined)) return [label, t.notMeasured];
267
+ const withPrice = read.filter((p) => p.priceShown).length;
268
+ return [label, withPrice ? t.priceYes(withPrice, read.length) : t.priceNo];
269
+ }
259
270
  return [label, t.notMeasured];
260
271
  });
261
272
 
package/src/prompts.mjs CHANGED
@@ -236,6 +236,12 @@ const P = {
236
236
  ru: { task: 'Подними форму заявки выше на длинных страницах: либо перенеси её в первую треть, либо поставь вверху кнопку, которая прокручивает к форме. Сами поля и текст формы не меняй.', verify: 'На проверенных страницах форма или кнопка, ведущая к ней, видна в первой половине страницы, в том числе на телефоне.' },
237
237
  en: { task: 'Move the enquiry form higher on long pages: either into the first third, or add a button near the top that scrolls to it. Do not change the form fields or wording.', verify: 'On the sampled pages the form, or a button leading to it, sits in the first half of the page, on a phone too.' },
238
238
  },
239
+ // Формы на проверенных страницах нет вовсе (08.10.2026): «подними форму выше» тут противоречит
240
+ // строке «Сейчас». Отдельное задание: добавить форму, только если владелец принимает заявки на сайте.
241
+ 'conv-form-missing': {
242
+ ru: { task: 'На проверенных страницах нет формы заявки. Спроси владельца, принимает ли он заявки через сайт. Если да, добавь короткую форму (имя и один способ связи) в первую треть главной и страниц услуг или поставь вверху кнопку, которая ведёт к форме. Если заявки принимаются только по телефону или в мессенджере, форму не добавляй, а напиши владельцу, что этот пункт можно закрыть.', verify: 'На главной и страницах услуг форма или кнопка, ведущая к ней, видна в первой половине страницы, в том числе на телефоне; либо владелец ответил, что форма не нужна.' },
243
+ en: { task: 'There is no enquiry form on the sampled pages. Ask the owner whether they take enquiries through the site. If they do, add a short form (a name and one way to reach them) in the first third of the home page and the service pages, or a button near the top that leads to it. If enquiries come only by phone or messenger, add no form and tell the owner this item can be closed.', verify: 'On the home page and the service pages a form, or a button leading to it, shows in the first half of the page, on a phone too; or the owner has said no form is needed.' },
244
+ },
239
245
  'conv-form-fields': {
240
246
  ru: { task: 'Сократи форму заявки до четырёх полей или меньше: имя и один способ связи (телефон или мессенджер, на выбор), остальное необязательным или убрать. Какие поля нужны для работы, решает владелец: перечисли ему, что предлагаешь убрать, и убирай после его ответа.', verify: 'В форме не больше четырёх полей, и заявка с заполненными обязательными полями доходит до владельца.' },
241
247
  en: { task: 'Cut the enquiry form to four fields or fewer: a name and one way to reach them (phone or messenger, their choice), the rest optional or gone. The owner decides which fields the business needs: list what you propose to drop and drop it once they agree.', verify: 'The form has at most four fields, and a submission with the required fields reaches the owner.' },
@@ -342,11 +348,14 @@ const promptKey = (id) => (String(id || '').startsWith('engine-') ? 'engine' : i
342
348
  export function agentPrompt(check, opts = {}) {
343
349
  const lang = opts.lang === 'ru' ? 'ru' : 'en';
344
350
  const h = HEAD[lang];
345
- const own = P[promptKey(check.id)] ? withLang(P[promptKey(check.id)], lang) : null;
351
+ const noForm = check.id === 'conv-form-depth' && /no form|формы нет/i.test(String(check.value || ''));
352
+ const key = noForm ? 'conv-form-missing' : promptKey(check.id);
353
+ const own = P[key] ? withLang(P[key], lang) : null;
346
354
  const rule = FIX_ACTIONS[check.id];
347
355
  const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
356
+ const title = noForm ? (lang === 'ru' ? 'Форма заявки' : 'Enquiry form') : check.label;
348
357
 
349
- const lines = [`### ${check.label}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
358
+ const lines = [`### ${title}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
350
359
  // Проверка обязательна у каждого задания: без неё агент не знает, когда остановиться.
351
360
  lines.push('', `**${h.verify}:** ${own ? own.verify : h.genericVerify}`);
352
361
  const where = whereLine(check.id, opts.platform || null, lang);
package/src/render.mjs CHANGED
@@ -339,11 +339,31 @@ const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join
339
339
  const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
340
340
  const statusCell = (s, L) => `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${L[STATUS_KEYS[s] || 'statusNa']}</td>`;
341
341
 
342
+ /*
343
+ * Название документа по тому, что купили (08.10.2026). Покупатель «Всех правок по сайту» за 800 ₽
344
+ * получал PDF с обложкой «Аудит сайта» и мог решить, что ему прислали не тот товар. Слово «аудит»
345
+ * остаётся только у аудита (tier audit) и у отчётов без ступени (запуск движка вручную); у ступеней
346
+ * 9, 29, watch и free документ называется отчётом.
347
+ */
348
+ const REPORT_TIERS = new Set(['9', '29', 'watch', 'free']);
349
+ const REPORT_WORDING = {
350
+ en: { eyebrowAudit: 'Measurement and fix list', coverTitle: 'SEO, AEO and GEO<br>site report', auditSubject: 'Site checked', auditType: 'Report type', runningHead: 'SEO, AEO and GEO report', docTitle: 'SEO, AEO and GEO report', from: [['this audit does not test', 'this report does not test'], ['re-run this audit yourself', 're-run this check yourself']] },
351
+ ru: { eyebrowAudit: 'Замер и список правок', coverTitle: 'Отчёт о сайте:<br>SEO, AEO и GEO', auditSubject: 'Что проверяли', auditType: 'Тип отчёта', runningHead: 'Отчёт SEO, AEO и GEO', docTitle: 'отчёт SEO, AEO и GEO', from: [['которую аудит не проверяет', 'которую этот отчёт не проверяет'], ['повторить этот аудит сами', 'повторить эту проверку сами']] },
352
+ };
353
+ export function labelsFor(lang, tier) {
354
+ const key = lang === 'ru' ? 'ru' : 'en';
355
+ const base = LABELS[key];
356
+ if (!REPORT_TIERS.has(String(tier ?? ''))) return { ...base, docTitle: key === 'ru' ? 'аудит SEO, AEO и GEO' : 'SEO, AEO and GEO audit' };
357
+ const w = REPORT_WORDING[key];
358
+ const swap = (txt) => w.from.reduce((t, [a, b]) => t.replace(a, b), txt);
359
+ return { ...base, eyebrowAudit: w.eyebrowAudit, coverTitle: w.coverTitle, auditSubject: w.auditSubject, auditType: w.auditType, runningHead: w.runningHead, docTitle: w.docTitle, scorecardFoot: swap(base.scorecardFoot), sourcesFoot: swap(base.sourcesFoot) };
360
+ }
361
+
342
362
  export function toHtml(audit, brand = null) {
343
363
  // Цвет агентства подменяет только акцент: остальная палитра отчёта остаётся выверенной.
344
364
  const accentCss = brand && brand.accent ? `:root{--accent:${brand.accent};--accent-soft:${softenHex(brand.accent)};}` : '';
345
365
  const a = audit;
346
- const L = LABELS[a.meta?.lang === 'ru' ? 'ru' : 'en'];
366
+ const L = labelsFor(a.meta?.lang, a.meta?.tier);
347
367
  const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
348
368
  const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
349
369
  const { scores: measured, scoreBasis } = computeScores(a.checks || []);
@@ -658,7 +678,7 @@ export function toHtml(audit, brand = null) {
658
678
  if (a.demand && Array.isArray(a.demand.queries)) {
659
679
  pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
660
680
  }
661
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: SEO, AEO and GEO audit</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
681
+ return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
662
682
  }
663
683
 
664
684
  function findChrome() {
package/src/rendered.mjs CHANGED
@@ -52,6 +52,13 @@ export function findChrome() {
52
52
  }
53
53
 
54
54
  /** Текст страницы после выполнения скриптов. null, если браузера нет или он не справился. */
55
+ /** Chrome после остановки ещё дописывает профиль: без повторов rm падал ENOTEMPTY на macOS и ронял
56
+ * весь сбор (brainmd.com, 12.10.2026). Временную папку не удалось убрать, значит уберёт система:
57
+ * ронять из-за неё замер нельзя. */
58
+ export function removeProfile(dir, rm = rmSync) {
59
+ try { rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); return true; } catch { return false; }
60
+ }
61
+
55
62
  export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 8000 } = {}) {
56
63
  const bin = chrome || findChrome();
57
64
  if (!bin) return Promise.resolve(null);
@@ -88,7 +95,7 @@ export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 80
88
95
  if (value && isBrowserErrorPage(value)) value = null;
89
96
  clearTimeout(timer);
90
97
  stopGroup();
91
- rmSync(profile, { recursive: true, force: true });
98
+ removeProfile(profile);
92
99
  resolve(value);
93
100
  };
94
101
  const stopGroup = () => {
@@ -60,6 +60,15 @@ ok('упоминания на чужих сайтах помечены как н
60
60
  ok('и объяснено почему', /за деньги/.test(d.geo.rows[1][2]));
61
61
  ok('в разделе ссылок сказано, что данные платные', /не покупаем данные/.test(d.offpage.note));
62
62
  ok('язык не выдуман, если его нет в коде', /не указан/.test(d.overview.rows[2][1]));
63
+ // Число в начале резюме это выборка, а не размер сайта: «сайт из 19 страниц» о магазине на
64
+ // сотни товаров неправда (brainmd.com, 12.10.2026).
65
+ ok('резюме говорит, сколько прочитано, а не сколько страниц у сайта', /^Мы прочитали \d+ страниц/.test(d.summary.lead) && !/сайт из/.test(d.summary.lead));
66
+ {
67
+ const en = draftNarrative(audit(), { lang: 'en' });
68
+ ok('и по-английски тоже', /^We read \d+ pages? of /.test(en.summary.lead) && !/is a site of/.test(en.summary.lead));
69
+ const withLang = audit(); withLang.meta.language = 'en';
70
+ is('язык из кода страницы попадает в обзор', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], 'en');
71
+ }
63
72
 
64
73
  // Числа сходятся между собой
65
74
  const openN = 4; const mech = 2; // alt, conv-cta механические; thin и answer-first это тексты
@@ -3,12 +3,21 @@
3
3
  import { chmodSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom } from './rendered.mjs';
6
+ import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom, removeProfile } from './rendered.mjs';
7
7
 
8
8
  let bad = 0;
9
9
  const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
10
10
  const ok = (n, c) => is(n, Boolean(c), true);
11
11
 
12
+ // Профиль Chrome: ENOTEMPTY на macOS ронял весь сбор (brainmd.com, 12.10.2026).
13
+ {
14
+ let opts = null;
15
+ is('удаление профиля с повторами', removeProfile('/tmp/x', (d, o) => { opts = o; }), true);
16
+ ok('повторы включены', opts && opts.maxRetries >= 3 && opts.recursive);
17
+ const fail = () => { const e = new Error('ENOTEMPTY: directory not empty'); e.code = 'ENOTEMPTY'; throw e; };
18
+ is('ENOTEMPTY не роняет сбор', removeProfile('/tmp/x', fail), false);
19
+ }
20
+
12
21
  const text = (n) => '<p>' + 'слово '.repeat(n) + '</p>';
13
22
  const body = (inner) => `<html><body>${inner}</body></html>`;
14
23
 
@@ -0,0 +1,35 @@
1
+ // 08.10.2026: отчёт за 800 ₽ не называется аудитом; задание про форму не противоречит строке «Сейчас».
2
+ import { readFileSync } from 'node:fs';
3
+ import { labelsFor, toHtml } from './render.mjs';
4
+ import { agentPrompt } from './prompts.mjs';
5
+
6
+ let failed = 0;
7
+ const ok = (name, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${name}`); if (!cond) failed++; };
8
+
9
+ for (const tier of ['9', '29', 'watch', 'free']) {
10
+ const ru = labelsFor('ru', tier); const en = labelsFor('en', tier);
11
+ ok(`ступень ${tier}: русская обложка без слова «аудит»`, !/аудит/i.test(ru.coverTitle + ru.eyebrowAudit + ru.runningHead + ru.auditType + ru.docTitle));
12
+ ok(`ступень ${tier}: английская обложка без слова audit`, !/audit/i.test(en.coverTitle + en.eyebrowAudit + en.runningHead + en.auditType + en.docTitle));
13
+ ok(`ступень ${tier}: сноски без слова «аудит»`, !/аудит/i.test(ru.scorecardFoot + ru.sourcesFoot) && !/audit/i.test(en.scorecardFoot + en.sourcesFoot));
14
+ }
15
+ for (const tier of ['audit', '', undefined]) {
16
+ ok(`ступень «${tier}»: аудит остаётся аудитом`, /Аудит/.test(labelsFor('ru', tier).coverTitle) && /audit/.test(labelsFor('en', tier).coverTitle));
17
+ }
18
+
19
+ const sample = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url)));
20
+ const html9 = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: '9' } });
21
+ ok('PDF за 800 ₽: русский title и lang', /<html lang="ru">/.test(html9) && /: отчёт SEO, AEO и GEO<\/title>/.test(html9));
22
+ ok('PDF за 800 ₽: в шапках страниц «Отчёт», не «Аудит»', /Отчёт SEO, AEO и GEO ·/.test(html9) && !/Аудит SEO, AEO и GEO ·/.test(html9));
23
+ const htmlAudit = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: 'audit' } });
24
+ ok('аудит за 12 500 ₽: обложка прежняя', /Аудит сайта/.test(htmlAudit));
25
+
26
+ const noForm = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'на проверенных страницах формы нет', status: 'bad' }, { lang: 'ru' });
27
+ ok('формы нет: заголовок задачи «Форма заявки»', noForm.startsWith('### Форма заявки'));
28
+ ok('формы нет: задание не просит поднять форму', /нет формы заявки/.test(noForm) && !/Подними форму/.test(noForm));
29
+ const noFormEn = agentPrompt({ id: 'conv-form-depth', label: 'How far down the form sits', value: 'no form on the sampled pages', status: 'bad' }, { lang: 'en' });
30
+ ok('no form: English task asks first', /no enquiry form/.test(noFormEn) && !/Move the enquiry form higher/.test(noFormEn));
31
+ const deep = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'медиана 85% вниз по странице, измерено на 3 страницах', status: 'bad' }, { lang: 'ru' });
32
+ ok('форма есть, но низко: прежнее задание', /Подними форму/.test(deep));
33
+
34
+ if (failed) { console.log(`\n${failed} не сошлось`); process.exit(1); }
35
+ console.log('\nвсё сходится');
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Магазин на Shopify, как brainmd.com 12.10.2026. Прогон 0.33.3 дал пять неправд о живом сайте:
4
+ * 1. «язык не указан», хотя <html> разбит на строки и lang="en" в нём стоит;
5
+ * 2. «в карте 349 страниц» при 1 357: из десяти файлов карты прочитаны шесть, статьи пропущены;
6
+ * 3. «CMS не измерено», хотя в коде Shopify.shop и cdn.shopify.com;
7
+ * 4. в выборку попали книги и диски вместо основных товаров, и FAQPage «не найдена»;
8
+ * 5. балл видимости не посчитан: в выборку попали agents.md и вход в аккаунт.
9
+ * Второй прогон того же дня нашёл ещё пять:
10
+ * 6. «нет sameAs», хотя в JSON-LD семь профилей, только слеши записаны как \/;
11
+ * 7. «мёртвая ссылка» в llms.txt с хвостовой обратной кавычкой из markdown;
12
+ * 8. «цены нет» при ценах на каждом товаре; пустая корзина $0.00 при этом не цена;
13
+ * 9. «на первом экране нечего нажать»: Add to cart не считалось, а меню съедало 15% разметки;
14
+ * 10. свой домен с точкой на конце в списке чужих хостов llms.txt.
15
+ * Здесь такой же магазин поднимается локально, и каждая из пяти неправд проверяется отдельно.
16
+ */
17
+ import { createServer } from 'node:http';
18
+ import { lookup } from 'node:dns/promises';
19
+ import { collect, detectStack, homeLinkRank } from './collect.mjs';
20
+ import { checkVisibility, linksInText } from './visibility.mjs';
21
+ import { localiseChecks } from './i18n.mjs';
22
+ import { draftNarrative } from './narrative.mjs';
23
+
24
+ let bad = 0;
25
+ const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
26
+ const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
27
+
28
+ // ---- движок по следам в коде
29
+ is('Shopify по Shopify.shop', detectStack('<script>Shopify.shop = "x.myshopify.com";</script>'), 'Shopify');
30
+ is('Shopify по cdn.shopify.com', detectStack('<link href="//cdn.shopify.com/s/files/1/a.css">'), 'Shopify');
31
+ is('WordPress по wp-content', detectStack('<img src="/wp-content/uploads/a.jpg">'), 'WordPress');
32
+ is('без следов не угадываем', detectStack('<p>Shopify is a platform we like</p>'), '');
33
+
34
+ // ---- 7, 10. Адреса из markdown без хвостов
35
+ is('хвосты markdown отрезаны', linksInText('See `https://x.com/.well-known/ucp`. Home: https://x.com. Also (https://x.com/a), "https://x.com/b"'),
36
+ ['https://x.com/.well-known/ucp', 'https://x.com', 'https://x.com/a', 'https://x.com/b']);
37
+
38
+ // ---- порядок по ссылкам главной
39
+ {
40
+ const r = homeLinkRank('<a href="/products/a">1</a><a href="/products/a">2</a><a href="https://www.x.com/products/b/">3</a><a href="https://other.com/products/c">4</a>', 'https://x.com/', new Set(['x.com', 'www.x.com']));
41
+ is('ссылки главной посчитаны', [r.get('https://x.com/products/a/'), r.get('https://www.x.com/products/b/'), r.has('https://other.com/products/c/')], [2, 1, false]);
42
+ }
43
+
44
+ const BOOKS = Array.from({ length: 12 }, (_, i) => `/products/book-${i + 1}`);
45
+ const MAIN = ['/products/omega-power', '/products/memory-boost'];
46
+ const ARTICLES = Array.from({ length: 980 }, (_, i) => `/blogs/news/post-${i + 1}`);
47
+ const PAGES = ['/pages/contact', '/pages/about'];
48
+ const CHILDREN = ['agentic_discovery', 'products_1?from=1&amp;to=2', 'pages_1', 'collections_1', 'blogs_1', 'articles_1', 'metaobject_pages_1', 'metaobject_pages_2', 'metaobject_pages_3'];
49
+
50
+ const shopifyHead = (title, schema = '') => `<!doctype html>
51
+ <html
52
+ class="no-js market-us "
53
+ lang="en"
54
+
55
+ >
56
+ <head><title>${title} | Brain Store supplements</title>
57
+ <meta name="description" content="A description of the page that is long enough to count as a real one for the check.">
58
+ <link rel="stylesheet" href="//cdn.shopify.com/s/files/1/theme.css">
59
+ <script>var Shopify = Shopify || {}; Shopify.shop = "x09u0a-0i.myshopify.com";</script>${schema}</head>`;
60
+ const sameAs = '<script type="application/ld+json">{"@context":"https:\\/\\/schema.org","@type":"Organization","name":"Brain Store","sameAs":["https:\\/\\/www.instagram.com\\/brainstore\\/","https:\\/\\/www.youtube.com\\/brainstore","https:\\/\\/www.facebook.com\\/brainstore"]}</script>';
61
+ const cartDrawer = '<div class="drawer">Cart 0. Spend $0.00 more to reach free shipping!</div>';
62
+ const bigMenu = `<header><div class="menu">${'<div class="item"><img src="/i.png" alt=""><span class="x"></span></div>'.repeat(400)}</div></header>`;
63
+ const buyBox = `<div class="gallery">${'<img src="/g.jpg" alt="">'.repeat(60)}</div><div class="buy"><p>$39.95</p><button type="submit" name="add">Add To Cart</button></div>`;
64
+ const faq = '<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}</script><script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"X"}</script>';
65
+ const body = (h1, links = []) => `<body><main><h1>${h1}</h1>${'<p>Plain paragraph about the product, what it does and the price.</p>'.repeat(8)}
66
+ <nav>${links.map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
67
+ const urlset = (base, list) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${list.map((p) => `<url><loc>${base}${p}</loc></url>`).join('')}</urlset>`;
68
+
69
+ const hits = [];
70
+ const server = createServer((req, res) => {
71
+ hits.push(req.url);
72
+ const base = `http://${req.headers.host}`;
73
+ const xml = (s) => { res.writeHead(200, { 'content-type': 'application/xml' }); res.end(s); };
74
+ const html = (s) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(s); };
75
+ const u = req.url;
76
+ if (u === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`User-agent: *\nDisallow: /cart\nSitemap: ${base}/sitemap.xml\n`); }
77
+ if (u === '/sitemap.xml') return xml(`<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${CHILDREN.map((c) => `<sitemap><loc>${base}/sitemap_${c.replace(/\?.*/, '')}.xml${c.includes('?') ? `?${c.split('?')[1]}` : ''}</loc></sitemap>`).join('')}</sitemapindex>`);
78
+ if (u === '/sitemap_agentic_discovery.xml') return xml(urlset(base, ['/agents.md']));
79
+ // Shopify отдаёт товары только с обоими параметрами: &amp; в адресе значит пустую карту.
80
+ if (u.startsWith('/sitemap_products_1.xml')) return xml(urlset(base, u === '/sitemap_products_1.xml?from=1&to=2' ? [...BOOKS, ...MAIN] : []));
81
+ if (u === '/sitemap_pages_1.xml') return xml(urlset(base, PAGES));
82
+ if (u === '/sitemap_articles_1.xml') return xml(urlset(base, ARTICLES));
83
+ if (u.startsWith('/sitemap_')) return xml(urlset(base, []));
84
+ if (u === '/agents.md') { res.writeHead(200, { 'content-type': 'text/markdown' }); return res.end('# Agents\n'); }
85
+ if (u.startsWith('/customer_authentication/')) return; // висит, как вход в аккаунт Shopify
86
+ if (u === '/llms.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`# Brain Store\n\nShop: ${base}.\nAgents: \`${base}/.well-known/ucp\`.\n- [Omega](${base}${MAIN[0]})\n- [Memory](${base}${MAIN[1]})\n- [Contact](${base}/pages/contact)\n- [Shop app](https://shop.app/brainstore)\n`); }
87
+ if (u === '/.well-known/ucp') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{}'); }
88
+ if (u === '/') return html(shopifyHead('Home', sameAs) + body('Brain Store', [MAIN[0], MAIN[0], MAIN[0], MAIN[1], MAIN[1], '/pages/contact', '/customer_authentication/redirect']));
89
+ if (MAIN.includes(u)) return html(shopifyHead(u, faq) + `<body>${cartDrawer}${bigMenu}<main><h1>${u}</h1>${buyBox}${'<p>Plain paragraph about the product, what it does and how to take it.</p>'.repeat(40)}</main></body></html>`);
90
+ if (PAGES.includes(u)) return html(shopifyHead(u) + `<body>${cartDrawer}` + body(u).slice('<body>'.length));
91
+ if (BOOKS.includes(u) || u.startsWith('/blogs/')) return html(shopifyHead(u) + body(u));
92
+ res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
93
+ });
94
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
95
+ // Бесплатная проверка не берёт 127.0.0.1 (это не публичный адрес), поэтому живая часть идёт через lvh.me.
96
+ let dnsOk = true;
97
+ try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
98
+ if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); server.close(); process.exit(bad ? 1 : 0); }
99
+ const base = `http://lvh.me:${server.address().port}`;
100
+
101
+ try {
102
+ const a = await collect(`${base}/`, { pages: 6, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
103
+ const check = (id) => a.checks.find((c) => c.id === id) || {};
104
+ const row = (label) => (a.overview.rows.find((r) => r[0] === label) || [])[1];
105
+
106
+ // 1. Язык
107
+ is('1. язык прочитан из многострочного <html>', a.meta.language, 'en');
108
+
109
+ // 2. Карта сайта
110
+ const total = BOOKS.length + MAIN.length + PAGES.length + ARTICLES.length + 1; // + agents.md
111
+ is('2. прочитаны все десять файлов карты', check('sitemap').value, `${total} URL(s) in ${base}/sitemap.xml`);
112
+ is('2. в обзоре полное число', row('Pages in sitemap'), String(total));
113
+ ok('2. товары прочитаны по адресу с &, а не с &amp;', hits.includes('/sitemap_products_1.xml?from=1&to=2'));
114
+
115
+ // 2. Если карта прочитана не вся, в обзоре это сказано, а не выдано за целое.
116
+ const part = await collect(`${base}/`, { pages: 2, pageDelayMs: 0, sitemapMaxFiles: 3, log: () => {}, visibility: { ok: false, error: 'skipped in test' }, rendered: false });
117
+ const partRow = (part.overview.rows.find((r) => r[0] === 'Pages in sitemap') || [])[1] || '';
118
+ ok(`2. частичная цифра названа частичной (${partRow})`, /^at least \d+: 3 of 10 sitemap files were read/.test(partRow));
119
+
120
+ // 3. Движок
121
+ is('3. Shopify в обзоре', row('CMS / stack'), 'Shopify');
122
+ is('3. Shopify в проверке', check('cms').value, 'Shopify (detected from the page code)');
123
+ is('3. по-русски', localiseChecks([check('cms')], 'ru')[0].value, 'Shopify (определён по коду страницы)');
124
+
125
+ // 4. Выборка
126
+ const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
127
+ ok(`4. основные товары с главной в выборке (${read.join(', ')})`, MAIN.every((p) => read.includes(p)));
128
+ ok('4. чаще упомянутый на главной идёт раньше', read.indexOf(MAIN[0]) < read.indexOf(MAIN[1]));
129
+ ok('4. книги не вытеснили товары', read.filter((p) => p.startsWith('/products/book-')).length <= 1);
130
+ ok('4. agents.md не читается как страница', !read.includes('/agents.md'));
131
+ is('4. FAQPage найдена', check('faq-schema').status, 'ok');
132
+
133
+ // 6. Профили из sameAs со слешами \/
134
+ is('6. профили прочитаны', check('trust-profiles').value, '3: instagram.com, youtube.com, facebook.com');
135
+
136
+ // 7, 10. llms.txt
137
+ ok(`7. адрес с обратной кавычкой не мёртвый (${check('llms-health').value})`, check('llms-health').status === 'ok');
138
+ // Точку после своего домена с портом URL не разберёт, поэтому сам случай brainmd.com. проверен выше на linksInText.
139
+ is('10. свой домен с точкой не чужой хост', check('llms').value, '6 link(s), 1 to other hosts');
140
+
141
+ // 8. Цена: из выборки, и пустая корзина ценой не считается
142
+ const priced = a.sample.filter((p) => p.priceShown).map((p) => new URL(p.url).pathname);
143
+ ok(`8. цена найдена на товарах и только на них (${priced.join(', ')})`, MAIN.every((p) => priced.includes(p)) && !priced.includes('/pages/contact'));
144
+ const drafted = draftNarrative(a, { lang: 'en' });
145
+ ok(`8. строка «цена видна» не пишет «цены нет» (${drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1]})`, /^yes: a price is shown on 2 of \d+ pages/.test(drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1] || ''));
146
+
147
+ // 9. Призыв на первом экране: Add To Cart после огромного меню и галереи
148
+ ok(`9. Add to cart на товаре засчитан (${check('conv-cta').value})`, /^2 of /.test(check('conv-cta').value) // два товара; ссылки главной стоят в <nav>, это не первый экран
149
+ && a.sample.filter((p) => MAIN.some((m) => p.url.endsWith(m))).every((p) => p.ctaFirstScreen === true));
150
+
151
+ // 5. Балл видимости
152
+ const v = await checkVisibility(`${base}/`, { lang: 'en' });
153
+ ok(`5. балл посчитан (${v.ok ? v.score : v.error})`, v.ok && Number.isFinite(v.score));
154
+ const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
155
+ ok(`5. в выборке нет agents.md и входа в аккаунт (${vRead.join(', ')})`, vRead.length >= 3 && !vRead.some((p) => p === '/agents.md' || p.startsWith('/customer_authentication')));
156
+ } finally { server.close(); server.closeAllConnections?.(); }
157
+
158
+ if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
159
+ console.log('\nвсё сходится');
@@ -241,7 +241,7 @@ const bare = (h) => String(h || '').toLowerCase().replace(/^www\./, '');
241
241
 
242
242
  /** Файлы и служебные разделы: это не страницы, по которым судят о сайте. */
243
243
  const FILE_EXT = /\.(?!html?$|php$|aspx?$)[a-z0-9]{2,5}$/i;
244
- const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|login|log-in|signin|sign-in|register|logout|wp-admin|wp-login\.php|wp-json|feed|rss|tag|tags|author|search|cdn-cgi)(\/|$)/i;
244
+ const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|login|customer_authentication|customer_identity|log-in|signin|sign-in|register|logout|wp-admin|wp-login\.php|wp-json|feed|rss|tag|tags|author|search|cdn-cgi)(\/|$)/i;
245
245
 
246
246
  /**
247
247
  * Свои ссылки страницы в порядке появления, без повторов.
@@ -249,6 +249,14 @@ const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|log
249
249
  * @param {string} baseUrl адрес страницы, от него считаются относительные ссылки
250
250
  * @param {Iterable<string>} [ownHosts] хосты сайта; по умолчанию хост baseUrl, с www и без
251
251
  */
252
+ /** Адреса из простого текста или markdown. Хвостовые знаки препинания, обратные кавычки и
253
+ * звёздочки к адресу не относятся: `https://x.com/a`. или (https://x.com/b), в конце строки. */
254
+ export function linksInText(text) {
255
+ return [...String(text || '').matchAll(/https?:\/\/[^\s)\]>"'`<]+/g)]
256
+ .map((m) => m[0].replace(/[.,;:!?*_'"`]+$/, ''))
257
+ .filter((u) => { try { return Boolean(new URL(u).host); } catch { return false; } });
258
+ }
259
+
252
260
  export function internalLinks(html, baseUrl, ownHosts = []) {
253
261
  let base;
254
262
  try { base = new URL(baseUrl); } catch { return []; }
@@ -531,13 +539,29 @@ async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
531
539
  const r = await get(u, { timeout: timeoutFn() });
532
540
  if (r.error === 'timeout') { timedOut = true; continue; }
533
541
  if (!r.ok || !/<(urlset|sitemapindex)/.test(r.text)) continue;
534
- let locs = [...r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
542
+ const locsOf = (text) => [...text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1].replace(/&amp;/g, '&'));
543
+ const pagesOf = (list) => list.filter((l) => { try { const p = new URL(l).pathname; return !FILE_EXT.test(p) && !UTILITY.test(p); } catch { return false; } });
544
+ let locs = locsOf(r.text);
535
545
  let lastmod = /<lastmod>/.test(r.text);
536
- if (/<sitemapindex/.test(r.text) && locs.length && timeoutFn() > 500) {
537
- const child = await get(locs[0], { timeout: timeoutFn() });
538
- if (child.ok) { locs = [...child.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]); lastmod = lastmod || /<lastmod>/.test(child.text); }
546
+ /*
547
+ * Из индекса берём первую вложенную карту, в которой есть страницы. До 0.33.4 брали просто
548
+ * первую, а у Shopify это sitemap_agentic_discovery.xml с одним agents.md: в выборку шёл файл
549
+ * не HTML, он считался «не ответившей страницей», и балл не считался вовсе (brainmd.com,
550
+ * 12.10.2026). Смотрим не больше трёх вложенных карт.
551
+ */
552
+ if (/<sitemapindex/.test(r.text) && locs.length) {
553
+ const children = locs;
554
+ for (const childUrl of children.slice(0, 3)) {
555
+ if (timeoutFn() <= 500) break;
556
+ const child = await get(childUrl, { timeout: timeoutFn() });
557
+ if (!child.ok) continue;
558
+ const childLocs = locsOf(child.text);
559
+ lastmod = lastmod || /<lastmod>/.test(child.text);
560
+ if (pagesOf(childLocs).length) { locs = childLocs; break; }
561
+ }
562
+ if (locs === children) locs = [];
539
563
  }
540
- return { found: true, url: u, count: locs.length, lastmod, pages: locs.filter((l) => !/\.(xml|pdf|jpe?g|png)$/i.test(l)) };
564
+ return { found: true, url: u, count: locs.length, lastmod, pages: pagesOf(locs) };
541
565
  }
542
566
  return { found: false, url: '', count: 0, lastmod: false, pages: [], timedOut };
543
567
  }
@@ -672,7 +696,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
672
696
  * сайт без карты мерился по одной главной. Запросов не добавляет, главная уже прочитана.
673
697
  * Добавлено 30.09.2026.
674
698
  */
675
- const fromSitemap = sitemap.pages.filter(isOwn);
699
+ const fromSitemap = sitemap.pages.filter(isOwn).filter((u) => { try { const p = new URL(u).pathname; return !FILE_EXT.test(p) && !UTILITY.test(p); } catch { return false; } });
676
700
  const allowedByRobots = robotsAllows(robotsRes.ok ? robotsRes.text : '');
677
701
  const fromHome = fromSitemap.length > samplePages ? [] : internalLinks(home.text, home.url, [host]).filter((l) => allowedByRobots(l));
678
702
  const poolSeen = new Set();
@@ -713,9 +737,15 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
713
737
  }
714
738
  const read = async (list) => Promise.all(list.map((p) => get(p, { timeout: Math.min(4000, left() - 300) })));
715
739
  let rs = await read(picks);
740
+ /*
741
+ * Адрес, который ответил 200, но не HTML (markdown, PDF, JSON), это не «страница не ответила»,
742
+ * а не страница вовсе. Повторять его бессмысленно, и в счёт выборки он не идёт: иначе один
743
+ * agents.md в карте Shopify отменял балл всему сайту (12.10.2026).
744
+ */
745
+ const notPages = new Set(picks.filter((p, i) => rs[i].ok && !/html/i.test(rs[i].type)));
716
746
  // Одна повторная попытка по тем, что не ответили: страницы маленькие, а разница в балле
717
747
  // между «прочитали две» и «прочитали одну» доходила до двадцати пунктов.
718
- const failed = picks.filter((p, i) => !(rs[i].ok && /html/i.test(rs[i].type)));
748
+ const failed = picks.filter((p, i) => !notPages.has(p) && !(rs[i].ok && /html/i.test(rs[i].type)));
719
749
  if (failed.length) {
720
750
  // Повтору даётся своё время сверх общего бюджета, один раз и не больше трёх секунд.
721
751
  // Без этого на медленном сайте повтор упирался в тот же истёкший бюджет и проверка
@@ -732,13 +762,13 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
732
762
  // Дубли по адресу: повтор мог вернуть ту же страницу, что и первая попытка.
733
763
  const seenUrl = new Set();
734
764
  sampled = sampled.filter((p) => (seenUrl.has(p.url) ? false : seenUrl.add(p.url)));
735
- wantedPages = Math.min(picks.length, samplePages);
765
+ wantedPages = Math.min(picks.length - notPages.size, samplePages);
736
766
  }
737
767
  const pages = [homePage, ...sampled];
738
768
 
739
769
  // llms.txt
740
770
  const llmsIsText = llmsRes.ok && !/<html/i.test(llmsRes.text.slice(0, 400));
741
- const llmsLinks = llmsIsText ? [...llmsRes.text.matchAll(/https?:\/\/[^\s)\]>]+/g)].map((m) => m[0]) : [];
771
+ const llmsLinks = llmsIsText ? linksInText(llmsRes.text) : [];
742
772
  const llmsForeign = llmsLinks.filter((l) => { try { return !new URL(l).host.endsWith(host.replace(/^www\./, '')); } catch { return false; } });
743
773
 
744
774
  // Area 1: access for AI crawlers (25)