@operstack/audit 0.31.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/audit.mjs +47 -5
- package/package.json +2 -2
- package/src/collect.mjs +101 -4
- package/src/handover.mjs +234 -0
- package/src/index.mjs +1 -0
- package/src/narrative.mjs +4 -3
- package/src/prospect.mjs +30 -6
- package/src/render.mjs +180 -5
- package/src/test-handover.mjs +148 -0
- package/src/test-prospect-count.mjs +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/oper-stack/brand/main/og-audit.png" alt="OperStack audit-generator" width="100%">
|
|
2
|
+
|
|
1
3
|
# @operstack/audit
|
|
2
4
|
|
|
3
5
|
The SEO, AEO and GEO audit generator behind the OperStack audit service. It collects the public signals of a site into one JSON file, leaves the narrative to an analyst or an agent, and renders a print-ready report.
|
|
@@ -90,10 +92,10 @@ Technically: `draft` fills the narrative fields (`summary`, `client.subject`, `o
|
|
|
90
92
|
**If you do not edit the site yourself.** Most people who read an audit now fix their site by telling an AI assistant what to do. This turns every finding into a task written for that assistant: copy it, paste it into Cursor or Claude Code, and it does the work. Each task says what the state is today, what to change, and how you will know it is finished, so the assistant cannot wander off and cannot quietly invent facts about your business.
|
|
91
93
|
|
|
92
94
|
```
|
|
93
|
-
npx @operstack/audit prompts audit.json --lang en
|
|
95
|
+
npx @operstack/audit prompts audit.json --lang en
|
|
94
96
|
```
|
|
95
97
|
|
|
96
|
-
Technically: every open check is rendered as a Markdown block with `Now` (the measured value), `Task` (the action and its boundaries) and `How to check` (the acceptance test), plus a standing rule against inventing business facts. Twenty checks carry text written for that specific finding; the rest fall back to the Fix action with a generic acceptance test. `--limit 3` is
|
|
98
|
+
Technically: every open check is rendered as a Markdown block with `Now` (the measured value), `Task` (the action and its boundaries) and `How to check` (the acceptance test), plus a standing rule against inventing business facts. Twenty checks carry text written for that specific finding; the rest fall back to the Fix action with a generic acceptance test. Every open finding becomes a task, and that is the free behaviour: no account, no key, no trimmed preview. `--limit 3` is there when you want only the three tasks that move the score most. The paid audit sells the reading and the work, not the list. `renderFixChecklist(plan, { withPrompts: true })` embeds the same tasks in the executor checklist.
|
|
97
99
|
|
|
98
100
|
## The Fix package: turning findings into agreed work
|
|
99
101
|
|
package/bin/audit.mjs
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* Scores are never typed by hand: the report always prints what the collected checks give,
|
|
25
25
|
* and an area with no checks is printed as "not measured" rather than scored low.
|
|
26
26
|
*/
|
|
27
|
-
import { collect, verifyScores } from '../src/collect.mjs';
|
|
27
|
+
import { collect, verifyScores, compareRivals } from '../src/collect.mjs';
|
|
28
|
+
import { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from '../src/handover.mjs';
|
|
28
29
|
import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
|
|
29
30
|
import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
|
|
30
31
|
import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
|
|
@@ -33,17 +34,26 @@ import { render, checkNarrative } from '../src/render.mjs';
|
|
|
33
34
|
import { buildDemandMap, renderDemandMarkdown, demandCsv } from '../src/demand.mjs';
|
|
34
35
|
import { resolveBranding } from '../src/agency.mjs';
|
|
35
36
|
import { rank, readList, runBatch, runProspect, split, summarise, toCsv, toMarkdown } from '../src/batch.mjs';
|
|
36
|
-
import { writeFileSync, readFileSync } from 'node:fs';
|
|
37
|
-
import { resolve } from 'node:path';
|
|
37
|
+
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
38
|
+
import { resolve, join } from 'node:path';
|
|
38
39
|
|
|
39
40
|
const [cmd, target, ...rest] = process.argv.slice(2);
|
|
41
|
+
/*
|
|
42
|
+
* Сколько страниц читает каждый платный тариф. Аудит за 149 обещает сто, и набирать это число
|
|
43
|
+
* руками каждый раз означает рано или поздно его забыть и отдать двадцать вместо ста.
|
|
44
|
+
* Автоматические ступени за 9 и 29 живут в ops-notify и сюда не приходят.
|
|
45
|
+
*/
|
|
46
|
+
const PAGES_BY_TIER = { audit: 100 };
|
|
47
|
+
|
|
40
48
|
const opt = (k, d) => { const i = rest.indexOf(k); return i === -1 ? d : rest[i + 1]; };
|
|
41
49
|
const has = (k) => rest.includes(k);
|
|
42
50
|
|
|
43
51
|
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
44
52
|
console.log([
|
|
45
53
|
'Usage:',
|
|
46
|
-
' operstack-audit collect <url> [--pages 20] [--out audit.json] [--lang ru] [--no-rendered]',
|
|
54
|
+
' operstack-audit collect <url> [--pages 20 | --tier audit] [--rivals "a.com,b.com"] [--out audit.json] [--lang ru] [--no-rendered]',
|
|
55
|
+
' --tier audit reads 100 pages: that is what the 149 audit promises, and typing it by hand is how it gets forgotten',
|
|
56
|
+
' --rivals reads up to five competitors with the same code and prints them beside you',
|
|
47
57
|
' collect the public signals of a site into audit.json; a real browser also measures how',
|
|
48
58
|
' much of the text is invisible without JavaScript (--no-rendered skips that)',
|
|
49
59
|
' operstack-audit render <audit.json> [--out report.html] [--pdf]',
|
|
@@ -57,6 +67,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
|
57
67
|
' autocomplete, volumes from Topvisor (TOPVISOR_USER_ID and TOPVISOR_KEY in env, --dry only prices it),',
|
|
58
68
|
' --attach writes the map into audit.json so render prints it as an appendix',
|
|
59
69
|
' operstack-audit check <audit.json>',
|
|
70
|
+
' operstack-audit handover <audit.json> [--out dir] [--lang ru|en]',
|
|
71
|
+
' the files the buyer pastes: llms.txt, the Organization markup and the title fixes',
|
|
60
72
|
' list narrative fields still holding placeholders and any score the checks do not support',
|
|
61
73
|
'',
|
|
62
74
|
' operstack-audit fix-plan <audit.json> [--platform files|cms] [--lang ru|en] [--price 249] [--out base]',
|
|
@@ -93,11 +105,16 @@ if (cmd === 'collect') {
|
|
|
93
105
|
// В платном аудите она нужна всегда: именно здесь мы можем соврать про чужой сайт. Выключается
|
|
94
106
|
// флагом --no-rendered, например когда браузера нет или прогон идёт по расписанию.
|
|
95
107
|
const audit = await collect(target, {
|
|
96
|
-
pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'),
|
|
108
|
+
pages: Number(opt('--pages', PAGES_BY_TIER[opt('--tier', '')] ?? 20)), tier: opt('--tier', ''), backlinks, lang: opt('--lang', 'en'),
|
|
97
109
|
rendered: !has('--no-rendered'), renderedPages: Number(opt('--rendered-pages', 3)),
|
|
98
110
|
preparedBy: opt('--by', ''),
|
|
99
111
|
log: (m) => console.error(` ${m}`),
|
|
100
112
|
});
|
|
113
|
+
/* Конкуренты читаются тем же кодом и кладутся в тот же файл: отчёту больше ничего не нужно. */
|
|
114
|
+
const rivalList = String(opt('--rivals', '')).split(',').map((x) => x.trim()).filter(Boolean);
|
|
115
|
+
if (rivalList.length) {
|
|
116
|
+
audit.rivals = await compareRivals(rivalList, { lang: opt('--lang', 'en'), log: (m) => console.error(` ${m}`) });
|
|
117
|
+
}
|
|
101
118
|
const out = resolve(opt('--out', 'audit.json'));
|
|
102
119
|
writeFileSync(out, JSON.stringify(audit, null, 2));
|
|
103
120
|
console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
|
|
@@ -141,6 +158,31 @@ if (cmd === 'collect') {
|
|
|
141
158
|
console.log(`wrote ${out} (${html.length} bytes)${pdf ? `\nwrote ${pdf}` : ''}`);
|
|
142
159
|
const missing = checkNarrative(audit);
|
|
143
160
|
if (missing.length) console.log(`note: ${missing.length} narrative field(s) still carry placeholders: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? ', ...' : ''}`);
|
|
161
|
+
} else if (cmd === 'handover') {
|
|
162
|
+
/*
|
|
163
|
+
* Файлы, которые покупатель кладёт к себе. Они же печатаются в отчёте, но в отчёте их надо
|
|
164
|
+
* перенабирать, а тут они лежат готовыми: llms.txt, разметка и правки заголовков.
|
|
165
|
+
*/
|
|
166
|
+
const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
|
|
167
|
+
const lang = opt('--lang', audit?.meta?.lang === 'ru' ? 'ru' : 'en');
|
|
168
|
+
const dir = resolve(opt('--out', 'handover'));
|
|
169
|
+
mkdirSync(dir, { recursive: true });
|
|
170
|
+
const org = buildOrganisationSchema(audit, { lang });
|
|
171
|
+
const fixes = fixTitlesAndDescriptions(audit, { lang, limit: 40 });
|
|
172
|
+
writeFileSync(join(dir, 'llms.txt'), buildLlmsTxt(audit, { lang }), 'utf8');
|
|
173
|
+
writeFileSync(join(dir, 'organization.jsonld'), `${org.text}\n`, 'utf8');
|
|
174
|
+
const head = lang === 'ru' ? ['# Заголовки и описания', '', 'Чинится длина и структура, а не смысл.', ''] : ['# Titles and descriptions', '', 'Length and structure only, not new wording.', ''];
|
|
175
|
+
const body = fixes.flatMap((f) => [
|
|
176
|
+
`## ${f.path}`, '',
|
|
177
|
+
...(f.title ? [`**title** — ${f.title.problem}`, '', `Сейчас: ${f.title.now || '—'}`.replace('Сейчас:', lang === 'ru' ? 'Сейчас:' : 'Now:'), '', `${lang === 'ru' ? 'Поставить' : 'Put'}: ${f.title.suggestion}`, ''] : []),
|
|
178
|
+
...(f.description ? [`**description** — ${f.description.problem}`, '', `${lang === 'ru' ? 'Сейчас' : 'Now'}: ${f.description.now || '—'}`, '', `${lang === 'ru' ? 'Поставить' : 'Put'}: ${f.description.suggestion}`, ''] : []),
|
|
179
|
+
]);
|
|
180
|
+
writeFileSync(join(dir, 'titles.md'), `${[...head, ...body].join('\n').trimEnd()}\n`, 'utf8');
|
|
181
|
+
console.log(`wrote ${dir}: llms.txt, organization.jsonld, titles.md (${fixes.length} page(s) to fix)`);
|
|
182
|
+
if (org.missing.length) {
|
|
183
|
+
console.log(lang === 'ru' ? 'в разметке осталось вписать руками:' : 'the markup still needs, by hand:');
|
|
184
|
+
for (const m of org.missing) console.log(` ${m.field}: ${m.what}`);
|
|
185
|
+
}
|
|
144
186
|
} else if (cmd === 'check') {
|
|
145
187
|
const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
|
|
146
188
|
const missing = checkNarrative(audit);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
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",
|
|
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",
|
|
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
|
@@ -38,6 +38,72 @@ async function get(url, { method = 'GET', timeout = REQUEST_TIMEOUT } = {}) {
|
|
|
38
38
|
finally { clearTimeout(t); }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Вы и ваши конкуренты: тот же замер по чужим сайтам, чтобы число стало относительным.
|
|
43
|
+
*
|
|
44
|
+
* Простым языком. «88 из 100» само по себе ничего не говорит: много это или мало, видно только
|
|
45
|
+
* рядом с теми, у кого покупатель выбирает. Поэтому конкуренты читаются ТЕМ ЖЕ кодом.
|
|
46
|
+
*
|
|
47
|
+
* Конкуренты читаются по десять страниц, а свой сайт по сто: доли в проверках считаются от
|
|
48
|
+
* разного, и об этом сказано в отчёте прямо. Балл видимости у всех читается по десяти страницам
|
|
49
|
+
* одинаково, вот его сравнивать можно, и он стоит в таблице первым.
|
|
50
|
+
*
|
|
51
|
+
* Пятеро это потолок: шестой столбец в лист A4 уже не помещается, а таблица, которая не
|
|
52
|
+
* помещается, это таблица, которую не читают.
|
|
53
|
+
*/
|
|
54
|
+
export const RIVALS_MAX = 5;
|
|
55
|
+
|
|
56
|
+
export async function compareRivals(rivals, { pages = 10, lang = 'en', pageDelayMs = 400, log = () => {} } = {}) {
|
|
57
|
+
const hosts = [...new Set((rivals || []).map((x) => String(x || '').trim()).filter(Boolean))].slice(0, RIVALS_MAX);
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const raw of hosts) {
|
|
60
|
+
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
61
|
+
log(`конкурент ${raw}`);
|
|
62
|
+
try {
|
|
63
|
+
const a = await collect(url, { pages, lang, rendered: false, pageDelayMs, log: () => {} });
|
|
64
|
+
out.push({
|
|
65
|
+
host: a.meta.host,
|
|
66
|
+
reachable: a.meta.reachable !== false,
|
|
67
|
+
score: a.overall?.score ?? null,
|
|
68
|
+
grade: a.overall?.grade ?? null,
|
|
69
|
+
areas: (a.overall?.areas || []).map((x) => ({ id: x.id, label: x.label, score: x.score, max: x.max })),
|
|
70
|
+
seo: a.overall?.seo?.score ?? null,
|
|
71
|
+
reportScores: a.scores || {},
|
|
72
|
+
pagesRead: a.reading?.read ?? null,
|
|
73
|
+
status: a.meta.homeStatus ?? null,
|
|
74
|
+
});
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// Сайт не ответил, значит в таблице стоит прочерк и причина, а не выдуманный ноль.
|
|
77
|
+
out.push({ host: raw, reachable: false, score: null, grade: null, areas: [], seo: null, reportScores: {}, pagesRead: 0, error: e.message });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Вежливое чтение чужого сайта.
|
|
85
|
+
*
|
|
86
|
+
* Страницы читаются по одной, но без паузы это примерно семь запросов в секунду, и защита сайта
|
|
87
|
+
* видит в этом не аудит, а обход. Мы на этом уже горели: собственный Vercel начинал отдавать 403
|
|
88
|
+
* на наши же прогоны. На двадцати страницах это сходило с рук, на ста не сойдёт.
|
|
89
|
+
*
|
|
90
|
+
* Поэтому три вещи. Пауза между страницами опускает темп до двух запросов в секунду. Crawl-delay
|
|
91
|
+
* из robots.txt слушается, если сайт его назвал: это его правило, и оно старше нашего удобства.
|
|
92
|
+
* И отказ не игнорируется: на 429 или 403 пауза удваивается, а после трёх отказов подряд чтение
|
|
93
|
+
* останавливается, и в отчёт идёт «прочитано столько из стольких», а не тишина.
|
|
94
|
+
*/
|
|
95
|
+
const sleep = (ms) => (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve());
|
|
96
|
+
|
|
97
|
+
/** Crawl-delay из robots.txt в миллисекундах, с потолком: 30 секунд на страницу это не аудит. */
|
|
98
|
+
export function crawlDelayFrom(robotsText, capMs = 3000) {
|
|
99
|
+
const found = [...String(robotsText || '').matchAll(/^crawl-delay:\s*([\d.]+)/gim)]
|
|
100
|
+
.map((m) => Number(m[1]))
|
|
101
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
102
|
+
if (!found.length) return { ms: 0, asked: 0, capped: false };
|
|
103
|
+
const asked = Math.max(...found) * 1000;
|
|
104
|
+
return { ms: Math.min(asked, capMs), asked, capped: asked > capMs };
|
|
105
|
+
}
|
|
106
|
+
|
|
41
107
|
async function follow(url, max = 5) {
|
|
42
108
|
const hops = [];
|
|
43
109
|
let cur = url;
|
|
@@ -141,6 +207,7 @@ function analysePage(url, html) {
|
|
|
141
207
|
telephone: /"telephone"\s*:/.test(ld) || telLinks > 0,
|
|
142
208
|
email: /"email"\s*:/.test(ld) || mailLinks > 0,
|
|
143
209
|
sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
|
|
210
|
+
sameAsUrls: [...new Set(sameAs)],
|
|
144
211
|
aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
|
|
145
212
|
policyPages: [...new Set(hrefsLower.filter((h) => /\/(privacy|privacy-policy|terms|terms-of-use|legal|cookie|cookies|disclaimer)/.test(h)).map((h) => (h.match(/\/(privacy|terms|legal|cookie|disclaimer)/) || [])[1]).filter(Boolean))],
|
|
146
213
|
author: /"author"\s*:/.test(ld),
|
|
@@ -220,7 +287,7 @@ async function readSitemap(startUrl, { maxFiles = 6, budgetMs = 45000, maxUrls =
|
|
|
220
287
|
|
|
221
288
|
export const __readSitemap = readSitemap;
|
|
222
289
|
|
|
223
|
-
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', visibility: givenVisibility = null, requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 6 } = {}) {
|
|
290
|
+
export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', visibility: givenVisibility = null, requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 6, tier = '' } = {}) {
|
|
224
291
|
REQUEST_TIMEOUT = requestTimeout;
|
|
225
292
|
const opts = { backlinks };
|
|
226
293
|
const origin = new URL(startUrl).origin;
|
|
@@ -411,13 +478,30 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
411
478
|
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;
|
|
412
479
|
const trustFirst = rest.filter((u) => { try { return TRUST_PAGE.test(new URL(u).pathname); } catch { return false; } });
|
|
413
480
|
const pool = [home.final, ...trustFirst, ...rest.filter((u) => !trustFirst.includes(u))];
|
|
481
|
+
const wanted = pool.slice(0, pages);
|
|
482
|
+
const politeness = crawlDelayFrom(robots.ok ? robots.text : '');
|
|
483
|
+
let gap = Math.max(pageDelayMs, politeness.ms);
|
|
414
484
|
const sample = [];
|
|
415
|
-
|
|
485
|
+
let refusedInARow = 0;
|
|
486
|
+
let stoppedAt = null;
|
|
487
|
+
for (let i = 0; i < wanted.length; i += 1) {
|
|
488
|
+
const u = wanted[i];
|
|
489
|
+
if (i > 0) await sleep(gap);
|
|
416
490
|
const r = await get(u);
|
|
491
|
+
// 429 и 403 это «притормози», а не «страницы нет». Удваиваем паузу и считаем отказы подряд:
|
|
492
|
+
// три подряд означают, что сайт нас закрыл, и дальше мы только злим его защиту.
|
|
493
|
+
if (r.status === 429 || r.status === 403) {
|
|
494
|
+
refusedInARow += 1;
|
|
495
|
+
gap = Math.min(gap * 2 || 1000, 8000);
|
|
496
|
+
if (refusedInARow >= 3) { stoppedAt = i; break; }
|
|
497
|
+
} else if (r.ok) {
|
|
498
|
+
refusedInARow = 0;
|
|
499
|
+
}
|
|
417
500
|
if (r.ok && /html/i.test(r.type)) sample.push(analysePage(u, r.text));
|
|
418
501
|
else if (r.status >= 300 && r.status < 400) sample.push({ url: u, redirect: r.location, status: r.status });
|
|
419
502
|
else sample.push({ url: u, status: r.status, error: r.error });
|
|
420
503
|
}
|
|
504
|
+
const reading = { wanted: wanted.length, read: sample.filter((x) => x.title !== undefined).length, stoppedAt, gapMs: gap, crawlDelay: politeness };
|
|
421
505
|
const good = sample.filter((p) => p.title !== undefined);
|
|
422
506
|
const hp = homePage || good[0];
|
|
423
507
|
|
|
@@ -561,6 +645,16 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
561
645
|
checks.push(row('trust-contact', 'offpage', 'Direct contact details', channels.length >= 2 ? 'ok' : channels.length ? 'warn' : 'bad', channels.length ? channels.join(' and ') : 'no phone, email or messenger is published'));
|
|
562
646
|
const ownHost = host.replace(/^www\./, '');
|
|
563
647
|
const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
|
|
648
|
+
/*
|
|
649
|
+
* Для готовой разметки нужен адрес профиля, а не домен: «https://x.com» это не страница компании,
|
|
650
|
+
* а сам сайт. И список сужается до площадок, где профиль вообще бывает: сайт нередко перечисляет
|
|
651
|
+
* в своём sameAs партнёров и застройщиков, а вставить их в свою разметку значит сказать
|
|
652
|
+
* поисковику, что это одна и та же организация. Поэтому берём только профили и только с путём.
|
|
653
|
+
*/
|
|
654
|
+
const PROFILE_HOST = /^(instagram\.com|facebook\.com|fb\.com|x\.com|twitter\.com|linkedin\.com|youtube\.com|threads\.net|tiktok\.com|t\.me|telegram\.me|vk\.com|ok\.ru|dzen\.ru|pinterest\.[a-z.]+|wikidata\.org|crunchbase\.com|yelp\.com|trustpilot\.com|github\.com|behance\.net|maps\.google\.[a-z.]+|g\.page)$/i;
|
|
655
|
+
const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
|
|
656
|
+
try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
|
|
657
|
+
});
|
|
564
658
|
checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'the cheapest off-site signal there is, and it is free'));
|
|
565
659
|
const about = anyTrust((p) => p.trust?.aboutPage);
|
|
566
660
|
checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'warn', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
|
|
@@ -656,7 +750,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
656
750
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
657
751
|
|
|
658
752
|
return {
|
|
659
|
-
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 || '') : '' },
|
|
753
|
+
meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
|
|
660
754
|
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' },
|
|
661
755
|
overall,
|
|
662
756
|
reportScore,
|
|
@@ -684,7 +778,10 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
684
778
|
closing: '{{Key message for the client in three sentences.}}',
|
|
685
779
|
// В выборке остаётся и то, из чего считается работа с текстами: без этих полей пакет
|
|
686
780
|
// Foundation пришлось бы оценивать на глаз, как раньше оценивался Fix.
|
|
687
|
-
|
|
781
|
+
reading,
|
|
782
|
+
/* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
|
|
783
|
+
entity: { profiles: profileUrls, hosts: profiles, telephone: Boolean(hp?.trust?.telephone), email: Boolean(hp?.trust?.email) },
|
|
784
|
+
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),
|
|
688
785
|
notes,
|
|
689
786
|
};
|
|
690
787
|
}
|
package/src/handover.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Готовые файлы, а не советы.
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Аудит находит, что нет карты для агентов, что разметка неполная, что заголовок
|
|
5
|
+
* обрезается в выдаче. Написать «сделайте llms.txt» легко, и это ровно то, за что покупатель
|
|
6
|
+
* платить не должен. Здесь из уже прочитанных данных собираются файлы, которые человек кладёт к
|
|
7
|
+
* себе: карта для агентов, разметка организации и починенные заголовки.
|
|
8
|
+
*
|
|
9
|
+
* Главное правило: **ничего не выдумывать про чужой бизнес**. Всё, что здесь появляется, взято
|
|
10
|
+
* с его же страниц. Чего в данных нет, то остаётся помеченным пропуском с подписью, что туда
|
|
11
|
+
* вписать, а не догадкой. Придуманный ИНН или придуманный год основания в разметке это ложь,
|
|
12
|
+
* которую потом прочитают и поисковик, и ИИ.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const trim = (s, n) => {
|
|
16
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
if (t.length <= n) return t;
|
|
18
|
+
const cut = t.slice(0, n);
|
|
19
|
+
const space = cut.lastIndexOf(' ');
|
|
20
|
+
return `${(space > n * 0.6 ? cut.slice(0, space) : cut).replace(/[\s.,;:·|–—-]+$/, '')}…`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Описание, которое обрывается на многоточии, выглядит как недоделка, а не как готовое поле.
|
|
25
|
+
* Поэтому режем по концу предложения, если оно есть во второй половине лимита, иначе по слову,
|
|
26
|
+
* и никакого хвоста с точками.
|
|
27
|
+
*/
|
|
28
|
+
const sentenceTrim = (s, n) => {
|
|
29
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
30
|
+
if (t.length <= n) return t;
|
|
31
|
+
// Набираем целые предложения, пока влезают. Так поле кончается точкой, а не на полуслове.
|
|
32
|
+
let out = '';
|
|
33
|
+
for (const part of t.split(/(?<=[.!?])\s+/)) {
|
|
34
|
+
const next = out ? `${out} ${part}` : part;
|
|
35
|
+
if (next.length > n) break;
|
|
36
|
+
out = next;
|
|
37
|
+
}
|
|
38
|
+
if (out) return out;
|
|
39
|
+
const cut = t.slice(0, n);
|
|
40
|
+
const space = cut.lastIndexOf(' ');
|
|
41
|
+
return `${(space > n * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:–—-]+$/, '')}…`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Служебные страницы заголовком клики не зарабатывают, и чинить их длину незачем. */
|
|
45
|
+
const SERVICE_PATH = /\/(privacy|privacy-policy|terms|terms-of-use|legal|cookie|cookies|disclaimer|sitemap|404|thank-you|thanks|spasibo|politika|oferta|soglashenie)/i;
|
|
46
|
+
|
|
47
|
+
const pathOf = (u) => { try { return new URL(u).pathname; } catch { return String(u || ''); } };
|
|
48
|
+
|
|
49
|
+
/** Первый сегмент адреса: по нему страницы группируются в разделы карты. */
|
|
50
|
+
const sectionOf = (u) => {
|
|
51
|
+
const p = pathOf(u).replace(/^\/+|\/+$/g, '');
|
|
52
|
+
if (!p) return '';
|
|
53
|
+
return p.split('/')[0];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Как называется компания.
|
|
58
|
+
*
|
|
59
|
+
* Заголовок главной почти всегда собран из двух частей, но бренд стоит то в конце («Станки · ШопРу»),
|
|
60
|
+
* то в начале («MORE Group: агентство на Пхукете»). Отличает их длина: название это одно-три слова,
|
|
61
|
+
* а не фраза. Поэтому берём хвост, если он короткий, иначе начало, если короткое оно. Если коротким
|
|
62
|
+
* не оказалось ничего, названия мы не знаем: ставим домен и просим проверить, а не выдаём фразу со
|
|
63
|
+
* страницы за имя организации.
|
|
64
|
+
*/
|
|
65
|
+
const WORDS_IN_NAME = 3;
|
|
66
|
+
function brandOf(audit) {
|
|
67
|
+
const host = audit?.meta?.host || '';
|
|
68
|
+
const pages = (audit?.sample || []).filter((p) => p.title !== undefined);
|
|
69
|
+
const raw = String(pages[0]?.title || '').replace(/\s+/g, ' ').trim();
|
|
70
|
+
const parts = raw.split(/\s*[·|—–:]\s*|\s+-\s+/).map((x) => x.trim()).filter(Boolean);
|
|
71
|
+
if (parts.length > 1) {
|
|
72
|
+
const words = (x) => x.split(/\s+/).length;
|
|
73
|
+
const last = parts[parts.length - 1];
|
|
74
|
+
const first = parts[0];
|
|
75
|
+
if (words(last) <= WORDS_IN_NAME) return { name: trim(last, 60), sure: true };
|
|
76
|
+
if (words(first) <= WORDS_IN_NAME) return { name: trim(first, 60), sure: true };
|
|
77
|
+
}
|
|
78
|
+
return { name: host, sure: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const WORDS = {
|
|
82
|
+
en: {
|
|
83
|
+
other: 'Other pages',
|
|
84
|
+
home: 'Start here',
|
|
85
|
+
placeholder: (what) => `TODO: ${what}`,
|
|
86
|
+
titlePattern: 'Assembled from the page H1 and the site name so it fits the limit.',
|
|
87
|
+
descPattern: 'Assembled from the first paragraph of the page so it fits the limit.',
|
|
88
|
+
noDesc: 'the page has no meta description at all',
|
|
89
|
+
tooLong: (n) => `${n} characters: cut in results`,
|
|
90
|
+
tooShort: (n) => `${n} characters: the words that earn the click are missing`,
|
|
91
|
+
},
|
|
92
|
+
ru: {
|
|
93
|
+
other: 'Другие страницы',
|
|
94
|
+
home: 'Начните отсюда',
|
|
95
|
+
placeholder: (what) => `ВПИСАТЬ: ${what}`,
|
|
96
|
+
titlePattern: 'Собран из H1 страницы и названия сайта так, чтобы уложиться в лимит.',
|
|
97
|
+
descPattern: 'Собрано из первого абзаца страницы так, чтобы уложиться в лимит.',
|
|
98
|
+
noDesc: 'описания у страницы нет вовсе',
|
|
99
|
+
tooLong: (n) => `${n} знаков: в выдаче обрежется`,
|
|
100
|
+
tooShort: (n) => `${n} знаков: не хватает слов, ради которых кликают`,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Карта для агентов. Формат llms.txt: заголовок с названием сайта, абзац о нём, затем разделы
|
|
106
|
+
* списками ссылок с однострочным пояснением у каждой.
|
|
107
|
+
*
|
|
108
|
+
* Пояснение берётся из описания страницы, а если его нет, из её первого абзаца. Выдумывать
|
|
109
|
+
* пояснение нечем и незачем: пустая строка честнее придуманной.
|
|
110
|
+
*/
|
|
111
|
+
export function buildLlmsTxt(audit, { lang = 'en', maxLinks = 60 } = {}) {
|
|
112
|
+
const w = WORDS[lang === 'ru' ? 'ru' : 'en'];
|
|
113
|
+
const host = audit?.meta?.host || '';
|
|
114
|
+
const pages = (audit?.sample || []).filter((p) => p.title !== undefined && p.url);
|
|
115
|
+
const home = pages[0] || {};
|
|
116
|
+
const name = brandOf(audit).name || host;
|
|
117
|
+
const about = sentenceTrim(home.description || home.firstPara || '', 200);
|
|
118
|
+
|
|
119
|
+
const bySection = new Map();
|
|
120
|
+
for (const p of pages.slice(0, maxLinks)) {
|
|
121
|
+
const key = sectionOf(p.url);
|
|
122
|
+
if (!bySection.has(key)) bySection.set(key, []);
|
|
123
|
+
bySection.get(key).push(p);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const lines = [`# ${name}`, ''];
|
|
127
|
+
lines.push(about ? `> ${about}` : `> ${w.placeholder(lang === 'ru' ? 'одно предложение о том, чем занимается сайт' : 'one sentence on what this site is')}`);
|
|
128
|
+
lines.push('');
|
|
129
|
+
|
|
130
|
+
const sections = [...bySection.entries()].sort((a, b) => (a[0] === '' ? -1 : b[0] === '' ? 1 : a[0].localeCompare(b[0])));
|
|
131
|
+
for (const [key, list] of sections) {
|
|
132
|
+
lines.push(`## ${key === '' ? w.home : key}`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
for (const p of list) {
|
|
135
|
+
const note = sentenceTrim(p.description || p.firstPara || '', 140);
|
|
136
|
+
lines.push(`- [${trim(p.title, 95)}](${p.url})${note ? `: ${note}` : ''}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
}
|
|
140
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Разметка организации из того, что сайт уже сам про себя говорит.
|
|
145
|
+
*
|
|
146
|
+
* Возвращает и сам блок, и список того, чего в данных не нашлось: телефон, почта, адрес. Эти поля
|
|
147
|
+
* в блок не попадают, а уезжают отдельным списком «впишите сами». Так покупатель видит, что
|
|
148
|
+
* пропуск это пропуск, а не наша забывчивость.
|
|
149
|
+
*/
|
|
150
|
+
export function buildOrganisationSchema(audit, { lang = 'en' } = {}) {
|
|
151
|
+
const host = audit?.meta?.host || '';
|
|
152
|
+
const site = audit?.meta?.site || (host ? `https://${host}/` : '');
|
|
153
|
+
const pages = (audit?.sample || []).filter((p) => p.title !== undefined);
|
|
154
|
+
const home = pages[0] || {};
|
|
155
|
+
const brand = brandOf(audit);
|
|
156
|
+
const name = brand.name || host;
|
|
157
|
+
const description = sentenceTrim(home.description || home.firstPara || '', 200);
|
|
158
|
+
const profiles = (audit?.entity?.profiles || []).map((h) => (/^https?:\/\//i.test(h) ? h : `https://${h}`));
|
|
159
|
+
|
|
160
|
+
const json = { '@context': 'https://schema.org', '@type': 'Organization', name, url: site };
|
|
161
|
+
if (description) json.description = description;
|
|
162
|
+
if (profiles.length) json.sameAs = profiles;
|
|
163
|
+
|
|
164
|
+
const missing = [];
|
|
165
|
+
const need = lang === 'ru'
|
|
166
|
+
? { telephone: 'телефон', email: 'почта', address: 'адрес', logo: 'адрес картинки с логотипом' }
|
|
167
|
+
: { telephone: 'a telephone', email: 'an email', address: 'a postal address', logo: 'the URL of your logo image' };
|
|
168
|
+
if (!profiles.length) missing.push({ field: 'sameAs', what: lang === 'ru' ? 'адреса ваших профилей: инстаграм, ютуб, линкедин, карточка на картах' : 'the URLs of your own profiles: Instagram, YouTube, LinkedIn, your maps listing' });
|
|
169
|
+
if (!audit?.entity?.telephone) missing.push({ field: 'telephone', what: need.telephone });
|
|
170
|
+
if (!audit?.entity?.email) missing.push({ field: 'email', what: need.email });
|
|
171
|
+
missing.push({ field: 'address', what: need.address });
|
|
172
|
+
missing.push({ field: 'logo', what: need.logo });
|
|
173
|
+
if (!description) missing.push({ field: 'description', what: lang === 'ru' ? 'одно предложение о компании' : 'one sentence about the company' });
|
|
174
|
+
// Названия в заголовке главной не было, поэтому в разметке стоит домен: это надо проверить глазами.
|
|
175
|
+
if (!brand.sure) missing.push({ field: 'name', what: lang === 'ru' ? `проверьте название: в разметке стоит «${name}», взято из домена` : `check the name: the markup says “${name}”, taken from the domain` });
|
|
176
|
+
|
|
177
|
+
return { json, missing, text: JSON.stringify(json, null, 2) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Починенные заголовки и описания.
|
|
182
|
+
*
|
|
183
|
+
* Это не копирайтинг, и называть его так нельзя. Настоящий заголовок требует знать, чем человек
|
|
184
|
+
* торгует и как это ищут, а мы этого не знаем. Что здесь делается: заголовок собирается из
|
|
185
|
+
* СОБСТВЕННОГО H1 страницы и названия сайта так, чтобы уложиться в лимит и не обрезаться в
|
|
186
|
+
* выдаче, описание из первого абзаца той же страницы. Рядом всегда написано, что там сейчас и
|
|
187
|
+
* почему это плохо, чтобы покупатель видел, что чинится длина и структура, а не смысл.
|
|
188
|
+
*/
|
|
189
|
+
export function fixTitlesAndDescriptions(audit, { lang = 'en', limit = 6 } = {}) {
|
|
190
|
+
const w = WORDS[lang === 'ru' ? 'ru' : 'en'];
|
|
191
|
+
const pages = (audit?.sample || []).filter((p) => p.title !== undefined && p.url);
|
|
192
|
+
const brand = trim(brandOf(audit).name, 24);
|
|
193
|
+
|
|
194
|
+
const broken = [];
|
|
195
|
+
for (const p of pages) {
|
|
196
|
+
if (SERVICE_PATH.test(pathOf(p.url))) continue;
|
|
197
|
+
const title = String(p.title || '');
|
|
198
|
+
const desc = String(p.description || '');
|
|
199
|
+
/*
|
|
200
|
+
* Пороги те же, что у проверок аудита: заголовок 40-60 знаков, описание 70-160. Свои числа
|
|
201
|
+
* здесь означали бы, что отчёт называет страницу нормальной, а список правок её же чинит.
|
|
202
|
+
*/
|
|
203
|
+
const titleBad = !title.length ? (lang === 'ru' ? 'заголовка нет' : 'no title')
|
|
204
|
+
: title.length > 60 ? w.tooLong(title.length)
|
|
205
|
+
: title.length < 40 ? w.tooShort(title.length) : '';
|
|
206
|
+
const descBad = !desc.length ? w.noDesc : desc.length > 160 ? w.tooLong(desc.length) : desc.length < 70 ? w.tooShort(desc.length) : '';
|
|
207
|
+
if (!titleBad && !descBad) continue;
|
|
208
|
+
|
|
209
|
+
const item = { url: p.url, path: pathOf(p.url) };
|
|
210
|
+
if (titleBad) {
|
|
211
|
+
/*
|
|
212
|
+
* Заголовок собирается только из того, что на странице уже есть, и проверяется тем же
|
|
213
|
+
* порогом, что и в самом аудите: 40-60 знаков. Три попытки по очереди. H1 плюс бренд.
|
|
214
|
+
* Нынешний заголовок без бренда, если он просто длинный. H1 сам по себе. Что не уложилось
|
|
215
|
+
* целиком, то не предлагается: заголовок с многоточием в выдаче это не починка.
|
|
216
|
+
*/
|
|
217
|
+
const h1 = String(p.h1 || '').replace(/\s+/g, ' ').trim();
|
|
218
|
+
const withoutBrand = brand ? title.split(/\s*[·|—–]\s*/).filter((x) => x.trim() && x.trim() !== brand).join(' · ').trim() : '';
|
|
219
|
+
const fits = (x) => x && !x.includes('\u2026') && x.length >= 40 && x.length <= 60;
|
|
220
|
+
const suggestion = [h1 && brand ? `${h1} · ${brand}` : '', withoutBrand, h1].find(fits) || '';
|
|
221
|
+
if (suggestion && suggestion !== title) item.title = { now: title, problem: titleBad, suggestion, how: w.titlePattern };
|
|
222
|
+
}
|
|
223
|
+
if (descBad) {
|
|
224
|
+
const source = p.firstPara || desc;
|
|
225
|
+
const suggestion = source ? sentenceTrim(source, 155) : '';
|
|
226
|
+
const better = suggestion.length >= 70 && suggestion.length <= 160 && suggestion !== desc;
|
|
227
|
+
if (better) item.description = { now: desc, problem: descBad, suggestion, how: w.descPattern };
|
|
228
|
+
}
|
|
229
|
+
if (!item.title && !item.description) continue;
|
|
230
|
+
broken.push(item);
|
|
231
|
+
if (broken.length >= limit) break;
|
|
232
|
+
}
|
|
233
|
+
return broken;
|
|
234
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -8,3 +8,4 @@ export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist
|
|
|
8
8
|
export { draftNarrative, stillEmpty } from './narrative.mjs';
|
|
9
9
|
export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
10
10
|
export { buildDemandMap, expandQueries, coverage, seedsFromPages, volumesViaTopvisor, topvisorClient, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, REGIONS as DEMAND_REGIONS, DEMAND_DEFAULTS } from './demand.mjs';
|
|
11
|
+
export { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
|
package/src/narrative.mjs
CHANGED
|
@@ -113,8 +113,9 @@ function plural(n, one, few, many) {
|
|
|
113
113
|
const d = n % 10; const dd = n % 100;
|
|
114
114
|
return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
|
|
115
115
|
}
|
|
116
|
-
const
|
|
117
|
-
const
|
|
116
|
+
const sep = (a) => (a.some((x) => String(x).includes(',')) ? '; ' : ', ');
|
|
117
|
+
const listRu = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} и ${a[a.length - 1]}`);
|
|
118
|
+
const listEn = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
|
|
118
119
|
|
|
119
120
|
/** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
|
|
120
121
|
const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-profiles', 'trust-about', 'trust-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
|
|
@@ -163,7 +164,7 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
163
164
|
const weakest = areaName(scores.slice().sort((x, y) => x[1] - y[1])[0]?.[0] || '');
|
|
164
165
|
|
|
165
166
|
// Кто клиент. Имя это адрес сайта, предмет прочитан с главной и помечен как прочитанный.
|
|
166
|
-
const desc = String(home.description || '').trim().slice(0, 180);
|
|
167
|
+
const desc = String(home.description || '').trim().slice(0, 180).replace(/[\s.]+$/, '');
|
|
167
168
|
a.client.name = a.client.name?.includes('{{') ? host : a.client.name;
|
|
168
169
|
if (String(a.client.subject).includes('{{')) a.client.subject = desc ? t.subjectFrom(desc) : t.subjectNone;
|
|
169
170
|
|
package/src/prospect.mjs
CHANGED
|
@@ -85,9 +85,24 @@ export const OWNER_LINES = {
|
|
|
85
85
|
en: 'On a phone your page cannot be pinched to zoom: the code forbids it.',
|
|
86
86
|
ru: 'С телефона страницу нельзя увеличить пальцами, масштабирование запрещено в коде.',
|
|
87
87
|
},
|
|
88
|
+
/*
|
|
89
|
+
* Фраза от числа (задание Максима 26.09.2026): «Not one page…» стояло и при «5 of 8», то есть
|
|
90
|
+
* письмо говорило неправду про сайт, где половина страниц в порядке. Число берётся из самой
|
|
91
|
+
* проверки («5 of 8 sampled page(s) open…»).
|
|
92
|
+
*/
|
|
88
93
|
'answer-first': {
|
|
89
|
-
en:
|
|
90
|
-
|
|
94
|
+
en: (c) => {
|
|
95
|
+
const [have, of] = countOf(c);
|
|
96
|
+
return have > 0 && of > have
|
|
97
|
+
? `Only ${have} of your ${of} checked pages open with a short direct answer, and that opening is exactly what gets quoted.`
|
|
98
|
+
: 'Not one page opens with a short direct answer, and that opening is exactly what gets quoted.';
|
|
99
|
+
},
|
|
100
|
+
ru: (c) => {
|
|
101
|
+
const [have, of] = countOf(c);
|
|
102
|
+
return have > 0 && of > have
|
|
103
|
+
? `Короткий прямой ответ в первом абзаце есть только на ${have} из ${of} проверенных страниц, а цитируют именно его.`
|
|
104
|
+
: 'Ни на одной странице нет короткого прямого ответа в первом абзаце, а цитируют именно его.';
|
|
105
|
+
},
|
|
91
106
|
},
|
|
92
107
|
'tables': {
|
|
93
108
|
en: 'Your pages have no tables, and a table is the easiest thing for an assistant to lift and quote.',
|
|
@@ -164,8 +179,17 @@ const LEAD_ORDER = [
|
|
|
164
179
|
'conv-form-fields', 'conv-contact-path', 'agent-markdown', 'h1', 'dates', 'utility',
|
|
165
180
|
];
|
|
166
181
|
|
|
182
|
+
/** «5 of 8 sampled page(s)…» в находке: сколько страниц в порядке и сколько проверено. */
|
|
183
|
+
function countOf(check) {
|
|
184
|
+
const m = String(check?.value || '').match(/(\d+)\s+(?:of|из)\s+(\d+)/);
|
|
185
|
+
return m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
/** Фраза владельцу на нужном языке. Нет перевода, значит нет фразы, и печатается сама находка. */
|
|
168
|
-
const line = (id, lang = 'en'
|
|
189
|
+
const line = (id, lang = 'en', check = null) => {
|
|
190
|
+
const say = (OWNER_LINES[id] || {})[lang === 'ru' ? 'ru' : 'en'];
|
|
191
|
+
return (typeof say === 'function' ? say(check) : say) || '';
|
|
192
|
+
};
|
|
169
193
|
|
|
170
194
|
/** Вес области при подсчёте одной общей оценки. Считаем только то, что правда измерено. */
|
|
171
195
|
const WEIGHT = {
|
|
@@ -207,15 +231,15 @@ export function worstFindings(audit, limit = 3, lang = 'en') {
|
|
|
207
231
|
const s = (SEVERITY[a.status] ?? 2) - (SEVERITY[b.status] ?? 2);
|
|
208
232
|
if (s) return s;
|
|
209
233
|
// При равной тяжести вперёд идёт то, для чего есть фраза владельцу: она и попадёт в письмо.
|
|
210
|
-
return (line(b.id, lang) ? 1 : 0) - (line(a.id, lang) ? 1 : 0);
|
|
234
|
+
return (line(b.id, lang, b) ? 1 : 0) - (line(a.id, lang, a) ? 1 : 0);
|
|
211
235
|
});
|
|
212
236
|
return open.slice(0, limit).map((c) => ({
|
|
213
237
|
id: c.id,
|
|
214
238
|
status: c.status,
|
|
215
239
|
label: c.label,
|
|
216
240
|
value: c.value,
|
|
217
|
-
say: line(c.id, lang) || `${c.label}: ${c.value}`,
|
|
218
|
-
translated: Boolean(line(c.id, lang)),
|
|
241
|
+
say: line(c.id, lang, c) || `${c.label}: ${c.value}`,
|
|
242
|
+
translated: Boolean(line(c.id, lang, c)),
|
|
219
243
|
}));
|
|
220
244
|
}
|
|
221
245
|
|
package/src/render.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
|
12
12
|
import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
|
|
13
13
|
import { softenHex } from './agency.mjs';
|
|
14
14
|
import { renderDemandHtml, demandSourceRow } from './demand.mjs';
|
|
15
|
+
import { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
|
|
15
16
|
|
|
16
17
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
17
18
|
const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
|
|
@@ -48,6 +49,12 @@ const LABELS = {
|
|
|
48
49
|
secondMeasure: 'A second, separate measurement: how this report scores its own checks',
|
|
49
50
|
ofHundred: 'of 100', ofHundredShort: 'OF 100', aiVisibility: 'AI visibility', seoBasics: 'SEO basics',
|
|
50
51
|
enginesHead: 'Readiness by engine', madeOf: 'What the score is made of', worth: 'worth', blockedIn: 'Blocked', colTables: 'Tables', colLinks: 'Links',
|
|
52
|
+
secHandover: 'Files to paste', handoverEyebrow: 'Ready to paste', handoverTitle: 'What to paste', handoverLead: 'Not advice: files. Everything below was assembled from your own pages, so nothing here was invented about your business.', llmsHead: 'llms.txt, the map for AI agents', llmsNote: 'Save this as llms.txt in the root of your site. An answer engine reads it instead of crawling every page.', andMoreInFile: '… the full file is attached to the email', orgHead: 'Organization markup', orgNote: 'Paste this into a script tag of type application/ld+json in the head of your homepage. Every value here was read from your site.', orgMissingHead: 'Fill in yourself', orgMissingNote: 'These fields are not on your site, so we did not invent them. Add them and the markup gets stronger.', metaHead: 'Titles and descriptions', metaMore: (n) => `${n} more page${n === 1 ? '' : 's'} need the same treatment; all of them are in the titles file attached to the email.`, metaNote: 'Length and structure only, not new wording: the suggestion is assembled from the page\u2019s own H1 and first paragraph so it fits the limit. Grey is what is there now, then why it is a problem, then what to put.',
|
|
53
|
+
secRivals: 'Rivals', rivalsEyebrow: 'Rivals', rivalsTitle: 'You and your rivals', pagesReadLabel: 'Pages it let us read', rivalsThin: 'An asterisk marks a score taken from fewer than three pages: it is what the site let us see, not everything it has.', rivalsWithheld: (list) => `${list.join(', ')} answered, but not on every page we asked for, so there is no score: a score that goes up because we read less is not one we will print.`, rivalsRefused: (list) => `Turned a robot away: ${list.join(', ')}. Bot protection answers the same way to an answer engine, so a site that refuses us is also a site an AI cannot read.`,
|
|
54
|
+
rivalsLead: 'The same code read your rivals, asking each for up to ten pages. The row under the score says how many pages each one actually handed over.',
|
|
55
|
+
rivalsFoot: 'A dash means the site did not answer at all, and that is a fact about the site, not a zero we invented.',
|
|
56
|
+
appendixEyebrow: 'Appendix', appendixPages: 'Every page we read', fullListInAppendix: 'The first pages are shown here; every page we read is listed in the appendix at the end.',
|
|
57
|
+
readNote: (read, wanted) => (read === wanted ? `All ${wanted} pages answered and were read.` : `${read} of ${wanted} pages answered and were read; the rest did not answer or the site stopped us.`),
|
|
51
58
|
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
52
59
|
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.',
|
|
53
60
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
@@ -90,6 +97,12 @@ const LABELS = {
|
|
|
90
97
|
secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
|
|
91
98
|
ofHundred: 'из 100', ofHundredShort: 'ИЗ 100', aiVisibility: 'ИИ-видимость', seoBasics: 'Основы SEO',
|
|
92
99
|
enginesHead: 'Готовность по движкам', madeOf: 'Из чего сложился балл', worth: 'вес', blockedIn: 'Закрыты', colTables: 'Таблиц', colLinks: 'Ссылок',
|
|
100
|
+
secHandover: 'Что вставить', handoverEyebrow: 'Готово к вставке', handoverTitle: 'Что вставить', handoverLead: 'Не советы, а файлы. Всё ниже собрано с ваших же страниц, поэтому ничего про ваш бизнес здесь не выдумано.', llmsHead: 'llms.txt, карта для агентов ИИ', llmsNote: 'Сохраните это файлом llms.txt в корне сайта. Ответная система читает его вместо того, чтобы обходить все страницы.', andMoreInFile: '… файл целиком приложен к письму', orgHead: 'Разметка организации', orgNote: 'Вставьте это в тег script с типом application/ld+json в head главной страницы. Каждое значение здесь прочитано с вашего сайта.', orgMissingHead: 'Впишите сами', orgMissingNote: 'Этих полей на сайте нет, и мы их не выдумывали. Впишите, и разметка станет сильнее.', metaHead: 'Заголовки и описания', metaMore: (n) => `Ещё ${n} странице${n === 1 ? '' : 'й'} нужна такая же правка, все они есть в файле с заголовками, приложенном к письму.`, metaNote: 'Чинится длина и структура, а не смысл: предложенный вариант собран из собственного H1 страницы и её первого абзаца так, чтобы уложиться в лимит. Серым то, что стоит сейчас, ниже чем это плохо, ниже что поставить.',
|
|
101
|
+
secRivals: 'Конкуренты', rivalsEyebrow: 'Конкуренты', rivalsTitle: 'Вы и ваши конкуренты', pagesReadLabel: 'Сколько страниц дал прочитать', rivalsThin: 'Звёздочкой помечен балл, посчитанный меньше чем по трём страницам: это то, что сайт дал посмотреть, а не всё, что у него есть.', rivalsWithheld: (list) => `${list.join(', ')} ответил, но не на все запрошенные страницы, поэтому балла нет: балл, который растёт оттого, что мы прочитали меньше, мы не печатаем.`, rivalsRefused: (list) => `Развернули робота: ${list.join(', ')}. Защита от ботов так же отвечает и ответной системе, поэтому сайт, который не пустил нас, не пустит и ИИ.`,
|
|
102
|
+
rivalsLead: 'Конкурентов читал тот же код и просил у каждого до десяти страниц. Строка под баллом говорит, сколько страниц каждый сайт отдал на самом деле.',
|
|
103
|
+
rivalsFoot: 'Прочерк значит, что сайт не ответил вовсе, и это факт про сайт, а не выдуманный нами ноль.',
|
|
104
|
+
appendixEyebrow: 'Приложение', appendixPages: 'Все прочитанные страницы', fullListInAppendix: 'Здесь первые строки, весь список прочитанных страниц лежит приложением в конце отчёта.',
|
|
105
|
+
readNote: (read, wanted) => (read === wanted ? `Ответили и прочитаны все ${wanted} страниц.` : `Из ${wanted} страниц ответили и прочитаны ${read}; остальные не ответили или сайт нас остановил.`),
|
|
93
106
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
94
107
|
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
95
108
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
@@ -195,6 +208,29 @@ function css() {
|
|
|
195
208
|
.mast-meta { display: grid; grid-template-columns: repeat(4, auto); gap: 0 9mm; justify-content: start; border-top: 1px solid var(--rule); padding-top: 2.5mm; }
|
|
196
209
|
.mast-meta dt { font-size: 6.8pt; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 0 0 .6mm; }
|
|
197
210
|
.mast-meta dd { font-size: 8.5pt; font-weight: 600; margin: 0; }
|
|
211
|
+
|
|
212
|
+
/* Таблица конкурентов: свой сайт выделен, лучшее в строке подсвечено. */
|
|
213
|
+
table.rivals-table { table-layout: fixed; font-size: 8.8pt; }
|
|
214
|
+
table.rivals-table th, table.rivals-table td { padding-left: 1.4mm; padding-right: 1.4mm; }
|
|
215
|
+
table.rivals-table th:first-child, table.rivals-table td:first-child { width: 23%; }
|
|
216
|
+
table.rivals-table th.n, table.rivals-table td.n { text-align: center; font-variant-numeric: tabular-nums; }
|
|
217
|
+
table.rivals-table th.n { font-size: 6.8pt; letter-spacing: 0; text-transform: none; overflow-wrap: normal; word-break: normal; }
|
|
218
|
+
table.rivals-table th.mine { color: var(--accent); }
|
|
219
|
+
table.rivals-table td.beat { color: var(--ok); font-weight: 600; }
|
|
220
|
+
table.rivals-table td .na { color: var(--muted); }
|
|
221
|
+
table.rivals-table tr.lead-row td { font-family: "Fraunces", Georgia, serif; font-size: 13pt; }
|
|
222
|
+
table.rivals-table tr.foot-row td { color: var(--muted); font-size: 8pt; }
|
|
223
|
+
table.rivals-table td span { font-size: 8pt; color: var(--muted); }
|
|
224
|
+
|
|
225
|
+
/* Раздел «что вставить»: код мелко и моноширинно, чтобы копировался без потерь. */
|
|
226
|
+
.handover-page .hand-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; margin: 0 0 3mm; }
|
|
227
|
+
pre.code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 7.5pt; line-height: 1.5; background: #fafbfb; border: 1px solid var(--rule); border-radius: 4px; padding: 3mm 4mm; white-space: pre-wrap; word-break: break-word; margin: 0 0 4mm; }
|
|
228
|
+
ul.hand-missing { margin: 0; padding-left: 5mm; font-size: 9pt; }
|
|
229
|
+
ul.hand-missing code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 8pt; color: var(--accent); }
|
|
230
|
+
table.meta-fixes td { vertical-align: top; font-size: 8.5pt; }
|
|
231
|
+
table.meta-fixes .was { color: var(--muted); }
|
|
232
|
+
table.meta-fixes .why { color: var(--warn); font-size: 7.5pt; }
|
|
233
|
+
table.meta-fixes .now { color: var(--ink); font-weight: 600; }
|
|
198
234
|
.tool-line { font-size: 7.5pt; color: var(--muted); line-height: 1.4; margin: 4mm 0 0; }
|
|
199
235
|
.cover-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; margin-top: 14mm; padding-top: 6mm; border-top: 1px solid var(--rule); }
|
|
200
236
|
.cover-meta dt { font-size: 7.5pt; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 1mm; }
|
|
@@ -227,7 +263,7 @@ function css() {
|
|
|
227
263
|
.overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 14pt; font-weight: 600; margin: 0 0 1.5mm; }
|
|
228
264
|
.overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; margin: 0; }
|
|
229
265
|
|
|
230
|
-
h3.engines-head, h3.areas-head { font-size: 11pt; margin:
|
|
266
|
+
h3.engines-head, h3.areas-head { font-size: 11pt; margin: 4mm 0 2mm; }
|
|
231
267
|
.engines { display: grid; grid-template-columns: repeat(5, 1fr); gap: 2.2mm; }
|
|
232
268
|
.eng { border: 1px solid var(--rule); border-radius: 4px; padding: 2.4mm 2.6mm; background: #fafbfb; }
|
|
233
269
|
.eng p { margin: 0; }
|
|
@@ -244,7 +280,7 @@ function css() {
|
|
|
244
280
|
.eng-na .pbar i, .ar-na .pbar i { background: var(--muted); }
|
|
245
281
|
|
|
246
282
|
.areas-bars { margin-bottom: 2mm; }
|
|
247
|
-
.ar { padding:
|
|
283
|
+
.ar { padding: 1.6mm 0; border-top: 1px solid var(--rule); }
|
|
248
284
|
.ar:first-child { border-top: 0; padding-top: 0.5mm; }
|
|
249
285
|
.ar-h { display: grid; grid-template-columns: 1fr auto auto; gap: 3mm; align-items: baseline; margin-bottom: 1.4mm; }
|
|
250
286
|
.ar-n { font-size: 9pt; font-weight: 600; }
|
|
@@ -273,6 +309,11 @@ function css() {
|
|
|
273
309
|
.kpi { flex: 1; background: var(--panel); border-radius: 4px; padding: 3mm 4mm; }
|
|
274
310
|
.kpi-value { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-size: 17pt; font-weight: 600; color: var(--ink); line-height: 1.1; }
|
|
275
311
|
.kpi-label { font-size: 8.5pt; color: var(--muted); margin-top: 1mm; }
|
|
312
|
+
table.pages.appendix { table-layout: fixed; }
|
|
313
|
+
table.pages.appendix td, table.pages.appendix th { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
314
|
+
table.pages.appendix td:nth-child(1), table.pages.appendix th:nth-child(1) { width: 33%; }
|
|
315
|
+
table.pages.appendix td:nth-child(2), table.pages.appendix th:nth-child(2) { width: 35%; }
|
|
316
|
+
table.pages.appendix td:nth-child(n+3), table.pages.appendix th:nth-child(n+3) { width: 8%; text-align: right; font-variant-numeric: tabular-nums; }
|
|
276
317
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); }
|
|
277
318
|
.cards { display: grid; gap: 3mm; margin: 4mm 0; }
|
|
278
319
|
.card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
|
|
@@ -377,7 +418,13 @@ export function toHtml(audit, brand = null) {
|
|
|
377
418
|
* следующую страницу, и мы вернёмся к тому, от чего уходили.
|
|
378
419
|
*/
|
|
379
420
|
const subjectText = t(a.client.subject);
|
|
380
|
-
|
|
421
|
+
/* Обрыв на полуслове в первой строке отчёта за 149 выглядит как недоделка, поэтому режем по точке. */
|
|
422
|
+
const subjectShort = (() => {
|
|
423
|
+
if (subjectText.length <= 190) return subjectText;
|
|
424
|
+
const cut = subjectText.slice(0, 190);
|
|
425
|
+
const stop = cut.lastIndexOf('. ');
|
|
426
|
+
return stop > 60 ? cut.slice(0, stop + 1) : `${cut.slice(0, 187).trimEnd()}\u2026`;
|
|
427
|
+
})();
|
|
381
428
|
const masthead = `<div class="masthead">${logo}<p class="mast-eyebrow">${L.eyebrowAudit}</p><h1 class="mast-title">${L.coverTitle.replace(/<br>/g, ' ')}</h1><p class="mast-sub">${subjectShort}</p><dl class="mast-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>`;
|
|
382
429
|
/*
|
|
383
430
|
* Сводка занимает ДВА листа намеренно, а не один с переливом.
|
|
@@ -394,14 +441,36 @@ export function toHtml(audit, brand = null) {
|
|
|
394
441
|
* строк, как техническую. Первый лист несёт параметры сайта и первые строки, дальше только
|
|
395
442
|
* продолжение таблицы со своей шапкой.
|
|
396
443
|
*/
|
|
397
|
-
|
|
444
|
+
/*
|
|
445
|
+
* Список прочитанных страниц. На двадцати строках это два листа, на ста это семь листов
|
|
446
|
+
* подряд одной таблицей посреди разбора. Поэтому в основной части печатаются первые строки,
|
|
447
|
+
* а весь список уезжает приложением в конец: покупатель за сто страниц имеет право видеть все
|
|
448
|
+
* сто адресов, но не вместо чтения.
|
|
449
|
+
*/
|
|
450
|
+
const readStat = a.reading && typeof a.reading.read === 'number' ? a.reading : null;
|
|
451
|
+
const readNote = readStat && L.readNote ? `<p class="scorecard-foot">${esc(L.readNote(readStat.read, readStat.wanted))}</p>` : '';
|
|
452
|
+
const allSampleRows = (a.sample || []).filter((x) => x.title !== undefined);
|
|
453
|
+
const APPENDIX_FROM = 20;
|
|
454
|
+
/*
|
|
455
|
+
* В приложении заголовок обрезается: длинный переносится на вторую строку, и лист, рассчитанный
|
|
456
|
+
* по числу строк, переливается. Замерено: на восьмидесяти страницах так переливали три листа.
|
|
457
|
+
* Это справочник прочитанного, ключ в нём адрес, а не заголовок.
|
|
458
|
+
*/
|
|
459
|
+
const appendixRow = (x) => {
|
|
460
|
+
const title = String(x.title || '');
|
|
461
|
+
const short = title.length > 52 ? `${title.slice(0, 50).trimEnd()}\u2026` : title;
|
|
462
|
+
return `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(short)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const asAppendix = allSampleRows.length > APPENDIX_FROM;
|
|
466
|
+
const sampleRows = asAppendix ? allSampleRows.slice(0, 7) : allSampleRows;
|
|
398
467
|
const pagesHead = `<tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H2</th><th>${L.colTables || 'Tables'}</th><th>${L.colLinks || 'Links'}</th><th>Alt</th></tr>`;
|
|
399
468
|
const pageRow = (x) => `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(x.title)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
|
|
400
469
|
const FIRST_PAGE_ROWS = 7;
|
|
401
470
|
const NEXT_PAGE_ROWS = 16;
|
|
402
471
|
const firstRows = sampleRows.slice(0, FIRST_PAGE_ROWS);
|
|
403
472
|
const restRows = sampleRows.slice(FIRST_PAGE_ROWS);
|
|
404
|
-
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
|
|
473
|
+
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>${readNote}<table class="pages">${pagesHead}${firstRows.map(pageRow).join('')}</table>${asAppendix ? `<p class="scorecard-foot">${esc(L.fullListInAppendix || '')}</p>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
405
474
|
for (let i = 0; i < restRows.length; i += NEXT_PAGE_ROWS) {
|
|
406
475
|
const chunk = restRows.slice(i, i + NEXT_PAGE_ROWS);
|
|
407
476
|
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.pagesSampled}</h2><table class="pages">${pagesHead}${chunk.map(pageRow).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
@@ -471,6 +540,95 @@ export function toHtml(audit, brand = null) {
|
|
|
471
540
|
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p>${footer(a, n++, preparedBy)}</div>`);
|
|
472
541
|
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
473
542
|
|
|
543
|
+
/*
|
|
544
|
+
* Вы и ваши конкуренты. Балл видимости стоит первым, потому что он у всех посчитан по десяти
|
|
545
|
+
* страницам и сравним напрямую. Шесть оценок аудита идут ниже с подписью, что свой сайт читался
|
|
546
|
+
* глубже: без этой подписи «2 из 100» рядом с «1 из 10» читается как сравнение, которым не
|
|
547
|
+
* является. Сайт, который не ответил, получает прочерк, а не выдуманный ноль.
|
|
548
|
+
*/
|
|
549
|
+
const rivals = Array.isArray(a.rivals) ? a.rivals.slice(0, 5) : [];
|
|
550
|
+
if (rivals.length) {
|
|
551
|
+
const mine = { host: a.meta.host, score: a.overall?.score ?? null, grade: a.overall?.grade ?? null, areas: a.overall?.areas || [], seo: a.overall?.seo?.score ?? null, reportScores: a.scores || {}, pagesRead: a.reading?.read ?? null };
|
|
552
|
+
const all = [mine, ...rivals];
|
|
553
|
+
const cell = (v, max) => (v === null || v === undefined ? `<span class="na">\u00b7</span>` : max ? `${v}<span>/${max}</span>` : String(v));
|
|
554
|
+
const best = (vals) => { const nums = vals.filter((x) => typeof x === 'number'); return nums.length ? Math.max(...nums) : null; };
|
|
555
|
+
const hostLabel = (h) => esc(String(h || '')).replace(/\.(?=[^.]+$)/, '<wbr>.');
|
|
556
|
+
const headRow = `<tr><th>${esc(L.colSignal || 'Signal')}</th>${all.map((x, i) => `<th class="n${i === 0 ? ' mine' : ''}">${hostLabel(x.host)}</th>`).join('')}</tr>`;
|
|
557
|
+
const scoreVals = all.map((x) => x.score);
|
|
558
|
+
const topScore = best(scoreVals);
|
|
559
|
+
/*
|
|
560
|
+
* Крупный сайт часто пускает робота только на главную. Балл, посчитанный по одной странице,
|
|
561
|
+
* это не то же самое, что балл по десяти, поэтому такой столбец помечается звёздочкой, а
|
|
562
|
+
* строка «сколько страниц дал прочитать» стоит сразу под баллом, где её видно вместе с ним.
|
|
563
|
+
*/
|
|
564
|
+
const THIN = 3;
|
|
565
|
+
const thin = (x) => typeof x.pagesRead === 'number' && x.pagesRead > 0 && x.pagesRead < THIN;
|
|
566
|
+
const anyThin = rivals.some(thin);
|
|
567
|
+
const refused = rivals.filter((x) => x.reachable === false);
|
|
568
|
+
const withheld = rivals.filter((x) => x.reachable !== false && x.score === null);
|
|
569
|
+
const rows = [
|
|
570
|
+
`<tr class="lead-row"><td>${esc(L.aiVisibility || 'AI visibility')}</td>${all.map((x) => `<td class="n${x.score !== null && x.score === topScore ? ' beat' : ''}">${cell(x.score, 100)}${thin(x) ? '*' : ''}</td>`).join('')}</tr>`,
|
|
571
|
+
`<tr class="foot-row"><td>${esc(L.pagesReadLabel || 'Pages read')}</td>${all.map((x) => `<td class="n">${x.pagesRead ?? '\u00b7'}</td>`).join('')}</tr>`,
|
|
572
|
+
`<tr><td>${esc(L.seoBasics || 'SEO basics')}</td>${all.map((x) => `<td class="n">${cell(x.seo, 100)}</td>`).join('')}</tr>`,
|
|
573
|
+
...(mine.areas || []).map((area, k) => {
|
|
574
|
+
const vals = all.map((x) => (x.areas && x.areas[k] ? x.areas[k].score : null));
|
|
575
|
+
const top = best(vals);
|
|
576
|
+
return `<tr><td>${esc(area.label)}</td>${vals.map((v) => `<td class="n${v !== null && v === top ? ' beat' : ''}">${cell(v, area.max)}</td>`).join('')}</tr>`;
|
|
577
|
+
}),
|
|
578
|
+
].join('');
|
|
579
|
+
pages.push(`<div class="page">${header(a, L.secRivals || 'Rivals', L)}<div class="eyebrow">${esc(L.rivalsEyebrow || 'Rivals')}</div><h2>${esc(L.rivalsTitle || 'You and your rivals')}</h2><p class="lead">${esc(L.rivalsLead || '')}</p><table class="rivals-table">${headRow}${rows}</table><p class="scorecard-foot">${refused.length ? esc(L.rivalsFoot || '') : ''}${anyThin ? `${refused.length ? ' ' : ''}${esc(L.rivalsThin || '')}` : ''}${refused.length ? ` ${esc(L.rivalsRefused ? L.rivalsRefused(refused.map((x) => `${x.host}${x.status ? ` (${x.status})` : ''}`)) : '')}` : ''}${withheld.length ? ` ${esc(L.rivalsWithheld ? L.rivalsWithheld(withheld.map((x) => x.host)) : '')}` : ''}</p>${footer(a, n++, preparedBy)}</div>`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/*
|
|
583
|
+
* «Что вставить»: готовые файлы прямо в отчёте. Покупатель за 149 получает не список
|
|
584
|
+
* пожеланий, а карту для агентов, разметку организации и починенные заголовки, которые можно
|
|
585
|
+
* скопировать отсюда или взять файлами из письма.
|
|
586
|
+
*
|
|
587
|
+
* Пропуски в разметке названы отдельно и намеренно: поле, которого нет в данных, мы не
|
|
588
|
+
* придумываем, а говорим, что туда вписать.
|
|
589
|
+
*/
|
|
590
|
+
/*
|
|
591
|
+
* Готовые файлы входят только в аудит за 149. На ступенях дешевле они не печатаются: иначе
|
|
592
|
+
* то, чем дорогой товар отличается от дешёвого, уезжает в дешёвый бесплатно.
|
|
593
|
+
*/
|
|
594
|
+
if (a.meta?.tier === 'audit') {
|
|
595
|
+
const hl = a.meta?.lang === 'ru' ? 'ru' : 'en';
|
|
596
|
+
const llms = buildLlmsTxt(a, { lang: hl });
|
|
597
|
+
const org = buildOrganisationSchema(a, { lang: hl });
|
|
598
|
+
const allFixes = fixTitlesAndDescriptions(a, { lang: hl, limit: 40 });
|
|
599
|
+
const metaFixes = allFixes.slice(0, 6);
|
|
600
|
+
const llmsShort = llms.split('\n').slice(0, 16).join('\n');
|
|
601
|
+
const cut = llms.split('\n').length > 16;
|
|
602
|
+
pages.push(`<div class="page handover-page">${header(a, L.secHandover || 'Files', L)}<div class="eyebrow">${esc(L.handoverEyebrow || 'Files')}</div><h2>${esc(L.handoverTitle || 'What to paste')}</h2><p class="lead">${esc(L.handoverLead || '')}</p>
|
|
603
|
+
<h3>${esc(L.llmsHead || 'llms.txt')}</h3><p class="hand-note">${esc(L.llmsNote || '')}</p><pre class="code">${esc(llmsShort)}${cut ? `\n${esc(L.andMoreInFile || '')}` : ''}</pre>
|
|
604
|
+
${footer(a, n++, preparedBy)}</div>`);
|
|
605
|
+
pages.push(`<div class="page handover-page">${header(a, L.secHandover || 'Files', L)}<div class="eyebrow">${esc(L.handoverEyebrow || 'Files')}</div><h2>${esc(L.orgHead || 'Organization markup')}</h2><p class="hand-note">${esc(L.orgNote || '')}</p><pre class="code">${esc(org.text)}</pre>
|
|
606
|
+
${org.missing.length ? `<h3>${esc(L.orgMissingHead || 'Fill in yourself')}</h3><p class="hand-note">${esc(L.orgMissingNote || '')}</p><ul class="hand-missing">${org.missing.map((m) => `<li><code>${esc(m.field)}</code> ${esc(m.what)}</li>`).join('')}</ul>` : ''}
|
|
607
|
+
${footer(a, n++, preparedBy)}</div>`);
|
|
608
|
+
if (metaFixes.length) {
|
|
609
|
+
/*
|
|
610
|
+
* Три строки на лист, а не шесть. Замерено на отрисованном листе: самая высокая строка
|
|
611
|
+
* это 250 пикселей (два блока по четыре строки, каждая длинная часть в две строки), от
|
|
612
|
+
* листа под таблицу остаётся около 790. Длина того, что стоит сейчас, ограничена здесь же,
|
|
613
|
+
* иначе одна страница с описанием в триста знаков ломает расчёт.
|
|
614
|
+
*/
|
|
615
|
+
const METAFIX_ROWS = 3;
|
|
616
|
+
const short = (x) => { const t = String(x || ''); return t.length > 150 ? `${t.slice(0, 148).trimEnd()}\u2026` : t; };
|
|
617
|
+
const rowOf = (f) => `<tr><td><code>${esc(f.path)}</code></td><td>${[
|
|
618
|
+
f.title ? `<b>title</b><br><span class="was">${esc(short(f.title.now) || '—')}</span><br><span class="why">${esc(f.title.problem)}</span><br><span class="now">${esc(f.title.suggestion)}</span>` : '',
|
|
619
|
+
f.description ? `<b>description</b><br><span class="was">${esc(short(f.description.now) || '—')}</span><br><span class="why">${esc(f.description.problem)}</span><br><span class="now">${esc(f.description.suggestion)}</span>` : '',
|
|
620
|
+
].filter(Boolean).join('<br><br>')}</td></tr>`;
|
|
621
|
+
const parts = Math.ceil(metaFixes.length / METAFIX_ROWS);
|
|
622
|
+
for (let i = 0; i < metaFixes.length; i += METAFIX_ROWS) {
|
|
623
|
+
const chunk = metaFixes.slice(i, i + METAFIX_ROWS);
|
|
624
|
+
const part = Math.floor(i / METAFIX_ROWS) + 1;
|
|
625
|
+
const title = parts > 1 ? `${L.metaHead || 'Titles and descriptions'} (${part}/${parts})` : (L.metaHead || 'Titles and descriptions');
|
|
626
|
+
const last = part === parts;
|
|
627
|
+
pages.push(`<div class="page handover-page">${header(a, L.secHandover || 'Files', L)}<div class="eyebrow">${esc(L.handoverEyebrow || 'Files')}</div><h2>${esc(title)}</h2><p class="hand-note">${esc(L.metaNote || '')}</p><table class="meta-fixes"><tr><th>${esc(L.colUrl)}</th><th>${esc(L.colFinding)}</th></tr>${chunk.map(rowOf).join('')}</table>${last && allFixes.length > metaFixes.length ? `<p class="scorecard-foot">${esc(L.metaMore ? L.metaMore(allFixes.length - metaFixes.length) : '')}</p>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
474
632
|
const phases = (a.roadmap || []).map((x) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(x.badge)}</span><span class="phase-title">${t(x.title)}</span></div>${olist(x.items)}</div>`).join('');
|
|
475
633
|
/*
|
|
476
634
|
* Последний лист несёт и дорожную карту, и источники с границами замера. Оба раздела короткие,
|
|
@@ -480,6 +638,23 @@ export function toHtml(audit, brand = null) {
|
|
|
480
638
|
pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div><div class="eyebrow" style="margin-top:7mm">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${sources.length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</th></tr>${sources.map((x) => `<tr><td>${t(x.name)}</td><td>${t(x.access)}</td><td>${t(x.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}<p class="tool-line">${esc(brand && brand.showToolLine === false ? L.reproducible : L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</p>${footer(a, n++, preparedBy)}</div>`);
|
|
481
639
|
// Карта спроса идёт приложением после дорожной карты: она не входит в баллы, и об этом
|
|
482
640
|
// сказано в ней самой и в таблице источников выше.
|
|
641
|
+
/* Приложение со всеми прочитанными страницами: только когда их больше двадцати. */
|
|
642
|
+
if (asAppendix) {
|
|
643
|
+
/*
|
|
644
|
+
* Четырнадцать строк, а не на глаз. Замерено на отрисованном листе: строка таблицы это
|
|
645
|
+
* 54 пикселя, от верха листа до таблицы уходит 189, снизу отступ и подвал ещё 106.
|
|
646
|
+
* Остаётся около 790 пикселей, это четырнадцать строк вместе с шапкой таблицы.
|
|
647
|
+
*/
|
|
648
|
+
const APPENDIX_ROWS = 14;
|
|
649
|
+
for (let i = 0; i < allSampleRows.length; i += APPENDIX_ROWS) {
|
|
650
|
+
const chunk = allSampleRows.slice(i, i + APPENDIX_ROWS);
|
|
651
|
+
const part = Math.floor(i / APPENDIX_ROWS) + 1;
|
|
652
|
+
const parts = Math.ceil(allSampleRows.length / APPENDIX_ROWS);
|
|
653
|
+
const title = parts > 1 ? `${L.appendixPages || L.pagesSampled} (${part}/${parts})` : (L.appendixPages || L.pagesSampled);
|
|
654
|
+
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${esc(L.appendixEyebrow || 'Appendix')}</div><h2>${esc(title)}</h2><table class="pages appendix">${pagesHead}${chunk.map(appendixRow).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
483
658
|
if (a.demand && Array.isArray(a.demand.queries)) {
|
|
484
659
|
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>`);
|
|
485
660
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Готовые файлы: проверяем не «собралось ли», а что ничего не выдумано.
|
|
3
|
+
*
|
|
4
|
+
* Главный сторож здесь последний: поле, которого нет в данных, обязано остаться пропуском со
|
|
5
|
+
* словами «впишите», а не догадкой. Придуманный адрес в разметке это ложь, которую потом
|
|
6
|
+
* прочитают и поисковик, и ИИ.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
|
|
11
|
+
|
|
12
|
+
const ok = (name, cond) => { assert.ok(cond, name); console.log('ok ', name); };
|
|
13
|
+
|
|
14
|
+
const audit = {
|
|
15
|
+
meta: { host: 'shop.ru', site: 'https://shop.ru/', lang: 'ru' },
|
|
16
|
+
entity: { profiles: ['vk.com/shop', 't.me/shop'], telephone: true, email: false },
|
|
17
|
+
sample: [
|
|
18
|
+
{ url: 'https://shop.ru/', title: 'Станки для деревообработки с гарантией и сервисом · ШопРу', description: 'Продаём и обслуживаем станки для деревообработки по всей России: сто двадцать моделей в наличии, доставка за три дня.', h1: 'Станки для дерева', firstPara: 'Мы продаём станки с 2011 года.', words: 900 },
|
|
19
|
+
{ url: 'https://shop.ru/catalog/', title: 'Каталог', description: '', h1: 'Каталог станков', firstPara: 'Сто двадцать моделей в наличии на складе в Москве, доставка по России за три дня.', words: 700 },
|
|
20
|
+
{ url: 'https://shop.ru/catalog/cnc/', title: 'Станки с ЧПУ для серийного производства мебели на заказ · ШопРу', description: 'Коротко', h1: 'Станки с ЧПУ для серийного производства мебели', firstPara: 'Станки с числовым управлением для серийного производства мебели.', words: 800 },
|
|
21
|
+
{ url: 'https://shop.ru/catalog/rare/', title: 'Очень длинный заголовок страницы, который точно не помещается в выдачу поисковика и будет обрезан', description: 'Сто двадцать моделей в наличии на складе в Москве, доставка по России за три дня и пусконаладка в цене.', h1: 'Станки', words: 800 },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Карта для агентов
|
|
26
|
+
{
|
|
27
|
+
const txt = buildLlmsTxt(audit, { lang: 'ru' });
|
|
28
|
+
ok('карта начинается с названия компании, а не с фразы о странице', txt.startsWith('# ШопРу\n'));
|
|
29
|
+
ok('в карте есть абзац о сайте', /^> Продаём и обслуживаем/m.test(txt));
|
|
30
|
+
ok('страницы сгруппированы в разделы', /^## catalog$/m.test(txt) && /^## Начните отсюда$/m.test(txt));
|
|
31
|
+
ok('у ссылки есть пояснение', /- \[Каталог\]\(https:\/\/shop\.ru\/catalog\/\): Сто двадцать моделей/.test(txt));
|
|
32
|
+
ok('все страницы попали в карту', (txt.match(/^- \[/gm) || []).length === 4);
|
|
33
|
+
ok('файл кончается переводом строки', txt.endsWith('\n'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Разметка организации
|
|
37
|
+
{
|
|
38
|
+
const { json, missing, text } = buildOrganisationSchema(audit, { lang: 'ru' });
|
|
39
|
+
assert.equal(json['@type'], 'Organization');
|
|
40
|
+
assert.equal(json.name, 'ШопРу');
|
|
41
|
+
assert.equal(json.url, 'https://shop.ru/');
|
|
42
|
+
ok('профили сайта попали в sameAs', json.sameAs.length === 2 && json.sameAs.every((u) => u.startsWith('https://')));
|
|
43
|
+
ok('разметка это валидный JSON', JSON.parse(text)['@type'] === 'Organization');
|
|
44
|
+
// Вот главное: чего нет в данных, того нет и в разметке.
|
|
45
|
+
ok('адреса в разметке нет, он в списке пропусков', !('address' in json) && missing.some((m) => m.field === 'address'));
|
|
46
|
+
ok('почты нет, она в списке пропусков', !('email' in json) && missing.some((m) => m.field === 'email'));
|
|
47
|
+
ok('телефон найден на сайте, значит в пропуски не попал', !missing.some((m) => m.field === 'telephone'));
|
|
48
|
+
ok('ни одно поле не выдумано', !JSON.stringify(json).includes('ВПИСАТЬ'));
|
|
49
|
+
ok('название взято из заголовка, значит проверять его не просим', !missing.some((m) => m.field === 'name'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Название компании стоит то в конце заголовка, то в начале: берём короткую часть
|
|
53
|
+
{
|
|
54
|
+
const name = (title, host = 'x.ru') => buildOrganisationSchema({ meta: { host }, sample: [{ url: `https://${host}/`, title }] }).json.name;
|
|
55
|
+
ok('хвост из одного слова это бренд', name('Станки для деревообработки · ШопРу') === 'ШопРу');
|
|
56
|
+
ok('начало из двух слов тоже бренд', name('MORE Group: агентство недвижимости на Пхукете') === 'MORE Group');
|
|
57
|
+
ok('через двоеточие и точку разделяется тоже', name('Проверка сайта: ваш сайт глазами ИИ · OperStack') === 'OperStack');
|
|
58
|
+
ok('служебное слово в начале бренд не перебивает', name('Главная | Акме Корп Лтд') === 'Акме Корп Лтд');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Названия компании в заголовке главной нет: домен подставлен, но помечен на проверку
|
|
62
|
+
{
|
|
63
|
+
const noBrand = { meta: { host: 'shop.ru', site: 'https://shop.ru/' }, sample: [{ url: 'https://shop.ru/', title: 'Купить станки для деревообработки недорого', h1: 'Станки' }] };
|
|
64
|
+
const { json, missing } = buildOrganisationSchema(noBrand, { lang: 'ru' });
|
|
65
|
+
ok('фраза со страницы не выдана за название компании', json.name === 'shop.ru');
|
|
66
|
+
ok('и покупателя просят проверить название', missing.some((m) => m.field === 'name' && m.what.includes('shop.ru')));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Заголовки и описания
|
|
70
|
+
{
|
|
71
|
+
const fixes = fixTitlesAndDescriptions(audit, { lang: 'ru' });
|
|
72
|
+
const byPath = Object.fromEntries(fixes.map((f) => [f.path, f]));
|
|
73
|
+
ok('страница с длинным заголовком найдена', byPath['/catalog/cnc/'].title.problem.includes('обрежется'));
|
|
74
|
+
ok('предложенный заголовок укладывается в норму аудита', byPath['/catalog/cnc/'].title.suggestion.length >= 40 && byPath['/catalog/cnc/'].title.suggestion.length <= 60);
|
|
75
|
+
ok('заголовок собран из H1 страницы', byPath['/catalog/cnc/'].title.suggestion.startsWith('Станки с ЧПУ'));
|
|
76
|
+
ok('заголовок с многоточием не предлагается', !byPath['/catalog/cnc/'].title.suggestion.includes('\u2026'));
|
|
77
|
+
// Коротким H1 длинный заголовок не починить, и тогда правки нет, а не огрызок с многоточием.
|
|
78
|
+
ok('что нечем починить, то не предлагается', !byPath['/catalog/rare/']?.title);
|
|
79
|
+
ok('страница без описания найдена', byPath['/catalog/'].description.problem.includes('нет вовсе'));
|
|
80
|
+
ok('описание собрано из первого абзаца страницы', byPath['/catalog/'].description.suggestion.startsWith('Сто двадцать моделей'));
|
|
81
|
+
ok('рядом всегда сказано, что там сейчас', fixes.every((f) => (!f.title || 'now' in f.title) && (!f.description || 'now' in f.description)));
|
|
82
|
+
ok('главная в порядке и в список не попала', !byPath['/']);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Пустой аудит ничего не ломает и ничего не выдумывает
|
|
86
|
+
{
|
|
87
|
+
const empty = { meta: {}, sample: [] };
|
|
88
|
+
ok('карта пустого сайта не падает', typeof buildLlmsTxt(empty) === 'string');
|
|
89
|
+
ok('и честно просит вписать, чем занимается сайт', buildLlmsTxt(empty).includes('TODO'));
|
|
90
|
+
ok('разметка пустого сайта не падает', buildOrganisationSchema(empty).json['@type'] === 'Organization');
|
|
91
|
+
ok('правок у пустого сайта нет', fixTitlesAndDescriptions(empty).length === 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('\nготовые файлы: всё сходится');
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* Готовые файлы это то, за что платят 149. Сторож ниже следит, чтобы они не уехали в отчёт
|
|
98
|
+
* дешёвой ступени: там их печатать нельзя, иначе дорогой товар нечем отличать от дешёвого.
|
|
99
|
+
*/
|
|
100
|
+
{
|
|
101
|
+
const { toHtml } = await import('./render.mjs');
|
|
102
|
+
const base = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
103
|
+
const cheap = toHtml({ ...base, meta: { ...base.meta, tier: '29' } });
|
|
104
|
+
const paid = toHtml({ ...base, meta: { ...base.meta, tier: 'audit' } });
|
|
105
|
+
ok('в отчёте за 29 готовых файлов нет', (cheap.match(/class="page handover-page"/g) || []).length === 0);
|
|
106
|
+
ok('в аудите за 149 они есть', (paid.match(/class="page handover-page"/g) || []).length >= 2 && /"\@type": "Organization"/.test(paid));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
* Сайт нередко перечисляет в своём sameAs партнёров, застройщиков и просто домены площадок.
|
|
111
|
+
* Вставить это в разметку значит сказать поисковику, что покупатель и Sansiri одна организация.
|
|
112
|
+
*/
|
|
113
|
+
{
|
|
114
|
+
const messy = {
|
|
115
|
+
meta: { host: 'shop.ru', site: 'https://shop.ru/' },
|
|
116
|
+
entity: { profiles: ['https://www.instagram.com/shopru/', 'https://t.me/shopru'], telephone: true, email: true },
|
|
117
|
+
sample: [{ url: 'https://shop.ru/', title: 'Станки · ШопРу', description: 'Продаём станки.' }],
|
|
118
|
+
};
|
|
119
|
+
const { json } = buildOrganisationSchema(messy, { lang: 'ru' });
|
|
120
|
+
ok('профили это адреса страниц, а не домены', json.sameAs.every((u) => new URL(u).pathname.length > 1));
|
|
121
|
+
const bare = { ...messy, entity: { ...messy.entity, profiles: [] } };
|
|
122
|
+
const r = buildOrganisationSchema(bare, { lang: 'ru' });
|
|
123
|
+
ok('профилей нет, значит поля sameAs нет', !('sameAs' in r.json));
|
|
124
|
+
ok('и о пропуске сказано прямо', r.missing.some((m) => m.field === 'sameAs'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* Правка обязана чинить. Заголовок, где поменялся только знак между словами, длину не чинит,
|
|
129
|
+
* а описание, оборванное на многоточии, это не готовое поле, а недоделка.
|
|
130
|
+
*/
|
|
131
|
+
{
|
|
132
|
+
const a = {
|
|
133
|
+
meta: { host: 'shop.ru', site: 'https://shop.ru/' },
|
|
134
|
+
sample: [
|
|
135
|
+
{ url: 'https://shop.ru/', title: 'Станки для деревообработки с гарантией и сервисом · ШопРу', description: 'Продаём и обслуживаем станки для деревообработки по всей России: сто двадцать моделей в наличии, доставка за три дня.' },
|
|
136
|
+
{ url: 'https://shop.ru/privacy-policy/', title: 'Политика | ШопРу', description: '', h1: 'Политика' },
|
|
137
|
+
{ url: 'https://shop.ru/cnc/', title: 'ЧПУ | ШопРу', description: '', h1: 'ЧПУ' },
|
|
138
|
+
{ url: 'https://shop.ru/dostavka/', title: 'Доставка станков по России за три дня со склада в Москве', description: 'Мы возим станки по всей России. Доставка за три дня со склада в Москве, подъём на этаж и пусконаладка входят в цену. Работаем с 2011 года и держим свой парк машин.', h1: 'Доставка', firstPara: 'Мы возим станки по всей России. Доставка за три дня со склада в Москве, подъём на этаж и пусконаладка входят в цену. Работаем с 2011 года и держим свой парк машин.' },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const fixes = fixTitlesAndDescriptions(a, { lang: 'ru' });
|
|
142
|
+
const byPath = Object.fromEntries(fixes.map((f) => [f.path, f]));
|
|
143
|
+
ok('служебная страница в список не попала', !byPath['/privacy-policy/']);
|
|
144
|
+
ok('перестановка знака починкой не считается', !byPath['/cnc/']);
|
|
145
|
+
const d = byPath['/dostavka/'].description;
|
|
146
|
+
ok('описание обрезано по концу предложения', d.suggestion.endsWith('.') && !d.suggestion.includes('…'));
|
|
147
|
+
ok('и укладывается в лимит', d.suggestion.length >= 70 && d.suggestion.length <= 160);
|
|
148
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Фраза про короткий ответ зависит от числа страниц (задание 26.09.2026): «Not one page» только при нуле.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { prospectRow } from './prospect.mjs';
|
|
4
|
+
const audit = (value, status) => ({ meta: { host: 'a.test' }, scores: {}, checks: [{ id: 'answer-first', status, label: 'Answer-first opening paragraph', value }] });
|
|
5
|
+
const warn = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'));
|
|
6
|
+
assert.equal(warn.say, 'Only 5 of your 8 checked pages open with a short direct answer, and that opening is exactly what gets quoted.');
|
|
7
|
+
const bad = prospectRow(audit('0 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'bad'));
|
|
8
|
+
assert.match(bad.say, /^Not one page opens/);
|
|
9
|
+
const ru = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'), { lang: 'ru' });
|
|
10
|
+
assert.equal(ru.say, 'Короткий прямой ответ в первом абзаце есть только на 5 из 8 проверенных страниц, а цитируют именно его.');
|
|
11
|
+
assert.match(prospectRow(audit('', 'bad')).say, /^Not one page opens/, 'без числа прежняя фраза');
|
|
12
|
+
console.log('test-prospect-count: ok');
|