@operstack/audit 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/collect.mjs +40 -15
- package/src/index.mjs +2 -0
- package/src/render.mjs +34 -23
- package/src/test-overall.mjs +81 -74
- package/src/verify-live.mjs +23 -0
- package/src/visibility.mjs +491 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
31
|
"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-rendered.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",
|
|
32
|
-
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
|
|
32
|
+
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
33
|
+
"verify:live": "node src/verify-live.mjs"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"seo",
|
package/src/collect.mjs
CHANGED
|
@@ -9,6 +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 } from './visibility.mjs';
|
|
12
13
|
|
|
13
14
|
/** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
|
|
14
15
|
* в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
|
|
@@ -468,11 +469,27 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
468
469
|
checks.push(jsBlindnessCheck(measured, lang));
|
|
469
470
|
}
|
|
470
471
|
|
|
471
|
-
// Балл считается ПОСЛЕ всех этапов, включая браузерный: раньше он снимался до него, и на прогоне
|
|
472
|
-
// с `rendered: true` сохранённое поле расходилось с тем, что печатает отчёт. Письмо говорило 61,
|
|
473
|
-
// PDF печатал 59. Одна функция, один набор проверок, один момент: вот этот.
|
|
474
472
|
const { scores, scoreBasis } = computeScores(checks);
|
|
475
|
-
|
|
473
|
+
|
|
474
|
+
/*
|
|
475
|
+
* Общий балл отчёта это тот же балл видимости, который считает бесплатная проверка на странице,
|
|
476
|
+
* и считает его тот же код с теми же параметрами.
|
|
477
|
+
*
|
|
478
|
+
* 14.09.2026 человек видел 46 на странице, 61 в письме и шесть областей из шестидесяти в PDF.
|
|
479
|
+
* Три числа про один сайт в одном дне. Пока их считали разные движки с разными рамками, сойтись
|
|
480
|
+
* они не могли, и единственный вывод, который делает читатель, это что цифры выдуманы.
|
|
481
|
+
*
|
|
482
|
+
* Параметры намеренно взяты из VISIBILITY_DEFAULTS, а не расширены под платный аудит: стоит дать
|
|
483
|
+
* тут больше страниц или времени, и число снова разойдётся со страницей. Глубина аудита живёт в
|
|
484
|
+
* его собственных шести областях и в списке задач, а не в этом числе.
|
|
485
|
+
*/
|
|
486
|
+
log('visibility score');
|
|
487
|
+
const visibility = await checkVisibility(startUrl, { ...VISIBILITY_DEFAULTS, lang });
|
|
488
|
+
const overall = visibility.ok
|
|
489
|
+
? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, source: 'visibility', ms: visibility.ms }
|
|
490
|
+
: { score: null, grade: 'not measured', areas: [], source: 'visibility', error: visibility.error };
|
|
491
|
+
// Шесть областей отчёта остаются, но это другое измерение, а не разбивка общего балла.
|
|
492
|
+
const reportScore = computeOverall(checks);
|
|
476
493
|
|
|
477
494
|
const localised = localiseChecks(checks, lang);
|
|
478
495
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
@@ -481,6 +498,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
481
498
|
meta: { site: home.final, host, reachable: homeAnswered, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
|
|
482
499
|
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' },
|
|
483
500
|
overall,
|
|
501
|
+
reportScore,
|
|
484
502
|
scores,
|
|
485
503
|
scoreBasis,
|
|
486
504
|
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}}'] },
|
|
@@ -613,17 +631,24 @@ export function verifyScores(audit) {
|
|
|
613
631
|
const { scores } = computeScores(audit.checks || []);
|
|
614
632
|
const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/10`);
|
|
615
633
|
const problems = [];
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
634
|
+
const show100 = (v) => (v === null || v === undefined ? 'not measured' : `${v}/100`);
|
|
635
|
+
|
|
636
|
+
/*
|
|
637
|
+
* Общий балл это балл видимости, и пересчитать его из проверок отчёта нельзя: его считает движок
|
|
638
|
+
* по своему обходу сайта. Зато можно проверить, что он сходится сам с собой: пять областей обязаны
|
|
639
|
+
* давать в сумме ровно заголовок. Ровно это и защищает от числа, взятого с потолка.
|
|
640
|
+
*/
|
|
641
|
+
if (audit.overall && audit.overall.score !== undefined && audit.overall.score !== null && Array.isArray(audit.overall.areas) && audit.overall.areas.length) {
|
|
642
|
+
const sum = audit.overall.areas.reduce((acc, x) => acc + (Number(x.score) || 0), 0);
|
|
643
|
+
if (sum !== audit.overall.score) problems.push(`overall: the report says ${show100(audit.overall.score)}, its own five areas add up to ${sum}`);
|
|
644
|
+
const max = audit.overall.areas.reduce((acc, x) => acc + (Number(x.max) || 0), 0);
|
|
645
|
+
if (max !== 100) problems.push(`overall: the five areas are out of ${max}, not 100`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Балл по проверкам самого отчёта это отдельное измерение, и он пересчитывается из checks.
|
|
649
|
+
if (audit.reportScore && audit.reportScore.score !== undefined) {
|
|
650
|
+
const expected = computeOverall(audit.checks || []);
|
|
651
|
+
if (audit.reportScore.score !== expected.score) problems.push(`reportScore: the report says ${show100(audit.reportScore.score)}, the checks give ${show100(expected.score)}`);
|
|
627
652
|
}
|
|
628
653
|
for (const [label, expected] of Object.entries(scores)) {
|
|
629
654
|
const stored = (audit.scores || {})[label];
|
package/src/index.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
2
|
+
// Единственная копия движка видимости. Сайты и актор зовут его отсюда, своих копий больше нет.
|
|
3
|
+
export { checkVisibility, normaliseInput, parseRobots, agentVerdict, MESSAGES as VISIBILITY_MESSAGES, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
2
4
|
export { render, checkNarrative } from './render.mjs';
|
|
3
5
|
export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
|
|
4
6
|
export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
|
package/src/render.mjs
CHANGED
|
@@ -23,15 +23,14 @@ const LABELS = {
|
|
|
23
23
|
reproducible: 'Every status in this report was read from the public site and can be reproduced from it as it stood on the report date.',
|
|
24
24
|
collectedBy: (tool, date) => `Public signals were collected by ${tool} on ${date}. Every status in this report can be reproduced from the site as it stood on that day.`,
|
|
25
25
|
secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
|
|
26
|
-
scorecardFoot: 'Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from section 04 onwards.',
|
|
26
|
+
scorecardFoot: 'These six scores are not a breakdown of the number above and will not add up to it: they count the checks in this report, which go wider than AI visibility. Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from section 04 onwards.',
|
|
27
27
|
notMeasured: 'not measured',
|
|
28
|
-
overallLabel: '
|
|
29
|
-
grades: {
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
: `Only ${o.weighed} of 100 points were measurable here, so the areas this audit could not read are left out of the total rather than scored zero.`),
|
|
28
|
+
overallLabel: 'AI visibility score',
|
|
29
|
+
grades: { A: 'Strong', B: 'Workable', C: 'Weak', D: 'Poor', E: 'Critical' },
|
|
30
|
+
// Заголовок это то же измерение, что на странице проверки. Подпись обязана это говорить,
|
|
31
|
+
// иначе читатель снова начнёт сводить его с шестью областями ниже.
|
|
32
|
+
overallNote: 'The same measurement the free check at oper-stack.com/ai-visibility gives for this site: the same code, the same settings. The five areas below add up to it exactly, so you can recompute it by hand.',
|
|
33
|
+
secondMeasure: 'A second, separate measurement: how this report scores its own checks',
|
|
35
34
|
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
36
35
|
secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
|
|
37
36
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
@@ -53,14 +52,12 @@ const LABELS = {
|
|
|
53
52
|
reproducible: 'Каждый статус в этом отчёте прочитан с публичного сайта, и его можно перепроверить по сайту на дату отчёта.',
|
|
54
53
|
collectedBy: (tool, date) => `Публичные сигналы собраны инструментом ${tool} ${date}. Любой статус в этом отчёте можно перепроверить по сайту в том виде, в каком он был в этот день.`,
|
|
55
54
|
secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
|
|
56
|
-
scorecardFoot: 'Каждая оценка считает проверки из этого же отчёта: пройденная проверка это балл, спорная половина балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по разделам начиная с четвёртого.',
|
|
55
|
+
scorecardFoot: 'Эти шесть оценок не разбивка балла выше и в сумме его не дают: они считают проверки самого отчёта, а он шире, чем видимость в ИИ. Каждая оценка считает проверки из этого же отчёта: пройденная проверка это балл, спорная половина балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по разделам начиная с четвёртого.',
|
|
57
56
|
notMeasured: 'не измерялось',
|
|
58
|
-
overallLabel: '
|
|
59
|
-
grades: {
|
|
60
|
-
overallNote:
|
|
61
|
-
|
|
62
|
-
? 'Все области на этом сайте удалось измерить.'
|
|
63
|
-
: `Измеримыми здесь оказались ${o.weighed} ${ruPoints(o.weighed)} из 100, поэтому области, которые аудит прочитать не смог, не попали в знаменатель, а не получили ноль.`),
|
|
57
|
+
overallLabel: 'Балл видимости в ИИ',
|
|
58
|
+
grades: { A: 'Сильно', B: 'Рабочее состояние', C: 'Слабо', D: 'Плохо', E: 'Критично' },
|
|
59
|
+
overallNote: 'То же измерение, что даёт бесплатная проверка на oper-stack.com/ai-visibility для этого сайта: тот же код, те же настройки. Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками.',
|
|
60
|
+
secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
|
|
64
61
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
65
62
|
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
66
63
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
@@ -126,6 +123,11 @@ function css() {
|
|
|
126
123
|
.page-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 3mm; margin-bottom: 6mm; border-bottom: 1px solid var(--rule); font-size: 8pt; color: var(--muted); }
|
|
127
124
|
.page-header strong { color: var(--accent); }
|
|
128
125
|
.page-footer { position: absolute; bottom: 9mm; left: var(--pad); right: var(--pad); display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: 1px solid var(--rule); padding-top: 2.5mm; }
|
|
126
|
+
table.areas { border-collapse: collapse; width: 100%; margin: -2mm 0 5mm; font-size: 8.5pt; }
|
|
127
|
+
table.areas th { background: #eef4f4; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .05em; font-size: 7.5pt; padding: 2mm 2.5mm; text-align: center; border: 1px solid var(--rule); }
|
|
128
|
+
table.areas td { text-align: center; padding: 2.5mm; border: 1px solid var(--rule); font-family: "Fraunces", Georgia, serif; font-size: 13pt; font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
129
|
+
table.areas td span { font-family: inherit; font-size: 9pt; color: var(--muted); font-weight: 400; }
|
|
130
|
+
h3.second-measure { font-size: 11pt; margin: 7mm 0 3mm; }
|
|
129
131
|
.overall { display: flex; align-items: center; gap: 6mm; border: 1px solid var(--rule); border-radius: 5px; padding: 5mm 6mm; background: #f4f8f8; margin: 5mm 0 4mm; }
|
|
130
132
|
.overall-num { font-family: "Fraunces", Georgia, serif; font-size: 44pt; font-weight: 600; line-height: 1; color: var(--accent); font-variant-numeric: tabular-nums; }
|
|
131
133
|
.overall-num span { font-size: 18pt; color: var(--muted); }
|
|
@@ -180,15 +182,24 @@ export function toHtml(audit, brand = null) {
|
|
|
180
182
|
const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
|
|
181
183
|
const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
|
|
182
184
|
const { scores: measured, scoreBasis } = computeScores(a.checks || []);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
/*
|
|
186
|
+
* Заголовок отчёта это тот же балл видимости, который человек увидел на странице бесплатной
|
|
187
|
+
* проверки, и он приходит готовым из `audit.overall`: пересчитывать его здесь нельзя, иначе
|
|
188
|
+
* снова получатся два числа про один сайт. Под ним его же пять областей, которые дают в сумме
|
|
189
|
+
* ровно заголовок, чтобы читатель мог сложить их и сойтись.
|
|
190
|
+
*
|
|
191
|
+
* Шесть областей отчёта печатаются ниже отдельным блоком и с другой подписью: это второе,
|
|
192
|
+
* независимое измерение по проверкам самого отчёта, а не разбивка заголовка. Сводить их с
|
|
193
|
+
* заголовком не нужно и не получится, и об этом сказано словами.
|
|
194
|
+
*/
|
|
195
|
+
const overall = a.overall && a.overall.score !== undefined ? a.overall : null;
|
|
196
|
+
const overallAreas = overall && Array.isArray(overall.areas) ? overall.areas : [];
|
|
197
|
+
const overallBlock = !overall || overall.score === null ? '' : `<div class="overall">
|
|
188
198
|
<div class="overall-num ${scoreClass(Math.round(overall.score / 10))}">${overall.score}<span>/100</span></div>
|
|
189
199
|
<div class="overall-side"><div class="overall-label">${esc(L.overallLabel)}</div>
|
|
190
|
-
<div class="overall-grade">${esc(L.grades[overall.grade] || overall.grade)}</div>
|
|
191
|
-
<div class="overall-note">${esc(L.overallNote
|
|
200
|
+
<div class="overall-grade">${esc((L.grades && L.grades[overall.grade]) || overall.grade)}</div>
|
|
201
|
+
<div class="overall-note">${esc(L.overallNote)}</div></div></div>`
|
|
202
|
+
+ (overallAreas.length ? `<table class="areas"><tr>${overallAreas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${overallAreas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
|
|
192
203
|
const scoreCards = Object.entries(measured).map(([label, n]) => {
|
|
193
204
|
const basis = scoreBasis[label] || {};
|
|
194
205
|
const shown = (L.areas && L.areas[label]) || label;
|
|
@@ -209,7 +220,7 @@ export function toHtml(audit, brand = null) {
|
|
|
209
220
|
<dl class="cover-meta"><div><dt>${L.auditSubject}</dt><dd>${t(a.client.name)}</dd></div><div><dt>${L.reportDate}</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>${L.auditType}</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>${L.preparedBy}</dt><dd>${esc(preparedBy)}</dd></div></dl></div>
|
|
210
221
|
${toolLine}</div></div>`);
|
|
211
222
|
n++;
|
|
212
|
-
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}<div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
|
|
223
|
+
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}<h3 class="second-measure">${L.secondMeasure}</h3><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
|
|
213
224
|
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.value}</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
214
225
|
const cardBody = (c) => [
|
|
215
226
|
c.text ? `<p>${t(c.text)}</p>` : '',
|
package/src/test-overall.mjs
CHANGED
|
@@ -1,110 +1,117 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** Один балл
|
|
2
|
+
/** Один балл на странице, в письме и в отчёте.
|
|
3
3
|
*
|
|
4
|
-
* 14.09.2026 человек
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 14.09.2026 человек увидел 46 на странице проверки, 61 в письме и шесть областей из шестидесяти
|
|
5
|
+
* в приложенном PDF. Три числа про один сайт за один день. Считали их разные движки с разными
|
|
6
|
+
* рамками, и единственный вывод, который делает читатель, это что цифры выдуманы.
|
|
7
|
+
*
|
|
8
|
+
* Теперь движок один и живёт в этом пакете, а параметры зафиксированы в VISIBILITY_DEFAULTS.
|
|
9
|
+
* Эти тесты держат три вещи: балл сходится сам с собой, сборщик берёт его у того же движка, и
|
|
10
|
+
* отчёт печатает именно его, на обоих языках. */
|
|
7
11
|
import { readFileSync } from 'node:fs';
|
|
8
12
|
import { createServer } from 'node:http';
|
|
9
13
|
import { collect, computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
14
|
+
import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
10
15
|
import { toHtml } from './render.mjs';
|
|
11
16
|
|
|
12
17
|
let bad = 0;
|
|
13
18
|
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
14
19
|
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
15
|
-
|
|
16
20
|
const c = (group, status, i = 0) => ({ id: `${group}-${status}-${i}`, group, status, label: group, value: 'x' });
|
|
17
21
|
|
|
18
|
-
//
|
|
19
|
-
is('веса дают
|
|
20
|
-
|
|
21
|
-
// Всё пройдено это сто, всё провалено это ноль.
|
|
22
|
+
// ---- балл по проверкам отчёта: отдельное измерение, оно обязано пересчитываться из checks
|
|
23
|
+
is('веса шести областей дают сто', SCORE_AREAS.reduce((s, a) => s + a.weight, 0), 100);
|
|
22
24
|
const every = (status) => SCORE_AREAS.flatMap((a) => a.groups.map((g) => c(g, status)));
|
|
23
|
-
is('все
|
|
24
|
-
is('все
|
|
25
|
-
is('
|
|
26
|
-
|
|
27
|
-
// Неизмеренная область выкидывается из знаменателя, а не получает ноль. Ноль за то, чего не
|
|
28
|
-
// мерили, это неверное измерение, ровно как «0 знаков» вместо «описания у страницы нет».
|
|
25
|
+
is('все пройдены: сто', computeOverall(every('ok')).score, 100);
|
|
26
|
+
is('все провалены: ноль', computeOverall(every('bad')).score, 0);
|
|
27
|
+
is('пустой набор не даёт нуля', computeOverall([]).score, null);
|
|
29
28
|
{
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
is('
|
|
33
|
-
is('в знаменатель попали только измеренные веса', o.weighed, 40);
|
|
34
|
-
ok('и отчёт об этом говорит', /40 of 100 points were measurable/.test(o.note));
|
|
35
|
-
ok('неизмеренные области в разбивку не попали', o.parts.length === 2);
|
|
29
|
+
const o = computeOverall([c('technical', 'ok'), c('geo', 'ok')]);
|
|
30
|
+
is('неизмеренное выпадает из знаменателя, а не становится нулём', o.score, 100);
|
|
31
|
+
is('в знаменателе только измеренные веса', o.weighed, 40);
|
|
36
32
|
}
|
|
37
33
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
is('и называется неизмеренным', o.grade, 'not measured');
|
|
43
|
-
}
|
|
34
|
+
// ---- параметры измерения зафиксированы: их расхождение и есть расхождение чисел
|
|
35
|
+
is('бюджет времени один на всех', VISIBILITY_DEFAULTS.budgetMs, 8500);
|
|
36
|
+
is('глубина выборки одна на всех', VISIBILITY_DEFAULTS.samplePages, 3);
|
|
37
|
+
ok('параметры нельзя поменять на ходу', Object.isFrozen(VISIBILITY_DEFAULTS));
|
|
44
38
|
|
|
45
|
-
//
|
|
39
|
+
// ---- балл видимости сходится сам с собой
|
|
46
40
|
{
|
|
47
|
-
const
|
|
48
|
-
is('
|
|
41
|
+
const audit = { checks: [], overall: { score: 61, areas: [{ score: 20, max: 25 }, { score: 10, max: 15 }, { score: 12, max: 20 }, { score: 14, max: 25 }, { score: 5, max: 15 }] } };
|
|
42
|
+
is('честный заголовок проверку проходит', verifyScores(audit), []);
|
|
43
|
+
ok('подделанный заголовок ловится', verifyScores({ ...audit, overall: { ...audit.overall, score: 70 } }).some((p) => p.startsWith('overall')));
|
|
44
|
+
ok('шкала не из ста ловится', verifyScores({ checks: [], overall: { score: 20, areas: [{ score: 20, max: 30 }] } }).some((p) => /out of 30/.test(p)));
|
|
49
45
|
}
|
|
50
|
-
|
|
51
|
-
// Балл в отчёте обязан сходиться с проверками этого же отчёта.
|
|
52
46
|
{
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
audit
|
|
56
|
-
|
|
57
|
-
ok('честный отчёт проходит проверку', !problems.some((p) => p.startsWith('overall')));
|
|
58
|
-
audit.overall = { ...o, score: (o.score + 7) % 101 };
|
|
59
|
-
ok('подделанный балл ловится', verifyScores(audit).some((p) => p.startsWith('overall')));
|
|
47
|
+
const checks = [c('technical', 'ok'), c('geo', 'bad')];
|
|
48
|
+
const audit = { checks, scores: computeScores(checks).scores, reportScore: computeOverall(checks) };
|
|
49
|
+
is('балл по проверкам отчёта сходится с checks', verifyScores(audit), []);
|
|
50
|
+
ok('подделанный балл по проверкам ловится', verifyScores({ ...audit, reportScore: { score: 99 } }).some((p) => p.startsWith('reportScore')));
|
|
60
51
|
}
|
|
61
52
|
|
|
62
|
-
|
|
53
|
+
/*
|
|
54
|
+
* Движок намеренно отказывается мерить приватные адреса: публичная проверка не должна ходить
|
|
55
|
+
* по чужой локальной сети. Значит локальным сайтом «одно число» не докажешь, и это правильно.
|
|
56
|
+
* Здесь проверяется, что в таком случае сборщик не выдумывает балл, а честно отдаёт «не измерено»,
|
|
57
|
+
* и что отчёт от этого не ломается. Совпадение чисел на живом сайте проверяется отдельно,
|
|
58
|
+
* командой `npm run verify:live`, потому что для этого нужна сеть.
|
|
59
|
+
*/
|
|
63
60
|
{
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
const server = createServer((req, res) => {
|
|
62
|
+
res.setHeader('content-type', 'text/html');
|
|
63
|
+
res.end('<!doctype html><html><head><title>Local</title></head><body><h1>Local</h1><p>Text.</p></body></html>');
|
|
64
|
+
});
|
|
65
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
66
|
+
const base = `http://127.0.0.1:${server.address().port}/`;
|
|
67
|
+
const direct = await checkVisibility(base, { ...VISIBILITY_DEFAULTS, lang: 'en' });
|
|
68
|
+
const audit = await collect(base, { pages: 2, log: () => {} });
|
|
69
|
+
server.close();
|
|
70
|
+
|
|
71
|
+
ok('приватный адрес движок мерить отказывается', !direct.ok);
|
|
72
|
+
is('и сборщик не выдумывает за него балл', audit.overall.score, null);
|
|
73
|
+
is('а называет это неизмеренным', audit.overall.grade, 'not measured');
|
|
74
|
+
is('и говорит, откуда балл должен был прийти', audit.overall.source, 'visibility');
|
|
75
|
+
ok('причина отказа сохранена, а не потеряна', typeof audit.overall.error === 'string' && audit.overall.error.length > 0);
|
|
76
|
+
ok('балл по проверкам отчёта при этом посчитан', typeof audit.reportScore.score === 'number');
|
|
77
|
+
is('и отчёт по-прежнему сходится сам с собой', verifyScores(audit), []);
|
|
78
|
+
|
|
79
|
+
for (const lang of ['en', 'ru']) {
|
|
67
80
|
audit.meta.lang = lang;
|
|
68
81
|
const html = toHtml(audit, null);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
ok(`${lang}:
|
|
72
|
-
ok(`${lang}: веса напечатаны, балл можно пересчитать руками`, note.test(html));
|
|
73
|
-
ok(`${lang}: в подписи нет чужого языка`, lang === 'en'
|
|
74
|
-
? !/[А-Яа-яЁё]/.test((html.match(/overall-note">([^<]*)</) || [])[1] || '')
|
|
75
|
-
: !/Weighted|measurable/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
82
|
+
// Искать надо напечатанный элемент, а не название стиля: `.overall-num` есть в CSS всегда.
|
|
83
|
+
ok(`${lang}: без балла заголовок не печатается вовсе`, !/<div class="overall-num/.test(html));
|
|
84
|
+
ok(`${lang}: шесть областей отчёта на месте`, /second-measure/.test(html));
|
|
76
85
|
}
|
|
77
|
-
// Русское склонение: 75 баллов, а не 75 балла.
|
|
78
|
-
audit.meta.lang = 'ru';
|
|
79
|
-
ok('русское склонение «баллов» верное', !/\d+ балла из 100/.test(toHtml(audit, null)));
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
//
|
|
88
|
+
// Тот же отчёт, но с настоящим баллом: печать заголовка и обе подписи.
|
|
83
89
|
{
|
|
84
90
|
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
audit.overall = { score: 61, grade: 'B', source: 'visibility', areas: [
|
|
92
|
+
{ id: 'access', label: 'Access for AI crawlers', score: 20, max: 25 },
|
|
93
|
+
{ id: 'index', label: 'Agent index', score: 10, max: 15 },
|
|
94
|
+
{ id: 'entity', label: 'Entity and structure', score: 12, max: 20 },
|
|
95
|
+
{ id: 'content', label: 'Answer-first content', score: 14, max: 25 },
|
|
96
|
+
{ id: 'trust', label: 'Freshness and sources', score: 5, max: 15 }] };
|
|
97
|
+
is('заголовок сходится со своими областями', verifyScores(audit).filter((p) => p.startsWith('overall')), []);
|
|
98
|
+
for (const [lang, grade] of [['en', /Strong|Workable|Weak|Poor|Critical/], ['ru', /Сильно|Рабочее состояние|Слабо|Плохо|Критично/]]) {
|
|
99
|
+
audit.meta.lang = lang;
|
|
100
|
+
const html = toHtml(audit, null);
|
|
101
|
+
is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/overall-num[^>]*>(\d+)<span>/) || [])[1]), 61);
|
|
102
|
+
ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
|
|
103
|
+
ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai-visibility/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
104
|
+
ok(`${lang}: пять областей напечатаны рядом с баллом`, /table class="areas"/.test(html));
|
|
105
|
+
const foot = (html.match(/scorecard-foot">([^<]*)</) || [])[1] || '';
|
|
106
|
+
ok(`${lang}: сказано, что шесть областей не складываются в заголовок`, lang === 'en' ? /not a breakdown/.test(foot) : /\u043d\u0435 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0430/.test(foot));
|
|
107
|
+
}
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// 14.09.2026 письмо сказало 61, а PDF напечатал 59: поле снималось до браузерного этапа, а он
|
|
93
|
-
// дописывает проверки. Отчёт считал заново и получал другое число. Тест поднимает маленький сайт
|
|
94
|
-
// и сверяет то, что collect положил в объект, с тем, что даёт функция по его же checks.
|
|
110
|
+
// ---- движок в пакете это та же программа, что стоит на сайтах
|
|
95
111
|
{
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
res.setHeader('content-type', 'text/html');
|
|
100
|
-
res.end('<!doctype html><html><head><title>A small test page for the audit collector</title><meta name="description" content="A page that exists only so the collector has something real to read during the test run of the suite."></head><body><h1>Test page</h1><p>Ten pounds a month, measured on our own invoices in 2026.</p><h2>One</h2><p>Text.</p><h2>Two</h2><p>Text.</p><h2>Three</h2><p>Text.</p><a href="mailto:a@b.c">write</a></body></html>');
|
|
101
|
-
});
|
|
102
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
103
|
-
const audit = await collect(`http://127.0.0.1:${server.address().port}/`, { pages: 2, log: () => {} });
|
|
104
|
-
server.close();
|
|
105
|
-
is('сохранённый общий балл равен пересчитанному по тем же проверкам', audit.overall.score, computeOverall(audit.checks).score);
|
|
106
|
-
is('сохранённые оценки областей тоже сходятся', audit.scores, computeScores(audit.checks).scores);
|
|
107
|
-
is('и собственная проверка отчёта молчит', verifyScores(audit).filter((p) => p.startsWith('overall')), []);
|
|
112
|
+
const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|
|
113
|
+
ok('движок несёт свои параметры', /VISIBILITY_DEFAULTS/.test(here));
|
|
114
|
+
ok('и не тянет за собой зависимостей', !/^import /m.test(here.replace(/VISIBILITY_DEFAULTS/g, '')));
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Живая сверка: балл движка и балл, который кладёт в отчёт сборщик, на одном и том же сайте.
|
|
3
|
+
* Нужна сеть, поэтому отдельно от npm test. Запуск: node src/verify-live.mjs <адрес> [ещё...] */
|
|
4
|
+
import { collect } from './collect.mjs';
|
|
5
|
+
import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
6
|
+
|
|
7
|
+
const sites = process.argv.slice(2);
|
|
8
|
+
if (!sites.length) { console.error('укажите хотя бы один адрес'); process.exit(2); }
|
|
9
|
+
let bad = 0;
|
|
10
|
+
for (const site of sites) {
|
|
11
|
+
for (const lang of ['en', 'ru']) {
|
|
12
|
+
const direct = await checkVisibility(site, { ...VISIBILITY_DEFAULTS, lang });
|
|
13
|
+
const audit = await collect(site, { pages: 5, lang, log: () => {} });
|
|
14
|
+
const a = direct.ok ? direct.score : null;
|
|
15
|
+
const b = audit.overall.score;
|
|
16
|
+
const same = a === b;
|
|
17
|
+
if (!same) bad++;
|
|
18
|
+
const sum = (audit.overall.areas || []).reduce((s, x) => s + x.score, 0);
|
|
19
|
+
console.log(`${same ? 'ok ' : 'FAIL'} ${site} [${lang}] движок ${a} | отчёт ${b} | сумма областей ${audit.overall.areas?.length ? sum : '-'} | ${audit.overall.grade}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (bad) { console.error(`\n${bad} расхождений`); process.exit(1); }
|
|
23
|
+
console.log('\nбалл совпадает везде');
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI visibility check: can ChatGPT, Perplexity, Copilot, Claude and Gemini read, understand
|
|
3
|
+
* and cite this site? Public signals only, one homepage fetch plus robots, llms.txt, the
|
|
4
|
+
* sitemap head and up to three sampled pages, all in parallel, under a hard time budget so it
|
|
5
|
+
* runs inside a serverless function. Plain ESM so the same module runs in Node for tests.
|
|
6
|
+
*
|
|
7
|
+
* The measurement is language-neutral; only the words a person reads come from the MESSAGES
|
|
8
|
+
* table below, picked by the `lang` option ('en' by default, 'ru' on oper-stack.ru). Finding ids
|
|
9
|
+
* and area ids never change with the language, so task lists and reports can key on them.
|
|
10
|
+
*
|
|
11
|
+
* This file is also vendored by the Apify actor (OperStack/apify-ai-visibility/src). The two
|
|
12
|
+
* copies must stay byte for byte identical, and `npm test` in the actor fails if they drift.
|
|
13
|
+
* That is why `samplePages` lives here although both sites leave it at three: a copy that has
|
|
14
|
+
* to differ is a copy nobody keeps in sync, and this one had drifted by 304 lines.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const UA = 'Mozilla/5.0 (compatible; OperStackVisibility/0.1; +https://oper-stack.com/ai-visibility/)';
|
|
18
|
+
const MAX_BODY = 600_000;
|
|
19
|
+
|
|
20
|
+
const AI_AGENTS = [
|
|
21
|
+
{ agent: 'oai-searchbot', label: 'ChatGPT search (OAI-SearchBot)', labelRu: 'Поиск ChatGPT (OAI-SearchBot)', kind: 'search' },
|
|
22
|
+
{ agent: 'chatgpt-user', label: 'ChatGPT browsing (ChatGPT-User)', labelRu: 'Просмотр страниц ChatGPT (ChatGPT-User)', kind: 'search' },
|
|
23
|
+
{ agent: 'gptbot', label: 'OpenAI training (GPTBot)', labelRu: 'Обучение OpenAI (GPTBot)', kind: 'train' },
|
|
24
|
+
{ agent: 'perplexitybot', label: 'Perplexity (PerplexityBot)', labelRu: 'Perplexity (PerplexityBot)', kind: 'search' },
|
|
25
|
+
{ agent: 'perplexity-user', label: 'Perplexity browsing (Perplexity-User)', labelRu: 'Просмотр страниц Perplexity (Perplexity-User)', kind: 'search' },
|
|
26
|
+
{ agent: 'claudebot', label: 'Claude (ClaudeBot)', labelRu: 'Claude (ClaudeBot)', kind: 'train' },
|
|
27
|
+
{ agent: 'claude-searchbot', label: 'Claude search (Claude-SearchBot)', labelRu: 'Поиск Claude (Claude-SearchBot)', kind: 'search' },
|
|
28
|
+
{ agent: 'claude-user', label: 'Claude browsing (Claude-User)', labelRu: 'Просмотр страниц Claude (Claude-User)', kind: 'search' },
|
|
29
|
+
{ agent: 'anthropic-ai', label: 'Anthropic (anthropic-ai)', labelRu: 'Anthropic (anthropic-ai)', kind: 'train' },
|
|
30
|
+
{ agent: 'google-extended', label: 'Gemini grounding (Google-Extended)', labelRu: 'Gemini (Google-Extended)', kind: 'train' },
|
|
31
|
+
{ agent: 'bingbot', label: 'Copilot and Bing (Bingbot)', labelRu: 'Copilot и Bing (Bingbot)', kind: 'search' },
|
|
32
|
+
{ agent: 'applebot-extended', label: 'Apple Intelligence (Applebot-Extended)', labelRu: 'Apple Intelligence (Applebot-Extended)', kind: 'train' },
|
|
33
|
+
{ agent: 'ccbot', label: 'Common Crawl (CCBot)', labelRu: 'Common Crawl (CCBot)', kind: 'train' },
|
|
34
|
+
{ agent: 'duckassistbot', label: 'DuckDuckGo AI (DuckAssistBot)', labelRu: 'DuckDuckGo AI (DuckAssistBot)', kind: 'search' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Russian plural form: pl(1, ...) is "страница", pl(2, ...) "страницы", pl(5, ...) "страниц".
|
|
39
|
+
* Two shapes are needed and they differ: a bare count takes the counted form, while "из N"
|
|
40
|
+
* always takes the genitive, which is plural for every N but one. Getting this wrong reads as
|
|
41
|
+
* broken Russian on the one screen we send catalogue traffic to.
|
|
42
|
+
*/
|
|
43
|
+
const pl = (n, one, few, many) => { const a = Math.abs(n) % 100; const b = a % 10; return a > 10 && a < 20 ? many : b > 1 && b < 5 ? few : b === 1 ? one : many; };
|
|
44
|
+
const ofPages = (n) => (n === 1 ? 'проверенной страницы' : 'проверенных страниц');
|
|
45
|
+
|
|
46
|
+
/** Exported so the offline test can assert the Russian forms without hitting the network. */
|
|
47
|
+
export const MESSAGES = {
|
|
48
|
+
en: {
|
|
49
|
+
badInput: 'Enter a public site address, for example example.com',
|
|
50
|
+
tooSlow: 'The site took too long to answer',
|
|
51
|
+
badAnswer: (status, url) => `The site answered ${status || 'nothing'} for ${url}`,
|
|
52
|
+
botWall: (status) => `The site refuses unknown clients (HTTP ${status}). AI fetchers that do not run a browser get the same answer, so nothing here can be read or quoted.`,
|
|
53
|
+
botChallenge: (status) => `The site sits behind a browser challenge (Cloudflare, HTTP ${status}). AI fetchers do not run one, so ChatGPT, Perplexity and the rest are turned away exactly as this check was, and nothing on the site can be quoted.`,
|
|
54
|
+
area: { access: 'Can AI crawlers read it', index: 'Is there a map for agents (llms.txt)', entity: 'Is the entity clear (schema)', content: 'Is there something to quote', trust: 'Can it be dated and trusted' },
|
|
55
|
+
robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
|
|
56
|
+
fetchersBlocked: (list) => `Blocked answer-engine fetchers: ${list}. These are the bots that cite pages live.`,
|
|
57
|
+
trainingBlocked: (list) => `Blocked training crawlers: ${list}. Models will not learn the brand from the site.`,
|
|
58
|
+
noaiMeta: 'A page carries a noai robots meta tag.',
|
|
59
|
+
allAllowed: (n, signal) => `All ${n} AI crawlers and fetchers are allowed${signal ? ` (Content-Signal: ${signal})` : ''}.`,
|
|
60
|
+
llmsMissing: 'No llms.txt. Answer engines get no map of what the site is and which pages matter.',
|
|
61
|
+
llmsNotText: '/llms.txt answers with an HTML page instead of a text index.',
|
|
62
|
+
llmsForeign: (hosts) => `llms.txt links mostly to other hosts (${hosts}). An AI system may misidentify the business.`,
|
|
63
|
+
llmsOk: (n) => `llms.txt present with ${n} link(s) to the site's own pages.`,
|
|
64
|
+
orgOk: 'Organization or business schema names the entity behind the site.',
|
|
65
|
+
orgMissing: 'No Organization or business schema: AI systems have no entity to attach the site to.',
|
|
66
|
+
faqOk: 'FAQPage schema found, the easiest format for an engine to quote.',
|
|
67
|
+
faqMissing: 'No FAQPage schema on the sampled pages.',
|
|
68
|
+
articleOk: 'Article schema on the sampled pages: an engine can name and date what it is quoting.',
|
|
69
|
+
pageTypeOk: (type) => `${type} schema on the sampled pages: an engine can name what it is quoting.`,
|
|
70
|
+
pageTypeMissing: 'The sampled pages carry no page-level type (Article, Service, Product, Course and the like), so an engine cannot say what kind of thing it is quoting.',
|
|
71
|
+
canonicalMissing: 'The homepage has no canonical tag.',
|
|
72
|
+
ogMissing: 'No Open Graph tags on the homepage: shared links and previews render without a title or image.',
|
|
73
|
+
thinPages: (thin, total) => `${thin} of ${total} sampled page(s) hold under 300 words. Engines rarely cite thin pages.`,
|
|
74
|
+
avgWords: (avg) => `Sampled pages carry ${avg} words on average.`,
|
|
75
|
+
answerFirstMissing: (n, total) => `${n} of ${total} sampled page(s) open with an answer-first paragraph (20 to 90 words with a figure right after the H1). That paragraph is what gets quoted.`,
|
|
76
|
+
answerFirstOk: 'Every sampled page opens with an answer-first paragraph carrying a figure.',
|
|
77
|
+
fewH2: (n) => `${n} sampled page(s) have fewer than three H2 sections.`,
|
|
78
|
+
noTables: 'No tables on the sampled pages. Tables are the second most quoted format after the first paragraph.',
|
|
79
|
+
homeOnly: 'Only the homepage could be read, so this area is measured on one page rather than several. A sitemap that answers would give a fuller picture.',
|
|
80
|
+
datesMissing: (n) => `${n} sampled page(s) expose no publication or modified date. Engines prefer sources they can date.`,
|
|
81
|
+
datesOk: 'Sampled pages expose publication dates.',
|
|
82
|
+
noFigures: 'The sampled pages state no figures, so there is nothing that needs a source named.',
|
|
83
|
+
sourcesMissing: (n, total) => `${n} of ${total} sampled page(s) state figures without naming where they came from ("according to", "data from").`,
|
|
84
|
+
sourcesOk: 'Every sampled page that states figures names where they came from.',
|
|
85
|
+
sitemapNoLastmod: 'The sitemap carries no lastmod dates.',
|
|
86
|
+
sitemapTimedOut: 'The sitemap did not answer in time, so it was not checked. The score does not count this either way.',
|
|
87
|
+
sitemapMissing: 'No XML sitemap found at the usual paths or in robots.txt.',
|
|
88
|
+
},
|
|
89
|
+
ru: {
|
|
90
|
+
badInput: 'Введите адрес публичного сайта, например example.ru',
|
|
91
|
+
tooSlow: 'Сайт слишком долго не отвечал',
|
|
92
|
+
badAnswer: (status, url) => `Сайт ответил ${status ? `кодом ${status}` : 'ничем'} на ${url}`,
|
|
93
|
+
botWall: (status) => `Сайт отказывает незнакомым клиентам (код ${status}). Роботы ИИ, которые не запускают браузер, получают тот же ответ, поэтому прочитать и процитировать здесь нечего.`,
|
|
94
|
+
botChallenge: (status) => `Сайт закрыт проверкой браузера (Cloudflare, код ${status}). Роботы ИИ её не проходят, поэтому ChatGPT, Perplexity и остальных разворачивают ровно так же, как развернули эту проверку, и процитировать с сайта нельзя ничего.`,
|
|
95
|
+
area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
|
|
96
|
+
robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
|
|
97
|
+
fetchersBlocked: (list) => `Закрыты поисковые роботы ответных систем: ${list}. Именно они достают страницу, чтобы процитировать её в ответе.`,
|
|
98
|
+
trainingBlocked: (list) => `Закрыты обучающие роботы: ${list}. Модели не узнают о бренде с сайта.`,
|
|
99
|
+
noaiMeta: 'На одной из страниц стоит мета-тег robots со значением noai.',
|
|
100
|
+
allAllowed: (n, signal) => `Все ${n} роботов ИИ допущены на сайт${signal ? ` (Content-Signal: ${signal})` : ''}.`,
|
|
101
|
+
llmsMissing: 'Нет llms.txt. Ответные системы не получают карты: что это за сайт и какие страницы главные.',
|
|
102
|
+
llmsNotText: '/llms.txt отдаёт HTML-страницу вместо текстового указателя.',
|
|
103
|
+
llmsForeign: (hosts) => `Ссылки в llms.txt ведут в основном на чужие домены (${hosts}). Система ИИ может перепутать, чей это бизнес.`,
|
|
104
|
+
llmsOk: (n) => `llms.txt есть, в нём ${n} ${pl(n, 'ссылка', 'ссылки', 'ссылок')} на страницы самого сайта.`,
|
|
105
|
+
orgOk: 'Разметка Organization или бизнеса называет, кто стоит за сайтом.',
|
|
106
|
+
orgMissing: 'Нет разметки Organization или бизнеса: системам ИИ не к кому привязать сайт.',
|
|
107
|
+
faqOk: 'Найдена разметка FAQPage, самый удобный для цитирования формат.',
|
|
108
|
+
faqMissing: 'На проверенных страницах нет разметки FAQPage.',
|
|
109
|
+
articleOk: 'На проверенных страницах есть разметка Article: система может назвать и датировать то, что цитирует.',
|
|
110
|
+
pageTypeOk: (type) => `На проверенных страницах есть разметка ${type}: система может назвать, что именно цитирует.`,
|
|
111
|
+
pageTypeMissing: 'На проверенных страницах нет типа страницы (Article, Service, Product, Course и подобных), поэтому система не может сказать, что за вещь она цитирует.',
|
|
112
|
+
canonicalMissing: 'На главной нет канонического адреса (тега canonical).',
|
|
113
|
+
ogMissing: 'На главной нет тегов Open Graph: ссылка в мессенджерах и соцсетях показывается без заголовка и картинки.',
|
|
114
|
+
thinPages: (thin, total) => (total === 1
|
|
115
|
+
? 'Единственная проверенная страница короче 300 слов. Короткие страницы цитируют редко.'
|
|
116
|
+
: `Из ${total} ${ofPages(total)} ${thin} короче 300 слов. Короткие страницы цитируют редко.`),
|
|
117
|
+
avgWords: (avg) => `На проверенных страницах в среднем ${avg} ${pl(avg, 'слово', 'слова', 'слов')}.`,
|
|
118
|
+
answerFirstMissing: (n, total) => (total === 1
|
|
119
|
+
? 'Единственная проверенная страница не начинается с абзаца-ответа (20-90 слов с цифрой сразу после H1). Именно этот абзац попадает в цитату.'
|
|
120
|
+
: `Из ${total} ${ofPages(total)} с абзаца-ответа (20-90 слов с цифрой сразу после H1) ${n === 0 ? 'не начинается ни одна' : n === 1 ? 'начинается одна' : `начинаются ${n}`}. Именно этот абзац попадает в цитату.`),
|
|
121
|
+
answerFirstOk: 'Каждая проверенная страница начинается с абзаца-ответа с цифрой.',
|
|
122
|
+
fewH2: (n, total) => (total === 1
|
|
123
|
+
? 'У единственной проверенной страницы меньше трёх подзаголовков H2.'
|
|
124
|
+
: `У ${n} из ${total} ${ofPages(total)} меньше трёх подзаголовков H2.`),
|
|
125
|
+
noTables: 'На проверенных страницах нет таблиц. Таблица второй по цитируемости формат после первого абзаца.',
|
|
126
|
+
homeOnly: 'Прочитать удалось только главную, поэтому эта область измерена по одной странице, а не по нескольким. Отвечающая карта сайта дала бы полную картину.',
|
|
127
|
+
datesMissing: (n, total) => (total === 1
|
|
128
|
+
? 'Единственная проверенная страница не показывает дату публикации или изменения. Системы предпочитают источники, которые можно датировать.'
|
|
129
|
+
: `${n} из ${total} ${ofPages(total)} не ${n === 1 ? 'показывает' : 'показывают'} дату публикации или изменения. Системы предпочитают источники, которые можно датировать.`),
|
|
130
|
+
datesOk: 'Проверенные страницы показывают даты публикации.',
|
|
131
|
+
noFigures: 'На проверенных страницах нет цифр, поэтому и источники называть нечему.',
|
|
132
|
+
sourcesMissing: (n, total) => (total === 1
|
|
133
|
+
? 'Единственная проверенная страница с цифрами не называет, откуда они («по данным», «источник»).'
|
|
134
|
+
: `${n} из ${total} ${ofPages(total)} ${n === 1 ? 'приводит' : 'приводят'} цифры, не называя, откуда они («по данным», «источник»).`),
|
|
135
|
+
sourcesOk: 'Каждая проверенная страница с цифрами называет их источник.',
|
|
136
|
+
sitemapNoLastmod: 'В карте сайта нет дат изменения (lastmod).',
|
|
137
|
+
sitemapTimedOut: 'Карта сайта не ответила вовремя, поэтому её не проверяли. На оценку это не влияет ни в какую сторону.',
|
|
138
|
+
sitemapMissing: 'XML-карта сайта не найдена ни по обычным адресам, ни в robots.txt.',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function isPrivateHost(host) {
|
|
143
|
+
const h = host.toLowerCase();
|
|
144
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true;
|
|
145
|
+
const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
146
|
+
if (m) {
|
|
147
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
148
|
+
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
149
|
+
}
|
|
150
|
+
return h.includes(':') || h.startsWith('[');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function normaliseInput(input) {
|
|
154
|
+
let s = String(input || '').trim();
|
|
155
|
+
if (!s) return null;
|
|
156
|
+
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
|
|
157
|
+
let u;
|
|
158
|
+
try { u = new URL(s); } catch { return null; }
|
|
159
|
+
if (!/^https?:$/.test(u.protocol) || !u.hostname.includes('.') || isPrivateHost(u.hostname)) return null;
|
|
160
|
+
u.hash = ''; u.search = '';
|
|
161
|
+
return u.href;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function get(url, { timeout = 6000, method = 'GET' } = {}) {
|
|
165
|
+
const c = new AbortController(); const t = setTimeout(() => c.abort(), timeout);
|
|
166
|
+
const started = Date.now();
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(url, { method, redirect: 'follow', signal: c.signal, headers: { 'user-agent': UA, accept: 'text/html,application/xml,text/plain,*/*' } });
|
|
169
|
+
let text = '';
|
|
170
|
+
if (method === 'GET') { const buf = await res.arrayBuffer(); text = new TextDecoder('utf-8', { fatal: false }).decode(buf.slice(0, MAX_BODY)); }
|
|
171
|
+
return { ok: res.ok, status: res.status, url: res.url, type: res.headers.get('content-type') || '', server: res.headers.get('server') || '', mitigated: res.headers.get('cf-mitigated') || '', text, ms: Date.now() - started };
|
|
172
|
+
} catch (e) { return { ok: false, status: 0, url, type: '', text: '', ms: Date.now() - started, error: e.name === 'AbortError' ? 'timeout' : 'unreachable' }; }
|
|
173
|
+
finally { clearTimeout(t); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const strip = (s) => s.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim();
|
|
177
|
+
const attr = (html, re) => { const m = html.match(re); return m ? m[1].trim() : ''; };
|
|
178
|
+
|
|
179
|
+
export function parseRobots(text) {
|
|
180
|
+
const blocks = []; let cur = null;
|
|
181
|
+
for (const raw of (text || '').split(/\r?\n/)) {
|
|
182
|
+
const line = raw.replace(/#.*$/, '').trim(); if (!line) continue;
|
|
183
|
+
const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i); if (!m) continue;
|
|
184
|
+
const key = m[1].toLowerCase(); const val = m[2].trim();
|
|
185
|
+
if (key === 'user-agent') { if (!cur || cur.rules.length) { cur = { agents: [], rules: [] }; blocks.push(cur); } cur.agents.push(val.toLowerCase()); }
|
|
186
|
+
else if (cur && (key === 'allow' || key === 'disallow')) cur.rules.push({ key, val });
|
|
187
|
+
}
|
|
188
|
+
const signal = (text.match(/^content-signal:\s*(.+)$/im) || [])[1] || '';
|
|
189
|
+
return { blocks, signal };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Effective verdict for one agent: the most specific block wins, then the star block, then allowed. */
|
|
193
|
+
export function agentVerdict(robots, agent) {
|
|
194
|
+
const own = robots.blocks.find((b) => b.agents.includes(agent));
|
|
195
|
+
const star = robots.blocks.find((b) => b.agents.includes('*'));
|
|
196
|
+
const block = own || star;
|
|
197
|
+
if (!block) return 'allowed';
|
|
198
|
+
const fullBlock = block.rules.some((r) => r.key === 'disallow' && r.val === '/') && !block.rules.some((r) => r.key === 'allow' && r.val === '/');
|
|
199
|
+
return fullBlock ? 'blocked' : 'allowed';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/*
|
|
203
|
+
* Что считается утверждением, требующим источника, и что считается названным источником.
|
|
204
|
+
*
|
|
205
|
+
* Здесь дважды ошибались, и оба раза в сторону несправедливого штрафа. Сначала регулярки знали
|
|
206
|
+
* только английский, и русская страница теряла пять баллов за то, что написана по-русски.
|
|
207
|
+
* Потом выяснилось хуже: собственная цена («от 800 ₽»), длительность своей же встречи
|
|
208
|
+
* («30 до 45 минут») и подписанный словом «иллюстративный» пример весов считались утверждениями
|
|
209
|
+
* о мире, а настоящая ссылка на исследование Harvard Business Review источником не считалась,
|
|
210
|
+
* потому что рядом не стояло оборота «по данным». Стоматолог, который пишет «приём 45 минут,
|
|
211
|
+
* от 3 000 ₽», получал ровно тот же штраф.
|
|
212
|
+
*
|
|
213
|
+
* Поэтому теперь: цена и длительность это факты о себе, они источника не требуют; источником
|
|
214
|
+
* считается не только оборот, но и внешняя ссылка рядом с цифрой; а число, честно подписанное
|
|
215
|
+
* как пример, утверждением не является. \b в JavaScript работает только по латинице, поэтому
|
|
216
|
+
* кириллица огорожена просмотрами вперёд и назад.
|
|
217
|
+
*/
|
|
218
|
+
const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank|по данным|источник:|источники:|согласно|по информации|по сведениям|росстат|центробанк|банк россии|росреестр|минфин|минэкономразвития)(?!\p{L})/giu;
|
|
219
|
+
/** Число, которое что-то утверждает о мире: доля, объём, расстояние, площадь. */
|
|
220
|
+
const CLAIM_FIGURE_RE = /\d[\d,.\s]*\s*(%|percent|процент\w*|km\b|км(?!\p{L})|m²|м²|sqm|кв\.?\s?м|тыс(?!\p{L})|млн(?!\p{L})|млрд(?!\p{L})|thousand|million|billion)/giu;
|
|
221
|
+
/*
|
|
222
|
+
* Число в первом абзаце. «Шестнадцать гейтов» это такая же цифра, как «16»: дом стиля многих
|
|
223
|
+
* сайтов (и нашего) требует писать числа словами, и проверка, которая видит только 16, штрафует
|
|
224
|
+
* за грамотность. 13 из 16 наших витрин продуктов попались ровно на этом.
|
|
225
|
+
* «Один» и «one» не в счёт: в обоих языках это слишком часто не число, а оборот речи.
|
|
226
|
+
*/
|
|
227
|
+
const SPELLED_NUMBER_RE = new RegExp(
|
|
228
|
+
'(?<![A-Za-z])(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand|million|billion)(?![A-Za-z])'
|
|
229
|
+
+ '|(?<!\\p{L})(два|две|двух|двум|двумя|три|трёх|трех|трём|трем|тремя|четыре|четырёх|четырех|пять|пяти|шесть|шести|семь|семи|восемь|восьми|девять|девяти|десять|десяти'
|
|
230
|
+
+ '|одиннадцат\\p{L}*|двенадцат\\p{L}*|тринадцат\\p{L}*|четырнадцат\\p{L}*|пятнадцат\\p{L}*|шестнадцат\\p{L}*|семнадцат\\p{L}*|восемнадцат\\p{L}*|девятнадцат\\p{L}*'
|
|
231
|
+
+ '|двадцат\\p{L}*|тридцат\\p{L}*|сорок|сорока|пятьдесят|пятидесяти|шестьдесят|шестидесяти|семьдесят|восемьдесят|девяносто|сто|ста|двест\\p{L}*|трист\\p{L}*|четырест\\p{L}*|пятьсот'
|
|
232
|
+
+ '|тысяч\\p{L}*|миллион\\p{L}*|миллиард\\p{L}*)(?!\\p{L})',
|
|
233
|
+
'iu',
|
|
234
|
+
);
|
|
235
|
+
/** Абзац-ответ несёт число: цифрой или словом. */
|
|
236
|
+
const hasFigure = (text) => /\d/.test(text) || SPELLED_NUMBER_RE.test(text);
|
|
237
|
+
|
|
238
|
+
/** Число, честно подписанное как пример, не является утверждением. */
|
|
239
|
+
const ILLUSTRATIVE_RE = /(иллюстратив\w*|для примера|условн\w+|примерн\w+|illustrative|for example|hypothetical)/giu;
|
|
240
|
+
/** Ссылка наружу рядом с цифрой это и есть названный источник. */
|
|
241
|
+
const OUTBOUND_LINK_RE = /<a[^>]+href=["']https?:\/\//i;
|
|
242
|
+
|
|
243
|
+
function analysePage(html, url) {
|
|
244
|
+
const head = html.slice(0, 200_000);
|
|
245
|
+
const title = strip(attr(head, /<title[^>]*>([\s\S]*?)<\/title>/i));
|
|
246
|
+
const description = attr(head, /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || attr(head, /<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
|
|
247
|
+
const robotsMeta = attr(head, /<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i).toLowerCase();
|
|
248
|
+
const canonical = attr(head, /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i) || attr(head, /<link[^>]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i);
|
|
249
|
+
const ogTitle = /property=["']og:title["']/i.test(head);
|
|
250
|
+
const ld = [...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
|
|
251
|
+
const types = new Set();
|
|
252
|
+
for (const block of ld) for (const m of block.matchAll(/"@type"\s*:\s*"([A-Za-z]+)"/g)) types.add(m[1]);
|
|
253
|
+
const datePublished = attr(head, /<meta[^>]+property=["']article:published_time["'][^>]+content=["']([^"']*)["']/i) || (ld.join('\n').match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '';
|
|
254
|
+
const dateModified = attr(head, /<meta[^>]+property=["']article:modified_time["'][^>]+content=["']([^"']*)["']/i) || (ld.join('\n').match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '';
|
|
255
|
+
// Site chrome is dropped so it cannot be counted as content, with one exception that matters:
|
|
256
|
+
// an article's title block is usually <header><h1>…</h1><p>the answer…</p></header>. Stripping
|
|
257
|
+
// every <header> deleted exactly the paragraph this check looks for, so a page that did open
|
|
258
|
+
// with an answer scored as if it had none. Keep any header that carries the H1.
|
|
259
|
+
const stripChrome = (h) => h
|
|
260
|
+
.replace(/<(nav|footer|aside)[\s\S]*?<\/\1>/gi, ' ')
|
|
261
|
+
.replace(/<header[\s\S]*?<\/header>/gi, (block) => (/<h1[\s>]/i.test(block) ? block : ' '));
|
|
262
|
+
const bodyHtml = stripChrome((html.match(/<body[\s\S]*<\/body>/i) || [html])[0]);
|
|
263
|
+
const text = strip(bodyHtml);
|
|
264
|
+
const words = (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
265
|
+
const h1 = strip((bodyHtml.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) || ['', ''])[1]);
|
|
266
|
+
const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
|
|
267
|
+
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
268
|
+
const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
|
|
269
|
+
const firstPara = strip((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
|
|
270
|
+
const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
271
|
+
const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && hasFigure(firstPara);
|
|
272
|
+
/*
|
|
273
|
+
* Цифру и её источник ищем в одном блоке, а не по всей странице: иначе одно «по данным» внизу
|
|
274
|
+
* прикрывает десять неподписанных чисел сверху. Таблица берётся целиком, потому что подпись
|
|
275
|
+
* («Иллюстративный вес») стоит в шапке, а число в ячейке.
|
|
276
|
+
*/
|
|
277
|
+
const tableBlocks = [...bodyHtml.matchAll(/<table[\s\S]*?<\/table>/gi)].map((m) => m[0]);
|
|
278
|
+
const outsideTables = bodyHtml.replace(/<table[\s\S]*?<\/table>/gi, ' ');
|
|
279
|
+
const textBlocks = [...outsideTables.matchAll(/<(p|li|h2|h3|figcaption|blockquote)[^>]*>([\s\S]*?)<\/\1>/gi)].map((m) => m[0]);
|
|
280
|
+
let claimFigures = 0;
|
|
281
|
+
let unsourcedFigures = 0;
|
|
282
|
+
for (const block of [...tableBlocks, ...textBlocks]) {
|
|
283
|
+
const blockText = strip(block);
|
|
284
|
+
const found = (blockText.match(CLAIM_FIGURE_RE) || []).length;
|
|
285
|
+
if (!found) continue;
|
|
286
|
+
claimFigures += found;
|
|
287
|
+
const named = Boolean(blockText.match(SOURCE_RE)) || Boolean(blockText.match(ILLUSTRATIVE_RE)) || OUTBOUND_LINK_RE.test(block);
|
|
288
|
+
if (!named) unsourcedFigures += found;
|
|
289
|
+
}
|
|
290
|
+
return { url, title, description, robotsMeta, canonical, ogTitle, schemaTypes: [...types], datePublished, dateModified, words, h1, h2Count, tables, firstPara: firstPara.slice(0, 220), answerFirst, claimFigures, unsourcedFigures, noai: /noai|noimageai/.test(robotsMeta) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
|
|
294
|
+
const fromRobots = [...(robotsText || '').matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]);
|
|
295
|
+
const candidates = fromRobots.length
|
|
296
|
+
? [...fromRobots.slice(0, 2), `${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`, `${origin}/sitemap.xml`]
|
|
297
|
+
: [`${origin}/sitemap_index.xml`, `${origin}/sitemap-index.xml`, `${origin}/sitemap.xml`];
|
|
298
|
+
/*
|
|
299
|
+
* "We ran out of time" and "there is no sitemap" are different answers and used to score the
|
|
300
|
+
* same. A slow response once cost a real site three points and made the same site score 78, 81
|
|
301
|
+
* and 84 on different days, which is the one thing a number we sell must never do.
|
|
302
|
+
*/
|
|
303
|
+
let timedOut = false;
|
|
304
|
+
for (const u of [...new Set(candidates)]) {
|
|
305
|
+
if (timeoutFn() < 500) { timedOut = true; break; }
|
|
306
|
+
const r = await get(u, { timeout: timeoutFn() });
|
|
307
|
+
if (r.error === 'timeout') { timedOut = true; continue; }
|
|
308
|
+
if (!r.ok || !/<(urlset|sitemapindex)/.test(r.text)) continue;
|
|
309
|
+
let locs = [...r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
|
|
310
|
+
let lastmod = /<lastmod>/.test(r.text);
|
|
311
|
+
if (/<sitemapindex/.test(r.text) && locs.length && timeoutFn() > 500) {
|
|
312
|
+
const child = await get(locs[0], { timeout: timeoutFn() });
|
|
313
|
+
if (child.ok) { locs = [...child.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]); lastmod = lastmod || /<lastmod>/.test(child.text); }
|
|
314
|
+
}
|
|
315
|
+
return { found: true, url: u, count: locs.length, lastmod, pages: locs.filter((l) => !/\.(xml|pdf|jpe?g|png)$/i.test(l)) };
|
|
316
|
+
}
|
|
317
|
+
return { found: false, url: '', count: 0, lastmod: false, pages: [], timedOut };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Единственные параметры, с которыми этот балл считается где бы то ни было: на странице проверки,
|
|
323
|
+
* в аудите и в акторе. Если где-то дать больше страниц или времени, число про один и тот же сайт
|
|
324
|
+
* разойдётся, и мы вернёмся к тому, ради чего всё это затевалось.
|
|
325
|
+
*/
|
|
326
|
+
export const VISIBILITY_DEFAULTS = Object.freeze({ budgetMs: 8500, samplePages: 3 });
|
|
327
|
+
|
|
328
|
+
export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', samplePages = 3 } = {}) {
|
|
329
|
+
const T = MESSAGES[lang] || MESSAGES.en;
|
|
330
|
+
const agentLabel = (a) => (lang === 'ru' ? a.labelRu : a.label);
|
|
331
|
+
const started = Date.now();
|
|
332
|
+
const deadline = started + budgetMs;
|
|
333
|
+
const left = () => deadline - Date.now();
|
|
334
|
+
const url = normaliseInput(input);
|
|
335
|
+
if (!url) return { ok: false, error: T.badInput };
|
|
336
|
+
const origin = new URL(url).origin; const host = new URL(url).host;
|
|
337
|
+
const [home, robotsRes, llmsRes] = await Promise.all([get(url, { timeout: Math.min(6000, left() - 300) }), get(`${origin}/robots.txt`, { timeout: Math.min(4000, left() - 300) }), get(`${origin}/llms.txt`, { timeout: Math.min(4000, left() - 300) })]);
|
|
338
|
+
if (!home.ok || !/html/i.test(home.type)) {
|
|
339
|
+
// Код 401/403/405/429 это бот-стена, а не сломанный сайт, и это находка сама по себе: та же
|
|
340
|
+
// стена, что развернула эту проверку, разворачивает и роботов ответных систем.
|
|
341
|
+
const blocked = [401, 403, 405, 429].includes(home.status);
|
|
342
|
+
// Проверка браузера и простой отказ лечатся по-разному, и первое дороже: робот ИИ никогда
|
|
343
|
+
// её не проходит, поэтому сайт за ней невидим для всех ассистентов, что бы ни было на
|
|
344
|
+
// страницах. Пришло из актора Apify, где это работало, а на сайтах человек видел «403».
|
|
345
|
+
const challenged = blocked && (/challenge|managed/i.test(home.mitigated) || (/cloudflare/i.test(home.server) && home.status === 403));
|
|
346
|
+
const error = home.error === 'timeout' ? T.tooSlow
|
|
347
|
+
: challenged ? T.botChallenge(home.status)
|
|
348
|
+
: blocked ? T.botWall(home.status)
|
|
349
|
+
: T.badAnswer(home.status, url);
|
|
350
|
+
return { ok: false, blocked, challenged, status: home.status, error };
|
|
351
|
+
}
|
|
352
|
+
const homePage = analysePage(home.text, home.url);
|
|
353
|
+
const robots = parseRobots(robotsRes.ok ? robotsRes.text : '');
|
|
354
|
+
const sitemap = left() > 1500 ? await readSitemap(origin, robotsRes.ok ? robotsRes.text : '', () => Math.min(3000, left() - 300)) : { found: false, url: '', count: 0, lastmod: false, pages: [], skipped: true };
|
|
355
|
+
let sampled = [];
|
|
356
|
+
if (left() > 2000 && sitemap.pages.length) {
|
|
357
|
+
const norm = (u) => u.replace(/\/$/, '').toLowerCase();
|
|
358
|
+
const picks = sitemap.pages.filter((p) => norm(p) !== norm(home.url) && p.startsWith(origin)).sort((a, b) => b.length - a.length).slice(0, Math.max(12, samplePages * 4)).filter((_, i) => i % 4 === 0).slice(0, samplePages);
|
|
359
|
+
const rs = await Promise.all(picks.map((p) => get(p, { timeout: Math.min(4000, left() - 300) })));
|
|
360
|
+
sampled = rs.filter((r) => r.ok && /html/i.test(r.type)).map((r) => analysePage(r.text, r.url));
|
|
361
|
+
}
|
|
362
|
+
const pages = [homePage, ...sampled];
|
|
363
|
+
|
|
364
|
+
// llms.txt
|
|
365
|
+
const llmsIsText = llmsRes.ok && !/<html/i.test(llmsRes.text.slice(0, 400));
|
|
366
|
+
const llmsLinks = llmsIsText ? [...llmsRes.text.matchAll(/https?:\/\/[^\s)\]>]+/g)].map((m) => m[0]) : [];
|
|
367
|
+
const llmsForeign = llmsLinks.filter((l) => { try { return !new URL(l).host.endsWith(host.replace(/^www\./, '')); } catch { return false; } });
|
|
368
|
+
|
|
369
|
+
// Area 1: access for AI crawlers (25)
|
|
370
|
+
const verdicts = AI_AGENTS.map((a) => ({ ...a, verdict: agentVerdict(robots, a.agent) }));
|
|
371
|
+
const blockedSearch = verdicts.filter((v) => v.kind === 'search' && v.verdict === 'blocked');
|
|
372
|
+
const blockedTrain = verdicts.filter((v) => v.kind === 'train' && v.verdict === 'blocked');
|
|
373
|
+
const starBlocked = agentVerdict(robots, '__none__') === 'blocked';
|
|
374
|
+
const noai = pages.some((p) => p.noai);
|
|
375
|
+
let access = 25;
|
|
376
|
+
if (starBlocked) access = 0; else { access -= Math.min(15, blockedSearch.length * 4); access -= Math.min(8, blockedTrain.length * 2); if (noai) access -= 5; }
|
|
377
|
+
access = Math.max(0, access);
|
|
378
|
+
const accessFindings = [];
|
|
379
|
+
if (starBlocked) accessFindings.push({ id: 'robots-all-blocked', level: 'fail', text: T.robotsAllBlocked });
|
|
380
|
+
if (blockedSearch.length) accessFindings.push({ id: 'robots-fetchers-blocked', level: 'fail', text: T.fetchersBlocked(blockedSearch.map(agentLabel).join(', ')) });
|
|
381
|
+
if (blockedTrain.length) accessFindings.push({ id: 'robots-training-blocked', level: 'warn', text: T.trainingBlocked(blockedTrain.map(agentLabel).join(', ')) });
|
|
382
|
+
if (noai) accessFindings.push({ id: 'noai-meta', level: 'warn', text: T.noaiMeta });
|
|
383
|
+
if (!accessFindings.length) accessFindings.push({ level: 'pass', text: T.allAllowed(AI_AGENTS.length, robots.signal) });
|
|
384
|
+
|
|
385
|
+
// Area 2: agent index (15)
|
|
386
|
+
let index = 0; const indexFindings = [];
|
|
387
|
+
if (!llmsRes.ok) indexFindings.push({ id: 'llms-missing', level: 'fail', text: T.llmsMissing });
|
|
388
|
+
else if (!llmsIsText) { index = 3; indexFindings.push({ id: 'llms-not-text', level: 'fail', text: T.llmsNotText }); }
|
|
389
|
+
else if (llmsLinks.length && llmsForeign.length > llmsLinks.length / 2) { index = 5; indexFindings.push({ id: 'llms-foreign', level: 'fail', text: T.llmsForeign([...new Set(llmsForeign.map((l) => new URL(l).host))].slice(0, 3).join(', ')) }); }
|
|
390
|
+
else { index = llmsLinks.length >= 5 ? 15 : 10; indexFindings.push({ level: 'pass', text: T.llmsOk(llmsLinks.length) }); }
|
|
391
|
+
|
|
392
|
+
// Area 3: entity and structure (20)
|
|
393
|
+
const allTypes = new Set(pages.flatMap((p) => p.schemaTypes));
|
|
394
|
+
const hasOrg = [...allTypes].some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|NewsMediaOrganization|Person|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|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency/.test(t));
|
|
395
|
+
const hasFaq = allTypes.has('FAQPage');
|
|
396
|
+
const hasArticle = [...allTypes].some((t) => /Article|BlogPosting|NewsArticle/.test(t));
|
|
397
|
+
/*
|
|
398
|
+
* A page does not have to be an article to be quotable. Asking every site for Article schema
|
|
399
|
+
* marks a correctly typed service, shop, course or job page as missing something, which is the
|
|
400
|
+
* same mistake the check once made by refusing "Dentist" as a business type. What an answer
|
|
401
|
+
* engine needs is a page-level type it can name, so any of these earns the same points.
|
|
402
|
+
*/
|
|
403
|
+
const PAGE_TYPES = /^(Service|Product|Course|Event|HowTo|Recipe|SoftwareApplication|WebApplication|MobileApplication|JobPosting|Book|Review|QAPage|MedicalWebPage|CollectionPage|ItemPage|AboutPage|ProfilePage)$/;
|
|
404
|
+
const pageType = [...allTypes].find((t) => PAGE_TYPES.test(t)) || null;
|
|
405
|
+
let entity = 0; const entityFindings = [];
|
|
406
|
+
if (hasOrg) { entity += 8; entityFindings.push({ level: 'pass', text: T.orgOk }); } else entityFindings.push({ id: 'schema-org-missing', level: 'fail', text: T.orgMissing });
|
|
407
|
+
if (hasFaq) { entity += 5; entityFindings.push({ level: 'pass', text: T.faqOk }); } else entityFindings.push({ id: 'schema-faq-missing', level: 'warn', text: T.faqMissing });
|
|
408
|
+
if (hasArticle) {
|
|
409
|
+
entity += 3;
|
|
410
|
+
entityFindings.push({ level: 'pass', text: T.articleOk });
|
|
411
|
+
} else if (pageType) {
|
|
412
|
+
entity += 3;
|
|
413
|
+
entityFindings.push({ level: 'pass', text: T.pageTypeOk(pageType) });
|
|
414
|
+
} else if (sampled.length) {
|
|
415
|
+
entityFindings.push({ id: 'schema-article-missing', level: 'warn', text: T.pageTypeMissing });
|
|
416
|
+
}
|
|
417
|
+
if (homePage.canonical) entity += 2; else entityFindings.push({ id: 'canonical-missing', level: 'warn', text: T.canonicalMissing });
|
|
418
|
+
if (homePage.ogTitle) entity += 2; else entityFindings.push({ id: 'og-missing', level: 'warn', text: T.ogMissing });
|
|
419
|
+
|
|
420
|
+
// Area 4: answer-first content (25)
|
|
421
|
+
const contentPages = sampled.length ? sampled : [homePage];
|
|
422
|
+
/* Say so. A score built from one page is not the same measurement as one built from four. */
|
|
423
|
+
const homeOnly = !sampled.length;
|
|
424
|
+
const thin = contentPages.filter((p) => p.words < 300).length;
|
|
425
|
+
const answerFirst = contentPages.filter((p) => p.answerFirst).length;
|
|
426
|
+
const structured = contentPages.filter((p) => p.h2Count >= 3).length;
|
|
427
|
+
const withTables = contentPages.filter((p) => p.tables > 0).length;
|
|
428
|
+
let content = 0; const contentFindings = [];
|
|
429
|
+
content += Math.round(10 * (1 - thin / contentPages.length));
|
|
430
|
+
if (thin) contentFindings.push({ id: 'thin-pages', level: thin === contentPages.length ? 'fail' : 'warn', text: T.thinPages(thin, contentPages.length) }); else contentFindings.push({ level: 'pass', text: T.avgWords(Math.round(contentPages.reduce((a, p) => a + p.words, 0) / contentPages.length)) });
|
|
431
|
+
content += Math.round(8 * (answerFirst / contentPages.length));
|
|
432
|
+
if (answerFirst < contentPages.length) contentFindings.push({ id: 'answer-first-missing', level: answerFirst ? 'warn' : 'fail', text: T.answerFirstMissing(answerFirst, contentPages.length) }); else contentFindings.push({ level: 'pass', text: T.answerFirstOk });
|
|
433
|
+
content += Math.round(4 * (structured / contentPages.length)) + Math.round(3 * (withTables / contentPages.length));
|
|
434
|
+
if (structured < contentPages.length) contentFindings.push({ id: 'few-h2', level: 'warn', text: T.fewH2(contentPages.length - structured, contentPages.length) });
|
|
435
|
+
if (!withTables) contentFindings.push({ id: 'no-tables', level: 'warn', text: T.noTables });
|
|
436
|
+
if (homeOnly) contentFindings.push({ level: 'warn', text: T.homeOnly });
|
|
437
|
+
|
|
438
|
+
// Area 5: freshness and sources (15)
|
|
439
|
+
const dated = contentPages.filter((p) => p.datePublished || p.dateModified).length;
|
|
440
|
+
/*
|
|
441
|
+
* Only a page that states figures can fail to attribute them. Docking a page for naming no
|
|
442
|
+
* source for numbers it does not contain is not a measurement, it is a complaint, and the
|
|
443
|
+
* finding said something untrue about the page. A price and the length of your own meeting
|
|
444
|
+
* are facts about you, not claims about the world, and they never counted here.
|
|
445
|
+
*/
|
|
446
|
+
const withFigures = contentPages.filter((p) => p.claimFigures > 0);
|
|
447
|
+
const sourced = withFigures.filter((p) => p.unsourcedFigures === 0).length;
|
|
448
|
+
let trust = 0; const trustFindings = [];
|
|
449
|
+
trust += Math.round(7 * (dated / contentPages.length));
|
|
450
|
+
if (dated < contentPages.length) trustFindings.push({ id: 'dates-missing', level: dated ? 'warn' : 'fail', text: T.datesMissing(contentPages.length - dated, contentPages.length) }); else trustFindings.push({ level: 'pass', text: T.datesOk });
|
|
451
|
+
if (!withFigures.length) {
|
|
452
|
+
trust += 5;
|
|
453
|
+
trustFindings.push({ level: 'pass', text: T.noFigures });
|
|
454
|
+
} else {
|
|
455
|
+
trust += Math.round(5 * (sourced / withFigures.length));
|
|
456
|
+
if (sourced < withFigures.length) {
|
|
457
|
+
trustFindings.push({ id: 'sources-missing', level: 'warn', text: T.sourcesMissing(withFigures.length - sourced, withFigures.length) });
|
|
458
|
+
} else {
|
|
459
|
+
trustFindings.push({ level: 'pass', text: T.sourcesOk });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (sitemap.found) {
|
|
463
|
+
trust += sitemap.lastmod ? 3 : 1;
|
|
464
|
+
if (!sitemap.lastmod) trustFindings.push({ id: 'sitemap-no-lastmod', level: 'warn', text: T.sitemapNoLastmod });
|
|
465
|
+
} else if (sitemap.timedOut || sitemap.skipped) {
|
|
466
|
+
// Not measured, so not scored down. Printing a low number for something we never read is the
|
|
467
|
+
// error this check exists to avoid in other people's tools.
|
|
468
|
+
trust += 3;
|
|
469
|
+
trustFindings.push({ level: 'warn', text: T.sitemapTimedOut });
|
|
470
|
+
} else {
|
|
471
|
+
trustFindings.push({ level: 'fail', text: T.sitemapMissing });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const areas = [
|
|
475
|
+
{ id: 'access', label: T.area.access, score: access, max: 25, findings: accessFindings },
|
|
476
|
+
{ id: 'index', label: T.area.index, score: index, max: 15, findings: indexFindings },
|
|
477
|
+
{ id: 'entity', label: T.area.entity, score: entity, max: 20, findings: entityFindings },
|
|
478
|
+
{ id: 'content', label: T.area.content, score: content, max: 25, findings: contentFindings },
|
|
479
|
+
{ id: 'trust', label: T.area.trust, score: trust, max: 15, findings: trustFindings },
|
|
480
|
+
];
|
|
481
|
+
const total = areas.reduce((a, x) => a + x.score, 0);
|
|
482
|
+
const grade = total >= 80 ? 'A' : total >= 65 ? 'B' : total >= 45 ? 'C' : total >= 25 ? 'D' : 'E';
|
|
483
|
+
const fixes = areas.flatMap((a) => a.findings.filter((f) => f.level === 'fail').map((f) => ({ id: f.id, area: a.label, text: f.text }))).concat(areas.flatMap((a) => a.findings.filter((f) => f.level === 'warn').map((f) => ({ id: f.id, area: a.label, text: f.text })))).slice(0, 3);
|
|
484
|
+
return {
|
|
485
|
+
ok: true, url: home.url, host, checkedAt: new Date().toISOString(), ms: Date.now() - started,
|
|
486
|
+
score: total, grade, areas, fixes,
|
|
487
|
+
sample: pages.map((p) => ({ url: p.url, title: p.title, words: p.words, answerFirst: p.answerFirst, dated: Boolean(p.datePublished || p.dateModified), schema: p.schemaTypes.slice(0, 4) })),
|
|
488
|
+
sitemap: { found: sitemap.found, count: sitemap.count, lastmod: sitemap.lastmod, unchecked: Boolean(!sitemap.found && (sitemap.timedOut || sitemap.skipped)) },
|
|
489
|
+
crawlers: verdicts.map((v) => ({ label: agentLabel(v), kind: v.kind, verdict: v.verdict })),
|
|
490
|
+
};
|
|
491
|
+
}
|