@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 +2 -2
- package/src/collect.mjs +156 -32
- package/src/i18n.mjs +1 -1
- package/src/narrative.mjs +15 -4
- package/src/prompts.mjs +11 -2
- package/src/render.mjs +22 -2
- package/src/rendered.mjs +8 -1
- package/src/test-narrative.mjs +9 -0
- package/src/test-rendered.mjs +10 -1
- package/src/test-report-wording.mjs +35 -0
- package/src/test-shopify-store.mjs +159 -0
- package/src/visibility.mjs +40 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.33.
|
|
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
|
-
|
|
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
|
|
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|&|&) 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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 =
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 амперсанд записан как &: Shopify так пишет адреса вложенных карт (?from=..&to=..).
|
|
389
|
+
const l = m[1].replace(/&/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
|
-
|
|
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
|
-
|
|
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(/&/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
|
-
|
|
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
|
-
|
|
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.
|
|
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?.
|
|
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.
|
|
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) =>
|
|
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) =>
|
|
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))
|
|
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
|
|
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 = [`### ${
|
|
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 =
|
|
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)}:
|
|
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
|
-
|
|
98
|
+
removeProfile(profile);
|
|
92
99
|
resolve(value);
|
|
93
100
|
};
|
|
94
101
|
const stopGroup = () => {
|
package/src/test-narrative.mjs
CHANGED
|
@@ -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 это тексты
|
package/src/test-rendered.mjs
CHANGED
|
@@ -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&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 отдаёт товары только с обоими параметрами: & в адресе значит пустую карту.
|
|
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. товары прочитаны по адресу с &, а не с &', 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всё сходится');
|
package/src/visibility.mjs
CHANGED
|
@@ -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
|
-
|
|
542
|
+
const locsOf = (text) => [...text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1].replace(/&/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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
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 ?
|
|
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)
|